# solana-ai-kit — maintainer instructions
<!-- This file is the config repo's own instructions and never ships.
     CLAUDE-solana.md is what install.sh writes as a project's CLAUDE.md. -->

You maintain the agents, commands, skills, hooks, permissions and install/update scripts that ship into users' Solana projects. A defect here reaches every installed project, and some of it guards mainnet deploys and keypairs.

Two audiences, never conflated: **this file** is maintainer-only; **`CLAUDE-solana.md`** ships as the project `CLAUDE.md`, held to 60 lines because it loads in every user session and subagent. The full spec is in `docs/` (index: `docs/README.md`), which `install.sh` never copies into a project — so `docs/` is where length is affordable, and whatever it explains better belongs there behind a pointer.

Be direct: no filler, code before explanation, say so when unsure. Write for a strong model — add only what it wouldn't know or would get wrong, link `ext/` references instead of pasting patterns, and state rules calmly with the reason, since an unexplained rule gets ignored or cargo-culted.

You hold the thread, not the user — they instruct, leave, and come back, so anything needing their input has to still be visible when they return. Close your final reply of a turn with up to three short blocks, in this order: **Done** (what landed, with links), **Warnings** (what broke, degraded or needs watching), **Open** (one line per item naming what it waits on — questions awaiting their answer, decisions only they can make, instructions received but not yet executed, work that is blocked). **Number the items in every block**, restarting at 1 in each, so either of you can cite one as "Open 3" rather than quoting it back. Link every PR, issue, commit and file you mention: a full URL, or a file path with its line. Restate Open in full each turn, because a point raised once and then dropped is a point lost; an item leaves it when they resolve it or say to drop it, never because the conversation moved on. Omit any of the three that would be empty rather than writing "none", and leave them off progress notes between tool calls — they belong on the reply the user comes back to.

Ask first only when their answer would change what gets built. Re-confirming something they already instructed spends the time they left to save, and guessing where the answer matters produces work to throw away — opposite failures with one test between them. Keep to the scope you were given rather than an adjacent thing that seemed to follow, and delete nothing outside the files and actions they named — orphaned code a refactor leaves behind is yours to clean up; a second registry entry next to the one they pointed at is not.

Subagent output is evidence to verify and synthesise, not a result to forward. Check a load-bearing claim against the source yourself before repeating it; a subagent report can be confidently wrong about what the user actually said, and relaying that unchecked turns it into your own false claim.

Commits and PR bodies carry no Claude attribution and no co-author trailers. Author with an email registered on the author's GitHub account — the one in session context often is not, and GitHub links such commits to no profile. Set it per commit with leading assignments (`GIT_AUTHOR_NAME=... GIT_AUTHOR_EMAIL=... GIT_COMMITTER_NAME=... GIT_COMMITTER_EMAIL=... git commit ...`), not `git -c`: every tier denies `Bash(git -c *)` as a wrapper that evades the `git <subcommand>` rules, while leading `VAR=value` assignments are deliberately left matchable.

## Token Loading Model
<!-- CLAUDE.md arrives as a user message, not a system prompt, so shorter buys adherence
     as well as tokens. Claude Code strips HTML comments like this one from instruction
     files before the model sees them, which is why maintainer notes live in comments;
     install.sh --agents strips them itself, since Codex and opencode do not. -->

