agentm rescope topology - alexherrero/agentm GitHub Wiki

AgentM Rescope โ€” Storage Topology & Crickets Seam

Where the truth lives

The vault moves off its current Google-Drive-synced mount onto local disk, inside a private git repository. This is the direct consequence of principle 2 (agentm-rescope-principles.md) โ€” files are truth, git is undo โ€” and it was the first of three decisions only the operator could make, because it relocates the canonical home of everything FRIDAY will ever be told, for years forward.

The condition attached to that decision: Android Obsidian has to keep working. The resolution is that the phone never runs git. The transport is Google Drive rather than Syncthing (decided at cutover, 2026-08-10): ~/Vault is a Drive for Desktop "My Computer" synced folder, and DriveSync on the phone keeps a local folder in step with it, so the phone sees and edits plain files and Obsidian on Android notices nothing about the transport underneath it. The daemon (below) is the only thing that ever runs git commit.

Keeping git out of a cloud syncer's hands takes one piece of indirection, because Drive for Desktop offers only top-level folder selection โ€” no pattern matching, no .driveignore, no way to exclude a subfolder. The repository is therefore created with git init --separate-git-dir, which leaves a 40-byte .git pointer file in the vault and puts the object database at ~/vault-git/vault.git, outside the sync set entirely. Drive syncs one small text file and never sees an object. This was verified against the daemon's own go-git before adoption: PlainOpen follows the pointer, and Head, Dirty and attributed Commit all work unchanged.

The cost this transport carries, accepted knowingly: Drive rewriting mtimes remains possible, so the pre-cutover corpus's capture dates stay approximate; Drive conflict files keep the crickets obsidian-vault apparatus alive rather than retiring it; and the vault's contents continue to sit unencrypted in Google's hands, which is the standing exposure the remote policy below would otherwise have ended. A pure-git transport โ€” the NAS hosting the repository, reachable through a house tunnel, with the phone pulling it via a git client โ€” is the intended successor and is queued as an unhomed backlog item.

The validation spike ran before anything moved and passed on all four counts: bidirectional sync proven in both directions, conflict behaviour on a genuine two-device divergence (both versions preserved, the loser deterministically renamed โ€” the same guarantee Syncthing gives), latency, and background reliability. One wrinkle worth recording: DriveSync cannot browse Drive's Computers section at all, and the way through is to star the folder, which makes it reachable from the app's Starred view.

Backup and multi-machine access fall out of this for free once it's git, and the concrete shape is decided (2026-08-08): the Mac holds the working repository, a repository on the primary Unraid NAS receives a push every four hours, and the backup NAS's existing monthly cold mirror carries a third, air-gapped copy. That third copy was confirmed on 2026-09-30. The backup NAS pulls an rsync mirror of the primary's whole data share and cache pool, so the old bare repository, the git server's nightly dump and its live data are all inside it. It is a mirror, not a history: a deletion on the primary carries over at the next run. That matters less for a git repository, because its history travels inside it. Tailscale covers a push from outside the house when wanted, inside the homelab's minimal-attack-surface lock. All three copies live in the house by choice; the amendment log records the trade-off.

The NAS leg landed differently than assumed, twice. The first version reached a bare repository over the existing SMB mount as a plain filesystem remote, with no git server on the NAS. A push was ordinary file writes, so it was verified rather than trusted, because a network filesystem gives no locking guarantees. It failed silently. The daemon commits but never pushes. The push was a separate launchd job, and macOS won't let a background job read inside a network volume without Full Disk Access, so the job never completed a push on its own. The NAS copy fell weeks behind between pushes made by hand.

Since 2026-09-27 the NAS runs Gitea in a container, and the NAS copy is a private repository on it. The push is git over SSH, using a deploy key that can write to that one repository only, into a branch protected against force-push and deletion. The job needs neither the mount nor a Full Disk Access grant. It is still verified rather than trusted: git fsck --full --strict ran on the server after the first push, and every push compares the remote ref with the one it sent. The git server's own data is dumped nightly into the NAS's backups folder. The old SMB bare repository was retired on 2026-10-01, after its only ref was confirmed to be in the git server's copy.

An external cloud remote is a different question, and it is settled by the vault's most sensitive content rather than by convenience. The moment the operator's own spaces joined this repository, Personal/ put scanned documents and financial records inside it; a standing email ingest would add distilled facts about other people on top of that. There is no path-level escape, either โ€” memory shards by capture date, so a note about a person and a note about SQLite land in the same directory and no .gitignore can tell them apart. The policy is therefore whole-vault: a bare remote on the home network, and no external cloud remote unless the repository is encrypted at rest before it leaves the house. The encrypted-off-site option was weighed against this policy on 2026-08-08 and declined โ€” the in-house copies are redundancy enough for the operator's tastes, so the policy stands unexercised rather than repealed.

What FRIDAY is allowed to overhear

The second operator decision: ambient mining of everything, plus everything explicitly handed to it through capture and ingest โ€” the phone's Capture project, the ingest pipeline, anything dropped in on purpose. Nothing is off by default.

This changes what the capture pipeline has to be. The current system's 4,933-item inbox landfill was not caused by ambient listening being on โ€” it was caused by the thing doing the listening being a regex pattern-matcher. Of that pile, 3,028 entries are LOW-confidence fragments a pattern-matcher staged and then nothing ever triaged. Keeping ambient mining on while fixing quality means the distiller has to become a judgment call, not a pattern match: sessions get summarized into well-formed candidate memories by an actual model call, not scraped by string matching.

Because the producer stays high-volume by the operator's own choice, the loud-queue mechanism below is not an optional nicety here โ€” it's the thing standing between "ambient capture" and "landfill, again, under a new name."

Capture is instant; filing is not

Two operations, cleanly separated, because collapsing them is what caused both of the previous system's failures โ€” the version that filed synchronously blocked capture on network availability, and the version that filed nothing left 82% of what it captured invisible to recall.

Capture is one daemon transaction: write the file, upsert it into the FTS5 index, done. No model call, no network dependency, works with the laptop offline. This is what makes capture ambient-safe at any volume โ€” the mechanism that makes something exist and findable never waits on judgment.

Filing โ€” which space something belongs in, what it links to, whether it's a duplicate โ€” is async, and is where the claude -p distillation from the previous section runs. It can take minutes or hours without anything being lost, because principle 3's round-trip (save it, ask for it, get it back) is already satisfied the moment capture finishes. Filing lag is a staleness cost, not a loss.

What prevents the filing queue from becoming the old inbox: it is loud, in three parts.

  1. Two numbers on the status surface at all times: how many items are waiting to be filed, and the age of the oldest one. Both are a query rather than a folder โ€” there is no inbox, so "how many are waiting" is a SELECT over frontmatter status.
  2. Red thresholds that page. Past a queue-age or queue-size threshold, the daemon emails through the operator's own relay (plugins.autonomy.email_to + email_smtp_url), at most once a day for the same set of conditions and again when a different one goes red. The age threshold is three days and does the work; the size backstop is a thousand and exists only for the case age cannot see, a producer that wrote thousands of fresh items at once. A queue does not silently reach five thousand items; it reaches a threshold and says so.
  3. A daily self-probe. The daemon captures a synthetic test item over its own MCP surface, asks for it back in words the note's prose does not contain, confirms the round trip inside its expected time, and alerts if it fails. The probe note is marked synthetic in frontmatter โ€” probe: self-probe, read by anything that must not count it in a measurement โ€” rather than identified by where it sits, because capture shards by date and any path rule would quietly stop matching. This is principle 3 running as a live process against itself, every day, not just as a one-time experiment.

The four spaces

FRIDAY's memory and FRIDAY's projects are ID-stable: a space field and a status field in frontmatter, no directory that has to match. Reorganizing FRIDAY's own filing is a metadata edit, never a file move, so nothing that links to a FRIDAY-owned note can ever go stale by virtue of FRIDAY tidying up after itself.

