refactor(claude): triage always-loaded user rules into skills, hooks, and ambient - #378
Conversation
… and ambient The 38 rules in exact_dot_claude/rules/ that carry no `paths:` frontmatter load into every session of every project — 114,284c (~28.6k est. tokens) before the first prompt is read. This applies the issue's triage: a rule is push (resident, shaping behaviour unasked), a skill is pull (loads once recognised as relevant), and the sort is by whether the trigger is recognisable *in advance*. Nine rules had one, and became stubs: read-issue-thread-before-contributing 7431 -> 618 git-plugin:git-issue-scoping multi-model-delegation 6607 -> 756 agent-patterns-plugin (already covered) pr-merge-hazards 7607 -> 2163 git-plugin:git-merge-hazards verify-machine-facts-before-publishing 4945 -> 593 documentation-plugin:docs-verify-machine-facts taskwarrior-tracking 4735 -> 620 taskwarrior-plugin:task-add / task-claim documentation-authoring 3093 -> 505 documentation-plugin:docs-single-source scaffold-fix-backport 2960 -> 590 code-quality-plugin:code-scaffold-backport verify-upstream-before-patching 2710 -> 654 git-plugin:git-upstream-fix-check repo-deletion-safety 2576 -> 649 git-plugin:git-repo-delete-check tool-use-patterns (9917 -> 7713) was split rather than moved: the always-on traps stay resident (Read-before-Edit, re-Read after a formatter, `rg -r` is --replace, a rejected flag looks like no results, parallel-batch cancellation, Bash denials are terminal) and only the lookup material left, with pointers in its place. Every body was moved by script and verified byte-identical against the pre-change rule; the only in-body edits are sibling-rule path requalifications. pr-merge-hazards keeps its two gates inline — the `gh pr list --head` authority order and UNSTABLE != BLOCKED are read at the instant of deciding, with no earlier moment to invoke a skill. Rules whose trigger *is* the failure they prevent were deliberately left resident: diagnose-at-the-failure-point, never-fabricate-test-identifiers, communication, decision-defaults, security, and above all skill-and-agent-catalog-check — promoting rules to skills only pays off if the catalog is consulted at task boundaries, so that one is load-bearing for the whole plan. Total: 114,284c -> 76,564c, ~9,430 est. tokens/session, 33% off the surface. test-claude-context-budget.sh: the 2026-08-09 justification comment claimed a cleanup had landed that never did (multi-model-delegation was still 6,607 bytes), so a TOTAL_BUDGET_BYTES bump was argued on bytes that were never freed. Corrected, kept as a lesson, remeasured from disk, and ratcheted 120,000 -> 86,000 — 6.4% headroom, deliberately not razor-thin so the gate keeps its signal. Closes #353
|
…un (#381) Closes #380. Picks up @claude's patch from [the issue thread](#380 (comment)) — it did the research and chose option B, but its push was rejected because the GitHub App lacks `workflows` permission on `.github/workflows/`. This session has it, so I'm landing the work. **One substantive change to that patch** — see below; it had a hole that would have made the smoke test green while testing nothing. ## The fix `timeout` is GNU coreutils. macOS runners ship neither `timeout` nor `gtimeout` without `brew install coreutils`, so `Build (macOS)` died at `command not found` before sourcing anything, and has never passed since #374 added it. Replaced with `perl -e 'alarm shift; exec @argv; …' 60 …` in **both** jobs, not just macOS: perl is preinstalled on both runner images, the `alarm(2)` timer survives the `exec`, and using one mechanism on both platforms means the hang-guard can't drift per-platform. That drops the coreutils assumption rather than papering over it on one side. ## The correction to the proposed patch The bot's form was `perl -e 'alarm shift; exec @argv' 60 zsh -c '…'`. perl's `exec()` **returns only on failure**, so with no tail after it the one-liner falls off the end and exits **0** when the target binary is missing: ```console $ timeout 60s definitely-not-a-real-binary; echo $? timeout: failed to run command 'definitely-not-a-real-binary': No such file or directory 127 $ perl -e 'alarm shift; exec @argv' 60 definitely-not-a-real-binary; echo $? 0 # <- silently green ``` That is not hypothetical here: the Ubuntu job invokes bare `zsh`, installed by an earlier setup step. Had that install ever broken, this smoke test would have gone green having sourced nothing — the failure mode `.claude/rules` calls a lying test. `timeout` reported 127; the added `warn` + `exit 127` reproduces it. ## Verification Ran the final form directly — this container has no zsh and no macOS runner, so these exercise the wrapper's semantics, which is what changed: | Case | Result | Want | |---|---|---| | success | `0` | 0 | | `exit 1` | `1` | 1 — real failures must still fail | | `exit 3` | `3` | exit code passed through, not flattened | | hang (`sleep 60`, alarm 2) | `142` in 2s | non-zero, at the deadline — not the 6-hour job limit | | blocked on stdin (`cat`) | `142` in 2s | the actual hazard the guard exists for | | missing binary | `127` | 127 — **was `0`** before the correction | YAML parses and both `run:` blocks render as intended. `actionlint` isn't installed in this container, so CI is the first real lint pass — the `Linters` job covers it. The issue's acceptance criterion — *"a deliberately hanging `~/.zshrc` still fails the job rather than running to the 6-hour limit"* — is the hang and stdin rows above. ## Also Inline scripts move from double- to single-quoted `zsh -c '…'`, dropping the `\"`/`\$` escaping so zsh expands `$ZSH_VERSION` itself. Same output, less quoting to get wrong. Once this merges, #378 gets its base merged in so its `Build (macOS)` re-runs against a green base — that's its last red check and the only thing standing between it and mergeable. --- _Generated by [Claude Code](https://claude.ai/code/session_01FQV3do6dGoc7HABQm6v5ZQ)_ Co-authored-by: Claude <noreply@anthropic.com>
… a skill (#384) ## What Three new rules, three extended, and the next step of the skill triage started in #378 — including the one that keeps the context-budget gate green. ## Why **New rules** - **`gui-companion-cli-not-headless`** — a desktop app that also ships a `cli` binary invites the assumption that the CLI is standalone. Often it is a remote control for the running GUI process, so a LaunchAgent or cron job built on it works whenever a human happens to have the app open and fails silently the rest of the time. `--help` documents the commands, never the requirement. - **`verify-license-position-before-declaring-blocked`** — a `LICENSE` reads authoritative, but for source-available models the vendor's actual position routinely lives in a licence FAQ and the discussion tab of the same repo. Also: HuggingFace's `isOwner` reads `false` for every human commenter including staff, so check commit authorship instead. - **`machine-local-notes`** — a `CLAUDE.local.md` is git-ignored, so nothing reviews it while it loads on every turn. Environment facts stay; findings belong in the versioned docs. **Extended rules** - **`git-hazards`** — the rebase variant of the clean-merge-broken-result trap. A branch that removes state which `main`'s new commits depend on replays with *zero conflicts* (different files, nothing to overlap on) and still produces a tree that cannot run. A rebase that replays cleanly feels verified, which is why this one is easy to miss. - **`never-fabricate-test-identifiers`** — don't fabricate the *subject* either. A retyped copy of the code under test is not that code; extract the shipped text and feed it inputs. And when your own target set is empty, borrow a control set, because an empty run and a broken harness print the same thing. - **`shell-pipefail-grep-q`** — additional cases. ## How **The budget gate is why this PR is shaped this way.** `tool-use-patterns.md` had reached 18456 bytes against the 10000-byte per-file cap, and pushed total always-loaded context to 95525 against an 86000 budget — `tests/test-claude-context-budget.sh` refused the commit outright. `main` sits at 80463, so the new findings were a ~15 KB overshoot into a file #378 had just dieted. Rather than raise the cap or drop the findings, this follows the gate's own remedy and the pattern #378 established: promote the bulky sections into a skill and leave a stub. ``` tool-use-patterns.md 18456 -> 5317 bytes always-loaded total 95525 -> 82386 (budget 86000) per-file cap all rules under 10000 STATUS=PASS ``` The seven promoted sections go to `agent-patterns-plugin:tool-result-traps` (laurigates/claude-plugins#2472), which needs to land first for the stub's reference to resolve. They share one law — an empty result, a green exit, and a well-formed line of output are each claims about mechanics, not content — so they make one skill rather than several. The rule keeps its Read / Edit-Write / WebFetch / Bash-denial sections, plus **one inline line**: control-test any negative that gates an action. That one stays because it applies to every promoted trap and is read *while* the decision is being made, so there is no earlier moment at which to invoke a skill — the same carve-out `pr-merge-hazards.md` already uses for its two gates. Also triaged, continuing #378: `ci-cd-multirepo` and `tool-migration-cutover` reduced to stubs. **Content preservation** was checked mechanically rather than by eye — all four extracted blocks (14441 bytes) present verbatim in the skill, none left behind in the rule, and the original 397 lines tiled with no gaps so nothing could be dropped *between* blocks. The check asserts a non-empty input set first, so it cannot pass vacuously. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #353. Paired with laurigates/claude-plugins#2450, which carries the skills and hooks these stubs point at — merge that one first, or the stubs point at nothing.
Result
114,284c → 76,564c across the 38 always-loaded rules — ~9,430 est. tokens/session, 33% off the surface, in every project, on top of the ~8.2k already landed for the portfolio rules.
The issue projected ~10,400. The gap is accounted for:
prefer-diy-over-heavy-dependency(7,079c in the issue's table) was already promoted before this work started, so its ~1,600 tokens were already banked. Several other rules had grown since the issue was measured on 2026-08-05 —read-issue-thread-before-contributing3,916 → 7,431,taskwarrior-tracking2,978 → 4,735 — so the per-rule figures below are measured now, not copied from the issue.Bucket 2 — nine rules with a trigger recognisable in advance
read-issue-thread-before-contributinggit-plugin:git-issue-scopingpr-merge-hazardsgit-plugin:git-merge-hazardsmulti-model-delegationagent-patterns-plugin:multi-model-delegation(already existed)verify-machine-facts-before-publishingdocumentation-plugin:docs-verify-machine-factstaskwarrior-trackingtaskwarrior-plugin:task-add/task-claimdocumentation-authoringdocumentation-plugin:docs-single-sourcescaffold-fix-backportcode-quality-plugin:code-scaffold-backportverify-upstream-before-patchinggit-plugin:git-upstream-fix-checkrepo-deletion-safetygit-plugin:git-repo-delete-check+ a hookmulti-model-delegationwas a free win the issue didn't have: it listed the rule as already-stubbed, but on disk it was still 6,607c of full body while anagent-patterns-pluginskill of the same name had covered 100% of it since 2026-07-17 — plus four sections the rule lacked. The dotfiles copy was the stale side. Verified section-by-section before deleting rather than taken on faith.pr-merge-hazardskeeps its two load-bearing gates inline (thegh pr list --headauthority order for squash-merge detection, andUNSTABLE≠BLOCKED), following theworktree-stale-base-mergeexception. Both are read at the instant of deciding whether to delete a branch or merge over red — there is no earlier moment at which to invoke a skill, and getting it wrong deletes unmerged work. Those copies are byte-identical to the skill's, so drift is a literal string diff.Bucket 4 —
tool-use-patternssplit, not moved9,917 → 7,713. Ambient traps stay resident; only lookup material left, with pointers in its place.
rg -ris--replace, not a bundled short flagSmaller than the issue's ~1,300-token estimate (~555 gross, ~485 net of the new skill's listing cost) because the file had shrunk since it was measured. Still worth doing — no always-on trap was made lazy.
Bucket 1 — two hooks
Both in laurigates/claude-plugins#2450:
repo-deletion-safety.sh(block, 57 assertions) andbranch-base-guard.sh(nudge, 59 assertions). Therepo-deletion-safetystub here names the hook, its self-extinguishing property, and its opt-out, so the rule reads as skill + enforcement rather than skill alone.Bucket 3 — deliberately left resident
Rules whose trigger is the failure they prevent:
diagnose-at-the-failure-point,never-fabricate-test-identifiers(by the time you'd think to load the skill you have already fabricated the identifier), pluscommunication,decision-defaults,security,plan-mode,agent-and-tool-selection. Andskill-and-agent-catalog-checkabove all — promoting rules to skills only pays off if the catalog is actually consulted at task boundaries, which makes it load-bearing for this entire plan.Every move is verifiable
Bodies were moved by script, never retyped, then diffed against a pre-change snapshot of all 49 rules. The only edits inside a moved body are sibling-rule path requalifications (
git-hazards.md→~/.claude/rules/git-hazards.md), since the text no longer sits beside those rules. That keeps "the skill cannot have drifted from the rule it replaced" a checkable claim rather than a hope.A false premise found in the budget test
tests/test-claude-context-budget.shcarried a 2026-08-09 comment asserting that a cleanup had landed — "multi-model-delegation went 6,607 → 493 bytes … freeing 6.1 kB". It never did; the rule was still 6,607 bytes when this work started, so aTOTAL_BUDGET_BYTESbump to 120,000 was argued on bytes that were never freed.Corrected and kept as a lesson rather than quietly deleted (a budget bump must cite measured bytes on disk, never a cleanup believed to have landed). Remeasured from disk and ratcheted 120,000 → 86,000 — 6.4% headroom, deliberately not razor-thin, because the file's own #2324 finding is that a razor margin destroys the gate's signal and forces each new rule to adjudicate someone else's.
Note for the maintainer
These are chezmoi sources. Before applying, run
chezmoi status ~/.claudeand treat everyDline as a stop signal — a path-scopedchezmoi diffdoes not show pending deletions of unmanaged files under anexact_tree. Thenchezmoi apply -v ~/.claude. Nothing here was applied to a live target.Generated by Claude Code