/** * The docs sidecar, in two halves that want different lifetimes. * * One record per materialized doc — which item it came from, where it landed, * the hash of the bytes we wrote, and the item version we wrote them from. That * is exactly enough to answer the two questions the docs phase cannot otherwise * ask: *did the user edit this file since we wrote it?* (hash) and *did the doc * move on upstream?* (version). Crossing those two gives the three-point state * in cross-repo-docs-plan.md → Conflict policy. * * | Fact | Scope | File | * |---|---|---| * | `itemId`, `bundle`, `path` — which doc, which subscription, and where it lives *in this repo* | repo | `.auden/docs-placement.jsonl`, **committed** | * | `hash`, `itemVersion` — what *this machine* last wrote | machine | `~/.auden/state/machine-cache.json`, keyed by absolute path | * * **Why they split** (`docs-in-place-plan.md` → "The sidecar fuses two facts"). * Committing placement is what makes "this doc lives here" a property of the * repo rather than of one laptop: a teammate's clone picks up the location * instead of re-materializing the doc at its default path, and a `git mv` one * person makes is a fact everyone else inherits. Committing the baseline would * be actively wrong — it records what *this machine* observed, and sharing it * would let one machine's write history license another machine's overwrite. * * **The machine half moved out of the repo** (`machine-state-consolidation-plan.md`). * It used to live in this repo's gitignored `.auden/docs.json`, keyed by item id. * It is now one machine-wide cache keyed by each file's absolute path * (`state/machine-cache.ts`), because the same fact was also being kept, under a * different key, by `rules/guide-baseline.ts` — two caches, no record. Nothing * about the committed half changed. A repo whose last sync predates the move * still has its `docs.json`, which is read as a fallback and folded into the * cache by the next write; it is never written again. * * **A placement with no baseline is therefore a real state**, and it is what * every fresh clone sees. It must not read as "we wrote this and it changed": * `hash` is optional, an absent one matches nothing, and the reconciler falls * through to the doc's own version history — the mechanism that exists for a * file this machine did not write. `isPushable` refuses such a mapping too, so * a baseline-less clone can never push over a teammate's edit. * * **The committed half is line-oriented and union-merged**, one JSON object per * line sorted by path, with `.auden/.gitattributes` marking it `merge=union`. * Two teammates each adding a doc is two disjoint facts, not a conflict, and * union merge takes both sides without ever stopping a rebase. What union merge * cannot arbitrate is two records for one `itemId` at different paths — that is * a genuine disagreement about where a doc lives, so it survives into the file * and is *reported* (`Sidecar['placementConflicts']`) rather than silently * resolved. Per-line parsing is the other half of the win: a malformed line * costs one mapping, where the old whole-file parse cost every mapping in the * repo and a whole-repo re-materialization with it. * * Per CLAUDE.md → Validation this is parsed with Valibot (`v.safeParse`), and * degrades to "no mappings" on a missing or malformed file the same way * `readRepoDocsConfig` does. The sibling `.auden/config.json` reader hand-parses * because it predates that constraint; this is not that. * * Kept free of citty/console so it unit-tests directly against a temp dir. */ /** Bump only on a breaking change to the record shape. */ export declare const SIDECAR_SCHEMA_VERSION = 1; /** * The fused view the docs phase works with. `hash` is optional: a fresh clone * has the committed placement and none of this machine's baselines, and an * absent hash must read as "cannot prove we wrote this" rather than as a match. */ export type DocMapping = { itemId: string; bundle: string; path: string; hash?: string | undefined; itemVersion?: number | undefined; }; /** Two placement records naming one item at different paths. */ export type PlacementConflict = { itemId: string; /** Bundle the conflicting records name, so they can be re-emitted verbatim. */ bundle: string; /** Every distinct path claimed for this item, in file order. */ paths: string[]; }; export type Sidecar = { version: number; docs: DocMapping[]; /** * Items the placement file disagrees about. Reported, never resolved: both * paths are somebody's committed file, and picking one by omission is exactly * the silent last-writer-wins this file's shape exists to avoid. The sync * skips such a doc for the pass and names it. */ placementConflicts: PlacementConflict[]; }; /** * The filesystem the sidecar touches, injectable so the docs phase's tests stay * hermetic — they already inject a fake fs for the materialization path, and a * sidecar that reached around it would have those tests writing into a real * directory named after the fixture root. */ export type SidecarDeps = { readFileFn?: (path: string, encoding: 'utf8') => Promise; writeFileFn?: (path: string, data: string) => Promise; mkdirFn?: (path: string, options: { recursive: true; }) => Promise; /** * Where the machine cache lives. Injected by tests for the same reason the * filesystem is: the cache is machine-wide, so a suite that left it at the * default would read and write the developer's real `~/.auden/state/` and one * test's fixture would license another's overwrite. */ machineCachePath?: string; }; /** * Normalize an absolute local path into the repo-root-relative POSIX form the * sidecar stores. Paths outside the root keep their `..` prefix rather than * being rewritten — a caller handing us one is a bug, and silently rebasing it * would hide that. */ export declare function toSidecarPath(root: string, absolutePath: string): string; /** Resolve a stored sidecar path back to an absolute path under `root`. */ export declare function fromSidecarPath(root: string, storedPath: string): string; /** * Hash file bytes. sha256 over the exact string written to disk — note * `writeItemsToDir` appends a trailing newline when the item content lacks one, * so the hash must be taken over the *written body*, not the item's `content`, * or every freshly pulled doc would read back as locally edited. */ export declare function hashContent(content: string): string; /** * True when a mapping can be pushed back. A versionless item has no * precondition to send, so a PUT would silently fall through to last-write-wins * and clobber a concurrent edit. The design review's resolution is to refuse * the round-trip rather than degrade it, so this is the one place that decides, * and the push path asks it. * * `guide` items were the whole population this excluded until slice 2b-B of * `guide-versioned-identity`, which made the server report a guide's body-item * version (`ContextItemSchema.version`). Nothing here changed for that: this * asks about the version, not the type, so guides became pushable the moment * the server had an honest answer. What remains excluded is any item genuinely * without one — a guide whose `body_item_id` is still NULL, a doc row predating * the version column, or a mapping this machine has no baseline for at all. */ export declare function isPushable(mapping: DocMapping): boolean; /** * Read a repo's sidecar — placement from the committed file, baselines from the * gitignored one, fused by item id. * * A missing or malformed file yields no mappings rather than a refusal to sync, * and the two halves degrade independently: no placement file means the legacy * records in `docs.json` supply it (an upgrade, not a re-materialization), and * no baseline means the mappings arrive without a hash — which is exactly what a * fresh clone should see. */ export declare function readSidecar(root: string, deps?: SidecarDeps): Promise; /** * Persist the **committed half alone**, leaving the machine-local baseline * untouched. * * Split out for `auden docs relink`, whose whole contract is that it repoints a * placement record and writes nothing else: routing it through `writeSidecar` * would rewrite `docs.json` from the in-memory mappings as a side effect, and * the reader deliberately withholds a conflicted item from `docs` — so an * unrelated conflicted doc would silently lose the baseline that proves this * machine wrote its file. Serialization lives here rather than in the command * so there is exactly one writer of this format. * * Records are sorted by path and written one per line: sorting keeps repeated * writes from churning a *committed* file, and the line orientation is what * makes two teammates' additions merge instead of overlapping inside a * pretty-printed object. Conflicted records are re-emitted verbatim — a rewrite * that dropped them would resolve by deletion a disagreement only the user may * settle. * * **This writes placement only, so a caller that uses it to *drop* a mapping * leaves that path's machine-cache entry behind.** `writeSidecar` forgets dropped * paths and `relinkDocMapping` re-keys the moved one; both of today's callers are * therefore correct. A third that removes a record here without doing one of * those would leave a stale hash at a path a later file could inherit, and it * would read as "we wrote this, unmodified". If a third caller appears, the rule * belongs inside this function rather than in a comment. */ export declare function writePlacement(root: string, docs: readonly DocMapping[], conflicts?: readonly PlacementConflict[], deps?: SidecarDeps): Promise; /** * Persist both halves: the committed placement file in the repo, and this * machine's observations into the machine cache keyed by absolute path. * * Placement records are sorted by path and written one per line: sorting keeps * repeated syncs from churning a *committed* file, and the line orientation is * what makes two teammates' additions merge instead of overlapping inside a * pretty-printed object. * * A mapping with no baseline writes no cache entry — writing a placeholder would * turn "this machine never wrote this file" into a hash that matches nothing, * which is the same thing with a worse failure mode. * * **Mappings this repo dropped lose their cache entry.** The old whole-file * rewrite of `docs.json` did that implicitly; here it has to be deliberate, or a * path that was pruned and later re-used would carry the previous file's hash and * read as "we wrote this, unmodified". Only paths this repo's own placement * claimed are forgotten — the cache is machine-wide, and a write for one repo * must never reach into another's entries. */ export declare function writeSidecar(root: string, sidecar: { version?: number; docs: readonly DocMapping[]; /** * Conflicted records to re-emit verbatim. * * A conflicted item is deliberately absent from `docs` — the reader * withholds it rather than picking a side — so a rewrite triggered by any * *other* doc would drop both of its lines and thereby resolve the conflict * by deletion, after which the next sync sees an unmapped item and * materializes a third copy. The conflict must survive every rewrite until * the user removes a line. Raised by Codex on PR #564. */ conflicts?: readonly PlacementConflict[]; }, deps?: SidecarDeps): Promise; /** * Make sure `.auden/.gitignore` ignores the pre-consolidation baseline half. * * Kept after that file stopped being written: an upgraded repo still has one on * disk, and dropping the rule would surface it in `git status` as something the * user has to decide about. It is left in place rather than deleted from under * them — the same call `auden-paths.ts` makes for the retired `~/.auden/queue`. */ export declare function ensureSidecarIgnored(root: string, deps?: SidecarDeps): Promise; /** * Make sure `.auden/.gitattributes` union-merges the committed placement file. * * Two teammates each adding a doc is two disjoint facts, and union merge takes * both sides rather than stopping a rebase over lines that do not actually * disagree. What it cannot arbitrate — one item at two paths — is precisely what * the reader reports instead. */ export declare function ensurePlacementUnionMerged(root: string, deps?: SidecarDeps): Promise; /** * The repo-relative paths the committed placement file claims — the "is this file * dashboard-owned?" question, and nothing else. * * `auden import` and `auden init` ask only this, to keep a bundle-managed * file from being re-imported as a second guide. Routing them through * `readSidecar` would make them read the machine cache to answer a question that * is purely about the committed record, which is both wasted work and a reader on * the fused view that does not need to be there * (`machine-state-consolidation-plan.md` → slice 2). * * A conflicted item still counts as claimed: two committed lines disagreeing * about *where* a doc lives is not doubt about *whether* the dashboard owns it, * and importing it during the disagreement is the fork this guards against. */ export declare function readPlacement(root: string, deps?: SidecarDeps): Promise>; /** Index a sidecar's records by item id for lookup during a sync pass. */ export declare function indexByItemId(sidecar: { docs: readonly DocMapping[]; }): Map; //# sourceMappingURL=sidecar.d.ts.map