Driving Claude with SOLID and DRY: The CLAUDE.md + Skills System
SOLID and DRY don't feel expensive when you're writing them. […] when you come back six months later to change something upstream, the compound interest of all those small decisions is the difference between an afternoon's diff and a refactor that takes over your week.
— Content Fragments: A 10× Lighter SPA Navigation in One Afternoon
I wrote that paragraph about TypeScript modules. It applies just as well — maybe more — to the instructions I give the agent that helps me write those modules. If your code is SOLID, DRY, and tested but your prompts are vibes-driven improvisation, the bottleneck has just moved one layer up the stack. The compound interest argument doesn't care which layer you cash it in at.
So here's the mechanism. Be happy, my friends: you can apply the same engineering discipline to how you drive Claude that you apply to the code Claude writes for you. This post is the documentation of how I do it in this very repo.
The Problem: Asking for SOLID Every Time Doesn't Scale
Every coding session starts the same way: I want SOLID, I want DRY, I want unit tests, I want the agent to use the existing abstractions instead of reinventing them, I want it to read the build pipeline before touching it, I want it to never edit a generated file. If I retype that into every prompt, two things happen: (a) I forget half the rules half the time, and (b) the agent has to figure out which rules apply to this file from a context window full of generic instructions that mostly don't.
The naive fix is one giant CLAUDE.md at the repo root with every rule, every convention, every pipeline diagram, every gotcha. I tried it. It grew to 800 lines. Ninety percent of those lines were irrelevant to any given task — the agent was paying token cost to know about the mermaid renderer when it was editing a CV markdown file, paying for a11y theme matrix details when it was writing a unit test. The instructions had become as wasteful as the 105 KB HTML pages from the content-fragments post: mostly shell, very little payload. Same diagnosis, one layer up.
The Mechanism: A Thin Index + Sixteen Loaded-on-Demand Skills
The current shape is two-tier:
CLAUDE.mdat the project root — kept tiny. Above the index it carries only the absolute non-negotiables (NO PYTHON,DO NOT STASH CONTENT). Below those, it is a flat list of skill hooks: one line per skill, name + one-sentence description + link to the file. This file is loaded on every invocation, so it must stay short..claude/skills/*.md— sixteen specialised files, each one owning exactly one concern. They are not loaded by default. The agent reads the descriptions inCLAUDE.md, decides which skills match the task at hand, and loads only those. A pure CV edit might load zero skills. A unit test edit loadsvitest-unitand maybefrontend-modules. A mermaid debug session loadsmermaid-pipelineandbuild-pipeline. The token cost is proportional to the task, not to the size of the repo.
It is exactly the same architecture as the SPA navigation refactor in the content-fragments post: the cold load is a small bootstrap, every subsequent operation pulls only the fragment it needs, and the shell never leaves disk. The difference is that here, the "fragment" is a chunk of instructions and the "browser" is the agent.
The Sixteen Skills
Here is the full table of skills currently shipped under .claude/skills/, grouped by concern. Each row shows the skill name, the one-line description from its frontmatter (this is what the agent matches against when deciding to load it), and what it actually brings to a session that triggers it.
Orientation
| Skill | Description (when to load) | What it brings |
|---|---|---|
| repo-structure | Use FIRST when arriving in this repo cold | Top-level layout (src/, scripts/, public/, content/, cv/, test/), npm script entry points, build → push → Vercel deploy path, and a routing table to the other 15 skills. The hub. |
Build & Dev
| Skill | Description (when to load) | What it brings |
|---|---|---|
| build-pipeline | Modifying the static site build, debugging build:static, adding pipeline stages, touching the worker pool / mermaid orchestration |
The three-stage pipeline contract (validate:links → build:state-graph → build-static.ts), the worker pool wiring, the asset rewriting rules |
| link-validation | Adding cross-links between markdown files, debugging broken-link reports, extending the link checker | First-stage validate-md-links.ts contract: which references it walks, which it skips (external, anchors, non-.md), and how it blocks the build |
| toc-build | Adding a section, reordering blog categories, debugging missing TOC entries | build-toc.ts → toc.json shape, frontmatter requirements, section/category ordering rules |
| dev-workflow | Iterating locally, setting up the watch/hot-reload loop, explaining the deploy path | npm run dev / watch wiring, WebSocket hot-reload contract, the no-cloud-CI/CD model (push main → Vercel) |
| cv-build | Modifying the printable CV PDF, updating CV markdown, debugging md-to-pdf | Standalone cv/scripts/build-cv.js pipeline, separate from main SSG, with its own stylesheet and Puppeteer config |
| mermaid-pipeline | Adding diagrams, debugging missing/stale SVG, modifying @no-max-height / @no-max-width directives |
Puppeteer renderer + manifest cache, BlockState machine (`Pending → Rendering → Writing → Done |
State Machines
| Skill | Description (when to load) | What it brings |
|---|---|---|
| fsm-tooling | Adding a new FSM in src/lib/, modifying @FiniteStateMachine decorators, debugging the extractor or elkjs layout |
Three-phase pipeline: infer-fsm-transitions → extract-state-machines → render-state-machine-svg. Decorator metadata schema, render cache, --force override |
| fsm-feature-audit | Adding an FSM without a feature: { id, ac } link, investigating "orphaned machine" warnings |
The principle: every machine must link to a Feature AC; orphans are a requirements gap, not technical debt. Filename heuristic for candidate features |
Tests
| Skill | Description (when to load) | What it brings |
|---|---|---|
| vitest-unit | Writing/running unit tests, debugging coverage gates, modifying vitest.config.js |
Strict thresholds (≥98% on src/lib/), test file glob, the contract that js/ is artefact and only src/*.ts is source |
| playwright-e2e | Writing or debugging Playwright e2e/visual/a11y/perf, switching between dev and static targets, updating snapshots | TEST_TARGET=dev (port 4001) vs static (port 4000), spec layout, snapshot conventions |
| a11y-testing | Verifying accessibility, debugging axe/pa11y, running theme-coverage sweeps | axe CLI for fast feedback + a11y-test-themes.mjs for the four-theme matrix (Dark, Dark+HC, Light, Light+HC) |
| compliance-report | Investigating requirements compliance, debugging feature → test traceability, running test:all strict gate |
The cross-reference between requirements/features/*.ts and @Implements-decorated tests; coverage matrix; the --strict build gate |
Frontend & Content
| Skill | Description (when to load) | What it brings |
|---|---|---|
| frontend-modules | Modifying SPA modules (markdown-renderer, theme-switcher, topbar-search, tour), debugging the static-vs-dev split | The app-shared / app-static / app-dev split, what lives in each entry, the bundle wiring |
| event-topology | Adding/renaming a custom DOM event, debugging FSM emits/listens drift, investigating event topology scan failures |
scan-event-topology.ts, data/event-map.md registry, the drift gate that fails the script |
| content-authoring | Writing or editing markdown under content/, frontmatter (title, section, accent, tooltips, toc-expand), image pipe modifiers, creating a blog series |
Frontmatter schema, image modifier syntax, heading-section wrapping rules, blog-series directory conventions |
Sixteen rows, sixteen separable concerns. The first column is the file you can navigate to right now from this very page; the second column is exactly the string the agent sees when it decides whether to load the skill for the current task; the third column is the payload you pay tokens for when it does.
Anatomy of a Skill File
Here is the shape every file under .claude/skills/ follows. The example is condensed from .claude/skills/link-validation.md:
---
name: link-validation
description: Use when adding cross-links between markdown files,
debugging broken-link reports from `validate:links`,
or extending the link checker to cover new file roots.
---
# Link Validation
## Purpose
First stage of `build:static`. Walks every `.md` file under
`content/` and `cv/content/`, parses it via `marked.lexer()`,
and verifies that every internal `.md` link and every image
reference resolves to a real file on disk. Exit `1` on any
broken link, blocking the build.
## Key files
- [scripts/validate-md-links.ts](../../scripts/validate-md-links.ts)
## Conventions
- Skipped: external URLs (`http`, `https`, `mailto`, `tel`,
`data`, `ftp`, `sms`), pure intra-page anchors (`#section-id`),
and non-`.md` / non-image references.
## Gotchas
- (…)---
name: link-validation
description: Use when adding cross-links between markdown files,
debugging broken-link reports from `validate:links`,
or extending the link checker to cover new file roots.
---
# Link Validation
## Purpose
First stage of `build:static`. Walks every `.md` file under
`content/` and `cv/content/`, parses it via `marked.lexer()`,
and verifies that every internal `.md` link and every image
reference resolves to a real file on disk. Exit `1` on any
broken link, blocking the build.
## Key files
- [scripts/validate-md-links.ts](../../scripts/validate-md-links.ts)
## Conventions
- Skipped: external URLs (`http`, `https`, `mailto`, `tel`,
`data`, `ftp`, `sms`), pure intra-page anchors (`#section-id`),
and non-`.md` / non-image references.
## Gotchas
- (…)Five things to notice, and they are deliberate.
The frontmatter description is task-shaped, not noun-shaped. It does not say "this skill is about link validation." It says "use when adding cross-links, debugging broken-link reports, or extending the link checker." The agent matches user requests against this string, so it has to read like a trigger: the conditions under which loading this file would be a useful decision. Writing it as a noun ("link validation utility") would match poorly. Writing it as a verb-phrase task list matches well. This is the Interface Segregation Principle applied to documentation: expose the smallest, most task-shaped surface possible.
Purpose is one paragraph, then it stops. No motivation, no history, no architectural retrospective. The agent is loading this because it has decided the skill is relevant; it does not need to be sold on it. Keeping Purpose to a paragraph means the rest of the file can be load-bearing detail.
Key files uses repo-relative links with ../../ prefixes. Because skills live under .claude/skills/, every file reference walks up two levels. This is mechanical but it matters: it means every skill is also a navigation hub for the parts of the repo it governs. Loading link-validation.md gives the agent a one-click jump to scripts/validate-md-links.ts.
Conventions and Gotchas are where the hidden knowledge lives. This is the part you cannot derive by reading the code. "External URLs are skipped" is a convention you would have to reverse-engineer from a regex. "The validator follows symlinks but not dotfiles" is a gotcha you would only learn after a debugging session. These are the lines the agent could not produce on its own; everything else, it could.
Cross-skill references use markdown links to other skills, not inline duplication. When build-pipeline.md mentions link validation, it links to link-validation.md instead of restating its rules. This is DRY at the documentation layer: each fact lives in exactly one skill, and other skills compose it by reference. When I change how link validation works, I change exactly one file, and every other skill that references it updates automatically.
It's Just SOLID, One Level Up
The five SOLID letters map onto the skills system almost embarrassingly cleanly:
- Single Responsibility — each skill owns exactly one concern.
link-validationdoes not know about TOC building.mermaid-pipelinedoes not know about a11y. If a skill grows two responsibilities, it gets split. (The fact that there are sixteen skills, not three, is the system telling me where the natural seams are.) - Open/Closed — the index in
CLAUDE.mdis open to extension (add a new skill: add one line to the index, add one file under.claude/skills/) and closed to modification (existing skills don't have to change when a new one is added). - Liskov Substitution — every skill obeys the same file-level contract (frontmatter, Purpose, Key files, Conventions, Gotchas). The agent can load any skill the same way, with the same expectations about what it will find.
- Interface Segregation — the
description:field is the task-shaped interface, narrow and trigger-oriented. The agent does not need to know what is inside a skill to decide whether to load it. - Dependency Inversion — skills depend on each other through links, not through embedded duplication. The high-level orchestration (
CLAUDE.mdindex) does not depend on the low-level details (any one skill); both depend on the abstractname + description + fileinterface.
DRY shows up as: every fact has one home. The TOC schema is in toc-build. The Mermaid manifest format is in mermaid-pipeline. The four-theme a11y matrix is in a11y-testing. No two skills restate the same convention. When a convention changes, exactly one file changes. The blast radius is bounded by construction.
And — because this is the same trick — every skill is also unit-tested in a sense, by the compliance-report and fsm-feature-audit gates that already enforce the "every concept must link to a Feature AC" invariant from the feedback memory. The skills system is not exempt from the rule it documents.
The Compound Interest, Again
The closing argument from the content-fragments post is, word for word, the closing argument here. Writing the first skill felt like overhead. Writing the second one felt like overhead I'd already paid for. Writing the sixteenth one took ten minutes, slotted into a system that knew exactly where it belonged, with a frontmatter shape I no longer have to think about. Future me, six months from now, debugging an a11y theme regression at 11pm, will load exactly one file and find exactly the conventions and gotchas they need to fix it. Not a 800-line CLAUDE.md. Not a search through commit history. One file, named by the task, sized to the task.
The discipline I demand of my code I also demand of the prompts that produce my code. When I tell every agent invocation "be SOLID, be DRY, write unit tests" — and the repo answers back with sixteen single-responsibility, cross-linked, task-triggered skill modules indexed in a thirty-line CLAUDE.md — those instructions are no longer asks. They are a contract the repository itself enforces, on both the human and the agent, every time anyone touches it.
That is why I am happy. You should be too. The next time you start a Claude Code project, before you write a single line of code, write the CLAUDE.md. Add a .claude/skills/repo-structure.md. Make every new concern a new skill. Refuse to inline. Refuse to duplicate. Demand the same discipline of your prompts that you demand of your modules — and watch the compound interest land in exactly the same way, on exactly the same schedule, for exactly the same reasons.