/** * Archival integrity — make "move content between state files" incapable of * destroying that content. * * Archival is a two-half operation: append to a destination, then trim from a * source. Three production defects came from the halves coming apart: * * - #1774 — the trim ran and the append never did. * - #1783 — the append ran, but to an *untracked* destination. Under a * git-excluded `.squad/`, already-tracked files still commit while brand-new * files silently never do. The trim commits; the destination does not. * Archival becomes deletion, and the reported outcome is success. * - #1760 — inbox bodies were spliced verbatim under `###` entries, landing * `##` children beneath an `###` parent and breaking document hierarchy. * * The rules encoded here, in the order they must execute: * * 1. Assert the destination is git-tracked *before* writing to it. * 2. Append, verify the entries actually landed, and only then trim. * 3. Report entry counts, never file sizes. Size is not an integrity signal — * a merge and an archive in the same pass can move a file's size in the * wrong direction while both halves behave correctly. * 4. Demote inbox headings on merge so relative structure is preserved. * 5. Never report a gate outcome that was not measured. * * @module state/io/archival */ /** Thrown when an archive destination cannot be committed. */ export declare class UntrackedArchiveDestinationError extends Error { readonly destination: string; constructor(destination: string); } /** Thrown when an append cannot be proven to have landed. Source is left intact. */ export declare class ArchiveVerificationError extends Error { readonly missing: string[]; constructor(message: string, missing?: string[]); } /** Runs a git command and returns its exit code. Injectable for tests. */ export type GitRunner = (args: string[], cwd: string) => number; /** * Rule 1 — is this path tracked by git? * * `git ls-files --error-unmatch ` exits 0 only for tracked paths. It is * deliberately *not* `git check-ignore`: a file can be untracked without being * ignored, and both cases are equally uncommittable in an automated run. */ export declare function isTrackedInGit(filePath: string, repoRoot: string, git?: GitRunner): boolean; /** * Rule 1 — refuse to archive into a destination that cannot be committed. * * When `fallbackDestination` is supplied and itself tracked, the write is * redirected there instead of aborting. * * @returns the destination that is safe to write to. * @throws {UntrackedArchiveDestinationError} when no tracked destination exists. */ export declare function resolveTrackedDestination(options: { destination: string; repoRoot: string; fallbackDestination?: string; git?: GitRunner; }): { destination: string; redirected: boolean; }; /** * Rule 1, applied to a destination that may not exist yet. * * `resolveTrackedDestination` demands an already-tracked path, which is right * for a merge into an existing archive but wrong for the first archival in a * repository, where the destination legitimately does not exist yet. * * The narrower hazard is a destination git has been told to ignore. Content * moved there is removed from a tracked source — which commits — and written to * a file that never can, so the move lands as a net deletion (#1783). A path * that is merely absent is safe: it stages normally. * * @returns `true` when writing to `filePath` can survive a commit. */ export declare function isCommittableDestination(filePath: string, repoRoot: string, git?: GitRunner): boolean; /** * Fence-aware line indices of every heading at `level`. * * Callers that slice a document into records on a heading delimiter must not * treat a `###` inside a fenced code sample as a record boundary — doing so * mis-associates content across records (#1760). Exported so those callers * reuse this fence tracking instead of re-implementing `/^###\s/`. */ export declare function findHeadingLineIndices(markdown: string, level: number): number[]; /** Fence-aware heading text extraction, e.g. `### 2026-03-26: Copilot git safety rules`. */ export declare function extractHeadings(markdown: string, level?: number): string[]; /** * Rule 3 — count entries. This is the only valid integrity signal for archival. * * Fence-aware, so `#` comments inside code samples are never counted. */ export declare function countEntries(markdown: string, level?: number): number; /** * Rule 4 — shift every heading in a body down by `by` levels, fence-aware. * * Levels clamp at 6 (the deepest heading markdown defines), so a body that is * already deep degrades gracefully rather than emitting `#######`. */ export declare function demoteHeadings(markdown: string, by?: number): string; /** * Rule 4 — prepare an inbox body to be spliced beneath an `###` entry. * * The shift is computed from the body's *shallowest* heading so that it lands * at `####`, which preserves relative structure whether the inbox file used * `##` sections (the common case, #1760) or `###`. */ export declare function prepareInboxBodyForMerge(body: string, parentLevel?: number): string; /** A single `###` decision entry: its heading and the body that follows it. */ export interface DecisionEntry { readonly heading: string; readonly text: string; } /** * Split a decisions document into a preamble plus its `###` entries. * * Boundaries are drawn **only** at `level` headings. A shallower heading does * NOT close an entry: `.squad/decisions.md` carries ~60 stray `##`/`#` headings * spliced under `###` entries (#1760), and treating those as boundaries would * push the rest of the entry into the preamble and reorder the document on * rebuild. Splitting only at `level` makes the split lossless by construction — * `[preamble, ...entries.map(e => e.text)].join('\n')` reproduces the input. */ export declare function splitEntries(markdown: string, level?: number): { preamble: string; entries: DecisionEntry[]; }; /** Measured outcome of an archival run. Counts only — never sizes. */ export interface ArchivalResult { readonly removedFromSource: number; readonly addedToDestination: number; readonly headings: string[]; readonly destination: string; readonly redirected: boolean; } export interface ArchiveEntriesOptions { /** Tracked file entries are moved out of. */ sourcePath: string; /** Intended archive destination. Must be tracked, or redirect via fallback. */ destinationPath: string; /** Repo root used for `git ls-files`. */ repoRoot: string; /** Predicate selecting which entries to archive. */ select: (entry: DecisionEntry) => boolean; /** Existing tracked archive to fall back to when `destinationPath` is untracked. */ fallbackDestination?: string; /** Heading level that delimits entries. Defaults to 3 (`###`). */ level?: number; git?: GitRunner; /** * File I/O seam. Defaults to `node:fs`. Exists so the verify-then-trim rule * can be exercised against a destination that accepts a write but does not * persist it — which is exactly #1774's shape. */ io?: { readFile: (p: string) => string; appendFile: (p: string, data: string) => void; writeFile: (p: string, data: string) => void; exists: (p: string) => boolean; }; } /** * Archive entries from a source document into a destination archive. * * Order is load-bearing and enforced, not merely documented: * * 1. resolve a **tracked** destination (rule 1) — before any write; * 2. **append** to the destination; * 3. **verify** by re-reading the destination and confirming every archived * heading is literally present and the count matches (rule 2); * 4. only then **trim** the source. * * If verification fails the source is never touched, so a failed archive * degrades to a no-op with a duplicate in the archive — recoverable — rather * than to silent data loss. */ export declare function archiveEntries(options: ArchiveEntriesOptions): ArchivalResult; /** * Rules 3 + 5 — render a report from measured counts. * * Refuses to render a mismatched result: a report is only allowed to describe * numbers that were actually observed and that balance. "No archival required" * must come from a measurement, never from an assumption. */ export declare function formatArchivalReport(result: ArchivalResult, repoRoot?: string): string; //# sourceMappingURL=archival.d.ts.map