/** * 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