/** * `hq share` command — selective push to entity vault (VLT-5 US-002). * * Broadcasts local file(s) to the company's S3 vault bucket. * Refuses to overwrite a newer remote version without prompting. */ import type { EntityContext, VaultServiceConfig, SyncJournal } from "../types.js"; import type { UploadAuthor } from "../s3.js"; import { journalFingerprintMatchesStat } from "../journal.js"; import type { ConflictStrategy } from "./conflict.js"; import type { SyncProgressEvent } from "./sync.js"; import { type CompanyTombstone } from "./tombstones.js"; import { type ScopePrefixInput } from "../prefix-coalesce.js"; import { type TransferSemaphore } from "../sync-core.js"; import { type RegisterCompanySkillResult } from "../vault-client.js"; import { type ReconcileApplyCheckpoint } from "../sync/reconcile-cursor.js"; /** * Local-only ephemeral artifacts: conflict-side files written whenever a * 3-way merge preserves the displaced side for inspection. Depending on the * selected strategy, that side may be local or remote. Format: * `.conflict--[.ext]` * (e.g. `.claude/CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md`, * or `.gitignore.conflict-2026-05-13T19-40-40Z-e5797a` — extensionless * originals produce no trailing dot, see `buildConflictPath` in * `../lib/conflict-file.ts`). * * These files MUST never round-trip to S3 — they're local-only safety backups * the user reviews and deletes once the merge is resolved. Pre-fix, the push * walker happily uploaded them, the journal recorded them, and the * `owned-only` delete policy then refused to clean them up when the user * deleted them locally (because pull-confirmation had stamped them as * `direction: "down"`). Net effect: a permanent litter ratchet on remote. * * Two known producer-shapes the regex must accommodate (both observed on * affected user trees prior to this fix): * * 1. **`unknown` machine token.** Pre-`/.hq/machine-id` * provisioning (see `../lib/machine-id.ts`), hosts without * `~/.hq/menubar.json` — every Linux HQ Pro Outpost, every fresh CLI * install — fell through to the literal string `"unknown"` from the * old `readShortMachineId()` fallback. The letters `k`, `n`, `o`, `w` * live outside `[a-f]`, so the pre-fix `[a-f0-9]+` class refused those * filenames. They round-tripped to S3 as ordinary files (which IS the * "permanent litter ratchet" this module's contract was supposed to * prevent). The new machine-id provisioning closes the producer side, * but we still accept `unknown` here so legacy files already on disk * are filtered out by the next push. * * 2. **Extensionless originals.** `path.extname('.gitignore')` returns * `''` in Node, so `buildConflictPath` produces no trailing `.` * segment for hidden-but-extensionless files like `.gitignore`, * `.hqignore`, or any `.agents/skills`-style entry. The pre-fix `\.` * tail was mandatory, so those names slipped through. * * Wire-points: (1) push walker — `collectFiles` / `walkDir` skip these so * they never upload; (2) `computeDeletePlan` — skip these so an already- * journaled mirror that's been deleted locally doesn't get included in the * regular delete plan (the dedicated reconcile path handles existing litter). */ export declare const EPHEMERAL_PATH_PATTERN: RegExp; /** * Cheap pure check — pass the relative key OR a basename; either works. Used * in both the file walker (basename matching) and the delete-plan walker * (relative-key matching), and also by the pull walker in sync.ts to refuse * downloading legacy conflict-mirror files that still live in cloud staging * (Bug #2 in the 5.33.0 deep-test report — push-side filtered them since * 5.33.0 but pull-side downloaded them freely until this export). The regex * matches anywhere in the string, which is fine: the * `.conflict--.` token is unambiguous. */ /** * Rescue-overlay drift marker pattern. Written by `replace-rescue.sh` * (`rescue_one` / `conflict_one`) when its rescue target already exists: * `.drift--` — a collision suffix so the previous override * is never silently overwritten. Distinct from `EPHEMERAL_PATH_PATTERN` — * different producer (the rescue script vs the sync conflict-mirror path), * different filename grammar (decimal timestamp + decimal pid vs ISO timestamp * + hex machine hash). Like conflict mirrors, drift markers should never live * in the vault; if a buggy past run uploaded one, the delete plan must be * able to drain it regardless of where it sits. */ export declare const DRIFT_PATH_PATTERN: RegExp; /** * True iff the key is local-only ephemeral vault litter — a sync conflict * mirror (`.conflict--[.ext]`) OR a rescue drift marker * (`.drift--`). * * Used by `computeDeletePlan` to UNCONDITIONALLY drain existing vault litter, * bypassing every "skip" gate that would otherwise trap it: * * 1. `shouldSync` — the personal-vault default exclusions (introduced in * 5.25) reject paths like `**.obsidian/**`, `**.env`, `**output/**`, * `**node_modules/**`, etc. from BOTH the upload walk AND the delete * plan. That's correct for fresh content (don't upload), but it also * strands any litter already in the vault at those paths — `.drift` * / `.conflict` files inside an excluded parent get re-pulled * every sync and never tombstoned. The live obsidian case here: * `personal/.obsidian/graph.json.drift-1779863862-42519` survived every * sync for two weeks because `.obsidian` is excluded. * 2. `isEphemeralPath` — the existing skip in `computeDeletePlan` was * designed to prevent a FRESH local `.conflict-*` mirror (written by the * pull leg's "keep" branch as a side-by-side comparison file) from being * miscounted as a delete candidate before the user has resolved the * conflict. That intent stands, but it accidentally protects EXISTING * cloud litter too. The fix: when the local file is already gone AND the * key matches the litter pattern, drain it; the "fresh mirror, hasn't * been resolved yet" case is impossible because that mirror would still * be on disk. * 3. The policy gate — `owned-only`'s direction filter and * `currency-gated`'s etag check both encode user-content invariants * that don't apply to litter (a `.drift-…-PID` file has no meaningful * "ownership" or "freshness"; it's a stale local-overlay collision * marker, by construction). * 4. The bulk-asymmetry circuit breaker — litter cleanup is intentional * ratchet-drain, not a "corrupt local mirror, refuse mass-delete" * signal. Litter is queued via a separate bucket the breaker never * sweeps. * * Together: a vault key matching either pattern always tombstones on the * next push leg, regardless of personal-vault exclusions, ephemeral skip, * policy, or breaker. Producer-side exclusions (upload walker + rescue * script) close the ratchet on new litter; this drains the legacy buildup. */ export declare function isVaultLitterArtifact(p: string): boolean; export declare function isEphemeralPath(p: string): boolean; /** * A vault key containing a backslash is never legitimate. HQ keys are POSIX * (`toPosixKey` normalizes at every walker since 5.47.2 and `uploadFile` * hard-normalizes at the S3 boundary), so a `\` in a remote key can only come * from a pre-5.47.2 Windows client whose walker built keys with `path.sep` — * verified live 2026-06-10: one such client duplicated 5,711 keys * (`skills\demo-hq\SKILL.md`, …) into a company vault, and every up-to-date * puller then materialized them as junk single-filename-with-backslash files * that churned conflicts forever. The pull walker refuses these keys * (skip-excluded-policy), symmetric with the ephemeral-mirror filter above. */ export declare function isMalformedVaultKey(key: string): boolean; declare function enableCooperativeRowVisits(): void; declare function disableCooperativeRowVisits(): void; declare function snapshotCooperativeRowVisits(): { collect: Map; plan: Map; }; /** * Test-only export. Kept under a `_testing` namespace so the module's public * surface stays focused on `share()` / `ShareOptions` / `ShareResult` while * regression-critical regex contracts (the conflict-mirror pattern) can be * pinned by direct unit tests without round-tripping through share(). * * Do NOT import from `_testing` outside of tests in this package. */ export declare const _testing: { isEphemeralPath: typeof isEphemeralPath; EPHEMERAL_PATH_PATTERN: RegExp; wrapFilterWithIgnoreVisibility: typeof wrapFilterWithIgnoreVisibility; collectFiles: typeof collectFiles; resolveNamedPath: typeof resolveNamedPath; isWithinLexicalOrReal: typeof isWithinLexicalOrReal; defaultConsoleLogger: typeof defaultConsoleLogger; statMatchesJournal: typeof statMatchesJournal; enableCooperativeRowVisits: typeof enableCooperativeRowVisits; disableCooperativeRowVisits: typeof disableCooperativeRowVisits; cooperativeRowVisits: typeof snapshotCooperativeRowVisits; computePushPlan: typeof computePushPlan; computePushPlanCooperatively: typeof computePushPlanCooperatively; }; /** * Stage-1 classification for a single local file in a push run. Pre-HEAD — * only inputs we can evaluate locally (size limit, journal hash, optional * skip-unchanged) determine the action. Files that pass classification as * `upload` are still subject to a per-file HEAD + 3-way conflict check in * Stage 2 before the actual PUT, so the `filesToUpload` count in the plan * event is an upper bound: it includes files that may turn out to be * conflicts. V1.5 follow-up: replace per-file HEAD with a single LIST so * conflicts can be classified up-front and reported in the plan. */ type PushPlanItem = { action: "upload"; kind: "file"; absolutePath: string; relativePath: string; localHash: string; size: number; statAtHash?: StatSnapshot; } | { action: "upload"; kind: "symlink"; absolutePath: string; relativePath: string; target: string; localHash: string; size: 0; } | { action: "refuse-server-owned"; absolutePath: string; relativePath: string; } | { action: "skip-size-limit"; absolutePath: string; relativePath: string; } | { action: "skip-unchanged"; absolutePath: string; relativePath: string; restamp?: { mtimeMs: number; ctimeMs: number; size: number; }; }; interface PushPlan { items: PushPlanItem[]; filesToUpload: number; bytesToUpload: number; filesToSkip: number; /** Skip-unchanged rows omitted from `items` so a full pass does not retain them. */ preplannedFilesSkipped: number; } /** * Pure Stage-1 pass for push: walk the candidate file list, hash each one, * apply the size-limit and skip-unchanged gates, and return a classified * plan plus aggregate counts. No S3 calls, no journal writes, no event * emission. * * The conflict count is intentionally absent from the returned `PushPlan` — * detecting a push conflict requires a remote HEAD that we defer to Stage 2. * Consumers that want a conflict count get it from the `complete` event. */ /** The three stat fields the skip gate compares. */ interface StatSnapshot { size: number; mtimeMs: number; ctimeMs: number; } declare function statMatchesJournal(absolutePath: string, entry: Parameters[0]): boolean; /** * Same wall-clock contract as pull LIST (`LIST_COOPERATIVE_YIELD_MS`): a * first-tick collect must not sit in `readdirSync` / journal refresh for * minutes without offering the scheduler a checkpoint. */ export declare const PUSH_COLLECT_COOPERATIVE_YIELD_MS = 15000; export declare function setPushCollectCooperativeYieldMsForTesting(ms: number | undefined): void; declare function computePushPlan(filesToShare: CollectedEntry[], journal: SyncJournal, skipUnchanged: boolean, onHeartbeat?: () => void): PushPlan; /** * Build a full-pass plan in bounded row batches. A scoped replan deliberately * uses the synchronous single-row planner above, so it does not add an await * boundary between upload admissions and collapse the transfer pool. */ declare function computePushPlanCooperatively(filesToShare: CollectedEntry[], journal: SyncJournal, skipUnchanged: boolean, onHeartbeat: (() => void) | undefined, cooperativeCheckpoint: (() => Promise) | undefined): Promise; export interface ShareOptions { /** Path(s) to share (files or directories) */ paths: string[]; /** Company slug or UID (defaults to active company from config) */ company?: string; /** Optional message attached to journal entries */ message?: string; /** Non-interactive conflict strategy */ onConflict?: ConflictStrategy; /** * Vault service config — used when share() must resolve the entity and vend * STS credentials itself (the default CLI path). * * Mutually exclusive with `entityContext`. Exactly one of the two must be * provided; supplying both throws. */ vaultConfig?: VaultServiceConfig; /** * Pre-resolved entity context. When provided, share() skips its own * `resolveEntityContext` call (no entity lookup, no STS vending) and uses * these credentials directly. * * Use case: AppBar HQ Sync vends task-scoped creds via `/sts/vend-child` * (preserving audit traceability via `task_id` + `task_description`) * before invoking `hq sync push` as a subprocess. The subprocess reads the * EntityContext JSON from stdin and passes it here. * * IMPORTANT: When using `entityContext`, the caller is responsible for * vending credentials with enough TTL to cover the entire upload run. * share() cannot auto-refresh a pre-vended context (it has no Cognito * token to re-vend with) — if the credentials are expiring mid-run, * share() throws a clear error rather than silently failing on the * next S3 call. * * Mutually exclusive with `vaultConfig`. */ entityContext?: EntityContext; /** * Identity-registration seam for pre-vended entity-context callers. Normal * CLI/runner calls omit it: share() binds a VaultClient from `vaultConfig`. * A company push that encounters an unstamped canonical SKILL.md fails closed * when neither path can register it. */ registerCompanySkill?: CompanySkillRegistrar; /** HQ root directory */ hqRoot: string; /** * Per-file event callback. When present, suppresses the default * `console.log`/`console.error` human output — same contract as `sync()`. * This is the seam `hq-sync-runner` uses to stream ndjson for push events. */ onEvent?: (event: SyncProgressEvent) => void; /** See SyncOptions.transferSemaphore: shared across concurrent fanout legs. */ transferSemaphore?: TransferSemaphore; /** See SyncOptions.progressRecorder: fanout writes one aggregate snapshot. */ progressRecorder?: (event: SyncProgressEvent) => void; /** * Watch-runner liveness callback for long synchronous file walks and hash * planning. The caller owns throttling; one-shot CLI callers omit it. */ onHeartbeat?: () => void; /** * Cooperative root-lock hand-off for a watch-mode full push. It is called * only after a bounded upload unit has completely settled and its journal * updates are on disk. A `true` result means realtime work ran while this * pass yielded, so the push is rebuilt from current local and journal state * before it can plan another durable mutation. */ cooperativeCheckpoint?: () => Promise; /** * When true, files whose local hash matches the journal entry from the * last sync are skipped (no remote HEAD, no upload). This is the gate * that makes "push everything that changed" efficient — without it, a * bidirectional Sync Now would re-upload every file each tick. * * Default false to preserve `hq share ` semantics: when a user * explicitly names a file, they expect it to be sent even if the local * hash matches the last-sync state (e.g. to re-heal a bucket). */ skipUnchanged?: boolean; /** * When true, journal entries whose local file is gone trigger a remote * `DeleteObject`. Only entries whose key falls under one of the supplied * `paths` (after resolution to absolute paths under `companies/{slug}/`) * are considered, so `hq share ` can never sweep deletes outside * the named scope. * * Vault buckets have versioning enabled, so the delete is soft: a * delete-marker becomes the current version and prior object versions * remain recoverable indefinitely. The pull-side `listRemoteFiles` skips * objects whose current version is a delete-marker (default * `ListObjectsV2` behavior), so a deletion stops the next pull from * re-downloading the file on this and any other machine. * * Default false to preserve `hq share ` semantics — only the * full-tree bidirectional runner opts in. */ propagateDeletes?: boolean; /** * Policy for which journal entries `propagateDeletes` is willing to * convert into remote `DeleteObject` calls. Only consulted when * `propagateDeletes === true`. * * - `"currency-gated"` (safest; default scheduled for 5.25 after soak): * for each candidate, issue a remote HEAD and compare the current * remote ETag against the journal's * last-recorded `remoteEtag`. Match → safe-to-delete (this machine is * current for the file, so the local deletion reflects an intentional * removal AFTER seeing the latest remote version). Mismatch → refuse * and emit `delete-refused-stale-etag`; the journal entry is left * intact so the next pull leg re-pulls via the same hasRemoteChanged * path. 404 → tombstone: drop the journal entry, no DeleteObject (the * remote was already gone). Strictly safer than `owned-only` because * it gates on per-file proof of currency rather than direction-of- * origin — files that arrived via `/update-hq` (direction:"down") can * legitimately be deleted by the device that pulled them, as long as * no other device has touched them since. * - `"owned-only"` (current default in 5.24): only entries whose journal * `direction === "up"` are eligible. That is, only files this machine * previously uploaded can be remotely deleted on its behalf. Entries * recorded as pulled from elsewhere are never delete-propagated. * Default in 5.24 while currency-gated soaks; scheduled to lose the * default in 5.25. Downside: any file that arrived via `/update-hq` * or another device's push is stuck on remote forever once locally * removed, because no device "owns" it under this rule. * - `"all"`: legacy behaviour — every in-scope journal entry whose * local file is missing is eligible (regardless of direction or * currency). The bidirectional runner's first-push and any tool that * wants to mirror a destructive local checkout opts in here * explicitly. Use with care — a stale device can erase peer uploads. * * Independently of this policy, an entry is also dropped from the plan * when (a) it matches `EPHEMERAL_PATH_PATTERN` (conflict mirrors never * propagate), or (b) neither the file-shape nor the directory-shape probe * of `shouldSync` accepts the path — i.e. the current ignore filter would * have skipped the path on pull. That symmetry blocks the failure mode * where a path was filtered locally but lived in the vault (and the * journal) from an older HQ layout or a different machine, causing the * next push to erase it. */ propagateDeletePolicy?: "currency-gated" | "owned-only" | "all"; /** * Explicit vault-relative delete roots captured by a live watcher before a * scoped push. These roots do not need to exist locally; delete execution * still requires a current version-bound intent (or server tombstone). */ deleteScopeRoots?: string[]; /** * Internal runner marker for a watcher/realtime path-scoped push. It uses a * keyed journal snapshot and defers whole-journal maintenance to cadence. */ scopedJournal?: boolean; /** * Hq-root-relative key prefixes whose journal entries should be * unconditionally decommissioned from the remote bucket and journal, * independent of whether the local file is present. Each prefix matches * its exact-key form (e.g. "companies/foo") AND any descendant key * (e.g. "companies/foo/knowledge/notes.md") — same prefix semantics as * `propagateDeletes`'s scope roots. * * Use case: a company that previously synced to the operator's personal * bucket has been promoted to its own team bucket (`/designate-team`). * Its keys at `companies/{slug}/...` in the personal bucket are now * orphans — the on-disk files still exist (the team bucket is the new * canonical home), so the standard `propagateDeletes` gate ("local file * missing") never fires. This option asserts "these objects no longer * belong in THIS bucket regardless of local state" and uses the same * DeleteObject + journal-removal path as `propagateDeletes`. * * Honors `propagateDeletePolicy`. Under `"owned-only"` (this function's * own fallback when the caller passes nothing; every live caller passes * `resolveDeletePolicy()`, which defaults to `"currency-gated"`) only * journal entries with `direction === "up"` are decommissioned, so a * misconfigured caller never erases content pulled from elsewhere. * * Independent of `propagateDeletes`: callers can opt into decommission * without enabling general delete propagation. In practice the runner * sets both for the personal slot. */ decommissionPrefixes?: string[]; /** * Identity stamped onto each uploaded object's S3 user metadata * (`created-by`, `created-by-sub`, `created-at`). The hq-console vault UI * reads `Metadata['created-by']` for its "CREATED BY" column; uploads * without an author leave that column blank for every file synced via * this engine. The runner pipes Cognito idToken claims through here. */ author?: UploadAuthor; /** * When true, share() targets the caller's person-entity bucket: syncRoot * is `hqRoot` itself (NOT `hqRoot/companies//`), so remote keys are * hq-root-relative (e.g. ".claude/skills/foo.md", "knowledge/notes.md") to * match the Rust hq-sync first-push contract in * `src-tauri/src/commands/personal.rs`. The exclusion of top-level dirs * (.git, companies, core, data, personal, repos, workspace) is enforced * by the runner — share() trusts its `paths` input. */ personalMode?: boolean; /** * Override for the per-slug journal file name. Defaults to `ctx.slug`. The * runner passes `journalSlug: "personal"` for the personal slot so the TS * push and the Rust personal first-push share idempotency state under one * `sync-journal.personal.json` file. */ journalSlug?: string; /** * Effective ACL push scope as a list of company-relative prefixes (the same * coalesced `prefixSet` the pull leg uses for `syncMode: "shared"`). When * provided, the upload + delete plans are filtered to paths covered by these * prefixes — any candidate outside them is skipped (and surfaced via a * `scope-excluded` event) rather than PUT, because the vended child * credential is scoped to exactly these prefixes and an out-of-scope PUT * draws the server's correct 403 `SCOPE_EXCEEDS_PARENT`. * * `undefined` (the owner/`all` case, and `hq share `) applies NO scope * filter — full access. An empty array means "no granted prefixes" → every * path is out of scope (mirrors the pull side's `isCoveredByAny([])`). */ prefixSet?: ScopePrefixInput[]; /** * Pre-fetched FILE_TOMBSTONE map (POSIX key → tombstone) for the push-side * delete-resync consult. When omitted, share() fetches it itself via * `fetchFileTombstones` for either company or personal vaults that have a * `vaultConfig` (pre-vended `entityContext`-only callers without a * `vaultConfig` degrade to no-suppression — the safe, legacy direction). * * Injection is an optimization + test seam: a sync run that already fetched * tombstones for the pull leg can hand the same map to the push leg to avoid a * second round-trip, and tests can supply a controlled map without stubbing * the network. See the consult in the Stage-2 classification pass. */ fileTombstones?: ReadonlyMap; /** * Opt-in bounded reconcile apply checkpoint. It is recorded only after * `finalizeShareJournal()` has durably committed this slice's outcomes. */ reconcileApply?: ReconcileApplyCheckpoint; /** * What to do when a path the caller EXPLICITLY named cannot be shipped — * it does not resolve under any base, or it resolves outside the company * folder (see `ShareResult.unreachablePaths`). * * - `"error"` (DEFAULT): a path that EXISTS on disk but resolves outside the * company folder throws {@link UnreachablePushPathsError} BEFORE any upload * runs, so the push is an atomic no-op and the CLI exits nonzero. This is * ask #2 of feedback_a51cb63d — "error, not warn-skip, when the named file * exists locally but is unreachable by the resolver". A user who typed a * path and got "✓ Pushed 0 file(s)" had no way to know their content never * left the machine. A path that resolves to NOTHING under any base is still * only warn-recorded (see `collectFatalUnreachablePaths` for why bulk * membership fanout depends on that). * - `"warn"`: record it on the result + emit the `not-shipped` event and * carry on, never throwing. For callers whose `paths` are INTERNAL walk * roots rather than user input — the background sync runner — where a path * disappearing mid-run is a benign race (a directory removed between the * scan and the push) and must never fail an unattended sync. */ unreachablePathPolicy?: "error" | "warn"; /** * Handling for uploads to server-owned paths (`sources/`, `signals/`, * `ontology/`, and generated root files). The default `"error"` makes an * otherwise all-refused direct push fail instead of reporting a false * success. Background/watch sync explicitly selects `"skip"`: it emits one * exact-count summary for the pass while keeping the recurring pass * successful because the pull/server pipeline is authoritative. */ serverOwnedPathPolicy?: "error" | "skip"; } export interface CompanySkillRegistrationRequest { companySlug: string; path: string; content: string; /** These exact bytes have successfully landed in the company vault. */ landed?: boolean; } export type CompanySkillRegistrar = (input: CompanySkillRegistrationRequest) => Promise; export declare class UnregisteredCompanySkillError extends Error { readonly paths: string[]; constructor(paths: string[]); } /** Why an explicitly-named push path could not be shipped. */ export type UnreachablePathReason = "missing" | "outside-company" | "unreadable-link"; /** * Thrown by `share()` when a caller-named path cannot be pushed and * `unreachablePathPolicy` is `"error"` (the default). Raised while the plans * are still being built, so NOTHING has been uploaded, journaled, or deleted * when it surfaces — the failed push leaves no partial state behind. */ export declare class UnreachablePushPathsError extends Error { /** Caller's original spellings, verbatim (see the CollectHooks contract). */ readonly paths: string[]; /** Per-path reason, keyed by the same original spelling. */ readonly reasons: Record; constructor(unreachable: ReadonlyMap, syncRoot: string); } /** Raised when every eligible upload in a direct push is server-owned. */ export declare class ServerOwnedPushPathsError extends Error { readonly paths: string[]; constructor(paths: string[]); } export interface ShareResult { filesUploaded: number; bytesUploaded: number; filesSkipped: number; /** * Number of remote `DeleteObject` calls that succeeded this run. Always 0 * when `propagateDeletes` is false. The corresponding journal entries are * removed in the same pass so the next sync sees the key as truly gone. * Does NOT include tombstones (remote was already 404; no DELETE was * issued — see `filesTombstoned`) or refused-stale entries (currency- * gated refused because remote etag drifted — see `filesRefusedStale`). */ filesDeleted: number; /** * Number of journal entries dropped because the remote was already 404 at * HEAD time (cleaned out-of-band — e.g. someone hand-deleted via the S3 * console, or another tool ran a destructive operation). No `DeleteObject` * was issued for these; the journal converges with reality. Always 0 when * `propagateDeletes` is false or `propagateDeletePolicy !== "currency-gated"`. */ filesTombstoned: number; /** * Number of delete candidates refused by the `currency-gated` policy * because the remote object's current ETag no longer matches the journal's * recorded one (some other device modified the file since this device last * synced it) — OR because the journal entry is a legacy record with no * `remoteEtag` to compare against. Neither S3 nor the journal is mutated * for these; the next pull leg re-pulls naturally via `hasRemoteChanged`. * Always 0 when `propagateDeletes` is false or policy is not * `currency-gated`. */ filesRefusedStale: number; /** * Paths corresponding to `filesRefusedStale`, capped at 50 to keep the * event payload bounded (mirrors `newFiles` capping). Surfaces *which* * paths were refused so operators can triage the recurring * \`filesRefusedStale: 205\` signal flagged in the 5.33.0 deep-test — * the count alone is impossible to investigate because the per-file * \`delete-refused-stale-etag\` events vanish from the event stream * once the runner has folded them into the totals. */ filesRefusedStalePaths: string[]; /** * Number of uploads suppressed by the push-side FILE_TOMBSTONE consult — keys * an authoritative delete (`hq files delete`) tombstoned that this machine * still held as the unchanged synced baseline. Skipping the upload is what * stops a behind peer from resurrecting an authoritatively-deleted key. Always * 0 when there are no tombstones for in-scope keys (the common case) or when * the run can't load tombstones (personal vault / no `vaultConfig`). */ filesSuppressedByTombstone: number; /** * Number of paths blocked by `PERSONAL_VAULT_DEFAULT_EXCLUSIONS` during this * run (push leg, personalMode=true). Includes both files that would have * uploaded and journal entries that would have been included in the delete * plan; deduplicated across walks. Always 0 outside personalMode. Mirrors * the `count` field of the `personal-vault-out-of-policy` event (which is * emitted exactly once if this is > 0). */ filesExcludedByPolicy: number; /** * Number of distinct company-relative paths/prefixes skipped because they * fell OUTSIDE the run's ACL `prefixSet` (member/guest scoped push). Always * 0 when `prefixSet` is undefined (owner/`all`) or the whole tree is in * scope. Mirrors the `count` field of the `scope-excluded` event (emitted * once if this is > 0). These paths were never PUT, so the server's correct * 403 `SCOPE_EXCEEDS_PARENT` is never triggered and the company still syncs * its in-scope subset. */ filesExcludedByScope: number; /** * Number of distinct hq-root-relative paths skipped by the base ignore * filter that look like real content rather than expected build/VCS/cache * noise. Mirrors the `count` field of the `ignore-excluded` event (emitted * once if this is > 0). */ filesExcludedByIgnore: number; /** * Paths the caller EXPLICITLY named for push that exist locally but the * resolver could not place under the company folder (or could not find under * any base). Empty in the common case (internal walk roots are always the * reachable company folder). A non-empty list means the push did NOT ship * something the caller asked for. * * Only ever non-empty under `unreachablePathPolicy: "warn"` — the DEFAULT * `"error"` policy throws {@link UnreachablePushPathsError} instead of * returning, so an interactive `hq sync push` fails loudly rather than * reporting the pre-fix silent "Pushed 0 file(s)" success. This field is the * warn-mode surface for unattended callers (sync runner / watcher). * * Entries are the caller's ORIGINAL spellings, verbatim — a relative token * stays relative, an absolute token stays absolute — so the list is directly * comparable to the `paths` input. See the CollectHooks spelling contract. * Mirrors the `not-shipped` event with `reason: "unreachable-path"`. */ unreachablePaths: string[]; /** * Company-relative keys of directory symlinks that were recorded as links but * NOT descended because their target lives outside the company folder — their * contents sync via their own repo, not the vault. Surfaced so files created * under such a link (e.g. `companies/{co}/knowledge` → a linked repo) no * longer vanish from every push bucket without a trace. Mirrors the * `not-shipped` event with `reason: "linked-subtree"`. */ linkedSubtreesNotShipped: string[]; /** * Paths (company-relative) that were detected as push conflicts. Mirrors * `SyncResult.conflictPaths` so push and pull surface conflicts the same * way to runner/UI consumers. */ conflictPaths: string[]; /** Uncapped per-path delete outcomes used by scoped watcher publication. */ pathResults?: SharePathResult[]; aborted: boolean; } export type ShareDeleteRefusalReason = "stale-etag" | "legacy-no-etag" | "bulk-asymmetry" | "divergent-local" | "missing-delete-intent" | "intent-changed" | "recreated-locally" | "marker-mismatch" | "scope-excluded" | "transfer-error"; export interface SharePathResult { path: string; status: "accepted" | "refused"; operation: "delete" | "tombstone"; reason?: ShareDeleteRefusalReason; } /** * Wrap the base ignore filter so push can observe paths it rejects before * personal-vault policy or ACL scope wrappers add their own exclusions. * Every base-ignore rejection increments `onAnyExcluded`; only rejections * that do NOT match expected build/VCS/cache noise are tagged through * `onIgnoreExcluded` for the one-shot `ignore-excluded` summary. */ export declare function wrapFilterWithIgnoreVisibility(underlying: (absPath: string, isDir?: boolean) => boolean, hqRoot: string, onIgnoreExcluded: (relPath: string) => void, onAnyExcluded?: () => void): (absPath: string, isDir?: boolean) => boolean; /** * Share local file(s) to the entity vault. * * A cooperative hand-off yields the root lock at a drained 500-row unit, then * resumes the same collection, plan, and executor instead of restarting * `share()`. Journal writes stay delta-only (absence-is-deletion is the delete * plan, computed once from the completed walk), so a realtime writer's rows * are not dropped when this pass finalizes. */ export declare function share(options: ShareOptions): Promise; type UploadPlanItem = Extract; /** * Current on-disk body size for byte-budget admission. Re-stats the file so one * that GREW since planning reserves its true size — a stale planned size would * let several "small" files buffer much larger bodies and blow the budget. * Symlinks upload an empty body (0). Falls back to the planned size if the stat * fails; the upload itself surfaces any real I/O error. */ export declare function currentUploadBytes(item: UploadPlanItem): number; /** Read only the immutable identity field from the first YAML frontmatter. */ export declare function parseRegisteredCompanySkillUid(content: string): string | undefined; /** * Maximum completed upload mutations between cooperative full-push hand-offs. * The executor drains a unit before yielding, so realtime never overlaps an * upload body or the push's mutable journal state. */ export declare const FULL_PUSH_COOPERATIVE_CHECKPOINT_BATCH_SIZE = 500; /** * Default human-readable share output. Preserves the exact format the CLI * emitted before `onEvent` was added — tty users see no change. */ declare function defaultConsoleLogger(event: SyncProgressEvent): void; /** * One entry produced by collectFiles/walkDir. Files describe regular * payloads that get hashed + size-checked + uploaded via uploadFile; * symlinks describe link records whose target string flows through * uploadSymlink as user metadata. Walked-dir traversal NEVER descends * into a symlink — directory symlinks are recorded as link entries and * left at that, so following them never duplicates content into the * wrong vault path (the same topology safety the legacy walker provided * by accident of Dirent.isFile() returning false for links). */ type CollectedEntry = { kind: "file"; absolutePath: string; relativePath: string; /** * A hash of this file computed earlier in the same pass, with the stat it * was computed against. `computePushPlan` reuses it instead of reading the * body again when the current stat still matches (same ctime argument as * the journal gate); any change falls back to hashing. Set only by the * execution-seam re-plan — the whole-run plan never carries one. */ knownHash?: { hash: string; stat: StatSnapshot; }; } | { kind: "symlink"; absolutePath: string; relativePath: string; target: string; }; /** * Optional visibility callbacks for {@link collectFiles} / {@link walkDir}. * They turn two previously-SILENT outcomes into surfaced signals — without * changing WHAT gets uploaded (feedback_258e4a86 / feedback_a51cb63d): * * - `onUnreachablePath`: a path that cannot be shipped. It is normally the * caller's explicit spelling (`"outside-company"` or `"missing"`), and is * the company-relative key for an unreadable link discovered during a * directory walk (`"unreadable-link"`). Pre-fix this was a bare * `console.error` warn-skip that still let the push report "Pushed 0 file(s)" * — a false success. The caller can now turn a non-empty set into a real * error / nonzero exit. * * SPELLING CONTRACT: resolver outcomes use the caller's ORIGINAL token, * verbatim — never the resolved absolute path, and never normalized. A * relative `knowledge/agents/x.md` is reported as `knowledge/agents/x.md`; * an absolute path is reported absolute because that is what was passed. * An unreadable link discovered during a recursive walk instead uses its * company-relative vault key, because it has no separate caller token. * This keeps UI output actionable without exposing host-specific paths. * - `onLinkedSubtree`: a directory symlink recorded as a link but NOT * descended because its target resolves OUTSIDE the company folder (e.g. * `companies/{co}/knowledge` → `repos/private/knowledge-{co}/`). The link's * contents ship via their own repo, not the vault; reporting it stops files * created under such a link from vanishing from every push bucket silently. */ interface CollectHooks { onUnreachablePath?: (namedPath: string, reason: UnreachablePathReason) => void; onLinkedSubtree?: (rel: string) => void; onHeartbeat?: () => void; cooperativeCheckpoint?: () => Promise; /** Test seam: fake clock for the 15s collect yield. */ now?: () => number; /** Test seam: delay per dirent to prove time-based yields. */ onDirent?: () => void | Promise; } /** * Resolve a caller-supplied push path to an absolute path. * * Relative paths were historically resolved against `hqRoot` ONLY, so a * company-relative spelling like `knowledge/agents/x.md` became * `/knowledge/agents/x.md` and reported "does not exist" no matter the * caller's cwd or the company being pushed (feedback_258e4a86 / * feedback_a51cb63d — "there is no path spelling that reaches the file"). * * PRECEDENCE (documented contract, asserted by test): `hqRoot` → `syncRoot` * (the company folder) → `cwd`. hqRoot stays FIRST so this change is purely * ADDITIVE to the legacy behavior: every relative spelling that resolved * pre-fix still resolves to exactly the same file, and the two new bases only * catch spellings that previously resolved to nothing. Probing cwd first would * silently re-point existing callers (a `knowledge/` directory in the shell's * cwd would win over the hq-root one), which is a behavior change no reporter * asked for. Fall back to the hqRoot candidate so a genuine typo still surfaces * the unchanged "does not exist" diagnostic. Absolute paths are returned * verbatim. * * `cwd` is an explicit injected parameter (defaulting to `process.cwd()`) * rather than an ambient read, so resolution is deterministic and testable * without mutating process state. */ declare function resolveNamedPath(p: string, hqRoot: string, syncRoot: string, cwd?: string): string; /** * Containment check for a regular file or directory that tolerates a symlinked * ANCESTOR. `isWithin` canonicalizes the full child via `realpathSync`, so a * path reached through a symlinked ancestor (`companies/{co}/knowledge` → * `repos/private/knowledge-{co}/`) resolves OUTSIDE the company folder and was * rejected as "outside company folder" — even though its logical path is * in-tree and `vaultKeyForLocalPath` (also lexical) derives a correct * company-namespaced key for it. Accept when the LEXICAL path is inside * (honoring the same logical topology the vault key uses) OR the realpath is * inside (preserving `isWithin`'s macOS APFS case-insensitivity tolerance). * * The lexical arm carries TWO bounds, because it is the only place where a * path's bytes and its vault key come from different trees: * * 1. `hqRoot` — the realpath must still land inside the HQ tree, so a * symlinked ancestor pointing at `/etc` cannot upload arbitrary machine * state under a company-namespaced key. * 2. The TENANT — the realpath must not land inside another company's bytes * (`foreignTenantRoots`). The hqRoot bound alone is NOT sufficient and * must never be mistaken for a tenant boundary: hqRoot CONTAINS every * other company, so `companies/acme/knowledge → companies/other/secret` * (or → `repos/private/knowledge-other`, the linked-repo topology) is * lexically inside acme and really inside HQ, and would upload the other * tenant's bytes into acme's bucket under the key `knowledge/…`. * * The motivating topology (`companies/{co}/knowledge` → * `repos/private/knowledge-{co}`) satisfies both, so it is unaffected. A link * that escapes HQ, or one that reaches another tenant, is refused — and under * the default unreachable-path policy, refused LOUDLY rather than warn-skipped. * * Known limit, stated so it is not mistaken for a guarantee: a foreign tenant's * externally-linked subtree can only be recognized while that company's folder * is materialized locally and publishes the link. A machine holding * `repos/private/knowledge-other` with no `companies/other` folder has no * on-disk evidence of the claim, so a link into it is indistinguishable from a * link into any other local repo. Ownership metadata (not path shape) is what * would close that gap. */ declare function isWithinLexicalOrReal(parent: string, child: string, hqRoot: string, tenantRootsCache?: Map): boolean; /** * Collect files from paths (expanding directories recursively). * * Remote S3 keys are computed relative to `syncRoot` (companies/{slug}/), not * `hqRoot`. Files outside `syncRoot` are skipped with a warning — sharing * anything outside a company's folder would leak state into the wrong vault. * * Symlink classification uses lstat (not stat), so a top-level path that is * itself a symlink is recorded as a link record rather than dereferenced. * Pre-fix, statSync followed the link and the target's bytes were uploaded * under the link's key — silently flattening the link topology. */ declare function collectFiles(paths: string[], hqRoot: string, syncRoot: string, filter: (p: string, isDir?: boolean) => boolean, hooks?: CollectHooks): Promise; export {}; //# sourceMappingURL=share.d.ts.map