On this page
Claude Code: Shared + Personal AI Config Pattern
Split AI instructions into committed (shared) and gitignored (personal) layers
I spent weeks tuning Claude Code instructions for my project — custom commands, domain-specific prompts, coding conventions. Then a new developer joined the team, cloned the repo, and got zero AI assistance. My entire configuration was gitignored because it contained personal symlinks to my knowledge management system.
The fix is a two-layer architecture: shared instructions committed to the repo so new developers get working AI out of the box, and personal extensions gitignored so existing developers keep their custom setup.
The Problem
Claude Code reads its instructions from CLAUDE.md and .claude/ in the
project root. When one developer’s config references personal paths, symlinks,
or external systems, gitignoring the whole thing is the obvious move. But that
means every other developer starts with a blank slate.
The challenge is splitting instructions into a shared layer (committed, works for everyone) and a personal layer (gitignored, per-developer customizations) without duplication or drift.
Architecture
The split looks like this:
Committed (shared) Gitignored (personal)
────────────────── ─────────────────────
CLAUDE.md ← source of truth CLAUDE.local.md
AGENTS.md ← synced copy .claude/settings.local.json
.claude/prompts/ ← domain context .claude/skills/
.claude/settings.json .claude/prompts/*.ko.md
.cursor/rules/index.mdc .mcp.json
.github/copilot-instructions.md Shared files contain everything a new developer needs: project architecture, coding conventions, command reference, deployment instructions. Personal files contain per-developer preferences, private tool configs, and symlinks to external systems.
Key Decisions
CLAUDE.md Is the Single Source of Truth
All other AI instruction files are generated from CLAUDE.md via npm run ai:sync. This prevents drift between Claude Code, Cursor, and GitHub
Copilot. One file to edit, three tools stay in sync.
MCP Rules Are Softened in Shared Config
Personal config uses “ALWAYS USE” for Context7 and Postgres MCP servers. Shared config uses “If configured” instead, since new developers may not have MCP servers set up. The instructions still describe what the tools do and when to use them — they defer the requirement.
Symlinks Stay for Personal-Only Content
.claude/skills/ remains a symlink to my knowledge management system
(gitignored). Personal extensions that don’t affect the team stay as symlinks or
in CLAUDE.local.md. The boundary is clear: if it helps everyone, commit it. If
it’s specific to your setup, gitignore it.
Sync Script
npm run ai:sync reads CLAUDE.md and writes to three targets:
AGENTS.md(exact copy).github/copilot-instructions.md(exact copy).cursor/rules/index.mdc(Cursor YAML frontmatter + content)
Running the script after editing CLAUDE.md keeps all three in sync. No manual
copying, no forgetting to update one of them.
Gitignore Pattern
The .gitignore makes the boundary explicit:
# AI configuration - shared (committed)
# CLAUDE.md, AGENTS.md, .claude/prompts/, .claude/settings.json,
# .cursor/rules/, .github/copilot-instructions.md are tracked
# AI configuration - personal (gitignored)
CLAUDE.local.md
.claude/settings.local.json
.claude/skills
.claude/prompts/*.ko.md
.mcp.json
.claudeignore New developers clone the repo and get the shared config immediately. Personal files never leak into the repository.
SoT Directory Pattern (project-claude/)
Managing the shared/personal split across multiple projects gets unwieldy fast. The solution is a central source-of-truth directory with symlinks to each project repo:
3b/.claude/project-claude/
├── backend-project.md # Shared SoT → backend-v2/CLAUDE.md (symlink)
├── backend-project.local.md # Personal SoT → backend-v2/CLAUDE.local.md (symlink)
├── backend-project.mcp.json # MCP SoT → backend-v2/.mcp.json (symlink)
├── infra-project.md # Combined (personal-only repo)
├── infra-project.mcp.json # MCP SoT → backend-infra/.mcp.json (symlink)
├── orchestration-project.md # Combined (personal-only repo)
├── etl-project.md # Combined (personal-only repo)
├── crucio.mcp.json # MCP SoT → crucio/.mcp.json (symlink)
└── ... Only repos with other team members need the shared/local split. Personal-only
repos use a single combined file. .mcp.json follows the same pattern — the
knowledge base holds the canonical version, project repos get symlinks. Sentry
and Notion MCP servers were removed from .mcp.json in favor of
Anthropic-hosted integrations that require zero config and handle OAuth
natively.
Guard Comments for Shared Files
The most common mistake with this pattern is accidentally putting personal content into a shared file. An HTML comment at the top of each shared SoT file prevents this:
<!-- SHARED FILE — This file syncs to {repo}/CLAUDE.md (team-visible).
DO NOT add personal content (3B paths, buffer, symlink, user profile).
Personal overrides go in {name}.local.md → {repo}/CLAUDE.local.md --> This works as a point-of-authorship guardrail. Claude (or a human) sees the constraint before editing. More effective than rules in a separate file because it doesn’t require the editor to have loaded the rule first.
Symlink Deployment Chain
For shared repos, the deployment is two hops:
project-claude/{name}.md (3B SoT)
↓ filesystem symlink
{repo}/CLAUDE.md (Claude Code reads this)
↓ npm run ai:sync
AGENTS.md + copilot-instructions.md + cursor rules (team sees) Guard comments at the SoT prevent personal content from leaking through both hops. The symlink is transparent to all tools — edits to either end modify the same file.
Layer Deduplication Strategy
After setting up shared configs for several projects, a new problem appeared:
the same universal principles (5W1H documentation, buffer format, .me.md rules, communication style) were copy-pasted into every project’s CLAUDE.md.
They drifted over time and wasted tokens on redundant instructions.
The fix is a two-step promotion:
- Identify duplication — grep across all project-claude files for repeated
instructions (buffer appeared 7 times, 5W1H appeared 6 times,
.me.mdappeared 4 times) - Promote to global — move the canonical version to
~/.claude/CLAUDE.mdand replace each project copy with a 1-line reference:Universal principles (...) are in ~/.claude/CLAUDE.md.
This works because Claude Code’s loading hierarchy guarantees ~/.claude/CLAUDE.md loads first in every session. Project files inherit global
rules without restating them.
Results from the 2026-02-23 restructuring:
- 8 universal principles promoted to global (YAML Frontmatter,
Cross-Referencing, 5W1H, Decision Documentation, Zettelkasten,
.me.md, Buffer, Communication Style) - 7 project files deduplicated (~25-35% token savings each)
Further reductions (2026-03-09):
- Markdownlint quick-reference table fully removed — redundant with
.markdownlint-cli2.jsoncplus a husky pre-commit hook. Originally compressed from ~330 to ~28 lines (2026-02-23), then eliminated entirely when the tooling backstop proved sufficient - Mermaid section compressed from ~89 lines to 6 lines (kept the behavioral rule, removed examples/tables/checklist since no tooling enforces Mermaid preference)
- 3 more sections extracted to
.claude/rules/files (change-discipline, yaml-frontmatter-schema, personal-folder-governance) - Net result: global
CLAUDE.mdreduced from 491 to 371 lines (~24.4% savings) - Project
CLAUDE.md: 541 to 478 (Tier 1) to 328 (Tier 2) = -39.4% total - Combined always-loaded context: 912 to 720 lines (-21%)
- Every session now enforces the same principles while loading fewer lines
Settings.local.json Consolidation
Per-project settings.local.json files were the next deduplication target. I
had 14 files (8 of them symlinks from a central source) that mostly repeated
the same bash command allow-list. The key insight was that Claude Code’s
permission precedence — deny > ask > allow — makes a Bash(*) catch-all
safe.
Before (14 files, 8 symlinks)
3b/.claude/settings.local.json ← source file
↑ symlinked from 8 projects (brandonwie, crucio, backend-v2, etc.)
+ 5 independent files (dev/, personal/, dotfiles/, frontend/, mobile/) Most entries were redundant with global settings.json, which had evolved to
cover the same commands. Only 6 items were unique across all files.
After (global settings.json only)
permissions.allow: ["Bash(*)"] ← catch-all for all non-destructive commands
permissions.deny: [dangerous] ← terraform destroy, git push --force, sudo
permissions.ask: [risky] ← git push, rm, kill (prompted)
defaultMode: "default" ← with Bash(*) catch-all, effectively auto-approved The cleanup removed 8 symlinks and 3 redundant regular files. Settings like outputStyle, enableAllProjectMcpServers, and prefersReducedMotion moved to
global. New projects automatically get correct permissions from the global
config — no per-project setup needed.
Why Bash(*) Is Safe
The deny > ask > allow precedence means Bash(*) only auto-approves commands
that don’t match a deny or ask pattern. Dangerous commands like terraform destroy and git push --force are in deny. Risky commands like git push and rm are in ask. Everything else flows through to the catch-all.
Per-Profile settings.json (Corrected)
When I first wrote this post in March, I claimed settings.json couldn’t be
symlinked across profiles and described the architecture as “three copies.” That
was wrong. The actual architecture is symlinked end-to-end, and per-profile
differences live in a separate settings.local.json override file that Claude
Code deep-merges over the shared base. I only discovered this when an audit
script flagged a broken symlink and I had to trace the chain to repair it.
The corrected topology:
- Knowledge base SoT (
global-claude-setup/settings.json) — canonical source, gitignored because it contains machine-specific plugin install state - Personal profile (
~/.claude/settings.json) — symlink to the SoT - Work profile (
~/.claude-work/settings.json) — symlink chained through personal:~/.claude-work/settings.json → ~/.claude/settings.json → SoT. It is not a separate copy. - Work overrides (
~/.claude-work/settings.local.json) — symlink to a separatesettings.local.work.jsonin the SoT directory. Contains only the two keys that differ from personal:statusLine.command(with theCLAUDE_CONFIG_DIR=~/.claude-workprefix) andenabledMcpjsonServers(the whitelist for work-specific database connections). Claude Code deep-merges this over the basesettings.jsonat load time.
All non-override settings — env, permissions, hooks, plugins — come from the single shared SoT through the symlink chain. Editing the SoT instantly propagates to both profiles. There is no manual sync step.
Watch out for junk accumulation. Interactive permission approvals (“Always
allow”) store the exact command string as a permission entry — including
multi-line bash scripts, entire code blocks, and auth tokens. My work profile
accumulated ~160 entries (32KB) before cleanup. The Bash(*) catch-all
prevents this by auto-approving before the interactive prompt fires.
Chain Failure Mode
The work → personal → SoT chain has a single-file failure mode that took me a
while to recognize. If anything breaks ~/.claude/settings.json, both
profiles lose the chain at once, not just personal. The most common cause is
the Claude Code UI (or a plugin’s permission prompt) writing the file
atomically: it creates a temp file and uses rename() to move it over the
target. That rename() replaces the symlink inode with a regular file in
place, silently orphaning the SoT.
You don’t notice immediately. The first hint is usually “settings I changed in the SoT aren’t showing up” or “a plugin I enabled isn’t running.” By then, the two profiles may already have drifted apart from the SoT, and any user activity that happened in the meantime — UI permission toggles, plugin enables — only exists in the broken local file.
Detection. I run an audit script that walks all 55 expected symlinks across personal, work, and project categories and reports a “REPLACED” classification when it finds a regular file where a symlink should be. That classification is the telltale failure signature.
Repair strategy depends on what drifted. There are two cases.
The first case is straightforward. If the local file is strictly stale and the SoT is current — meaning no user activity hit the local file during the broken window — a one-way restore is safe:
# Back up the local file just in case
cp ~/.claude/settings.json /tmp/settings.local.backup.$(date +%s)
# Remove the regular file and re-link to SoT
rm ~/.claude/settings.json
ln -sfn /path/to/sot/settings.json ~/.claude/settings.json
# Verify the chain is intact from both profiles
realpath ~/.claude/settings.json
realpath ~/.claude-work/settings.json The second case is harder. If the local file accumulated user intent — UI toggles, “Always allow” clicks, plugin enables — and the SoT was separately edited during the same window, a naive restore would silently reverse the user’s changes. You need a bidirectional merge: walk the two JSONs structurally, classify each diff, and merge before re-linking. A minimal walker looks like this:
# Returns diffs as (path, kind, local_value, sot_value)
# where kind is LOCAL-ONLY | SOT-ONLY | VALUE | LIST
def walk(l, s, path=""):
if type(l) != type(s): ...
if isinstance(l, dict):
for k in set(l) - set(s): yield (f"{path}.{k}", "LOCAL-ONLY", l[k], None)
for k in set(s) - set(l): yield (f"{path}.{k}", "SOT-ONLY", None, s[k])
for k in set(l) & set(s):
yield from walk(l[k], s[k], f"{path}.{k}")
elif isinstance(l, list):
if l != s: yield (path, "LIST", len(l), len(s))
elif l != s:
yield (path, "VALUE", l, s) Once you have the diffs classified, you can merge structurally — usually
LOCAL-ONLY entries come from UI activity and should be kept, SOT-ONLY entries
come from intentional config edits and should also be kept, and VALUE conflicts
need a human decision. Write the merged result atomically via temp file plus rename() in the same directory as the SoT, so the work profile’s symlink sees
a consistent file at all times during the swap.
Why this matters beyond one-off incidents. User activity that legitimately modifies the local file (permission “Always allow” clicks, plugin toggles from the UI) is invisible to the SoT during the window the local file is broken. If you wait to detect the break, drift can accumulate unintentionally. The incident that surfaced this for me had ~3 hours of drift, including a plugin-on experiment that was collecting zero data because the tracking hooks only existed in the SoT — but my running profile was looking at a stale local file that didn’t have them.
No git rollback. The SoT settings.json is gitignored because it contains
machine-specific plugin install state. That means any destructive repair must
be preceded by a manual backup (e.g., cp $SOT /tmp/settings.sot.backup.$(date +%s)) so you can recover if the merge is wrong. /tmp is ephemeral but
sufficient for the repair window itself; move to ~/ if you want longer
retention.
Open question: should the chain be decoupled? The current work → personal → SoT topology exists because historically only one profile existed and the
work profile was added as a second hat on top. The alternative — two
independent symlinks ({personal,work} → SoT) — would contain a single UI
break to one profile instead of cascading across both. The cost is that the settings.local.json deep-merge mechanism would need re-verification to
confirm it still works with decoupled chains. I haven’t done that spike yet,
but it’s worth it before the next major settings restructure.
Cross-Check Discipline
When creating shared instructions, cross-check all prompt files for personal references that won’t work for other developers:
- Hardcoded absolute paths (
/Users/username/...) - Personal usernames in assignee fields
- References to gitignored scripts or folders
- MCP tool references without “if configured” guard
- Buffer location (
~/dev/personal/3b/.claude/buffer.md) - Symlink documentation (
docs/pointing to personal paths) - User context sections (level, experience, role)
This checklist catches the leaks that guard comments alone can miss. Run through it before every PR that touches AI configuration files.
The Result
New developers clone the repo and get working Claude Code instructions immediately. Personal customizations stay private. The sync script prevents drift across AI tools. And the layer deduplication keeps token usage low by promoting shared principles to the global config.
The pattern scales to any number of projects and developers. The central SoT directory makes it easy to audit what’s shared vs. personal, and guard comments prevent accidental leaks at the point of authorship.