/** * Personal-vault scope helpers — shared between the menubar runner * (`hq-sync-runner`) and the `hq sync` CLI so every composer of a personal * push uses the same exclusion list. * * The exclusion list mirrors the Rust constant of the same name in * `hq-sync/src-tauri/src/commands/personal.rs` so the Tauri menubar's * first-push and this Node engine's steady-state push enforce identical * scope. Every other top-level entry under hq_root (e.g. `.claude/`, * `knowledge/`, `modules/`, `README.md`, `.codex/`, `core/`, `data/`, * `personal/`) is included, subject to the usual `.hqignore` filter. * * Excluded entries (and why): * - `.git`: a git repo's own metadata is hostile to multi-machine * sync; .gitignore alone doesn't cover `.git/` because it's the repo * itself, not a tracked path. * - `companies/`: the top-level `companies/` directory is never enumerated * wholesale. Team-backed companies are synced by the runner's * per-membership fanout (one bucket per company). Individual * `companies/{slug}/` subdirs are added back via * `computePersonalCompanySubdirs()` for every LOCAL (non-cloud) company * — i.e. any company that is not in the operator's team-synced membership * set and is not explicitly marked `cloud: true` in `company.yaml`. Those * land under the personal bucket as `companies/{slug}/...` keys. (The * single `companies/manifest.yaml` is also carved in unconditionally as * the routing source-of-truth.) * - `repos/`, `workspace/`: per user directive — heavy local-only * content (cloned remotes, session threads) that has no business in * the personal vault. Three explicit sub-paths pierce this exclusion and * DO round-trip: `workspace/threads/handoff.json` (+ its active thread) via * {@link computeContinuityPointerPaths}, `workspace/agency/` via * {@link computeAgencySyncPaths}, and `workspace/.session-logs/` (the * reindex-captured Claude Code transcripts) via * {@link computeSessionLogsSyncPaths}. * * Note: `core/`, `data/`, and `personal/` were previously excluded but are * INCLUDED as of user directive 2026-05-13. `core/` ships the hq-core * scaffold — policies/, settings/, skills/, workers/, plus the rules * manifest at core/core.yaml. `data/` and `personal/` carry per-user data, * policies, hooks, and skills that follow the user across machines. A * root-level `core.yaml` (at hq_root, distinct from `core/core.yaml`) is * filtered layout-aware in `createIgnoreFilter`: excluded only when * `core/core.yaml` exists (v15+, where root core.yaml is a stale duplicate); * on v12–v14 roots with no `core/core.yaml`, the root `core.yaml` IS the * scaffold/version manifest and round-trips like any other top-level file. */ import type { SyncJournal } from "./types.js"; export declare const PERSONAL_VAULT_EXCLUDED_TOP_LEVEL: readonly string[]; /** * Company slugs that are never eligible for the personal-bucket fallback, * regardless of their `cloud:` marker. `_template` is the scaffolding * source for `/newcompany` — copying it into the personal vault would * pollute every machine's vault with the template tree. */ export declare const PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS: readonly string[]; export interface PersonalVaultOptions { /** * Slugs of companies that already have their own team bucket (i.e. the * operator has an active Membership row for them). These are excluded * from the personal-bucket fallback so a single company's content never * ends up in two buckets. This membership-derived set is the single * "cloud-backed" signal: when a company becomes cloud-true (gains an * active membership), its slug lands here and the personal vault both * stops pushing it and decommissions its stale copy. */ teamSyncedSlugs?: ReadonlySet; } /** * Compute absolute paths to share for the personal vault. * * Two sources are concatenated: * 1. Every top-level entry under `hqRoot` whose basename is NOT in * `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL`. * 2. Every local (non-cloud) `companies/{slug}/` subdir, after excluding * (a) `_template`, (b) slugs already in `opts.teamSyncedSlugs` * (cloud-backed — they sync to their own team bucket), (c) slugs with * `cloud_uid: cmp_*` in `companies/manifest.yaml`, and (d) any * subdir explicitly marked `cloud: true` in its `company.yaml`. * A company with no `company.yaml` (or one without a `cloud:` marker) * is included — local companies sync to the personal vault by default. * * Order is whatever `fs.readdirSync` returns — share() doesn't care, and * the per-file walk inside share() handles recursion uniformly. Missing * hqRoot returns []; callers treat that as "no personal content to push" * rather than a hard error. */ /** * S3 key (hq-root-relative, forward-slash) of the companies manifest — the * routing source-of-truth — carved into the personal vault even though * `companies/` is otherwise excluded. Exported so the PULL plan applies the * SAME exemption: skipping it on the pull leaves it unjournaled, which re-fires * a transient push-side conflict every sync (no journal baseline). */ export declare const PERSONAL_VAULT_MANIFEST_KEY = "companies/manifest.yaml"; /** * Return true for a path where HQ materializes generated links into the * release-owned `core/` surface. The caller must additionally establish that * the entry is a symlink: regular files in these namespaces remain canonical * core content and must continue to sync. * * The generated links point at canonical content in `personal/` or * `core/packages/`. Reindex / package wiring may remove and recreate them, so * treating the links themselves as personal-vault data creates a pull → * reindex → pull oscillation. */ export declare function isGeneratedCoreMirrorKey(key: string): boolean; /** * True when `key` is the session-log namespace VaultAccessRole refuses to * DeleteObject (`workspace/.session-logs/*` under any `hq-vault-*` bucket). * IAM `*` is unanchored, so the deny also matches a nested leftover such as * `companies//workspace/.session-logs/…` in a personal bucket after a * company was promoted to its own vault. Exact-segment only: a sibling * `workspace/.session-logs-old/` must not match. */ export declare function isSessionLogDeletionKey(key: string): boolean; /** * Release-owned scaffold and reindex-generated wrappers must never be * delete-propagated from the personal vault. They are recreated locally by a * release or `hq reindex`, so an ordinary local absence is not user intent. * Session-log objects are lifecycle-managed; VaultAccessRole denies * s3:DeleteObject on that prefix, so a sync delete would 403 and fail the * whole personal leg. */ export declare function isPersonalVaultDeletionExcluded(key: string): boolean; export declare function computePersonalVaultPaths(hqRoot: string, opts?: PersonalVaultOptions): string[]; /** * Fixed relative path (forward-slash, hq-root-relative) of the session * continuity pointer. The continuity pointer is the ONE file under the * otherwise machine-local `workspace/` that must travel across machines: * `/handoff` writes it on machine A so a fresh session on machine B can * resume via `/startwork`. See {@link computeContinuityPointerPaths}. */ export declare const CONTINUITY_POINTER_REL = "workspace/threads/handoff.json"; /** * Compute the absolute paths of the session-continuity pointer carve-out: * `workspace/threads/handoff.json` plus the single thread file it points to. * * `workspace/` is in {@link PERSONAL_VAULT_EXCLUDED_TOP_LEVEL} — it is * machine-local by design (session scratch, locks, reports, the full thread * history). The continuity pointer is the one exception: without it a * `/handoff` on one machine never reaches a second machine, so the session * pointer doesn't follow the user even though the durable output already * syncs. This carve-out pierces the exclusion for EXACTLY two files and * nothing else, mirroring the `companies/manifest.yaml` special-case above * (a single file pushed back in despite its parent dir being excluded). * * The "active thread file" is not a fixed name — it is resolved from * `handoff.json.thread_path` (the pointer the finalize script writes). We * read + parse `handoff.json`, then include `thread_path` ONLY when it is a * relative path that resolves to an existing regular file strictly within * `/workspace/threads/`. This containment check is a hard security * boundary: a malformed or tampered `handoff.json` must never be able to * smuggle an arbitrary file (e.g. `../../.env`, an absolute path, or a * symlink escaping the threads dir) into the personal vault. * * Fail-soft throughout: a missing/unreadable/malformed `handoff.json`, or an * out-of-bounds / missing `thread_path`, silently degrades to "include * whatever is valid" (handoff.json alone, or `[]`). Callers tolerate empty * arrays — same contract as the manifest special-case. */ export declare function computeContinuityPointerPaths(hqRoot: string): string[]; /** * Whether `absolutePath` is the currently selected continuity thread file. * * The watcher needs this companion to {@link computeContinuityPointerPaths}: * the pointer itself is not the only uploadable continuity object. Do the * same containment, regular-file and symlink checks as the push walk before * admitting an event, so a forged pointer cannot widen the watcher's scope. * This intentionally re-resolves on a `workspace/threads/` event. A handoff * may replace the pointer between watcher startup and the first write to its * new thread file; caching the old target would recreate the silent gap. */ export declare function isCurrentContinuityThreadPath(hqRoot: string, absolutePath: string): boolean; /** * Fixed relative path (forward-slash, hq-root-relative) of the agency * workspace subtree. See {@link computeAgencySyncPaths}. */ export declare const AGENCY_SYNC_REL = "workspace/agency"; /** * Compute the absolute path of the agency-workspace carve-out: * `workspace/agency/` (the whole subtree). * * Like the session-continuity pointer above, this pierces the `workspace/` * top-level exclusion for ONE specific subtree. hq-pack-agency teams keep * their cross-session chat.jsonl inboxes and roster/state under * `workspace/agency///`; that state must travel across machines * so an agency team picked up on machine B sees the same conversation and * roster it had on machine A. The rest of `workspace/` (session scratch, * locks, reports, full thread history) stays machine-local. * * Returns the directory path when it exists; share()'s per-file walk handles * recursion, and the nested personal-vault exclusions still apply to files * inside it (e.g. a stray `node_modules/` or `.env`). Fail-soft: a missing or * unreadable `workspace/agency/` returns []. Callers tolerate empty arrays — * same contract as the manifest + continuity special-cases. */ export declare function computeAgencySyncPaths(hqRoot: string): string[]; /** * Fixed relative path (forward-slash, hq-root-relative) of the session-logs * subtree. See {@link computeSessionLogsSyncPaths}. */ export declare const SESSION_LOGS_SYNC_REL = "workspace/.session-logs"; /** * Compute the absolute path of the session-logs carve-out: * `workspace/.session-logs/` (the whole subtree). * * `hq reindex` copies this HQ root's Claude Code session transcripts here (see * reindex's copySessionLogs). Like the agency carve-out above, this pierces the * `workspace/` top-level exclusion for ONE specific subtree so those raw * transcripts round-trip to the personal vault / S3 — the operator's explicit * directive is that the session logs travel with the vault. The rest of * `workspace/` (session scratch, locks, reports, worktrees) stays machine-local. * * Returns the directory path when it exists; share()'s per-file walk handles * recursion, and the nested personal-vault exclusions still apply to files * inside it. Fail-soft: a missing or unreadable `workspace/.session-logs/` * returns []. Callers tolerate empty arrays — same contract as the manifest, * continuity, and agency special-cases. */ export declare function computeSessionLogsSyncPaths(hqRoot: string): string[]; /** * Discover `companies/{slug}/` subdirs that should sync to the personal * bucket as a fallback for companies the operator has not designated as * team-backed. Filter rules (all must hold): * * 1. The subdir is a directory (skip stray files). * 2. The slug is NOT in `PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS` * (currently just `_template`). * 3. The slug is NOT in `teamSyncedSlugs` (membership-backed slugs sync * to their own bucket and must not be double-written). * 4. The slug is NOT listed with a `cloud_uid: cmp_*` in * `companies/manifest.yaml`. Full-HQ agent boxes often run * `--personal` alone (no company fanout targets), so * `teamSyncedSlugs` is empty even though indigo (etc.) is * cloud-backed; without this check the multi-GB company tree is * re-mirrored into the agent personal vault every ritual and * times out the 180s personal push. * 5. `companies/{slug}/company.yaml` does NOT contain an explicit * `cloud: true` line. Local companies sync to the personal vault by * default, so a missing/markerless `company.yaml` is INCLUDED; only an * explicit `cloud: true` marker opts the directory OUT (belt-and-braces * with `teamSyncedSlugs` / manifest `cloud_uid` for a company designated * cloud-backed before its membership has resolved). * * Returns absolute paths. */ export declare function computePersonalCompanySubdirs(hqRoot: string, teamSyncedSlugs?: ReadonlySet): string[]; /** * Slugs whose `companies/manifest.yaml` entry carries a `cloud_uid: cmp_*`. * These are cloud-backed team companies and must not personal-vault mirror. * * Lightweight line parse (no YAML dep): fail-open on missing/unreadable * manifest → empty set → companies stay eligible for personal fallback. * Only `cmp_*` uids count (same rule as company-resolver / telemetry). */ export declare function readCloudBackedSlugsFromManifest(hqRoot: string): Set; /** * Result of a {@link purgeExcludedPersonalVaultJournalEntries} pass. */ export interface PersonalVaultJournalPurge { /** Vault keys removed from the journal, capped-free (full list). */ removed: string[]; /** Count of removed entries, bucketed by the reason class that caught them. */ byReason: Record; /** * Set (with `removed` left empty) when the bulk-purge circuit breaker vetoed * the pass: dropping this many rows out of `totalRows` would have removed * more than {@link PURGE_GUARD_FRACTION} of the journal in one pass, which is * almost always a transient enumeration/filter fault rather than a real scope * change. The journal is left intact so sync resumes instead of stalling. */ guardTripped?: { attempted: number; totalRows: number; }; } export declare const PURGE_GUARD_FRACTION = 0.5; export declare const PURGE_GUARD_MIN_JOURNAL = 1000; export declare const PURGE_IGNORED_PRESENT_BATCH = 25000; export interface PersonalVaultJournalPurgeOptions { /** Override {@link PURGE_IGNORED_PRESENT_BATCH} (tests). Must be >= 1. */ ignoredPresentBatch?: number; } export type PersonalVaultJournalPurgeReason = "ignore-filtered" | "ignore-filtered-present" | "excluded-top-level"; /** * Purge journal entries whose local file is confirmed absent AND falls OUTSIDE * the current personal-vault push scope, so a stale baseline written by an * older engine (before an exclusion rule existed) converges instead of being * re-diffed on every push forever. * * WHY THIS EXISTS. The push WALK already prunes excluded trees correctly * (`collectFiles`/`walkDir` in share.ts apply the same filter and never descend * into `.claude/worktrees/`, `node_modules/`, etc.). But nothing ever REMOVES a * journal entry that predates an exclusion rule: `computeDeletePlan` skips such * an entry (either because its local file still exists — the `presentLocally` * short-circuit — or because the exclusion filter rejects it) WITHOUT deleting * the journal row, and `finalizeShareJournal` then writes the journal back * verbatim. So entries under rules added after they were first journaled — * the `.claude/worktrees/` default ignore (added 5.8.2), the `workspace/` * top-level exclusion, the `.hq-conflicts/` conflict-mirror rule, the * `.conflict-*` ephemeral pattern, `node_modules/` — persist indefinitely, are * re-`lstat`'d every push, and keep the baseline from ever stabilising. * * The two exclusion mechanisms this mirrors: * 1. `shouldSync` — the shared ignore filter (`createIgnoreFilter`) plus, in * personal mode, the personal-vault default exclusions. Catches * `.claude/worktrees/`, `node_modules/`, `.hq-conflicts/`, `.env`, etc. * 2. The `workspace/` (and `repos/`) top-level exclusion, which lives ONLY at * the walk-root level (`PERSONAL_VAULT_EXCLUDED_TOP_LEVEL`, applied by * `computePersonalVaultPaths`) and is therefore INVISIBLE to `shouldSync`. * The synced carve-outs under `workspace/` (the continuity pointer + its * active thread, `workspace/agency/`, `workspace/.session-logs/`) are * re-included so they are never purged. * * DEFERS to two existing owners so it is a pure safety-net for the classes no * other mechanism drains: * - `isVaultLitterArtifact` (`isLitterArtifact` here) — conflict-mirror * (`.conflict-*`) and rescue-drift (`.drift-*`) litter is REMOTELY DELETED * by `computeDeletePlan`'s litter drain when the local file is gone, which * is stronger than forgetting the row. Never purge it here — that would * preempt the remote cleanup and strand the vault object. * - `isPersonalVaultDeletionExcluded` — release scaffold (`core/`), * reindex-generated skill wrappers (`.claude/skills/:/`), and * command wrappers are recreated locally by a release / `hq reindex`, so * their journal rows are deliberately retained (a local absence is not user * intent). Keep them. * * `companies/` is deliberately NOT purged here even though it is in * `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL`: local companies legitimately journal * `companies/{slug}/…` keys, and cloud companies' stale keys are drained by the * separate decommission plan (`computeDecommissionPlan`) — purging them here * would rob that plan of the journal rows it deletes remotely against. * * Disposition is option (a) of the stale-journal question: drop the row so it * is no longer re-diffed. Any remote object the row pointed at is left as an * inert orphan — the PULL leg already refuses these keys * (`computePullPlan` in sync.ts consults the same filters), so a peer can never * rematerialise them, and existing rescue/litter tooling sweeps the remote. * * Missing-on-disk alone is deliberately insufficient: an in-scope absence is * a genuine user delete candidate and stays in the journal for the normal, * currency-gated delete/tombstone planner. Only ENOENT plus one of the * positive out-of-scope classifications below is eligible. Any other lstat * error (permissions, ENOTDIR, transient I/O, etc.) retains the row — an error * is never read as presence. This helper only mutates `journal.files`; it owns * no object-IO client and cannot issue a remote mutation. * * PRESENT + IGNORE-FILTERED rows (`ignore-filtered-present`) are purged too. * Presence does not make such a row live bookkeeping: the push walk never * descends into an ignored tree and the pull leg refuses the key, so nothing * ever reads the row again — it is only re-lstat'd and re-serialized on every * pass. When an operator adds an `.hqignore` rule over a synced carve-out * AFTER its files were journaled (2026-09-05: ~460k `workspace/agency/` and * `workspace/.session-logs/` rows whose files stayed on disk), a missing-only * purge never converges and the 200MB+ journal is structuredClone'd and * re-encoded every pass (3–4GB heap, ~50% CPU, repeated OOM recycles of the * runner). Two safety properties differ from the missing-row classes: * - They are EXEMPT from the fraction breaker. That breaker guards the * 2026-08-18 class of fault — a transient enumeration/stat failure * reclassifying MISSING rows. Whether a present file is ignored is a * deterministic function of the ignore configuration, and a failed * `.hqignore`/`.hqinclude` read yields FEWER ignores, never more. Counting * them would veto the exact backlog this drain exists for, forever. * - They drain at most {@link PURGE_IGNORED_PRESENT_BATCH} rows per pass, so * an accidental blanket rule surfaces through the pruned event while most * rows are still recoverable by fixing the rule. (The cost of a wrongly * purged row is a re-upload on the next in-scope pass, never data loss.) * - A pass vetoed by the breaker drops nothing at all, present-ignored rows * included: the pass is treated as untrustworthy wholesale. * - `isPersonalVaultDeletionExcluded` does not shield them. That predicate * guards against misreading a local ABSENCE of scaffold / reindex wrappers / * `workspace/.session-logs/` as a delete; a present file has no absence to * misread, and a dropped row can never be delete-propagated. Litter rows * (`isLitterArtifact`) are still deferred: the remote drain needs them. * * Mutates `journal.files` in place and returns what was removed for a summary * event. */ export declare function purgeExcludedPersonalVaultJournalEntries(journal: SyncJournal, hqRoot: string, syncRoot: string, shouldSync: (absPath: string, isDir?: boolean) => boolean, isLitterArtifact: (key: string) => boolean, options?: PersonalVaultJournalPurgeOptions): PersonalVaultJournalPurge; //# sourceMappingURL=personal-vault.d.ts.map