| File | When loaded | Budget guidance |
|------|-------------|-----------------|
| `CLAUDE.md` (this file) | Session start and every subagent | Under 200 lines; costs every turn |
| `CLAUDE-solana.md` | Session start and every subagent, in user projects | Under 60 lines |
| The decision record's `in-force.md` (`$(git rev-parse --git-common-dir)/decisions/`) | Only in a repo that keeps a record: every SessionStart and SubagentStart (`decisions-inject.sh`, reaching Explore and Plan too, which load no `CLAUDE.md`), then once per change per agent at its next tool call (`decisions-push.sh`) | `decisions.sh` holds it near 1.5K tokens, cutting long fields and warning past 6,000 and 9,000 bytes. The hooks cut it at a line under 9,000 bytes, since `additionalContext` past 10,000 characters collapses to a file path and a preview |
| `.claude/rules/*.md` | With `paths:`, when a matching file is read; without it, every session and subagent | The kit ships none; `validate.sh` fails on an unscoped rule |
| Agent `description` | Every session, in the Agent tool list | Routing only, ≤250 chars (`validate.sh`) |
| Command `description` | Every session, in the skill listing, unless `disable-model-invocation: true` | One line, ≤100 chars (`validate.sh`); user-only side-effect commands set that flag |
| `.claude/skills/<name>/SKILL.md` `description` | Every session and every subagent | Routing only, ≤1024 chars (`tests/test_local_skills.sh`); detail goes in `references/`. Two ship in-repo (token-extensions, skill-packs); a core *upstream* pack installs more beside them, each description joining this listing. This listing is the only place the kit can make the model *notice* something unprompted, which is why pack routing lives here and not in a hook |
| `.claude/skills/ext/**` | Only when a file is read, while the packs cooperate | Nothing under `ext/` is listed *because* the packs keep their skills at `<pack>/skills/<name>/` — not because of `ext/`: Claude Code discovers a directory named `.claude/skills` at **any** depth, and `expo` ships one. So `.claude/bin/_pack_strip.sh` deletes a pack's own `.claude/` from the copy that lands in a project, and `tests/test_pack_load_surfaces.sh` records which packs ship one so a new one is reviewed rather than listed in every session |
| `.claude/skills/SKILL.md` (24 KB hub), `.claude/skills/*.md` | When read; not auto-listed, as the hub is not inside a `<name>/` directory | Routing table; detail belongs in the packs it routes to. A comment here is **not** stripped the way an instruction file's is, so keep maintainer notes out of it — a constraint on editing, not a cost today (it carries none) |
| Agent, command and skill bodies | On spawn, invocation or read | Terse; the non-obvious commands verbatim |
| `MEMORY.md` + `memory/` | Session start | Claude Code's own auto-memory under `~/.claude/projects/<slug>/`, created organically — the kit ships no template and install/update never overwrite one. `/dream` holds it to 200 lines / 25 KB of index pointers (a kit convention, not a platform cap) |

## Common Mistakes

