Manifest and Drift
This page explains how OAT remembers what it manages and how it distinguishes clean sync state from drift, missing files, or unmanaged strays.
Quick Look
- What it does: describes the manifest contract behind provider sync and the drift/adoption model built on top of it.
- When to use it: when
oat statusshows drift or strays and you need to understand why OAT thinks a file is managed, missing, or adoptable. - Primary commands:
oat status,oat init,oat sync
Manifest locations
- Project:
.oat/sync/manifest.json - User:
~/.oat/sync/manifest.json
Manifest purpose
Tracks managed mappings so the CLI can:
- detect drift safely
- avoid deleting unmanaged provider content
- execute scoped removals only for managed entries
Install-triggered auto-sync narrows that removal scope further: after oat tools install <pack>, the follow-up sync only plans removals for canonical entries from the installed pack. This protects unrelated provider views if the current worktree has stale manifest entries for other packs whose canonical assets are missing locally.
Autonomous worktree bootstrap also treats sync output as setup state. oat-worktree-bootstrap-auto checks inherited cleanliness before the all-scope sync run, then commits dirty sync-managed output as chore: run sync when needed. The commit is scoped to existing or tracked sync paths (.oat/sync/manifest.json, .claude, .cursor, .codex) and reports the result as sync_commit: pass | fail | skip in its structured status.
For transformed mappings such as project-scoped rules, the manifest stores hashes for the rendered provider output that was actually written, not the canonical source markdown. This keeps drift detection aligned with the on-disk managed file.
Collection alias ownership
Manifest version 2 can record an exact collection-directory alias plus the
canonical entries inherited through it. Collection records distinguish links
created by OAT (oat-created) from exact links OAT adopted without rewriting
(adopted-exact). Inherited entries use the collection strategy and refer to
their owning collection record; OAT does not mutate provider child paths under
that alias.
The current runtime adopts an existing alias only when it exactly resolves to
the canonical collection and has the same entry set. When the destination is
absent, auto falls back to ordinary per-entry sync: Node does not expose the
identity-bound parent-relative primitive OAT requires to create a collection
alias safely. OAT does not use copy fallback for collection aliases.
OAT also does not automatically unlink a collection alias in the current
runtime because a separate identity check followed by path-based removal has a
final replacement race. Disabling a provider detaches ownership while
preserving the alias. Configuring an explicit per-entry strategy does not
release an owned collection. Deferred collection-directory copies and symlinks
fail closed because the current runtime cannot publish through replaceable
ancestors without following them.
To leave the provider directory externally owned, run
oat providers set --scope <scope> --disabled <provider> and then
oat sync --scope <scope> to detach OAT ownership. Then verify and remove the
preserved alias before managing the directory manually. Automatic transition
requires an identity-bound, non-following publication primitive.
Deferred file operations and ordinary non-transition per-entry symlinks retain
their existing behavior. Real directories, destination races, broken links,
foreign targets, and unverifiable identities are preserved and fail closed.
Canonical targets are never removed.
Drift states
in_syncdriftedmissingstray
drifted reasons currently include:
modifiedbrokenreplaced
Rendered rule files participate in the same drift states as other managed copies. If a provider rule file is edited directly, drift is computed against the expected rendered output for that provider.
Collection-backed entries are checked through the owning collection identity.
An exact alias is in_sync; an absent alias is missing; and a broken,
replaced, foreign, or otherwise unverifiable alias is drifted. Provider list
and inspect output summarize collection ownership separately from copy mode.
Resolution-time skill view classes
Drift states answer "does the tracked view still match?", which presupposes a
manifest entry. A canonical skill that was never synced has no entry at all, so
oat tools info <name> adds a second, additive classification beside the
unchanged drift state. Provider and path identity for an untracked projection
come from the active provider set and the adapter's scope mappings, not from
the manifest.
missing-additive— active, supported, no manifest entry, nothing at the expected path: the only true projection gapremoved— a manifest entry exists but the provider file is gonemodified— a manifest entry exists and the view no longer agrees with what the last sync recorded, or its version has fallen behind canonicalin-sync— a symlinked, collection-aliased, or natively read view is the canonical file; acopyview matches the content recorded at its last sync. A managed directory copy is compared with its OAT-managed banner and its.oat-generatedsentinel excluded, so a freshly synced copy readsin_syncrather than drifted (owned byskill-view-convergence.integration.test.ts, "converges a copy-strategy skill directory")untracked— something exists at the expected path that no manifest entry tracks; stray detection skips provider entries whose name matches a canonical entry, sooat statusdoes not report it as a stray and reports the untracked projection asmissinginsteadunverified— the view's state is unknown: either a manifest entry arrived with no drift observation, or reading that one view failed and the row carries the redacted reasoninactive,unsupported,excluded— no projection is expected in this scope, so none of them is reported as missing
unsupported and excluded are defensive: no shipped adapter or config
produces them today. Every shipped adapter maps skills in both scopes and the
command passes no canonical-path filter. The branches exist so a future adapter
or filter cannot be silently reported as a projection gap. unverified is
reachable: an unreadable provider path makes drift detection throw, and that one
view degrades to unverified with its reason.
A row for inactive, unsupported, or excluded prints neither a projection
qualifier nor a path, because no view is projected for it: naming an expected
path and a sync strategy for a file that is never produced described work that
is never going to happen.
Only missing-additive, removed, and modified carry a repair, and it is
always one concrete oat sync --scope project or oat sync --scope user for
the scope where the gap was observed. Versions are compared only for copy
views: a symlinked, collection-aliased, or natively read view is the canonical
file, so it has no second version.
If reading the sync config or the manifest fails — an invalid, unreadable, or
otherwise non-loadable .oat/sync/config.json or .oat/sync/manifest.json, for
instance — the section for that scope reports unavailable with the reason, and
the rest of the command is unaffected. A failure while reading one provider's
view, such as drift detection throwing on an unreadable provider path, is
narrower: only that row degrades, to unverified with the same redacted reason,
and the other providers keep their real rows. The
diagnostic is additive evidence, so it never changes the exit code or removes
the tool detail of a user whose sync state is already broken. A broken sync
config is one of the likeliest reasons a provider view is missing, which is
exactly what this command is run to explain, so it is reported rather than
dropped: printing no section at all would read as "no providers configured".
An absent .oat/sync/config.json is a different case and not a failure — it
resolves to the defaults, and the scope is diagnosed normally. Reasons are
redacted; the scope root becomes <project> or ~ (only at a path boundary, so
a sibling directory that merely shares the prefix is left alone), and any
absolute path outside it is replaced entirely with <path>, quoted or not and
whatever punctuation precedes it. A manifest providerPath that escapes its own
scope is redacted the same way rather than rendered in the row, and no version
is read from it: its SKILL.md is a file outside the tree the command was asked
about.
A drift state and a view class can legitimately disagree. Drift compares a copy
against the hash recorded at its last sync, so a copy that was never re-synced
after a canonical edit still matches its own manifest entry and reads as
in_sync. When the two versions differ, the view class is modified and the
drift state is reported unchanged beside it.
The manifest is keyed by (canonicalPath, provider) and never by path, so a
manifest entry can track a view somewhere other than the adapter's expected
projection path — reachable after a providerDir change or from a manifest
written under an older layout. Drift resolves the entry's own providerPath, so
in that case the row names the tracked path rather than the expected one, and
its --json record carries the expected path separately as
expectedProviderPath. The detail says which path the state describes and
whether anything exists at the expected path. A drift verdict computed for one
path is never attached to a row labelled with another: doing so reported removed
with "the provider file is gone from disk" against a healthy file that was
sitting at the expected path all along.
Both sides resolve their version through the same shared reader, so
metadata.version takes precedence over the deprecated top-level version
alias for a projected view exactly as it does for the canonical source; the only
extra step for a view is stripping the generated banner. When a projected
SKILL.md contradicts itself — a top-level version and a differing
metadata.version — or declares a version the reader cannot use at all, the
comparison is withheld rather than decided: no version is reported for that
view, and the class is left to the drift state alone. That is deliberately
conservative and has a cost, since a copy in that state could be genuinely
stale and would not be reported as such. The view's detail names the two
declared values and says the comparison was skipped, so the ambiguity is
visible rather than silently resolved. When the resolver's answer happens to
agree with canonical, nothing is withheld and the detail says so instead.
Stray adoption
oat init and oat status can offer adoption of unmanaged provider entries into canonical .agents.
Provider files ignored by Git are treated as intentionally local runtime files and are not reported as strays. This includes files covered by tracked .gitignore, repo-local .git/info/exclude, or other standard Git exclude mechanisms.
Provider files listed in sync config knownStrays are also omitted from stray
summaries and adoption prompts. Known strays can be configured at project scope
in .oat/sync/config.json or user scope in ~/.oat/sync/config.json; entries use
exact provider-path matching, so .cursor/skills/cloud-environment-setup does
not suppress .cursor/skills/cloud-environment-setup-extra.
Cursor skill migration
Cursor reads canonical project and user skills directly from .agents/skills
and ~/.agents/skills. OAT therefore treats .cursor/skills as a Cursor-only
extension and adoption surface rather than generated output.
Interactive oat init and oat status ask about every unresolved Cursor-local
skill separately:
- Adopt: move the skill to the matching canonical
.agents/skillsdirectory without creating a Cursor skill view or manifest row. If identical canonical content already exists, OAT removes the redundant Cursor-local copy. - Keep Cursor-only: leave the skill in place and immediately add its exact normalized path to the applicable project or user sync config.
- Abort: keep the current and remaining skills unresolved. Choices already completed in the same run remain saved.
Keep Cursor-only is unavailable when a canonical skill has the same name. Rename one skill before retrying; Cursor does not document a safe precedence rule for duplicates discovered from both roots. Non-interactive and JSON modes report pending migration actions without choosing or mutating a disposition.
Retiring legacy Cursor skill views
When upgrading from generated Cursor skill views, sync classifies each obsolete manifest-owned path before acting:
- Verified clean symlinks and managed copies are removed with their manifest entries.
- Missing paths lose only their stale manifest entries.
- Modified, replaced, broken, unreadable, or otherwise unverified paths are preserved while OAT detaches their obsolete manifest ownership.
- Unmanaged Cursor-only content is never removed.
Use oat sync --scope <project|user|all> --dry-run to distinguish planned
removal from preserve-and-detach operations before running a mutating sync.
Copilot skill migration
Copilot reads canonical project and user skills directly from .agents/skills
and ~/.agents/skills. OAT no longer generates .github/skills or
~/.copilot/skills views, but it still scans those legacy directories as
adoption sources.
Interactive oat init and oat status ask about each unresolved Copilot-local
skill separately. Adopt moves it into the matching canonical directory without
recreating a provider view or manifest entry. Keep Copilot-only leaves it in
place and records its exact path as a known stray. A same-name canonical skill
blocks Keep Copilot-only until one package is renamed. Non-interactive and JSON
modes report pending migration actions without choosing a disposition.
During upgrade cleanup, OAT removes only verified clean manifest-owned views. Changed, replaced, broken, unreadable, or otherwise unverifiable paths are preserved and detached from obsolete manifest ownership so they remain available for adoption or manual resolution.
Copilot agent mappings remain materialized under .github/agents and
~/.copilot/agents, and project rules remain rendered under
.github/instructions; this retirement behavior applies only to skill views.
For rules, adoption maps provider-native files back into .agents/rules/*.md:
- Claude:
.claude/rules/*.md - Cursor:
.cursor/rules/*.mdc - Copilot:
.github/instructions/*.instructions.md
After adoption, oat sync regenerates the managed provider copies from the canonical rule file.
Generated provider roles
Some Codex roles are generated-derived - produced by the Codex sync
extension rather than mapped 1:1 from a canonical .agents/agents/*.md file.
Materialized dispatch roles are the current example: a matrix target or
oat providers codex materialize command supplies a canonical agent, model, and
reasoning effort, and OAT writes a role such as
oat-phase-implementer-gpt-5-6-terra-xhigh.
oat status and oat init treat any role listed in the Codex extension plan's managedRoles set as managed, so generated variants are not reported as stray and are not offered for adoption — even though they have no canonical .agents source. A genuinely orphaned Codex role (no canonical source and not in managedRoles) is still flagged.
Reference artifacts
.oat/projects/<scope>/<project>/spec.md(FR2, FR3, FR6).oat/projects/<scope>/<project>/design.mdpackages/cli/src/manifest/**packages/cli/src/drift/**