The operator's projects and personal files are real directories, named and laid out the way he wants, because he browses them โ€” in Obsidian, in a file browser โ€” and a generated or virtual view of a person's own files is a database's abstraction wearing a filesystem's clothes. What he sees has to be what's actually there.

All four spaces live in one git repository, not four. The reason is the promotion door below: moving a file across the boundary, rewriting the links that pointed at its old location, and recording why, all have to land as one atomic, revertible commit. That's a same-repo operation.

The layout, decided jointly and grounded in the filing the operator already keeps:

Obsidian/                        โ† vault root = git repo root
โ”œโ”€โ”€ index.md                     โ€” OKF entry point
โ”œโ”€โ”€ Filing.md                    โ€” the declared address space
โ”œโ”€โ”€ Ideas.md
โ”‚
โ”œโ”€โ”€ Agent/                       โ€” spaces 1+2 ยท readable anytime, writable never
โ”‚   โ”œโ”€โ”€ memory/                  โ€” space 1 ยท ID-stable ยท OKF frontmatter
โ”‚   โ””โ”€โ”€ desk/                    โ€” space 2 ยท everything in flight
โ”‚       โ”œโ”€โ”€ projects/<slug>/     โ€” plans, roadmaps, progress, drafts
โ”‚       โ””โ”€โ”€ briefs/              โ€” daily digests
โ”‚
โ”œโ”€โ”€ Projects/                    โ€” space 3 ยท promoted artifacts only
โ”œโ”€โ”€ Calendar/                    โ€” daily notes: brief window and capture inbox
โ””โ”€โ”€ Personal/                    โ€” Church ยท Home ยท Other ยท Tech

The repository root is the whole Obsidian vault rather than a subdirectory of it. Agent/ sits as a peer to the operator's existing top-level folders, and wikilinks resolve across all of them, so a link-integrity check after a promotion has to be able to see every space. Scoping the repo to Agent/ alone would let the door pass its check while silently breaking a link from the operator's side.

Nothing exists in Projects/, Calendar/, or Personal/ that FRIDAY put there without asking. Either the operator made it, or it came through the door. That makes the promotion door the single write path into the operator's half of the vault โ€” one mechanism to build, one to audit, one to revert โ€” and turns principle 5 from a policy FRIDAY has to remember into a structural fact about the layout.

A project's working life happens entirely in Agent/desk/projects/<slug>/ โ€” every plan, every revision, every draft. Projects/<slug>/ stays empty until something is worth keeping, and fills only at junctures: a design goes final, a deliverable ships. The cost accepted on purpose is that one project lives in two places. What it buys is a Projects/ tree that records what mattered instead of mirroring everything in flight.

Personal/ is the operator's existing filing, moved one level down and otherwise untouched: life-area domains, Title Case names, a resources/ folder beside each category, _Archive/ local to the area that needs one. It is close enough to PARA to be described as one โ€” Projects, Areas, Archives, arrived at independently over years โ€” with Resources folded into Areas rather than kept as a fifth top-level folder.

Three things this layout adds to what the operator already had:

  1. Calendar/ โ€” the time axis his filing never covered. FRIDAY's daily brief is transcluded into the day's note by date, so it renders live without any file crossing into the operator's space, and the same note below it is where quick capture lands. Raw briefs live and die in Agent/desk/briefs/; one promotes only if it turns out to be worth keeping.
  2. Filing.md โ€” a declared, finite list of legal destinations that the door validates against and refuses to file outside of. Adding an area is an edit to this file, not a mkdir. It is operator-edit-only: FRIDAY proposes changes in conversation and never applies them, because an agent that can widen its own address space does not have one.
  3. OKF frontmatter for Agent/memory/, plus index.md as the entry point at the root and in each desk project. The convention is convergent with what the vault already does, and it makes the memory portable across agents.

Archival differs by side, deliberately. FRIDAY's spaces have no _Archive/ at all โ€” a superseded memory gets a status change and sinks in ranking, staying exactly where it is, because spaces 1โ€“2 are ID-stable and files there never move. Dreaming ages memory out; hard deletion stays the rare reviewed operation. Desk scratch is a different thing from memory: a consumed dream-staging batch is exhaust, and the daemon may plainly delete it, since git makes that recoverable. The operator's spaces keep _Archive/, local to whichever area needs one, and archiving there is a browsing operation rather than a preservation one โ€” git is the real archive, so a file moves to declutter a view, never to keep it safe.

Two operational consequences follow. Agent/ is excluded from phone sync, because desk churn and memory writes buy nothing on a phone and capture still works through the daily note, which syncs and gets picked up by the laptop's daemon. And when the operator moves a file himself in a file browser, the daemon repairs the inbound links and commits the repair with attribution โ€” Obsidian only rewrites links for moves made inside Obsidian, so without this the link-integrity guarantee would cover FRIDAY's half of the vault and not the operator's.

Two items are deferred on purpose. Projects/ and Personal/Home/Projects/ share a name while meaning opposite things โ€” one a promoted-only destination, the other a folder the operator scribbles in freely โ€” and he will migrate that by hand rather than let the cutover decide it. The internal organization of memory/ and desk/ is its own session.

The promotion door

The door is the only way anything reaches the operator's spaces, and it knows three verbs: place, when a file crosses for the first time; update, when a file already on the operator's side changes; and retire, when one moves into an _Archive/. All three run the same daemon-executed machinery, never a plain mv โ€” write the file at its agreed destination, rewrite every inbound link that pointed at the old path, append a provenance note recording where it came from and when, carry the note's resources/ attachments across with it so its images don't break, run a link-integrity check across the vault, and refuse to land if that check comes back red. All of it lands as one commit.

Review scales with how autonomous the write was, rather than applying uniformly. When the operator is in the conversation and approves the change, he is the review โ€” a diff and a commit, nothing further. An unattended write, whether from dreaming or scheduled filing or anything else he is not watching happen, gets the second model's review principle 5 describes, the same gate covering any other destructive operation. What separates the two is who was watching, not how large the change was: a typo fix requested live and a typo fix made at 3am are different operations even when the diff is identical.

update earns its place because the common case is not a file arriving. It is the operator asking FRIDAY to fix something already sitting in his space, and a door that could only place files would have to answer that by copying out to desk, editing there, and promoting back over the original โ€” ceremony for a typo, with him sitting right there having just asked for it.

The daemon

One resident process, written in Go. The case for Go over Rust rests entirely on not needing to run a model inside the binary: modernc.org/sqlite gives pure-Go SQLite with FTS5 built in, no cgo; a static binary cross-compiles for any machine on the home network with CGO_ENABLED=0; the standard library covers HTTP and the dashboard with //go:embed; go-git is mature. Rust's one structural advantage โ€” in-process ML inference via candle โ€” only matters if the daemon runs a model directly, and it doesn't.

The week-1 experiment ran on 2026-08-06 and its rule came back FTS5-only, so v0 ships no vector sidecar and the daemon stays a single process with no model anywhere near it. The shape stands recorded for the one case that would reopen it โ€” a driver weaker than Opus calling memory_search, a small local model above all, which is where lexical-only retrieval degrades. That case arrived on 2026-08-12 โ€” the prompt-submit hook is a driver weaker than any model, and the goldv2 scorecard measured lexical-only at 10.9% R@5 on that path โ€” and the shape held exactly as recorded: small GGUF models served by llama.cpp, run as child processes the daemon spawns, health-checks, and restarts, reachable over localhost, reported as part of the daemon's own status. Supervised children, never a second independently-managed resident process, and principle 4 written into the process tree rather than remembered. agentm-hybrid-retrieval.md carries the full design.

Adding it later stays cheap by construction, which is what makes shipping without it safe. The driver talks to the daemon over memory_search, so swapping drivers is a client-side change with no daemon work at all, and the sidecar would be additive โ€” a child process plus a fusion step, with FTS5 unchanged underneath. The one genuinely one-way cost is backfill: embeddings for the existing corpus have to be computed once before search can fuse them.