- **`.claude/bin/update.sh` lines 1-93 are frozen byte-identical to tag `v2.1.0`.** Bash reads a running script by byte offset, and the copy loop at the end of that region overwrites `update.sh` itself mid-run, so an older copy resumes in the new code at line 94. Any edit above 94 breaks the next `/update` for every existing install. These are the only line numbers worth citing in this file, precisely because they cannot shift; cite everything else by name.
- **`.claude/settings.json` is effectively write-once** (issue #91, open). `install.sh` copies it only when absent, and the only in-place edits `/update` makes are the retired-defaults strip, the context-mode PreToolUse matcher patch, the `RULE_SET_VERSION`-gated tier re-apply, and the `HOOK_SET_VERSION`-gated registration of a kit hook the project lacks (Ripple Map, guard row). New `permissions`, `extraKnownMarketplaces` or `enabledPlugins` reach fresh installs only — so never ship a security feature whose sole enforcement is a `settings.json` entry; it produces confident false security. Label such a change fresh-install-only in the changelog and give `/doctor` a row that tells "off because chosen" from "config present, gate not wired". Plugin installs are stricter still: `plugin.json` cannot carry `permissions` or `sandbox` at all, so hooks are the only enforcement there. What the kit does ship in that file is security policy (sandbox, permissions, hooks), attribution and the `stbr` marketplace; session behavior is the user's — `validate.sh` rejects the retired keys, and retiring one needs an `update.sh` migration entry.
- **Permission lists merge across settings files and `deny` wins from any scope.** `allow` cannot carve an exception out of a broad `deny`, and no project or local file can loosen one. That is why a firewall tier is regenerated wholesale rather than layered.
- Leaving a stale count. `tests/test_cross_references.sh` reads *every* copy of the agent, command and MCP server counts across the six files the Ripple Map's first row names, so a second stale copy now fails CI. Nothing else is covered — any other count, and those three anywhere else, still need a grep before committing.
- Deleting an HTML comment under `.claude/` while trimming context. Nine of them are MIT/Apache attribution for adapted upstream material — a licence obligation, not waste.

## Ripple Map

When X changes, also update Y. A row that is wrong is worse than absent, because it is trusted — correct it when you find it.

| Changed | Also update |
|---------|-------------|
| Add/remove an **agent**, **command** or **MCP server** | The count sits in nine places: README.md's intro line, "What This Is" bullet and documentation table; `docs/README.md`'s index; `docs/agents-and-commands.md`'s intro; `docs/plugin.md`'s "ships the core kit" sentence (prose, not a tree); QUICK-START.md's section heading and tree; `docs/repo-structure.md`'s tree. Then the reference table (`docs/agents-and-commands.md`, or `docs/configuration.md` for MCP) and the hardcoded numbers in `tests/test_agents.sh`, `test_commands.sh`, `test_cross_references.sh` — and, for a command, four more that count the installed `commands/` directory: `test_install.sh`, `test_install_idempotent.sh`, `test_install_existing_claude.sh`, `test_update.sh`. Six tests pin a command count; `test_agents.sh` holds only the agent one. An MCP server also needs `.mcp.json`, CLAUDE-solana.md's and QUICK-START.md's MCP lists, `.env.example`, `/setup-mcp` and `tests/test_mcp_config.sh`'s server list. An agent also joins `decisions-report-check.sh`'s agent list and the anchored `SubagentStop` matcher in both hook files, which hold the kit's agents to the report contract; `tests/test_hooks.sh` fails until all three name what `.claude/agents/` holds |
| Add/remove an **.env.example key** | `.claude/commands/setup-mcp.md` (`tests/test_commands.sh` checks it names every key), and `.env.schema` at the same position with a description, `@sensitive` or `@sensitive=false`, and `@optional` (`tests/test_env_keys.sh` checks keys, order and decorators). `install.sh` and `/update` append a new key's block to an existing `.env.schema`, so an existing install gets it too. Keep one `KEY=` per blank-line-separated block (`tests/test_env_keys.sh` checks): the merge copies a block as a unit, keyed on its `KEY=` |
| Change an agent/command **`model:`** | `docs/agents-and-commands.md`'s Model column and routing note. `tests/test_model_routing.sh` requires `opus` on every agent (#106) and allows `sonnet haiku inherit` for commands and kit skills, forbids any Fable value, requires a `disable-model-invocation: true` command to inherit, and checks drift against that doc's `## Agents` section |
| Add/remove a **local skill** (`.claude/skills/<name>/SKILL.md`) | A route from the hub spelled `(<name>/SKILL.md)` (`tests/test_local_skills.sh` requires that exact spelling), the `.claude/skills/` trees in `docs/repo-structure.md` + QUICK-START.md, the listed-description row in the token loading table above, and the standing cost in `docs/skill-packs.md` — a top-level skill is auto-discovered, so its `name` + `description` is listed in every session and every subagent on every install, with no opt-out. A plugin symlink (`plugin/skills/<name>`) only if the skill works without `ext/` submodules and without `.claude/bin/`; otherwise leave it out of the plugin on purpose |
| Bump a default **MCP server** (`.mcp.json` pins `npx` packages exactly; `validate.sh` rejects `@latest` and ranges) | `npm view <pkg> version`, read the diff or release notes since the pin, then edit `.mcp.json`. Nothing bumps these automatically |
| Add/remove a **submodule pack** | **Scan the repo at the commit you are pinning first** — `docs/skill-packs.md` carries the checklist, a pack that fails it is not pinned, and what the scan found goes in the entry's `safety`. Then `.gitmodules`; a `skill-registry.json` entry with `tier`, `path`, `commit` (a placeholder must exist before `pins --write` will rewrite it), `triggers`, `default_installed`, an `install` object (every entry needs one — only `type: aggregator` may have `install: null`, and must; `tests/test_registry_installability.sh`), and an https `source` if any plugin file links the pack; `docs/skill-packs.md`'s submodules table; `.claude/skills/SKILL.md` routing. For an extension, every line linking into it names `bash .claude/bin/skills.sh add <id>` and the hub's Extensions table gets a row (`tests/test_skill_extensions.sh` enforces both). **QUICK-START.md's tree enumerates every pack id** — only `docs/repo-structure.md` collapses `ext/` to one line. Add the pin with `skills.sh pins --write`, never by hand. A removed pack also loses its row in the `skill-packs` work-to-pack table, and a new one earns a row there when some kind of work should surface it (`tests/test_pack_routing.sh` fails on a name the registry no longer pins) |
| Move a pack between **core and extension** | `tier` + `default_installed` in the registry (they must agree); the hub's intro sentence and Extensions table (core packs are not in it); `docs/skill-packs.md`'s Tier column; QUICK-START.md's core list and tree; `CORE=` in `tests/test_install_packs.sh`. **A pack with a `skills` list** is an upstream pack, not a submodule, and gains two consequences as core: `wanted_upstream()` in `skills.sh` is what makes `select` and `prune` fetch it at all, since `keep` holds only extensions; and it installs top-level, so it is in neither `ext/` nor `extensions.txt` — compare it against `kit-packs.txt`, and keep the `ext/` assertions on the submodule core packs alone (`KIT_CORE` in `tests/test_skill_extensions.sh`, `CORE=` likewise). Its fetch failure warns and the install continues; a submodule core pack's failure stops it. A pack entering core also leaves the `skill-packs` work-to-pack table entirely, since that table exists to surface what is *not* installed (`tests/test_pack_routing.sh` fails on a pack sitting in the wrong column) |
| Change a pack's **pinned commit** | The gitlink and the registry `commit` move together or `validate.sh` fails: `skills.sh pins --write` after the bump. Dependabot's weekly PR gets that push from `.github/workflows/sync-skill-pins.yml`. A bump is a new commit range, so it gets the scan a new pack gets — read the diff, don't just move the sha. A pack's *own* submodules are recorded in `vendored` and deliberately not auto-synced, so a third party moving a pin inside a pack we ship fails CI until someone reads it; `install.sh` and `skills.sh` never fetch them, while `update.sh` clones with `--recurse-submodules` and prunes the `vendored` paths after the copy — either way nothing nested reaches a user project |
| Link an agent, command or bundled skill into **`ext/`** | Plugin installs have no `ext/` and no `.claude/bin/`, so the plugin hub's "When a link into an `ext` pack is missing" section is their next step. Keep that section, and give the pack an https `source` in its registry entry (`tests/test_plugin.sh` enforces both) |
| Re-pin or change **anthropic-skills** (a core pack pinned by `commit` in its registry entry, not a submodule; moved by hand, not Dependabot) | Scan and review the upstream diff of each folder in its `skills` list first; never list docx, pdf, pptx, xlsx or doc-coauthoring (`DENIED_SKILLS` in `skills.sh` refuses them). Changing that list ripples into the entry's own prose — `description`, `triggers`, `tags`, and the skill count and standing-token figure in `safety` — plus `docs/skill-packs.md`'s "Anthropic's skills in any agent" section, the hub's and QUICK-START.md's token figures, and the two core-pack rows above. `tests/test_anthropic_skills.sh` re-checks licences and frontmatter at the pin, reading the allowed set from the registry |
| Re-pin or change **safe-ai-skill** (a core plugin from its own repo, not a submodule) | The plugin id `safe-ai-skill@stbr` is also spelled in `install.sh`'s install step, `/doctor` check 11 and `/update`'s not-installed notice (`tests/test_safe_ai_skill_install.sh` drives the first), and its `core-plugin` entry in `skill-registry.json` records the same commit as the marketplace (`tests/test_skill_extensions.sh` holds the two equal). `.claude-plugin/marketplace.json` entry `sha` (a commit whose `plugins/safe-ai-skill/bin/` has every platform binary), `plugin.json` `dependencies`, `.claude/settings.json` `extraKnownMarketplaces` + `enabledPlugins`, README's "Security firewall: safe-ai-skill" section, `tests/test_plugin.sh` + `test_settings_deep.sh`. Its project policy is separate: `install.sh` writes `.safe-ai-skill/policy.yaml`, `/update` adds it when missing and refreshes a copy still identical to an earlier version, `tests/test_safe_ai_skill_policy.sh` covers it, and the firewall denies `Edit` on it at every tier — so a policy change crosses the row below too. Changing the file means adding the outgoing version's blob id to `SHIPPED_POLICY_BLOBS` in `update.sh` (the test fails otherwise), and an edited copy never receives it, which the changelog must say. Never allow a dotenv name there: an allow glob matches anywhere on the machine, so it opens every project's copy and `~/.env` |
| Change a **firewall tier's rule set** (`.claude/bin/firewall.sh`) | The tier is **regenerated wholesale**, never layered (see Common Mistakes). **Bump `RULE_SET_VERSION`**: `/update` re-applies the declared tier when `enforced.ruleSetVersion` is behind, the only route to a project that already has a `security.json` (the pre-firewall migration exits early there). Then re-apply, from a **clean non-worktree copy**, or `git_common_write()` bakes this machine's absolute `.git` path into the committed `settings.json`. Also update `enforced.ruleIds` in `.claude/security.json`; the tier migration in `update.sh` (below line 93, or existing installs never receive it); `/doctor`'s declared-vs-enforced check; `/firewall`; the firewall assertions in `tests/` and `validate.sh`; README's "Firewall tiers" table; `docs/firewall.md`'s gate table and honesty paragraphs; `docs/other-agents.md`'s cross-harness table. Don't express a tier through `permissions.defaultMode` — session mode is the user's call and nothing in the suite would catch it, since `validate.sh`'s retired-keys check rejects only a bare top-level `defaultMode`, which is not a real setting — nor `disableBypassPermissionsMode`, which limits the *user's* mode choice rather than the agent's reach |
| Add a **PreToolUse guard** (`.claude/hooks/<name>.sh`) | Register it in **two** copies that must stay in step: the `hooks` array in `.claude/settings.json` and `plugin/hooks/hooks.json` — and symlink **every** file it reads into `plugin/hooks/`, because a plugin resolves hooks under `CLAUDE_PLUGIN_ROOT` and a missing `.awk` there makes the guard `exit 0` silently instead of failing. Keep the matcher `Bash|mcp__context-mode__.*` and never anchor it: Codex's shell tool is `shell`/`local_shell`, so `^Bash$` never fires there. **A guard added later now reaches existing installs**: #221 gave `update.sh` a migration that reads the shipped `hooks` block out of the fresh clone and enumerates `kit_scripts` from the copied-in `hooks/` directory, so it registers any kit hook a project is not already running with no code edit here — **bump `HOOK_SET_VERSION`** in `.claude/bin/update.sh`, which is the gate, paired with a fingerprint of that block so a forgotten bump is caught too. What still reaches fresh installs only is a *rule* living in `settings.json` and nothing else (#91): label such a change fresh-install-only in `.claude/CHANGELOG.md` and give `/doctor` a row that tells "predates the guard" from "removed on purpose" (plus its check count and output table). Then `docs/firewall.md`'s gate table; a `tests/test_<name>.sh` in `test_hooks.sh`'s style; the guard tuple in `tests/test_hooks.sh`'s registration check; and `required` in `tests/run_all.sh` if it carries a security property — `tests/test_skip_accounting.sh` stubs exactly that list and now derives it from `run_all.sh`, so adding one needs no edit there, but check it still does. Express tier behaviour in the hook, never in `permissions`: a `deny` rule cannot vary by tier, which makes a hook the only route for anything `permissions.allow` already allows (`cargo install`) — and since a hook decides before the permission layer, that allow entry stays put |
| Add or change a **decision hook** (`.claude/hooks/decisions-*.sh`) | The row above, as far as registration goes: both hook files, a symlink in `plugin/hooks/`, a `HOOK_SET_VERSION` bump (the migration creates an event list a project lacks, so a new event reaches existing installs too). Except `decisions-guard.sh`, they are not guards: each runs through `sh "$H"; exit 0`, never `exec`, so nothing it does can block a tool call or a session; none joins the guard tuple or `required`; only `SubagentStop` carries a matcher, the anchored list of the kit's agents. `tests/test_settings_deep.sh` pins the event set and that the subagent events run decision hooks only, `tests/test_hooks.sh`'s `[decision hooks: registration]` holds the wiring, and `tests/test_decision_hooks.sh` the behaviour, including that the record block `decisions-inject.sh` and `decisions-push.sh` share stays byte-identical in both. `decisions-guard.sh`, which refuses a subagent's write to the record, is a guard in full (the row above), wired through `exec` with `Write|Edit|MultiEdit|NotebookEdit` added to the guards' matcher; `tests/test_decisions_guard.sh`, which `run_all.sh` requires, holds its behaviour and its copies of `json_str` and `common_dir` identical to `decisions-inject.sh`'s. Output stays under the 10,000-character `additionalContext` cap. The report check's five labels are also spelled in CLAUDE-solana.md's subagent line, which is what makes a kit agent pass first time; rename one in both. Then `docs/decisions.md`, and `/doctor` check 12 if the events or scripts change |
| Change the **decision record**'s layout or CLI (`.claude/bin/decisions.sh`) | The two record hooks never call the helper: they find `<common-dir>/decisions/` by reading `.git` themselves (`git rev-parse` per tool call costs more than the rest of the push) and read `in-force.md` plus the first word of `in-force.sha`, so moving the record, or changing either file's shape, moves them too, and `/doctor` check 13 with them. `decisions-guard.sh` finds the record the same way and refuses subagents the helper's write verbs, so a new verb that writes joins `writes()` in `decisions-guard.awk`. Then every command calling the helper (`grep -l decisions.sh .claude/commands/*.md`), `tests/test_decisions.sh` and `docs/decisions.md`. Renaming or moving the script breaks the firewall's `Bash(bash .claude/bin/decisions.sh *)` allow rule, which rule set 10 adds (under an earlier set the helper may prompt), a rule-set change (firewall row). Keep the credential refusal, and keep the readers silent outside a repository and before the first entry: whatever `in-force.md` holds is injected into every agent. `--stdin` exists because a guard can read a credential path named in a literal argument as a file the call opens, and blocks it; `tests/test_decisions.sh` pins that route silent in all four guards |
| Modify **install.sh** | `bash tests/test_install.sh` in a temp dir. It also asserts `docs/` and `QUICK-START.md` stay out of a project: the copy loop takes named `.claude/` subdirectories plus named root files, so kit-only documentation is excluded by construction. `install.sh` takes an optional target path, defaulting to the cwd — which is what the `curl | bash` route uses |
| Move a section between **README.md and `docs/`** | The README is for getting installed and oriented; everything else lives in `docs/` behind a link. Update the `docs/README.md` index, all three README.md link tables (routes, documentation, Firewall tiers), any QUICK-START.md pointer, and the test that reads the moved text — `tests/test_cross_references.sh` targets `docs/agents-and-commands.md` (agent + command names), `docs/skill-packs.md` (submodule rows) and `docs/install.md` (from-a-clone steps), and checks every relative link and anchor in README, QUICK-START and `docs/`; `tests/test_model_routing.sh` reads `docs/agents-and-commands.md`. Retarget an assertion, never drop it. `docs/` is also on `/cleanup`'s removal list |
| Change the **repo URL** | Find them with `grep -rn "solanabr/solana-ai-kit\|solanabr/ai-kit" --exclude-dir=ext --exclude-dir=.git .` and update every one except `.claude/bin/update.sh:16`, which sits in the frozen region and still names the pre-rename `solanabr/solana-ai-kit`. GitHub's rename redirect covers it; editing it breaks self-update for every existing install |
| Bump the **pinned agent CLIs** (`@openai/codex`, `@xai-official/grok` in `.github/workflows/ci.yml`) | Re-check that `codex debug prompt-input` still emits the shape `codex-skill-budget.py` parses — that subcommand is undocumented. `grok inspect --json` is documented, but assert on its JSON keys and keep the count floor, so a reshaped payload fails instead of matching nothing; Grok needs `--trust`, since in an untrusted folder it reports no project instructions and no project skills and still exits 0. Codex splits one shared ~22 KB budget across every registered skill description and truncates each to its share with no warning, so assert a minimum length rather than non-empty; and its shell tool is `shell`/`local_shell`, so a `^Bash$` hook matcher may never fire there. The job gates Codex and Grok only — opencode is still an `--agents` client, just not one CI pins |
| Add a **Claude-Code-only** command (describes the kit repo, `/plugin`, or anything an `--agents` install can't do) | Add it to `AGENTS_SKIP_FILES` in both `install.sh` and `.claude/bin/update.sh`; `tests/test_install_agents_only.sh` asserts the two lists match |
| Modify **CLAUDE-solana.md** | Ships to every user project — re-read the two-audiences note above |
| Bump **`.claude/VERSION`** | Also bump `plugin/.claude-plugin/plugin.json` `version` and `.claude-plugin/marketplace.json` `metadata.version` (both must equal it — `tests/test_plugin.sh` enforces) and the README.md badge (`tests/test_cross_references.sh` enforces). Keep the new version outside `update.sh`'s retired-defaults `case`, or `/update` would strip settings a current user set themselves (`validate.sh` fails on it). The plugin is pinned by `plugin.json` `version` plus the `vX.Y.Z` git tag; don't run `claude plugin tag`, which adds a redundant `{name}--vX.Y.Z` tag |

## Submodule Pitfalls

- Add a pack with `git submodule add <url> .claude/skills/ext/<name>`, then `git add .gitmodules .claude/skills/ext/<name>`. A bare `git add` on the directory stages a gitlink with git's "embedded git repository" warning when the checkout has a `.git`, and the whole tree when it doesn't — neither is a submodule.
- Path renames upstream ripple into every agent and command referencing a skill file. Grep the old path before committing.
- `install.sh` skips submodule init with a warning when the target is not a git repo — intentional.
- Submodule bump PRs are never auto-merged: a person reads the `Submodule review` job summary (hooks, scripts, new hosts, installers, credential names, licence changes) first, because agents read and may run what a pack ships.
- **Don't run safe-ai-skill while working in this repo.** The repo's own `.claude/settings.json` enables it, so set `"safe-ai-skill@stbr": false` under `enabledPlugins` in `.claude/settings.local.json`. Its session-start sweep scans the skills and every `ext/` submodule, and *moves* any with a high-severity heuristic finding into `~/.safe-ai-skill/quarantine/`, out of the checkout. On a fresh `git clone --recursive` with all 45 packs at their pins, it moved 38 of them plus the in-repo `skill-packs` skill, which then shows as deleted and would be committed by `git add -A` (measured 2026-10-22 at safe-ai-skill `2997280e`). Move them back from that directory to restore. User installs hold copies rather than submodules, and one measured install lost none, but the README's caveat stands: a pack whose docs match a heuristic can still be quarantined there. Upstream solanabr/safe-ai-skill#5, #9 and #17 track it.

## Plugin Layout

`.claude-plugin/marketplace.json` is an in-repo marketplace; `plugin/` is the core-plugin subtree. Its `agents/`, `commands/`, `.mcp.json`, `VERSION`, `skills/skill-registry.json` and its one local skill (`token-extensions` — #210 deleted the other three) are **symlinks** into `.claude/` — only `hooks/hooks.json` and the plugin-variant `skills/solana-ai-kit/SKILL.md` are real files, and that hub must carry no `ext/` links, since submodules are absent in plugin installs. Every plugin skill lives at `plugin/skills/<name>/SKILL.md`; a `SKILL.md` placed directly in `plugin/skills/` loads as the only skill and hides the rest. Validate with `claude plugin validate .` and `./plugin`.

## Workflow

All work on a feature branch: `git checkout -b <type>/<scope>-<description>-<DD-MM-YYYY>`.

- **Local install test**: `SOLANA_AI_KIT_LOCAL_SRC=. bash install.sh /tmp/test-project` uses the local repo instead of cloning from GitHub (legacy `SOLANA_CLAUDE_LOCAL_SRC` still works). Also test `bash install.sh --agents /path`, which installs into `.agents/` with `AGENTS.md`, whenever you touch `install.sh`.
- **Editing the guards or probing with `claude`**: the kit's own firewall denies `Edit` on `.claude/hooks/**` and `Bash(claude *)` at every user tier, and no local settings file can lift a deny. Switch this checkout to the maintainer tier: `bash .claude/bin/firewall.sh apply maintainer` from a terminal (it writes `settings.json`, which the sandbox protects), then restart. Before committing, run `git checkout -- .claude/settings.json .claude/security.json`, because `validate.sh` fails on a committed maintainer tier. What it omits and why is in [`docs/firewall.md`](docs/firewall.md#the-maintainer-tier). On a checkout older than the tier, `claude --setting-sources user` is the fallback. It is blunt, because it drops every project setting, the sandbox and the guard hooks included, so keep it to the one session that needs it.
- Before merging: `bash validate.sh && bash tests/run_all.sh` passes; `/diff-review` is clean (no duplicate functionality, no AI slop); every Ripple Map row has been walked; and the install above verifies in Claude Code.

## Release Management

- `.claude/VERSION` holds `<name> <semver>` on one line, so read the version with `awk '{print $NF}'`, never `cat`. Bump **patch** for fixes, **minor** for new agents, skills or commands, **major** for breaking `install.sh` changes, then walk the VERSION row above.
- Prepend a dated `.claude/CHANGELOG.md` entry with categorized changes (Added/Changed/Fixed/Removed).
- Then `bash validate.sh && bash tests/run_all.sh` and tag: `git tag "v$(awk '{print $NF}' .claude/VERSION)"`.

## Project Learnings
<!-- /dream and /diff-review append here, one or two lines per entry, under one of the
     three subsections below. Don't duplicate an existing entry; check before appending. -->

### Recurring Issues

- fnmatch is not Claude Code's permission matcher, and reasoning from it has produced false holes and real ones: a sole trailing ` *` is optional (`Bash(git prune *)` denies a bare `git prune`), while a `*` with spaces on both sides, or a trailing one in a multi-wildcard rule, cannot be empty. Judge rule coverage with `validate.sh`'s `gap_probe`, and a hook mirroring a glob must mirror its bare form.

### Fix Patterns

### Config Conventions

- A default MCP server must work with no key and no extra install; the rest are opt-in in `docs/configuration.md`.
- Model routing intent: every agent pins `model: opus` (#106), because Opus 5.5 is the best value right now across implementation, docs and research as well as deep reasoning. Pinning means a Fable session's subagents run on Opus — the trade accepted; never pin Fable instead. A new agent gets `model: opus` too; `tests/test_model_routing.sh` fails on any other value or a missing line. A command gets `sonnet` only when it is mechanical and runs at session start — a mid-session switch drops the prompt cache. `modelDefaults` is not a Claude Code setting; it is on `validate.sh`'s retired-keys list.

---

**Ships to projects**: `CLAUDE-solana.md` | **Full spec**: `docs/` | **Agents**: `.claude/agents/` | **Skills**: `.claude/skills/` | **Commands**: `.claude/commands/` | **MCP**: `.mcp.json`
