Skip to content

Client ↔ provisioning synchronization

How artifacts served by the server (skills, agents, hooks, instructions, mcp) reach client providers (claude, opencode, codex, kiro, hermes, antigravity) and stay in sync. Package internal/provisioning; MCP tools sync_check/sync_apply/sync_pull; CLI cartographer status/sync/resolve. Rationale: synchronization/provisioning decisions and client decisions.

The problem

The artifacts a client must have come from two sources that change over time:

Source Where it lives When it changes
Server bundle internal/skillbundle/bundled/ (compiled into the binary) — skill only Cartographer upgrade
KB artifacts skills/, agents/, hooks/, mcp/ at the KB root (git) git pull, skill_install, edits

The bundled skill catalog is cartographer-ops (server and client operations), kb-create, kb-import, and kb-conflict-resolve. Because the bundle is compiled into the binary, clients receive operational guidance that matches their installed Cartographer version after sync.

Without a sync mechanism, the files materialized on the client silently drift on every bundle upgrade or KB git pull. Goal: the client notices the change and realigns, with a model extensible to new kinds without a redesign.

Core concepts

1. Provisioning artifact (provisioning.Artifact)

The generic unit of what gets synchronized: kind (skill/agent/hook/instructions/mcp, extensible), name, source (bundle | kb:<name>), version, content_hash, signed. The per-provider destination is resolved by destDir(kind, name, provider). A new future kind = a new emitter in BuildManifest + a case in destDir: manifest, lockfile, revision, detection, triggers, and pruning don't change.

A KB's provisioning artifacts are edited through two equivalent channels: git (clone/push of the KB repo, as usual) or the MCP tools artifact_read/artifact_write/artifact_list/artifact_delete (→ control-plane.md §KB-root artifacts; requires allow_artifact_write: true for the KB). The MCP write path goes through gitWrap (lock, commit, SyncIn/SyncOut), so it also absorbs concurrent git pushes; the manifest revision changes with the content on disk, and the layered triggers propagate to clients either way. templates/<slug>.md is intentionally a KB-only artifact: it uses the same artifact lifecycle but is excluded from BuildManifest, the kind × provider matrix, lockfiles, pruning and manifest revisions.

2. Provisioning manifest — source of truth

Generated by the server (BuildManifest, which knows the bundle + all mounted KBs): the complete set of what the client should have, with an aggregate revision — the hash of the ordered list of content_hashes (+ kind+name+source). A single changed artifact changes the revision: drift is detected by comparing a single hash.

3. Client lockfile — applied state

.cartographer-sync.lock.json next to the generated configs, in v2 multi-provider format (provisioning.LockFile): one Lock per provider (applied_revision + managed[], the files Cartographer itself created). The old v1 single-provider format is read and automatically migrated by ReadLockFile.

Two optional per-provider fields, both empty for everything written before they existed and both meaning "unchanged behaviour" when absent: base_dir — the directory managed[].path is relative to, recorded only for a provider that materializes outside the shared base dir (D141); server_version — the Cartographer server version this state was materialized against (D142), which sync compares with the live one to report that the server changed, and which a sync that cannot reach /health leaves untouched rather than erasing.

Drift = manifest.revision ≠ lockfile.providers[<provider>].applied_revision. managed[] enables pruning: sync only removes what it created, never user files.

Detection and triggers — layered model

Layer 1 — SessionStart bootstrap hook (primary trigger)