The daemon exposes exactly two MCP tools to any connected client: memory_search and memory_capture. Sessions call memory_search and iterate โ€” the week-1 experiment's whole premise is that an agent searching a warm index in a loop beats a single blind lookup โ€” instead of receiving a fixed dump at session start. The current ~80KB always-load broadcast shrinks to a pointer a session can follow if it wants to, not a wall of text every session pays for whether it's relevant or not.

Three index details come out of week 1's miss lists rather than taste. The tokenizer is FTS5's porter stemmer, so morphological variants stop counting as vocabulary misses. Ranking is column-weighted โ€” title, aliases, and tags above body โ€” and applies the designed status-and-shape penalty: miner fragments and unfiled items sort below every non-penalized match without disappearing, which attacks the junk competition that held paraphrase P@5 to 0.14. The penalty's exact form is validated by re-running the gold set on the old stack before it's baked into Go. And memory_search takes optional after:/before: bounds on capture date, because episodic questions โ€” the second-weakest stratum โ€” are time questions the driver couldn't previously express.

The daemon commits continuously to a repository it shares with the operator's CLI and with scheduled jobs, so it follows git's own lock protocol rather than assuming it is alone. Three rules keep that commit path from stopping in silence. When go-git can't find an object CLI git can read, the daemon repairs the repository itself and retries. It removes no lock but its own, and only once the pid that lock names is dead. And the vault repository's own config hands all packing to the daemon, which repacks once a day in the one form go-git reads. A run of failed commit cycles shows on every status surface and pages after half an hour. A git worktree never belongs in the vault. Drive would upload it, and the config key the desktop app sets for one once stopped the daemon committing. A machine-wide hook refuses the tool calls that would make one there. The daemon reads past that key, and its status surface and the doctor name a worktree that got in the same day. The amendment log has the incident behind each rule.

Existing to retire on cutover: the orphaned FastMCP daemon on port 7821, and the agentmemory MCP entry that currently resolves to the stock filesystem server pointed at the vault's parent directory โ€” a coincidence of naming, not a relationship, and it goes away when the real daemon takes that name.

The 8,030 existing files

Index everything, immediately, in place. The index is a cache โ€” reading every existing file into FTS5 costs nothing structurally and satisfies "nothing is invisible" on day one, landfill included. Migrate nothing as a separate step; there is no big-bang move.

The 4,933-item inbox specifically: don't exclude it from search, and don't run it through an LLM triage project either โ€” a month of judgment tokens for a one-time cleanup that produces no lasting capability. Instead, rank-penalize it. Items with status: promoted, status: superseded, or status: expired, and unreviewed LOW-confidence fragments, sort lower in search results without disappearing from them. The old system's actual sin was exclusion, not the pile's existence โ€” don't repeat the sin while cleaning up its evidence. Draining the pile for real is dreaming's job, on dreaming's own schedule, once dreaming exists.

The crickets seam

Crickets depends on agentm through one consolidated file, agentm_bridge.py โ€” six verbs, 25 call sites across the repo โ€” and that bridge was already built to survive agentm's absence gracefully: every verb has a documented skip exit code, and the bridge's own contract states it plainly โ€” never an error, never a hang. That design choice, made before this rescope existed, is most of what makes this transition safe to do incrementally.

Three verbs die because the thing they query no longer exists: capability (the capability/tier resolver โ€” gone under principle 1), workflow-persona (personas โ€” gone under principle 1), and phase-dispatch (orchestration nudges โ€” gone under principle 1). Three verbs simplify to near-nothing: process-seam state-path becomes a fixed rule โ€” plan and progress files live at Agent/desk/projects/<slug>/, no backend selection required; repo-registry list becomes a JSON file read, the file itself moving into the vault repo; governing-design becomes a glob over wiki/designs/, which never needed agentm-specific machinery in the first place.

The obsidian-vault crickets plugin โ€” vault-doctor, the conflict-merger session-start hook, vault_conflicts.py โ€” stays, against this document's original intent. It was to be retired outright on the reasoning that it manages a failure mode, Google-Drive sync conflicts, that a local-disk-plus-git store doesn't have. The cutover kept Drive as the transport, so the failure mode is still live and the plugin is still load-bearing. Git improves the situation without removing it: a conflict copy is now visible in a diff and revertible rather than merely present. This retirement moves to the pure-git-transport backlog item that supersedes the Drive transport. check-cross-repo-script-parity and check-hook-parity retire alongside it for the same reason principle 1 kills them on the agentm side: they guard vendored copies, and one-copy-of-everything removes the copies they were guarding.

Two things worth noticing rather than just accepting as migration cost. Project phase state โ€” Agent/desk/, space 2 โ€” gets indexed by the daemon exactly like everything else, so "what did we decide in the crickets v3 plan" becomes an ordinary memory query instead of a separate lookup path; the harness state stops being a parallel filing system and folds into memory itself. And the direction of context flow inverts: today hooks push context at sessions whether they need it or not; tomorrow a session pulls what it needs by calling memory_search. That's an edit to a handful of crickets command files โ€” add a sentence asking them to search memory before drafting โ€” not new code. The pull instruction should also teach the search pattern week 1 measured the absence of: try lexically diverse phrasings before concluding, don't stop on the first plausible hit, and answer "nothing found" only after distinct vocabularies fail โ€” the two drivers split exactly along that behavior, persistent-but-credulous against calibrated-but-quick, and the pattern is a sentence, not a system.

Crickets' own simplification, beyond what dies automatically here, is out of scope for this document and follows as its own arc once the daemon has earned its two weeks of live use.

The compatibility bar for this seam: a /work session, with the daemon running, can resolve its plan's path, save a project memory through memory_capture, and a fresh session started afterward can recall that memory through memory_search. That's principle 3's round trip, applied to crickets specifically, and it's the only test this seam needs to pass.

Build sequence

Week 1 โ€” done 2026-08-06. Ran the retrieval experiment (agentm-rescope-week1-experiment.md) on the existing Python stack, no daemon code. Delivered the 60-question gold set, four scorecards, and the sidecar call: FTS5-only. Arm A scored R@5 0.725 under Opus, the driver in use.

Weeks 2โ€“3 โ€” done 2026-08-08. Daemon v0 shipped in Go, in daemon/: it watches the vault, maintains one FTS5 index file (deletable, rebuildable), serves memory_search and memory_capture over MCP, and captures in a single transaction per the section above. A cold index of all 8,864 files takes 2.6 seconds off the current mount; an unchanged pass takes 39ms. The orphaned port-7821 daemon is retired โ€” its launchd job stopped, its plist archived. No llama.cpp sidecar, per week 1's rule. Reference: wiki/reference/Memory-Daemon.md.

Three things the build learned that this document had assumed otherwise, each corrected in the body above: the vault is not a git repository yet, so commit attribution is built and tested but reports itself degraded until the transport migration runs; almost no note in the existing corpus carries a captured field, so the temporal bounds are mtime-derived for everything the daemon did not write itself; and the filesystem notifier is an accelerator rather than the mechanism โ€” a periodic reconcile pass is what makes the index's correctness independent of it.

Week 4 โ€” done 2026-08-09. The daily self-probe, the two queue numbers and their red thresholds, email alerting on the existing notifier config, and the corpus-write gate. agentmd status reports the queue, index freshness, git state, and the last probe result, and exits non-zero when any of them is red. The probe captures a marked synthetic note over the daemon's own MCP surface once a day, asks for it back by two words its prose does not contain, and records the result where status reads it. Reference: wiki/reference/Memory-Daemon.md.

The gate, which replaces an ordering sentence with a precondition. agentmd gate corpus-write fails while git is degraded, and no corpus-wide write job โ€” migration, backfill, reclassification, dreaming's future drain โ€” may start unless it passes. The job asks; a person does not remember. It refuses on two conditions, both of which mean the same thing: there is no repository, or the worktree already carries uncommitted changes, so undoing the job and undoing whatever else is in flight would be one command. On a pass it hands back the commit the job would be reverted to. It fails closed โ€” a gate that cannot decide refuses, and there is no override flag, because what is being checked is whether an undo exists at all. Every corpus-wide write job in the repo asks it: alias_backfill.py's run and reapply, recall.py heat-policy --apply, sweep_junk_preferences.py --apply, and all three migrate_arcs.py subcommands. Dry runs do not, since they write nothing, and revert deliberately does not, because gating the undo on there being an undo is the one arrangement that could strand the corpus.

