/**
* The docs phase of `auden sync`: keep a repo's subscribed bundles in step with
* the canonical docs on the server, in both directions.
*
* Slice 1a pulled unconditionally — every item overwrote its file on every sync.
* Slice 2 makes the phase state-aware: before touching a file it establishes
* what that file *is*, then acts.
*
* the doc's current content → in-sync, nothing written
* an older version of the doc → pull (our copy; overwriting loses nothing)
* no version of the doc, remote moved → both-changed: touch nothing, report
* no version of the doc, remote still → push (with `expectedVersion`)
*
* That classification comes from two sources, in this order:
*
* 1. **The baseline in `.auden/docs.json`** (`sidecar.ts`) — the hash of the
* bytes we last wrote. Free, exact, works offline, and settles every file
* that is still the copy we put there.
* 2. **The doc's version history** (`classify.ts`) — the server saves a
* `contentHash` per version, so a file that is *not* our newest copy can
* still be recognized as one of our older ones by looking its hash up.
*
* The second exists because the first is structurally blind twice over: it knows
* nothing about a file it did not write (left by `auden pull`, a colleague's
* commit, a fresh clone — previously all forced to "conflict"), and nothing
* about older copies, so a file reverted to an earlier version read as an edit
* and blocked the doc from ever updating again.
*
* The baseline is asked first rather than replaced, because the sidecar has to
* carry that hash regardless — version history is membership-gated
* (`versionAccess.ts` → `isGuideFileInBundle` 404s once an item leaves its
* bundle), so a file whose doc is gone can only be proven unmodified against a
* hash recorded while it was still there. Given it must exist, reading it first
* costs nothing and saves a request on the commonest path. `itemVersion` stays
* for the same reason of necessity: history says *whether* a file matches some
* version, never which version the user started editing from.
*
* **This phase never prompts.** `auden sync` runs from the Stop hook on every
* turn (Slice 1c), and a prompt there would hang an unattended session — so
* both-changed is reported and skipped rather than resolved interactively, and
* `conflict: 'local' | 'remote'` is the non-interactive escape hatch. That is a
* deviation from the spec's "diff prompt" sketch, taken because a default that
* never blocks cannot be forgotten by a hook call site the way "remember to pass
* --non-interactive" can (Planning Principle 14).
*
* Kept free of citty/console so it unit-tests against a mocked client.
*/
import type { ContextClient } from '../mcp/client.js';
import type { WriteItemsDeps } from '../mcp/write-items.js';
import type { SpawnGitFn } from './relink.js';
import type { DocMapping } from './sidecar.js';
import type { ContextItem } from '@auden.to/protocol';
/**
* Server-side maximum items a single pull returns (`MAX_LIMIT` in the items
* endpoint). Without an explicit limit the endpoint defaults to 100, so a
* subscribed bundle with more than 100 docs would silently materialize only the
* newest 100. Request the max, and flag when a bundle hits the ceiling — the
* endpoint has no cursor pagination, so beyond this we surface the truncation
* rather than pretend the bundle is fully synced (no silent caps).
*/
export declare const MAX_BUNDLE_PULL = 500;
/** What the phase did with one doc. */
export type DocOutcome =
/** First materialization — no prior mapping. */
'created'
/** Local and remote both match the baseline; nothing written. */
| 'in-sync'
/** Remote moved, local untouched — overwritten from the server. */
| 'pulled'
/** Local moved, remote untouched — sent to the server. */
| 'pushed'
/** Both moved. Nothing touched; the user resolves it. */
| 'both-changed'
/** Local moved but the item carries no version, so a safe push is impossible. */
| 'unpushable'
/** Local moved but exceeds the server's content cap. */
| 'too-large'
/** A push was attempted and the server rejected it for some other reason. */
| 'push-failed'
/**
* The local file exists but could not be read, so neither side can be trusted
* to be current. Nothing written, nothing pushed, nothing pruned.
*/
| 'unreadable'
/**
* The file differs from our baseline, but the version history ran out of
* lookback before it could be shown to be the user's work rather than one of
* our own very old copies. Left alone in both directions.
*/
| 'unverified'
/**
* A `guide` member whose origin-derived projection path failed the safety
* guard (absolute, traversal, symlink escape, or already claimed). Nothing
* written — a remote-supplied path is never "fixed up" (slice 1b).
*/
| 'invalid-path'
/**
* A `guide` scoped to the user's home directory, reached from a repo. It has
* no place in this tree at all, so nothing is written, pushed, or pruned for
* it (`guide-write-side-identity-plan.md` → slice 2).
*
* Distinct from `invalid-path` on purpose: nothing is wrong with the item or
* its path, and the two want opposite fixes — an unsafe path is a defect to
* report, while this resolves the moment a home root is available (slice 4).
*
* **It must be checked before the mapping is honoured, not only on first
* materialization.** An older CLI mapped these guides to a repo-relative
* path, and reconciling that mapping does not merely maintain the erroneous
* copy: an edit to it is a local change against an unchanged remote, which
* pushes the repo copy's contents *into* the canonical machine-global guide.
* Raised by Codex on PR #543.
*/
| 'out-of-scope'
/**
* The committed placement file holds two records for this doc at different
* paths — a genuine disagreement about where it lives, which union merge
* cannot arbitrate and this pass will not resolve by omission. Nothing is
* written, pushed or retired for it; both paths stay claimed so nothing else
* adopts them, and the report names the doc and every path claimed for it.
*/
| 'placement-conflict'
/**
* The mapped file is gone and detection could not say where it went. Nothing
* is written — re-materializing at the mapped path is precisely how a moved
* doc becomes two files — and the report offers `auden docs relink`.
*/
| 'relink-needed';
export type DocResult = {
itemId: string;
/** Repo-root-relative path of the local file. */
path: string;
outcome: DocOutcome;
/** Present for `push-failed` — never swallowed. */
error?: string;
};
export type DocsSyncBundleResult = {
bundle: string;
/** Directory the bundle materialized into. */
dir: string;
/** Number of items the bundle returned (including skipped null-content ones). */
items: number;
/** Number of files actually written to disk (created + pulled). */
written: number;
/** True when the bundle returned the server cap — more docs may exist unpulled. */
truncated: boolean;
/** Per-doc outcomes, in the order the bundle returned them. */
docs: DocResult[];
/**
* File names in `dir` that no mapping accounts for and this sync did not
* write — files Auden never put there (a note the user created by hand, a
* leftover from a clone made before the sidecar existed).
*
* **Reported, never removed.** Removal is licensed by "we wrote this, we
* recorded it, and it still matches what we wrote"; a file with no mapping
* fails the first two clauses, so nothing here can be shown to be ours. Until
* 2026-07-30 these were swept along with retired mappings, which meant an
* unattended `auden sync` could delete a user's own file out of a pull
* directory — the reason the Stop hook carried `--no-docs`
* (`docs/plans/cross-repo-docs-plan.md` → Open questions).
*
* Always empty when the orphan check was skipped (see `orphanCheck`).
*/
unmanaged: string[];
/**
* Files whose item left the bundle but which were **not** safe to remove —
* either they carry unsynced local edits, or they could not be read to check.
* Never pruned, always named: pruning is licensed by "we wrote this and nobody
* changed it", and both of these fail that test, the second by never having
* been looked at.
*
* They keep their sidecar mapping, so a later sync reconsiders them.
*/
orphansKept: string[];
/**
* Files whose item left the bundle and which were safe to remove, but which
* `--no-prune-docs` kept. Named so the flag's effect is visible rather than
* inferred from a file quietly staying put.
*/
keptUnpruned: string[];
/**
* Guides whose stale pre-slice-1b copy in this directory could not be removed,
* so the migration to their `.agents/` projection path was deferred.
*
* Reported because the deferral is otherwise invisible: the guide reconciles
* as ordinarily `in-sync` at a path that is inert (a bundle directory is not
* a discovery root, `discover.ts`), so a permissions error that never clears
* would keep it out of the agent's context forever while every sync looked
* clean.
*/
migrateFailed: string[];
/**
* Whether absence could be trusted to mean deletion for this bundle. Only a
* complete, untruncated response over a readable, contained directory licenses
* that inference — see the guards in `findBundleSweepState`.
*/
orphanCheck: 'ran' | 'skipped-error' | 'skipped-truncated' | 'skipped-unreadable' | 'skipped-unsafe-path';
/**
* Repo-relative paths this pass wrote **outside** `dir`, now that a doc lands
* at its own declared path rather than in the bundle folder.
*
* Without this the summary line's `→
` is the only destination the user
* is told, and `dir` is always `.auden/context/` — so a sync that
* wrote `docs/architecture.md` reported the wrong place and named no file
* (created and pulled docs are counted, not listed). Raised by Codex on #560.
*/
wroteOutsideDir: string[];
/** Retired mappings actually unlinked (only with `prune`). */
pruned: string[];
/** Retired mappings `prune` tried and failed to unlink — reported, never swallowed. */
pruneFailed: string[];
/** Present when this bundle failed to pull; the others still ran. */
error?: string;
};
export type DocsSyncResult = {
results: DocsSyncBundleResult[];
/** True when at least one bundle failed to pull. */
hadError: boolean;
/**
* Repo-relative paths of retired files unlinked **outside** the bundle
* directories (guide projections in discovery roots). The orphan sweep only
* enumerates bundle dirs, so without this pass a guide detached upstream
* would keep steering the agent from `.agents/` forever — and, once
* unmapped, be re-imported as repo-authored (the fork). Only unedited files
* (hash matches the baseline) are ever unlinked; cross-bundle, so reported
* here rather than per bundle.
*/
retired: string[];
/** Out-of-dir retirements that failed to unlink — reported, never swallowed. */
retireFailed: string[];
/**
* Repo-relative paths **outside** the bundle directories whose item left every
* subscribed bundle but which were not safe to remove — locally edited, or
* unreadable. The per-bundle `orphansKept` cannot carry these: it reports
* basenames scoped to a bundle folder, and a doc at `docs/architecture.md` is
* in none of them, so the basename would be attributed to whichever bundle the
* mapping happened to record.
*/
keptOutOfDir: string[];
/**
* The same, for files `--no-prune-docs` kept rather than safety. Named for the
* same reason the in-directory list is: a flag's effect must be visible, not
* inferred from a file quietly staying put. Raised by Codex on #560.
*/
keptUnprunedOutOfDir: string[];
/**
* Docs whose file was found somewhere else and whose mapping was repointed,
* before anything was written. Always reported: a mapping rewriting itself is
* exactly the kind of silent repair that has to be visible.
*/
relinked: Array<{
itemId: string;
from: string;
to: string;
}>;
/**
* Docs whose mapped file is gone and which detection could not settle —
* several equally good candidates, nothing matching, or no git work tree to
* ask. Nothing is written for these, which is the point: the old behaviour
* (re-materialize at the mapped path) is what puts a second copy on disk.
*/
relinkUnresolved: Array<{
itemId: string;
path: string;
/**
* `unstaged-move` is the near-miss: exactly one added-or-untracked file
* matches the baseline, which is almost always the moved doc — but `??` does
* not mean "appeared since the last sync", so it is named rather than
* adopted, with the candidate in `candidates`.
*/
reason: 'ambiguous' | 'no-candidate' | 'no-git' | 'unstaged-move';
candidates?: string[];
}>;
};
/** The directory-entry surface the orphan sweep needs, and nothing more. */
export type DirEntryLike = {
name: string;
isFile: () => boolean;
};
/**
* Narrow structural types rather than `typeof readdir` / `typeof unlink`: the
* node signatures are overloaded, and the overload TypeScript resolves for
* `readdir` yields `Buffer` names. These say exactly what this module uses, and
* the real `fs/promises` functions satisfy them.
*/
export type ReaddirFn = (dir: string, options: {
withFileTypes: true;
}) => Promise;
export type UnlinkFn = (path: string) => Promise;
export type RealpathFn = (path: string) => Promise;
export type ReadFileFn = (path: string, encoding: 'utf8') => Promise;
/** Just the size, which is all the relink candidate guard needs. */
export type StatFn = (path: string) => Promise<{
size: number;
}>;
export type DocsSyncDeps = WriteItemsDeps & {
/** Injected so relink detection unit-tests without a real git work tree. */
spawnGitFn?: SpawnGitFn;
readdirFn?: ReaddirFn;
unlinkFn?: UnlinkFn;
realpathFn?: RealpathFn;
readFileFn?: ReadFileFn;
statFn?: StatFn;
};
/** How a both-changed doc is resolved when the user has pre-committed to one side. */
export type ConflictResolution = 'skip' | 'local' | 'remote';
export declare const CONFLICT_RESOLUTIONS: readonly ConflictResolution[];
/**
* Validate `--docs-conflict`, returning null for anything unrecognized.
*
* Null rather than a silent fall back to `'skip'`: a typo (`--docs-conflict
* locl`) would otherwise look like it ran and quietly leave every conflict
* unresolved, which is the opposite of what the user asked for. The caller turns
* null into a hard error.
*/
export declare function parseConflictResolution(value: string): ConflictResolution | null;
export type DocsSyncOptions = DocsSyncDeps & {
/**
* Remove files the bundle no longer contains. **Defaults to on** — opting out
* is `--no-prune-docs`.
*
* Reconciling by default is the point of the deliverable: agents read this
* directory, not the terminal, so reporting a deleted note while leaving its
* file in place lets the content the user deleted keep flowing into agent
* context indefinitely. Subscribing the repo to a bundle is the consent.
*
* Slice 2 narrows what this can reach. Pruning was safe as a default because
* the directory was a pull-only cache that preserved no local edits; now that
* it can, a file with unsynced edits is never pruned regardless of this flag
* (`orphansKept`), and neither is any path a live mapping claims.
*/
prune?: boolean;
/**
* What to do with a doc that changed on both sides. Defaults to `'skip'` —
* touch nothing and report. `'local'` re-pushes over the server's version,
* `'remote'` overwrites the local file. Never prompts (see the module note).
*/
conflict?: ConflictResolution;
/**
* Where the machine cache lives — the record for a `global` guide, which no
* repo can hold. Injected by tests, which must not read or write the
* developer's real `~/.auden/state/`.
*/
machineCachePath?: string;
};
/**
* Names of top-level `.md` files present in a pull directory that this sync
* cannot account for — neither written this pass, nor claimed by a mapping,
* nor retired from one. Kept pure so the guards around it are testable.
*
* `accountedPaths` carries two things the write list cannot. A doc that is
* in-sync, both-changed, or awaiting a push is deliberately *not written* this
* pass, so without its mapping the difference would read "not written" as
* "not ours" and name a file the user is mid-edit on. A mapping retired this
* pass is likewise ours — the sweep state is read before retirement runs, so a
* file already unlinked is still in `presentFileNames`. Both are passed in
* rather than consulted here so this stays a pure set difference.
*
* What is left is a file Auden has no record of ever writing. It is reported
* and never removed; see `DocsSyncBundleResult['unmanaged']`.
*
* Matching is case-insensitive to mirror `writeItemsToDir`'s collision tracking:
* coding agents commonly run on case-insensitive filesystems, where a rename
* that only changes case rewrites the same file, and a case-sensitive compare
* would then report the file it just wrote as unmanaged.
*/
export declare function findUnmanagedFileNames(presentFileNames: readonly string[], writtenPaths: readonly string[], accountedPaths?: readonly string[]): string[];
/**
* Paths two or more *different* items declare across the whole subscription.
*
* **Both are refused, not one** (`unified-item-sync-plan.md` → "Check the whole
* subscription for path collisions before any write, and refuse both items with
* a named report"). Sent to one local file, the better outcome is a permanent,
* legible conflict and the worse one is a silent overwrite — and refusing only
* the second claim would still let bundle order decide which doc owns the file,
* which is the ordering dependence item-derived placement removes.
*
* Three deliberate narrowings:
*
* - **Declared paths only.** A guide with no `path` derives one from its origin,
* and a doc with none lands in the bundle directory; neither is a *declaration*
* two items can disagree about, and the per-pass claim check in
* `containedProjection` remains the backstop for those.
* - **Keyed by item id**, so the same item surfacing from two subscribed bundles
* is one item at one path, not a collision with itself.
* - **Already-mapped items are exempt.** Their path is authoritative and is never
* re-derived, so a mapping cannot be talked out of its file by a newcomer's
* declaration — the claim set refuses the newcomer instead.
*
* Compared case-insensitively: the claim is about a *file*, and macOS and Windows
* resolve `Docs/API.md` and `docs/api.md` to one.
*/
export declare function findDeclaredPathCollisions(items: readonly ContextItem[], mappings: ReadonlyMap): Set;
export declare function syncSubscribedDocs(client: ContextClient, bundles: string[], root: string, options?: DocsSyncOptions): Promise;
/**
* Report lines for everything that happened **outside** the bundle folders —
* printed by the sync command after the per-bundle lines, which cannot carry
* them: those are scoped to one folder and name files by basename.
*
* Once a doc lands at its own declared path this is no longer a guide-projection
* report, so it says "file(s) outside the bundle folders" rather than "projected
* guide file(s)". It covers removals, failed removals, and — since slice 2 — the
* two ways a file is *kept*: unsafe to remove, and `--no-prune-docs`. A kept file
* that nothing names is indistinguishable from one nothing noticed.
*
* Empty when nothing out-of-dir happened.
*/
export declare function formatDocsRetirementLines(result: DocsSyncResult): string[];
export { displayFileName } from './display-path.js';
/**
* One legible block per bundle for the sync output (Planning Principle 9 — what
* moved is never ambiguous). Failures read as warnings; a successful sync says
* how many files moved in each direction, and names everything that needs the
* user: conflicts, refusals, and files the bundle no longer contains.
*/
export declare function formatDocsSyncLine(result: DocsSyncBundleResult): string;
//# sourceMappingURL=sync-docs.d.ts.map