Skip to content

Architecture

How agentsync is put together: the data model, the apply/capture pipelines, the drift classifier, and the safety and secrets invariants that make it trustworthy enough to point at your real config and your real credentials.

If you haven’t yet, read Concepts & glossary first — this page assumes that vocabulary. For a package-by-package index, see the component map.


agentsync inherits chezmoi’s three-state design. Every operation is a comparison between Source (what you committed), Target (what the source renders to, computed in memory), and Destination (what’s on disk in each agent).

flowchart LR
    subgraph S["Source — ~/.agentsync/"]
        TOML["TOML + .md<br/>(hand-editable)"]
    end
    subgraph T["Target — in memory"]
        OPS["per-agent FileOps<br/>+ Skips"]
    end
    subgraph D["Destination — on disk"]
        NATIVE["~/.claude.json<br/>~/.config/opencode/…"]
    end
    TOML -- "render (resolve secrets + project)" --> OPS
    OPS -- "write (atomic)" --> NATIVE
    NATIVE -- "capture (ingest + re-reference)" --> TOML

Drift is a hash comparison against the last-applied hashes recorded in state: if the destination’s hash no longer matches what agentsync last wrote, the file was edited outside agentsync.


There is no separate internal IR. The Go structs in internal/source that parse the TOML/markdown in ~/.agentsync/ are the canonical model (source.Canonical), and adapters render directly from it. Adding a component field means changing those structs; adding an agent means adding an adapter that consumes them — the schema is the contract between the two.

