I've been running PAI v2.3 with heavy customization — custom skills, CLI tools, observability stack, voice server, and a feature branch with signal processing work. When v2.5 dropped, I wanted to upgrade without losing any of that, and without any downtime. Here's how I did it.
Before getting into the migration itself, the key thing to understand is the architecture that makes version switching painless. I use a three-layer symlink system combined with git worktrees. This is what lets me switch between PAI versions in under a second, and what made the v2.5 migration low-risk.
Layer 1: Claude Code entry point
~/.claude → ~/Developer/pai/versions/2.5-develop/.claude
(changing this one symlink = instant version switch)
Layer 2: Personal data lives outside the repo
.claude/.env → ~/.config/pai/.env (API keys)
.claude/MEMORY → ~/.config/pai/MEMORY (session history)
.claude/WORK → ~/.config/pai/WORK (active work)
.claude/settings.json → ~/pai-personal-data/settings.json
.claude/profiles → ~/pai-personal-data/profiles
.claude/skills/PAI/USER → ~/.config/pai/CORE_USER (my customizations)
Layer 3: Two external stores
~/.config/pai/ → secrets + state (never in git)
~/pai-personal-data/ → settings + profiles (private git repo)
Why this matters: Every PAI version points to the same personal data through Layer 2 symlinks. My API keys, memory, identity, and settings don't live inside any version's directory. When I switch from v2.3 to v2.5, all my personal data is already there.
This is probably the most important pattern in the whole setup. PAI has three categories of personal data, and each needs different handling:
Secrets (~/.config/pai/) — API keys, tokens, anything that would be a disaster if leaked. This directory follows XDG conventions, lives outside any repo, and is never committed anywhere. The .env file here has my Anthropic key, ElevenLabs key, Telegram tokens, etc.
State (~/.config/pai/MEMORY, WORK) — Session history and active work context. Also in ~/.config/pai/ because it's machine-specific and changes constantly. Not something you'd want in a git repo generating noise.
Configuration (~/pai-personal-data/) — My settings.json (DA identity, hooks, permissions), agent profiles, and user customizations (AI steering rules, DA personality). This is in a private git repo because I want version history and backup. If I break my settings.json, I can git revert. If my machine dies, I clone this repo on the new one.
User customizations (~/.config/pai/CORE_USER) — My personal AI steering rules, DA identity file, etc. These are symlinked into whichever PAI version is active at skills/PAI/USER/.
The key insight: none of this lives inside the PAI repo. The PAI repo is stock code + custom skills + custom tools. Personal data is entirely external. This means:
- I can
git clean -fdxthe PAI repo without losing my identity - I can share the PAI repo without leaking secrets
- Multiple PAI versions share the same identity and memory
- Upgrading is just "point the symlinks at the new version"
Instead of one checkout with branches, each version gets its own directory:
~/Developer/pai/
├── versions/
│ ├── 1.2/ v1.2 (legacy, read-only)
│ ├── 2.3/ v2.3 main
│ ├── 2.3-develop/ v2.3 develop (worktree)
│ ├── 2.5/ v2.5 main
│ └── 2.5-develop/ v2.5 develop (worktree)
├── pai-personal-data/ private repo for settings
└── tooling/
└── bin/pai-switch version switcher
Every version is always checked out and ready. No git stash, no git checkout, no waiting. I can diff v2.3 against v2.5 side by side. Feature branches get their own worktrees too.
I wrote a pai-switch script that manages the Layer 1 symlink. It handles switching, baselines, and test environments:
pai-switch v25-dev # → ~/.claude points to 2.5-develop
pai-switch v2 # → ~/.claude points to 2.3-develop (instant rollback)
pai-switch status # → shows current environment
pai-switch save name # → tarballs current env as a named baseline
pai-switch baselines # → lists available baselinesHere's the full script:
#!/bin/bash
# pai-switch - Switch between PAI environments
#
# Usage:
# pai-switch v1|prod Switch to PAI v1.2 (production)
# pai-switch v2|dev Switch to PAI v2.3 (develop)
# pai-switch v2-main|main Switch to PAI v2.3 (production)
# pai-switch v25|latest Switch to PAI v2.5 (production)
# pai-switch v25-dev Switch to PAI v2.5 (develop)
# pai-switch test Switch to test sandbox
# pai-switch status Show current environment
# pai-switch nuke-test Wipe and recreate test environment
# pai-switch restore Restore test from baseline
# pai-switch save Save current test as baseline
# pai-switch baselines List available baselines
set -e
# ── Configure these paths for your setup ──
SRC_DIR="$HOME/Developer/pai"
CLAUDE_LINK="$HOME/.claude"
BASELINES_DIR="$SRC_DIR/baselines"
PAI_ENV_FILE="$HOME/.pai-env"
# Environment paths
V1_PATH="$SRC_DIR/versions/1.2/Personal_AI_Infrastructure/.claude"
V2_PATH="$SRC_DIR/versions/2.3/.claude"
DEV_PATH="$SRC_DIR/versions/2.3-develop/.claude"
V25_PATH="$SRC_DIR/versions/2.5/.claude"
V25_DEV_PATH="$SRC_DIR/versions/2.5-develop/.claude"
TEST_PATH="$SRC_DIR/test/.claude"
# Color output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
show_status() {
local current=$(readlink "$CLAUDE_LINK" 2>/dev/null || echo "not a symlink")
echo -e "${BLUE}Current ~/.claude:${NC} $current"
if [[ "$current" == *"/pai/versions/1.2"* ]]; then
echo -e "${GREEN}Environment: v1.2 (production)${NC}"
elif [[ "$current" == *"/pai/versions/2.5-develop"* ]]; then
echo -e "${GREEN}Environment: v2.5 (develop)${NC}"
elif [[ "$current" == *"/pai/versions/2.5"* ]]; then
echo -e "${GREEN}Environment: v2.5 (production)${NC}"
elif [[ "$current" == *"/pai/versions/2.3-develop"* ]]; then
echo -e "${YELLOW}Environment: v2.3 (develop)${NC}"
elif [[ "$current" == *"/pai/versions/2.3"* ]]; then
echo -e "${YELLOW}Environment: v2.3 (production)${NC}"
elif [[ "$current" == *"/pai/test"* ]]; then
echo -e "${RED}Environment: test (sandbox)${NC}"
else
echo -e "${RED}Environment: unknown${NC}"
fi
}
switch_to() {
local target="$1"
local label="$2"
if [[ ! -d "$target" ]]; then
echo -e "${RED}Error: Target does not exist: $target${NC}"
exit 1
fi
if [[ -L "$CLAUDE_LINK" ]]; then
rm "$CLAUDE_LINK"
elif [[ -d "$CLAUDE_LINK" ]]; then
echo -e "${RED}Error: ~/.claude is a directory, not a symlink${NC}"
echo "Remove it manually if you want to switch: rm -rf ~/.claude"
exit 1
fi
ln -s "$target" "$CLAUDE_LINK"
cat > "$PAI_ENV_FILE" << 'EOF'
# Generated by pai-switch - do not edit manually
export PAI_DIR="$HOME/.claude"
EOF
echo -e "${GREEN}Switched to $label${NC}"
show_status
}
save_baseline() {
local name="${2:-$(date +%Y-%m-%d)-snapshot}"
local target="$BASELINES_DIR/$name"
if [[ -d "$target" ]]; then
echo -e "${RED}Error: Baseline already exists: $name${NC}"
exit 1
fi
mkdir -p "$BASELINES_DIR"
local current=$(readlink "$CLAUDE_LINK" 2>/dev/null)
cp -R "$current" "$target"
echo -e "${GREEN}Saved baseline: $name${NC}"
}
case "$1" in
v1|prod) switch_to "$V1_PATH" "v1.2 (production)" ;;
v2|dev) switch_to "$DEV_PATH" "v2.3 (develop)" ;;
v2-main) switch_to "$V2_PATH" "v2.3 (production)" ;;
v25|latest) switch_to "$V25_PATH" "v2.5 (production)" ;;
v25-dev) switch_to "$V25_DEV_PATH" "v2.5 (develop)" ;;
test|sandbox) switch_to "$TEST_PATH" "test (sandbox)" ;;
status|s) show_status ;;
save) save_baseline "$@" ;;
baselines) ls -la "$BASELINES_DIR" 2>/dev/null || echo "No baselines" ;;
*)
echo "pai-switch - Switch between PAI environments"
echo ""
echo "Usage:"
echo " pai-switch v25-dev Switch to PAI v2.5 (develop)"
echo " pai-switch v25|latest Switch to PAI v2.5 (production)"
echo " pai-switch v2|dev Switch to PAI v2.3 (develop)"
echo " pai-switch status Show current environment"
echo " pai-switch save [name] Save current as baseline"
echo " pai-switch baselines List baselines"
show_status
;;
esacTo install, put it somewhere on your PATH:
mkdir -p ~/Developer/pai/tooling/bin
# save the script as pai-switch
chmod +x ~/Developer/pai/tooling/bin/pai-switch
# add to PATH in your shell rc:
export PATH="$HOME/Developer/pai/tooling/bin:$PATH"That's it. One symlink change, open a new terminal, run claude.
I had an existing fork of danielmiessler/Personal_AI_Infrastructure. Fetched the upstream v2.5.0 tag, created branches, set up worktrees:
cd ~/Developer/pai/versions/2.5
git fetch upstream --tags
git checkout -b v2.5-main v2.5.0
git worktree add ../2.5-develop v2.5-developDiscovery: v2.5 uses Packs. The .claude/ directory in the repo is a stub. The actual content comes from 23 Packs, each with a src/ directory that gets assembled into .claude/. I ran the assembly and ended up with a populated .claude/ containing all the stock skills, hooks, and agents.
Same pattern as v2.3, just pointing to the new directories:
V25=~/Developer/pai/versions/2.5-develop/.claude
ln -sf ~/.config/pai/.env $V25/.env
ln -sf ~/.config/pai/MEMORY $V25/MEMORY
ln -sf ~/.config/pai/WORK $V25/WORK
ln -sf ~/Developer/pai/pai-personal-data/settings.json $V25/settings.json
ln -sf ~/Developer/pai/pai-personal-data/profiles $V25/profiles
ln -sf ~/.config/pai/CORE_USER $V25/skills/PAI/USERDiscovery: The CORE to PAI rename is conceptual only. The v2.5 release notes talk about renaming skills/CORE/ to skills/PAI/, but the upstream repo still uses skills/CORE/ internally in many places. The rename shows up in contextFiles paths in settings.json, but the actual directory structure depends on how Packs assemble. Don't waste time bulk-renaming things.
This was the most important step. The settings.json schema changed:
paiVersion:"2.3"→"2.5"contextFiles: went from 1 file (skills/CORE/SKILL.md) to 4 files (SKILL.md + AISTEERINGRULES + DAIDENTITY)hooks: 15 → 17 (added RelationshipMemory, SoulEvolution; renamed FormatEnforcer → FormatReminder)permissions.allow: new tools added (MultiEdit, LS, ExitPlanMode, Skill)alwaysThinkingEnabled: new field, set totrue
My approach: Start from the v2.5 stock settings.json, then overlay my personal values (DA identity, voice config, timezone, env vars, custom permissions). Don't try to patch v2.3 settings incrementally — you'll miss structural changes.
v2.5 updated every hook. I replaced the entire hooks/, hooks/lib/, and hooks/handlers/ directories with v2.5 stock versions.
The notable rename: FormatEnforcer → FormatReminder. This isn't just a rename — the new version uses AI inference to classify response depth instead of regex matching. Significant improvement.
New hooks added: RelationshipMemory and SoulEvolution.
My custom skills (underscore-prefixed: _CONTEXT, _COUPA, _JIRA, _JIRA_ANALYSIS, _DOCX, _DISPATCH, _AI_ONCHARGING, _SPECFIRST) just copied straight over. They're independent of the PAI version.
Only thing to check: grep -r "skills/CORE" skills/_*/ for any stale references that need updating to skills/PAI/.
v2.5 also brought new stock skills I didn't have: Documents, Evals, PromptInjection, SECUpdates, BeCreative, Algorithm.
Copied my bin/ tools (ctx, ingest, jira, docx, etc.), ran bun install in each directory. Copied Observability stack, VoiceServer, agents, security system, context taxonomies.
# Tarball v2.3 as a safety net
tar -czf ~/Developer/pai/baselines/pai-2.3-final.tar.gz -C ~/.claude .
# Switch
pai-switch v25-dev
# Open new terminal, run claudeHooks from a feature branch caused issues. I had been working on a feature/signal-agent-2 branch on v2.3 with modified hooks (custom LoadContext, ToolUseInstrumentation for observability). When I initially tried to bring those forward, some hooks referenced paths and patterns that didn't exist in v2.5.
The fix was simple: restore to stock v2.5 hooks. The custom hook modifications weren't worth the conflict resolution. v2.5's stock hooks were improved enough that my customizations were either already covered or not worth maintaining separately.
The session-end hooks (SessionSummary.hook.ts, WorkCompletionLearning.hook.ts) initially errored because they were referenced in settings.json but the files pointed to the old v2.3 paths via ${PAI_DIR}. Once ~/.claude was pointing at v2.5 with the stock hooks in place, everything resolved.
Lesson: If you have a feature branch with hook modifications, don't try to bring them forward during the migration. Get stock v2.5 working first, then selectively re-apply customizations.
| What | v2.3 | v2.5 |
|---|---|---|
| Distribution | .claude/ is the repo |
Packs → assembled .claude/ |
| Core skill path | skills/CORE/ |
skills/PAI/ (in config) |
| Context loading | 1 file at startup | 4 files (SKILL + steering rules + identity) |
| Format enforcement | Regex-based (FormatEnforcer) | AI inference (FormatReminder) |
| Hooks | 15 | 17 (+RelationshipMemory, +SoulEvolution) |
| Permissions | Basic set | Extended (MultiEdit, LS, Skill, ExitPlanMode) |
| Thinking | Not configurable | alwaysThinkingEnabled: true |
| New skills | — | Documents, Evals, PromptInjection, SECUpdates, BeCreative, Algorithm |
-
Don't upgrade in-place. Set up v2.5 alongside v2.3. The worktree approach means both are always ready and switching is instant.
-
Set up the symlink architecture first if you haven't already. It's the single most valuable pattern in PAI — it decouples your identity and data from any specific version.
-
Start settings.json from the v2.5 template. Overlay your personal values onto it. Don't try to patch your v2.3 settings.
-
Replace all stock hooks wholesale. Don't try to merge. v2.5 hooks are all updated. Re-apply customizations afterward.
-
Custom skills just copy over. The underscore-prefix convention means they're independent of the version.
-
The CORE → PAI rename is mostly cosmetic. Don't panic about it. The
contextFilespaths changed, but the actual directory structure varies. -
Save a baseline before switching. A tarball of your v2.3
.claude/means instant rollback. -
Feature branch hooks are the risk. If you've modified stock hooks on a feature branch, restore to stock v2.5 first and get that working before re-applying changes.
After migration: 46 skills, 21 hooks, 12 agents, 12 bin tools. Everything working on v2.5-develop. The whole migration was done in a single Claude Code session.