Building a Cross-Machine Memory System for Coding Agents
Claude Code keeps memory per project. You work in a repo for a few weeks, it accumulates a pile of notes about that repo, and those notes are genuinely useful. Then you open a different repo and you’re talking to an amnesiac again.
The first thing I noticed was that some of my memory wasn’t about the repo at
all. “Carl doesn’t want em-dashes in his writing” is not a fact about a build
system. Neither is “the Jira REST search endpoint was removed, use
/rest/api/3/search/jql.” Those got filed in whatever repo I happened to be
working in when I learned them, and then they were invisible everywhere else.
The standard answer here is CLAUDE.md. Put your rules in the file, the agent reads the file, done. That advice is everywhere, but it doesn’t scale. Every token in CLAUDE.md gets added to every session and you pay for those tokens, even just to ask a simple question. It also slows down your session. My electronics repair notes are 114KB. I am not paying for those while I debug a Terraform module.
The subtler problem is where it sits. CLAUDE.md is at the very front of the context window, and the front is the part that goes stale. The longer a session runs, the less pull the early tokens have. A rule at position zero of a 200k-token conversation is not enforcement, it’s a note the model skimmed hours ago, competing against 190k tokens of more recent and more specific material. I have watched an instruction in all caps at the top of CLAUDE.md get ignored at turn 300. It is still technically in context. It just doesn’t win any more.
So I started running claude from my home directory. Not to work on anything in
$HOME, just to get at the good memory. That’s a dumb hack and it worked well
enough to expose the real problem: my desktop’s brain and my work laptop’s brain
were two different brains, and neither one knew what the other had learned.
What I ended up building is a hierarchical, globally-scoped memory system that lives in a git repo, syncs itself across four machines, and loads in layers so it doesn’t eat my context window. It currently holds 694 memory files, about 3.4MB of text. The always-loaded part is 67KB of that.
What I was actually trying to get
- All of the memory available on all of my machines, all the time.
- Without loading 3.4MB into every session. Context is the budget.
- Zero thinking about it day to day. If I have to remember to sync, it’s broken.
- Stop running 8-hour sessions because I’m scared to
/clear. If everything important is written down, clearing is free. - Still be deliberate. Auto-saving every stray thought produces a landfill, not a memory.
- Share the rest of the setup too: hooks, skills, conventions, helper scripts.
- Someday, point a different agent at the same memory and have it just work.
That last one was not in the original requirements, but I realized I was becoming hopelessly dependent on Claude and I didn’t like it. Now, the best thing about the whole system is that it works with pretty much any agent, even opencode and open-weight models.
One private repo, synced by a daemon
I already had a private_notes repo, a private git repo I’d cloned onto most of
my machines over the years. It was full of scratch files and howtos. That’s the
substrate.
The sync is a single bash script with a systemd user service on Linux and a launchd agent on macOS. It commits everything, fetches, merges, pushes. My first version was a cron entry running every minute, which is the obvious implementation and is wrong the moment you have two machines going at once. A minute of latency is a minute where both sides are editing the same index file and neither knows it.
So watch mode is event-driven: inotifywait on Linux, fswatch on macOS, with a
3-second debounce to coalesce bursts. Local edits sync in about three seconds.
The part people miss is that file events only fire for local changes, so the
watcher also wakes on a 60-second idle timer. That idle wake is the only thing
that pulls in the other machine’s work.
Cron is still installed, but only as a backstop. Watch mode touches a heartbeat file every loop, and the cron tick exits immediately if the heartbeat is fresh, so the two never double up.
The other half of not-thinking-about-it is conflicts. Memory files are
append-heavy, and git has a built-in union merge driver that keeps both sides'
lines instead of writing conflict markers. In .gitattributes:
# Markdown and text are append-heavy; union keeps BOTH sides' lines when two
# machines diverge. Worst case is a duplicate line to tidy later, never a
# stuck merge and never lost data.
*.md merge=union
*.txt merge=union
# Scripts, JSON, etc. get NORMAL merge on purpose: if two machines genuinely
# diverge on one of those you DO want a real conflict, loudly.
*.enc binary
That distinction matters. I want silent auto-resolution on prose and a screaming
halt on code. When a real conflict does happen, the script aborts the merge so
the repo stays clean and usable, writes the reason to a gitignored
.sync-status file, and after three consecutive failures pushes one ntfy alert
to my phone. One alert, then it shuts up until it recovers.
The whole script is 430 lines, mostly guard rails: a lock so two runs can’t overlap, a bounded connectivity probe so an offline laptop fails in five seconds instead of hanging, a refusal to commit any file over 95MB (GitHub hard-rejects 100MB and a single fat file wedges the push for every machine), and a check that the vault passphrase never got staged. I’ll hand you the interesting parts at the bottom.
Hierarchy instead of project scope
Every memory is one file holding one fact, with a category: tag that’s a path:
---
name: memory-load-sections-and-fix-source
description: Load the relevant section_*.md BEFORE the first CLI call, and when a
sharp edge lives in something fixable, fix the source instead of documenting it.
category: claude-setup/memory
metadata:
type: feedback
---
Learned from a session where the jira-api wrapper's env-var requirement was
already recorded in an on-demand memory, but wasn't loaded before scripting
against Jira, so several calls got burned rediscovering it.
...
Categories nest arbitrarily and I don’t declare them up front. tools/rootly,
3d-printing/filament/asa, writeups/voice, work/people/managers,
electronics-repair/game-gear. When something new shows up it gets a new path
and that’s the whole ceremony.
The reason this beats project scope is that the scoping question changes. It stops being “which repo was I in when I learned this” and becomes “what is this about.” Those are different questions and only the second one has a stable answer. Half my useful memory isn’t about a repo at all.
The index gets too big, so tier it
Everything hangs off one line in ~/.claude/CLAUDE.md:
@~/projects/private_notes/claude_entrypoint.md
That file pulls in the conventions (output style, git safety, execution model) and the memory index. The index is one line per memory: title, link, and a hook that says what’s in the body. Bodies get read on demand.
At 694 memories the index alone hit 67KB. So the index got tiered too. The
highest-volume sections live in separate section_*.md files that are not
auto-loaded, and the index keeps a trigger block for each:
- **tools** -> `section_tools.md`
Load when: using or scripting against any CLI/API. git, gh, gcloud, jira,
Google Docs, ntfy, tmux, jq. Broad by design; when in doubt, load it.
Eight sections are broken out right now. The biggest is electronics repair at 114KB, which would otherwise be sitting in my context while I debug a Terraform module.
One thing I got wrong at first: I sized every index hook to a uniform length. Wrong axis. The right question is discovery risk. If a memory has an obvious keyword that any relevant task will contain (a hostname, a tool name, a ticker), a 60-character pointer is plenty because search will find it. If it’s a broad behavioral rule with no natural trigger word, the index is the only path to it and the detail has to live in the hook itself. Trimming one of those to a pointer is the expensive mistake.
The index is lossy, so: memory-grep
One-line hooks are a summary, and summaries drop things. If the hook’s wording doesn’t match how the task actually shows up, I re-derive something that’s already written down.
memory-grep is a shell script that full-text searches the global memory and the
current repo’s project memory in one shot. Terms are OR’d, case-insensitive, no
operators to remember.
It summarizes by default, and that behavior came from a specific failure. A broad
query used to dump everything: one search returned 2,014 lines, 1.64MB, call it
400k tokens. Nobody reads that, so the caller pipes it through head -80. Which
is exactly what happened, and the hit that mattered (“eBay browser automation is
dead, do not retry it”) started at line 499. An hour later I got advised to do
the known-dead thing.
So now a large result set collapses into per-file hit counts plus a few sample
lines, and it names every file that matched. Even a caller who truncates still
sees that queue.md had 40 hits. A missed file shows up as a number instead of
as absent output. The instructions say never pipe it through head, but I don’t
want to rely on that, which is the general theme of this next part.
Hooks, because written-down rules get missed anyway
AIs are shockingly knowledgeable toddlers. They might do what you ask. They might do it for about five minutes. If they listen at all they will forget later, and they will forget at the exact moment it mattered. Their behavior is not deterministic, and no amount of writing the rule down harder makes it deterministic.
Hooks are deterministic. Hooks are how you fix this.
This is the part I’d tell you to build second, right after sync.
I have a memory file about how I write. No em-dashes, no AI hedge sentences, no “I’d rather tell you this than have you find it,” which implies I considered lying and want credit for not doing it. That rule is in the always-loaded index. It says, in capital letters, to read the banned-phrase list before drafting.
It still ended up in a document headed for a promotion panel.
A second copy of an ignored instruction is not a fix. Arriving at the right
moment is. Claude Code has PreToolUse hooks: shell scripts that get the tool
call on stdin before it runs, and can either inject context or flat-out deny it.
I have 44 of them now. Two flavors:
Blocking. Exit 2, and stderr becomes the reason the model sees. I use these for things that are expensive to undo: pushing to a default branch, force-pushing, overwriting a shared Google Doc wholesale (which orphans every reviewer comment, ask me how I know), the known banned phrases.
Non-blocking. Emit hookSpecificOutput.additionalContext and exit 0. The
tool still runs and the text lands next to the call. This is how the Jira field
IDs and the “the old search endpoint is gone” note get in front of the model at
the exact moment it’s about to write a Jira script, instead of hoping it read the
index an hour ago.
The design detail that took me a couple of tries: gate on content, not on
session. My first voice reminder fired once per session on any tool call, which
in practice meant the session’s first git status, hours before any prose got
written. By drafting time it was scrollback. Now it fires when the tool call
actually contains prose.
And hooks have their own footguns, which are all in the comments of mine now:
they run with a thin PATH, so an unresolvable wc dies silently under set -e
and leaves a guard that looks healthy while checking nothing. And don’t write
printf ... | grep -q; under pipefail, grep exits on first match, printf takes
SIGPIPE, and the pipeline reports 141, so a successful match reads as a failure.
That one only bites on large inputs, which is precisely the case the guard exists
for.
A guard that has never fired is indistinguishable from a broken one. Force the fire path before you believe it.
Distributing the setup itself
Writing a hook on one machine doesn’t help the other three. So there’s a ledger,
machine-setup.md, with one entry per setup item:
### jira-context-reminder-hook
desc: PreToolUse hook injecting Jira field IDs / REST endpoints before any Jira call
install: copy claude_hooks/jira-context-reminder.sh to ~/.claude/hooks/ (chmod +x),
then add a PreToolUse entry to ~/.claude/settings.json with matcher "Bash|mcp__.*[Jj]ira.*"
(merge into the existing array without clobbering other entries)
installed: portal lasersword
skipped:
machine-setup-check runs at session start, compares hostname -s against those
lines, and prints anything not yet installed or skipped here. It prints nothing
when the machine is fully accounted for, so a clean machine is silent.
It never auto-installs. Per item I get offered install, delay, or skip-on-this-
machine, and the answer gets written back to the ledger. Note that install: is
sometimes a shell command and sometimes prose, because “merge this key into
settings.json without clobbering its siblings” is a judgment call, and there is no good way to
express it as a shell one-liner.
Same repo, same sync. I write a hook on the desktop, and the next time I open a session on the laptop it offers to install it.
Curation, or: don’t just save everything
The goal was never maximum recall. A memory store that eats every stray thought is a landfill you have to search instead of a brain you can use.
So there’s a /reflect skill. It scans the conversation, proposes durable
memories, routes each one to global or project scope, tags the global ones with a
category path, and reconciles my queue and active-work ledger in the same pass.
Two things about how it runs. It never fires automatically, and it never writes
anything without showing me the list first. I can run it whenever, but in
practice I run it right before a /clear, which is the natural moment to ask
what was worth keeping. Everything it produces is a proposal. These days I accept
80 to 90 percent of them.
The most valuable thing it does is reject candidates, and the rule it uses is my favorite thing in this whole system. If a proposed memory reads “remember to X,” that’s a signal to go fix whatever makes X necessary. A memory saying “remember the jira-api wrapper needs these two env vars” outlives the problem; making the wrapper read its own config file removes it. When the sharp edge is in something I control, patching the source beats documenting the scar.
claude_skills/reflect/SKILL.md
---
name: reflect
description: Scan the current conversation for durable, memory-worthy information and reconcile ledger files before a context reset. Run /reflect before /clear so insights survive into future sessions without bloating context. Routes each memory to global (cross-machine) or project (per-repo) scope and surfaces queue/idea/active-work updates.
triggers:
- reflect
- before clear
- harvest memory
- save context
- what should I remember
---
# Reflect — pre-context-reset memory harvest
Run this before `/clear` (or any context reset). It mines the current
conversation for things worth carrying into future sessions, proposes them
for approval, and on the user's OK writes them to the right memory scope
and reconciles the project ledger files.
The goal is **signal, not volume**. A few high-value memories beat a pile
of noise. When in doubt, leave it out — the whole point of this skill is to
let the user clear context freely *without* polluting future context with
junk.
## Step 1 — Scan the conversation
Review the entire current conversation. Look for three things.
### Memory candidates
Follow the four-type taxonomy and the "what NOT to save" rules defined in
the global CLAUDE.md auto-memory system. Do not restate those rules here;
defer to them. Briefly:
- **user** — role, preferences, expertise, working style learned this session
- **feedback** — corrections the user gave, OR non-obvious approaches they
explicitly validated. Capture the *why* and the *when-to-apply*.
- **project** — decisions, motivations, constraints, who/what/why/by-when
that are NOT derivable from code or git history
- **reference** — pointers to external systems/resources and their purpose
### Ledger candidates
Only if the project's auto-memory directory actually contains these files —
otherwise skip this section entirely:
- **active-work.md** — entries now stale or complete that should be cleared
- **queue.md** — work the user said they wanted done "later" that never got
queued
- **ideas.md** — ideas raised in passing but never captured to the stack
### Hook candidates (tool-triggered memories)
Whenever a memory's relevance is reliably signalled by a specific *tool use*
— hitting a particular API/CLI, editing a certain file, running a given
command — it's a candidate for a **PreToolUse hook** that surfaces the memory
at that exact moment instead of relying on the model to recall it. This is the
most robust fix for a memory that's easy to miss at the right time. Flag such
memories (new *or* already saved) and propose a hook alongside them. See "The
tool-triggered-memory hook pattern" below.
## The tool-triggered-memory hook pattern
When a memory should fire on a tool use, encode it as a PreToolUse hook:
- **Non-blocking:** emit
`{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"..."}}`
and exit 0 — the tool still runs and the text lands in the model's context. Do
NOT deny/block a legitimate call.
- **Once per session:** key a sentinel on the `session_id` from the hook's stdin
JSON (e.g. `${TMPDIR:-/tmp}/<slug>-$session_id`) so it never nags.
- **Detect precisely:** switch on `tool_name`, and for Bash grep
`tool_input.command`; exit 0 silently when it doesn't apply.
- **Put the facts IN `additionalContext`**, not just "go read X" — so the
reminder is load-bearing even if the file isn't opened.
- **Build strings with `printf`, not `cat`/heredoc** — a `$(cat <<EOF)` can block
on a live fd when run as a hook.
- **Persist portably:** script in `private_notes/claude_hooks/` (canonical),
installed to `~/.claude/hooks/`, registered in `~/.claude/settings.json`
(`hooks.PreToolUse`, merge don't clobber), plus a `machine-setup.md` item so
new machines pick it up.
Reference implementation: `private_notes/claude_hooks/jira-context-reminder.sh`.
## Step 2 — Classify scope, then dedupe
For each memory candidate, decide **scope**:
- **global** — true regardless of which repo the user is in: tool/hardware
facts, working-style preferences, cross-cutting feedback, references that
aren't repo-specific. Stored in the global memory dir
(`~/projects/private_notes/claude_memory/`).
- **project** — only meaningful in the current repo. Stored in the repo's
project auto-memory dir.
Assign each **global** memory a path-style `category:` tag (e.g.
`3d-printing/filament/asa`, `claude-setup/skills`, `work/deploys`).
Categories nest arbitrarily. Reuse an existing category path where one
fits; only coin a new one when nothing does.
Then dedupe. Read the relevant index (the global `MEMORY.md` and/or the
project `MEMORY.md`) and existing files. Dedupe runs in **two passes**:
**Candidate pass** — for each *new* candidate decide NEW, UPDATE to an
existing memory, or ALREADY CAPTURED (skip). Prefer updating over creating
a near-duplicate.
**Set-audit pass (FUSE)** — independent of any new candidate, scan the
existing memories in each touched scope/category for two-or-more that now
cover the same fact from different angles, and flag them to **FUSE**:
synthesize one better memory and retire the originals. This pass exists
because the candidate pass is candidate-driven — two redundant memories
saved in different sessions never collide with a single new candidate, so
without FUSE they accumulate forever. UPDATE keeps a memory *correct* and
holds the count flat; FUSE is the only operation that keeps the *set*
small. Be conservative: fuse only memories that are genuinely the same
knowledge, not merely adjacent, and remember FUSE deletes files — treat it
as higher-stakes than an in-place edit.
## Step 3 — Present for approval, then STOP
Show a numbered list of proposals grouped by scope (GLOBAL / PROJECT),
then by type. For each:
- Scope, and for global the category path
- Type + proposed title
- One-line summary of the body
- NEW, or UPDATE to `<file>`
For ledger changes, show the specific edits (entries to remove from
active-work.md, items to add to queue.md / ideas.md).
For hook proposals, show the trigger (tool + match), the memory it surfaces, and
that it would be a new PreToolUse hook (`claude_hooks/` + settings.json +
machine-setup entry).
For FUSE proposals, list explicitly which existing memories are being
**retired** (by file) and the single memory that replaces them, with its
final title/scope/category. Because FUSE deletes memories, surface those
removals distinctly — it is a materially higher-stakes confirmation than a
one-file UPDATE.
Then stop and wait. The user may approve all, approve a subset, edit
wording or scope/category, or reject items. Write nothing until they
respond. Keep this review conversational (free text) so the user can edit
individual items — do not force discrete-choice prompts.
## Step 4 — Write the approved items
For each accepted item:
- **Global**: write/update the body in
`~/projects/private_notes/claude_memory/` with frontmatter including
`category:` (the path tag) and `metadata.type`. Add/update the one-line
entry in that dir's `MEMORY.md`, under the matching
`## <top-level-category>` section — create the section if it's the first
entry for that prefix.
- **Project**: write/update the body in the current repo's project
auto-memory dir and update its `MEMORY.md`, per the global CLAUDE.md
memory format.
- For an approved **FUSE**: write the synthesized memory (either as a new
file or by rewriting one of the originals in place), then delete the
retired memory files and remove their one-line entries from the relevant
`MEMORY.md`. Do the write-plus-delete together — never leave an index
line pointing at a deleted file, or a fused-away file still on disk.
- Apply the approved ledger edits.
- For an approved **hook**: write the script to `private_notes/claude_hooks/`,
install it to `~/.claude/hooks/`, register it in `~/.claude/settings.json`
(merge into `hooks.PreToolUse`, don't clobber), and add a `machine-setup.md`
item. Test it (feed a sample stdin payload) before reporting done.
Report a concise summary of what was written and where. The user can then
`/clear`.
## Step 5 — Self-tune
If the user rejected proposals, look for a pattern. If a *kind* of proposal
keeps getting rejected across runs, save or update a **feedback** memory
(global, `category: claude-setup/reflect`) recording what not to propose in
future `/reflect` runs. This is how the skill learns the user's filtering
preferences over time.
## Notes
- This skill writes to memory, ledger, and hook/config files — all in
`private_notes` and `~/.claude/` (which auto-commit / aren't product repos),
so no worktree is needed. It does NOT touch any project git repo's code.
- Conservative by default. A rejected-but-real item can be re-proposed next
run; a noisy memory has to be hunted down and deleted later.
- This skill is user-invoked (`/reflect`). It is not triggered automatically.
- **Canonical copy** of this skill lives in
`~/projects/private_notes/claude_skills/reflect/`. Make fixes there first,
then carry them over to each machine's installed `~/.claude/skills/reflect/`
copy. Never treat the installed copy as the primary — edits there are lost
on the next copy-over.
The skill names a reference implementation, so here is that hook too. This is the real one off my work machine, with the board, project and custom-field IDs swapped for placeholders. Everything else is untouched, comments included.
claude_hooks/jira-context-reminder.sh
#!/usr/bin/env bash
#
# PreToolUse hook: once-per-session Jira context reminder.
#
# When a tool call is about to interact with work Jira -- a Bash command that
# mentions Atlassian/Jira in any form (atlassian, jira, jache, acli, a REST
# api/agile path, or an internal issue key like ABC-123), OR any Atlassian/Jira MCP
# tool -- inject a NON-BLOCKING reminder pointing at the canonical field-IDs /
# REST-endpoints memory. Detection is deliberately broad (bias to fire; it's
# once per session, so an unnecessary hit is cheap and a miss is not).
# Fires at most once per session (keyed on session_id) so it never nags.
#
# Non-blocking: emits hookSpecificOutput.additionalContext and exits 0, so the
# tool still runs and the text lands in the model's context next to the tool
# call. (Verified against code.claude.com/docs/en/hooks PreToolUse.)
#
# Canonical copy: private_notes/claude_hooks/. Installed to ~/.claude/hooks/ and
# registered in settings.json PreToolUse with matcher
# "Bash|mcp__.*[Aa]tlassian.*|mcp__.*[Jj]ira.*". See machine-setup.md -> jira-context-reminder-hook.
# SIGPIPE TRAP (2026-08-26): do NOT use `printf ... | grep -q` here. Under
# `set -o pipefail`, grep -q exits on the first match while printf is still
# writing, printf takes SIGPIPE, and the PIPELINE reports 141 -- so a successful
# match reads as failure. Only bites once the input is big enough that printf
# has not finished writing, which is exactly the large-input case these checks
# exist for. Use `grep -E ... >/dev/null 2>&1` so grep drains stdin.
# Hooks can run with a thin PATH. A missing external (jq/grep/wc/sed/awk/tr)
# dies silently under `set -e`, leaving the guard looking healthy while it
# checks nothing. Pin PATH explicitly.
export PATH=/usr/bin:/bin:/usr/sbin:/sbin:$PATH
set -uo pipefail
input=$(cat)
tool_name=$(printf '%s' "$input" | jq -r '.tool_name // ""')
session_id=$(printf '%s' "$input" | jq -r '.session_id // "nosession"')
command=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')
is_jira=0
case "$tool_name" in
# Any Atlassian/Jira MCP tool, whatever the server is named.
mcp__*[Aa]tlassian*|mcp__*[Jj]ira*) is_jira=1 ;;
Bash)
# Deliberately broad (bias to fire; once-per-session so an extra hit is cheap):
# bare atlassian/jira (case-insensitive) covers atlassian.net, jira-api,
# atlassian-cli, JIRA_BASE, etc.; plus jache, acli, the REST paths, and any
# internal issue-key reference (ABC-123, DEF-54, ...). Boundaries use
# explicit char classes (portable across BSD/GNU grep; no \b).
if printf '%s' "$command" | grep -iE 'atlassian|jira|(^|[^[:alnum:]-])jache([^[:alnum:]-]|$)|(^|[^[:alnum:]])acli([^[:alnum:]]|$)|/rest/(api|agile)/|(^|[^[:alnum:]])(ABC|DEF|GHI)-[0-9]' >/dev/null 2>&1; then
is_jira=1
fi
;;
esac
[ "$is_jira" = 1 ] || exit 0
# Once per session: sentinel keyed on session_id.
sentinel="${TMPDIR:-/tmp}/claude-jira-hint-${session_id}"
[ -e "$sentinel" ] && exit 0
: > "$sentinel" 2>/dev/null || true
# Build the reminder with printf (no heredoc/cat: those can block on a live fd
# when this runs as a hook). Single-quoted args -> no backtick/apostrophe traps.
msg=$(printf '%s\n' \
'Jira reminder (once per session): the authoritative board/field IDs and REST-search endpoints are in global memory claude_memory/jira_field_ids.md -- read it before scripting against Jira if you have not already this session. Key facts you likely need now:' \
'- <PROJECT> board NNNN, project <KEY> (id NNNNN); Story Points = customfield_NNNNN; Sprint = customfield_NNNNN.' \
'- REST search: the classic POST /rest/api/3/search is REMOVED. Use /rest/api/3/search/approximate-count for counts, and /rest/api/3/search/jql (paginate on nextPageToken) for enumeration.' \
'- Agile API (/rest/agile/1.0/*) works only via direct curl with ~/.atlassian-token (the jira-api wrapper 403s on it).' \
'- MCP contentFormat:"markdown" means MARKDOWN, not Jira wiki markup. "h2. Foo" renders as the literal string; use "## Foo". Ordered lists are "1." not "#". Bullets are "-" not "*". No {code} -- use backticks. The API accepts wiki markup silently, so CHECK THE RENDERED ISSUE (it bit us once).')
jq -n --arg ctx "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse", additionalContext:$ctx}}'
exit 0
That rule turned out to be recursive, which I did not see coming. Reflect now regularly proposes improvements to reflect. And to memory-grep. And to the rest of my toolchain. It will close out a session by pointing at the reason I keep re-learning something, identify it as a defect in the tool that was supposed to prevent it, and offer to go fix the tool instead of writing the note. That is the best behavior I have gotten out of any of this.
The real win from all this is one I didn’t predict: I /clear constantly now.
Sessions are short. I don’t hoard context. When something matters I run
/reflect and it’s in the repo thirty seconds later, on every machine.
The payoff: a brain transplant
This is the part I actually wanted to write about.
I set up opencode to test some open-weight models, mostly to see how far GLM had
come. I pointed its instructions array at the same files:
"instructions": [
"/home/cmyers/projects/private_notes/claude_entrypoint.md",
"/home/cmyers/projects/private_notes/claude_conventions/global.md",
"/home/cmyers/projects/private_notes/claude_conventions/execution.md",
"/home/cmyers/projects/private_notes/claude_conventions/ledgers.md",
"/home/cmyers/projects/private_notes/claude_memory/MEMORY.md"
]
And it just picked it up and went. It knew my conventions. It ran memory-grep
before answering. It knew which section file to load for a hardware question and
which one for a git question. A completely different vendor’s agent, running a
completely different model, behaving like the one I’d spent months training.
I replaced my secretary and transplanted the previous one’s brain into the new hire’s skull. Everything the old one had learned about how I work came along.
One gap: opencode has no hook system, so all 44 of my guards were inert. I wrote
one prompt asking for a plugin that reads ~/.claude/settings.json, translates
opencode’s tool calls into Claude’s tool_name / tool_input shape, runs the
same shell scripts, and honors the same contract (exit 2 blocks, additionalContext
gets appended to the tool result, anything else fails open). About 230 lines of
JavaScript. Worked on the first try. My git-push guard and my voice guard now fire
under both agents, from one copy of each script.
~/.config/opencode/plugins/claude-hooks-shim.js
// Claude Code hook shim for opencode.
//
// Claude Code enforces Carl's conventions via PreToolUse/PostToolUse shell hooks
// registered in ~/.claude/settings.json. opencode has no equivalent, so this
// plugin re-runs those same scripts with a synthesized Claude-shaped payload,
// translating opencode tool calls into the Claude tool-name/tool_input contract.
//
// Behavior per Claude Code:
// - exit 2 -> block, stderr is the reason
// - permissionDecision: "deny" -> block, permissionDecisionReason is the reason
// - additionalContext / systemMessage -> surfaced on the tool result (Claude
// injects it around the call; we append it)
// - anything else / plugin error -> allow (fail-open, matching opencode defaults)
//
// Not covered: hooks needing a Claude JSONL transcript (context-pressure-alert)
// have no transcript to read and exit cleanly. "ask" is treated as allow+notice
// because opencode's prompt flow is separate.
import { spawn } from "node:child_process"
import { existsSync, readFileSync, statSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
const TOOL_MAP = {
bash: "Bash",
edit: "Edit",
write: "Write",
patch: "Edit",
multiedit: "MultiEdit",
read: "Read",
webfetch: "WebFetch",
}
const HOOK_TIMEOUT_MS = 15000
function toClaudeInput(tool, args) {
const a = args || {}
switch (tool) {
case "Bash":
return { command: a.command ?? "" }
case "Edit":
return {
file_path: a.filePath ?? a.file_path ?? "",
old_string: a.oldString ?? a.old_string ?? "",
new_string: a.newString ?? a.new_string ?? "",
replace_all: a.replaceAll ?? a.replace_all ?? false,
}
case "Write":
return { file_path: a.filePath ?? a.file_path ?? "", content: a.content ?? "" }
case "Read":
return { file_path: a.filePath ?? a.file_path ?? "" }
case "WebFetch":
return { url: a.url ?? "", prompt: a.prompt ?? "" }
default:
return a
}
}
function settingsFiles(directory) {
const files = [
join(homedir(), ".claude", "settings.json"),
join(homedir(), ".claude", "settings.local.json"),
join(directory, ".claude", "settings.json"),
join(directory, ".claude", "settings.local.json"),
]
return files.filter((f) => existsSync(f))
}
const cache = new Map()
function loadHooks(directory) {
const files = settingsFiles(directory)
const stamp = files.map((f) => `${f}:${statSync(f).mtimeMs}`).join("|")
const cached = cache.get(directory)
if (cached && cached.stamp === stamp) return cached.hooks
const hooks = { PreToolUse: [], PostToolUse: [] }
for (const file of files) {
let cfg
try {
cfg = JSON.parse(readFileSync(file, "utf8"))
} catch {
continue
}
for (const event of ["PreToolUse", "PostToolUse"]) {
for (const group of cfg.hooks?.[event] ?? []) {
for (const h of group.hooks ?? []) {
if (h?.type === "command" && h.command) {
hooks[event].push({ matcher: group.matcher, command: h.command })
}
}
}
}
}
cache.set(directory, { stamp, hooks })
return hooks
}
function matches(matcher, tool) {
if (!matcher || matcher === "*") return true
try {
return new RegExp(`^(?:${matcher})$`).test(tool)
} catch {
return false
}
}
function runHook(command, payload, cwd) {
return new Promise((resolve) => {
let stdout = ""
let stderr = ""
let done = false
const finish = (code) => {
if (done) return
done = true
clearTimeout(timer)
resolve({ code: code ?? 0, stdout, stderr })
}
let child
try {
child = spawn("bash", ["-c", command], { cwd, env: process.env })
} catch (e) {
resolve({ code: 0, stdout: "", stderr: String(e) })
return
}
const timer = setTimeout(() => {
try {
child.kill("SIGKILL")
} catch {}
finish(0)
}, HOOK_TIMEOUT_MS)
child.stdout?.on("data", (d) => (stdout += d))
child.stderr?.on("data", (d) => (stderr += d))
child.on("error", () => finish(0))
child.on("close", (code) => finish(code))
child.stdin?.on("error", () => {})
try {
child.stdin.write(JSON.stringify(payload))
child.stdin.end()
} catch {}
})
}
function parse(res) {
let decision = null
let reason = ""
let context = ""
if (res.code === 2) {
decision = "deny"
reason = res.stderr.trim() || "blocked by Claude hook"
}
const text = res.stdout.trim()
if (text) {
try {
const j = JSON.parse(text)
const hso = j.hookSpecificOutput ?? {}
if (hso.permissionDecision) {
decision = hso.permissionDecision
reason = hso.permissionDecisionReason || reason
}
if (j.decision === "block") {
decision = "deny"
reason = j.reason || reason
}
if (hso.additionalContext) context = hso.additionalContext
if (j.systemMessage) context = context ? `${context}\n\n${j.systemMessage}` : j.systemMessage
} catch {}
}
return { decision, reason, context }
}
export const ClaudeHooksShim = async ({ directory }) => {
const pending = new Map()
return {
"tool.execute.before": async (input, output) => {
const tool = TOOL_MAP[input.tool]
if (!tool) return
const payload = {
session_id: input.sessionID,
cwd: directory,
hook_event_name: "PreToolUse",
tool_name: tool,
tool_input: toClaudeInput(tool, output.args),
}
const contexts = []
const denies = []
for (const h of loadHooks(directory).PreToolUse) {
if (!matches(h.matcher, tool)) continue
const { decision, reason, context } = parse(await runHook(h.command, payload, directory))
if (context) contexts.push(context)
if (decision === "deny") denies.push(reason)
}
if (contexts.length) pending.set(input.callID, contexts)
if (denies.length) throw new Error(`Blocked by Claude hook:\n\n${denies.join("\n\n")}`)
},
"tool.execute.after": async (input, output) => {
const tool = TOOL_MAP[input.tool]
const notes = []
const ctx = pending.get(input.callID)
if (ctx) {
pending.delete(input.callID)
notes.push(...ctx)
}
if (tool) {
const payload = {
session_id: input.sessionID,
cwd: directory,
hook_event_name: "PostToolUse",
tool_name: tool,
tool_input: toClaudeInput(tool, input.args),
tool_response: output.output,
}
for (const h of loadHooks(directory).PostToolUse) {
if (!matches(h.matcher, tool)) continue
const { context } = parse(await runHook(h.command, payload, directory))
if (context) notes.push(context)
}
}
if (notes.length) output.output += `\n\n[claude-hook notice]\n${notes.join("\n\n")}`
},
}
}
export default ClaudeHooksShim
One footgun if you try this. My global ~/.claude/CLAUDE.md is deliberately one
@-import and nothing else. Every third-party agent advertises “we read your
existing CLAUDE.md,” but plenty of them honor the file without resolving the
import chain, so they load a paragraph about machine drift and nothing else. The
agent then behaves like it has no conventions and you conclude the agent sucks,
when the actual fault is configuration. Check whether your tool inlines imports or
just points at the file. Better, skip the compatibility question entirely and give
it an explicit file list like the one above.
Build your own
The design is more valuable than my implementation, so here’s how to get it.
The fastest version: point your agent at this page and tell it to build what’s described here. The whole post is a specification, your agent can fetch a URL, and this is exactly the kind of work these things are good at. I expect that to work for most people, and there’s something appropriate about the setup being transmissible the same way everything else in it is.
If you’d rather take it in stages, or you want to understand each piece as it lands, which I’d recommend because you’re going to be living with it, here are the prompts in roughly the order I did them. Run them somewhere you can inspect the results before they touch anything.
1. Sync. Take my script rather than prompting for one. Mine did not arrive in
one generation. It grew over dozens of prompts, a couple of months, one problem
at a time: cron and the watcher doubling up on each other, an offline laptop
hanging instead of failing, a lock left behind by a run that died, a single
oversized file wedging the push for every machine, stat -c on Linux against
stat -f on macOS, a home-link blip failing a push that would have succeeded
three seconds later. Every guard in it exists because something broke first.
A from-scratch generation gives you a cron poll and none of that. So either take it as-is, or hand it to your agent as reference while it writes yours, so it can see which edge cases are already accounted for and why. The whole thing is below, collapsed. The shape is:
# Watch mode: event-driven, with an idle timer that is the ONLY thing
# that pulls in other machines' changes.
watch_mode() { # Linux branch; macOS uses fswatch
watch_tick # sync once up front
while true; do
inotifywait -qq -t "$WATCH_IDLE" -r -e modify,create,delete,move \
--exclude '(/\.git/|\.sync\.lock|\.sync-status|\.watch-heartbeat)' "$REPO"
rc=$?
[ "$rc" -eq 1 ] && { sleep 10; continue; } # 0=event 2=idle 1=error
[ "$rc" -eq 0 ] && sleep "$WATCH_DEBOUNCE" # coalesce bursts
watch_tick
done
}
sync_once() (
acquire_lock || exit 0 # mkdir-based; macOS has no flock
trap 'rm -rf "$LOCK" 2>/dev/null' EXIT
git_q add -A
git_q commit -q -m "update from $(hostname) on $(date '+%F %R %Z')" 2>/dev/null || true
online || { log_status OFFLINE "committed locally"; exit 0; }
git_q fetch -q origin || { log_status ERROR "fetch failed"; exit 0; }
if ! git_q merge -q -m "sync: merge origin/$BRANCH" "origin/$BRANCH"; then
conflicted="$(git_q diff --name-only --diff-filter=U | tr '\n' ' ')"
git_q merge --abort # leave the repo CLEAN
log_status CONFLICT "conflict on: $conflicted"
exit 1
fi
git_q push -q origin "$BRANCH" && log_status OK "synced"
)
bin/sync.sh
#!/usr/bin/env bash
# private_notes sync — commit local changes, merge remote, push.
#
# Cross-platform: Linux + macOS. Offline-safe: every network step is
# bounded by a timeout, so an offline machine (airplane, GitHub outage)
# fails fast and exits cleanly instead of leaving a hung process.
#
# Usage:
# sync.sh [--once] run one sync cycle now, unconditionally. Default.
# sync.sh --cron cron entry point: sync, but skip entirely if the
# watch service is already running (so cron and the
# watcher never double up commits / GitHub calls).
# sync.sh --watch long-running: sync on local file changes AND on a
# ~60s idle timer. The idle pull is what catches
# REMOTE changes from other machines.
# sync.sh --install decrypt the deploy key into ~/.ssh (if it's not
# there yet) + install/refresh a cron entry that
# runs --cron every minute.
# sync.sh --uninstall remove that cron entry.
# sync.sh --install-watch install + start a persistent watch-mode
# service (systemd user service on Linux, launchd
# agent on macOS) so --watch runs in the background
# and starts automatically.
# sync.sh --uninstall-watch remove that service.
# sync.sh --status print the last sync result.
#
# Conflict handling: append-heavy notes/memory files use `merge=union`
# (see .gitattributes) which auto-resolves the common case with no
# markers. A genuine conflict (two divergent edits, or a non-union file)
# is NOT papered over: the merge is aborted so the repo stays clean and
# usable, and the conflict is recorded in .sync-status. The entrypoint
# tells Claude to check .sync-status on load, so it surfaces next session.
set -u
# Cron runs with a minimal PATH; make sure git/curl/ssh/fswatch resolve
# whether they're system tools or Homebrew (Apple Silicon or Intel).
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
REPO="$HOME/projects/private_notes"
BRANCH="master"
SSH_KEY="$HOME/.ssh/private_notes_deploy" # repo-scoped deploy key
ENC_KEY="$REPO/private_notes_deploy.key.enc" # passphrase-encrypted copy, in-repo
LOCK="$REPO/.sync.lock" # mkdir-based lock — gitignored
STATUS="$REPO/.sync-status" # per-machine last-run status — gitignored
WATCH_DEBOUNCE=3 # seconds to coalesce rapid file events
WATCH_IDLE=60 # max seconds between syncs with no local activity
HEARTBEAT="$REPO/.watch-heartbeat" # watch-mode liveness beacon — gitignored
HEARTBEAT_STALE=$((WATCH_IDLE * 3)) # cron treats a watcher older than this as dead
STALE_LOCK_SECS=600 # a lock older than this is treated as stale
NOTIFY_STATE="$REPO/.sync-wedge-state" # per-machine wedge-alert state — gitignored
WEDGE_THRESHOLD=3 # consecutive ERROR/CONFLICT cycles before one ntfy alert
OVERSIZE_MB=95 # refuse to commit any file bigger than this (GitHub hard-rejects >100MB)
# SSH: use the deploy key if it's present. Always bound the connection so
# an offline machine or a mid-transfer network drop fails fast (~10s) rather
# than hanging. BatchMode=yes => never prompt (no TTY under cron).
SSH_OPTS="-o ConnectTimeout=10 -o ServerAliveInterval=5 -o ServerAliveCountMax=2 -o BatchMode=yes"
if [ -f "$SSH_KEY" ]; then
export GIT_SSH_COMMAND="ssh -i $SSH_KEY -o IdentitiesOnly=yes $SSH_OPTS"
else
export GIT_SSH_COMMAND="ssh $SSH_OPTS"
fi
git_q() { git -C "$REPO" "$@"; }
# Epoch mtime of $1, portable across GNU stat (Linux) and BSD stat (macOS).
# Note: `stat -f %m` (BSD) must NOT be tried on Linux — there `-f` means
# "filesystem status" and it dumps multi-line FS info to stdout instead of
# failing cleanly. GNU stat has --version; BSD stat does not, so that's the
# discriminator.
file_mtime() {
if stat --version >/dev/null 2>&1; then
stat -c %Y "$1" 2>/dev/null || echo 0 # GNU / Linux
else
stat -f %m "$1" 2>/dev/null || echo 0 # BSD / macOS
fi
}
oversized_files() {
# Files git would add (tracked or new, not gitignored) that exceed the cap.
# A single >100MB file is rejected by GitHub and wedges the push, so we refuse
# to commit it. Prints "path (NNMB) ..." or nothing.
git_q ls-files -z --cached --others --exclude-standard 2>/dev/null \
| while IFS= read -r -d '' f; do
p="$REPO/$f"; [ -f "$p" ] || continue
sz=$(wc -c < "$p" 2>/dev/null || echo 0)
[ "$sz" -gt $((OVERSIZE_MB * 1024 * 1024)) ] && printf '%s (%sMB) ' "$f" "$((sz / 1024 / 1024))"
done
}
# Alert Carl via ntfy ONCE when sync gets wedged (ERROR/CONFLICT sustained for
# >= WEDGE_THRESHOLD cycles), then stay quiet until it recovers. OK re-arms.
# Not on OFFLINE (a laptop on a plane is normal). ntfy only (SAY=0) — a
# background daemon shouldn't speak aloud.
maybe_notify_wedge() {
local state="$1" msg="$2" count=0 notified=0
[ -f "$NOTIFY_STATE" ] && read -r count notified < "$NOTIFY_STATE" 2>/dev/null
case "$state" in
OK) rm -f "$NOTIFY_STATE" 2>/dev/null ;; # recovered — re-arm
ERROR|CONFLICT)
count=$(( ${count:-0} + 1 ))
if [ "$count" -ge "$WEDGE_THRESHOLD" ] && [ "${notified:-0}" != "1" ]; then
local host ts
host="$(hostname -s 2>/dev/null || hostname)"
ts="$(date '+%Y-%m-%d %H:%M %Z')"
if SAY=0 "$REPO/bin/notify-carl" -t "private_notes sync wedged" \
"private notes sync is wedged on $host" \
"private_notes sync WEDGED on $host at $ts. $state: $msg. Local commits piling up unpushed until resolved." \
>/dev/null 2>&1; then
notified=1
fi
fi
printf '%s %s\n' "$count" "$notified" > "$NOTIFY_STATE" ;;
*) : ;; # OFFLINE etc — transient
esac
}
log_status() {
# $1 = state (OK|OFFLINE|CONFLICT|ERROR); remaining args = message
local state="$1"; shift
printf '%s | %s | %s | %s\n' \
"$(date '+%Y-%m-%d %H:%M:%S %Z')" "$(hostname)" "$state" "$*" > "$STATUS"
maybe_notify_wedge "$state" "$*"
}
online() {
# Fast, portable reachability probe. curl ships on macOS and ~every
# Linux; --max-time bounds it so an offline machine returns in 5s.
curl -sf --max-time 5 -o /dev/null https://github.com 2>/dev/null
}
acquire_lock() {
# mkdir is atomic on POSIX — a portable lock with no `flock` dependency
# (macOS has no flock). Returns 0 if acquired, 1 if another run holds it.
if mkdir "$LOCK" 2>/dev/null; then
return 0
fi
# Lock exists. If it's stale (a previous run died without cleanup), reclaim.
local mtime now age
mtime="$(file_mtime "$LOCK")"
now="$(date +%s)"
age=$(( now - mtime ))
if [ "$age" -gt "$STALE_LOCK_SECS" ]; then
rm -rf "$LOCK" 2>/dev/null
mkdir "$LOCK" 2>/dev/null && return 0
fi
return 1
}
# Run one full sync cycle. Body runs in a SUBSHELL — its `exit` and EXIT
# trap are scoped to this invocation, so --watch can call it in a loop
# without the first call exiting the whole script.
sync_once() (
acquire_lock || exit 0 # another run in progress — skip quietly
trap 'rm -rf "$LOCK" 2>/dev/null' EXIT
# 0. Guard: never commit an oversized file — one >100MB file is hard-rejected
# by GitHub and wedges the whole sync. Refuse to commit until it's moved
# offline or split (see bin/readme_sync.md). The ERROR fires the ntfy alert.
big="$(oversized_files)"
if [ -n "$big" ]; then
log_status ERROR "oversized file(s) blocked (>${OVERSIZE_MB}MB): ${big}-- move offline or split (bin/readme_sync.md); NOT committing until cleared"
exit 0
fi
# 0b. Guard: the vault passphrase must NEVER be committed. .gitignore covers
# it, but one `git add -f` or a lost ignore line would publish the key to
# every encrypted file in the repo, to a remote we cannot un-publish from.
# Same shape as the oversize guard above: refuse to commit, log ERROR
# (which fires the ntfy alert), and wait for a human.
if git_q ls-files --error-unmatch .vault-passphrase >/dev/null 2>&1; then
log_status ERROR "vault passphrase is TRACKED BY GIT-- run: git rm --cached .vault-passphrase (see bin/readme_vault.md); NOT committing until cleared"
exit 0
fi
# 1. Commit local changes (if any). After this the work tree is clean,
# so the merge below always merges into a clean tree.
git_q add -A
git_q commit -q -m "update from $(hostname) on $(date '+%Y-%m-%d %H:%M %Z')" 2>/dev/null || true
# 2. Offline? The local commit is safe; we'll push next time we have a
# network. Exit cleanly — no hung process on an airplane.
if ! online; then
log_status OFFLINE "committed locally; no network, will sync when online"
exit 0
fi
# 3. Fetch remote.
if ! git_q fetch -q origin 2>/dev/null; then
log_status ERROR "git fetch failed (network dropped mid-sync?)"
exit 0
fi
# 4. Merge. merge=union auto-resolves the append-heavy notes/memory
# files. A real conflict means two genuinely divergent edits (or a
# non-union file): abort so the repo is left CLEAN and usable, record
# it, and stop. Local work is preserved; autocommit keeps working;
# only the push is paused until a human resolves it. The next cron
# run will retry harmlessly and stay in CONFLICT until then.
if ! git_q merge -q -m "sync: merge origin/$BRANCH" "origin/$BRANCH" 2>/dev/null; then
local conflicted
conflicted="$(git_q diff --name-only --diff-filter=U 2>/dev/null | tr '\n' ' ')"
git_q merge --abort 2>/dev/null
log_status CONFLICT "conflict on: ${conflicted:-unknown}-- resolve with: cd $REPO && git merge origin/$BRANCH (fix markers, commit, then sync resumes)"
exit 1
fi
# 5. Push. Retry a few times with exponential backoff before giving up:
# corebox lives on a home link, so a push can drop for a few seconds
# and succeed on the next try. Without this, every such blip burns a
# cycle toward the wedge-alert threshold and ntfy-pings Carl for a
# hiccup that would have cleared on its own. A genuine outage still
# ends in ERROR after the attempts are exhausted.
local push_tries=3 push_delay=3 attempt=1
while :; do
if git_q push -q origin "$BRANCH" 2>/dev/null; then
log_status OK "synced"
exit 0
fi
[ "$attempt" -ge "$push_tries" ] && break
sleep "$push_delay"
attempt=$(( attempt + 1 ))
push_delay=$(( push_delay * 2 ))
done
log_status ERROR "git push failed after ${push_tries} tries (network dropped, or remote rejected)"
exit 0
)
# Cron entry point. Backstop role: if the watch service is alive (it
# refreshes $HEARTBEAT every loop) it is already syncing, so skip — this
# is what stops cron and the watcher from doubling up commits / GitHub
# calls. Only do real work when the watcher is absent or has died
# (missing or stale heartbeat).
cron_tick() {
if [ -f "$HEARTBEAT" ]; then
local hb now age
hb="$(file_mtime "$HEARTBEAT")"
now="$(date +%s)"
age=$(( now - hb ))
if [ "$age" -lt "$HEARTBEAT_STALE" ]; then
exit 0 # watch service is live and handling syncs — nothing to do
fi
fi
sync_once
}
# One watch-loop tick: refresh the liveness heartbeat, then sync.
watch_tick() {
touch "$HEARTBEAT" 2>/dev/null
sync_once || true
}
watch_mode() {
echo "private_notes sync — watch mode (Ctrl-C to stop)."
watch_tick # sync once up front
if command -v inotifywait >/dev/null 2>&1; then
echo "watcher: inotifywait (event-driven + ${WATCH_IDLE}s idle pull)"
while true; do
# Wake on a local file event OR after WATCH_IDLE seconds. The idle
# wake is what pulls REMOTE changes — file events only fire for
# LOCAL edits, so without it the watcher would never see another
# machine's push on its own.
inotifywait -qq -t "$WATCH_IDLE" -r -e modify,create,delete,move \
--exclude '(/\.git/|\.sync\.lock|\.sync-status|\.watch-heartbeat)' "$REPO"
rc=$?
if [ "$rc" -eq 1 ]; then # 0 = event, 2 = idle timeout, 1 = error
echo "inotifywait error — retrying in 10s" >&2; sleep 10; continue
fi
[ "$rc" -eq 0 ] && sleep "$WATCH_DEBOUNCE" # real event: coalesce bursts
watch_tick
done
elif command -v fswatch >/dev/null 2>&1; then
echo "watcher: fswatch (event-driven + ${WATCH_IDLE}s idle pull)"
fswatch -o -r --exclude '/\.git/' --exclude '\.sync\.lock' \
--exclude '\.sync-status' --exclude '\.watch-heartbeat' "$REPO" \
| while true; do
# read times out after WATCH_IDLE with no event (rc > 128) — that
# idle wake pulls remote changes. rc 0 = event; rc 1 = the fswatch
# pipe closed (fswatch died) so exit and let the service restart.
read -r -t "$WATCH_IDLE" _; rc=$?
if [ "$rc" -gt 128 ]; then : # idle timeout: just sync
elif [ "$rc" -ne 0 ]; then
echo "fswatch pipe closed — exiting (service will restart)" >&2; break
else sleep "$WATCH_DEBOUNCE"; fi # real event: coalesce bursts
watch_tick
done
else
echo "No file-watcher installed (inotify-tools on Linux, fswatch on macOS)."
echo "Falling back to a ${WATCH_IDLE}s poll loop."
while true; do sleep "$WATCH_IDLE"; watch_tick; done
fi
}
install_key() {
# The cron job (--once) uses a plaintext key at $SSH_KEY — unattended
# cron can't type a passphrase. This function only seeds that plaintext
# key on a fresh machine, by decrypting the in-repo copy. The passphrase
# protects the key in the repo / in git history, not the installed copy.
if [ -f "$SSH_KEY" ]; then
echo "SSH key already present at $SSH_KEY — leaving it untouched."
return 0
fi
if [ ! -f "$ENC_KEY" ]; then
echo "No key at $SSH_KEY and no encrypted copy at $ENC_KEY."
echo "sync.sh will fall back to your default ssh config. Place the key"
echo "manually, or create $ENC_KEY, then re-run --install."
return 0
fi
echo "Decrypting deploy key from $ENC_KEY (enter the passphrase) ..."
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
if openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 \
-in "$ENC_KEY" -out "$SSH_KEY"; then
chmod 600 "$SSH_KEY"
echo "Installed deploy key to $SSH_KEY"
else
rm -f "$SSH_KEY" # never leave a half-written / wrong-passphrase file
echo "ERROR: decryption failed (wrong passphrase?). Key NOT installed." >&2
echo "sync.sh will fall back to default ssh config until this is fixed." >&2
fi
}
install_cron() {
local self="$REPO/bin/sync.sh"
local entry="*/1 * * * * $self --cron >/dev/null 2>&1"
# Idempotent: strip any prior line mentioning this script, then append.
local current
current="$(crontab -l 2>/dev/null | grep -v -F "$self" || true)"
printf '%s\n%s\n' "$current" "$entry" | grep -v '^[[:space:]]*$' | crontab -
echo "Installed cron entry (runs every minute; defers to a live watcher):"
echo " $entry"
echo "Verify with: crontab -l"
}
uninstall_cron() {
local self="$REPO/bin/sync.sh"
crontab -l 2>/dev/null | grep -v -F "$self" | grep -v '^[[:space:]]*$' | crontab - 2>/dev/null || true
echo "Removed any cron entries for $self"
}
install_watch() {
# Install a persistent watch-mode service for this platform so
# `sync.sh --watch` runs in the background and starts automatically.
local plat tmpl dest sh="$REPO/bin/sync.sh"
plat="$(uname -s)"
case "$plat" in
Linux)
tmpl="$REPO/bin/private-notes-sync.service"
dest="$HOME/.config/systemd/user/private-notes-sync.service"
[ -f "$tmpl" ] || { echo "missing unit template: $tmpl" >&2; return 1; }
mkdir -p "$(dirname "$dest")"
sed "s|__SYNC_SH__|$sh|g" "$tmpl" > "$dest"
systemctl --user daemon-reload
systemctl --user enable private-notes-sync.service
systemctl --user restart private-notes-sync.service # restart so re-runs take effect
echo "Installed + started systemd user service: private-notes-sync.service"
echo " status: systemctl --user status private-notes-sync.service"
echo " logs: journalctl --user -u private-notes-sync.service -f"
echo " note: 'loginctl enable-linger $(id -un)' makes it run even"
echo " when you are not logged in."
if ! command -v inotifywait >/dev/null 2>&1; then
echo ""
echo " !! inotify-tools is NOT installed -- watch mode is running in"
echo " !! 60s-poll fallback, NOT event-driven. To get event-driven:"
echo " !! sudo apt install inotify-tools"
echo " !! $REPO/bin/sync.sh --install-watch # re-run to restart"
fi
;;
Darwin)
tmpl="$REPO/bin/private-notes-sync.plist"
dest="$HOME/Library/LaunchAgents/com.cmyers.private-notes-sync.plist"
[ -f "$tmpl" ] || { echo "missing unit template: $tmpl" >&2; return 1; }
mkdir -p "$(dirname "$dest")"
sed "s|__SYNC_SH__|$sh|g" "$tmpl" > "$dest"
launchctl unload "$dest" 2>/dev/null # reload cleanly if already loaded
launchctl load "$dest"
echo "Installed + loaded launchd agent: com.cmyers.private-notes-sync"
echo " status: launchctl list | grep private-notes-sync"
echo " logs: tail -f /tmp/private-notes-sync.log"
if ! command -v fswatch >/dev/null 2>&1; then
echo ""
echo " !! fswatch is NOT installed -- watch mode is running in"
echo " !! 60s-poll fallback, NOT event-driven. To get event-driven:"
echo " !! brew install fswatch"
echo " !! $REPO/bin/sync.sh --install-watch # re-run to restart"
fi
;;
*)
echo "unsupported platform '$plat' -- watch service is Linux/macOS only." >&2
return 1
;;
esac
}
uninstall_watch() {
local plat
plat="$(uname -s)"
case "$plat" in
Linux)
systemctl --user disable --now private-notes-sync.service 2>/dev/null || true
rm -f "$HOME/.config/systemd/user/private-notes-sync.service"
systemctl --user daemon-reload 2>/dev/null || true
echo "Removed systemd user service: private-notes-sync.service"
;;
Darwin)
local dest="$HOME/Library/LaunchAgents/com.cmyers.private-notes-sync.plist"
launchctl unload "$dest" 2>/dev/null || true
rm -f "$dest"
echo "Removed launchd agent: com.cmyers.private-notes-sync"
;;
*)
echo "unsupported platform '$plat'." >&2; return 1 ;;
esac
}
show_status() {
if [ -f "$STATUS" ]; then cat "$STATUS"; else echo "no sync has run yet"; fi
}
case "${1:---once}" in
--once|"") sync_once ;;
--cron) cron_tick ;;
--watch) watch_mode ;;
--install) install_key; install_cron ;;
--uninstall) uninstall_cron ;;
--install-watch) install_watch ;;
--uninstall-watch) uninstall_watch ;;
--status) show_status ;;
*) echo "usage: sync.sh [--once|--cron|--watch|--install|--uninstall|--install-watch|--uninstall-watch|--status]" >&2; exit 2 ;;
esac
That excerpt is the Linux path. The real script picks fswatch on macOS and
falls back to a 60-second poll if neither watcher is installed, because one of my
four machines is a work MacBook and the entire point is that they all behave the
same. The portability tax is small but it is real: stat -c on GNU versus
stat -f on BSD, and a mkdir-based lock because macOS has no flock.
Then give the agent this:
Wrap this into a service. On Linux install a systemd user unit, on macOS a launchd agent. Also install a cron entry running every minute as a backstop, and have the cron path exit immediately if the watcher’s heartbeat file is under 180 seconds old so the two never double up. Everything that touches the network gets a timeout so an offline laptop fails in five seconds instead of hanging. Refuse to commit any file over 95MB. Write the last result to a gitignored
.sync-statusfile, and after three consecutive failures send one push notification, then stay quiet until it recovers.
2. The memory layout.
I want a globally-scoped memory system in
~/notesinstead of per-project memory. Each memory is one markdown file holding one fact, with frontmatter:name,description,metadata.type(user/feedback/project/reference), and a path-stylecategory:tag liketools/dockerorhobbies/woodworking. WriteMEMORY.mdas an always-loaded index with one line per memory: type, title, link, and a hook describing what’s in the body. Size each hook to how discoverable the memory is by keyword search, not to a fixed length. A broad behavioral rule with no natural search term needs its detail in the hook itself; a narrow fact with an obvious keyword needs a pointer. Then writeentrypoint.mdthat@-imports the index, and put a single@-import ofentrypoint.mdin my global CLAUDE.md so there is exactly one wiring point.
3. Tiering, once it’s too big.
MEMORY.mdis now over 60KB and it’s always loaded. Find the highest-volume top-level sections, move each to its ownsection_<name>.md, and replace it in the index with a trigger block saying when to load that file. Write the triggers as concrete situations, not topic labels, and tell the reader to bias toward loading: over-loading costs a few tokens, a missed memory costs a wrong answer.
4. Search.
Write
memory-grep: full-text search across the global memory directory and the current repo’s project memory in one command. Terms are OR’d and case-insensitive so I can just list words. If the result set is large, collapse it to per-file hit counts plus the three most term-dense sample lines from the top few files, and truncate any line over 220 chars. The summary must name EVERY file that matched with its count, so a caller who truncates still sees that a file had 40 hits. Put it on PATH and add a line to the entrypoint telling the agent to run it before starting substantive work rather than trusting the index hooks.
5. A hook. Start with one non-blocking reminder so you can see the mechanism work. Here’s the skeleton; the comments are the lessons, keep them.
#!/usr/bin/env bash
# PreToolUse: inject context before a tool call that matches. Non-blocking.
#
# Hooks can run with a thin PATH. A missing external (jq/grep) dies silently
# under `set -e` and leaves a guard that looks healthy while checking nothing.
export PATH=/usr/bin:/bin:/usr/sbin:/sbin:$PATH
#
# Do NOT use `printf ... | grep -q` here: under pipefail, grep exits on first
# match, printf takes SIGPIPE, and the PIPELINE reports 141, so a successful
# match reads as a failure. Only bites on large input, which is the case this
# exists for. Use `grep -E ... >/dev/null` so grep drains stdin.
set -uo pipefail
input=$(cat)
tool=$(printf '%s' "$input" | jq -r '.tool_name // ""')
command=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')
session=$(printf '%s' "$input" | jq -r '.session_id // "nosession"')
[ "$tool" = "Bash" ] || exit 0
printf '%s' "$command" | grep -iE 'terraform|tofu' >/dev/null 2>&1 || exit 0
# Once per session, keyed on session_id, so it never nags.
sentinel="${TMPDIR:-/tmp}/tf-hint-${session}"
[ -e "$sentinel" ] && exit 0
: > "$sentinel"
msg='Terraform reminder: state lives in <bucket>, always run plan against the
workspace named in the branch, never apply from a dirty tree.'
jq -n --arg ctx "$msg" \
'{hookSpecificOutput:{hookEventName:"PreToolUse", additionalContext:$ctx}}'
exit 0
Register it in ~/.claude/settings.json under hooks.PreToolUse with a
matcher of Bash. To make one blocking instead, exit 2 and put the reason on
stderr. Gate on content rather than on session for anything that should fire at a
specific moment; a session-gated reminder fires on your first git status and is
scrollback by the time it matters.
6. Distributing the setup.
Write
machine-setup.md, a ledger where each item is a### <id>heading withdesc:,install:,installed:andskipped:lines, the last two holding space-separatedhostname -svalues.install:may be a shell command to run verbatim or a prose instruction to perform with judgment. Writemachine-setup-checkto print items not installed or skipped on this host, and nothing at all when the machine is fully accounted for. Add a line to the entrypoint telling the agent to run it at session start, never auto-install, and offer install / delay / skip per item, writing the answer back to the ledger.
7. Curation.
Write a
/reflectskill. Before a context reset it scans the conversation for durable facts, proposes each as a memory file with frontmatter and a category path, routes it to global or project scope, and shows me the list for approval before writing anything. It should reject candidates aggressively: anything the code or git history already records, anything that only matters to this conversation, and anything phrased as “remember to X,” which is a signal to go fix whatever makes X necessary instead.
8. Portability, when you want it.
opencode has no hook system. Write a plugin for it that reads the PreToolUse and PostToolUse entries from
~/.claude/settings.json, maps opencode’s tool names and argument shapes onto Claude’stool_name/tool_inputcontract, runs each matching script with that JSON on stdin, and honors the Claude contract: exit 2 blocks the call with stderr as the reason, apermissionDecisionof “deny” blocks with its reason,additionalContextgets appended to the tool result, anything else allows. Fail open on a plugin error and put a timeout on each hook.
Then point its instructions array at your entrypoint, conventions, and index,
and verify it’s actually loading them rather than assuming the CLAUDE.md fallback
resolved your imports.
That’s the system. A few months in, the thing I’d defend hardest is the boring part: it’s a git repo full of markdown, synced by a shell script. Nothing about it is specific to a vendor, and that’s why the transplant worked.