/** * Scope-shrink detection + classification + clean removal (US-005). * * Implements the "hybrid scope-change contract" decided in US-000 Task 3 * (see companies/indigo/projects/hq-sync-browse-vs-sync/references.md): * * - Compare the current pull's `prefixSet` against the last `PullRecord`'s * `prefixSet` for the same company. Files in the journal covered by the * previous scope but NOT covered by the new scope are **orphans**. * - Classify each orphan **clean** (safe to silently delete) or **dirty** * (locally modified — sacred, never silently delete). * - The remote-pull caller drives: * * default mode: abort the leg if any dirty orphan exists; * * `--force-scope-shrink`: continue, leave dirty files on disk, * tombstone their journal entries. * * The pure-detection layer here intentionally does NOT touch disk for the * tombstone write — that lives in `journal.ts`. It DOES touch disk for the * orphan classification (hash + stat) because cleanliness is a function of * the file's current on-disk state vs the journal. * * PUSH-ONLY PREFIXES ARE NEVER PRUNED HERE (US-006). Session transcripts under * `sessions/{personUid}/...` are push-only: pushed into the vault, then * subtracted from every pull scope (incl. `all`/owner) via * `resolvePullScope().excludePrefixes`. That exclude set is applied ONLY to the * download filter (`computePullPlan`) and is DELIBERATELY NOT passed into this * module's `currentPrefixSet`. So a session file that was authored locally rides * the `direction:"up"` skip below, and one materialized on demand (`hq files * get`) rides the pin union already folded into the caller's inclusion * `prefixSet` — either way it is never pulled AND never orphaned. Feeding the * exclude set into `currentPrefixSet` would prune exactly those files; callers * must not. */ import type { JournalEntry, PullRecord, SyncJournal } from "./types.js"; import { type ScopePrefixInput } from "./prefix-coalesce.js"; export interface OrphanClassification { /** Relative path (journal key). */ path: string; /** Journal entry as of last sync. */ entry: JournalEntry; /** True iff the local file is provably unchanged since last sync. */ clean: boolean; /** Why we called it dirty — surfaced in the abort error for operators. */ dirtyReason?: "modified-after-sync" | "hash-mismatch" | "stat-error"; } export interface ScopeShrinkPlan { /** Set of files covered by `lastPrefixSet` but not by `currentPrefixSet`. */ orphans: OrphanClassification[]; /** Subset of `orphans` with `clean === true`. */ clean: OrphanClassification[]; /** Subset of `orphans` with `clean === false`. */ dirty: OrphanClassification[]; /** True iff at least one orphan was found. */ scopeChangeDetected: boolean; } export interface BuildScopeShrinkPlanInput { journal: SyncJournal; hqRoot: string; /** Coalesced prefixes used by the LAST pull for this company. */ lastPrefixSet: readonly ScopePrefixInput[]; /** Coalesced prefixes the CURRENT pull will use. */ currentPrefixSet: readonly ScopePrefixInput[]; /** * The caller's own Cognito `sub`. When set, a file the caller authored * (`entry.createdBySub === callerSub`) is NEVER orphaned by a scope shrink — * regardless of mode. This is the core of the authorship contract: sync mode * governs whether you mirror *other people's* files; it must never disown * your own work. Owners hold their whole vault by role-bypass, so without * this guard a `shared`/`custom` scope would treat their own un-granted * content as "someone else's file I happen to see" and prune it. */ callerSub?: string; /** * When `true`, an orphan whose authorship is unknown (`createdBySub` * undefined — a legacy entry predating author stamping, or an object * uploaded without author metadata) is also retained rather than pruned. * The automatic background pull sets this so a routine sync never makes a * destructive guess about pre-stamp content; the explicit `hq sync narrow` * ritual (which carries its own confirmation + dirty gate) leaves it off so * a deliberately-confirmed narrow can still reclaim legacy files. */ protectUnknownAuthors?: boolean; } /** * Build a scope-shrink plan: find orphans, classify each clean/dirty. * Pure given the journal + filesystem state — no network, no journal * mutation. * * **Tombstone-aware:** journal entries that already carry a `removedAt` * marker are skipped — they represent a prior scope-shrink prune and must * not be re-flagged as orphans on each subsequent pull (that's the whole * point of the tombstone retention window). * * **Direction-aware:** only `direction: "down"` entries (and pre-ETag * legacy entries without an explicit direction marker) participate in * shrink detection. Push-only files (`direction: "up"`) represent local * authorship — they aren't in scope-as-pulled, so a scope change doesn't * orphan them. */ export declare function buildScopeShrinkPlan(input: BuildScopeShrinkPlanInput): ScopeShrinkPlan; /** * Where a scope-shrink error is going to be rendered, so the structured error * can carry advice that is ACTUALLY FOLLOWABLE from that entry point. * * - `"cli"` — a human at a terminal running `hq sync pull|now`. They can * re-run with `--force-scope-shrink` or run the guided * `hq sync narrow --apply` ritual. * - `"runner"` — the menubar's `hq-sync-runner`. It accepts NO such flag * (DEV-1768 fix #2: the old "pass --force-scope-shrink" advice * was impossible to follow from here), so the only followable * action is to open a terminal and run `hq sync narrow --apply`. * In practice the runner pulls with `scopeShrinkPolicy: * "auto-recover"` and never throws this — but the context keeps * the message honest if it ever surfaces. * - `"engine"` — unknown/library caller; generic advice. */ export type ScopeShrinkAdviceContext = "cli" | "runner" | "engine"; /** * Structured error thrown when the engine refuses to proceed because a scope * shrink would orphan dirty files. The CLI catches this and renders the * operator-facing message; the engine never prints directly. * * `adviceContext` makes the message FOLLOWABLE from each entry point — the old * fixed "pass --force-scope-shrink" line was impossible to act on from the * menubar runner, which rejects that flag (DEV-1768 fix #2). */ export declare class ScopeShrinkBlockedError extends Error { readonly companyUid: string; readonly fromMode: PullRecord["syncMode"] | "unknown"; readonly toMode: PullRecord["syncMode"]; readonly dirty: OrphanClassification[]; readonly clean: OrphanClassification[]; readonly adviceContext: ScopeShrinkAdviceContext; readonly code = "SCOPE_SHRINK_BLOCKED"; constructor(companyUid: string, fromMode: PullRecord["syncMode"] | "unknown", toMode: PullRecord["syncMode"], dirty: OrphanClassification[], clean: OrphanClassification[], adviceContext?: ScopeShrinkAdviceContext); } /** * Structured error thrown when an AUTOMATIC scope shrink would prune more * CLEAN local files than the configured safety cap in a single pull. This is * the bulk-delete guard: a routine background sync should never silently * delete a large local tree — whether from a deliberate-but-abrupt first * narrow, a server bug returning an unexpectedly small grant set, or a * mis-resolved scope. The operator runs the explicit `hq sync narrow` ritual * (which has its own confirmation + dirty gate) or passes `--force-scope-shrink` * to proceed. The engine never deletes anything when it throws this. */ export declare class ScopeShrinkLargePruneError extends Error { readonly companyUid: string; readonly toMode: PullRecord["syncMode"]; readonly cleanCount: number; readonly cap: number; readonly adviceContext: ScopeShrinkAdviceContext; readonly code = "SCOPE_SHRINK_LARGE_PRUNE"; constructor(companyUid: string, toMode: PullRecord["syncMode"], cleanCount: number, cap: number, adviceContext?: ScopeShrinkAdviceContext); } /** * Disposition for CLEAN orphans (files provably unchanged since the last sync) * that fall outside the new scope: * * - `"delete"` — `unlink` the local file. The legacy behavior; reserved * for the explicit `hq sync narrow --apply` ritual, which * already confirms the file list with the operator. * - `"quarantine"` — MOVE the file into `quarantineRoot` instead of deleting * it, so it stays recoverable. The conservative default * for the automatic pull path: a background sync must * never silently PURGE local files (DEV-1768 fix #3). */ export type CleanOrphanDisposition = "delete" | "quarantine"; export interface ApplyScopeShrinkInput { journal: SyncJournal; plan: ScopeShrinkPlan; hqRoot: string; /** * When `true`, dirty files are LEFT ON DISK and their journal entries are * tombstoned anyway. When `false` (default), the caller should have * already aborted on dirty orphans — this function still tombstones any * dirty entries handed to it, on the assumption the caller knows what * it's doing. */ forceScopeShrink: boolean; reason?: "scope_shrink" | "narrow_apply" | "manual"; /** * How to dispose of CLEAN orphans. Defaults to `"delete"` so existing * callers (and the confirmed `narrow --apply` ritual) keep their behavior; * the automatic pull path passes `"quarantine"`. */ cleanDisposition?: CleanOrphanDisposition; /** * Absolute directory clean orphans are relocated into when * `cleanDisposition === "quarantine"`. Each orphan moves to * `/` (parent dirs created). REQUIRED when * quarantining; if absent, the function falls back to `"delete"` so it can * never get stuck unable to make progress. */ quarantineRoot?: string; } export interface ApplyScopeShrinkResult { /** Clean orphans `unlink`ed from disk (only when disposition is `delete`). */ cleanRemoved: number; /** Clean orphans MOVED to quarantine (only when disposition is `quarantine`). */ cleanQuarantined: number; /** Dirty orphans tombstoned in the journal (file LEFT on disk). */ dirtyTombstoned: number; /** Named paths deleted — for explicit, non-silent operator reporting. */ removedPaths: string[]; /** Named paths moved to quarantine — for explicit reporting. */ quarantinedPaths: string[]; /** Named dirty paths un-tracked but KEPT on disk — for explicit reporting. */ dirtyKeptPaths: string[]; /** Absolute quarantine directory used (when anything was quarantined). */ quarantineRoot?: string; } /** * Apply a scope-shrink plan: dispose of clean orphans (delete OR quarantine) * + tombstone their journal entries. With `forceScopeShrink: true`, dirty * orphans are PRESERVED on disk and only their journal entries are tombstoned * (so they stop being re-flagged on every pull — the idempotent recovery seam). * * Returns counts AND named paths so the caller can report exactly what moved / * was un-tracked — never a silent purge (DEV-1768 fix #3). */ export declare function applyScopeShrink(input: ApplyScopeShrinkInput): ApplyScopeShrinkResult; //# sourceMappingURL=scope-shrink.d.ts.map