Hook cartographer-bootstrap (reserved name, provisioning.BootstrapHookName): generated entirely by the client (never from the manifest), installed/updated by EnsureBootstrapHook on every connect/sync and removed by disconnect. At the start of a session, the bootstrap.sh script (deterministic, silent, always exit 0) checks that cartographer is in PATH and launches cartographer sync --auto-trust in the background. Explicitly excluded from ComputeDiff/Apply (a server manifest will never contain it → it's never treated as an orphan to prune). Details → D60.

Provider Hook point Registration
claude SessionStart entry in ~/.claude/settings.json
codex [[hooks.SessionStart]] managed block in ~/.codex/config.toml
opencode session.created event generated plugin in ~/.config/opencode/plugins/
kiro — (its hooks are declared per agent, not per machine, so none fires for the agent the user runs — see D140) the scheduled trigger below, or Layer 2
hermes the scheduled trigger below, or Layer 2
antigravity — (native hooks exist, but there is no SessionStart event) the scheduled trigger below, or Layer 2

Scheduled trigger (D140). For a client with no session hook, cartographer service sync-timer install [--interval 30m] registers a launchd agent (macOS) or a systemd user timer (Linux) that runs cartographer sync on an interval. It is opt-in: installing a background job on the user's machine during connect would be out of proportion, so connect and status only name the command when a connected provider has no session hook and the timer is not already installed — the same predicate doctor's trigger check uses, so the three commands cannot disagree about whether a trigger covers the client. A timer status that cannot be read is not evidence of coverage, so the hint is still printed. The timer runs sync without --auto-trust — an unattended job must not grant a trust the user never gave; the persisted trust setting in .cartographer.yaml still applies (D54). Logs: ~/Library/Logs/cartographer/sync.log on macOS, the journal on Linux.

Beyond the revision comparison, every sync also verifies the managed files on disk and restores what diverged — see §On-disk verification and healing.

Layer 2 — On-demand MCP tools

Tool What it does
sync_check Returns manifest.revision, status (in-sync/drift), and the diff against the supplied lockfile. Read-only.
sync_apply Materializes into base_dir shared with the server's filesystem (local/stdio deployment), updates the lockfile, prunes. Honors the signature gate. Supports dry_run.
sync_pull Returns the manifest with file contents in base64: used by the remote HTTP client, which does not share the filesystem with the server. Read-only.

Layer 3 — Push via MCP notifications (stdio only)

On the stdio transport, the server emits notifications/skills/list_changed after skill_install (via notifyWrap, outside gitWrap so it fires after the commit); capability skills.listChanged: true in initialize. Over HTTP, Server.Notify is a no-op: Layers 1-2 still cover this case. Serialized on writeMu, never held during dispatch (verified with -race).

Remote HTTP client (cartographer connect/sync/status)

When client and server don't share a filesystem (internal/client, internal/clientconfig, cmd/cartographer/clientsync.go):

  1. the client calls sync_pull once per KB in the union of every connected provider's binding (§Per-provider projection) and keeps the responses unmerged;
  2. for each provider it selects its bound KBs' responses (SelectForSources), merges them with provisioning.MergeArtifactsStrict — which refuses a kind+name claimed by two of that provider's KBs — and verifies signatures;
  3. it reconstructs each artifact hash from received paths, bytes and executable modes, then verifies any detached signature against the local KB pin;
  4. Apply materializes/prunes and writes the v2 lockfile, one entry per provider;
  5. pruning remains managed-only.

Each sync_pull call (and the equivalent cartographer reindex remote call) is qualified with that KB's tool-name prefix (D102), discovered from a live /health snapshot rather than re-derived client-side (D120: resolveKBTargets/qualifyTool in cmd/cartographer/multikb.go). This keeps the client plumbing correct whether the server prefixes tools or not — an unprefixed KB is called unchanged, exactly as before D120.

A native local upgrade is another trigger for the same path: after install.sh replaces the binary, cartographer upgrade-repair reconciles the configured providers in place (D121, → deployment §Upgrades, schema migration, and repo growth). After a Homebrew upgrade the next plain cartographer sync does it instead (D199): before syncing it replaces a native service still running the previous binary, under the client-state lock so concurrent session-start syncs replace it once. It runs the very same in-process sync as plain cartographer sync — same authorization, same persisted trust, pinned keys, point approvals and allow-lists. Automatic repair never implies --auto-trust, never invents an approval and never broadens trust; it also never disconnects or reconnects a provider. It only runs when the client is configured against that same native service over loopback HTTP; any other endpoint is skipped rather than contacted.

Writing a placeholder literally, and how deep the repo scan goes

Documenting the generic form of a placeholder used to trigger it: eleven warnings per sync in one deployment, which trains people to ignore warnings. Two ways out (D162):

  • the escape, authoritative: {{\repo:<key>}} emits the literal {{repo:<key>}} with the backslash removed and resolves nothing. Chosen over doubling the braces, unreadable in a document that is about the syntax, and over an HTML-comment wrapper, since skills are also read as plain markdown;
  • metasyntax, a convenience needing no edit to existing KBs: a key of the form <name>, or ..., is left verbatim and silent. A real key cannot look like that — repoindex keys are git remote names or path keys, and < > are legal in neither.

search_depth in .cartographer.yaml bounds how many directory levels the repo scan descends from each root: default 4, maximum 8, a value above it clamped with a warning rather than rejected. A workspace organised as <root>/<program>/<area>/<repo> puts the repository directory at the fourth level and its contents at the fifth, so it needs search_depth: 5 — with the old fixed cap ~160 repositories were invisible and every {{repo:<key>}} citing one was unusable. The not-found message now names the depth it searched, the maximum, and the setting. Keying stays on the normalised git remote, never on a directory name: that is what makes a repo: key identical for every operator.

A cache hit is not trusted blindly (D181): before a cached path is returned, repoindex checks that it still holds a live clone (a directory with a .git entry) — a moved or removed clone behaves as a cache miss and triggers a rescan, instead of resolving to a location that no longer exists (or, worse, to a leftover clone left behind at the old path). Editing search_roots in .cartographer.yaml also invalidates the cache — a changed root set is a rescan even for a key that is still present under the old roots, since root order decides which clone wins when more than one exists.

The generated instructions block

The client steering file carries one managed block, shared by every mounted KB, delimited by <!-- cartographer:instructions:begin … --> / <!-- cartographer:instructions:end -->. Inside it, each KB gets its own named, delimited region: <!-- cartographer:kb:<name>:begin --> … a generated routing sentence (KB name, server, archives), the generated "Operational instructions" bullets, a one-line scope sentence, the KB's own instructions.md verbatim … <!-- cartographer:kb:<name>:end --> (D182). Cartographer only wraps the curated markdown — it never rewrites, reflows or re-levels it, and never nests it under a generated heading, so a curated instructions.md may open with its own # without Cartographer demoting it. Client-wide trailers — the D75 WP4 local-paths table and the subagent sentence below — are appended after the last KB's region, never inside one.

The scope sentence attributes and bounds each KB's directives (D182): emitted immediately before the curated body, only when curated content exists, it says that what follows is that KB's own and governs work in its perimeter, and that the more specific source wins on a conflict with another KB's directives or with a repository's own instruction file. Before this, a directive written for one KB's perimeter reached the agent as an unqualified, session-wide rule, with nothing marking where one KB's voice ended and the next began, and nothing to prefer when two KBs disagreed. DetectCollisions (D171) cannot catch this: for kind instructions, Name is the KB name, unique by construction, so two KBs can never collide on it — the strict merge is structurally blind to this class of conflict. A KB can still opt a specific fact into structured comparison by giving it a key (D183, below).

A KB may declare a session-global directive with a key to make a conflict with another KB detectable, not just attributable (D183): a line of the shape <!-- cartographer:directive:<key>:<value> -->, matched as a full line (after trimming) anywhere in the curated body — not only the first line — asserts a session-wide fact the KB stands behind, such as a working timezone or environment name. <key> and <value> must each be non-empty and contain no :, the same strict-not-clever shape as every other Cartographer marker; a malformed or partial line (an extra :, an empty key or value) is left as ordinary prose, and a KB documenting the syntax itself never triggers it, the same D162 metasyntax trap preambleNoneRe and the cartographer:kb: markers already account for — including the marker shown alone on its own line inside a fenced code block, the natural way to document a syntax: extractDirectives tracks fence state (```/~~~) and never matches inside one. Unlike preamble: none, the recognised line is not stripped — it stays in the rendered block exactly as authored, since it is the KB's own content, not a control signal aimed at the generator. DetectDirectiveCollisions scans every kind: instructions artifact in the same provider-scoped set MergeArtifactsStrict already compares (D170/D171) for declared (key, value) pairs: two KBs agreeing on a key's value are not reported, two KBs declaring different values for the same key fail the sync with a *CollisionError naming the key, every declared value, and which KB declared each — the same "error, not warning" stance D171 takes for a structural kind+name collision. Two KBs bound to two different providers cannot collide on a directive, for the same reason they cannot collide on kind+name.

Section order follows the provider's KB binding, with an alphabetical fallback (D182 WP2): a provider with an explicit binding (clientconfig.ClientBinding.KBs, D170) gets its sections in that declared order — the operator's own choice, not an accident of directory naming; a provider with no binding (the default, every known KB) keeps the alphabetical order as its documented, deterministic default. A KB present in the manifest but absent from the binding list sorts alphabetically after the declared ones, so an ordering change never drops a section. Position was already known to matter (D154, the English-preamble-first case below): with several KBs, whichever one sorted first alphabetically occupied that position by accident of naming rather than by anyone's choice. A pure reorder of a binding — same KB set, different sequence — still rewrites the block even though no artifact's ContentHash changed: applyInstructionsGroup compares the previous run's recorded section order (carried in the lockfile's instructions entries) against the newly computed one and treats a mismatch as its own rewrite trigger.

The subagent sentence describes what THIS client received (D154). It is emitted per provider at Apply time, not baked into the artifact, and names only agents whose kind has a destination for that provider: kiro and hermes have no native subagent directory, so they get no sentence rather than being told to delegate to subagents they never received. The block's content therefore depends on the KB's agent set even though its artifact hash does not, so the rewrite trigger fires on an agent artifact too — otherwise the sentence would go stale the moment an agent was added.

A KB may opt out of the generated bullets by putting <!-- cartographer: preamble: none --> on the first line of its instructions.md. The generated wrapper is hardcoded English, so a KB written in another language produced a steering file that switched language twice, with the English first — the position that sets the model's expected output language. This is deliberately not a localisation mechanism: the KB owns the prose instead. The one-line routing sentence always stays, being generated state rather than prose, so the residual is one English line. The directive is recognised on the first line only, so a KB can document it in its own text without triggering it.

A kind the provider cannot receive is warned about on every run, computed from the manifest rather than from the diff: the per-artifact unsupported: line appears only on the run where that artifact enters the diff, after which the condition is invisible while the KB keeps declaring artifacts that are silently not installed.

Security

  • Cryptographic signature gate. A configured KB signer creates a canonical Ed25519 envelope over domain, format version, source KB, kind, name, version and content hash. The remote client recomputes the content hash and verifies against out-of-band signing_keys pins before writing any provider file or lockfile. signed:true is verification output only; malformed, invalid, source-mismatched, unknown-key or tampered content fails the whole sync. Bundled artifacts use the separate built_in:true origin.
  • Explicit unsigned authorization. trust: true and one-shot --auto-trust retain the backwards-compatible approval path for eligible unsigned KB artifacts, but never change signed. Rotate keys by pinning the new public key first, switching the server signer second, then removing the old pin.
  • Path traversal. Artifact names and paths come from the server via JSON: provisioning.Apply rejects anything that is not filepath.IsLocal (no absolute paths, no ../) before writing. Applies to every kind.
  • Symlinked destinations are refused (D148). os.WriteFile on a symlinked path opens the target with O_WRONLY|O_TRUNC — it does not replace the link — so a symlinked client directory diverts the write outside the client, and when the target is another git repository it lands there with a provenance footer declaring a false origin. Every materialization walks the destination's components under the base dir with os.Lstat and refuses the first one that is a link; the base dir itself is exempt, since it may legitimately be a link (a symlinked $HOME, a provider root from BaseDirEnv). The refusal is per artifact: it is reported in the sync summary as refused: <kind>/<name> and in AppliedResult.Refused, the rest of the pass proceeds, and the artifact is left out of the lockfile so the next sync retries and reports again — the condition can only be cleared by the operator. cartographer doctor reports a symlinked destination directly, since a sync only mentions it on a run where that artifact is in the diff. Symlinked client-config directories are ordinary (a dotfile manager, a monorepo checkout, a shared team directory, an earlier bootstrap that linked skills out of a source repo), which is why this is a refusal rather than an assumption.
  • Sync never carries secrets, only references (skills-services-secrets.md).

Pruning

  • Apply/PruneManaged remove only the paths in managed[] that are no longer in the manifest — never files not created by Cartographer. Healing (D139) obeys the same boundary: it reads and rewrites managed paths only.
  • Empty directories (pruneEmptyDirs): after every removal, it walks up the parent directories deleting the ones left empty, always stopping at known roots (.claude, .codex, .kiro, .opencode, .config, .config/opencode, .gemini, or BaseDir), which are never removed. os.Remove (never RemoveAll) is the natural guard against non-empty directories.
  • MCP configs reduced to empty (configurator.Remove): once the entry is removed, an empty mcpServers/mcp map is removed too; .kiro/settings/mcp.json, opencode.json and .gemini/config/mcp_config.json reduced to an empty shell are deleted. Absolute exception: .claude.json is never deleted (it's a file shared by Claude Code). For .codex/config.toml, only the marker-delimited block is removed (the file is deleted if it ends up empty).
  • Per-file ownership within one artifact (D178): when a multi-file artifact is rewritten, the files it owned in the previous lock and no longer declares are removed with it, through the same prune (so they are reported, and empty directories are cleaned). The removal set comes from the previous lock, never from a directory listing, and only covers paths under the artifact's own destination directory: a file the user added inside a managed directory survives, and a registration written elsewhere (a hook's generated plugin) is never mistaken for a dropped file. Without this, a file dropped upstream stayed on disk and left the lock — invisible to pruning, to ComputeDiff and to doctor, while an agent kept reading it. Files stranded by versions before D178 are absent from every lock: doctor reports them (check managed-files) and never deletes them.
  • Round-trip connectdisconnect leaves no residue, except for the provisioning roots themselves (deliberate boundaries): test TestRoundTrip_ConnectDisconnect_NessunResiduo. Details → D63.

On-disk verification and healing (D139)

Revision comparison answers "is the lockfile current"; it says nothing about the files. Every sync therefore also verifies the managed artifacts against the filesystem and restores what diverged: a skill edited by hand, an agent file deleted, a managed key or block removed from a shared file. The server is the source of truth — a restore is a rewrite, never a merge, and no backup copy is kept. That is exactly what the provenance stamp promises the reader.

Verification scope per kind, decided by what is verifiable:

Kind Check
skill, hook the artifact's own directory is re-hashed with the same helper that produced materialized_hash — an extra file left inside counts as modified, and a lost executable bit is part of the hash
agent the single materialized file's bytes
mcp, instructions presence only of the managed key or marker block: the file is shared with the user, so its other content is never compared and never rewritten

Findings are missing, modified, unregistered, or unknown. Existence and content are verified separately (D146), because they need different evidence: whether a path is still on disk needs no hash, whether its bytes changed does. So the destination is stat'd first, and unknown — the lockfile records no materialized_hash, having been written before D138 — applies only to an artifact that is on disk and whose content therefore cannot be compared. It is reported, never healed: treating it as drift would rewrite every artifact on every client at once on the first upgrade. A pre-D138 entry whose files are gone is missing like any other, and is healed. A read error is a finding too, never a fatal: one unreadable artifact must not abort the verification of the others.

cartographer sync --no-heal reports divergence and skips the restore, for someone deliberately iterating on a local copy. cartographer status counts on-disk divergence as drift, so a locally modified artifact now exits 1 where it used to exit 0. Healing obeys the pruning guarantee unchanged: only paths in managed[] are ever read, written or removed, and --dry-run writes nothing.

Provenance stamp (D138)

A materialized skill (its SKILL.md only) and agent carry a marker-delimited block appended to the file — <!-- cartographer:provenance:begin … --> / <!-- cartographer:provenance:end -->, the same convention as the instructions block, invisible in rendered Markdown. It states the source KB, the artifact's path inside that KB, the artifact's content hash, and one instruction: local edits are replaced on the next cartographer sync, and the supported way to change the file is artifact_write on that KB at that path. A bundled artifact says so instead, with no artifact_write line — changing it means changing the Cartographer release. Other kinds are never stamped: a hook's script and hook.json would change program semantics, mcp descriptors are JSON, and instructions already announces itself.

The block carries no timestamp and no manifest revision: both would change on every sync (the revision on any other artifact's change), rewriting every file and defeating hash comparison. It is rebuilt from the source content on every materialization, so re-stamping is a fixed point and an older version's block is replaced, never nested. Stamping is client-side only, like placeholder expansion: the KB's own copy is never modified.

Because the stamp changes the bytes on disk, the lockfile records two hashes per managed file: content_hash — the manifest artifact's hash, the one ComputeDiff compares — and materialized_hash, the hash of what was actually written (after expansion, stamping and any per-provider translation). An empty materialized_hash means "unknown" (a lockfile written before D138). Keeping them separate is also a fix: previously the expanded hash was stored in content_hash, so any artifact containing a placeholder compared unequal against the manifest on every sync and was reported as permanent drift.

Order of operations, and what a failure leaves behind (D172)

runSync writes nothing before the manifest is fetched and verified:

  1. enumerateKBs (/health);
  2. compute the MCP entries per provider — no writes;
  3. fetchMergedManifest → signature verification and cross-KB collision detection (D171);
  4. removeMCPEntries + applyMCPEntries;
  5. ensureBootstrapForProviders — never before the manifest passed its checks;
  6. clientconfig.Save of known_kbs;
  7. materializeForProviders, which checkpoints the lockfile after every provider.

A failed sync_pull, an unverifiable signature or a refused merge therefore leaves the machine exactly as it was, and the error says so. An unreachable server (/health itself failing) skips entry reconciliation entirely, as before.

The guarantee, stated honestly. A failure between steps leaves a consistent state, and a provider that completed is always recorded in the lockfile — before D172 a failure on provider N left providers 1..N−1 with files on disk and no lock entry, so nothing pruned them and doctor could not see them. It does not make a single Apply atomic: a provider whose Apply fails midway can still have partial files on disk. The cost is N atomic lockfile renames instead of one, which with at most six providers is a deliberate trade of I/O for safety.

The client lock (D172)

Every path that read-modify-writes the lockfile or .cartographer.yamlsync, disconnect, doctor --repair-hashes, and the TUI's sync actions — first takes an advisory OS file lock on .cartographer-client.lock beside the lockfile. The race is between processes, not goroutines: the session-start bootstrap hook runs cartographer sync per agent session, so several are routinely in flight, and the loser of that race silently dropped another provider's entry. A blocked acquisition waits up to 30s and then fails naming the file — a sync that quietly loses an entry is worse than one that asks to be rerun. A dry run takes no lock, since it writes nothing.

The TUI's S (sync all) runs its providers sequentially under one lock rather than fanning out through tea.Batch: with the lock in place, concurrent goroutines would only queue behind each other while making the progress reporting incoherent. A partial failure reports which providers completed.

Idempotence

sync_apply/provisioning.Apply applied twice on the same revision are no-ops; dry_run shows the diff without writing (sync_apply(dry_run=true), --dry-run on the client). The client plan covers everything the run would write: per-artifact files, the MCP entries it would add and the ones it would remove, and the known_kbs rewrite when the set changes; with --client it says in its header that the plan is restricted to those providers (D172). The provider JSON config merge remains the non-destructive deep-merge of configurator.mergeJSON.

Kind × provider matrix

The matrix below is data in the code: destinationMatrix in internal/provisioning/provisioning.go, resolved by destDir(kind, name, provider) (D137). Every cell either names a destination or is explicitly unsupported — a cell missing from the table fails a completeness test instead of degrading silently. unsupported is not needs_approval (no approval would unblock it): clients filter such artifacts out upstream with FilterForProvider, and they count as neither drift nor pending (D50).

Kind claude opencode codex kiro hermes antigravity
skill .claude/skills/<name>/ .opencode/skills/<name>/ .codex/skills/<name>/ .kiro/skills/<name>/ skill-inbox/<name>/cartographer/ (delivered, see below) .gemini/config/skills/<name>/
agent .claude/agents/<name>.md (verbatim) .opencode/agent/<name>.md (translated) .codex/agents/<name>.toml (translated) .kiro/agents/<name>.json (translated) unsupported — no native subagent directory .gemini/config/agents/<name>.md (translated)
hook .claude/hooks/<name>/ + registration in settings.json .opencode/hooks/<name>/ + generated JS plugin .codex/hooks/<name>/ + block in config.toml unsupported — no hook mechanism in the shipped client (D195) unsupported — no hook mechanism at all .gemini/config/hooks/<name>/ + registration in hooks.json
instructions managed block in .claude/CLAUDE.md block in .config/opencode/AGENTS.md block in .codex/AGENTS.md file .kiro/steering/cartographer.md unsupported — SOUL.md is operator-owned, rendered from a template block in .gemini/GEMINI.md
mcp key mcpServers.<name> in .claude.json key mcp.<name> in opencode.json block [mcp_servers.<name>] in config.toml key mcpServers.<name> in .kiro/settings/mcp.json unsupported — config.yaml is rendered by an Ansible role key mcpServers.<name> in .gemini/config/mcp_config.json

Paths are relative to the client base dir, which is the user's home for every provider but hermes: that one materializes under $HERMES_HOME, recorded as base_dir in its entry of the single lockfile (D141). An entry with no base_dir — every lockfile written before that, and every other provider — means the lockfile's own directory, so nothing migrated.

Hermes: delivery, not installation

Hermes' skills live in $HERMES_HOME/skills/ and are owned by the agent itself: a curator archives unused ones, keeps telemetry and honours pins, rewriting what it owns from its own learning loop. Cartographer therefore never writes there. A KB skill is instead delivered to $HERMES_HOME/skill-inbox/<name>/cartographer/ as a proposal: the skill's files (provenance block included, D138) plus a generated SOURCE.md naming the source KB, the artifact path, its content hash, and the fact that adopting it is the agent's own skill_manage decision. Cartographer delivers; Hermes adopts.

The delivery path deliberately carries no timestamp, departing from the skill-inbox/<skill>/<timestamp>/ convention: provisioning must be idempotent, and one directory per sync would accumulate a copy on every timer tick with no way to tell stale from current. One stable directory per (skill, source), updated in place — the last segment names the proposer, so another source never collides — with the content hash in SOURCE.md distinguishing an unchanged re-delivery from a new proposal; the proposal's history lives in the KB's git log. Pruning removes exactly skill-inbox/<name>/cartographer/ (and skill-inbox/<name>/ if that empties it), never an adopted copy under skills/ and never the shared skill-inbox/ root. A KB skill that ships its own SOURCE.md is a collision: that one artifact is not delivered and produces a warning naming it, while the rest of the sync completes.

Hermes has no session hook, so its trigger is the scheduled timer (cartographer service sync-timer install, D140).

Workspace scope (D193)

By default a provider has one catalogue, under the user's home, shared by every session of that provider on the machine. A provider that works in two perimeters therefore sees both perimeters' skills everywhere, and chooses between them from name and description alone — the provenance footer is in the body, read only after activation. That is how a DANTE skill got activated in a HomeLab session.

cartographer workspace bind <provider> <path> --kb <name>… switches that provider to workspace scope: each bound directory receives its own KBs, in that directory's own project-local configuration, and nothing KB-sourced is written globally any more.

provider scope (default) workspace scope
Where KB artifacts land the client base dir ($HOME) each bound workspace
Who sees them every session of that provider only sessions in that workspace
The binding one per provider (D169/D170) one per provider and workspace
Lockfile key provider provider + workspace, in its own namespace

Cartographer's own bundled skills stay global. cartographer-ops, kb-create and their siblings belong to no perimeter, and a session outside every bound workspace still needs them. They are the only thing the global catalogue of a workspace-scoped provider holds.

An unbound workspace is fail-closed. It receives the transversal bundle and no KB artifact at all. "No KBs" and "several KBs" are distinct explicit states; neither is ever "every KB", and a bound path that is gone or whose git remote has changed is an error, never a fall-back — falling back is the exposure the scope exists to close.

A projection is not an authorization boundary. A process running as the same user can read any file on the machine (D169). What this prevents is accidental exposure and activation, and that is all it claims.

Project-local destinations

Kind claude opencode codex kiro hermes antigravity
skill .claude/skills/<name>/ .opencode/skills/<name>/ .agents/skills/<name>/ .kiro/skills/<name>/ unsupported unsupported
agent .claude/agents/<name>.md .opencode/agent/<name>.md .codex/agents/<name>.toml .kiro/agents/<name>.json unsupported unsupported
hook .claude/hooks/<name>/ .opencode/hooks/<name>/ .codex/hooks/<name>/ unsupported unsupported unsupported
instructions block in ./CLAUDE.md block in ./AGENTS.md block in ./AGENTS.md .kiro/steering/cartographer.md unsupported unsupported
mcp .mcp.json opencode.json .codex/config.toml .kiro/settings/mcp.json unsupported unsupported

Paths are relative to the workspace. The matrix is data, like the global one, and is held to the same completeness rule: every kind × provider cell either names a destination or is explicitly unsupported. hermes and antigravity have no project-local scope at all — the first renders its configuration from an Ansible role and delivers skills to one inbox, the second documents only a global configuration root — so they cannot be bound to a workspace, and workspace bind refuses them with that reason rather than degrading to the global catalogue.

Codex is the one provider where correct files are not the whole story: it ignores a project's .codex/ layer unless the project is trusted. status and doctor report such a projection as inactive and name the fix, because reporting it as installed is the false-positive class D189 exists to eliminate.

Repository hygiene

A projection writes into a directory the user version-controls, so two rules hold:

  • Cartographer excludes only its own untracked paths, and only in .git/info/exclude, inside a marker-delimited block. It never edits .gitignore: that file is the repository's, shared with everyone who clones it. A shared file the repository already tracks — a CLAUDE.md the team wrote — is the user's: Cartographer writes its block inside it and does not exclude it.
  • A path Cartographer would own entirely that git already tracks is a refusal, before anything is written. Overwriting a versioned file destroys work under version control, and there is no safe silent answer.

git status is clean after a sync. That is asserted end-to-end by the 18_workspace_projection E2E scenario, along with the rest of this section.

Unbinding

cartographer workspace unbind <provider> <path> removes the declaration; the next sync prunes what was projected there and removes the exclusion block. Unbinding the last workspace returns the provider to the global scope — the only other state there is. Neither ever touches another workspace's files: the lockfile keys workspace projections in a separate namespace, so a prune cannot reach past its own projection.

Agents and hooks

  • KB layout (optional/backward-compatible): agents/<name>.md (Claude subagent: frontmatter + body) and hooks/<name>/ (script + hook.json with event/matcher/command). BuildManifest scans these (KB only, the bundle remains skill-only).
  • ContentHash: agent → sha256 of the file; hook/skill → an aggregate hash of the directory (ContentHashDirOS); always computed on the source, never on the translated form — so the manifest↔lock comparison doesn't depend on the provider.
  • Agent translation (translateAgentForProvider, a pure function): the source is always a Claude subagent. Claude = passthrough; OpenCode = minimal frontmatter description + mode: subagent + verbatim body; Codex = TOML name/description/developer_instructions; Antigravity = Markdown frontmatter with name, description, mainAgent: false, subagent: true; Kiro = a JSON config with name/description/prompt (D195 — the format the shipped client discovers, which is not the Markdown its documentation describes). The body stays verbatim; fields that can't be mapped reliably (tools, model) are dropped, not guessed. Details → D55/D58.
  • Hook registration: besides materializing the files, Apply registers the hook in the provider's native mechanism (internal/provisioning/hooksettings.go), idempotently and prunably:
  • claude: merges the entry into hooks.<Event>[] of settings.json (ownership = the .claude/hooks/<name>/ marker in the command: the materialized path, or — for commands that don't reference the hook's dir, e.g. a one-liner jq ... — an inert shell comment # cartographer-hook: ... appended at the end); the file is treated as generic JSON, unknown keys survive (D57);
  • codex: marker-delimited block # cartographer:hook:<name>:begin/end in .codex/config.toml via internal/blocktext — the TOML is never parsed/re-serialized (D58). Codex rewrites the file and drops the markers with every other comment, so before writing the block Apply removes any registration of that hook left outside it — identified by the .codex/hooks/<name>/ path in its command — which would otherwise make the hook fire twice, and reports the repair in AppliedResult.Warnings (D99);
  • opencode: a deterministic JS plugin cartographer-<name>.js in ~/.config/opencode/plugins/, generated only if the event is mappable (openCodeHookEvents); an unmappable event → files are still materialized + a warning in AppliedResult.Warnings (D59);
  • antigravity: an owned top-level cartographer-<name> definition in ~/.gemini/config/hooks.json. PreToolUse/PostToolUse preserve matcher groups; PreInvocation, PostInvocation, and Stop use direct handlers. Other events remain materialized and produce a warning because Antigravity has no native equivalent;
  • a missing/malformed hook.json skips registration without failing Apply; a command whose first token is a relative path (contains /, e.g. ./notify.sh) is resolved to the materialized absolute path; bare names (e.g. jq) are left verbatim, resolved via PATH.
  • A file's executable bit comes from the KB and is preserved on materialization (including skill scripts); hooks retain an unconditional executable floor for every file other than hook.json, which is always non-executable. The effective mode is part of the versioned artifact hash, so chmod alone changes the revision and realigns existing installs.
  • Per-kind counts: provisioning.KindCounts → a skill 4/5 · agent 2/2 · hook 1/1 line in cartographer status and the TUI.

Instructions (imprinting)

For every mounted KB, BuildManifest generates a kind: instructions artifact (name = KB name) whose content does not live on disk: it is produced by generateKBInstructions (pure and deterministic) and placed in Artifact.Files. It contains: a header (the KB is served via MCP) with the names of data/'s top-level archives inline (no page counts — it's stable imprinting, not state, D65), three lines of operational instructions (start with search/atlas_overview, read with concept_read, write with concept_write, close with log_append), the names only of the KB's agents (descriptions already come from the client's agent registry — the agent is installed natively, D65), and the verbatim content of the optional curated file <kbRoot>/instructions.md (at the root, never under data/). ContentHash = sha256 of the generated content: it changes only if the set of archives/agents or the curated file changes — not on every added page. Details → D56/D61 and D65.

Materialization: a managed block delimited by markers (<!-- cartographer:instructions:begin/end -->) inside the provider's global instructions file (see the matrix above) — a user file, never overwritten or deleted:

  • rewrite = replacement between the markers; missing markers → block appended at the end; missing file → created with only the block;
  • managed as a group: the block is the ordered concatenation of the snippets of all currently signed instructions — a removed KB disappears on the next rewrite; zero artifacts → block removed;
  • each snippet is individually wrapped and attributed (D182): wrapKBSection delimits it with <!-- cartographer:kb:<name>:begin --> / :end --> — a marker family distinct from the outer cartographer:instructions: one, so the malformed-block check (which counts occurrences of the outer pair) still counts exactly one of each no matter how many KBs contribute. generateKBInstructions additionally emits a one-line scope sentence right before the curated body, when curated content exists, naming the KB and stating that the more specific source wins on a conflict — the sentence is generated content, so it is part of ContentHash like the rest of the block;
  • order follows ApplyOptions.KBOrder (D182 WP2), set by the client from the provider's explicit binding (clientconfig.ClientBinding.KBs) when there is one, alphabetical by KB name otherwise; a KB outside the declared order sorts alphabetically after the declared ones. A pure reorder — same KB set, only the sequence changed — is invisible to ComputeDiff (no ContentHash differs), so applyInstructionsGroup separately compares the previous run's recorded order (the sequence of instructions entries in the incoming Lock) against the newly computed one and treats a mismatch as its own trigger;
  • the signature gate applies as usual; lockfile: one ManagedFile per artifact (not per physical file);
  • pruning: removes only the block, never the file — except when the file is left empty (this covers kiro's dedicated file).

Path portability placeholders (D75)

Shared content (concepts and provisioning artifacts) must never contain machine-specific (client-local) absolute paths: two placeholders, resolved client-side only, at materialization time:

  • {{repo:<name>}} (short form) / {{repo:<host>/<owner>/<name>}} (full, canonical form) — resolved automatically: the key is the normalized git remote (internal/repoindex, handles ssh scp-like, ssh://, https://, .git suffix), identical on every machine of the team. repoindex.Scan walks search_roots (client config, default ~/Documents) up to depth 4, reads each repo's .git/config (no git exec) and caches the result in ~/.config/cartographer/repos.json, refreshed on-miss. A cache hit is validated before use (D181): the candidate path must still be a directory containing a .git entry, or it is treated as a miss and a rescan follows; a change of search_roots also invalidates the cache, even for a key whose old path is still perfectly live.
  • {{path:<name>}} — manual paths: mapping in .cartographer.yaml, a fallback for directories that aren't git repos (and an override for {{repo:<key>}} too: repoindex.Resolve checks paths: before cache/scan).

The server-side machine_path lint flags a literal client-local path left in a concept body instead of one of these placeholders. Not every absolute path in a body is client-local: a Map's machine_path_allow_prefixes contract (D124, docs/data-plane.md §Maps and Journals) declares absolute prefixes — a container image's home directory, a remote node's runtime path — that are identical on every reader's machine and therefore not a placeholder candidate.

No server-side expansion: it would break content_hash/if_match (hash on the raw content, content served differently per client) and the server doesn't know clients' filesystems. internal/mcpserver never sets ApplyOptions.ExpandPlaceholders — only cmd/cartographer (connect/sync/TUI) does, passing cfg.SearchRoots/cfg.Paths.

  • Expansion points (provisioning.Apply, before writing to disk): agent content (single-file), skill/hook content (every file in the folder), instructions content (per artifact, before composing the group block).
  • Hash on the expanded content: ManagedFile.ContentHash in the lockfile is computed on the content after expansion (same formula as ContentHashDir/contentHashFile/contentHashBytes depending on the kind) — the client-side comparison must be against what was actually written to disk. Content with no placeholders → expansion is a no-op, hash identical to Artifact.ContentHash: zero drift for existing installations.
  • Unresolved placeholder: a warning on stderr (AppliedResult.Warnings), the text left as-is in the file — sync never blocks on a missing resolution.
  • "Local paths" table: when ExpandPlaceholders is active, applyInstructionsGroup appends to the instructions block (per provider) a placeholder → local path table with every key resolved during that same Apply (agent/skill/hook included, not just the instructions content), plus a fallback instruction for the agent: a placeholder missing from the table → cartographer resolve <key> (docs/configurator.md).
  • Repo ambiguity: a short name matching several distinct remotes is an explicit error (it asks for the full form host/owner/name); multiple clones of the same remote resolve to the first match in search_roots order, with a warning.

MCP servers (D69, D116)

A KB can distribute third-party MCP servers (endpoints that connected agents must be able to call) alongside skills/agents/hooks/instructions. Two transports are supported: http (D69) and stdio (D116, see below); any other type fails the build.

  • KB layout: mcp/<name>.json, one file per server (single-file like agents, not a directory like skills). Provider-neutral schema {"type":"http","url","headers"?,"env"?} or {"type":"stdio","command","args"?,"env"?}. BuildManifest parses+validates here (parseMCPServerSpec): malformed JSON, an unknown field, an unsupported type, a missing url/command, a field belonging to the other transport, or a headers/env value that doesn't reference a ${VAR} (looks like a literal secret) fail the build, not the apply. No mcp/ folder → zero artifacts (backward-compatible).
  • No secrets in the file: headers and env values only support ${VAR} references, resolved by the client against its own environment at materialization time — the same pattern as token_env (D64). On an "http" server env is validated but not emitted: none of the 4 providers exposes a verified native channel for generic env vars there — only the Authorization: Bearer ${VAR} header can be reliably represented. On a "stdio" server every provider does have a native env channel, so references are emitted (D116).
  • Emission (internal/configurator.EmitServer): a refactor that extracts from Emit/ServerConfig (Cartographer's own entry) a provider-neutral core EmitServer(name, spec ServerSpec, provider), reused both by connect (for the cartographer entry) and by Apply for KB servers. Translation of the ${VAR} reference: claude/kiro/codex leave it verbatim (native syntax); OpenCode translates it to {env:VAR}. Codex only exposes bearer_token_env_var for auth: an Authorization: Bearer ${VAR} header is translated there, any other header is not representable and is dropped with a warning (EmitResult.Warnings) instead of failing. Kiro has never had a header field for MCP servers (a pre-existing limitation, not introduced by D69): a KB server with headers generates a warning in AppliedResult.Warnings, not an error.
  • Apply: unlike skills/agents/hooks, an MCP server does not materialize its own file/folder — it merges into the provider's native config (internal/provisioning/mcpsettings.go, registerMCPServer/removeMCPServer), with per-name ownership so multiple MCP servers (and Cartographer's own entry) coexist in the same file: claude/opencode/kiro do a JSON merge on the mcpServers/mcp key; codex uses a marker-delimited block # cartographer:mcp:<name>:begin/end (distinct from the unnamed block cartographer connect writes for itself) via internal/blocktext. destDir("mcp", ...) resolves to the same shared file for all servers of that provider (like instructions), but — unlike instructions (a GROUP block) — each server is an independent ManagedFile.
  • Prune/disconnect: PruneManaged strips only that name's entry/block (never the whole file), with the same empty-shell cleanup as configurator.Remove (D63): .kiro/settings/mcp.json/opencode.json/.gemini/config/mcp_config.json are deleted if the removal leaves only $schema/nothing; .claude.json is never deleted; .codex/config.toml only loses the marker-delimited block.
  • Server policy and client consent: an MCP server receives the agent's data, so two independent grants apply. The server omits it unless the KB's exact mcp_allowlist entry matches name, transport and target — the normalized URL for http, the exact command for stdio; absent/empty policy denies every descriptor. On the client an unsigned descriptor requires cartographer approve mcp <name> --kb <kb>, binding consent to source, name and full content hash. trust/--auto-trust never authorize MCP. A cryptographically verified descriptor is eligible without a point approval, but is still subject to the server allow-list. Any hash change returns it to needs_approval; revoke then sync prunes managed provider config.
  • Remote sync: no per-kind filtering in sync_pull/tools_sync.go nor in the HTTP client — an mcp artifact travels as a single ArtifactFile (the mcp/<name>.json file), same schema as an agent.

Stdio descriptors (D116). A descriptor may use {"type":"stdio","command","args"?,"env"?}, which rejects url and headers. The command is a bare executable name resolved through PATH or a clean absolute path — never a shell expression: Cartographer passes command and arguments separately and never starts a shell. Arguments keep their order and env values are ${VAR} references only, so no secret value ever reaches the server or a provider file. The allow-list binds the exact command, while the artifact content hash binds every argument and reference: changing any field invalidates a D115 approval. Before any provider config or lockfile is written, PreflightStdioMCP resolves the command locally for every target provider (PATH lookup for bare names, executable regular-file check for absolute paths) and fails the whole sync naming the provider it protected; the resolved path is not persisted and the executable is never launched. Claude Code, Codex and Kiro receive native command/args/env; OpenCode receives type: "local", an ordered command array and environment with ${VAR} translated to {env:VAR}. A field a provider cannot represent fails instead of being silently dropped, so no provider ever runs a command different from the approved one.

Implementation choices

  • Dedup by kind+name: a skill present both in the bundle and in a KB is materialized only once, with the KB winning. The manifest holds exactly one artifact per kind+name.
  • Per-provider projection (D170): each provider receives only the KBs bound to it in .cartographer.yaml (clients.<provider>.kbs, see configurator.md §cartographer client). Three rules make it work:
  • selection happens before the merge, on the per-KB sync_pull responses. Filtering the merged manifest is wrong: MergeArtifacts has already discarded candidates, so if kb-A overrides a bundled skill and a provider is bound only to kb-B, the merge keeps kb-A's copy and a source filter then deletes it — the provider loses the skill instead of receiving the bundled one. The server performs the same KB-over-bundle merge inside each single-KB pull, so a candidate the client never received cannot be reconstructed;
  • the revision is recomputed per provider, after selection and after FilterForProvider. Two providers holding different KB sets under one revision string would make ComputeDiff.InSync lie, and changing a binding would produce no drift at all. FilterForProvider also drops kinds the provider has no destination for (e.g. agent/hook/instructions for hermes — D141), so the recomputed revision differs from the pre-projection one whenever a provider supports less than the full kind set. The CLI's synced to revision … (and would sync to revision … under --dry-run) reports the revision each provider actually recordedAppliedResult.NewLock.AppliedRevision, taken from materializeForProviders' return value, never the manifest sync fetched before projection — so it always matches what status reports for that provider (D184);
  • unbinding removes the artifacts for free: they leave the provider's manifest, so ComputeDiff marks them Removed and PruneManaged deletes them. Only files listed in the lock are touched, so user-owned files survive. A server that does not identify its KBs by name (no kbs in /health, or a first sync before any name is known) cannot express a binding: every client receives everything, exactly as before D170, with a warning saying so. ManagedFile.Source records each file's origin KB; empty means "unknown" (a lockfile written before D170) and is never treated as wrong.
  • Cross-KB collisions are refused, not resolved (D171). Two KBs claiming the same kind+name is an error: MergeArtifactsStrict — what the client uses — fails with a report naming the kind, the name and the claiming KBs, before anything is materialized. MergeArtifacts stays tolerant and keeps the alphabetical source tie-break; the server builds a manifest from one KB plus the bundle, where the case cannot arise. cartographer client bind warns when a new binding creates one, and doctor's kb-collisions check reports it per provider.
  • Lockfile: <base-dir>/.cartographer-sync.lock.json (v2 multi-provider).
  • Pruning is per tracked file, not per whole directory.

Out of scope

  • Bidirectional sync (client → server): the flow is unidirectional, the server is the source of truth.
  • Dynamic secret rotation/manager (→ skills-services-secrets.md).
  • Conflicts on skill content (that's git, KB-side → concurrency.md).