@indigoai-us/hq-cloud 6.18.22-alpha.732d → 6.18.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/sync.js CHANGED
@@ -30,6 +30,7 @@ import { buildScopeShrinkPlan, applyScopeShrink, ScopeShrinkBlockedError, ScopeS
30
30
  import { coalescePrefixes, compileScopeMatcher, intersectPrefixSets, pathToScopePrefix, toScopePrefixEntries, } from "../prefix-coalesce.js";
31
31
  import { SESSION_LOGS_PUSH_ONLY_PREFIX } from "../sync/pull-scope.js";
32
32
  import { contentHashMatchesRawSha256 } from "../sync/content-hash.js";
33
+ import { isKms10CopyOfJournalBase } from "../kms10-etag-safe-pull.js";
33
34
  import { isTargetedUpsertExactListEnabled } from "../sync/targeted-upsert-exact-list.js";
34
35
  import { coalesceDenseTargetedPullPaths } from "../sync/targeted-pull-prefixes.js";
35
36
  import { listRemoteForScope, POST_FILTER_THRESHOLD } from "../remote-pull.js";
@@ -1109,6 +1110,10 @@ async function executeConflictExecutor(run, plan, scopeRun, counters) {
1109
1110
  executeContentMatchSkip(run, item, counters);
1110
1111
  continue;
1111
1112
  }
1113
+ if (item.action === "skip-kms10-copy") {
1114
+ executeKms10CopySkip(run, item, counters);
1115
+ continue;
1116
+ }
1112
1117
  if (item.action === "skip-unreadable-link") {
1113
1118
  // An unreadable local link must stay non-fatal: the runner treats a
1114
1119
  // generic error event as a failed company leg. Surface the exact remote
@@ -1227,6 +1232,47 @@ function executeContentMatchSkip(run, item, counters) {
1227
1232
  run.journalChangesSinceCheckpoint++;
1228
1233
  counters.filesSkipped++;
1229
1234
  }
1235
+ /**
1236
+ * Size prefilter for KMS-10 copy proof. A symlink's journal row records size 0
1237
+ * while LIST reports the byte length of its encoded `hq-symlink:<target>` body,
1238
+ * so the sizes never compare; the HEAD proof (original ETag, since link uploads
1239
+ * carry no content hash) still has to match before the copy is accepted.
1240
+ */
1241
+ function kms10ListedSizeMatchesJournal(remoteFile, entry) {
1242
+ return entry.kind === "symlink" || remoteFile.size === entry.size;
1243
+ }
1244
+ function executeKms10CopySkip(run, item, counters) {
1245
+ const key = item.remoteFile.key;
1246
+ if (!isDownloadWritePathStillContained(run.companyRoot, key, item.localPath)) {
1247
+ throw new Error(`KMS-10 copy skip escaped the sync root for ${key}`);
1248
+ }
1249
+ if (!matchesLocalSnapshot(item.localPath, item.localSnapshot)) {
1250
+ counters.filesSkipped++;
1251
+ counters.hasRetryableOutcome = true;
1252
+ run.emit({
1253
+ type: "error",
1254
+ diagnostic: true,
1255
+ path: key,
1256
+ message: "KMS-10 copy skip deferred because the local file changed after planning",
1257
+ });
1258
+ return;
1259
+ }
1260
+ const entry = journalRows(run.journal).get(key);
1261
+ const remoteEtag = normalizeEtag(item.remoteFile.etag);
1262
+ if (!entry ||
1263
+ entry.hash !== item.journalBaseHash ||
1264
+ !kms10ListedSizeMatchesJournal(item.remoteFile, entry) ||
1265
+ remoteEtag === "" ||
1266
+ remoteEtag === entry.remoteEtag) {
1267
+ counters.filesSkipped++;
1268
+ return;
1269
+ }
1270
+ // HEAD proved the remote hash matches the journal baseline. Preserve local
1271
+ // bytes and record only the copied remote revision.
1272
+ journalRows(run.journal).set(key, { ...entry, remoteEtag });
1273
+ run.journalChangesSinceCheckpoint++;
1274
+ counters.filesSkipped++;
1275
+ }
1230
1276
  async function executeConflictItem(run, plan, scopeRun, counters, downloadItems, item) {
1231
1277
  const { remoteFile, localPath } = item;
1232
1278
  // Cloud-authoritative paths (server-regenerated: company-brief.md, board.json,
@@ -2705,6 +2751,7 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
2705
2751
  if (item.action === "download" ||
2706
2752
  item.action === "conflict" ||
2707
2753
  item.action === "skip-content-match" ||
2754
+ item.action === "skip-kms10-copy" ||
2708
2755
  item.action === "tombstone-delete" ||
2709
2756
  item.action === "skip-junk-key-spelling" ||
2710
2757
  item.action === "skip-unreadable-link") {
@@ -2820,679 +2867,752 @@ propagateDeletePolicy = "owned-only", onHeartbeat, journalRowCount = journalRows
2820
2867
  // promise/microtask work per object even though no I/O is being awaited.
2821
2868
  const cooperativeWork = { rowsSinceYield: 0 };
2822
2869
  let plannedRows = 0;
2823
- for (const remoteFile of listedFiles) {
2824
- onHeartbeat?.();
2825
- noteCooperativeRowVisit("plan", remoteFile.key);
2826
- plannedRows++;
2827
- if (cooperativeCheckpoint &&
2828
- plannedRows % cooperativeUnitSize() === 0) {
2829
- dropJournalDecodeWindow();
2830
- if (await yieldToCooperativeCheckpoint(cooperativeWork, cooperativeCheckpoint, true, plannedRows)) {
2831
- onCooperativeHandoff?.();
2832
- }
2833
- }
2834
- else if (plannedRows % decodeWindowSize() === 0) {
2835
- dropJournalDecodeWindow();
2836
- }
2837
- const posixRemoteKey = toPosixKey(remoteFile.key);
2838
- // S3 object keys are POSIX. Match the old delete-sweep membership check:
2839
- // a legacy backslash journal entry counts as present only when its POSIX
2840
- // form appears in the LIST, while a malformed backslash remote key does
2841
- // not accidentally change that behavior.
2842
- if (remoteFile.key === posixRemoteKey) {
2843
- unseenJournalKeys.delete(remoteFile.key);
2844
- for (const legacyKey of legacyJournalKeysByPosix.get(remoteFile.key) ?? []) {
2845
- unseenJournalKeys.delete(legacyKey);
2846
- }
2847
- }
2848
- if (remoteFile.key.startsWith(SKILLS_KEY_PREFIX)) {
2849
- // A remote junk spelling means its canonical journal twin is still
2850
- // remotely present. Keep that twin out of the delete sweep unless an
2851
- // exact FILE_TOMBSTONE is authoritative, precisely matching the old
2852
- // remoteCanonicalSkillKeys protection without retaining remote keys.
2853
- const canonical = canonicalVaultKeySpelling(remoteFile.key);
2854
- for (const journaledKey of journaledSkillKeysByCanonical.get(canonical) ?? []) {
2855
- if (fileTombstones.get(toPosixKey(journaledKey)) === undefined) {
2856
- unseenJournalKeys.delete(journaledKey);
2870
+ const listedIterator = listedFiles[Symbol.iterator]();
2871
+ const pullHeadBatchSize = 16;
2872
+ while (true) {
2873
+ const remoteBatch = [];
2874
+ while (remoteBatch.length < pullHeadBatchSize) {
2875
+ const next = listedIterator.next();
2876
+ if (next.done)
2877
+ break;
2878
+ remoteBatch.push(next.value);
2879
+ }
2880
+ if (remoteBatch.length === 0)
2881
+ break;
2882
+ // KMS-10 copies change ETags without changing the journal content hash.
2883
+ // Probe bounded batches so large vaults never queue an unbounded HEAD set.
2884
+ const prefetchedHeads = new Map();
2885
+ const headCandidates = ctx === undefined
2886
+ ? []
2887
+ : remoteBatch.filter((remoteFile) => {
2888
+ const entry = getEntry(journal, remoteFile.key);
2889
+ return entry !== undefined &&
2890
+ hasRemoteChanged(remoteFile, entry) &&
2891
+ kms10ListedSizeMatchesJournal(remoteFile, entry) &&
2892
+ normalizeEtag(remoteFile.etag) !== "" &&
2893
+ normalizeEtag(remoteFile.etag) !== entry.remoteEtag;
2894
+ });
2895
+ if (headCandidates.length > 0 && ctx !== undefined) {
2896
+ await Promise.all(headCandidates.map(async (remoteFile) => {
2897
+ try {
2898
+ prefetchedHeads.set(remoteFile.key, {
2899
+ head: await headRemoteFile(ctx, remoteFile.key),
2900
+ });
2901
+ }
2902
+ catch (error) {
2903
+ prefetchedHeads.set(remoteFile.key, { head: null, error });
2904
+ }
2905
+ }));
2906
+ }
2907
+ for (const remoteFile of remoteBatch) {
2908
+ onHeartbeat?.();
2909
+ noteCooperativeRowVisit("plan", remoteFile.key);
2910
+ plannedRows++;
2911
+ if (cooperativeCheckpoint &&
2912
+ plannedRows % cooperativeUnitSize() === 0) {
2913
+ dropJournalDecodeWindow();
2914
+ if (await yieldToCooperativeCheckpoint(cooperativeWork, cooperativeCheckpoint, true, plannedRows)) {
2915
+ onCooperativeHandoff?.();
2857
2916
  }
2858
2917
  }
2859
- }
2860
- const invalidKey = classifyVaultKey(remoteFile.key, personalMode ? "personal" : "company");
2861
- if (invalidKey !== null) {
2862
- invalidRemoteKeys.push({
2863
- path: remoteFile.key,
2864
- errorCode: invalidKey.code,
2865
- });
2866
- items.push({
2867
- action: "skip-excluded-policy",
2868
- remoteFile,
2869
- localPath: companyRoot,
2870
- });
2871
- continue;
2872
- }
2873
- const localPath = resolveContainedVaultPath(companyRoot, remoteFile.key);
2874
- if (localPath === null) {
2875
- items.push({ action: "skip-excluded-policy", remoteFile, localPath: companyRoot });
2876
- continue;
2877
- }
2878
- // This is intentionally exact-prefix and storage-class-specific. Other
2879
- // archived objects retain their established sync behavior until a separate
2880
- // policy explicitly defines it. LIST retains the object in remoteKeys, so
2881
- // an existing journal row is neither tombstoned nor deleted.
2882
- if (remoteFile.key.startsWith("workspace/.session-logs/") &&
2883
- remoteFile.storageClass === "DEEP_ARCHIVE") {
2884
- items.push({ action: "skip-archived-session-log", remoteFile, localPath });
2885
- continue;
2886
- }
2887
- // Ephemeral-mirror filter — symmetric with the push-side walker. Bug #2
2888
- // in the 5.33.0 deep-test: the push side has refused to upload conflict
2889
- // mirrors since 5.33.0, but the pull side downloaded them freely from
2890
- // staging, so legacy `.conflict-*` litter rode every sync into clean
2891
- // trees. Refuse them here; the V2 verification test (direct S3 inject
2892
- // bypassing the push filter entirely) is the regression contract.
2893
- if (isEphemeralPath(remoteFile.key)) {
2894
- items.push({ action: "skip-excluded-policy", remoteFile, localPath });
2895
- continue;
2896
- }
2897
- // Personal-vault policy must be symmetric across both transfer legs.
2898
- // The push walker refuses derived/machine-local/sensitive paths, but old
2899
- // objects can remain in the bucket from clients that predate an exclusion.
2900
- // Refuse those objects here too so a pull cannot rematerialize them (or,
2901
- // for generated skill wrappers, fight reindex over a directory link).
2902
- if (personalMode &&
2903
- isPersonalVaultExcluded(remoteFile.key) &&
2904
- !(allowRootMcpConfig && remoteFile.key === ".mcp.json")) {
2905
- items.push({ action: "skip-excluded-policy", remoteFile, localPath });
2906
- continue;
2907
- }
2908
- if (personalMode &&
2909
- remoteFile.key.startsWith("companies/") &&
2910
- // EXEMPTION: companies/manifest.yaml is the routing source-of-truth
2911
- // carved INTO the personal vault on the push side
2912
- // (computePersonalVaultPaths). It must round-trip on the pull leg too —
2913
- // skipping it here leaves it forever unjournaled, which re-fires a
2914
- // transient push-side conflict every sync (no journal baseline). Let it
2915
- // fall through to download + journal like any personal file.
2916
- remoteFile.key !== PERSONAL_VAULT_MANIFEST_KEY) {
2917
- // Local (non-cloud) companies sync to the personal vault: keys under
2918
- // `companies/{slug}/...` are legitimate content a peer machine pushed
2919
- // for a company that is not cloud-backed. Allow them through EXCEPT
2920
- // for slugs the operator has an active team-bucket Membership for:
2921
- // those are orphan remnants from before the company became cloud-true,
2922
- // and downloading them would clash with the team-bucket pull at the
2923
- // same disk path.
2924
- //
2925
- // Symmetric to the push-side `decommissionPrefixes` logic in
2926
- // share.ts — both target the same orphan class, both honor the
2927
- // same `teamSyncedSlugs` set derived from the operator's live
2928
- // membership at runtime.
2929
- const slug = remoteFile.key.split("/")[1] ?? "";
2930
- const isTeamSyncedOrphan = teamSyncedSlugs !== null && slug !== "" && teamSyncedSlugs.has(slug);
2931
- if (isTeamSyncedOrphan) {
2932
- items.push({ action: "skip-personal-mode", remoteFile, localPath });
2933
- continue;
2918
+ else if (plannedRows % decodeWindowSize() === 0) {
2919
+ dropJournalDecodeWindow();
2920
+ }
2921
+ const posixRemoteKey = toPosixKey(remoteFile.key);
2922
+ // S3 object keys are POSIX. Match the old delete-sweep membership check:
2923
+ // a legacy backslash journal entry counts as present only when its POSIX
2924
+ // form appears in the LIST, while a malformed backslash remote key does
2925
+ // not accidentally change that behavior.
2926
+ if (remoteFile.key === posixRemoteKey) {
2927
+ unseenJournalKeys.delete(remoteFile.key);
2928
+ for (const legacyKey of legacyJournalKeysByPosix.get(remoteFile.key) ?? []) {
2929
+ unseenJournalKeys.delete(legacyKey);
2930
+ }
2934
2931
  }
2935
- }
2936
- // Scope filter (US-005). Keys outside the effective `syncMode` prefix set
2937
- // are not downloaded. `prefixSet` is `[""]` in `all` mode, which
2938
- // `isCoveredByAny` treats as covering everything — so this is a no-op for
2939
- // `all` and preserves the legacy full-bucket pull bit-for-bit. The
2940
- // previously-downloaded counterparts of these keys (if scope just shrank)
2941
- // are pruned separately by the scope-shrink pass in `sync()`.
2942
- // A key can fail the inclusion check AND sit under a push-only exclude
2943
- // prefix. Attribute it to `push-only` in that case: widening the
2944
- // membership to `all` would move it into `prefixSet` but the exclusion
2945
- // below would still withhold it, so it is NOT part of the shared-vs-all
2946
- // gap and must not be advertised as fixable by raising the access level.
2947
- if (!prefixMatcher.coversPath(remoteFile.key)) {
2948
- items.push({
2949
- action: "skip-out-of-scope",
2950
- remoteFile,
2951
- localPath,
2952
- reason: excludeMatcher.coversPath(remoteFile.key)
2953
- ? "push-only"
2954
- : "membership-scope",
2955
- });
2956
- continue;
2957
- }
2958
- // Push-only exclusion filter (US-006). A key that PASSED the inclusion check
2959
- // above is STILL not downloaded if it falls under a push-only exclude prefix
2960
- // (`sessions/`). This is what makes sessions push-only in EVERY mode — it
2961
- // subtracts from `all` (whose `prefixSet` is `[""]`, covering everything)
2962
- // and from an owner's role-wide scope alike. Empty `excludePrefixes` (the
2963
- // default) makes this a no-op. Deliberately AFTER inclusion and reported on
2964
- // the same `filesOutOfScope` axis. The matching local file (if a session was
2965
- // previously pulled, authored locally, or pinned+materialized) is NOT pruned
2966
- // here or by scope-shrink — see `planScopeShrink`, which never sees this set.
2967
- if (excludeMatcher.coversPath(remoteFile.key)) {
2968
- items.push({
2969
- action: "skip-out-of-scope",
2970
- remoteFile,
2971
- localPath,
2972
- reason: "push-only",
2973
- });
2974
- continue;
2975
- }
2976
- // LIST gives us no kind signal for the remote object — we don't
2977
- // know whether this key is a regular file or a symlink record
2978
- // until we either HEAD it (expensive — N extra calls per pull) or
2979
- // download it. So probe the ignore filter with BOTH isDir hints
2980
- // and include the entry if either probe passes. Without this,
2981
- // a symlink record at a dir-only-allowlisted path (e.g. an
2982
- // .hqinclude of `companies/*/knowledge/`) would be classified
2983
- // skip-ignored and the symlinks freshly uploaded by the
2984
- // push-side fix would never propagate. Mirrors the dual-hint
2985
- // probe in walkDir/collectFiles. Pure path lookup, no I/O.
2986
- if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) {
2987
- items.push({ action: "skip-ignored", remoteFile, localPath });
2988
- continue;
2989
- }
2990
- const journalEntry = getEntry(journal, remoteFile.key);
2991
- // Reindex and package wiring own symlinks under these `core/<type>`
2992
- // surfaces; their canonical sources are elsewhere. A prior pull records
2993
- // the remote object as a symlink, then reindex removes the generated link.
2994
- // Without this classification the next pull sees only an absent local path
2995
- // and downloads the same marker forever. Retain regular core files: the
2996
- // journal kind is the discriminator, because LIST exposes no object type.
2997
- // An unchanged ETag is also required: a remote writer can replace a legacy
2998
- // symlink with canonical file content at the same key, and that replacement
2999
- // must still be pulled.
3000
- if (personalMode &&
3001
- journalEntry?.kind === "symlink" &&
3002
- isGeneratedCoreMirrorKey(remoteFile.key) &&
3003
- journalEntry.remoteEtag !== undefined &&
3004
- !hasRemoteChanged(remoteFile, journalEntry)) {
3005
- items.push({ action: "skip-excluded-policy", remoteFile, localPath });
3006
- continue;
3007
- }
3008
- // Junk-spelling guard — SKILL-FARM KEYS ONLY. The amplifier is specific
3009
- // to `.claude/skills/<ns>:<skill>` wrapper spellings; outside that prefix,
3010
- // keys whose percent-decode fixpoints collide (`notes/a b.md` vs a genuine
3011
- // `notes/a%20b.md`) are DISTINCT files that must both pull.
3012
- // Within the farm: a remote key that is NOT journaled under its own
3013
- // spelling but whose percent-decode fixpoint matches an already-journaled
3014
- // key with a different spelling is a mixed-version twin (`%3A`/`%253A`
3015
- // generations of a canonical `:` key). Materializing it would mint junk
3016
- // local dirs and fork the journal into a second key family for the same
3017
- // logical path — skip it (loudly, never fatally; see the
3018
- // `skip-junk-key-spelling` event doc) and leave the journaled spelling
3019
- // authoritative for this machine.
3020
- if (journalEntry === undefined &&
3021
- remoteFile.key.startsWith(SKILLS_KEY_PREFIX) &&
3022
- // Gate NON-canonical remote spellings only. A remote key that already
3023
- // IS the canonical spelling must always be downloadable — after the
3024
- // doctor collapses the vault, a machine whose journal still holds only
3025
- // the junk spelling would otherwise skip the canonical object forever
3026
- // (and the delete-resync twin protection keeps that stale junk entry),
3027
- // deadlocking it out of every future update.
3028
- canonicalVaultKeySpelling(remoteFile.key) !== remoteFile.key) {
3029
- const journaledTwin = journaledKeyByCanonical.get(canonicalVaultKeySpelling(remoteFile.key));
3030
- if (journaledTwin !== undefined && journaledTwin !== remoteFile.key) {
2932
+ if (remoteFile.key.startsWith(SKILLS_KEY_PREFIX)) {
2933
+ // A remote junk spelling means its canonical journal twin is still
2934
+ // remotely present. Keep that twin out of the delete sweep unless an
2935
+ // exact FILE_TOMBSTONE is authoritative, precisely matching the old
2936
+ // remoteCanonicalSkillKeys protection without retaining remote keys.
2937
+ const canonical = canonicalVaultKeySpelling(remoteFile.key);
2938
+ for (const journaledKey of journaledSkillKeysByCanonical.get(canonical) ?? []) {
2939
+ if (fileTombstones.get(toPosixKey(journaledKey)) === undefined) {
2940
+ unseenJournalKeys.delete(journaledKey);
2941
+ }
2942
+ }
2943
+ }
2944
+ const invalidKey = classifyVaultKey(remoteFile.key, personalMode ? "personal" : "company");
2945
+ if (invalidKey !== null) {
2946
+ invalidRemoteKeys.push({
2947
+ path: remoteFile.key,
2948
+ errorCode: invalidKey.code,
2949
+ });
3031
2950
  items.push({
3032
- action: "skip-junk-key-spelling",
2951
+ action: "skip-excluded-policy",
3033
2952
  remoteFile,
3034
- localPath,
3035
- journaledKey: journaledTwin,
2953
+ localPath: companyRoot,
3036
2954
  });
3037
2955
  continue;
3038
2956
  }
3039
- }
3040
- // ── FILE_TOMBSTONE consult (delete-resync) ───────────────────────────────
3041
- // A remote object present in the LIST may be an intentionally-deleted key
3042
- // (a peer re-pushed it, or its delete-marker hasn't propagated to this
3043
- // caller's view). `tombstoneSuppresses` is true when a FILE_TOMBSTONE marks
3044
- // this key deleted AND the remote object is NOT a newer re-create — i.e. the
3045
- // object is the stale/deleted version and must NOT be downloaded. A genuine
3046
- // re-create (object newer than the tombstone) leaves this false so the
3047
- // normal download/merge path runs (the tombstone is not permanent
3048
- // suppression). An empty tombstone map (the default / degraded-fetch case)
3049
- // makes this always false — legacy behavior preserved bit-for-bit.
3050
- const tombstone = fileTombstones.get(toPosixKey(remoteFile.key));
3051
- const tombstoneSuppresses = tombstone !== undefined &&
3052
- !isRemoteRecreateAfterTombstone(remoteFile, tombstone);
3053
- // LOCAL journal tombstone — this machine's own intentional-delete record,
3054
- // independent of the (best-effort, intermittently-degraded) server
3055
- // FILE_TOMBSTONE fetch above. When a clean local delete is classified, the
3056
- // push leg stamps `removedAt` on the journal entry; honoring it here makes
3057
- // the delete-vs-drift-restore outcome DETERMINISTIC, closing the
3058
- // intermittent respawn where a degraded tombstone fetch let the pull leg
3059
- // re-materialize an intentionally-deleted file. Reuses the same re-create
3060
- // guard (a remote object newer than the local delete is a genuine re-create
3061
- // and still downloads). Consulted ONLY on the `!localExists` re-download
3062
- // path below — never to delete a file that is present locally.
3063
- const localTombstoneSuppresses = journalEntry !== undefined &&
3064
- isTombstone(journalEntry) &&
3065
- hasCurrentLocalDeleteIntent(journalEntry) &&
3066
- journalEntry.removedAt !== undefined &&
3067
- !isRemoteRecreateAfterTombstone(remoteFile, {
3068
- deletedAt: journalEntry.removedAt,
3069
- });
3070
- // Only a key that passed validation, policy, scope and ignore filtering
3071
- // and is not itself a deleted object can own a local file, so only such a
3072
- // key may protect a case-only twin from deletion (feedback_c8ed69d1). A
3073
- // local file whose folder merely keeps an older case spelling is not
3074
- // protected: when no live key owns it, the remote delete still applies.
3075
- if (!tombstoneSuppresses && !localTombstoneSuppresses) {
3076
- caseAliasTracker?.observeRemote(remoteFile.key);
3077
- }
3078
- // lstat (not existsSync/statSync) handles three cases the legacy
3079
- // checks got wrong for symlinks:
3080
- // 1. A valid symlink at localPath: existsSync returns true and the
3081
- // planner falls through to hashFile, which follows the link and
3082
- // hashes the target file's bytes — never matching the journal's
3083
- // sha256(target string), so every pull would mis-classify a
3084
- // pristine symlink as locally changed.
3085
- // 2. A dangling symlink: existsSync returns false (existsSync
3086
- // follows links) so the planner treats the entry as missing and
3087
- // re-downloads, silently clobbering the user's intentional
3088
- // dangling link on every run.
3089
- // 3. A regular file: indistinguishable from current behaviour.
3090
- let localLstat = null;
3091
- let plannedLocalSnapshot = { kind: "absent" };
3092
- let localPathBlockedByFileAncestor = false;
3093
- try {
3094
- localLstat = fs.lstatSync(localPath);
3095
- }
3096
- catch (err) {
3097
- const code = err && typeof err === "object" && "code" in err
3098
- ? err.code
3099
- : undefined;
3100
- // ENOENT → truly absent → treated as "new file from cloud".
3101
- // ENOTDIR → an ancestor of localPath is a regular file (cloud has a
3102
- // dir at that key, local has a file). Pre-fix this threw
3103
- // and aborted the whole company sync (Bug #10 in the
3104
- // 5.33.0 deep-test verification — \`v4-dir-vs-file\` repro
3105
- // wedged the personal company at \"errored\" status,
3106
- // skipping every later file). Recover by classifying as
3107
- // a structural collision the operator must resolve
3108
- // manually, and let the rest of the company process.
3109
- if (code !== "ENOENT" && code !== "ENOTDIR") {
3110
- throw err;
3111
- }
3112
- if (code === "ENOTDIR") {
3113
- localPathBlockedByFileAncestor = true;
3114
- }
3115
- }
3116
- const localExists = localLstat !== null;
3117
- if (localPathBlockedByFileAncestor) {
3118
- // Symmetric counterpart to the (local-dir, cloud-file) warning below.
3119
- // Emit the same "manual reconciliation required" surface so the
3120
- // operator sees one consistent message for both topologies; record as
3121
- // skip-local-only so the file is not silently dropped from the
3122
- // count and the executor never tries to lstat/write it.
3123
- console.error(` Warning: an ancestor of ${remoteFile.key} exists locally as a regular file; ` +
3124
- `cloud has a deeper key under that path. Skipping; manual ` +
3125
- `reconciliation required (rm the conflicting local file to pull).`);
3126
- items.push({ action: "skip-local-only", remoteFile, localPath });
3127
- continue;
3128
- }
3129
- if (localExists) {
3130
- const isLocalSymlink = localLstat.isSymbolicLink();
3131
- // Kind-mismatch guard: a remote LIST entry is always a single
3132
- // S3 object (regular file or symlink record), but the local
3133
- // path may be a real directory if the user previously had a
3134
- // dir at this key. Hashing a directory throws EISDIR and
3135
- // aborts the whole pull. We don't auto-replace because that
3136
- // would `rm -rf` the user's directory; surface as a non-fatal
3137
- // skip and warn so the operator can reconcile manually
3138
- // (delete the local dir, or restructure the remote).
3139
- if (localLstat.isDirectory() && !isLocalSymlink) {
3140
- // Stale personal-overlay marker vs. release-shipped core directory:
3141
- // a single vault object at a `core/<overlay-type>/.../<entry>` key
3142
- // whose local counterpart is a real directory is an orphaned overlay
3143
- // symlink marker (reindex mirror artifact) left behind after a release
3144
- // shipped a real core dir of the same name. The local release-shipped
3145
- // directory is authoritative and the marker can never be materialized
3146
- // over it, so keep local and ignore the marker QUIETLY — the generic
3147
- // "rm -rf the local directory" advice below is actively dangerous here
3148
- // (it would destroy release-shipped core content to pull inert derived
3149
- // state), and it recurred on every sync. feedback_ce9aeede.
3150
- if (isGeneratedCoreMirrorKey(remoteFile.key)) {
3151
- console.error(` Note: ${remoteFile.key} is a stale personal-overlay marker ` +
3152
- `shadowing a release-shipped core directory; keeping the local ` +
3153
- `directory and ignoring the marker (no action needed).`);
3154
- items.push({
3155
- action: "skip-stale-overlay-marker",
3156
- remoteFile,
3157
- localPath,
3158
- });
3159
- continue;
3160
- }
3161
- // Directory-overlay marker vs. a locally-materialized directory:
3162
- // the remote object at this key carries CHILD objects under it in the
3163
- // same LIST (`remoteKeysWithChildren`), so it is a symlink/overlay
3164
- // record for a directory — e.g. the DPP vault's `skills/cody-copywriter`
3165
- // overlay whose 13 child objects are already materialized locally. The
3166
- // marker can never be renamed over the real directory, and its children
3167
- // carry the actual content and materialize via their own plan items, so
3168
- // the marker is a benign, no-action skip. The generic "rm -rf the local
3169
- // directory to pull" advice below would tell the operator to DELETE the
3170
- // materialized (and possibly locally-authored) contents to install an
3171
- // inert link, and it recurred on EVERY sync cycle. feedback_d2082110.
3172
- if (remoteKeysWithChildren.has(toPosixKey(remoteFile.key))) {
3173
- console.error(` Note: ${remoteFile.key} is a directory-overlay marker whose ` +
3174
- `contents already exist locally as a directory; keeping the ` +
3175
- `local directory and ignoring the marker (no action needed).`);
3176
- items.push({
3177
- action: "skip-overlay-marker-with-children",
3178
- remoteFile,
3179
- localPath,
3180
- });
3181
- continue;
3182
- }
3183
- console.error(` Warning: ${remoteFile.key} exists locally as a directory; ` +
3184
- `cloud has a single object at this key. Skipping; manual ` +
3185
- `reconciliation required (rm -rf the local directory to pull).`);
3186
- items.push({ action: "skip-local-only", remoteFile, localPath });
2957
+ const localPath = resolveContainedVaultPath(companyRoot, remoteFile.key);
2958
+ if (localPath === null) {
2959
+ items.push({ action: "skip-excluded-policy", remoteFile, localPath: companyRoot });
3187
2960
  continue;
3188
2961
  }
3189
- let localHash;
3190
- if (isLocalSymlink) {
3191
- const target = readlinkOrNull(localPath);
3192
- if (target === null) {
3193
- // We cannot prove whether the local link diverged, so never
3194
- // overwrite or tombstone it from this plan. A later pass can retry
3195
- // once the OS exposes a readable target.
3196
- items.push({ action: "skip-unreadable-link", remoteFile, localPath });
3197
- continue;
3198
- }
3199
- localHash = hashSymlinkTarget(target);
3200
- }
3201
- else if (journalEntry && journalEntryMatchesStat(journalEntry, localLstat)) {
3202
- // size + mtime + ctime all match the row that recorded this hash, so
3203
- // the bytes have not been written since (ctime cannot be set from
3204
- // userspace — see `journalEntryMatchesStat`). Reading them would only
3205
- // reproduce the recorded hash. This is the pull half of the push
3206
- // planner's stat fast path; without it every local file with a remote
3207
- // counterpart was hashed on every pass — about 13 GB per runner start
3208
- // on a real install, a quarter of the first pass's CPU.
3209
- localHash = journalEntry.hash;
2962
+ // This is intentionally exact-prefix and storage-class-specific. Other
2963
+ // archived objects retain their established sync behavior until a separate
2964
+ // policy explicitly defines it. LIST retains the object in remoteKeys, so
2965
+ // an existing journal row is neither tombstoned nor deleted.
2966
+ if (remoteFile.key.startsWith("workspace/.session-logs/") &&
2967
+ remoteFile.storageClass === "DEEP_ARCHIVE") {
2968
+ items.push({ action: "skip-archived-session-log", remoteFile, localPath });
2969
+ continue;
3210
2970
  }
3211
- else {
3212
- // A same-size rewrite can preserve mtime, so mtime alone cannot prove
3213
- // content identity, and rows without stamps carry no evidence at all.
3214
- // Hash the file before deciding whether local state diverged from the
3215
- // journal baseline.
3216
- localHash = hashFile(localPath);
3217
- }
3218
- const localChanged = !!journalEntry && journalEntry.hash !== localHash;
3219
- plannedLocalSnapshot = {
3220
- kind: isLocalSymlink ? "symlink" : "file",
3221
- hash: localHash,
3222
- };
3223
- const remoteChanged = !!journalEntry && hasRemoteChanged(remoteFile, journalEntry);
3224
- const announcedContentHash = pullContentHashes[remoteFile.key];
3225
- // ── Authoritative delete reaches a machine that still holds the file ──
3226
- // A FILE_TOMBSTONE marks this key deleted and the remote object is not a
3227
- // newer re-create, but this machine still has a local copy. Honor the
3228
- // delete — EXCEPT never destroy unsynced local work: if the local copy
3229
- // diverges from the synced baseline (a post-tombstone edit, or an
3230
- // untracked local file with no journal entry), surface a CONFLICT instead
3231
- // of silently deleting (the safety guard the brief requires + the 3-way
3232
- // merge invariant). A local copy that still matches the deleted baseline
3233
- // is the stale version → delete it + drop the journal entry so it stays
3234
- // gone on this machine.
3235
- if (tombstoneSuppresses) {
3236
- if (localChanged || !journalEntry) {
3237
- items.push({
3238
- action: "conflict",
3239
- remoteFile,
3240
- localPath,
3241
- localHash,
3242
- localMtime: localLstat.mtime,
3243
- localSize: isLocalSymlink ? 0 : localLstat.size,
3244
- localSnapshot: plannedLocalSnapshot,
3245
- });
3246
- }
3247
- else {
3248
- items.push({
3249
- action: "tombstone-delete",
3250
- remoteFile,
3251
- localPath,
3252
- localSnapshot: plannedLocalSnapshot,
3253
- });
3254
- }
2971
+ // Ephemeral-mirror filter — symmetric with the push-side walker. Bug #2
2972
+ // in the 5.33.0 deep-test: the push side has refused to upload conflict
2973
+ // mirrors since 5.33.0, but the pull side downloaded them freely from
2974
+ // staging, so legacy `.conflict-*` litter rode every sync into clean
2975
+ // trees. Refuse them here; the V2 verification test (direct S3 inject
2976
+ // bypassing the push filter entirely) is the regression contract.
2977
+ if (isEphemeralPath(remoteFile.key)) {
2978
+ items.push({ action: "skip-excluded-policy", remoteFile, localPath });
3255
2979
  continue;
3256
2980
  }
3257
- // ── Untracked-local guard (unjournaled local file) ──────────────
3258
- // Both `localChanged` and `remoteChanged` are gated on `!!journalEntry`,
3259
- // so a key with NO journal entry reads "clean on both sides" and falls
3260
- // all the way through to a plain `download` — silently replacing a local
3261
- // file that exists on disk. There is no conflict, no `.conflict-` mirror
3262
- // and no conflict-index record, so the loss is invisible unless someone
3263
- // happens to know what the file should contain.
3264
- //
3265
- // This destroyed in-progress edits to
3266
- // `companies/*/projects/*/prd.json` three times on 2026-08-01/02, but
3267
- // nothing about it is prd- or project-specific: it fires for ANY synced
3268
- // path whose journal entry is absent. Absence is common — a file created
3269
- // on this machine and on a peer under the same key before either pushed,
3270
- // a journal shard reset/lost/rebuilt (`readJournal` returns an empty
3271
- // `files` map for a missing shard, so EVERY divergent local file is in
3272
- // scope), a run under a different `HQ_STATE_DIR`, or an entry dropped by
3273
- // scope-shrink or rescue.
3274
- //
3275
- // No journal entry means no evidence that local and remote ever agreed,
3276
- // which is exactly when an overwrite is least safe. Route it through the
3277
- // conflict path instead: the executor's convergence probe fetches the
3278
- // remote once and, when the bytes already match (the ordinary
3279
- // fresh-machine / rebuilt-journal case), reconciles silently — no
3280
- // conflict, no mirror, journal stamped. Only genuine divergence surfaces,
3281
- // and there local is PRESERVED as-is with the cloud copy written beside
3282
- // it under `--on-conflict keep`, or the run halts under `abort`.
3283
- // Cloud-authoritative paths (board.json, ontology/, signals/, …) are
3284
- // unaffected — the conflict executor short-circuits those back to a
3285
- // pull-wins download before any mirror is minted.
3286
- if (!journalEntry) {
3287
- items.push({
3288
- action: "conflict",
3289
- remoteFile,
3290
- localPath,
3291
- localHash,
3292
- localMtime: localLstat.mtime,
3293
- localSize: isLocalSymlink ? 0 : localLstat.size,
3294
- localSnapshot: plannedLocalSnapshot,
3295
- });
2981
+ // Personal-vault policy must be symmetric across both transfer legs.
2982
+ // The push walker refuses derived/machine-local/sensitive paths, but old
2983
+ // objects can remain in the bucket from clients that predate an exclusion.
2984
+ // Refuse those objects here too so a pull cannot rematerialize them (or,
2985
+ // for generated skill wrappers, fight reindex over a directory link).
2986
+ if (personalMode &&
2987
+ isPersonalVaultExcluded(remoteFile.key) &&
2988
+ !(allowRootMcpConfig && remoteFile.key === ".mcp.json")) {
2989
+ items.push({ action: "skip-excluded-policy", remoteFile, localPath });
3296
2990
  continue;
3297
2991
  }
3298
- // An event hash and a LIST row are separate observations. Only use the
3299
- // event to skip a changed ETag after HEAD proves that the exact listed
3300
- // revision carries that hash; otherwise the event may be stale and the
3301
- // LIST may already describe newer bytes.
3302
- let listedRevisionMatchesAnnouncement = false;
3303
- if (ctx !== undefined &&
3304
- remoteChanged &&
3305
- !isLocalSymlink &&
3306
- !localChanged &&
3307
- announcedContentHash !== undefined &&
3308
- contentHashMatchesRawSha256(announcedContentHash, journalEntry.hash) &&
3309
- journalEntryMatchesStat(journalEntry, localLstat) &&
3310
- remoteFile.size === localLstat.size &&
3311
- normalizeEtag(remoteFile.etag) !== "" &&
3312
- normalizeEtag(remoteFile.etag) !== journalEntry.remoteEtag) {
3313
- try {
3314
- const head = await headRemoteFile(ctx, remoteFile.key);
3315
- const remoteContentHash = head?.metadata?.[FILE_CONTENT_SHA256_META_KEY];
3316
- listedRevisionMatchesAnnouncement =
3317
- head !== null &&
3318
- normalizeEtag(head.etag) === normalizeEtag(remoteFile.etag) &&
3319
- head.size === remoteFile.size &&
3320
- remoteContentHash !== undefined &&
3321
- contentHashMatchesRawSha256(announcedContentHash, remoteContentHash);
3322
- }
3323
- catch (headError) {
3324
- // No proof means the ordinary GET path remains authoritative.
3325
- console.error(`sync: unable to verify announced content hash for ${remoteFile.key}; falling back to GET`, headError);
2992
+ if (personalMode &&
2993
+ remoteFile.key.startsWith("companies/") &&
2994
+ // EXEMPTION: companies/manifest.yaml is the routing source-of-truth
2995
+ // carved INTO the personal vault on the push side
2996
+ // (computePersonalVaultPaths). It must round-trip on the pull leg too —
2997
+ // skipping it here leaves it forever unjournaled, which re-fires a
2998
+ // transient push-side conflict every sync (no journal baseline). Let it
2999
+ // fall through to download + journal like any personal file.
3000
+ remoteFile.key !== PERSONAL_VAULT_MANIFEST_KEY) {
3001
+ // Local (non-cloud) companies sync to the personal vault: keys under
3002
+ // `companies/{slug}/...` are legitimate content a peer machine pushed
3003
+ // for a company that is not cloud-backed. Allow them through EXCEPT
3004
+ // for slugs the operator has an active team-bucket Membership for:
3005
+ // those are orphan remnants from before the company became cloud-true,
3006
+ // and downloading them would clash with the team-bucket pull at the
3007
+ // same disk path.
3008
+ //
3009
+ // Symmetric to the push-side `decommissionPrefixes` logic in
3010
+ // share.ts — both target the same orphan class, both honor the
3011
+ // same `teamSyncedSlugs` set derived from the operator's live
3012
+ // membership at runtime.
3013
+ const slug = remoteFile.key.split("/")[1] ?? "";
3014
+ const isTeamSyncedOrphan = teamSyncedSlugs !== null && slug !== "" && teamSyncedSlugs.has(slug);
3015
+ if (isTeamSyncedOrphan) {
3016
+ items.push({ action: "skip-personal-mode", remoteFile, localPath });
3017
+ continue;
3326
3018
  }
3327
3019
  }
3328
- if (listedRevisionMatchesAnnouncement) {
3020
+ // Scope filter (US-005). Keys outside the effective `syncMode` prefix set
3021
+ // are not downloaded. `prefixSet` is `[""]` in `all` mode, which
3022
+ // `isCoveredByAny` treats as covering everything — so this is a no-op for
3023
+ // `all` and preserves the legacy full-bucket pull bit-for-bit. The
3024
+ // previously-downloaded counterparts of these keys (if scope just shrank)
3025
+ // are pruned separately by the scope-shrink pass in `sync()`.
3026
+ // A key can fail the inclusion check AND sit under a push-only exclude
3027
+ // prefix. Attribute it to `push-only` in that case: widening the
3028
+ // membership to `all` would move it into `prefixSet` but the exclusion
3029
+ // below would still withhold it, so it is NOT part of the shared-vs-all
3030
+ // gap and must not be advertised as fixable by raising the access level.
3031
+ if (!prefixMatcher.coversPath(remoteFile.key)) {
3329
3032
  items.push({
3330
- action: "skip-content-match",
3033
+ action: "skip-out-of-scope",
3331
3034
  remoteFile,
3332
3035
  localPath,
3333
- contentHash: announcedContentHash,
3036
+ reason: excludeMatcher.coversPath(remoteFile.key)
3037
+ ? "push-only"
3038
+ : "membership-scope",
3334
3039
  });
3335
3040
  continue;
3336
3041
  }
3337
- // Mirror the original 3-way merge from the inline loop. Tested by
3338
- // `does NOT flag a pull conflict when only local changed since last
3339
- // sync` and `detects conflicts with local changes…`.
3340
- if (localChanged && remoteChanged) {
3042
+ // Push-only exclusion filter (US-006). A key that PASSED the inclusion check
3043
+ // above is STILL not downloaded if it falls under a push-only exclude prefix
3044
+ // (`sessions/`). This is what makes sessions push-only in EVERY mode — it
3045
+ // subtracts from `all` (whose `prefixSet` is `[""]`, covering everything)
3046
+ // and from an owner's role-wide scope alike. Empty `excludePrefixes` (the
3047
+ // default) makes this a no-op. Deliberately AFTER inclusion and reported on
3048
+ // the same `filesOutOfScope` axis. The matching local file (if a session was
3049
+ // previously pulled, authored locally, or pinned+materialized) is NOT pruned
3050
+ // here or by scope-shrink — see `planScopeShrink`, which never sees this set.
3051
+ if (excludeMatcher.coversPath(remoteFile.key)) {
3341
3052
  items.push({
3342
- action: "conflict",
3053
+ action: "skip-out-of-scope",
3343
3054
  remoteFile,
3344
3055
  localPath,
3345
- localHash,
3346
- // localLstat is non-null inside this `if (localExists)`
3347
- // branch — the planner already lstat'd the path. Carry
3348
- // its mtime forward so the executor doesn't have to
3349
- // re-stat (which would follow links and crash on a
3350
- // dangling symlink). Same goes for size: the conflict
3351
- // executor's keep/skip branch needs to stamp the
3352
- // journal with a size, and statSync on a dangling
3353
- // symlink throws ENOENT (silently swallowed pre-fix),
3354
- // leaving the journal stale and re-firing the conflict
3355
- // every sync.
3356
- localMtime: localLstat.mtime,
3357
- localSize: isLocalSymlink ? 0 : localLstat.size,
3358
- localSnapshot: plannedLocalSnapshot,
3056
+ reason: "push-only",
3359
3057
  });
3360
3058
  continue;
3361
3059
  }
3362
- if (journalEntry && localChanged && !remoteChanged) {
3363
- items.push({ action: "skip-local-only", remoteFile, localPath });
3060
+ // LIST gives us no kind signal for the remote object — we don't
3061
+ // know whether this key is a regular file or a symlink record
3062
+ // until we either HEAD it (expensive — N extra calls per pull) or
3063
+ // download it. So probe the ignore filter with BOTH isDir hints
3064
+ // and include the entry if either probe passes. Without this,
3065
+ // a symlink record at a dir-only-allowlisted path (e.g. an
3066
+ // .hqinclude of `companies/*/knowledge/`) would be classified
3067
+ // skip-ignored and the symlinks freshly uploaded by the
3068
+ // push-side fix would never propagate. Mirrors the dual-hint
3069
+ // probe in walkDir/collectFiles. Pure path lookup, no I/O.
3070
+ if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) {
3071
+ items.push({ action: "skip-ignored", remoteFile, localPath });
3072
+ continue;
3073
+ }
3074
+ const journalEntry = getEntry(journal, remoteFile.key);
3075
+ // Reindex and package wiring own symlinks under these `core/<type>`
3076
+ // surfaces; their canonical sources are elsewhere. A prior pull records
3077
+ // the remote object as a symlink, then reindex removes the generated link.
3078
+ // Without this classification the next pull sees only an absent local path
3079
+ // and downloads the same marker forever. Retain regular core files: the
3080
+ // journal kind is the discriminator, because LIST exposes no object type.
3081
+ // An unchanged ETag is also required: a remote writer can replace a legacy
3082
+ // symlink with canonical file content at the same key, and that replacement
3083
+ // must still be pulled.
3084
+ if (personalMode &&
3085
+ journalEntry?.kind === "symlink" &&
3086
+ isGeneratedCoreMirrorKey(remoteFile.key) &&
3087
+ journalEntry.remoteEtag !== undefined &&
3088
+ !hasRemoteChanged(remoteFile, journalEntry)) {
3089
+ items.push({ action: "skip-excluded-policy", remoteFile, localPath });
3364
3090
  continue;
3365
3091
  }
3366
- if (journalEntry && !localChanged && !remoteChanged) {
3367
- // Flavor-health repair (win32 skill farm): a link journaled while its
3368
- // target was absent was minted file-flavor; once the target directory
3369
- // exists the hashes still match (hash = sha256(target)) so this
3370
- // branch would skip it forever, leaving the wrapper broken. Re-download
3371
- // instead — the materializer's flavor-aware path recreates it 'dir'.
3372
- if (isLocalSymlink &&
3373
- remoteFile.key.startsWith(SKILLS_KEY_PREFIX) &&
3374
- win32SymlinkFlavorBroken(localPath)) {
3092
+ // Junk-spelling guard — SKILL-FARM KEYS ONLY. The amplifier is specific
3093
+ // to `.claude/skills/<ns>:<skill>` wrapper spellings; outside that prefix,
3094
+ // keys whose percent-decode fixpoints collide (`notes/a b.md` vs a genuine
3095
+ // `notes/a%20b.md`) are DISTINCT files that must both pull.
3096
+ // Within the farm: a remote key that is NOT journaled under its own
3097
+ // spelling but whose percent-decode fixpoint matches an already-journaled
3098
+ // key with a different spelling is a mixed-version twin (`%3A`/`%253A`
3099
+ // generations of a canonical `:` key). Materializing it would mint junk
3100
+ // local dirs and fork the journal into a second key family for the same
3101
+ // logical path — skip it (loudly, never fatally; see the
3102
+ // `skip-junk-key-spelling` event doc) and leave the journaled spelling
3103
+ // authoritative for this machine.
3104
+ if (journalEntry === undefined &&
3105
+ remoteFile.key.startsWith(SKILLS_KEY_PREFIX) &&
3106
+ // Gate NON-canonical remote spellings only. A remote key that already
3107
+ // IS the canonical spelling must always be downloadable — after the
3108
+ // doctor collapses the vault, a machine whose journal still holds only
3109
+ // the junk spelling would otherwise skip the canonical object forever
3110
+ // (and the delete-resync twin protection keeps that stale junk entry),
3111
+ // deadlocking it out of every future update.
3112
+ canonicalVaultKeySpelling(remoteFile.key) !== remoteFile.key) {
3113
+ const journaledTwin = journaledKeyByCanonical.get(canonicalVaultKeySpelling(remoteFile.key));
3114
+ if (journaledTwin !== undefined && journaledTwin !== remoteFile.key) {
3375
3115
  items.push({
3376
- action: "download",
3116
+ action: "skip-junk-key-spelling",
3377
3117
  remoteFile,
3378
3118
  localPath,
3379
- isNew: false,
3380
- localSnapshot: plannedLocalSnapshot,
3119
+ journaledKey: journaledTwin,
3381
3120
  });
3382
3121
  continue;
3383
3122
  }
3384
- items.push({ action: "skip-unchanged", remoteFile, localPath });
3123
+ }
3124
+ // ── FILE_TOMBSTONE consult (delete-resync) ───────────────────────────────
3125
+ // A remote object present in the LIST may be an intentionally-deleted key
3126
+ // (a peer re-pushed it, or its delete-marker hasn't propagated to this
3127
+ // caller's view). `tombstoneSuppresses` is true when a FILE_TOMBSTONE marks
3128
+ // this key deleted AND the remote object is NOT a newer re-create — i.e. the
3129
+ // object is the stale/deleted version and must NOT be downloaded. A genuine
3130
+ // re-create (object newer than the tombstone) leaves this false so the
3131
+ // normal download/merge path runs (the tombstone is not permanent
3132
+ // suppression). An empty tombstone map (the default / degraded-fetch case)
3133
+ // makes this always false — legacy behavior preserved bit-for-bit.
3134
+ const tombstone = fileTombstones.get(toPosixKey(remoteFile.key));
3135
+ const tombstoneSuppresses = tombstone !== undefined &&
3136
+ !isRemoteRecreateAfterTombstone(remoteFile, tombstone);
3137
+ // LOCAL journal tombstone — this machine's own intentional-delete record,
3138
+ // independent of the (best-effort, intermittently-degraded) server
3139
+ // FILE_TOMBSTONE fetch above. When a clean local delete is classified, the
3140
+ // push leg stamps `removedAt` on the journal entry; honoring it here makes
3141
+ // the delete-vs-drift-restore outcome DETERMINISTIC, closing the
3142
+ // intermittent respawn where a degraded tombstone fetch let the pull leg
3143
+ // re-materialize an intentionally-deleted file. Reuses the same re-create
3144
+ // guard (a remote object newer than the local delete is a genuine re-create
3145
+ // and still downloads). Consulted ONLY on the `!localExists` re-download
3146
+ // path below — never to delete a file that is present locally.
3147
+ const localTombstoneSuppresses = journalEntry !== undefined &&
3148
+ isTombstone(journalEntry) &&
3149
+ hasCurrentLocalDeleteIntent(journalEntry) &&
3150
+ journalEntry.removedAt !== undefined &&
3151
+ !isRemoteRecreateAfterTombstone(remoteFile, {
3152
+ deletedAt: journalEntry.removedAt,
3153
+ });
3154
+ // Only a key that passed validation, policy, scope and ignore filtering
3155
+ // and is not itself a deleted object can own a local file, so only such a
3156
+ // key may protect a case-only twin from deletion (feedback_c8ed69d1). A
3157
+ // local file whose folder merely keeps an older case spelling is not
3158
+ // protected: when no live key owns it, the remote delete still applies.
3159
+ if (!tombstoneSuppresses && !localTombstoneSuppresses) {
3160
+ caseAliasTracker?.observeRemote(remoteFile.key);
3161
+ }
3162
+ // lstat (not existsSync/statSync) handles three cases the legacy
3163
+ // checks got wrong for symlinks:
3164
+ // 1. A valid symlink at localPath: existsSync returns true and the
3165
+ // planner falls through to hashFile, which follows the link and
3166
+ // hashes the target file's bytes — never matching the journal's
3167
+ // sha256(target string), so every pull would mis-classify a
3168
+ // pristine symlink as locally changed.
3169
+ // 2. A dangling symlink: existsSync returns false (existsSync
3170
+ // follows links) so the planner treats the entry as missing and
3171
+ // re-downloads, silently clobbering the user's intentional
3172
+ // dangling link on every run.
3173
+ // 3. A regular file: indistinguishable from current behaviour.
3174
+ let localLstat = null;
3175
+ let plannedLocalSnapshot = { kind: "absent" };
3176
+ let localPathBlockedByFileAncestor = false;
3177
+ try {
3178
+ localLstat = fs.lstatSync(localPath);
3179
+ }
3180
+ catch (err) {
3181
+ const code = err && typeof err === "object" && "code" in err
3182
+ ? err.code
3183
+ : undefined;
3184
+ // ENOENT → truly absent → treated as "new file from cloud".
3185
+ // ENOTDIR → an ancestor of localPath is a regular file (cloud has a
3186
+ // dir at that key, local has a file). Pre-fix this threw
3187
+ // and aborted the whole company sync (Bug #10 in the
3188
+ // 5.33.0 deep-test verification — \`v4-dir-vs-file\` repro
3189
+ // wedged the personal company at \"errored\" status,
3190
+ // skipping every later file). Recover by classifying as
3191
+ // a structural collision the operator must resolve
3192
+ // manually, and let the rest of the company process.
3193
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
3194
+ throw err;
3195
+ }
3196
+ if (code === "ENOTDIR") {
3197
+ localPathBlockedByFileAncestor = true;
3198
+ }
3199
+ }
3200
+ const localExists = localLstat !== null;
3201
+ if (localPathBlockedByFileAncestor) {
3202
+ // Symmetric counterpart to the (local-dir, cloud-file) warning below.
3203
+ // Emit the same "manual reconciliation required" surface so the
3204
+ // operator sees one consistent message for both topologies; record as
3205
+ // skip-local-only so the file is not silently dropped from the
3206
+ // count and the executor never tries to lstat/write it.
3207
+ console.error(` Warning: an ancestor of ${remoteFile.key} exists locally as a regular file; ` +
3208
+ `cloud has a deeper key under that path. Skipping; manual ` +
3209
+ `reconciliation required (rm the conflicting local file to pull).`);
3210
+ items.push({ action: "skip-local-only", remoteFile, localPath });
3385
3211
  continue;
3386
3212
  }
3387
- if (journalEntry && !localChanged && remoteChanged) {
3388
- // ── Anti-rollback guard (silent newer→older overwrite) ───────────
3389
- // Remote etag moved but local still matches the journal baseline, so
3390
- // the classic 3-way merge calls this a clean `download` and overwrites
3391
- // local with remote. That assumes the remote *advanced*. It can also
3392
- // REGRESS: a second device (or a stale re-push) can replace the S3
3393
- // object with an OLDER, SMALLER copy. `hasRemoteChanged` is pure-ETag
3394
- // and cannot tell an advance from a rollback — ListObjectsV2 carries no
3395
- // content hash, and `lastModified` is the upload time (newer even for
3396
- // stale content), so neither distinguishes the two. A silent download
3397
- // then DESTROYS newer local bytes with no conflict surfaced.
3398
- //
3399
- // This bit append-only bot chat logs on 2026-06-10, the day
3400
- // event-driven bidirectional sync (which lets a peer device push to
3401
- // S3 between this device's pulls) first rolled out: a regressed S3
3402
- // object truncated freshly-appended lines, undetected by the planner.
3213
+ if (localExists) {
3214
+ const isLocalSymlink = localLstat.isSymbolicLink();
3215
+ // Kind-mismatch guard: a remote LIST entry is always a single
3216
+ // S3 object (regular file or symlink record), but the local
3217
+ // path may be a real directory if the user previously had a
3218
+ // dir at this key. Hashing a directory throws EISDIR and
3219
+ // aborts the whole pull. We don't auto-replace because that
3220
+ // would `rm -rf` the user's directory; surface as a non-fatal
3221
+ // skip and warn so the operator can reconcile manually
3222
+ // (delete the local dir, or restructure the remote).
3223
+ if (localLstat.isDirectory() && !isLocalSymlink) {
3224
+ // Stale personal-overlay marker vs. release-shipped core directory:
3225
+ // a single vault object at a `core/<overlay-type>/.../<entry>` key
3226
+ // whose local counterpart is a real directory is an orphaned overlay
3227
+ // symlink marker (reindex mirror artifact) left behind after a release
3228
+ // shipped a real core dir of the same name. The local release-shipped
3229
+ // directory is authoritative and the marker can never be materialized
3230
+ // over it, so keep local and ignore the marker QUIETLY — the generic
3231
+ // "rm -rf the local directory" advice below is actively dangerous here
3232
+ // (it would destroy release-shipped core content to pull inert derived
3233
+ // state), and it recurred on every sync. feedback_ce9aeede.
3234
+ if (isGeneratedCoreMirrorKey(remoteFile.key)) {
3235
+ console.error(` Note: ${remoteFile.key} is a stale personal-overlay marker ` +
3236
+ `shadowing a release-shipped core directory; keeping the local ` +
3237
+ `directory and ignoring the marker (no action needed).`);
3238
+ items.push({
3239
+ action: "skip-stale-overlay-marker",
3240
+ remoteFile,
3241
+ localPath,
3242
+ });
3243
+ continue;
3244
+ }
3245
+ // Directory-overlay marker vs. a locally-materialized directory:
3246
+ // the remote object at this key carries CHILD objects under it in the
3247
+ // same LIST (`remoteKeysWithChildren`), so it is a symlink/overlay
3248
+ // record for a directory — e.g. the DPP vault's `skills/cody-copywriter`
3249
+ // overlay whose 13 child objects are already materialized locally. The
3250
+ // marker can never be renamed over the real directory, and its children
3251
+ // carry the actual content and materialize via their own plan items, so
3252
+ // the marker is a benign, no-action skip. The generic "rm -rf the local
3253
+ // directory to pull" advice below would tell the operator to DELETE the
3254
+ // materialized (and possibly locally-authored) contents to install an
3255
+ // inert link, and it recurred on EVERY sync cycle. feedback_d2082110.
3256
+ if (remoteKeysWithChildren.has(toPosixKey(remoteFile.key))) {
3257
+ console.error(` Note: ${remoteFile.key} is a directory-overlay marker whose ` +
3258
+ `contents already exist locally as a directory; keeping the ` +
3259
+ `local directory and ignoring the marker (no action needed).`);
3260
+ items.push({
3261
+ action: "skip-overlay-marker-with-children",
3262
+ remoteFile,
3263
+ localPath,
3264
+ });
3265
+ continue;
3266
+ }
3267
+ console.error(` Warning: ${remoteFile.key} exists locally as a directory; ` +
3268
+ `cloud has a single object at this key. Skipping; manual ` +
3269
+ `reconciliation required (rm -rf the local directory to pull).`);
3270
+ items.push({ action: "skip-local-only", remoteFile, localPath });
3271
+ continue;
3272
+ }
3273
+ let localHash;
3274
+ if (isLocalSymlink) {
3275
+ const target = readlinkOrNull(localPath);
3276
+ if (target === null) {
3277
+ // We cannot prove whether the local link diverged, so never
3278
+ // overwrite or tombstone it from this plan. A later pass can retry
3279
+ // once the OS exposes a readable target.
3280
+ items.push({ action: "skip-unreadable-link", remoteFile, localPath });
3281
+ continue;
3282
+ }
3283
+ localHash = hashSymlinkTarget(target);
3284
+ }
3285
+ else if (journalEntry && journalEntryMatchesStat(journalEntry, localLstat)) {
3286
+ // size + mtime + ctime all match the row that recorded this hash, so
3287
+ // the bytes have not been written since (ctime cannot be set from
3288
+ // userspace — see `journalEntryMatchesStat`). Reading them would only
3289
+ // reproduce the recorded hash. This is the pull half of the push
3290
+ // planner's stat fast path; without it every local file with a remote
3291
+ // counterpart was hashed on every pass — about 13 GB per runner start
3292
+ // on a real install, a quarter of the first pass's CPU.
3293
+ localHash = journalEntry.hash;
3294
+ }
3295
+ else {
3296
+ // A same-size rewrite can preserve mtime, so mtime alone cannot prove
3297
+ // content identity, and rows without stamps carry no evidence at all.
3298
+ // Hash the file before deciding whether local state diverged from the
3299
+ // journal baseline.
3300
+ localHash = hashFile(localPath);
3301
+ }
3302
+ const localChanged = !!journalEntry && journalEntry.hash !== localHash;
3303
+ plannedLocalSnapshot = {
3304
+ kind: isLocalSymlink ? "symlink" : "file",
3305
+ hash: localHash,
3306
+ };
3307
+ const remoteChanged = !!journalEntry && hasRemoteChanged(remoteFile, journalEntry);
3308
+ const announcedContentHash = pullContentHashes[remoteFile.key];
3309
+ // ── Authoritative delete reaches a machine that still holds the file ──
3310
+ // A FILE_TOMBSTONE marks this key deleted and the remote object is not a
3311
+ // newer re-create, but this machine still has a local copy. Honor the
3312
+ // delete — EXCEPT never destroy unsynced local work: if the local copy
3313
+ // diverges from the synced baseline (a post-tombstone edit, or an
3314
+ // untracked local file with no journal entry), surface a CONFLICT instead
3315
+ // of silently deleting (the safety guard the brief requires + the 3-way
3316
+ // merge invariant). A local copy that still matches the deleted baseline
3317
+ // is the stale version → delete it + drop the journal entry so it stays
3318
+ // gone on this machine.
3319
+ if (tombstoneSuppresses) {
3320
+ if (localChanged || !journalEntry) {
3321
+ items.push({
3322
+ action: "conflict",
3323
+ remoteFile,
3324
+ localPath,
3325
+ localHash,
3326
+ localMtime: localLstat.mtime,
3327
+ localSize: isLocalSymlink ? 0 : localLstat.size,
3328
+ localSnapshot: plannedLocalSnapshot,
3329
+ });
3330
+ }
3331
+ else {
3332
+ items.push({
3333
+ action: "tombstone-delete",
3334
+ remoteFile,
3335
+ localPath,
3336
+ localSnapshot: plannedLocalSnapshot,
3337
+ });
3338
+ }
3339
+ continue;
3340
+ }
3341
+ // ── Untracked-local guard (unjournaled local file) ──────────────
3342
+ // Both `localChanged` and `remoteChanged` are gated on `!!journalEntry`,
3343
+ // so a key with NO journal entry reads "clean on both sides" and falls
3344
+ // all the way through to a plain `download` — silently replacing a local
3345
+ // file that exists on disk. There is no conflict, no `.conflict-` mirror
3346
+ // and no conflict-index record, so the loss is invisible unless someone
3347
+ // happens to know what the file should contain.
3403
3348
  //
3404
- // Guard: if the incoming remote object is strictly SMALLER than the
3405
- // local file we already hold (a size regression on a tracked regular
3406
- // file), do NOT silently clobber. Route through the conflict path —
3407
- // the executor's convergence probe fetches the remote once and, on
3408
- // genuine divergence, PRESERVES local as a `.conflict-*` mirror instead
3409
- // of overwriting it (and under `--on-conflict abort` halts loudly
3410
- // rather than losing data). A byte-identical remote can never be
3411
- // smaller, so this never manufactures a false conflict on a no-op
3412
- // re-upload. Symlinks are exempt: their on-disk size is not the target
3413
- // length, so the comparison is meaningless.
3349
+ // This destroyed in-progress edits to
3350
+ // `companies/*/projects/*/prd.json` three times on 2026-08-01/02, but
3351
+ // nothing about it is prd- or project-specific: it fires for ANY synced
3352
+ // path whose journal entry is absent. Absence is common — a file created
3353
+ // on this machine and on a peer under the same key before either pushed,
3354
+ // a journal shard reset/lost/rebuilt (`readJournal` returns an empty
3355
+ // `files` map for a missing shard, so EVERY divergent local file is in
3356
+ // scope), a run under a different `HQ_STATE_DIR`, or an entry dropped by
3357
+ // scope-shrink or rescue.
3414
3358
  //
3415
- // Direction gate: only guard copies this device AUTHORED (last synced
3416
- // UP). A consumer copy (last synced DOWN) has no local authorship to
3417
- // protect — the remote is authoritative, so a smaller remote is a
3418
- // legitimate update (e.g. the gardener rewriting a shorter brief), not a
3419
- // regression. Without this gate a consumer device floods `.conflict`
3420
- // mirrors and stalls on stale gardener output forever (the 786-file
3421
- // pile-up, 2026-06-22). `"up"` and any missing/unknown direction stay
3422
- // guarded exactly as before — the 2026-06-10 chat-log protection holds.
3423
- if (journalEntry.direction !== "down" &&
3359
+ // No journal entry means no evidence that local and remote ever agreed,
3360
+ // which is exactly when an overwrite is least safe. Route it through the
3361
+ // conflict path instead: the executor's convergence probe fetches the
3362
+ // remote once and, when the bytes already match (the ordinary
3363
+ // fresh-machine / rebuilt-journal case), reconciles silently — no
3364
+ // conflict, no mirror, journal stamped. Only genuine divergence surfaces,
3365
+ // and there local is PRESERVED as-is with the cloud copy written beside
3366
+ // it under `--on-conflict keep`, or the run halts under `abort`.
3367
+ // Cloud-authoritative paths (board.json, ontology/, signals/, …) are
3368
+ // unaffected — the conflict executor short-circuits those back to a
3369
+ // pull-wins download before any mirror is minted.
3370
+ if (!journalEntry) {
3371
+ items.push({
3372
+ action: "conflict",
3373
+ remoteFile,
3374
+ localPath,
3375
+ localHash,
3376
+ localMtime: localLstat.mtime,
3377
+ localSize: isLocalSymlink ? 0 : localLstat.size,
3378
+ localSnapshot: plannedLocalSnapshot,
3379
+ });
3380
+ continue;
3381
+ }
3382
+ // An event hash and a LIST row are separate observations. Only use the
3383
+ // event to skip a changed ETag after HEAD proves that the exact listed
3384
+ // revision carries that hash; otherwise the event may be stale and the
3385
+ // LIST may already describe newer bytes.
3386
+ let listedRevisionMatchesAnnouncement = false;
3387
+ let kms10CopyMatchesJournalBase = false;
3388
+ if (ctx !== undefined &&
3389
+ remoteChanged &&
3424
3390
  !isLocalSymlink &&
3425
- remoteFile.size < localLstat.size) {
3391
+ !localChanged &&
3392
+ announcedContentHash !== undefined &&
3393
+ contentHashMatchesRawSha256(announcedContentHash, journalEntry.hash) &&
3394
+ journalEntryMatchesStat(journalEntry, localLstat) &&
3395
+ remoteFile.size === localLstat.size &&
3396
+ normalizeEtag(remoteFile.etag) !== "" &&
3397
+ normalizeEtag(remoteFile.etag) !== journalEntry.remoteEtag) {
3398
+ try {
3399
+ const headResult = prefetchedHeads.get(remoteFile.key);
3400
+ if (headResult?.error !== undefined)
3401
+ throw headResult.error;
3402
+ const head = headResult
3403
+ ? headResult.head
3404
+ : await headRemoteFile(ctx, remoteFile.key);
3405
+ const remoteContentHash = head?.metadata?.[FILE_CONTENT_SHA256_META_KEY];
3406
+ listedRevisionMatchesAnnouncement =
3407
+ head !== null &&
3408
+ normalizeEtag(head.etag) === normalizeEtag(remoteFile.etag) &&
3409
+ head.size === remoteFile.size &&
3410
+ remoteContentHash !== undefined &&
3411
+ contentHashMatchesRawSha256(announcedContentHash, remoteContentHash);
3412
+ }
3413
+ catch (headError) {
3414
+ // No proof means the ordinary GET path remains authoritative.
3415
+ console.error(`sync: unable to verify announced content hash for ${remoteFile.key}; falling back to GET`, headError);
3416
+ }
3417
+ }
3418
+ const kmsHeadResult = prefetchedHeads.get(remoteFile.key);
3419
+ if (kmsHeadResult !== undefined) {
3420
+ if (kmsHeadResult.error !== undefined) {
3421
+ console.error(`sync: unable to verify marked KMS-10 copy for ${remoteFile.key}; falling back to GET`, kmsHeadResult.error);
3422
+ }
3423
+ else {
3424
+ kms10CopyMatchesJournalBase = isKms10CopyOfJournalBase(remoteFile, kmsHeadResult.head, journalEntry.hash, journalEntry.remoteEtag ?? "", remoteFile.key);
3425
+ }
3426
+ }
3427
+ if (listedRevisionMatchesAnnouncement) {
3428
+ items.push({
3429
+ action: "skip-content-match",
3430
+ remoteFile,
3431
+ localPath,
3432
+ contentHash: announcedContentHash,
3433
+ });
3434
+ continue;
3435
+ }
3436
+ if (kms10CopyMatchesJournalBase) {
3437
+ if (localChanged || !journalEntryMatchesStat(journalEntry, localLstat)) {
3438
+ items.push({
3439
+ action: "skip-kms10-copy",
3440
+ remoteFile,
3441
+ localPath,
3442
+ localSnapshot: plannedLocalSnapshot,
3443
+ journalBaseHash: journalEntry.hash,
3444
+ });
3445
+ }
3446
+ else {
3447
+ items.push({
3448
+ action: "skip-content-match",
3449
+ remoteFile,
3450
+ localPath,
3451
+ contentHash: `sha256:${journalEntry.hash}`,
3452
+ });
3453
+ }
3454
+ continue;
3455
+ }
3456
+ // Mirror the original 3-way merge from the inline loop. Tested by
3457
+ // `does NOT flag a pull conflict when only local changed since last
3458
+ // sync` and `detects conflicts with local changes…`.
3459
+ if (localChanged && remoteChanged) {
3426
3460
  items.push({
3427
3461
  action: "conflict",
3428
3462
  remoteFile,
3429
3463
  localPath,
3430
3464
  localHash,
3465
+ // localLstat is non-null inside this `if (localExists)`
3466
+ // branch — the planner already lstat'd the path. Carry
3467
+ // its mtime forward so the executor doesn't have to
3468
+ // re-stat (which would follow links and crash on a
3469
+ // dangling symlink). Same goes for size: the conflict
3470
+ // executor's keep/skip branch needs to stamp the
3471
+ // journal with a size, and statSync on a dangling
3472
+ // symlink throws ENOENT (silently swallowed pre-fix),
3473
+ // leaving the journal stale and re-firing the conflict
3474
+ // every sync.
3431
3475
  localMtime: localLstat.mtime,
3432
- localSize: localLstat.size,
3476
+ localSize: isLocalSymlink ? 0 : localLstat.size,
3433
3477
  localSnapshot: plannedLocalSnapshot,
3434
3478
  });
3435
3479
  continue;
3436
3480
  }
3437
- // Remote genuinely advanced (same-or-larger) → normal catch-up
3438
- // download below.
3439
- }
3440
- // No journal entry, or remote-only changed (non-shrinking) → fall
3441
- // through to download.
3442
- }
3443
- // Suppress the re-download of an intentionally-deleted key (delete-resync).
3444
- // Reaches here only for the `!localExists` case — the `localExists` branch
3445
- // above already handled (and `continue`d) any suppressing tombstone, so a
3446
- // tombstone that survives to here means "remote present, local absent, not a
3447
- // re-create": the classic resurrection ("remote present → I'm behind →
3448
- // download") the planner must NOT do. Route to `tombstone-delete` so the
3449
- // executor drops any stale journal entry and the key stays gone, instead of
3450
- // pulling the deleted object back in. `localTombstoneSuppresses` adds the
3451
- // same protection driven by this machine's own journal tombstone, so an
3452
- // intentional delete is not resurrected even when the server FILE_TOMBSTONE
3453
- // fetch is degraded.
3454
- if (tombstoneSuppresses || localTombstoneSuppresses) {
3455
- // A persisting `local-delete` tombstone (stamped by the intentional-delete
3456
- // producer below) must KEEP its journal entry — the push leg still needs
3457
- // it to propagate the S3 delete. Skip the re-download without dropping the
3458
- // entry. Other tombstone kinds (scope-shrink / FILE_TOMBSTONE) drop it.
3459
- if (journalEntry?.removedReason === "local-delete") {
3481
+ if (journalEntry && localChanged && !remoteChanged) {
3482
+ items.push({ action: "skip-local-only", remoteFile, localPath });
3483
+ continue;
3484
+ }
3485
+ if (journalEntry && !localChanged && !remoteChanged) {
3486
+ // Flavor-health repair (win32 skill farm): a link journaled while its
3487
+ // target was absent was minted file-flavor; once the target directory
3488
+ // exists the hashes still match (hash = sha256(target)) so this
3489
+ // branch would skip it forever, leaving the wrapper broken. Re-download
3490
+ // instead — the materializer's flavor-aware path recreates it 'dir'.
3491
+ if (isLocalSymlink &&
3492
+ remoteFile.key.startsWith(SKILLS_KEY_PREFIX) &&
3493
+ win32SymlinkFlavorBroken(localPath)) {
3494
+ items.push({
3495
+ action: "download",
3496
+ remoteFile,
3497
+ localPath,
3498
+ isNew: false,
3499
+ localSnapshot: plannedLocalSnapshot,
3500
+ });
3501
+ continue;
3502
+ }
3503
+ items.push({ action: "skip-unchanged", remoteFile, localPath });
3504
+ continue;
3505
+ }
3506
+ if (journalEntry && !localChanged && remoteChanged) {
3507
+ // ── Anti-rollback guard (silent newer→older overwrite) ───────────
3508
+ // Remote etag moved but local still matches the journal baseline, so
3509
+ // the classic 3-way merge calls this a clean `download` and overwrites
3510
+ // local with remote. That assumes the remote *advanced*. It can also
3511
+ // REGRESS: a second device (or a stale re-push) can replace the S3
3512
+ // object with an OLDER, SMALLER copy. `hasRemoteChanged` is pure-ETag
3513
+ // and cannot tell an advance from a rollback — ListObjectsV2 carries no
3514
+ // content hash, and `lastModified` is the upload time (newer even for
3515
+ // stale content), so neither distinguishes the two. A silent download
3516
+ // then DESTROYS newer local bytes with no conflict surfaced.
3517
+ //
3518
+ // This bit append-only bot chat logs on 2026-06-10, the day
3519
+ // event-driven bidirectional sync (which lets a peer device push to
3520
+ // S3 between this device's pulls) first rolled out: a regressed S3
3521
+ // object truncated freshly-appended lines, undetected by the planner.
3522
+ //
3523
+ // Guard: if the incoming remote object is strictly SMALLER than the
3524
+ // local file we already hold (a size regression on a tracked regular
3525
+ // file), do NOT silently clobber. Route through the conflict path —
3526
+ // the executor's convergence probe fetches the remote once and, on
3527
+ // genuine divergence, PRESERVES local as a `.conflict-*` mirror instead
3528
+ // of overwriting it (and under `--on-conflict abort` halts loudly
3529
+ // rather than losing data). A byte-identical remote can never be
3530
+ // smaller, so this never manufactures a false conflict on a no-op
3531
+ // re-upload. Symlinks are exempt: their on-disk size is not the target
3532
+ // length, so the comparison is meaningless.
3533
+ //
3534
+ // Direction gate: only guard copies this device AUTHORED (last synced
3535
+ // UP). A consumer copy (last synced DOWN) has no local authorship to
3536
+ // protect — the remote is authoritative, so a smaller remote is a
3537
+ // legitimate update (e.g. the gardener rewriting a shorter brief), not a
3538
+ // regression. Without this gate a consumer device floods `.conflict`
3539
+ // mirrors and stalls on stale gardener output forever (the 786-file
3540
+ // pile-up, 2026-06-22). `"up"` and any missing/unknown direction stay
3541
+ // guarded exactly as before — the 2026-06-10 chat-log protection holds.
3542
+ if (journalEntry.direction !== "down" &&
3543
+ !isLocalSymlink &&
3544
+ remoteFile.size < localLstat.size) {
3545
+ items.push({
3546
+ action: "conflict",
3547
+ remoteFile,
3548
+ localPath,
3549
+ localHash,
3550
+ localMtime: localLstat.mtime,
3551
+ localSize: localLstat.size,
3552
+ localSnapshot: plannedLocalSnapshot,
3553
+ });
3554
+ continue;
3555
+ }
3556
+ // Remote genuinely advanced (same-or-larger) → normal catch-up
3557
+ // download below.
3558
+ }
3559
+ // No journal entry, or remote-only changed (non-shrinking) → fall
3560
+ // through to download.
3561
+ }
3562
+ // Suppress the re-download of an intentionally-deleted key (delete-resync).
3563
+ // Reaches here only for the `!localExists` case — the `localExists` branch
3564
+ // above already handled (and `continue`d) any suppressing tombstone, so a
3565
+ // tombstone that survives to here means "remote present, local absent, not a
3566
+ // re-create": the classic resurrection ("remote present → I'm behind →
3567
+ // download") the planner must NOT do. Route to `tombstone-delete` so the
3568
+ // executor drops any stale journal entry and the key stays gone, instead of
3569
+ // pulling the deleted object back in. `localTombstoneSuppresses` adds the
3570
+ // same protection driven by this machine's own journal tombstone, so an
3571
+ // intentional delete is not resurrected even when the server FILE_TOMBSTONE
3572
+ // fetch is degraded.
3573
+ if (tombstoneSuppresses || localTombstoneSuppresses) {
3574
+ // A persisting `local-delete` tombstone (stamped by the intentional-delete
3575
+ // producer below) must KEEP its journal entry — the push leg still needs
3576
+ // it to propagate the S3 delete. Skip the re-download without dropping the
3577
+ // entry. Other tombstone kinds (scope-shrink / FILE_TOMBSTONE) drop it.
3578
+ if (journalEntry?.removedReason === "local-delete") {
3579
+ continue;
3580
+ }
3581
+ items.push({
3582
+ action: "tombstone-delete",
3583
+ remoteFile,
3584
+ localPath,
3585
+ localSnapshot: plannedLocalSnapshot,
3586
+ });
3587
+ continue;
3588
+ }
3589
+ // Intentional-delete producer: a clean, CURRENT (etag matches), non-divergent
3590
+ // file that is MISSING locally is the user deleting a genuinely-synced file.
3591
+ // A watcher intent is the strict proof used by `owned-only` (and `all`).
3592
+ // `currency-gated` additionally admits an intent-less candidate because a
3593
+ // watcherless runner can never mint one; the matching ETag still proves no
3594
+ // peer changed this individual object. The post-loop bulk-asymmetry decision
3595
+ // remains the whole-set guard for BOTH candidate classes. A stale-etag,
3596
+ // divergent, tombstoned, or never-journaled missing file falls through to
3597
+ // the normal download below.
3598
+ if (!localExists &&
3599
+ journalEntry !== undefined &&
3600
+ !isTombstone(journalEntry) &&
3601
+ !journalEntry.localDiverges &&
3602
+ (hasCurrentLocalDeleteIntent(journalEntry) ||
3603
+ propagateDeletePolicy === "currency-gated") &&
3604
+ normalizeEtag(remoteFile.etag) === journalEntry.remoteEtag) {
3605
+ intentionalDeleteCandidates.push({ remoteFile, localPath });
3460
3606
  continue;
3461
3607
  }
3462
3608
  items.push({
3463
- action: "tombstone-delete",
3609
+ action: "download",
3464
3610
  remoteFile,
3465
3611
  localPath,
3612
+ isNew: !localExists,
3466
3613
  localSnapshot: plannedLocalSnapshot,
3467
3614
  });
3468
- continue;
3469
3615
  }
3470
- // Intentional-delete producer: a clean, CURRENT (etag matches), non-divergent
3471
- // file that is MISSING locally is the user deleting a genuinely-synced file.
3472
- // A watcher intent is the strict proof used by `owned-only` (and `all`).
3473
- // `currency-gated` additionally admits an intent-less candidate because a
3474
- // watcherless runner can never mint one; the matching ETag still proves no
3475
- // peer changed this individual object. The post-loop bulk-asymmetry decision
3476
- // remains the whole-set guard for BOTH candidate classes. A stale-etag,
3477
- // divergent, tombstoned, or never-journaled missing file falls through to
3478
- // the normal download below.
3479
- if (!localExists &&
3480
- journalEntry !== undefined &&
3481
- !isTombstone(journalEntry) &&
3482
- !journalEntry.localDiverges &&
3483
- (hasCurrentLocalDeleteIntent(journalEntry) ||
3484
- propagateDeletePolicy === "currency-gated") &&
3485
- normalizeEtag(remoteFile.etag) === journalEntry.remoteEtag) {
3486
- intentionalDeleteCandidates.push({ remoteFile, localPath });
3487
- continue;
3488
- }
3489
- items.push({
3490
- action: "download",
3491
- remoteFile,
3492
- localPath,
3493
- isNew: !localExists,
3494
- localSnapshot: plannedLocalSnapshot,
3495
- });
3496
3616
  }
3497
3617
  dropJournalDecodeWindow();
3498
3618
  if (cooperativeCheckpoint &&