Every new Claude Code session starts with amnesia. It doesn’t know the project’s conventions, the issue we encountered last week, or which commands are safe to run without asking. Mine now mostly does, and that’s the whole point of the setup below.
Four pieces, each fixing a different part of the amnesia problem: a git repo of skills and config, a knowledge loop that writes learnings back to disk, four hooks that enforce things instead of repeatedly asking nicely, and a statusline so I can see what’s happening. Few of the ideas are mine. I’ve linked where the rest came from.
One thing up front, because posts like this always read as if someone sat down and designed the whole thing from scratch. Nothing here was planned. It grew over some months of daily use, one piece at a time, and every part of it exists because something annoyed me repeatedly first.
One repo, symlinked into place
Everything lives in a single git repo. 37 skills under skills/, and the surrounding agent config under config/: the global CLAUDE.md, the permission rules, the hooks. A sync script mirrors that into ~/.claude/, and the mechanism matters per file type:
| File | Target | How |
|---|---|---|
config/CLAUDE.md | ~/.claude/CLAUDE.md | symlink, old file backed up |
settings.json | ~/.claude/ | merged, never overwritten |
config/hooks/* | ~/.claude/hooks/ | symlinked, registrations merged |
❓ Why a sync script instead of just packaging the setup as a Claude Code plugin? Valid question. There are two reasons, first plugins don’t support global CLAUDE.md guidelines and neither do they support granular permission settings to be passed to the global configuration. Second reason is, my setup is designed to be shared with my colleagues as a common baseline to work on dozens of different repos across multiple different machines.
Merging the settings instead of overwriting is the part that took a rewrite to get right. Overwrite settings.json and you nuke whatever else lives there, including per-machine settings that were never meant to be shared.
The sync runs automatically because I wrapped the claude command in my .zshrc:
claude() {
local SYNC_SCRIPT="$HOME/.claude/skills/smdm-skills/scripts/sync-skills.sh"
if [ -f "$SYNC_SCRIPT" ]; then
bash "$SYNC_SCRIPT" --auto || true
fi
command claude "$@"
}
--auto is silent when nothing changed, auto-activates newly added skills, and the || true means a broken sync never stops me from starting a session. Skipping it for one session is NO_SKILLS_SYNC=1 claude.
Skills themselves are unremarkable in the best way: a SKILL.md per directory, YAML frontmatter with a name and a description that tells the model when to load it, and heavier material in references/ that gets read only on demand. The description is the most important. A vague one means the skill never triggers, or triggers on everything.
coding-standards, the one that carries the weight
The core skill is /coding-standards, and the idea came straight from Spatie’s post on turning their guidelines into AI skills. Their framing stuck with me: nobody wants to spend their afternoon fixing AI-generated code that uses string|null instead of ?string. We already had those conventions written down. They just weren’t where the model could reach them.
So the guidelines became a skill that triggers on any code being written, reviewed, or refactored. The SKILL.md itself is only a router, a list of “if you’re doing X, load Y”:
- Writing or reviewing a `use*.ts` composable — naming, anatomy, error surfacing, cleanup?
→ @references/vue-composables.md
- Fixing PHPStan errors, choosing a level, writing generics annotations?
→ @references/php-phpstan.md
Behind it sit roughly 7,800 lines of references covering PHP, Laravel, Vue, TYPO3, Shopware, and vanilla web components. None of that loads until the router points at it, which is the only reason a body of standards this size is usable at all. A handful of non-negotiables live in the router itself, because they apply to every PHP file we write regardless of framework: declare(strict_types=1), no bare array or mixed, DTOs across layer boundaries, backed enums over string constants.
The rest of the repo is the same move applied to workflows instead of conventions. Starting a ClickUp task, pulling a remote environment into DDEV, setting up Playwright, writing a client email, deploying: things that used to live in one person’s head, or in a wiki page nobody opened, are now skills the model loads when the situation matches. Making them explicit for the machine turned out to be the cheapest way to make them explicit for the team.
The knowledge loop
This is the part I’d keep if I had to throw the rest away. I found it through Alexander Opalic’s blog post “Open Knowledge Format: I Already Do This, and Now It Has a Name”, which pointed me at both OKF as a format and poteto/brainmaxxing as the loop around it. I took the loop and rebuilt it on OKF instead of an Obsidian vault, mostly because the OKF format already resembles our documentation conventions at work and I could still use it in Obsidian if I wanted to.
Every project’s docs/ folder is a knowledge bundle. Plain Markdown, one topic per file, YAML frontmatter with a required type:
---
type: Architecture
title: Repository Architecture
description: How ai-skills is structured and the three ways a skill reaches an agent.
resource: /home/jo/projects/ai-skills
tags: [architecture, skills, plugin]
timestamp: 2026-07-19T14:10:00+02:00
---
Two filenames are reserved. index.md lists every concept with its description, and log.md is a dated decision ledger. Not a changelog: git is the changelog. If a diff already shows what happened, it doesn’t belong in the log.
Then five skills form a loop around that folder. The first three do almost all of the work:
/reflectat the end of a task. Scans the conversation and routes each genuine learning to exactly one home: projectdocs/for codebase facts,~/.claudememory for personal preferences, the/principlesskill for cross-project rules, a skill file, or a ClickUp task for follow-up work./planand/analysisread the bundle fresh on every run, so planning starts from what we already established rather than from vibes./principlesholds seven stack-agnostic judgment rules (“prove it works”, “fix root causes”, “subtract before you add”). It’s a skill rather than a file in each repo, so it distributes everywhere through the same sync and no project maintains its own copy.
The other two are housekeeping and run rarely. /maintain sends read-only auditor subagents over the bundle to prune stale concepts and reconcile the index. /excavate mines past conversation history for learnings that /reflect never caught, which is mainly useful for bootstrapping a bundle on a project that already has months of history behind it.
If you copy one thing from this post, copy /reflect plus a /plan skill that reads your /principles. That trio is the loop. Everything else is optimisation.
💡 Over-capture is the failure mode, not under-capture. The test before writing anything down: would this change how a future session behaves? If not, skip it. Three concise notes beat nine with noise.
The loop only pays off if it actually runs. /reflect at the end of a task is a habit I had to build. Without it this is the same stale-docs problem in a nicer wrapper.
Four hooks
Hooks are where a lesson stops being an instruction and becomes a mechanism. Written instructions get ignored, by models and humans alike. Hooks don’t.
{
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "command": "bash \"$HOME/.claude/hooks/allow-dev-commands.sh\"" }]
}
],
"SessionStart": [
{
"matcher": "startup|resume|clear",
"hooks": [{ "command": "bash \"$HOME/.claude/hooks/inject-docs-index.sh\"" }]
}
]
}
inject-docs-index.sh (SessionStart) reads the project’s docs/index.md and injects it, so the model knows what project knowledge exists before it starts guessing. If there is no index yet but a docs/ folder exists it gets an auto-generated file listing instead, clearly labelled as uncurated. No docs/ at all is a silent no-op.
Since the index is a curated list of documentation it usually helps the agent to find the correct files faster and to have the bigger picture in mind while working. It prevents re-explaining over and over again. That advantage is worth the token cost in my opinion.
check-docs-index.sh (PostToolUse on writes under docs/) runs a drift check right after I add or delete a doc: which files aren’t in the index, which index links point at files that no longer exist. This one makes sure that the docs index always is correct and up to date.
Two hooks for one check, because of a detail worth knowing: only SessionStart output reaches the model’s context. PostToolUse output surfaces in the transcript and nowhere else. So one carries drift into context next session, and one gives me immediate feedback this session.
Neither one rewrites the index. The original brainmaxxing version safely regenerates its index because that file is nothing but bare wikilinks, a lossless mechanical output. An OKF index carries curated descriptions and prose, so a mechanical rebuild destroys work. Detect and report seemed the better solution to me.
allow-dev-commands.sh (PreToolUse on Bash) auto-approves a small allowlist of read-only dev commands so php artisan route:list and vendor/bin/pint stop prompting, bare or wrapped in ddev exec. It is strictly additive: it only ever emits allow, never deny, and anything it doesn’t positively recognise falls through to the normal permission flow. Before matching, it rejects any command containing a shell operator, substitution, redirection, or backgrounding, so an allowlisted prefix can’t be used to smuggle in chained code. I added this hook additionaly to the natively supported permissions because we run our project in DDEV at work and it feels like the more maintainable approach than 100 entries in the settings.json.
validate-claude-md.sh (PostToolUse) is the one I didn’t expect to need. It measures CLAUDE.md after every edit and complains past 200 lines, hard-fails past 300:
✗ ./CLAUDE.md: 341 lines, 19204 chars (~4801 tokens) — over the hard limit.
64 lines ## Deployment
41 lines ## Testing conventions
CLAUDE.md loads into every request in its scope. A skill or a docs/ concept costs tokens only when something reads it. That makes CLAUDE.md length a permanent tax and a context bloat problem: the instructions that matter compete with the ones that don’t. The hook names the three largest sections so trimming is actionable instead of vague.
All four hooks are fail safe. Malformed input, no project dir, any exceptions, and they exit 0 with no output. A hook that can break the session is worse than no hook.
I’ve since ported the whole setup, skills and config included, over to Opencode as well, since I switch between the two. The hooks don’t carry over as-is: opencode has no hook system, it has plugins instead. Same four checks, same intent, just re-attached to a different mechanism.
The statusline
Lifted almost verbatim from Creating The Perfect Claude Code Status Line of Matt Pocock, which I found through freek.dev. It answers the four questions I actually have mid-session: which repo, which branch, do I have uncommitted work, and how much context is left.
Two small scripts. The first prints git state, parsing the JSON on stdin with sed because spawning jq on every render is a tax I don’t want:
printf '%s | %s | S: %s | U: %s | A: %s' \
"$repo_name" "$branch" "$staged" "$unstaged" "$untracked"
Staged, unstaged, untracked counts, all with --no-optional-locks so a statusline render never fights an interactive git command for the index lock. Outside a repo it just prints the directory.
The second script composes that with the remaining context window, which is the number I actually watch:
input=$(cat)
git_info=$(echo "$input" | bash ~/.claude/statusline-command.sh)
context_pct=$(echo "$input" | npx ccstatusline)
printf '%s | %s' "$git_info" "$context_pct"
Reading stdin once and passing it to both is the entire trick, since a statusline command gets exactly one shot at stdin. ccstatusline is configured down to a single yellow context percentage, because everything else it can show I either already have or don’t need. If you build this, install it globally rather than calling npx every render.
What it costs
Not a weekend. Several months of noticing the same friction twice and then doing something about it, in small pieces, between actual client work. The order was roughly: coding standards first, because bad output was the loudest problem. Then the workflow skills, one per repeated explanation. Then the knowledge loop, once I got tired of re-explaining the same project to a fresh session. The hooks came last, each one from a lesson I was clearly not going to remember on my own.
The upkeep is uneven. Hooks and the statusline are set-and-forget. The knowledge bundle is not: it decays the moment I stop reflecting at the end of tasks, and no amount of tooling fixes that.
What I get back is that a fresh session in a client project I haven’t touched in months opens already knowing the architecture, the deploy quirks, and the decisions we made and why. That’s worth the discipline.