notemd · 2026-06-20 · 11 min read
Letting Claude Desktop write into my vault (without it eating my edits)
So here's the thing I actually wanted this week: I store every article in notemd as a plain .md file in a vault folder (Obsidian-style — folders are the hierarchy, filenames are titles). Claude Desktop now has a file connector that can read and write files on disk. Put those two together and you get something I've wanted for ages: ask Claude to draft, restructure, or fact-check a note, have it write straight into the vault, and watch the change appear in notemd.
Before wiring anything up, I asked myself the only question that mattered: if Claude writes a .md file while notemd is open, does notemd notice?
I went and read my own sync code to find out. The answer was "no", and the deeper I read the worse it got.
What I found (a.k.a. auditing your own code is humbling, part 2)
notemd's vault is a hybrid: Core Data is the live working copy, the .md files are the durable copy, and ProjectArticleVaultService reconciles them in a function called performMerge. Three things fell out of the audit:
1. There was no file-watching at all. No FSEvents, no DispatchSource, no NSFilePresenter. A merge only ran in three situations: the app coming back to the foreground, a folder operation in the sidebar, or you saving in the editor. So if Claude wrote a file while I was looking at notemd, I'd see nothing until I ⌘-Tabbed away and back. There wasn't even a "Reload" button.
2. The open editor would silently overwrite external edits. This was the scary one. ArticleEditorViewModel loads article.bodyMarkdown into an in-memory document once, at init, and never re-reads it. So the sequence was: Claude edits Foo.md → a merge pulls Claude's text into Core Data → but the open editor still holds the old text → the editor's autosave writes the old text back to disk → Claude's edit is gone. For a "Claude and I both touch the vault" workflow, this is the whole ballgame.
3. Every merge re-read and re-indexed the entire vault. performMerge had no change detection. Each run read every .md file in full, re-imported every article, and re-indexed wiki-links for every article — whether or not anything changed. Fine when merges are rare. Catastrophic the moment you make them frequent, which is exactly what a live workflow needs.
So I couldn't just "add a file watcher." A watcher on top of #3 would re-read the whole vault on every keystroke Claude made. And a watcher on top of #2 would just deliver Claude's edits straight into the jaws of the clobber. The fixes had an order.
I ended up doing this in three layers, and the nice part is each one makes the next one safe.
Layer 1: make the merge cheap (so it can run often)
The goal: an idle merge should do zero file reads, zero Core Data writes, zero re-indexing. Only files that actually changed get touched.
The trick is a per-file fingerprint. notemd already keeps a little sidecar at .notemd/article-path-index.json mapping each file's relative path to its article's UUID. I bumped it to version 2 and made each entry carry the file's (mtime, size) alongside the id:
private struct VaultPathIndexEntry: Codable {
let id: String // lowercased uuid
let mtime: Double // contentModificationDate
let size: Int64
}v1 files still decode (the old path → uuid shape maps to a fingerprint with mtime/size = 0), which forces one full read the first time and then upgrades itself. No migration dance.
Then the scan gets a fast path. For every file it enumerates, it stats (mtime, size) and compares against the index. If they match, it emits a lightweight "unchanged" entry carrying only identity and path — no String(contentsOf:), no markdown parse:
if let prior = fingerprintsByRelativePath[relativePath],
prior.mtime == mtime, prior.size == size,
let priorID = UUID(uuidString: prior.id) {
// emit an .unchanged entry; skip the read + parse entirely
continue
}And performMerge leaves unchanged articles completely alone — no body re-import, no re-index, no Core Data mutation. Only .changedOrNew files go through the expensive path, and only they get added to the re-index list (this used to be "every article, every time").
This is the rsync heuristic, and it has the rsync caveat: if an external tool somehow rewrites a file to the exact same byte size and restores the original mtime, you'd miss it. That doesn't happen for real markdown edits, and the alternative — hashing — means reading every file, which is the thing I'm trying to avoid. I wrote the trade-off into a comment and moved on.
The accidental bonus
There's a lovely emergent property here. notemd writes the path-index fingerprint right after it writes a file. So when the app saves a note, the index already records that file's new (mtime, size). Which means when the file watcher (Layer 2) reports notemd's own write, the next merge computes a fingerprint that matches the index → unchanged → no-op.
The self-write feedback loop that file-watchers are famous for? Layer 1 dissolves it for free. I didn't plan that; I just noticed it while reasoning about Layer 2 and grinned.
I also gated the post-merge notification on "did anything actually change", and made it carry the set of changedArticleIDs — which Layer 3 needs.
Layer 3: teach the editor to reconcile instead of clobber
This is the data-loss fix, so I did it before turning on anything live.
The mental model is a three-way merge. At any moment the open article has three versions:
- base — what the editor loaded (the common ancestor)
- disk — what the merge just pulled in (
article.bodyMarkdown) - local — the editor's in-progress content
The decision is a pure function, which means it's trivially testable:
func resolveEditorExternalMerge(base: String, disk: String, local: String) -> EditorExternalMergeResolution {
if disk == base { return .noop } // our file didn't change
if local == base { return .fastForward } // no local edits → adopt disk
if local == disk { return .noop } // converged independently
return .conflict // both diverged → ask the human
}The editor subscribes to the merge notification, and if its article is in changedArticleIDs, it runs this. Fast-forward is the common Claude case: I'm reading a note, Claude rewrites it, the editor live-updates with no interaction and nothing lost. Conflict — both of us edited — raises a non-destructive banner ("Load disk version" / "Keep mine") instead of silently picking a winner. Autosave is suppressed entirely while a conflict is open, and "Keep mine" snapshots the disk version into history before overwriting it, so even the rejected side is recoverable.
One subtlety bit me here: notemd has two markdown spaces. There's editor-space (the block model ↔ markdown) and vault-space ([[wikilinks]] ↔ notemd:// links). If you compare a disk file to article.bodyMarkdown naively, they always differ, because of the link transform. The fix is that all three of base/disk/local are kept in the same space (article.bodyMarkdown-space — what the merge produces and what the editor exports), so the comparison is actually meaningful. Took me an embarrassing few minutes to see why my first conflict check thought everything was a conflict.
There's also a belt-and-suspenders mtime guard in the heavy autosave: if the file moved on disk since the editor loaded it, don't write — kick a merge and let the reconcile path sort it out.
Layer 2: actually watch the folder
Now that merges are cheap and the editor won't clobber, the watcher is safe to add.
I went with FSEvents, not a DispatchSource vnode watch. A vnode source watches a single file descriptor and doesn't recurse into subfolders — useless for a folder tree. FSEvents recurses, coalesces, and reports cross-process writes, which is exactly the "another app wrote my files" case. The wrapper lives in VaultFileWatcher.swift.
The filter is a pure function (the one part worth unit-testing — FSEvents itself is miserable to test deterministically):
func vaultWatchPathsAreRelevant(_ paths: [String]) -> Bool {
paths.contains { path in
!path.contains("/.notemd/") // ignore our own index/manifest/history
&& path.lowercased().hasSuffix(".md")
}
}The stream is created with FileEvents | NoDefer | IgnoreSelf | UseCFTypes and a 0.5s latency, then I debounce another 0.3s on top so a burst of Claude writing ten files collapses into one merge. So there are now three lines of defense against the self-write loop: IgnoreSelf, the .notemd/ path filter, and Layer 1's fingerprint absorption. Belt, suspenders, and a second belt.
A VaultWatchCoordinator owns the lifecycle. The sidebar starts it on appear, on project switch, and when the app becomes active (running a catch-up merge each time, for edits made while we weren't watching), and tears it down when the app backgrounds. For custom vault paths that live outside the sandbox, it holds the security-scoped bookmark open for the watch's lifetime.
How I built it: tests first, one layer at a time
I did the whole thing test-first, which paid off immediately. The very first test I wrote for Layer 1 was "a merge does not re-read an unchanged file, even if the in-memory article diverged" — and watching it fail against the old code (which clobbered the in-memory copy from disk) is what proved the test actually tested something. Then I made it pass.
The pure functions — resolveEditorExternalMerge and vaultWatchPathsAreRelevant — are a joy to test because they have no I/O, no Core Data, no async. The riskiest logic in the whole change is a dozen lines of branch-free code with exhaustive unit tests.
One process note for future-me: this repo's test suites have to run one at a time. Running two together spins up two in-memory Core Data stacks and you get a flood of Failed to find a unique match for an NSEntityDescription errors and spurious failures. Isolate the suite, and it's clean.
Did it work?
| Before | After | |
|---|---|---|
| External edit while app is open | invisible until ⌘-Tab | appears live (FSEvents → merge) |
| Editor + external edit, no local changes | could show stale text | live fast-forward, nothing lost |
| Editor + external edit, both changed | silently overwritten | conflict banner, disk version snapshotted |
| Idle merge cost | read + re-index entire vault | near-zero (stat-only, no reads) |
| Re-index scope per merge | every article | only changed articles |
| Self-write feedback loop | n/a (no watcher) | absorbed three ways over |
The end-to-end feel is the payoff: Claude writes a note into the vault, FSEvents fires, a cheap incremental merge imports just that one file, and if I'm looking at it the editor updates under my cursor. If we both touched it, I get asked instead of robbed.
Two stale tests I found along the way
While I was in here I noticed two tests that had been red on a clean checkout for a while — both, it turned out, test bugs rather than product bugs. I fixed them as a separate commit so they wouldn't muddy the sync diff.
manifestRecordsFormatVersionTwo wasn't even failing an assertion — it was crashing with a signal trap. It's declared @Test (Swift Testing runs those off the main actor) but wrapped its body in MainActor.assumeIsolated { }, which traps when you're not on the main actor. Its sibling tests sidestep this by being @MainActor @Test. One annotation and the crash is gone; the manifest code was correct the whole time.
mergeProjectVaultDeletesLocalArticleWhenVaultFileIsDeleted was more interesting, because the "failure" was actually the product doing the right thing. The test had a single note, deleted its only file, and expected the note to vanish. But the deletion safety net deliberately refuses to delete when the vault scan comes back empty — it can't tell "the user deleted everything" from "the external vault is momentarily unreadable", so it keeps your data. The test predated that guard. I rewrote it to use two notes and delete one file, which exercises a genuine single-note deletion without tripping the wipe-protection — and caught a nice gotcha while doing it: once the merge deletes a managed object, touching its .id crashes, so you have to capture the id before the merge runs.
Lesson restated for the hundredth time: a red test is a claim, not a verdict. Sometimes the claim is wrong.
Until next week — Andre