This is what the earlier ordering sentence โ€” git-transport lands before dreaming's first corpus-wide pass โ€” was reaching for. The alias backfill ran 1,930 edits with a homemade revert journal as its only undo, and the missing repository was the binding constraint twice in one session. Dreaming's inbox drain is larger, and it now cannot repeat that arrangement rather than being asked not to.

Three threshold decisions are worth stating where the numbers live, and all three are the same decision: an alert that is always red is an alert nobody reads.

Degraded git is reported on every surface and blocks the gate, but it does not page โ€” the vault is not a repository until the migration runs, and a daily email about a deferred migration is how an alert channel teaches its reader to delete it unread. The queue counts only unfiled and inbox; superseded and expired are rank-penalized for a different reason and are not waiting on anything, so counting them would put a note retired in 2020 at the head of the queue. And the daemon records a queue baseline on its first run, because the first status read against the real vault was 4,349 unfiled items with the oldest 29 days old โ€” the landfill this document already decided to rank-penalize rather than drain. Items captured before the baseline are reported on every surface, with their own count and their own age, and are not paged about; the thresholds read what accumulated since. There is a second reason and it is the stronger one: almost nothing in the existing corpus carries a captured field, so those ages come from filesystem mtime, which the sync client can and evidently did rewrite โ€” the age threshold is applied where the number it reads is trustworthy. A four-day-old item captured after the baseline pages even with thousands of inherited items behind it, which is what keeps the split from being a mute button.

Then two weeks of just living in it before anything else is allowed to start โ€” dreaming, self-improvement, the rest of the dashboard, the promotion door's mechanism (its policy โ€” FRIDAY doesn't write into the operator's spaces โ€” is already in effect from day one; the door's tooling waits), and any crickets simplification beyond the seam above. The git-transport migration and its validation spike came out of this deferred list and ran on 2026-08-10; so did the interactive recall hook's move onto the daemon, which the window could not wait out because the hook was returning nothing at all. The amendment log carries what each found. The Python stack freezes for new feature work starting now and keeps running as today's system until the daemon replaces it.

Related

  • agentm-rescope-principles.md โ€” the five principles this topology exists to satisfy, each with the failure it was paid for by.
  • agentm-rescope-memory.md โ€” what happens inside Agent/: the memory layout, the two lifecycle classes, capture doctrine, dreaming's job list, and the autonomy dial the promotion door's review rule is one instance of.
  • agentm-rescope-week1-experiment.md โ€” the experiment gating the vector-sidecar decision referenced above.

