Personalization sync
Cross-machine sync of the operator’s Claude Code state — global config, per-project memory, spec/superpower outputs, and workspace planning notes — through a separate (typically private) GitHub repo. ctrlrelay clones that repo into a checkout, lays down symlinks at the right places, and rebases onto a per-machine branch so two computers pushing concurrently never overwrite each other’s deltas.
The personalization repo is not the same as the project repos in
repos:. It’s an out-of-tree store for things that survive across sessions
and machines but don’t belong inside any project’s source tree.
Why this exists
Without sync, the operator hits three problems:
~/.claude/CLAUDE.md, skills, agents, commands drift between machines. Edits on the laptop don’t reach the workstation.- Per-project memory under
~/.claude/projects/<encoded>/memory/is lost the moment you switch machines. Claude has to relearn the project from scratch every time. - Spec / superpower outputs that Claude writes per project would be
either lost (if kept under
~/), polluting (if committed inside the project repo), or invisible to other machines.
A second git repo solves all three cleanly. The cost is one private repo
per operator, plus a personalization: block in orchestrator.yaml.
Layout: the dotclaude repo
The repo can be named anything, but the convention is <your-handle>/dotclaude
or similar. The example layout this doc assumes:
dotclaude/
├── global/
│ ├── CLAUDE.md # ~/.claude/CLAUDE.md
│ ├── skills/ # ~/.claude/skills/
│ ├── agents/ # ~/.claude/agents/
│ ├── commands/ # ~/.claude/commands/
│ └── keybindings.json
├── claude-memory/
│ └── <owner>--<repo>/ # ~/.claude/projects/<encoded>/memory/
└── specs/
└── <owner>--<repo>/ # <project-parent>/specs/<owner>--<repo>/
The <owner>--<repo> slug uses a double hyphen as the owner/repo
separator so a-b/c and a/b-c produce different slugs (a-b--c vs.
a--b-c) and never collide.
Why a directory next to the project for specs, not inside it: the project’s own git history must stay uncontaminated. Spec docs Claude writes during development belong to your operator state, not to the project’s commit log.
One-time setup
1. Create the personalization repo
Create an empty private GitHub repo. The gh CLI is fine:
gh repo create your-handle/dotclaude --private --description \
"Cross-machine sync of Claude Code personalization (config, memory, specs) — managed by ctrlrelay"
Don’t add a README, .gitignore, or license — ctrlrelay’s init will
populate it.
2. Configure personalization: in orchestrator.yaml
personalization:
repo: "your-handle/dotclaude"
# checkout_path: "~/.ctrlrelay/personalization" # default
# main_branch: "main" # default
# node_id: "studio-mac" # default: top-level node_id
paths:
- source: "global/CLAUDE.md"
target: "~/.claude/CLAUDE.md"
- source: "global/skills/"
target: "~/.claude/skills/"
- source: "global/agents/"
target: "~/.claude/agents/"
- source: "global/commands/"
target: "~/.claude/commands/"
# Per-project memory. Placeholders resolve from each repo's
# local_path at link time, so the same config works on machines
# with different home directories or repo layouts.
- source: "claude-memory/${PROJECT}/"
target: "~/.claude/projects/${PROJECT_ENCODED}/memory/"
project_scoped: true
# Spec / superpower outputs Claude writes per project, kept
# NEXT TO the repo (not inside it).
- source: "specs/${PROJECT}/"
target: "${PROJECT_PARENT}/specs/${PROJECT}/"
project_scoped: true
Trailing slashes matter: source: "global/CLAUDE.md" is treated as a
file, source: "global/skills/" is treated as a directory. The target
must agree — a mismatch is rejected with skipped-target-type-mismatch.
Path placeholders
Use these in source and target. ${PROJECT_*} placeholders only
work when project_scoped: true.
| Placeholder | Resolves to |
|---|---|
${HOME} |
Current user’s home (target only) |
${PROJECT} |
<owner>--<repo> flat slug |
${PROJECT_ENCODED} |
Claude’s path encoding of the repo’s local_path |
${PROJECT_LOCAL} |
Absolute path of the repo’s local checkout |
${PROJECT_PARENT} |
Parent dir of ${PROJECT_LOCAL} |
${PROJECT_ENCODED} matches the way Claude Code itself encodes paths
into ~/.claude/projects/: /Users/foo/Projects/bar →
-Users-foo-Projects-bar. ctrlrelay computes it from each repo’s
local_path so the same config works across machines with different
home directories.
3. Run init
ctrlrelay personalization init
This:
- Clones
your-handle/dotclaudeinto~/.ctrlrelay/personalization. - Creates a per-machine working branch named
personalization/<node_id>(or fast-forwards to it if it already exists on the remote). - Walks the
pathslist and lays down symlinks. Adopt-flow is on by default: when a target like~/.claude/CLAUDE.mdalready exists as a real file but the corresponding source slot in the repo is empty, the existing target is moved into the repo and a symlink is wired in its place. Your content is preserved and immediately under sync.
Pass --no-adopt to opt out of adoption and keep the conservative
“refuse to touch any pre-existing real path” behavior:
ctrlrelay personalization init --no-adopt
With --no-adopt, pre-existing real targets surface as
skipped-real-file-at-target and you back them up + remove them
before re-running init.
After init, run push once so the adopted content reaches GitHub:
ctrlrelay personalization push -m "initial personalization import from <hostname>"
Day-to-day commands
personalization status
ctrlrelay personalization status
Prints the working branch, repo URL, ahead/behind counts vs. origin,
and the per-symlink state — correct, wrong-target, missing,
source-missing, etc. — without touching the filesystem.
personalization push
ctrlrelay personalization push -m "added a new skill"
Stages everything inside the allowlist (the entries declared in
paths), commits, rebases the per-machine branch onto origin/main,
and pushes. If origin/main advanced between your fetch and your
push, the FF is retried up to three times so concurrent edits from
your other machine don’t strand your commit.
--force-with-lease is used on the per-machine branch when the local
working branch has diverged from origin/<working_branch> (i.e. the
rebase rewrote commits the remote already had). Per-machine branches
are owned by exactly one node by design, so the lease almost always
passes; if a stray update slips in, the push fails safely instead of
clobbering it.
personalization pull
ctrlrelay personalization pull
Fetches, rebases your per-machine branch onto origin/main, fast-
forwards your local main if it’s a strict ancestor of the remote,
and re-wires symlinks (the config-as-code shipped in the repo may have
changed). Conflicts during the rebase abort cleanly and list the
unmerged files — resolve in the checkout, then re-run.
Auto-pull on cron
Set schedules.personalization_cron to converge machines without
manual sync:
schedules:
secops_cron: "0 6 * * *"
personalization_cron: "*/15 * * * *" # every 15 min
The poller registers an APScheduler job that runs personalization pull
on this schedule. Two safety rails:
- Skip-on-dirty. If the working tree has uncommitted changes, the
auto-pull skips with a
working tree dirtysummary. A daemon rebasing under your unsaved edits is exactly the surprise we don’t want. - No adoption. Adoption is an init-time concern. The auto-pull
re-wire phase runs with
adopt=Falseso a background sync never silently moves files. New entries inpathsthat have no remote source yet stay asskipped-source-missinguntil youinitagain.
Auto-push is intentionally not scheduled. A daemon committing on the operator’s behalf without explicit intent is the kind of thing that surprises people; commits stay manual.
Multi-machine bootstrap
On the second machine:
- Install ctrlrelay, copy your
orchestrator.yamlover (thepersonalization:block must be identical except fornode_idif you set it explicitly). ctrlrelay personalization init.- Done. The clone fast-forwards
personalization/<this-machine>fromorigin/main, lays the symlinks down on top of your existing~/.claude/(adopting where empty, refusing where both sides have real content), and you’re synced.
If the second machine already has a populated ~/.claude/CLAUDE.md
that differs from the one on the first machine, you’ll get
skipped-conflict-both-exist for that path. Pick a winner manually
(diff, copy the chosen content into the repo source, delete the
loser’s local), then re-run init.
Per-machine branches and the FF dance
Each machine commits to personalization/<node_id>. push rebases that
branch onto origin/main, then fast-forwards origin/main from the
per-machine branch. Two machines pushing concurrently:
A: main = X
A: personalization/a = X + a1
A: push -> rebases on X, ff main: main = X + a1
B: main = X (stale)
B: personalization/b = X + b1
B: push -> fetch sees main = X + a1
-> rebases b1 on top: main + b1
-> ff main: main = X + a1 + b1 (succeeds, retry not needed)
If both machines fetch at the exact same instant and both push, only
one wins the FF; the other retries up to three times. No force-push to
main is ever attempted.
Gotchas
Worktrees inside the personalization checkout
Don’t create git worktrees inside ~/.ctrlrelay/personalization. The
checkout is a single working copy of a single per-machine branch.
Worktrees would attach to the same .git and confuse status /
pull. ctrlrelay’s per-repo worktrees (paths.worktrees) are an
unrelated, project-scoped feature.
Editing through the symlink is editing the synced repo
A write to ~/.claude/CLAUDE.md is a write to
~/.ctrlrelay/personalization/global/CLAUDE.md. After editing, run
ctrlrelay personalization push. If you forget, the auto-pull’s
skip-on-dirty rail keeps things safe — the daemon won’t rebase under
your unsaved work — but your edit doesn’t reach other machines until
you push.
Allowlist enforcement
Only the entries declared in paths are staged on push. Random
files dropped into the checkout (a stray .DS_Store, a temp note)
are not committed. This is intentional: the personalization repo is
the operator’s, not Claude’s, and “everything that’s there”
is not the right granularity.
Conflict during auto-pull
If a scheduled auto-pull hits a rebase conflict (someone pushed an
edit to the same file from another machine while a local commit was
pending), the rebase aborts and the conflict files appear in the
poller log. Resolve manually with ctrlrelay personalization pull,
which surfaces the same files in your terminal.
Repo URL match is strict
init verifies the existing checkout’s origin URL is exactly
github.com:<your-handle>/<repo> before treating it as “ours” for
re-init. A mismatch refuses to proceed — back up or remove the
checkout before running init.
Privacy
The personalization repo holds your Claude memory, skills, and per- project notes. Treat it like any other operator-private store:
- Make the repo private.
gh repo create --private. - Don’t put secrets in
~/.claude/CLAUDE.mdthat you wouldn’t put in any other git repo. Auth tokens, API keys, etc. belong in environment variables, not in synced markdown. - ctrlrelay never reads, transforms, or transmits the contents itself
beyond
git push/git pullto the URL you configured. There is no telemetry on personalization.
Disabling
Remove the personalization: block from orchestrator.yaml and
restart the poller. Existing symlinks remain on disk pointing into
~/.ctrlrelay/personalization. To revert a target back to a real file,
copy through the symlink first (cp ~/.claude/CLAUDE.md /tmp/x &&
rm ~/.claude/CLAUDE.md && mv /tmp/x ~/.claude/CLAUDE.md). The
checkout itself can be deleted once nothing points into it.