source.Canonical
├── Config (agentsync.toml: agents, update defaults, secrets backend, [memory] banner)
├── MCPServers (mcp/*.toml)
├── Skills (skills/<name>/ — SKILL.md + bundled scripts/references/assets)
├── Subagents, Commands, Hooks, LSPServers
├── Plugins, Marketplaces (plugins/*.toml, marketplaces/*.toml)
├── Memory (memory/AGENTS.md + fragments/; rendered files get the managed banner — see below)
└── Project (overlay loaded from a <root>/.agentsync/ tree, project scope)

Units live in the canonical model, not at its edges. Where harnesses disagree about what a number means, the canonical field fixes one meaning and the adapters convert. Hook.Timeout is the live example: it is seconds, a whole number greater than zero, capped at source.MaxHookTimeout, and 0 is the model’s spelling of “no timeout key at all”. Claude Code, Codex, Cursor and Grok Build document their native timeout in seconds and pass it straight through; Gemini CLI documents milliseconds, so its adapter multiplies on render and divides on ingest, refusing any native value that is not a whole number of seconds rather than rounding it. The cap is math.MaxInt32 / 1000 seconds so the multiplied millisecond figure always fits in a 32-bit int and re-ingests as the same canonical value. A canonical timeout outside the representable range fails the load (loadHooks) instead of being silently dropped at render, and a native one outside it is refused as unmodelable rather than malformed — the distinction that decides whether import retires the stale canonical file (§ hook retirement below). A plugin manifest’s timeout outside it is the deliberate exception: pluginHookTimeout logs a warning and projects the hook without it, because a third-party manifest must not be able to make itself unloadable.

At project scope the same canonical is loaded a second time from the repo’s <root>/.agentsync/ tree (identical layout) and overlaid onto the user canonical by project.Merge: entries are merged by id/name (project wins), project memory is appended, and a project plugins/<id>.toml with disabled = true is excluded from projection in that repo. The project’s [agents] table is authoritative — project.Merge never inherits the user’s enabled agents, so identical committed source renders identically for every collaborator. An empty or absent project [agents] is rejected before render by requireProjectAgents (internal/cli), which every scope-aware command reaches via loadProjectedForScope (check calls it on its own load path); the error points at agentsync agent add <name> --scope project. import --scope project is deliberately exempt: it is the capture path used to bootstrap a tree. The retired M5 single-file .agentsync.toml marker is no longer read — project.Discover surfaces a migration error if it finds one.

Managed memory banner. Every rendered memory file (CLAUDE.md, AGENTS.md, …) is prepended with a short agentsync notice naming the file and pointing edits back at .agentsync/memory/AGENTS.md + agentsync apply. Every adapter renders memory through the one helper source.RenderManagedMemory (which wraps ExpandMemoryImports), so the banner is byte-identical across agents. It is a property of the rendered destination file only — it is wrapped in reversible <!-- agentsync:managed memory-banner --> markers (the agentsync:managed namespace carries a per-marker identifier so future managed markers stay unambiguous) and stripped by source.StripManagedBanner on the way back in (each adapter’s ingest, plus a backstop at the import / reconcile write-back funnels), so it never enters the canonical source and never compounds. Because the banner text is static (only the filename varies) it hashes identically on every render, so an untouched file still classifies InSync — the banner never manufactures drift. It is on by default; [memory] banner = false in agentsync.toml opts out (the project overlay inherits the user setting unless it sets its own). The agentsync:managed marker is reserved: checkReservedMarkers (in loadMemory and WriteMemory) rejects a canonical whose body or a fragment carries it rather than letting it collide with the banner’s markers, and StripManagedBanner matches agentsync’s full rendered banner (not the bare markers) so it removes only agentsync’s own banner — a user-authored marker block is preserved, never deleted.


Every agent integration implements one interface (internal/adapter/adapter.go):

type Adapter interface {
Name() string
Detect() (bool, error) // is this agent installed? (informational; consumed by `doctor`)
Render(r secrets.Resolved, scope Scope, project string) ([]FileOp, []Skip, error)
Ingest(scope Scope, project string) (source.Canonical, error)
KeyMergeStrategy() string // "merge-json-keys" | "merge-jsonc-keys" | "merge-toml-keys" | ""
Apply(ops []FileOp, w DestWriter) error
}

Two design points worth internalizing:

  • Render accepts only secrets.Resolved, never a raw source.Canonical. Resolved is a wrapper type produced by secret substitution; you cannot pass the templated source model to Render, and you cannot pass the resolved (cleartext) model to a source writer. This makes “leak a resolved secret back into source” a compile error, not a code-review check.
  • Every destination write goes through DestWriter. Adapters never call iox.AtomicWrite or the destructive os.* family (os.Remove/os.RemoveAll/os.WriteFile/os.Create) directly. DestWriter owns the foreign-collision backup invariant (back up any pre-existing file agentsync doesn’t yet own, before overwriting). A forbidigo lint rule — plus a belt-and-braces source-scanning test (internal/render/writer_lint_test.go) — fails any direct write outside the allowed non-destination packages, so a new adapter can’t regress the backup guarantee.
  • Project scope requires a project root. Each adapter’s ResolvePaths falls through to user-scope paths when the project root is empty, so a (ScopeProject, "") call would silently write the project overlay into the user’s global config. Every scope-resolving adapter method — Render, Ingest, and IngestPlugins — calls adapter.RequireProjectRoot first thing and returns ErrProjectRootRequired instead — a loud failure at the narrowest waist rather than a silent wrong-scope I/O. The CLI’s resolveScope already guarantees a non-empty root for project scope, so this is defense-in-depth against a future or non-CLI caller.
  • Ingest treats only os.IsNotExist as “component absent.” A native config file (or component directory) the user never created is a silent skip; any other read or parse error on a present file — a permission error, an EISDIR, transient I/O, or a corrupt settings.json/config.toml — is returned, so a transient failure never reads as an empty component that drift could misclassify as “the user cleared it” and reconcile could then write back as nothing over the canonical source. The shared adapter.ReadFileOptional / adapter.ReadDirOptional helpers enforce this absent-vs-error split uniformly across every adapter’s Ingest (a per-entry read inside a component-directory loop stays a deliberate skip, surfaced as a warning where a warn sink exists).

Component support is expressed by what Render emits, not a capability declaration. When an agent has no native target for a component, its Render returns a []Skip entry for that component rather than a FileOp — there is no separate capability bitmask to keep in sync with the render logic. So the OpenCode adapter reports Hook and LSP as skips; Claude, Codex, Cursor, and the other v1 adapters report LSP as a skip too (Claude Code reads LSP servers from plugin manifests rather than settings.json, and agentsync does not synthesize Claude plugins in v1; the others have no native LSP config concept).

Skips are typed, not stringly-classified. A Skip carries a Kind (adapter.SkipKind): SkipDropped when the whole component had no native target and was not emitted, SkipReduced when it rendered but lost fields the agent has no home for (a subagent’s Claude-only tools/color, a command’s frontmatter). The adapter that builds the Skip sets Kind — the CLI’s plugin explain reads it directly and explain --json surfaces it as kind ("reduced"/"dropped"). The zero value SkipKindUnset is invalid: Component is the plain kind (mcp, subagent, …) and no longer encodes the distinction via a -frontmatter suffix. Two complementary guards make an unclassified skip impossible to ship. TestEverySkipLiteralSetsKind (internal/adapter) statically parses every production adapter.Skip literal under internal/ and fails if one omits Kind — reachability-independent, so a skip site gated on a path that is never empty at runtime (e.g. a scope-gap branch) cannot hide from it. TestEveryAdapterClassifiesSkips (internal/cli) is the behavioral complement: it renders every registered adapter at both scopes, fails on any unset Kind, and pins that both kind values are exercised.

FileOp is typed too. Action (adapter.Action) is ActionWrite or ActionDelete, and ActionWrite is the zero value — a FileOp built without naming an action writes. There is no empty spelling and therefore no intake normalization: Plan, Apply and PreviewApply used to rewrite "" → "write" before their guards ran, and every downstream reader carried a comment saying whether its ops were plan-normalized or raw adapter output; both are gone. Kind (adapter.OpKind) says why the op exists — an ordinary render (OpRender, the zero value) or a synthesized orphan cleanup (OpCleanup), not which source or plugin produced its content — and is orthogonal to Action: a cleanup op is an ActionWrite of {} whose only work is pruning an emptied section’s owned keys (the merge path performs the removal via OwnedKeys), so the kind is what lets apply label and count it as a removal rather than sniffing {}+OwnedKeys. Cleanup ops are built by adapter.NewCleanupOp — the only producer of OpCleanup, called from render.orphanCleanupOps and from agent disable --purge — and TestEveryCleanupLiteralUsesNewCleanupOp fails any production FileOp literal that hand-rolls the cleanup shape or stamps the kind by hand, so a hand-rolled cleanup literal cannot ship unstamped or hand-stamped.

Key-merge strategies and on-disk format. KeyMergeStrategy / FileOp.MergeStrategy name how an adapter co-owns keys inside a shared config file: merge-json-keys (Claude’s .claude.json/settings.json, a project’s repo-root .mcp.json for project-scope MCP servers, Cursor’s .cursor/mcp.json + .cursor/hooks.json, Windsurf’s ~/.codeium/windsurf/mcp_config.json, Roo’s project .roo/mcp.json, and Cline’s ~/.cline/mcp.json), merge-jsonc-keys (OpenCode’s comment-tolerant opencode.json and Gemini’s .gemini/settings.json — which co-owns both mcpServers and hooks), and merge-toml-keys (Codex and Grok config.toml). Grok hooks use merge-json-keys in .grok/hooks/agentsync.json. The Continue adapter co-owns no shared file (it projects one block file per item), so it has no key-merge strategy.

Single-format adapters declare their format through KeyMergeStrategy(). An adapter owning shared files of different formats implements the optional PathKeyMerger interface:

type PathKeyMerger interface {
KeyMergeStrategyForPath(path string) string
}

adapter.MergeStrategyForPath resolves this per absolute destination path, falling back to KeyMergeStrategy() for existing adapters. Both orphan cleanup and agent disable --purge use that helper. Grok is the first mixed-format adapter: config.toml uses merge-toml-keys, while hooks/agentsync.json uses merge-json-keys. The registry-wide TestKeyMergeStrategy_MatchesEmittedOps pins the resolved strategy against every rendered shared-file op at both scopes. Grok’s CLI lifecycle tests exercise actual state-driven cleanup and purge for both formats. MergeStrategy itself stays a plain string: typing it would change the published Adapter interface (KeyMergeStrategy() string) and is deferred to #250.

Deep vs breadth-tier adapters. The ten hand-written packages above are deep adapters — agent-specific, multi-component, often bidirectional. Beyond them, a single data-driven generic adapter (internal/adapter/generic) serves a long tail of agents from a verified Spec table — memory, (where expressible) MCP, and (where the agent scans a SKILL.md directory) Agent Skills, every other component reported as a skip except Factory command hooks (see the capability matrix). Both kinds implement the same Adapter interface and register identically, so the rest of the pipeline (plan, classify, write, capture, state) treats them uniformly. The set of valid agent names is derived from the deep package list plus generic.Specs() (see internal/cli/agent.go allAgentNames), so adding a breadth agent — a verified table row — needs no change to validation, doctor, or init. The merge currency is always a map[string]any decoded from the rendered op’s JSON Content, so the pipeline’s pointer/ownership machinery (owned-key synthesis, orphan cleanup, per-pointer state hashing, foreign-collision backup) is format-agnostic; only the destination file is decoded/encoded per strategy — TOML for merge-toml-keys (internal/adapter/codex/settings.go), JSON otherwise. As with opencode.json and mcp/*.toml, the TOML round-trip does not preserve comments in the rewritten file (a documented v1 limit).

Several optional extensions sit beside the core interface — an adapter implements one only if the agent has the concept, and callers type-assert for it: PluginIngester (below), MCPSpecIngester, HookIngestGuard, HookEventNamer, VersionedDirs and WarnEmitter. The first two are read-only by construction: they exist to let the capture direction ask the adapter a question, and neither has a Render-side counterpart.

The first of them:

type PluginIngester interface {
IngestPlugins(scope Scope, project string) ([]NativeMarketplace, []NativePlugin, error)
}

An adapter implements it only if the agent tracks installed plugins + marketplaces in its native config (Claude reads enabledPlugins / extraKnownMarketplaces from settings.json; Codex reads [plugins."<name>@<source>"] enable-state from config.toml). import type-asserts for it: an adapter that doesn’t implement it imports no plugins. It’s kept off the core Adapter because the canonical schema doesn’t otherwise depend on a native plugin concept (OpenCode has no plugins; the Cursor adapter has them but its enable-state location is undocumented, so it implements no PluginIngester yet).

The asymmetry is the invariant — read-only by design

Section titled “The asymmetry is the invariant — read-only by design”

PluginIngester has no Render-side counterpart, and Adapter.Render MUST NOT emit plugin-enablement or marketplace-registry metadata back into the native config. This is the rule for every adapter, present and future:

import reads the agent’s plugin enable-state for discovery; apply never writes it back. Apply fans out the plugin’s components, not the plugin itself.

Concretely, for each adapter:

directionwhat crosses the boundary
import (Adapter → canonical via IngestPlugins)enable-state + marketplace sources, so agentsync can fetch the same plugins and own them in ~/.agentsync/plugins/, ~/.agentsync/marketplaces/
apply (canonical → Adapter via Render)only the plugin’s projected components — its skills go to the agent’s skills path, its MCP server to mcpServers, its commands to the commands path, etc. Never enabledPlugins, never extraKnownMarketplaces, never [plugins."x@y"].

Why the asymmetry rather than a tidy round-trip:

  • Plugin identity dissolves at the projection boundary. Once a plugin’s skills land at ~/.claude/skills/<name>/, its MCP entry under mcpServers, its commands at ~/.claude/commands/<name>.md, the consumer agent reads them through the same code path it uses for hand-authored components. It does not need plugin-manager metadata to use a projected skill — the plugin grouping is purely agentsync’s internal bookkeeping.
  • Ownership stays singular. agentsync is the source of truth for which plugins exist. The consumer agent’s plugin manager is the source of truth for whatever the agent itself installed locally. Writing enabledPlugins back would blur this and pick a fight with the agent’s own UI: every user /plugin disable would be reverted by the next apply (a ping-pong loop).
  • No double-install. If agentsync also wrote enabledPlugins/X = true, the consumer agent would keep its own copy under ~/.claude/plugins/<id>/... alongside agentsync’s projection at the shared path — two copies of the same skill, served by different code paths, with the agent’s UI free to upgrade/disable one but not the other.

The CLI’s import maps each NativePlugin result onto an agentsync marketplace source and re-fetches it through the same code path as marketplace add + plugin add, so a captured plugin lands as a normal plugins/<id>.toml + marketplaces/<name>.toml pair with a pinned manifest SHA. From then on, the projection layer drives every apply.

The other half of “no double-install”: native_agents

Section titled “The other half of “no double-install”: native_agents”

The bullet above explains why apply must not write enabledPlugins back. It does not, by itself, prevent the duplicate — because apply does not remove the native enablement either. A plugin the agent installed itself stays installed, and agentsync projecting the same plugin’s components into that agent’s standalone paths lands two of every skill, subagent and command, and fires every hook twice. Namespacing (<plugin>-<name>) keeps the two sets from colliding on one path, which is precisely why they coexist rather than overwrite.

So the deferral is recorded in canonical source, per plugin:

keymeaningwho writes it
agentsthe user’s fan-out allowlist (["*"] / omitted = every enabled agent)the user
native_agentsagents whose OWN plugin manager installs this plugin, so apply must not project it thereimport prompts per plugin (declining warns about the duplicate); the user edits it

A plugin component renders for an agent only if agents targets it and native_agents does not claim it (source.PluginTargetsAgent).

Two properties are load-bearing:

  • Declared, never inferred. apply does NOT probe the destination to decide what to render. If it did, the render would become a function of destination state: the same ~/.agentsync/ would render differently on two machines, diff would stop being reproducible, and — worse — the 3-way drift classifier compares source / last-applied / destination on the assumption that the first is independent of the third. import reads native state ONCE and writes the conclusion into canonical source; every later apply reads only that.
  • One enforcement point. The filter runs at the render waist — source.FilterForAgent, reached via secrets.Resolved.ForAgent in render.Plan — never inside an adapter. Ten adapters × six component kinds is sixty places to forget, and each omission silently re-creates the duplicate for one kind on one agent.
  • The import-side refusal set is deliberately WIDER than the render set. import refuses to capture any component ANY installed plugin provides, not just the ones this agent receives. Narrowing it to the render set would be symmetric and wrong: between recording a deferral and the apply that reclaims the files, the destination still holds agentsync’s own rendered output, and a narrowed filter would capture that into the canonical source as hand-authored. Over-refusal fails loudly, naming the plugin; under-refusal silently mints a canonical copy that diverges from the plugin’s on its next update.

The residual — a plugin installed natively AFTER it was declared in agentsync — is invisible to apply by construction. status and doctor do read native state, and report a plugin that is both installed in an agent and projected to it (adapter.DuplicatedNativePlugins), naming both remedies.

The Codex adapter implements IngestPlugins; Cursor has a native plugin system too but does not implement it yet (its enable-state location is undocumented — see below). Both Codex and Claude project a plugin’s components to every enabled agent via its capability matrix, and both follow the read-only-on-import, components-only-on-apply rule above:

  • Claude reads enabledPlugins / extraKnownMarketplaces on import and deliberately leaves both keys untouched on apply. Foreign entries (plugins the user enabled directly in Claude Code, marketplaces the user added by hand) are preserved by the merge-keys writer because the render doesn’t claim those keys.
  • Codex records enable-state in ~/.codex/config.toml under [plugins."<name>@<source>"] tables — the same name@source shape Claude uses. IngestPlugins parses those TOML tables into NativePlugin records. Unlike Claude, Codex records no marketplace fetch source in a documented config location, so it returns no NativeMarketplaces; import resolves each plugin’s marketplace from agentsync’s own registered marketplaces (warning + skipping any it can’t), exactly the path Claude’s auto-available built-in marketplace takes. The Codex render never emits the [plugins."x@y"] tables back, matching the Claude rule.
  • OpenCode, Gemini CLI, Continue, Windsurf, Roo Code, and Cline have no native plugin concept agentsync models (Gemini uses extensions; Continue composes Hub + local blocks), so they implement neither side — all still receive plugin-projected components (skills, MCP, …) on apply like every other component, because that’s the whole point.
  • Cursor ships a real adapter (MCP, memory, skills, subagents, commands, hooks) but implements no PluginIngester yet. Its plugin content schema — .cursor-plugin/plugin.json + .cursor-plugin/marketplace.json, near-identical to Claude’s .claude-plugin/* (rules, skills, agents, commands, hooks, MCP) — means the projection layer largely transfers, but where Cursor records local enable-state is undocumented (possibly app-local like its user rules), so plugin discovery on import is deferred. When that location is identified the Cursor adapter implements PluginIngester for it — and, by the same invariant, still never renders it back. It already receives plugin-projected components on apply like every other adapter.

See the capability matrix for source links.

type MCPSpecIngester interface {
IngestMCPSpec(raw map[string]any) source.MCPServerSpec
}

An agent whose native MCP config is a key-merge object — one root key holding one entry per server — implements this optional extension so the dest→source path can translate a single native server entry back to the canonical model in that agent’s own dialect. Reconcile’s key-level [w]rite-back type-asserts for it; an adapter that does not implement it has its MCP key items refused rather than mistranslated.

Read-only, on the same terms as PluginIngester: IngestMCPSpec is an inverse of Render, not a second render path, and the spec it returns reaches ~/.agentsync/ only through capture.Capture (§5).

Why the dialect must come from the rendering adapter, not from the pointer’s root key. Root keys are per-agent data, not a fixed set, and they collide: /mcpServers is Claude’s and Cursor’s, Gemini’s, Windsurf’s, Roo’s, Cline’s and eleven breadth-tier agents’; /mcp is OpenCode’s and Crush’s. Selecting the translator by root key therefore cannot be made correct — it silently hands a Crush entry to OpenCode’s inverse (array-shaped command, environment), demoting args/env into the Extra passthrough, and hands Gemini’s httpUrl / Windsurf’s serverUrl to Claude’s 1:1 shape, dropping the URL out of the model entirely. The rendering adapter is the only thing that knows which bytes it wrote, so the inverse belongs to it.

An optional interface rather than a method on Adapter (which would force all 32 adapters, noop and Continue included, to implement a contract they cannot honor) or a registry-side name → func table (which is exactly the hand-maintained allowlist this interface exists to delete). The name follows the PluginIngester precedent: it says what the implementor does.

Implemented by every adapter that renders MCP as a key-merge op — claude, opencode, codex, cursor, gemini, windsurf, roo, cline, grok, and the generic breadth tier (per its Spec’s MCPTarget, so each dialect knob is honored on write-back exactly as on ingest). Continue does not implement it: it renders one whole file per server (MergeStrategy: "replace"), and its own IngestMCPSpec operand is an element of a YAML mcpServers list inside a block rather than a root-keyed value. Its MCP write-back reaches the whole-file arm but is refused there — that arm copies the destination verbatim, which for a secret-bearing kind would put YAML into a canonical TOML file and persist resolved secrets in cleartext — and reconcile points at agentsync import continue:mcp:<id> instead (see §5). Each implementation delegates to the SAME package translator the adapter’s Ingest uses, so a dialect has exactly one definition.

The registry-wide guard TestMCPSpecIngester_CoversEveryKeyMergeMCPRenderer (internal/cli) renders a real two-server MCP fixture (one stdio, one remote) through every registered adapter at both scopes and fails if one emits an MCP key-merge FileOp without implementing the interface — and asserts that feeding each adapter’s own rendered entry back through its own IngestMCPSpec returns the modeled fields as modeled fields, never demoted into Extra. That second half is what makes “route write-back through the rendering adapter” a guarantee: the routing is only correct if each implementor’s inverse is faithful to its own render.

LoadProjected flattens every enabled plugin’s components into one canonical model, and adapters derive each destination path from the component’s Name. So two plugins shipping a same-named component render two files at one path — a real, stock case: feature-dev and pr-review-toolkit (both official Claude plugins) each ship agents/code-reviewer.md. Apply refused, correctly, since silently keeping one is data loss. But both files live under the marketplace-managed plugin cache, so the remedy the error named — “rename one” — was one the user structurally could not perform (issue #211).

A plugin-provided subagent, skill, or command is therefore renamed to <plugin>-<name> at projection, in marketplace.namespaceProjected, which also stamps Plugin (the providing plugin’s id) and BaseName (the upstream name) onto the component. Three consequences worth knowing:

  • It is not an adapter concern, structurally. Provenance is dropped when loadProjected appends each plugin’s components into flat slices, so by the time an adapter can detect a collision, the information needed to resolve it is gone. Rewriting Name (rather than teaching each adapter an “effective name”) is also what keeps every render site correct with no adapter change.
  • The frontmatter name key is rewritten in step, when present. Renaming only the file would leave the components colliding anyway: Codex’s name is the agent’s identity and the Codex adapter prefers it over the file stem, and Claude’s Agent Skills require the frontmatter name to match the skill directory. An absent name stays absent, so this never invents an identity the upstream artifact did not declare.
  • Hand-authored components are never renamed. Plugin/BaseName are empty for them. Note this is not the same as “a plugin can never take your name”: the derived name is not injective, so a plugin CAN land on a name you chose. That residual is caught and reported by checkProjectedConflicts (below), never silently resolved in the plugin’s favour.

The separator is a hyphen because Claude Code documents a subagent name as a “Unique identifier using lowercase letters and hyphens” — its familiar plugin:agent form is a scoped identifier Claude Code derives from the plugin directory, never a name value, and : is rejected by source.ValidateComponentID (it becomes a filename, and a colon is illegal on Windows). A plugin id comes from a marketplace, outside agentsync’s trust boundary, so every derived name is re-validated through that same write-boundary sanitizer before it can reach a path or a diagnostic.

MCP and LSP servers are deliberately not renamed. They are id-keyed and already covered by checkProjectedConflicts, whose hard failure on a same-id divergence is a security property: two sources claiming one server id can be a silent endpoint hijack, which is a case to refuse rather than to rename apart. They ARE stamped with provenance, so the capture paths can still refuse them. Hooks have no name key at all.

The derived name is not injective, so the collision guard stays. Plugin a shipping b-c and plugin a-b shipping c both derive a-b-c, and a user who hand-authors feature-dev-code-reviewer collides with what plugin feature-dev derives. checkProjectedConflicts therefore guards name-keyed components exactly as it guards MCP/LSP ids — fatal for the mutating loads, a warning for the lenient read-only ones — and its message names each side’s origin (the providing plugin, or the user’s own canonical file). Without it those cases reach the render pipeline, where divergent content aborts with a message that can name neither origin (a FileOp carries no provenance) and identical content is silently deduped, dropping a component with no report at all. The silent drop is the reason the guard exists; the loud one it merely makes actionable.

Namespacing itself never fails. An earlier revision validated the derived name at projection and returned an error — a regression, because the projection’s own validateProjectedName permits : and control runes, and because loadProjected propagates a projection error regardless of lenient, taking down the read-only commands whose whole design is to degrade and show state. The name safety lives downstream at the single dispatch waist, where render.Plan runs ValidateComponentID over every component id before any of them is joined into a destination path.

The rename happens once, on the first apply after upgrading, so apply reclaims the pre-rename destination files it previously wrote — see §7 Safety primitives. Without that, a stale ~/.claude/agents/code-reviewer.md would sit beside the two namespaced files and Claude Code would load it, leaving the user with more duplicate agents than before.

A second optional extension lets callers redirect the warnings an adapter’s Ingest emits (lenient-YAML notices, dropped components, …) away from os.Stderr:

type WarnEmitter interface {
SetStderr(w io.Writer)
}

Concrete adapters (claude/opencode/codex) implement it; the noop adapter doesn’t (it emits no warnings). Four contract rules every implementor honours:

  1. SetStderr(nil) resets to the default (os.Stderr) — and MUST NOT panic. Pinned by per-adapter TestSetStderr_NilResetsToDefault tests that capture os.Stderr via a pipe and assert the warning actually lands there — a faulty SetStderr(nil) routing to io.Discard would not pass.
  2. Configure stderr BEFORE Ingest. Adapters snapshot the writer at Ingest entry (warn := a.stderr()), so calling SetStderr mid-Ingest is ignored for the remainder of that call. The RouteTo-before-Ingest pattern is the supported one; don’t depend on dynamic switching.
  3. Compile-pin against adapter.WarnEmitter. Each adapter’s claude_test.go / opencode_test.go / codex_test.go carries a var _ adapter.WarnEmitter = a line so dropping the method fails the test build, not a runtime no-op.
  4. The writer’s lifetime is the caller’s problem. Today’s caller (import) uses the restore-handle pattern — defer warnW.RouteTo(a)() evaluates the inner RouteTo(a) immediately (wires the writer) and defers the returned restore closure (calls SetStderr(nil) on the way out) — paired with defer warnW.Flush() to drain any partial line in the WarnWriter’s line-assembly buffer.

import is the only caller today: it wraps cmd.ErrOrStderr() in a ui.WarnWriter that restyles "warning: " line prefixes to bold-yellow "⚠️ warning:", then defer warnW.RouteTo(a)() injects the wrapper and arranges the restore. The same wrapper backs capture.Opts.Warn and the command’s own io.warn calls, so every warning the user sees during an import — adapter, capture, or CLI — shares one styling.

Kept off the core Adapter for the same reason as PluginIngester: an adapter that emits no Ingest warnings shouldn’t be forced to implement a setter it’ll never use.

A third optional extension lets an adapter declare the on-disk directories the apply tail should keep in a local-only git rollback history (issue #118, step 9 below):

type VersionedDirs interface {
VersionRoots(scope Scope, project string) []string
}

It is read-only and does not widen the Render/Apply contract — it only reports directories to back up. The contract every implementor honours:

  1. ScopeProject MUST return nil. Project destinations live inside the user’s own project repo and are left to that repo’s source control; git backup is a user-scope-only feature. (project.Merge drops any project [destination_directory_git_backup] override for the same reason.)
  2. The unit is the directory, not the agent. An adapter returns its own config dir plus any shared cross-agent dir it writes into — Codex and several breadth agents all write skills to ~/.agents/skills; OpenCode writes skills to ~/.claude/skills. The apply tail unions these across every enabled adapter, de-nests them (drops a root nested under another — never a repo inside a repo), and de-dups them (a shared dir is one repo, checkpointed once). “Nested” is decided by the lexical paths.ContainsDir — the declared spelling, no symlink resolution — because git backup inits, opens and stages by the declared path and agit.Detect walks that spelling’s ancestors: a child root that is a symlink out of its parent folds into the parent like a real subdirectory (a separate repo at the link’s target would never be opened, and would make the parent un-revertable through the nested-repo probe). Only the never-at-$HOME guard below compares directory identity (paths.ContainsDirResolved).
  3. Paths are absolute, after AGENTSYNC_TARGET_ROOT redirection — they match the FileOp.Path values the adapter emits, so tests redirect $HOME uniformly.
  4. $HOME-level strays are excluded. A deep agent may also write a top-level file outside any returned dir (Claude’s ~/.claude.json); those are intentionally not versioned — agentsync never inits a repo at $HOME. The apply tail enforces that invariant centrally: a declared root that is $HOME or an ancestor of it — by identity (paths.ContainsDirResolved: symlinks resolved through the deepest existing ancestor, case folded on macOS/Windows) or by spelling (paths.ContainsDir, so /home is refused even when $HOME=/home/alice is itself a symlink to /data/alice) — is dropped from the union with a warning, before de-nesting could fold every other root into it (enabledVersionRoots / partitionVersionRoots, TestEnabledVersionRoots_NeverAtOrAboveHome). No hardcoded adapter root can trip it; an env-derived one (Grok’s GROK_HOME) can, and the adapter refuses the two obvious values (/, $HOME) with an error before it gets this far.

An adapter with no versionable directory (e.g. noop) does not implement it. The apply tail’s use of these roots is the step-9 narrative in §4.

HookIngestGuard and HookEventNamer (optional)

Section titled “HookIngestGuard and HookEventNamer (optional)”

Two further optional extensions serve import’s stale-hook retirement (the second-order issue #124 fix — a hook event captured while clean and later enriched natively must not leave a stale canonical hooks/<event>.toml that the next apply rewrites lossily):

type HookIngestGuard interface {
RefusedHookEvents(scope Scope, project string) ([]string, error)
}
type HookEventNamer interface {
NativeHookEvent(canonical string) (string, bool)
}
  • HookIngestGuard (claude, gemini, cursor, codex, grok, and Factory — every hook-rendering adapter; the other breadth-tier agents do not implement it) re-reads the destination and returns the hook events ingest semantically refused: unmodeled fields on well-formed entries, plus non-command handlers where the adapter’s render cannot round-trip them (claude, gemini, cursor, grok); codex re-renders non-command types verbatim, so it refuses unmodeled fields only. Structurally-malformed shapes (a settings.json typo) are excluded, because import deletes the canonical file for every returned event and a native typo must never be destructive. Returned names are always canonical: a renaming adapter maps its refused native spellings back (gemini BeforeTool → PreToolUse, cursor preToolUse → PreToolUse), and a native-only event (BeforeModel, afterFileEdit, …) is never returned — no canonical file exists to retire. The registry-wide guard TestHookIngestGuard_ReportsCanonicalNames (internal/cli) enforces the canonical-spelling half of that contract for every implementor; the never-return-a-native-only-event half stays pinned per-adapter (no native-only event can exist in the guard’s canonical-rendered fixture, so each adapter’s guard table carries its own “native-only event is never refused” row).
  • HookEventNamer (gemini, cursor) reports the native spelling an adapter uses in its owned /hooks/<name> pointers (PreToolUse → BeforeTool / preToolUse). Canonical hooks are shared across agents, so a retirement disowns every agent’s state key for the event at that scope — a renaming agent’s key under its native spelling, and Factory’s key at /<event> because hooks.json is the event map rather than a hooks object. An adapter that renames without declaring would silently escape the disown and the next apply’s orphan cleanup would delete the event from its native config; the registry-wide guard TestHookEventNamer_CoversEveryRenamedPointer (internal/cli) makes that state unrepresentable.

4. The apply pipeline (Source ▶ Destination)

Section titled “4. The apply pipeline (Source ▶ Destination)”

agentsync apply is local-only and offline. It renders from the cache that agentsync plugin outdated populated.

flowchart TD
    A["cli: newApplyCmd"] --> B["source.Load(fs, home)"]
    B --> P["project.Discover &lt;root&gt;/.agentsync/<br/>load + project.Merge (if project scope)"]
    P --> C["marketplace.LoadProjected<br/>(plugins → components, from cache)"]
    C --> SEC["secrets.SubstituteCanonical<br/>→ secrets.Resolved"]
    SEC --> REN["render.Plan<br/>(each adapter.Render → FileOps + Skips)"]
    REN --> CL["drift.Classify per file/key<br/>(H_src vs H_applied vs H_dest)"]
    CL --> W["render.Apply via DestWriter<br/>(two-phase atomic write + backups)"]
    W --> ST["state.Save targets.json<br/>(record new hashes)"]
    W --> RPT["render.TranslationReport<br/>(✓ / ◐ / ✗ per plugin per agent)"]

Key stages:

  1. Load the canonical source (internal/source).
  2. Overlay the project source tree (<root>/.agentsync/) if the apply is project-scoped — load it and project.Merge it onto the user canonical (internal/project).
  3. Project plugins into components from the local cache (internal/marketplace).
  4. Resolve secrets — ${secret:…}/${env:…} → secrets.Resolved (internal/secrets).
  5. Plan — each enabled adapter renders the resolved model into FileOps and Skips (internal/render, internal/adapter/*).
  6. Classify each file/key with the 3-way drift classifier (internal/drift).
  7. Write through DestWriter with two-phase atomic writes and foreign-collision backups (internal/render, internal/iox).
  8. Record new hashes in targets.json (internal/state) and print the translation report.
  9. Git-backup (issue #118) — for a user-scope apply (including the apply at the tail of plugin upgrade), checkpoint each destination directory into its own local-only git repo (internal/cli/gitbackup.go → internal/git). The unit is the directory, not the agent: every enabled adapter declares its version roots via the optional adapter.VersionedDirs extension (its config dir plus any shared cross-agent dir it writes — Codex and several breadth agents all target ~/.agents/skills; OpenCode targets ~/.claude/skills). The apply tail unions those roots, de-nests them (drops a root nested under another, so there’s never a repo inside a repo), and de-dups them (a shared dir is one repo, checkpointed once with all its files regardless of how many agents wrote there). De-nesting only sees the current run’s roots, so before initializing a dir agentsync also scans the filesystem (git.HasNestedRepoBelow) and refuses to init a repo that would wrap an existing one — the cross-run case where a child dir was versioned before a parent-dir agent was enabled. That same scan (symlink-aware — it shallow-probes a symlinked subdir’s target for a .git) is re-run on every later path that assumes the work tree is wholly agentsync’s: the commit path warns if a foreign repo has appeared under an owned root (the append-only checkpoint is still recorded), agentsync revert refuses/skips such a root before its destructive hard reset (an error under --strict), and doctor downgrades a StateUntracked root that contains a nested .git to a warn so its report matches what apply does. The managed-file set committed under each root is the written set from step 7 plus any tracked deletions; $HOME- level strays (Claude’s ~/.claude.json) are never versioned (agentsync never inits a repo at $HOME). This step is best-effort (the files are already written and state already saved, so a git failure never fails the apply), opt-out (the [destination_directory_git_backup] mode — prompt/on/off — plus the apply --no-git-backup per-run bypass), and never pushes: internal/git’s own API exposes no remote/push calls, enforced by TestNoPushSurface — a source-scanning convention guard (a grep over the package’s non-test .go files for banned remote/push tokens), not a type-level impossibility. The go-git *Repository that Repo holds still has Push/CreateRemote, so this is a convention guard (a struct-shape or reflection change could defeat the scan), in the same spirit as the secrets lint fence (§8 / SECURITY.md). agentsync revert (which takes the same global lock apply holds) rolls a dir back to a prior checkpoint append-only. Snapshotting uncommitted hand-edits to tracked files is enforced inside the engine Restore (safe-by-construction for every caller, not just the CLI wrapper), so the rollback can’t lose them; a partial-reset failure after that snapshot surfaces a recovery hint naming the pre-revert HEAD and the snapshot commit. .state/ is untouched by this step — the two are complementary (operational memory vs. user-facing rollback history).

--dry-run runs steps 1–6, then a non-writing pass of step 7 (the writer’s merge

  • convergence check, no disk write) so it can label each destination ✓ synced vs → write and preview foreign-collision backups, and prints the plan/report — all without writing a byte (and it skips the git-backup step 9 entirely).

One implementation. The pipeline above lives in a single callable, runApplyPipeline (internal/cli/apply.go), and apply is not its only caller: the re-apply tail of plugin upgrade and plugin upgrade --all (reapplyAfterPluginChange) runs the same function with the default options — a real apply of every enabled agent, with destination git backup governed by [destination_directory_git_backup] exactly as for apply. It used to be a second, hand-maintained transcription of steps 1–7 and the state-recording half of step 8, and the copy had already fallen behind: no translation report (the rest of step 8), no pre-apply baseline or checkpoint (step 9), no removal-aware headline, no backup pruning. Every apply-side invariant added from here holds on the plugin path by construction (#231).


5. The capture pipeline (Destination ▶ Source)

Section titled “5. The capture pipeline (Destination ▶ Source)”

The reverse path — used by agentsync import and reconcile’s [w]rite-back — goes through exactly one function, capture.Capture:

flowchart LR
    NATIVE["native config on disk"] --> ING["adapter.Ingest<br/>→ source.Canonical"]
    ING --> CAP["capture.Capture"]
    CAP --> RR["secrets.ReReferenceCanonical<br/>(cleartext → ${secret:…})"]
    RR --> PRES["preserve targeting<br/>(agents source-only; enabled if ingest carried none)"]
    PRES --> WR["source.Write* (templated only)"]
    WR --> SRC["~/.agentsync/*.toml"]

capture.Capture is the single dest→source funnel. It re-references any resolved secret back to its ${secret:…} form before writing, and it preserves the server targeting the destination doesn’t fully carry: an MCP/LSP server’s agents list is source-only (no native dest carries it) and is always restored, while enabled — which some destinations do carry (Codex reads a native enabled back, issue #152) — is restored from source only when the ingest carried none, so a real native enable/disable round-trips instead of being reset. It also normalizes numeric passthrough values: adapter ingests decode native JSON/JSONC with UseNumber, and a json.Number left in an MCP/LSP Extra map would be marshaled by go-toml as a TOML string (timeout = '30'), silently flipping the value’s native type on the next render — so Capture converts every json.Number to int64/float64 before writing. No other code path writes destination data back into the source. (Two guarded code paths delete canonical files without going through Capture: reconcile’s removeDroppedSource unlinks mcp/<id>.toml when the user writes back a destination-side server deletion — gated on that chosen write-back (a per-item [w], a confirmed bulk [W], or --auto-writeback), withinDir-bounded to ~/.agentsync — and import’s stale-hook retirement calls source.RemoveHooks on hooks/<event>.toml. A pure deletion carries no content to re-reference, so the funnel’s secret guarantees are not in play; anything that writes content back still must go through capture.Capture.)

Plugin-provided components are never captured. A component projected from an installed plugin has no canonical file of its own — it is re-derived from the plugin cache on every load — so writing a destination back for one would mint a canonical file under its (namespaced) name. The next load would then hold two components of that name, the captured copy and the plugin’s own projection, rendering to one destination path, which apply refuses. Both dest→source entry points refuse:

  • import skips a plugin-provided component with a warning naming the plugin, and errors outright if the user named one explicitly. An adapter’s Ingest reads the agent’s native config, where a file agentsync rendered from a plugin is indistinguishable from a hand-written one — so import projects the plugins separately (pluginProvided) and matches by component name, which is exact now that plugin components are namespaced. If that projection FAILS the filter fails closed (the import refuses) rather than proceeding with an empty skip set: the filter exists to stop import poisoning the canonical source, and it is fed by plugin data, so a fail-open would be a way to switch the defence off.
  • reconcile’s [w]rite-back refuses the item and points at [o]verride. It works from the PROJECTED canonical, so provenance is already on the components (pluginProvidedSourceIDs) and no re-projection is needed. The check sits at writeBackItem, the dispatch waist, so it covers both shapes: whole-file components matched by SourceID, and key-level MCP/LSP servers matched from the item’s JSON pointer.

This covers MCP servers, LSP servers, and hooks too — plugin-owned but not namespaced. Capturing an MCP/LSP server mints a canonical copy that renders identically today and diverges the moment the plugin updates, at which point checkProjectedConflicts refuses every load. MCPServer.Plugin / LSPServer.Plugin / Hook.Plugin carry that provenance (derived state, never serialized, each classified non-secret in walkerCovered).

Hooks are filtered per HANDLER, not per event. A canonical hooks/<event>.toml holds many handlers from many sources, so refusing a whole event because a plugin contributed one would silently drop the user’s own. The join key is a content signature (event + matcher + type + command + timeout), which is what lets import match a plugin’s projected handler against the agent’s native ingest — the ingest carries no provenance at all. Reconcile resolves hooks to no owner by construction: key-level write-back is implemented for MCP servers only and already errors for every other pointer shape, so no hook keys are registered for it to find. The lookup still returns "" explicitly rather than falling through, so implementing hook write-back later cannot silently permit the capture.

The key-item lookup takes the component KIND from the op’s SourceID, never from the pointer’s root key. Root keys are per-agent data (generic.MCPTarget.RootKey grows with every agent added: /context_servers, /servers, /amp.mcpServers), so a hand-maintained allowlist silently drops the refusal for each one someone forgets. Probing the id against both kinds instead would be worse than imprecise: a plugin shipping an LSP server named github would make an /mcpServers/github pointer resolve to it, refusing write-back of the user’s own MCP server and blaming a plugin that does not own it. Continue is the one adapter that renders MCP as a whole-file op (one file per server), so servers are registered under both the bare mcp/<id> key and the mcp/<id>.toml SourceID form.

Key-level write-back derives BOTH the kind and the dialect. The kind comes from the op’s SourceID, exactly as above (cli.keyItemKind, the single derivation the plugin-owner lookup, the pointer→source inversion and explain’s pointer→component mapping all share). The dialect — how to read one native server entry back into the canonical model — comes from the rendering adapter, through the optional adapter.MCPSpecIngester extension, never from the pointer’s root key. Selecting a translator by root key cannot be made correct, because root keys collide: /mcpServers is Claude’s and also Cursor’s, Gemini’s, Windsurf’s, Roo’s, Cline’s and eleven breadth-tier agents’; /mcp is OpenCode’s and also Crush’s. A root-keyed selector therefore handed a Crush entry to OpenCode’s inverse — demoting args/env into the Extra passthrough — and Gemini’s httpUrl or Windsurf’s serverUrl to Claude’s 1:1 shape, dropping the URL out of the model entirely, while refusing every root nobody had added to the list. Asking the adapter that wrote the bytes removes the whole class. An adapter that renders an MCP key-merge op without declaring its inverse is REFUSED rather than guessed at, and the registry-wide guard TestMCPSpecIngester_CoversEveryKeyMergeMCPRenderer turns that state into a failing test.

The whole-file arm applies the same rule from the other side. It copies the destination verbatim into the canonical file the SourceID names — right for the text components (skills, subagents, commands, memory), whose canonical form is the rendered text, and wrong for the structured, secret-bearing kinds walkSecretFields visits — so a SourceID under mcp/, lsp/ or hooks/ is refused there by kind, with agentsync import <agent>:<component>:<name> as the remedy (it captures the edit through the adapter’s Ingest and capture.Capture). Continue’s per-server YAML is the one render that reaches that arm today; before the refusal, [w] overwrote ~/.agentsync/mcp/<id>.toml with the YAML and persisted the resolved secrets in cleartext, outside capture.Capture. Pinned by TestWriteBackFileItem_Refusals and TestReconcile_Writeback_ContinueWholeFileIsRefused.

Pointer segments are RFC 6901 encoded, so the entry segment is DECODED (jsonkeys.UnescapeToken) before it is used as a destination map key or a canonical filename. Using it raw was a live bug: an MCP server id containing ~ arrived as til~0de, missed the destination map, and a miss is the tombstone signal — so an edited server was reported as a destination-side deletion and the user’s edit was discarded.

Both lookups are scoped to the canonical the render actually uses: at project scope that is the project-only overlay, so a user-scope plugin never shadows a project component that merely shares its name.

One exception, and it is asymmetric on purpose. A component the user ALSO declares is not treated as plugin-provided — but only for hooks, because source.WriteHooks replaces the whole hooks/<event>.toml, so a handler import declines to capture is erased from the canonical source. Everywhere else (WriteMCP, WriteSkill, …) writes one file per component, so a refusal deletes nothing — and letting the user’s claim win there would be worse: import would capture the drifted native content, the user’s copy and the plugin’s projection would diverge, and every later mutating load would hard-fail in checkProjectedConflicts. The override exists to prevent a DELETION, not to decide ownership.

The edit belongs upstream in the plugin, or the plugin can be disabled. This is the capture-side complement to plugin component namespacing.

Re-reference matches by value, so it cannot distinguish a moved or rotated secret from a deliberate non-secret edit. As a fail-closed backstop, capture.Capture re-scans the about-to-be-written model (secrets.ResidualSecretCleartext): if a live vault secret value would still be written verbatim, or a ${secret:K} the source referenced has vanished from the captured group (rotated/edited away), it refuses the write rather than risk persisting cleartext — directing the user to update the vault or edit the source. The backstop detects live secret values regardless of length: it does not inherit the re-reference value-based fallback’s length floor (which skips 1–3 char values to avoid substring-rewriting unrelated text), because refusing to persist a leak is not a rewrite — so even a 1–3 char credential trips it.

The backstop is also fail-closed under indeterminacy: its value prong builds its detection set by resolving the source’s ${secret:…} refs through the backend. If the backend can’t resolve them (vault locked / unavailable), that set is empty and the prong is blind — it cannot prove a resolved secret wasn’t moved into a literal field. So an unresolvable ${secret:…} the source references forces capture.Capture to refuse the whole write-back rather than fall through to a warning; the user unlocks/restores the vault (or edits the canonical source directly) and retries. (${env:…} is unaffected — the value prong resolves only through the secret backend, so an unresolvable env ref stays a warning.)


internal/drift is a pure function over three hashes. For every managed file or key:

  • H_src — computed now from the canonical source
  • H_applied — recorded last apply in targets.json, under a typed state.Key (agent · scope · project · path · pointer) — see §7.8
  • H_dest — current on-disk content (or nil)
H_applied vs H_srcH_applied vs H_destClassapply behavior
==cleannoop
≠=pendingwrite H_src
=≠driftoverwrite H_dest — no backup, since the file is already state-owned (reconcile is how you keep the edit instead)
≠≠, H_dest = H_srcconvergedrefresh state silently
≠≠, all differconflictoverwrite H_dest — no backup, same reason (reconcile is how you merge the edit instead)
H_applied nil, H_dest nil—newcreate
H_applied nil, H_dest ≠ nil—foreign-collisionback up dest, then write
H_src nil, H_applied ≠ nilH_dest = H_appliedorphandelete
H_src nil, H_applied ≠ nilH_dest ≠ H_appliedorphan-driftedback up dest, then delete

apply never blocks or prompts on any of these — it always finishes the run. Only foreign-collision and orphan-drifted get a per-file backup before the write/delete (see below): those are the two cases where the destination holds content agentsync doesn’t already own in state. drift and conflict ARE already state-owned by definition, so the writer’s per-file backup path (Writer.maybeBackupFileOp) skips them and overwrites directly — the hand edit is simply lost; reconcile is how you catch it first, and a user-scope apply’s destination git-versioning (§4 above, default prompt, opt-out) is the after-the-fact recovery net when enabled — absent entirely at project scope, so a project-scope drift/conflict overwrite has no automatic recovery at all beyond your own source control of the destination.

The same state-ownership check governs key-merge ops (Writer.maybeBackupKeyOp) per JSON pointer, not per file — a drifted/conflicted key you own is overwritten with no backup exactly like a whole-file drift/conflict. Two narrower exceptions exist there: an owned top-level section (mcpServers, hooks, …) holding a foreign scalar/array instead of an object — which can only mean something else wrote there, since agentsync never stores anything but an object at an owned key — backs the whole file up unconditionally, without consulting ownership; and any ONE unowned, differing pointer elsewhere in an otherwise-owned file triggers a whole-file backup that, as a side effect, also preserves your owned, drifted keys alongside it.

reconcile --auto-safe resolves nothing on its own: every item that reaches its loop needs manual review (drift, conflict, foreign-collision, orphan-drifted, or an orphan), so it reports each one as skipped and leaves orphans in place. drift.SafeForAutoApply names the classes that could be resolved without losing work (clean, pending, new, converged) but has no production caller today.

Orphan reclamation on apply. apply itself reclaims two kinds of orphan so a removed component doesn’t linger in the destination: emptied key-merge sections (an MCP/hook/LSP section whose source went empty — cleaned via a synthesized empty-merge op) and whole-file components whose source_id is under skills/, subagents/, commands/, or the RETIRED agents/ spelling — a whole skill, one bundled scripts//references//assets/ file within one, a subagent, or a slash command that the source no longer renders. The retired prefix is listed because the canonical agents/ → subagents/ rename ships in the same release as namespacing: an upgrading user’s state still holds the old spelling, and the only rewriter runs from migrate subagents, which no-ops for a user whose subagents come only from plugins. Without it their pre-rename destination would never be reclaimed — the exact leftover this exists to remove. In every case the writer deletes the orphaned file and backs up an orphan-drifted dest first (a hand-edit is never destroyed un-preserved). If that pre-delete read fails for any reason other than “already gone” — EACCES, EIO, EISDIR, or a non-regular shape like a FIFO whose read would BLOCK rather than fail — that one delete is skipped with a warning rather than performed blind: agentsync cannot tell whether the destination held an unsynced edit, and convergence is never worth data loss. The run itself continues, because a lingering orphan is not data loss while a failed apply would wedge every other agent’s writes. A skipped delete is retried: PruneStaleState keeps the state entry for a reclaimable destination that is still on disk, so the next apply tries again and warns again rather than forgetting the file forever. A path another agent holds is not an orphan at all (#246): apply skips the delete, and PruneStaleState releases the dropping agent’s state entry so status does not keep offering a deletion that will never run. “Holds” means one of two things, collected into render.SharedDests: an agent in this run’s plan writes the path, or an enabled agent that --agents left out of the run already owns it in state (RenderPlan.WithSiblingOwners — the same rule agent disable --purge uses to keep a shared file). Two consequences are deliberate. Unselected agents are not rendered, so one of them failing to render cannot fail an --agents run, including status. And the test is ownership, not “would render”: an agent that has never applied owns nothing, so it cannot keep a path alive on another agent’s behalf — otherwise the dropper releases its entry, nothing tracks the file, and once that agent is disabled it is never reclaimed. Only agents absent from the run are read from state; a full apply reads none, so an entry every agent has stopped rendering is still reclaimed. Empty-directory pruning applies to skills only — a skill is a directory under the Agent Skills spec, so removal must reclaim the whole tree, pruned up to but never including the agent’s skills root; subagents and commands are flat files in a directory the agent always owns.

Subagents and commands joined skills here because plugin component namespacing renames every plugin-provided component exactly once, on the first apply after upgrading. Without reclamation the pre-rename file would linger, and Claude Code reads every file in its agents directory — its docs are explicit that two same-name definitions in one directory mean it loads only one, “chosen by filesystem read order rather than a documented precedence”. The same convergence argument covers an ordinary removal from the canonical source, which previously lingered until a reconcile.

Granularity. Structured files (JSON/JSONC/TOML) are tracked per JSON pointer, so agentsync can own $.mcpServers.github inside ~/.claude.json without touching keys it didn’t write. Those untouched keys are foreign keys — surfaced in status but never entering the classifier. If a structured file fails to parse, the read degrades to an empty document: every key agentsync owns in it classifies against an absent value — per pointer, never as one file-level item.

Every surface that classifies — status, diff, reconcile, explain — walks the plan through one shared iterator, walkPlanItems in internal/cli/planwalk.go, which composes render.IsKeyMerge, render.CollectPointers, render.OrphanFiles, the guarded destination readers and drift.Classify. Whole-file ops are deduped by destination path per agent; key-merge ops never are, because one agent emits several of them to one file (Codex’s /mcp_servers and /hooks both land in config.toml). Merged keys are walked in sorted pointer order, so status --json, diff and reconcile list them reproducibly. Each surface keeps its own presentation on top: status re-partitions whole-file rows ahead of key rows, and both status and explain fold permission drift into the class of a content-clean whole file — measured against op.Mode, the mode the next apply chmods to, which is the same question diff’s mode hunk asks (planItem.opModeDrifted, one predicate behind all three, so they cannot disagree). reconcile still ignores mode entirely; that gap is #245. diff masks and compares text, reconcile dedupes the orphans the walk already excluded (a path any enabled agent still renders), explain groups by owner.

A symlinked destination is read through only when AGENTSYNC_ALLOW_SYMLINK_DEST=1 — the same switch under which iox.AtomicWrite writes through the link. iox.SymlinkDestAllowed is the one reading of the switch, and destReadPath (internal/cli/destread.go) is the read-side gate that the three whole-file destination facts — the content hash, the permission bits and the text — pass through. Unset, all four surfaces answer an opaque sentinel that can never equal a content hash, so the classifier sees a changed destination (drift, or conflict/foreign-collision by its usual table); diff prints a symlink hunk naming the switch rather than reading through and reporting no difference; reconcile shows the SHA display instead of a text diff, and its [w]rite-back refuses to capture through the link with advice that omits [o]verride (that omission now reflects a clean REFUSAL rather than a hazard: Writer.Write’s mode arm used to chmod through the link before the policy was consulted, and since #248 it resolves through iox.ResolveSymlinkDest like every other write). Set, all four resolve the link and compare the file it points at, so a converged chezmoi setup reports clean; a link that does not resolve answers a second sentinel so the advice is “fix the link”, not “set the switch”. A link to a FIFO, device or directory is a shape problem the switch cannot fix: it is refused as a bare one is, and diff prints a shape hunk for both — as it does for a path it cannot stat, which shares the token. The mirror is a policy, not a prediction: apply itself only fails on a symlinked destination when the content differs (its convergence read follows the link), so the unset answer means “a managed regular file became a link you have not opted into — that is drift”, not “the next apply would fail”. The rule covers whole-file destinations only; the symlink sentinel is a whole-file-only policy signal. A key-merged destination is decoded through the link on every surface, as apply treats it (a converged symlinked key-merge destination is a no-op plus a chmod through the link, a differing one is ErrSymlinkDest — exactly as for a whole-file destination, so the read-through already has apply-parity): refusing it would make every owned pointer of a chezmoi ~/.claude.json classify against an absent value permanently, because the classifier has no per-pointer sentinel to carry, with reconcile’s [d] showing <absent> and its [w] failing.


All present in v1.0 (internal/iox, internal/render, internal/state):

  1. Two-phase atomic write — write to .state/staging/, fsync, rename onto the final path. A crash leaves either the old or the new file, never a partial.

  2. File lock — gofrs/flock on .state/apply.lock serializes concurrent apply/reconcile. apply --dry-run is read-only and takes no lock.

  3. AGENTSYNC_TARGET_ROOT — every dest path resolves through one helper (internal/paths), so tests redirect $HOME to a tmpdir. A forbidigo rule bans os.UserHomeDir() in _test.go. The redirect is a sandbox: while it is set, the canonical source resolves to <root>/.agentsync and the user-facing overrides (AGENTSYNC_HOME, Grok’s GROK_HOME) are ignored — otherwise an override exported on a machine that actually uses agentsync would outrank the redirect and every test resolving the canonical source would land on the developer’s real tree (issue #270). Third-party agent home variables are read only through paths.AgentHomeOverride, never a raw os.Getenv (TestAgentHomeVarsReadOnlyThroughPaths scans the sources). The test harness (internal/testenv) additionally scrubs every ambient AGENTSYNC_* override plus GROK_HOME/NO_COLOR/EDITOR from the process at package init — before any TestMain or t.Setenv — simply by being imported (ScrubAmbient; TestScrubRunsAtInit re-executes the test binary to pin the wiring), and CI runs the suite once pristine and once with those variables exported (just test-release-configured; TestConfiguredEnvLegCoversAmbientVars keeps the three lists in step). internal/cli’s TestMain also runs under a throwaway HOME and fails the package if any test wrote there instead of into its redirect. PATH cannot be scrubbed, so a test whose outcome depends on an agent binary being absent neutralizes it itself ("PATH": t.TempDir()), and the configured container leg puts a stub for every probed agent binary on PATH (TestConfiguredLegFakesEveryAgentBinary keeps that list complete).

  4. First-apply backups — the foreign-collision case copies the pre-existing destination into .state/backups/<ts>/ before writing. Symlinked destinations are refused by default — and, on the read path, classified as drift rather than read through (§6).

  5. Manifest-SHA pinning — every plugin records a tree:v1: content hash over its entire cache tree (every projected component body — skills, command/subagent markdown — not just plugin.json, excluding .git/), so a re-uploaded version or a tampered component body is detected as drift rather than silently consumed. (An entry-only plugin with no cached bodies is pinned over its marketplace entry.)

  6. Display-boundary sanitization, enforced by type (internal/untrusted) — a fetched/native plugin or marketplace id, version, or name can carry terminal escapes (a screen-clear/recolor CSI, an OSC title-set) or deceptive bidi / zero-width runes (“Trojan Source”). Those fields are the defined string type untrusted.Text, whose String() runs Sanitize, so printing one through fmt strips the danger by construction; the raw value is reachable only via the explicit Unverified() (filesystem/lookup use, never display). The wire format is unchanged (Text is a string kind — omitempty and --json raw output are preserved). This also covers the native-ingested plugin name: a PluginIngester’s adapter.NativePlugin.Name is untrusted.Text, so the status/doctor “undeclared native plugins” notes that print it sanitize by construction (via untrusted.Join) with no per-site ui.Sanitize wrapper. Reflection-based TestUntrustedFieldGuards (internal/{source,marketplace,render,adapter}) fail the build if a new string field on those structs is left unclassified, so a future metadata field can’t ship as a raw string a new print site would leak. Carve-outs (hex SHAs, %q URLs, user-supplied CLI args, enum modes, and the import-only diagnostics surface — native marketplace ids / source types) stay plain strings. See SECURITY.md.

  7. Render-time component-id guard (render.Plan) — every deep adapter joins a component id into a destination filename: a text component’s canonical Name (filepath.Join(dir, Name+ext)), and, for adapters that write one file per server, an MCP/LSP server id (continuedev’s filepath.Join(MCPDir, id+".yaml")). An id like ../../../tmp/x would render a FileOp.Path that escapes the agent’s config dir — a write-anywhere primitive on apply; a marketplace-projected MCP id is an especially untrusted source (a raw manifest map key with no traversal check of its own). The dispatch waist closes this for all adapters at once: the id set is model-wide, so Plan validates it once up front — every subagent / command / skill Name and every MCP / LSP server id (project overlay included) — with the same source.ValidateComponentID the dest→source write boundary uses (§5), so the source→dest and dest→source boundaries share one sanitizer — a separator, .., absolute path, bare ., all-whitespace, or control/deceptive rune is refused identically in both directions. A traversal-bearing id is a hard error: the whole plan is refused (never an adapter.Skip, which would imply a benign capability gap), with an agent-agnostic message that names the component kind and id — the id is model-wide, not one agent’s fault. A conservative filepath.Clean containment backstop additionally rejects any emitted write whose cleaned path still traverses upward — defense-in-depth that also covers bundled skill-file paths (Skill.Files[*].Path), which legitimately contain / and so are not single ids. Plan reads only the id strings (via a string-only secrets.Resolved.ComponentIDs() accessor), never unwrapping the resolved model to a writable source.Canonical, so the guard doesn’t cross the secrets lint fence (§8).

  8. Injective state keys (internal/state/key.go) — every entry in targets.json is addressed by a typed state.Key (agent · scope · portable project root · portable dest path · JSON pointer), which is the Go map key type of Targets.Files / Targets.Keys. A hand-rolled fmt.Sprintf key is therefore a compile error, and the format has exactly one owner instead of a string contract spread across render, cli and one adapter. (A state.Key{…} composite literal still compiles — it just bypasses NewFileKey’s paths.HomeRelative, which is how tests plant keys in a specific portable spelling.) The wire form is length-prefixed — as1|6:claude|4:user|0:|29:${HOME}/.claude/settings.json|0: — the same technique hookSignature uses, so decoding never interprets a field’s bytes and ParseKey is a total left inverse of String(): two distinct destinations can never share a key. The v1 agent:scope:project:path[:ptr] encoding could: : is legal in a POSIX path, so a project root containing one produced keys that string-prefixed a sibling project’s, and a prefix-scoped prune deleted the sibling’s ownership (issue #227). Every consumer now compares fields, never string prefixes. state.Load upgrades schema_version 0/1 → 2 on read; a legacy key with more than one possible reading refuses the load, naming the key and the remedy, rather than guessing and recording it under the wrong project. Disambiguation leans on one property — every adapter joins its project-scope destinations onto the project root, so the reading agentsync actually wrote is always contained, and a competing reading can only push the contained count to two and force a refusal, never take the acceptance for itself. Keeping that true makes the containment test a per-component filter: it treats \ as a separator alongside / (a Windows project root outside %USERPROFILE% is stored verbatim, drive colon and backslashes included, and a slash-only test would find no contained reading and refuse every such file), and it deliberately does not collapse ... path.Clean did, and that collapse could drop the true reading out of the contained set and leave a false one alone in it — accepted silently under the wrong project, the very bug this format removes. For the same reason a root that normalizes to zero components (/) stays contained even though it discriminates nothing: excluding it would recover a handful of keys whose false split happens to be /, at the price of misdecoding any key whose true root is /. Over-refusal is the cheap failure here and misrecovery the expensive one, so the trade is refused; TestLegacyContainmentProperties pins the invariant over a generated corpus of 20,628 (root, destination) pairs — 41,256 keys, file and pointer — of which about 70% decode and none decodes under a project other than the root it was built from. (The counterfactual for the zero-component exclusion is not in that test: it comes from a throwaway sweep recorded in legacy.go’s comment, which says so.) Enumeration is also capped. The candidate readings are a product of the : and :/ counts and the tie-break walks all of them, so an uncapped key of a few KB could OOM-kill any command that loads state; on the 65th candidate agentsync stops counting and refuses the key. That cap bounds enumeration, not ambiguity — it fires mid-count, so a key it refuses may have had many readings, exactly one, or none — so the refusal carries its own sentinel and its own remedy paragraph, and never claims the key was ambiguous. The remedy itself is the same cheap delete-and-re-adopt. At schema_version: 2 each key is role-checked as well as parsed — a files key must carry no JSON pointer, a keys key must carry one — so a hand-edited file cannot be laxer than the v1 format it replaced.

    Downgrading is not safe, and the docs used to say it was. migrate does refuse a newer schema_version, but refusing at the migrate layer is not the same as being safe at the layer the user experiences — the question is what each caller does with that error. On a pre-schema-2 build, status, diff, apply, reconcile, explain, migrate, plugin and agent disable --purge return it (non-zero exit) and doctor reports a corrupt state file; none of them touch targets.json. But those builds’ addMarketplaceSource did st, _ := state.Load(...), so marketplace add (and import, which registers a plugin’s marketplace through the same function) fell through to state.New() and saved it: exit 0, a ✅ added marketplace … line, and targets.json replaced by a schema_version: 1 document holding only the marketplace just added — every files/keys ownership entry, every plugin pin and every other marketplace record destroyed. The canonical tree and the native files are untouched, and the next apply re-adopts each destination (backing up pre-existing content to .state/backups/ first), but that is a full re-adopt, not a no-op. Old binaries cannot be patched: the mitigation is to back up ~/.agentsync/.state/targets.json before downgrading, or delete it so the older build starts fresh. The read guard is now in place going forward (internal/cli/marketplace.go, pinned by TestMarketplaceAdd_DoesNotClobberUnreadableState and its remove twin), which protects a future downgrade past this release but nothing already shipped.

    Refusing an unreadable state file is pinned, not merely intended. The paragraph above describes pre-schema-2 builds, which no test in this tree can drive; what it says about this build is what makes a downgrade past this release survivable, and that half is now enforced. It needed to be: the tempting regression at any of the 15 state.Load call sites — if err != nil { s = state.New() }, exactly the marketplace add bug — is silent, turning a refusal into an apply against an empty state, which owns nothing and backs up every managed destination as a foreign collision. TestCommandsRefuseUnreadableState drives each command in-process against an unreadable targets.json and asserts both halves: a non-zero exit carrying the migration refusal, and the file byte-for-byte unchanged. It covers status, diff, apply (plain and --dry-run), reconcile, explain, plugin outdated, agent disable --purge, migrate subagents and doctor. The five remaining state.Load sites deliberately warn and continue instead of refusing: marketplace add/remove, pinned by the two tests named above, and import’s two sites plus opencode’s Ingest, which emit a warning: line into ui.WarnWriter.

    One boundary is narrower than the encoding: targets.json is JSON, and encoding/json rewrites an invalid UTF-8 byte in a string to U+FFFD, which would break a length prefix and make the next Load refuse the file — with no way out, since the next apply rebuilds the same key. state.Save therefore refuses a key whose any field is not valid UTF-8 (state.ErrNonUTF8Key) before writing, naming the offending destination path. Linux paths are byte strings, so this is reachable; failing closed at the moment the key is created is what makes it actionable.


The dangerous bug class is a resolved cleartext secret being persisted back into the canonical source (often a committed dotfiles repo). agentsync makes this hard to do by accident with three tiers of defense:

  • Compile-enforced (load-bearing). secrets.SubstituteCanonical returns secrets.Resolved, a wrapper that is not assignable to source.Canonical. Adapters’ Render take Resolved; source writers and capture.Capture take only the templated source.Canonical. Passing resolved data to a writer is a compile error.
  • Value-invariant (load-bearing). Secret substitution clones the model before resolving (no aliasing back to the caller’s templated copy), and the field walker only visits secret-bearing fields — so text components (memory, skills incl. their bundled files, commands) physically cannot carry a substituted secret.
  • Lint fence (defense-in-depth). A forbidigo rule forbids unwrapping a Resolved outside the two adapter Render egress sites.
  • Capture fail-closed backstop (defense-in-depth). The dest→source direction can’t be type-enforced (it legitimately writes a templated source.Canonical), and re-reference matches by value — so a secret moved into a literal-counterpart field or rotated to a vault-unknown value can evade restoration. capture.Capture re-scans the about-to-be-written model (secrets.ResidualSecretCleartext) and refuses to write if a resolved secret would persist, rather than guess.

There is one accepted residual: a deliberate two-step laundering (defeat the lint fence to obtain a writable source.Canonical, then call a source writer directly) could leak. No innocent mistake produces it, and capture.Capture always re-references. The single field list lives in walkSecretFields (internal/secrets/walk.go); a reflection-based test fails if a new string-shaped secret-bearing field is added without classification.

The MCP/LSP Extra passthrough maps (unmodeled native fields, carried verbatim) are a deliberate exception: they are not in walkSecretFields, so a ${secret:…} in Extra is written literally rather than resolved. The leak backstop scans Extra separately (scanExtraResidual) and refuses a write that would persist a live secret value through it.

The __ prefix is an agentsync-reserved Extra namespace. A __-prefixed Extra key is agentsync-INTERNAL round-trip metadata, never a verbatim native field — the sole current use is continuedev’s __block_version / __block_schema, which round-trip a Continue MCP block’s version/schema header through the shared Extra map. Because Extra is a shared canonical field, this namespace is owned by agentsync symmetrically on both sides of the round trip: the shared claude.MergeExtra (render) never projects a __ key into a destination, and claude.ExtraNativeKeys (capture) never ingests one — so one adapter’s synthetic keys can never leak into another agent’s native config, and a stray native __ key can never be captured-then-silently-dropped. An adapter that owns reserved keys reads and writes them directly (continuedev’s blockHeader / applyBlockHeader operate at the block level, bypassing both shared helpers). No supported harness uses a __-prefixed native config key.

If you ever find yourself unwrapping a secrets.Resolved outside an adapter’s Render, stop — you almost certainly want capture.Capture. The full set of invariants is in CLAUDE.md and SECURITY.md.


Every networked path lives in internal/marketplace’s fetchers, and every one of them writes only to .state/cache/. The commands that reach the network are plugin outdated, plugin upgrade, plugin add, marketplace add, import <agent>:plugin, and init <git-url>. They clone or fetch marketplaces (go-git, with a git shell-out fallback for sparse clones) and npm tarballs (registry HTTP, no npm binary required). Everything else — including apply — reads only from that cache, which keeps apply fast, offline, and reproducible in CI.

Untrusted-input hardening at this boundary: fetchers reject symlinks in tarballs (and confine git-cloned symlinks to the fetched tree, refusing any that escape), symlink-resolve a local marketplace entry’s source path before the marketplace-root containment check (so neither a symlinked path nor a symlinked intermediate directory can point the copy outside the root — see RelativeFetcher), cap decompressed size (AGENTSYNC_MAX_TARBALL_MB), verify manifest SHAs, bound component paths to the plugin cache, and reject http:///git:// sources unless AGENTSYNC_ALLOW_INSECURE_URLS=1.


A breaking rename is only half-shipped if the user never learns about it, and agentsync has no usable post-install hook on any channel it ships through: go install has none at all, a Homebrew cask’s caveats print only at install time, Scoop has nothing, and only deb/rpm have real scripts. So a hook-based message would reach a minority and silently miss everyone else. The binary is the only thing that reliably reaches an upgrading user, which makes this the single channel for anything a user must act on — and a banner that misfires is worse than none.

Where it runs. maybePrintUpgradeNotice is wired into the root command’s PersistentPreRunE (internal/cli/upgrade_notice.go), so every subcommand inherits it. Cobra’s hook is a plain field, so it shares ONE closure with enforceScopeStance (which runs last and owns the returned error, since it is the half that can refuse the command) rather than taking a second assignment: a second cmd.PersistentPreRunE = silently DISCARDS the first — it did exactly that to the scope enforcement once, with CI green throughout. TestRootDeclaresExactlyOnePersistentPreRun fails the build on a second assignment, and because cobra runs only the closest hook in the chain, TestNoSubcommandOverridesPersistentPreRun guards the same hazard one level down.

The record. .state/last-run.json (internal/state/lastrun.go), a separate file from targets.json on purpose:

  • a read-only status must be able to record that it showed the notice, and targets.json is written only by the mutating commands — a user whose daily loop never applies would otherwise see the banner forever;
  • a UX marker has no business gating on, or bumping, the drift state’s SchemaVersion.

.state/ is gitignored, so the record never travels with a dotfiles repo.

Keyed by ID, never by version comparison. Each notice carries a stable ID recorded in NoticesSeen []string; the show/skip decision is a pure string membership test (rec.Seen(n.ID)). That is what lets a user who jumps several releases see each notice they missed, and stops a pre-release or non-semver build silently dropping one. The table is therefore append-only: never rename, renumber, or delete an entry. A rename re-shows the notice to everyone who already dismissed it; a duplicate makes the second unreachable; a deletion orphans a retirement that other guards read. Since and the notice body are free to change at any time — only the ID is frozen once published.

Invariants, each test-pinned:

  1. stderr, always — status --json / diff --json / explain --json pipe their payload on stdout, and a banner there corrupts what a caller parses.
  2. Never on a fresh install. Nothing can have broken for a user with no config. The trigger is a regular agentsync.toml, not the home directory: a project-scope user’s home is created by central state, not by init. Writing the record when the home is absent also materializes it, so the user’s first agentsync init would refuse with “already contains files” — hence init seeds the record itself (seedUpgradeNoticeRecord), and a machine set up today is never later told about a rename that predates it.
  3. Silent for an unversioned (dev) build, which keeps it out of local go builds and the entire test/BDD/e2e suite.
  4. Silent for shell completion. Cobra runs the root pre-run hook for its hidden __complete request too, and completion scripts discard stderr — so one TAB would otherwise print the banner into /dev/null and record it seen.
  5. Best-effort throughout, and it takes no lock. Every read/write failure degrades to “say nothing” or “show again later”; it can never fail a user’s command. A corrupt or truncated record reads as “nothing shown here” — printed, then repaired — because an unparseable record carries no information, and treating it as fatal permanently suppressed the one message the user needed.
  6. Opting out does not record. AGENTSYNC_NO_UPGRADE_NOTICE=1 suppresses without marking seen, so unsetting it later still surfaces an unseen notice.

One consequence worth knowing. Being lock-free makes the notice path the only read-modify-write in the tool that can run concurrently with itself, and it performs two writes with different answers to that.

ensureStateGitignore is O_APPEND — iox.AtomicWrite does NOT make it safe (a fixed sibling temp name means concurrent writers share one inode) and it additionally refuses symlinked destinations, which would silently leave chezmoi/Stow users with .state/ unignored. A structural guard pins the append and forbids whole-file writers there.

state.SaveLastRun does go through iox.AtomicWrite, and therefore can tear under the same race. That is a deliberate asymmetry, not an exemption: a torn .gitignore destroys rules the user wrote and cannot recover, while a torn last-run.json is a UX marker that LoadLastRun reports as ErrCorruptLastRun — read as “this machine has shown nothing”, printed, and overwritten. It self-repairs at a cost of one duplicate banner, which is exactly why invariant 5 above treats a corrupt record as forgiving rather than fatal. The record is safe to tear because nothing depends on it; a field that made it authoritative would need a unique temp name or the global lock.

The banner’s content is pinned too, not just its plumbing: every retired command must be named, before its replacements, joined by a sanctioned break phrase, carrying an alias-free assertion and no continuity claim. The shared retirements table is the oracle for that check, for the resurrection guard, and for the stale-prose scan — so a retirement cannot be added to one and forgotten in the others.


Everything agentsync prints is one of two things, and internal/ui is the only place that decides how either looks.

What it isStreamRendering
Diagnostica notice about the runstderr✗ ERROR / ⚠ WARN / ℹ INFO / • DEBUG; message at column 9
Resultwhat the command was asked to producestdoutno level label; a success outcome leads with a curated emoji

Why this is an architectural concern and not styling. Issue #211 reported a correct, deliberate fatal error that read as a broken tool, and the formatting was the reason: agentsync: render codex: … printed flush-left, uncolored and unlabeled, directly beneath a 2026/07/28 15:03:45 WARN … line from a different subsystem. Nothing distinguished the fatal from the advisory. The cause was three unrelated renderings coexisting —

  1. commands hand-rolled their own prefixes (p.Yellow("agentsync:"), p.Cyan("note:"), p.Yellow("warning:"), a bare •);
  2. library packages called slog.Warn, and no handler was ever installed, so those fell through to the stdlib default and printed with a wall-clock timestamp in a shape nothing else used;
  3. main printed the terminal error itself, bypassing ui entirely.

All three now converge. ui.Level owns the label; ui.SlogHandler renders slog records through it and internal/log installs it as the process default from the root PersistentPreRunE; cli.Execute returns the process exit code and prints the terminal error as an ERROR diagnostic — owning the whole invocation is what lets it read the resolved --color flag off the still-in-scope root command instead of carrying it across the main boundary in package state. A slog.Warn from internal/marketplace, an adapter’s warning: line through ui.WarnWriter, and a command’s p.Warnf produce byte-identical output — for a sanitize-clean, single-line message, which is what a real diagnostic is. The three paths deliberately differ on hostile input: SlogHandler and WarnWriter sanitize, because their inputs are assembled by packages that cannot, while Diagf does not, because its callers may hand it pre-styled text. TestWarnPathsAreByteIdentical pins the clean case.

Three load-bearing rules.

  • The glyph and the level word are content, not decoration. Color is a second signal only. Piped, redirected, under NO_COLOR, or --color never, severity still reaches a log file — which is where CI reads it.

  • Color is resolved PER STREAM, not per Printer. auto asks whether the destination is a terminal, and stdout and stderr are different destinations: agentsync apply 2>err.log from a terminal has a TTY stdout and a file stderr. One decision taken off Out and reused for Err wrote ANSI into that file — the exact leak this rule forbids — and since every diagnostic goes to Err, that was the common path, not a corner case. ui.Printer therefore holds two decisions, and the writers that take an explicit io.Writer style for the stream they actually target. The two fallbacks for an unrecognized writer are deliberately opposite: a diagnostic takes the Err decision, a success line takes Out’s.

  • A command’s machine-readable stdout is never polluted by a diagnostic. This is the guarantee that matters and the one that is asserted end-to-end (TestSlogWarningNeverEntersAJSONPayload drives a real status --json with a library slog.Warn in flight, parses the payload, and rejects any level word in it). Errorf/Warnf/Infof all write to stderr, so this holds by construction for every command.

    It is not the same as “no diagnostic is ever written to stdout”, and that stronger claim would be false: reconcile’s interactive loop deliberately routes its labeled write-back failures through Fdiagf onto p.Out, the same stream as the prompt they answer. Splitting a keystroke-driven conversation across two streams would break the transcript for the sake of a rule that buys nothing there — reconcile is interactive and emits no machine-readable payload. Fdiagf exists for exactly that case, which is why it takes an explicit writer while Diagf does not.

  • Success carries no level word. An INFO on added agent: claude is noise that trains the eye to skip labels; the emoji says both “this worked” and “this is the outcome line”. Chosen by what happened, not by which command ran.

The emitter-side sentinel. Adapters and internal/capture must not depend on ui (they are below it in the layering), so they emit the plain warning: line prefix into an io.Writer they were handed, and ui.WarnWriter rewrites it into the WARN label at the one point that knows whether this terminal gets color. That split is a contract between two packages that share no types — rename it on one side and warnings silently stop being labeled, with nothing failing to compile — so both halves are pinned by TestWarnSentinelStaysWiredToTheWarnLabel.

The regression fence. A hand-rolled prefix compiles, reads like “just a Fprintf” in review, and breaks no test — the exact failure class that needs a mechanical guard. TestNoAdHocDiagnosticPrefixes parses the non-test sources of both internal/cli and cmd/agentsync and fails on a reintroduced "agentsync:" / "note:" prefix, or a bare "warning:" label token.

Both halves of that scoping are load-bearing, and generalize past this test: a source sweep has two independent ways to be vacuous, and green proves neither is absent.

  1. Wrong files. The sweep first globbed *.go relative to the working directory, but internal/cli’s TestMain chdirs to a scratch dir, so it matched nothing. It now resolves the repo root from runtime.Caller and holds a per-directory file-count floor — aggregate floors cannot defend a one-file entry, since dropping cmd/agentsync still left ~31 files from internal/cli. TestSweptDirsCoverTheEmitters pins the directory set, because a floor catches a directory going empty but not an entry being deleted.
  2. Wrong matcher. It then compared literals for equality, so the verbatim #211 emitter (fmt.Fprintln(os.Stderr, "agentsync:", err)) passed once restored to main(), and "agentsync: %v\n" would have slipped regardless. It now strconv.Unquotes each literal — so a raw backtick string is compared as the text a terminal receives — and matches by prefix.

"warning:" is matched differently on purpose: the warning: <message> sentinel is a contract with the packages that cannot import ui, so only the bare label forms are banned. The one legitimate agentsync: literal — a panic value in registry_internal.go, which surfaces through Go’s own panic output — is a commented entry in an exemption map, not a loosened pattern.

The prefix list stays narrow: a broader one (scope:, ok:) would flag legitimate field labels in a report body, where no level belongs — explain prints scope: user as a data row, not a notice.

Test isolation. The installation is process-global, so internal/log exposes Detach() and the CLI test harness calls it via t.Cleanup on every invocation. Without it, each runCLI leaves slog.Default() bound to a finished test’s bytes.Buffer — invisible today, and a data race the moment a test there adopts t.Parallel.


flowchart TD
    CLI["internal/cli — cobra command tree"]
    REN["internal/render — apply pipeline"]
    CAP["internal/capture — dest▶source funnel"]
    AD["internal/adapter (+ 10 deep adapters, generic breadth tier, noop)"]
    SRC["internal/source — canonical model + loaders/writers"]
    SEC["internal/secrets — resolve / re-reference / mask"]
    MKT["internal/marketplace — fetch + project plugins"]
    PRJ["internal/project — <root>/.agentsync/ tree overlay"]
    DRF["internal/drift — 3-way classifier (pure)"]
    ST["internal/state — targets.json"]
    GIT["internal/git — local-only go-git wrapper (no push surface)"]
    UI["internal/ui — presentation: color · glyphs · diagnostics"]
    LOG["internal/log — slog wiring (installs ui.SlogHandler)"]
    INFRA["internal/iox · paths · jsonkeys · untrusted"]

    CLI --> REN & CAP & AD & SRC & SEC & MKT & PRJ & DRF & ST & GIT & UI & LOG
    REN --> AD & SEC & SRC & ST & UI & INFRA
    CAP --> SRC & SEC & INFRA
    AD --> SRC & SEC & INFRA
    AD -. "opencode ingest ownership only (issue #148)" .-> ST
    MKT --> SRC
    PRJ --> SRC
    SRC --> INFRA
    SEC --> SRC & INFRA
    ST --> INFRA
    LOG --> UI
    UI --> INFRA

internal/ui is drawn as its own node rather than folded into INFRA because the distinction is load-bearing, not cosmetic: internal/adapter and internal/capture must not depend on ui, and an AD --> INFRA edge into a node containing ui asserted exactly the dependency §11 forbids. That constraint is why an adapter emits the plain warning: sentinel into an io.Writer it was handed instead of formatting a label itself. internal/render does depend on ui (its translation report renders through a *Printer), and internal/log depends on it too — so log is no longer a leaf.

internal/drift, internal/git, internal/iox, internal/jsonkeys, internal/paths, and internal/untrusted have no internal dependencies — they’re the leaves (internal/git is reached only from cli — internal/cli/gitbackup.go, doctor.go, and revert.go); internal/ui builds only on internal/untrusted, and internal/log only on internal/ui. See the component map for what each package contains.

Documented layering exception (opencode → state). Adapters otherwise depend only on source, secrets, and the infra leaves. The OpenCode adapter is the one exception: its Ingest reads the apply-state file (internal/state) to build an ownership filter so it re-captures only agents/commands agentsync actually wrote, never hand-authored siblings in OpenCode’s shared agents//commands/ dirs (issue #148). This is a deliberate, opencode-scoped dependency — the general fix (threading the owned-path set in from the CLI caller so no adapter touches state) is a class-wide follow-up, since no other adapter filters ingest ownership yet. The filter does not duplicate the state key format: it calls state.NewFileKey, the same constructor render.RecordOpsState uses, so the two agree structurally rather than by convention — and the round-trip test seeds ownership through the real render.RecordOpsState, so a divergence in what either side passes that constructor breaks that test rather than silently under-capturing.