Amendment log

  • 2026-10-07 ยท a git worktree is kept out of the vault, and the daemon survives the key one leaves. On 2026-10-02 a background-task chip opened from a session in projects/pixelton made a worktree inside the vault. The desktop app creates a chip's worktree under the session's cwd and sets extensions.worktreeConfig. Drive uploaded the worktree (6,276 events), and the key, left behind when the worktree was removed by hand, stopped the daemon committing at its restart on 2026-10-03 (#859). Three calls follow (agentm task 189). Refuse: a machine-wide PreToolUse hook refuses a chip, EnterWorktree, worktree-isolated subagents and Bash worktree add whenever the target is the vault's repository or a path under its root, resolved through vault_path(). Worktrees anywhere else pass. Why not the WorktreeCreate hook: it replaces worktree creation for every repository and has no matcher, so it would sit in the path of every agentm and crickets worktree. Tolerate: the daemon's storer hides extensions.worktreeConfig from go-git, whose v5.19.2 refuses it through two bugs of its own, and leaves the key on disk. Why not unset the key: it belongs to the app and the operator, and a live worktree still relies on it. Detect: the doctor, agentmd status and the morning note name a worktree in the vault the same day. The daemon prunes the record of a worktree gone for three months, which is git's own gc expiry. Why not prune at once: the record holds the worktree's HEAD, and a directory that is missing may only be unmounted. Some routes reach no hook: a desktop session opened on a vault folder with the worktree option, claude --worktree, and a scheduled task's worktree toggle. Detection covers those. Re-audit triggers: go-git learning worktreeConfig (the storer's hiding can then go); the app creating chip worktrees through WorktreeCreate (a hook could then refuse the creation itself); or a vault worktree that the doctor names, which means a route was missed.

  • 2026-10-04 ยท the committer heals around packs its git library can't read, takes back its own dead lock, and owns the repository's packing. The daemon stopped committing for eleven hours, from 00:32 to 11:30. git's loose-objects maintenance (the writer is still unknown) had left objects in a loose-<hash>.pack. go-git v5.19.2 lists only pack-* packs, and once a repack after the backup run pruned those objects' loose copies, they existed for every git client except the daemon. The restart meant to recover it then killed a daemon mid-commit, and its index.lock stalled the successor. Three calls follow (agentm task 188). Heal: on a missing object, the daemon reopens the repository. If a non-pack- pack is present, it first runs a lossless git repack -a -d --keep-unreachable under index.lock, with a distinct-object count before and after. It then retries once. Why not rename the pack to pack-: the name is git's to give, and a renamed pack is a state no git tool produced. Why not fork go-git: a replace fork is a dependency the operator then maintains. Own the packing: the repository's config sets gc.auto=0 and maintenance.auto=false, and the daemon repacks once a day. Why not change the backup job: any git client can trigger automatic maintenance, and the repository's config covers them all at once. Loud within the hour: consecutive failed commit cycles are counted in agentmd status, failed by the doctor, and paged as commit-stalled after 30 minutes. The 2026-08-11 rule "a lock is never stolen" narrows by one case. A lock whose content is exactly the daemon's own held by agentmd (pid N), naming a pid that isn't running and isn't the daemon itself, is removed and logged. A lock with any other content, or a live pid, is still waited out and refused. Why the exception is safe: only a killed daemon writes that content, so no human has anything to look at. Re-audit trigger: a go-git release that lists any *.pack, or that re-reads its pack list, makes the heal a fallback rather than the fix, so re-examine it then. A second commit-stalled page with a new cause means the heal's trigger is too narrow.

  • 2026-10-01 ยท the SMB fallback is retired. Its only ref was already in the git server's repository, so the vault's SMB remote was removed and the bare repository deleted. The body's "frozen fallback" sentence is updated to match. Re-audit trigger: none; restore now comes from the git server's nightly dump (tested 2026-10-01) or the Mac's own repository.

  • 2026-09-30 ยท the mirror-scope TO CONFIRM resolves positive. The backup NAS's monthly job pulls an rsync mirror (with deletions) of the primary's whole data share and cache pool over SMB, so every NAS-side copy of the vault rides on it. This retires the 2026-08-08 entry's trigger, "the mirror-scope TO CONFIRM resolving negative". Two weaknesses of that leg are recorded in the homelab notes rather than fixed here. It keeps no history of its own. And nothing records afterwards whether a month's run succeeded, which is the same silent-failure shape the old push had. Re-audit trigger: the homelab's backup-automation work changing the mirror's tool, scope or cadence.

  • 2026-09-28 ยท the NAS leg moves to a git server, after the SMB push failed silently. The first re-audit trigger in the 2026-08-08 entry fired. The push to the SMB bare repository never ran on its own, because macOS won't let a background launchd job read inside a network volume. For weeks the only pushes were made by hand, and nothing said so. The NAS now runs Gitea, and the push is git over SSH to a private repository, with a deploy key for that one repository and a protected branch. The body's backup paragraphs are reconciled. The cadence is every four hours, not daily, because that is how often the job runs. Why not the alternative (grant the job Full Disk Access and keep the SMB remote): that widens a standing permission for one job and keeps a transport with no locking guarantees, while a git server on the NAS was wanted anyway as the house's one git host. Still open: last-push-age was never built as one of the loud numbers, so a dead push would again be silent, and the push job itself lives outside any repository. Re-audit triggers: the job's log showing no verified push for a day while the Mac is on the LAN; the mirror-scope TO CONFIRM resolving negative; or the git server's nightly dump failing.

  • 2026-08-12 ยท the sidecar conditional is exercised; the daemon grows supervised model children. The week-1 rule's re-open condition โ€” a driver weaker than Opus โ€” turned out to be the recall hook itself, measured at 10.9% R@5 single-shot on the goldv2 corpus. The body's conditional paragraph is reconciled to the arrival; the design lives in agentm-hybrid-retrieval.md. What did not change: the daemon stays pure Go, CGO_ENABLED=0, one binary, one launchd unit โ€” inference lives in llama.cpp children precisely so those properties survive, and so a model crash degrades retrieval visibly instead of taking down the committer and the gate.

  • 2026-08-11 ยท the daemon speaks git's concurrency protocol, so sharing the repository with the operator's CLI stops being a race. go-git neither honors index.lock nor writes the index atomically โ€” it rewrites the file in place from whatever it last read โ€” and during the rehoming pass that clobbered CLI-staged deletions twice: the daemon read the index, git rm staged, the daemon wrote its stale read back. Fixed at the vcs layer in two moves that are both git's own protocol rather than an invented one. Every index-mutating operation (Commit, SweepDeletions) now holds .git/index.lock across its whole read-modify-write span, which extends mutual exclusion to every git client on the machine instead of only the daemon's own goroutines; and the index write itself goes through a temp file and a rename, so a concurrent reader sees the old index or the new one and never a torn one. Why the payload does not travel in index.lock the way C-git's does: C-git accumulates the new index in the lock file and renames it, which works because its lock spans one write; the daemon's lock spans a multi-step operation, so renaming the lock away mid-span would release the mutex early โ€” the lock stays a pure mutex and the payload gets its own temp file. Why a busy lock is waited out and never stolen: a lock that will not clear is either a live git operation or a crashed one, and both deserve a human look; the daemon backs off up to ten seconds, then skips the cycle with a WARN naming the lock and its age, and the next debounce retries. Verified live: with a foreign index.lock held, a write sat uncommitted and the log carried the refusal verbatim; on release, the next touch committed within the debounce. The regression test stages a deletion through the real git CLI and proves a daemon commit built afterward preserves it. Re-audit trigger: a go-git upgrade โ€” if upstream ever learns index.lock or rename-atomic index writes natively, the wrapper becomes double-locking to re-examine rather than protection.

  • 2026-08-10 ยท stage 2 landed, and the agent tree now has the shape this document draws. personal/ became memory/, and projects/, _briefs/ and _dream-staging/ became desk/projects/, desk/briefs/ and desk/scratch/ โ€” 9,378 renames, contents untouched. Agent/ itself did not move, so the phone's exclusion still holds. Obsidian now excludes Agent/memory/ and Agent/desk/scratch/ while desk/projects/ and desk/briefs/ stay indexed, which is the split this document asked for: the operator reads PLAN.md daily and never opens a memory shard. Verified end to end โ€” capture and the self-probe both write to Agent/memory/2026/08/ and are found again by phrasing their prose does not contain; zero broken and zero ambiguous links attributable to the move, against a baseline that already carried ~3,000 broken links so the absolute count was never the test; 39 battery gates and 3,215 unit tests green. What _meta/ did, and why: nothing. The original scope moved its machine state into desk/scratch/, and that turned out to buy nothing โ€” scratch means gitignored and freely deletable, which those files already are by name in .gitignore. Moving them would have cost 44 more call sites to relabel a property they already had. _meta/ therefore stays where it is, holding the corpus snapshots, the health history and repos.json, and this document's layout sketch should be read as naming the spaces rather than enumerating every directory under Agent/.

  • 2026-08-10 ยท stage 2 forked the corpus for fourteen minutes, and the gate that exists to prevent that watched it happen. Between the code being rewritten to say memory/ and the directories actually moving, the launchd runner โ€” which executes the source clone, not a release build โ€” ran a scheduled job against the new names. It re-seeded auto-orchestration-config.md into an Agent/memory/ that held nothing yet, with different values from the real one, and the reflect hooks added five inbox entries whose dedup could not see their originals. Six files, all shadows, all discarded in favour of the personal/ copies, all recoverable because the daemon had committed them. The mistake was mine and it is worth naming precisely: a rename is not atomic across a live system, and I left the code pointing at a directory that did not exist while a scheduled writer was still running. The move should have come first, or the writers should have been stopped first โ€” stopping the runner is what finally ended it. Why check-memory-root-consistency did not catch it: it checked that every daemon.spaces entry sits beneath memory_root, and Agent/memory sits beneath Agent exactly as well as Agent/personal does. Containment was never the property that mattered. The gate now also compares the names the two stacks resolve, so the daemon writing Agent/memory while the Python stack writes Agent/personal is a failure rather than a pass, and plugins.obsidian-vault.spaces exists so there is a name on the Python side to compare against. Re-audit trigger: the next migration that touches a live tree โ€” the rule this cost is that the directories move before the code does, or nothing writes while they disagree.

  • 2026-08-10 ยท three near-misses the tests caught that review would not have. The rewrite was mechanical across 123 Python files and 16 shell scripts, and three classes of string looked exactly like a space path without being one. ~/.claude/projects is Claude Code's transcript store, and rewriting it to desk/projects broke the reflect hook's ability to find any transcript at all. The week-1 gold set is labeled against the two frozen snapshots, which keep the pre-cutover layout forever by definition, so its personal/โ€ฆ labels had to survive a rename of the live tree โ€” the fixtures were rewritten twice before that stuck. And _EXCLUDE_DIRS in recall.py, dream.py, vault_lint.py and frontmatter_validator.py is matched against a single path segment, so the two-segment desk/scratch entry the rewrite produced matched nothing and silently readmitted 1,052 files of dream exhaust into recall. Why this is worth recording rather than just fixing: all three were invisible in the diff and loud in the suite, and the suite is the only reason a corpus-wide string rewrite is a safe thing to attempt at all.

  • 2026-08-10 ยท the committer's scope now matches the gate's, so a config file can no longer hold the undo mechanism shut. The daemon committed markdown only while agentmd gate corpus-write refused on anything git reported dirty, and everything in that gap โ€” .obsidian/app.json, Agent/_meta/repos.json, recall's runtime JSON โ€” was written, never committed, permanently dirty, gate shut, with no override to get past it. Indexing and committing are now separate questions asked separately: only markdown is indexed, because FTS5 has no use for a PNG, while any change to a tracked tree wakes the committer, which asks repo.Dirty() โ€” the same call the gate makes โ€” and commits that. Verified live: a non-markdown file written into the vault takes the gate from PASS to REFUSED, and roughly twelve seconds later the daemon has committed it and the gate passes again. Why the commit list comes from git rather than from the event batch: the batch is a trigger, git is the authority, and deriving the list from events would re-create the same drift one filter further down โ€” an event can be missed while the change still needs committing. Why the runtime-state question is not answered here: it is relocated rather than decided. .gitignore already answers it file by file, in a list that is versioned and hand-edited, so the daemon stops holding a second and weaker opinion. The one rule that sits above .gitignore is a safety rail, not a policy: a file under a dot directory is committed only if git already tracks it. The e2e suite carries a test, written after the cutover, asserting that Drive's staging churn never reaches history โ€” and it failed the moment the committer's reach widened, because the test vault has no .gitignore and the daemon's own guard is what it exercises. Trackedness restores that guard without a list of sync-client directory names to go stale: .obsidian/app.json is tracked because someone chose to version it, .tmp.driveupload/3700.md is untracked because nobody chose anything. Dot directories also never wake the committer, since a long sync would otherwise keep resetting the debounce and starve the commit; a tracked file under one arrives on the reconcile pass instead. The large-file question, decided rather than left implicit: an oversized addition is committed and logged loudly, not skipped. Skipping was the intuitive answer and it is self-defeating โ€” a skipped file stays dirty and a dirty worktree shuts the gate, which is the defect being removed, reintroduced under a new name for a rarer input. Committing is also the recoverable direction, undone by .gitignore plus git rm --cached, while a gate held shut by a file the daemon refuses to touch has no lever at all. Two re-audit triggers. The first time the reconcile interval is raised, since it is now the latency floor for tracked dot-directory files rather than only an index-freshness knob. And the first time the repository's size becomes a problem in its own right โ€” a slow clone, a NAS push that starts timing out, a backup that no longer fits โ€” because that is the symptom the large-file decision above trades against, and it is the point at which "commit it and warn" should be re-read rather than assumed.

  • 2026-08-10 ยท two of stage 1's three findings are fixed at the mechanism; the third becomes a committer change. The findings below were mitigated at the instance when they were found โ€” two files gitignored, one registry pruned, one duplicate fixture added โ€” which is not the same as fixed, so this closes them properly. Test pollution of the live registry is now structural rather than disciplinary: no_vault_configured() in scripts/test_project_config.py redirects $AGENTM_INSTALL_PREFIX alongside $MEMORY_VAULT_PATH, which is what "no vault" actually requires โ€” popping the env var alone leaves vault_path() free to fall through to the operator's real config, which is exactly how three throwaway /var/folders/โ€ฆ paths ended up registered as repos. A new battery gate, check-registry-hygiene, fails if any registered root_path sits under a temp root, so the next leak is loud at its own commit rather than discovered later as a gate that will not open. Verified by running the full 3,316-test suite and confirming repos.json is byte-identical across it. The gold set's two roots are now one labeled set and a flag: --expected-path-prefix Agent on week1_retrieval_experiment.py, week3_daemon_retest.py and week3_miss_probe.py, with week3 gaining the pre-flight path check it never had. Why the flag rather than the second file this document proposed hours earlier: two hand-synced 60-question sets drift the moment either is relabeled, and the drift is silent โ€” both stay valid JSON, both keep scoring, against different ground truth. The duplicate was retired the same day it was created. Why the check earns its place even though week1 already had one: week3 spends a real driver call per question, so an undetected root mismatch buys a full-price run whose every score is a labeling artefact; and the failure now names the prefix that would have worked, discovered by searching the corpus's own top-level directories rather than by assuming Agent/, so it survives the tree being renamed again. A second beneficiary of the runner fix, found while checking whether any scheduled job consumed the gold set: none does, but health-pass runs eval_v6_retrieval.py nightly against $MEMORY_VAULT_PATH, and its query set is labeled relative to the agent tree too. Under the old export 0 of its 16 expected paths resolved and its own drift guard would have failed the suite every night since the cutover; under memory_root() all 16 resolve. What is left: the daemon commits markdown only while the gate reads all of git, so any uncommitted non-markdown file still shuts it, and the gate's "let the daemon commit them" remedy is false for exactly those files. That is one defect, not two โ€” the text becomes true the moment the committer's scope matches the gate's โ€” and it is queued as its own plan, since a commit-path change deserves one. The ordering matters: registry hygiene had to land first, because a daemon that commits everything dirty would launder test pollution into permanent vault history instead of merely blocking on it.

  • 2026-08-10 ยท stage 1 of the four-space migration ran, and it is the operator-facing half only. Church, Home, Other and Tech moved one level down under Personal/, unchanged inside โ€” 529 files, of which 385 are notes and the rest the attachments beside them. Projects/ and Calendar/ were created, Calendar/ carrying today's note and a daily template, and index.md and Filing.md were authored at the root. Ideas.md stayed. The pre-session head, and the undo point for all of it, is d7e9e5c. The corpus went from 9,794 to 9,807 indexed documents across the session, and none of that growth is the migration โ€” it moved files without creating or destroying one. The rest is capture hooks, the daily digest, and this session's own round-trip note writing while the work ran. Agent/ did not move, which is what keeps the phone's path-based exclusion rule working. Why the moves were link-safe: every reference into the four folders was by bare name, which Obsidian resolves by basename, so a directory move cannot break one โ€” measured beforehand at zero path-qualified links inbound and zero relative links escaping upward, then confirmed afterward by a diff against a pre-migration baseline showing zero new broken and zero new ambiguous links. The baseline matters more than the absolute figure: the vault already carried 2,978 broken and 393 ambiguous links before anything moved, mostly Ideas.md pointing into retired incubator entries, so "zero broken across the vault" was never an available criterion and "nothing newly broken" is the honest one. Why retrieval was verified mechanically rather than by replaying the gold set: a rename cannot move a BM25 score, and 120 fixed queries returning byte-identical rankings before and after โ€” modulo the one legal Church|Home|Other|Tech โ†’ Personal/โ€ฆ rewrite, across 444 ranked paths โ€” proves it more tightly and far more cheaply than six Opus replicates per arm, given the snapshot README's own finding that day-to-day corpus drift swamps the effect being measured. The undo was rehearsed rather than assumed: batch A was reverted, the daemon kept serving throughout, and the re-apply returned a tracked tree byte-identical to the pre-rehearsal one at 10,056 files. One wrinkle the rehearsal exposed: a revert moves tracked files back but leaves ignored ones behind, and on a Drive-synced vault Finder recreates its Icon\r folder marker in whatever directory briefly exists, so the revert resurrected an empty Other/ at the root that no git command would report. Undo is complete for everything tracked and leaves ghost directories for everything not.

  • 2026-08-10 ยท what stage 1 deliberately did not do, and why the reason is the frozen Python stack. Agent/personal โ†’ Agent/memory and the Agent/desk/ consolidation were both planned for this session and both deferred. The layout change is trivial on disk and expensive everywhere else: the Python stack derives its layout from hardcoded literals rather than from the config seam the Go daemon has, at 762 references to personal/ across 102 files, 217 to projects/, 27 to _briefs/ and 30 to _dream-staging/, with save_entry building vault / group / kind / slug.md so the group name is the directory name and there is no chokepoint to redirect. Both writers are live โ€” com.agentm.daemon and com.agentm.runner are loaded and the reflect hooks fire on session stop โ€” so renaming personal/ would have had the next hook recreate Agent/personal/_inbox/ and resume writing there, reproducing the corpus fork this same day's memory_root entry describes. Why the existing gate would not have caught it: check-memory-root-consistency verifies that every daemon.spaces entry sits beneath memory_root, and Agent/memory sits beneath Agent perfectly well while the Python stack keeps writing to Agent/personal. The invariant it enforces is root containment; what a rename breaks is space-name agreement between the two stacks, and nothing checks that. Why deferral rather than rewriting the literals: this document freezes the Python stack for new feature work until the daemon replaces it, a ~1,000-literal sweep across a frozen stack is a poor fit for a layout session, and Agent/'s internals are the least operator-facing thing in the migration โ€” he does not browse them, and Agent/personal/ was excluded from Obsidian's index in this same session. Re-audit trigger: the stage-2 session that owns memory/ and desk/ organization, which has to carry the Python-stack seam and should extend check-memory-root-consistency to compare space names across the two stacks rather than containment alone.

  • 2026-08-10 ยท three things stage 1 found already broken, none of them caused by it. First, the daily digest had been writing to <vault>/_briefs/ instead of <vault>/Agent/_briefs/ since the git-transport cutover, because scripts/agentm-runner.sh exported $MEMORY_VAULT_PATH from vault_path() while every consumer that reads it โ€” recall.py, reflect.py, capture.py, inbox_digest.py โ€” joins personal/, _meta/ or _briefs/ onto it and so treats it as a memory root. The reflect and recall hooks were corrected at the cutover; the runner was the last unpatched export, and it now calls memory_root(), which falls back to vault_path() when the key is unset so no install that never moved changes behavior. The one misplaced digest was relocated. Second, the week-1 gold set resolves 0 of its 64 paths against the live vault and has since the cutover, because score_at_k matches by exact string equality and the daemon now answers Agent/projects/โ€ฆ where the labels say projects/โ€ฆ. That is a stale label rather than a retrieval collapse. It is worth being precise about what that cost, because the first telling of this entry was not: week1_retrieval_experiment.py already resolved expected paths before its first driver call and aborted with exit 2, so it never silently scored zeros โ€” week3_daemon_retest.py had no such check and would have. A stage1-pre / stage1-post snapshot pair was taken either side of the moves, rooted at the whole vault. Third, agentmd gate corpus-write is held shut by any uncommitted non-markdown file, because the daemon commits markdown only โ€” while its own remediation text says to "let the daemon commit them," which for those files never happens. Recall's Agent/.heat.json and Agent/.lifecycle.json are gitignored for that reason. A related nuisance worth naming: Agent/_meta/repos.json accumulates temp-path churn from test fixtures registering themselves into the live vault registry under /var/folders/.../T/tmpXXXX/repo, so it re-dirties on every test run and re-blocks the gate. Re-audit trigger: the gate's remediation text, which should distinguish what the daemon will commit from what it will not โ€” see the entry above, where that becomes a committer change rather than a wording one.

  • 2026-08-10 ยท the interactive recall hook now asks the daemon, ahead of the two-week window. UserPromptSubmit recall had been returning zero entries on every prompt: the Python engine's corpus walk reaches 113 of 2,096 entries inside the 300ms interactive budget, a partial walk is discarded rather than ranked, and the semantic half never runs. The hook fired, exited 0, and reported the nothing honestly, which is why it survived. recall.py prompt-submit now shells out to agentmd search -json first and keeps the whole existing engine as the fallback for when the daemon is absent, slow, or broken. End to end through the installed hook: p50 128ms, worst 264ms over 18 realistic prompts, against a 300ms budget it previously blew on every one. Why this is early: the build sequence gives the daemon two weeks of live use before anything else starts, and the alternative here was two weeks of no recall at all. What it is not: the context-flow inversion described under the crickets seam. Hooks still push; only the engine behind the push changed. The inversion โ€” a session calling memory_search when it wants something โ€” is still queued, and this makes it cheaper rather than pre-empting it. What it does pull forward: the interactive path's dependency on the daemon running, which the two weeks were partly meant to earn. The fallback is what makes that acceptable; a machine with no daemon finds out in about 2ms and behaves exactly as before. Why not simply raise the budget: 300ms is deliberate, and three seconds of dead air per prompt submission is a worse trade than the miss. Three things the build found that the plan had wrong. The daemon's 12ms figure is a three-word-query figure: handed a raw prompt it costs 578ms for nine words and 2,150ms for sixteen โ€” seven times the whole hook budget โ€” because BM25 scores every document any common word appears in, and it ranks worse, returning stale archive files where the extracted keywords return the right design. Recall therefore asks with content words only. Over-fetching to survive filtering is not free either, so it is 2x rather than 4x: p90 goes 49ms โ†’ 58ms โ†’ 66ms as k goes 5 โ†’ 10 โ†’ 15. And the daemon enforces none of recall's hygiene โ€” _dream-staging/, _inbox/, _archive/, status: superseded โ€” so all four are re-applied on the recall side, which a probe confirmed was not academic: a dream-staging proposal ranked second for "commit message trailer". Why recall stays scoped to the memory root: measured before choosing, 13% of top-5 results across 20 prompts fell outside Agent/, and the leaks were not on personal-topic queries where they would be legitimate โ€” they were on generic phrasings a dev session really does type, with "what should I work on next" returning two Church notes. Indexing everything and retrieving everything are separate decisions, and the memory_root entry below had already separated them a day earlier. RECALL_DAEMON_SCOPE=vault opts back in, and hits from outside the memory root are marked in the injected text and excluded from heat and decay bookkeeping, since they are readable context rather than curated memory. Re-audit trigger: the push-to-pull inversion landing, at which point the keyword extraction should move to the caller โ€” an agent composing its own query does not need a stopword list โ€” and this whole fast path becomes one memory_search call.

  • 2026-08-10 ยท the normalization defect is fixed in the daemon, so the pair the cutover found is now closed. The one-off repair of the two Church filenames left the vault clean but the daemon unchanged, which meant the gate would shut again the moment another decomposed name arrived โ€” from the phone, from Drive, or from any macOS app that writes one. vcs now does what core.precomposeunicode does, in the two places that matter. Dirty folds a pair of paths that differ only by normalization, and Commit files the index entry under the composed spelling. Together with the deletion quarantine that closed defect (2), both halves of the entry below are now fixed rather than merely repaired, and its re-audit trigger is discharged. Why the fold is conditional rather than unconditional: on a filesystem that stores what it is given, the two spellings are two files, and folding them there would hide a real deletion behind a real addition โ€” so the daemon reads core.precomposeunicode and defaults it the way git does, on for macOS and off elsewhere. Why it folds only a proven-benign pair: the fold has to be able to fail closed, since a gate that opens when it should not is worse than one that stays shut, so a pair collapses only when the file on disk carries exactly the blob and mode the index already records; a staged change, a real edit, a third spelling, or an unreadable file all still report dirty. Why the staging order is git's reversed: git folds the pathspec and then opens the folded name, which works only because a Mac's local volume treats the two spellings as one file โ€” the same folding that made git rm --cached remove the wrong entry during the incident. The daemon reads under the name the watcher actually observed and composes only the index key, so nothing depends on the mount being normalization-insensitive. Re-audit trigger: a vault that stops being macOS-only โ€” a second machine committing to it, or a Linux box running the daemon โ€” at which point two machines would disagree about what one filename is, and the config default is the thing to re-read.

  • 2026-08-10 ยท the cutover's write-split had a second half, in the Python stack. The daemon.spaces fix above closed the daemon's half and read as closed. It was not: the Python stack derives its whole layout โ€” personal/, projects/, _meta/, _moc/, _briefs/ โ€” directly from the vault path, so it followed the key to the root and kept writing memories to <vault>/personal/ for another day. By the time it was found the shadow tree held 102 files, every one shadowing a same-named entry on the Agent/ side with a different body, and both halves looked healthy in isolation. Resolved with plugins.obsidian-vault.memory_root, the vault-relative prefix where the agent's own tree begins; vault_path keeps meaning the tree to index and the git root the gate resolves against. Unset means the two are the same directory, so no install that never moved changes behavior. Why not have the Python stack read daemon.spaces: the daemon writes to exactly two places and a space map fits it, while the Python stack derives nine directories of which two are spaces โ€” sharing the key would have fixed personal/ and left _meta/ at the root, and the Go defaults describe the pre-cutover layout, so an absent key would have reproduced the bug. Why the env var was the actual mechanism: the hooks export $MEMORY_VAULT_PATH from vault_path and reflect.py joins personal/ to it, so the write path never consulted a resolver at all. A consequence the daemon's half masked: recall walks <vault>/** with no group allowlist, so the operator's own Church, Home, Tech and Other notes โ€” 404 of them โ€” had silently entered the agent's retrieval corpus, which went back from 2,498 entries to 2,094. Cleanup: 34 provable re-derivations removed and 10 memories that existed nowhere else moved into the lineage; 58 whose Agent/ copy is strictly richer (already promoted, higher occurrence counts) left for the operator rather than deleted on a rule written for a simpler case. Re-audit trigger: the stage-1 four-space migration, which now changes three keys rather than two โ€” check-memory-root-consistency fails the battery if they stop agreeing.

  • 2026-08-10 ยท the git-transport cutover ran; the vault is a repository. The vault moved from โ€ฆ/Obsidian/Agent on the Google Drive streaming mount to ~/Vault on local disk, and agentmd gate corpus-write now exits 0 โ€” the precondition it had been refusing on since it shipped. The copy was verified byte-for-byte before cutover (10,118 files, 119,432,197 bytes, identical sha256 manifest, zero mtime drift) and the Drive original is untouched as a cold fallback, going stale from this date. The remote is a bare repository on the primary Unraid NAS over SMB. Corpus went from 9,190 indexed documents to 9,676: the ~385 personal notes this document predicted (Church, Home, Other, Tech), plus roughly a hundred the memory hooks wrote into the old vault during the session itself. A scorecard run after this date is reading a different corpus โ€” the baseline moved, and the change is not a treatment effect. The vault root was not what this document assumed. It described a root containing Agent/ as a peer of the operator's folders, and that turned out to be literally true of the existing Obsidian folder โ€” .obsidian, .trash, Agent/, Church/, Home/, Other/, Tech/, Ideas.md โ€” while agentm had been pointed at the Agent/ subfolder all along. So the repository root is the Obsidian folder as specified, but reaching it was a discovery rather than a move. Why the root and not Agent/: the reason this document already gave โ€” wikilinks resolve across all of them, and a repo scoped to Agent/ would let a link-integrity check pass while a link from the operator's side broke. Consequence caught during the cutover: repointing the vault to the root also moved where capture writes, so the first capture landed in a new root-level personal/, splitting memory in two. Fixed for the daemon by setting daemon.spaces to {memory: Agent/personal, projects: Agent/projects} โ€” the config seam this document promised the later Agent/memory migration would turn on, exercised earlier than planned. The Python stack has no space map and kept writing to the root for another day; see the 2026-08-10 memory_root entry below for the half this one missed. Re-audit trigger: the stage-1 four-space migration, which changes daemon.spaces and memory_root together.

  • 2026-08-10 ยท the transport is Google Drive, not Syncthing. Operator decision at cutover, reversing this document's resolution of the Android condition. The body above carries the mechanism, the accepted costs, and the --separate-git-dir indirection that keeps the object database out of Drive. Why not Syncthing: the operator did not want another resident daemon plus a community-maintained Android app, and already runs DriveSync successfully. Why not Obsidian Sync, which was offered as the technically cleanest option and would have satisfied the remote policy through end-to-end encryption: declined in favour of reusing existing infrastructure. Why the object database moving out of the tree is not optional: Drive cannot exclude a subfolder, so a normal .git would be uploaded as thousands of loose objects with no ordering guarantees โ€” the standard way to corrupt a repository, and here it would corrupt the undo mechanism specifically. Re-audit trigger: the pure-git-transport backlog item, blocked on the operator's home tunnelling project, which retires DriveSync and revisits "the phone never runs git."

  • 2026-08-10 ยท two daemon defects the first live git exercise surfaced. Both were invisible until the vault became a repository, which is exactly what week 2โ€“3's note about commit attribution being "built and tested but degraded" was deferring. (1) go-git does not implement core.precomposeunicode. Two Church filenames were stored decomposed by macOS and composed by git; C-git folds them and sees a clean tree, go-git reads the pair as one deletion plus one untracked addition and reports the worktree permanently dirty. The gate refuses on a dirty worktree, so this alone would have held it shut forever. Repaired by normalizing both names to NFC โ€” and note the repair was not the obvious one, since core.precomposeunicode also folds pathspec arguments, so removing the NFD entries by passing their bytes to git rm --cached silently removed the NFC entries instead; rebuilding the index from disk is what worked. Only two such names exist in the corpus today, and the gap will recur if another enters. (2) The commit path stages a deletion for any file that momentarily fails os.Stat. Drive makes a file briefly unstattable at its own path while replacing it, so commit 25e61dc recorded deletions for two notes that were on disk the whole time. No content was lost โ€” a commit does not touch the working tree โ€” but the recorded history, and therefore the NAS backup, described a vault missing two notes that existed. Related and lower-severity: the commit path has no equivalent of the indexer's dot-directory skip, so during the bulk upload it batched Drive's transient .tmp.driveupload files (peaking above 1,400) and failed repeatedly with "cannot create empty commit" until the churn subsided. Neither is fixed here โ€” the cutover's job was the transport, and a commit-path change deserves its own plan rather than a repair at the end of a migration. Re-audit trigger: both, before dreaming's first corpus-wide pass, since that is the job the gate exists to protect and it will run against a vault the daemon is committing to continuously.

  • 2026-08-09 ยท the queue baseline, added after the first real-vault reading. The loud queue as specified went red immediately and permanently on the operator's own machine: 4,349 unfiled items, oldest 29 days. Both facts were already known and already decided โ€” the pile is rank-penalized and drained by dreaming later โ€” so the alert would have been noise from its first hour. The daemon now records a baseline on first run and applies the thresholds to what accumulated after it, while reporting the inherited count, its age, and the baseline date on every status surface. Why not just raise the thresholds: the numbers are correct; what was wrong was measuring a backlog with a stall detector. Why not exclude the pile from the count: that is the previous system's sin in a new costume โ€” it stays counted and visible, it simply does not page. Why the split is defensible beyond convenience: inherited ages are mtime-derived and a sync client rewrites mtimes, so the pre-baseline half of the queue has no trustworthy age to threshold on. Re-audit trigger: dreaming's inbox drain landing, after which the inherited count should fall toward zero โ€” if it does not move, the baseline is hiding a drain that never ran.

  • 2026-08-09 ยท week 4 shipped, and the build sequence's ordering sentence became a gate. The loud queue, the daily self-probe, and agentmd gate corpus-write are in the daemon. The ordering constraint recorded here on 2026-08-08 โ€” git-transport before dreaming's first corpus-wide pass โ€” is now a machine-readable precondition that job scripts ask rather than a rule people remember, and it refuses on a dirty worktree as well as on a missing repository, because both mean the same thing: no nameable state to revert to. Why the gate refuses rather than warns: a warning on a job that rewrites thousands of notes is read once and then filtered, and the thing being checked is not a preference but whether undo exists. Why degraded git does not page: it is a known deferred migration, and a daily email about it would train the operator to ignore the channel before it ever carries news. Re-audit trigger: the git-transport migration landing, at which point the gate's git-degraded branch stops being the one that runs and the dirty-worktree branch becomes the whole of it โ€” worth re-measuring then, because a vault the daemon commits continuously should be clean almost always, and if it is not, the gate will be the thing that discovers it.

  • 2026-08-08 ยท backup topology decided โ€” everything stays in the house. The working repository lives on the Mac; a bare repository on the primary Unraid NAS (plugin-installed git) takes a daily push; the backup NAS's existing monthly cold mirror inherits the third copy, contingent on the git share being inside its mirror scope; Tailscale is the optional away-path. No off-site leg. Why not the encrypted-cloud alternative (restic or git-remote-gcrypt): the operator judged an always-on NAS at daily cadence plus an air-gapped cold box at monthly cadence redundant enough for his tastes, and declined the standing key-custody cost against a house-level-loss risk he accepts. Re-audit triggers: the daily push breaking silently โ€” with one live backup leg, a dead push is zero redundancy, so last-push-age joins week 4's loud numbers alongside the unfiled-queue age; the homelab notes' mirror-scope TO CONFIRM resolving negative; or the vault taking on content whose loss would be unacceptable (email ingest's third-party facts, scanned originals with no other home).

  • 2026-08-08 ยท daemon v0 shipped; three assumptions corrected. The index, the rank penalty, and the two MCP tools are as specified. Corrected against what the machine actually is: git commits are built and verified but degraded, because the vault is still on the synced mount and the daemon will not initialize a repository on its own initiative โ€” that is the operator's migration to run, and a silent git init under 8,864 files is the unilateral move the build sequence defers. Capture dates fall back to filesystem mtime for 8,845 of 8,864 notes, so after:/before: are exact for daemon-written notes and approximate for the rest; the index pins the first date it observes so an edit cannot move it. And the vault layout stayed personal//projects/ with the space mapping in config, so the Agent/memory + Agent/desk migration is two config keys rather than a rewrite. Why not init the repo and backfill the dates: both rewrite the operator's vault to make a design document true, and the design's own build sequence puts that migration after two weeks of living in the daemon. Re-audit trigger: the git-transport migration landing โ€” at which point the degraded-git path stops being the one that runs, and the mtime fallback should be re-measured against a corpus the daemon has been writing into for a month.

โš ๏ธ **GitHub.com Fallback** โš ๏ธ