@indigoai-us/hq-cloud 6.15.31 → 6.15.32

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.
Files changed (125) hide show
  1. package/dist/bandwidth.d.ts +128 -0
  2. package/dist/bandwidth.d.ts.map +1 -0
  3. package/dist/bandwidth.js +254 -0
  4. package/dist/bandwidth.js.map +1 -0
  5. package/dist/bandwidth.test.d.ts +2 -0
  6. package/dist/bandwidth.test.d.ts.map +1 -0
  7. package/dist/bandwidth.test.js +243 -0
  8. package/dist/bandwidth.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  10. package/dist/bin/sync-runner-company.js +13 -24
  11. package/dist/bin/sync-runner-company.js.map +1 -1
  12. package/dist/bin/sync-runner-company.test.js +71 -0
  13. package/dist/bin/sync-runner-company.test.js.map +1 -1
  14. package/dist/bin/sync-runner-watch-loop.d.ts +10 -0
  15. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  16. package/dist/bin/sync-runner-watch-loop.js +181 -16
  17. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  18. package/dist/bin/sync-runner-watch-routes.d.ts +16 -1
  19. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  20. package/dist/bin/sync-runner-watch-routes.js +40 -2
  21. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  22. package/dist/bin/sync-runner-watch-routes.test.js +53 -1
  23. package/dist/bin/sync-runner-watch-routes.test.js.map +1 -1
  24. package/dist/bin/sync-runner.d.ts +15 -3
  25. package/dist/bin/sync-runner.d.ts.map +1 -1
  26. package/dist/bin/sync-runner.js +66 -13
  27. package/dist/bin/sync-runner.js.map +1 -1
  28. package/dist/bin/sync-runner.test.js +433 -13
  29. package/dist/bin/sync-runner.test.js.map +1 -1
  30. package/dist/cli/conflict.d.ts +25 -2
  31. package/dist/cli/conflict.d.ts.map +1 -1
  32. package/dist/cli/conflict.js +29 -4
  33. package/dist/cli/conflict.js.map +1 -1
  34. package/dist/cli/share.d.ts +40 -4
  35. package/dist/cli/share.d.ts.map +1 -1
  36. package/dist/cli/share.js +478 -86
  37. package/dist/cli/share.js.map +1 -1
  38. package/dist/cli/share.test.js +845 -150
  39. package/dist/cli/share.test.js.map +1 -1
  40. package/dist/cli/sync-scope.test.js +147 -18
  41. package/dist/cli/sync-scope.test.js.map +1 -1
  42. package/dist/cli/sync.d.ts +20 -0
  43. package/dist/cli/sync.d.ts.map +1 -1
  44. package/dist/cli/sync.js +261 -29
  45. package/dist/cli/sync.js.map +1 -1
  46. package/dist/cli/sync.test.js +519 -90
  47. package/dist/cli/sync.test.js.map +1 -1
  48. package/dist/journal-delta-writes.test.d.ts +2 -0
  49. package/dist/journal-delta-writes.test.d.ts.map +1 -0
  50. package/dist/journal-delta-writes.test.js +140 -0
  51. package/dist/journal-delta-writes.test.js.map +1 -0
  52. package/dist/journal.d.ts +1 -0
  53. package/dist/journal.d.ts.map +1 -1
  54. package/dist/journal.js +155 -25
  55. package/dist/journal.js.map +1 -1
  56. package/dist/journal.test.js +29 -11
  57. package/dist/journal.test.js.map +1 -1
  58. package/dist/object-io.d.ts +34 -13
  59. package/dist/object-io.d.ts.map +1 -1
  60. package/dist/object-io.js +154 -29
  61. package/dist/object-io.js.map +1 -1
  62. package/dist/operation-lock.d.ts +12 -0
  63. package/dist/operation-lock.d.ts.map +1 -1
  64. package/dist/operation-lock.js +103 -21
  65. package/dist/operation-lock.js.map +1 -1
  66. package/dist/operation-lock.test.js +90 -0
  67. package/dist/operation-lock.test.js.map +1 -1
  68. package/dist/outcome-telemetry.d.ts +7 -1
  69. package/dist/outcome-telemetry.d.ts.map +1 -1
  70. package/dist/outcome-telemetry.js +15 -9
  71. package/dist/outcome-telemetry.js.map +1 -1
  72. package/dist/outcome-telemetry.test.js +15 -0
  73. package/dist/outcome-telemetry.test.js.map +1 -1
  74. package/dist/outposts/client.d.ts +2 -2
  75. package/dist/outposts/client.d.ts.map +1 -1
  76. package/dist/outposts/client.js +4 -3
  77. package/dist/outposts/client.js.map +1 -1
  78. package/dist/outposts/command.d.ts.map +1 -1
  79. package/dist/outposts/command.js +9 -6
  80. package/dist/outposts/command.js.map +1 -1
  81. package/dist/outposts/command.test.js +59 -2
  82. package/dist/outposts/command.test.js.map +1 -1
  83. package/dist/outposts/remote-command.d.ts +8 -8
  84. package/dist/outposts/remote-command.d.ts.map +1 -1
  85. package/dist/outposts/remote-command.js +10 -8
  86. package/dist/outposts/remote-command.js.map +1 -1
  87. package/dist/prefix-coalesce.d.ts +14 -0
  88. package/dist/prefix-coalesce.d.ts.map +1 -1
  89. package/dist/prefix-coalesce.js +29 -0
  90. package/dist/prefix-coalesce.js.map +1 -1
  91. package/dist/prefix-coalesce.test.js +22 -1
  92. package/dist/prefix-coalesce.test.js.map +1 -1
  93. package/dist/skill-telemetry.d.ts +6 -1
  94. package/dist/skill-telemetry.d.ts.map +1 -1
  95. package/dist/skill-telemetry.js +14 -8
  96. package/dist/skill-telemetry.js.map +1 -1
  97. package/dist/skill-telemetry.test.js +43 -0
  98. package/dist/skill-telemetry.test.js.map +1 -1
  99. package/dist/sts-credential-refresh.test.js +87 -2
  100. package/dist/sts-credential-refresh.test.js.map +1 -1
  101. package/dist/sync/state-store.d.ts.map +1 -1
  102. package/dist/sync/state-store.js +20 -1
  103. package/dist/sync/state-store.js.map +1 -1
  104. package/dist/sync/state-store.test.js +64 -0
  105. package/dist/sync/state-store.test.js.map +1 -1
  106. package/dist/sync-core.d.ts.map +1 -1
  107. package/dist/sync-core.js +7 -2
  108. package/dist/sync-core.js.map +1 -1
  109. package/dist/sync-core.test.js +36 -0
  110. package/dist/sync-core.test.js.map +1 -1
  111. package/dist/telemetry.d.ts +30 -6
  112. package/dist/telemetry.d.ts.map +1 -1
  113. package/dist/telemetry.js +331 -135
  114. package/dist/telemetry.js.map +1 -1
  115. package/dist/telemetry.test.js +403 -0
  116. package/dist/telemetry.test.js.map +1 -1
  117. package/dist/types.d.ts +26 -3
  118. package/dist/types.d.ts.map +1 -1
  119. package/dist/watcher.d.ts +76 -0
  120. package/dist/watcher.d.ts.map +1 -1
  121. package/dist/watcher.js +105 -3
  122. package/dist/watcher.js.map +1 -1
  123. package/dist/watcher.test.js +165 -1
  124. package/dist/watcher.test.js.map +1 -1
  125. package/package.json +1 -1
package/dist/cli/sync.js CHANGED
@@ -8,6 +8,7 @@ import * as fs from "fs";
8
8
  import * as path from "path";
9
9
  import { VaultAuthError, VaultClient } from "../vault-client.js";
10
10
  import { readlinkOrNull } from "../lib/readlink-safe.js";
11
+ import { describeError } from "../lib/describe-error.js";
11
12
  import { emitCloudTelemetry, } from "../telemetry-events.js";
12
13
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
13
14
  import { createSyncProgressRecorder } from "../sync-progress.js";
@@ -18,7 +19,8 @@ import { readJournal, writeJournal, hashFile, hashSymlinkTarget, updateEntry, re
18
19
  import { isGeneratedCoreMirrorKey, PERSONAL_VAULT_MANIFEST_KEY, } from "../personal-vault.js";
19
20
  import { isPersonalVaultExcluded } from "../personal-vault-exclusions.js";
20
21
  import { buildScopeShrinkPlan, applyScopeShrink, ScopeShrinkBlockedError, ScopeShrinkLargePruneError, } from "../scope-shrink.js";
21
- import { coalescePrefixes, isCoveredByAny, } from "../prefix-coalesce.js";
22
+ import { coalescePrefixes, intersectPrefixSets, isCoveredByAny, pathToScopePrefix, toScopePrefixEntries, } from "../prefix-coalesce.js";
23
+ import { listRemoteForScope, POST_FILTER_THRESHOLD } from "../remote-pull.js";
22
24
  import { createIgnoreFilter } from "../ignore.js";
23
25
  import { hasRemoteChanged, isAccessDenied, resolveActiveCompany, resolveTransferConcurrency, readHostLoad, } from "../sync-core.js";
24
26
  import { isEphemeralPath, isForbiddenCompanyVaultKey, isMalformedVaultKey } from "./share.js";
@@ -326,7 +328,19 @@ async function buildPullContext(options) {
326
328
  // of the scope-shrink inputs (see `planScopeShrink`). Empty when the caller
327
329
  // passes nothing (legacy behavior: no exclusions).
328
330
  const currentExcludeSet = coalescePrefixes(options.excludePrefixes ?? []);
329
- const remoteFiles = await listRemoteFiles(ctx);
331
+ // Targeted-pull narrowing (`--scope-path`): intersect the requested paths
332
+ // with the durable scope so the LISTED scope can only shrink, never widen.
333
+ // A path may be a file or a directory — we can't know without I/O — so each
334
+ // requested path contributes both its exact and its directory spelling,
335
+ // mirroring `effectivePushPrefixSet` on the push side.
336
+ const requestedPullPrefixes = coalescePrefixes((options.pullPrefixes ?? []).flatMap((p) => [
337
+ pathToScopePrefix(p),
338
+ pathToScopePrefix(p.endsWith("/") ? p : p + "/"),
339
+ ]));
340
+ const listedPrefixSet = requestedPullPrefixes.length === 0
341
+ ? currentPrefixSet
342
+ : intersectPrefixSets(currentPrefixSet, requestedPullPrefixes);
343
+ const remoteFiles = await listRemoteFilesForScope(ctx, syncMode, listedPrefixSet);
330
344
  const fileTombstones = options.personalMode === true
331
345
  ? new Map()
332
346
  : await fetchCompanyTombstones(vaultConfig, ctx.uid);
@@ -346,13 +360,60 @@ async function buildPullContext(options) {
346
360
  remoteFiles,
347
361
  syncMode,
348
362
  currentPrefixSet,
363
+ listedPrefixSet,
349
364
  currentExcludeSet,
350
365
  fileTombstones,
351
366
  downloadsSinceJournalCheckpoint: 0,
352
367
  };
353
368
  }
369
+ /**
370
+ * List the remote objects the current pull can actually materialize.
371
+ *
372
+ * Historically this was ALWAYS a full-bucket `listRemoteFiles(ctx)` — even for
373
+ * a `shared`/`custom` membership whose effective scope is a handful of
374
+ * prefixes — so every pull materialized the entire remote listing (tens of
375
+ * thousands of RemoteFile objects on large vaults) only for `computePullPlan`
376
+ * to discard most of it as `skip-out-of-scope`. Scoped pulls now list ONLY the
377
+ * in-scope prefixes via `listRemoteForScope` (per-prefix ListObjectsV2 calls,
378
+ * bounded parallel, deduped), which bounds the listing's memory to the scope's
379
+ * actual footprint.
380
+ *
381
+ * `all`-mode (an unnarrowed full-vault pull, `listedPrefixSet` `[""]`) keeps
382
+ * the single broad listing: fanning `all` out by top-level prefix would need a
383
+ * delimiter LIST to discover the top-level names first and still union the
384
+ * same total object count in one plan, so it bounds nothing today. (A
385
+ * streaming planner is the real fix for `all` and is out of scope here.)
386
+ *
387
+ * An empty `listedPrefixSet` in a scoped mode means "nothing in scope" —
388
+ * issue zero LIST calls. Consumers already treat an empty listing correctly:
389
+ * the deletion sweep in `computePullPlan` only considers journal keys covered
390
+ * by the LISTED scope, so keys absent because they were never listed can never
391
+ * be mistaken for remote deletes.
392
+ */
393
+ async function listRemoteFilesForScope(ctx, syncMode, listedPrefixSet) {
394
+ const entries = toScopePrefixEntries(listedPrefixSet);
395
+ if (entries.some((e) => e.prefix === ""))
396
+ return listRemoteFiles(ctx);
397
+ if (entries.length === 0) {
398
+ // `all` mode always carries `[""]`, so an empty set only occurs for a
399
+ // scoped mode (empty grants, or a targeted intersection that excluded
400
+ // everything). Defensive `all` branch: never silently narrow a full pull.
401
+ return syncMode === "all" ? listRemoteFiles(ctx) : [];
402
+ }
403
+ return listRemoteForScope({
404
+ ctx,
405
+ scope: {
406
+ companyUid: ctx.uid,
407
+ syncMode,
408
+ prefixSet: listedPrefixSet,
409
+ strategy: listedPrefixSet.length > POST_FILTER_THRESHOLD
410
+ ? "broad-postfilter"
411
+ : "vend-fanout",
412
+ },
413
+ });
414
+ }
354
415
  function planPull(run) {
355
- return computePullPlan(run.remoteFiles, run.journal, run.companyRoot, run.shouldSync, run.options.personalMode === true, run.options.teamSyncedSlugs ?? null, run.currentPrefixSet, run.fileTombstones, run.currentExcludeSet, run.options.onHeartbeat);
416
+ return computePullPlan(run.remoteFiles, run.journal, run.companyRoot, run.shouldSync, run.options.personalMode === true, run.options.teamSyncedSlugs ?? null, run.listedPrefixSet, run.fileTombstones, run.currentExcludeSet, run.options.onHeartbeat);
356
417
  }
357
418
  function emitPullPlan(emit, plan) {
358
419
  emit({
@@ -626,12 +687,13 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
626
687
  // the `.conflict-` mirror loop AND the journal false-stamp (remote etag over
627
688
  // divergent local content) that the keep path produced for these files.
628
689
  if (isCloudAuthoritative(vaultKeyForLocalPath(run.hqRoot, localPath))) {
690
+ const currentSnapshot = snapshotLocalState(localPath);
629
691
  downloadItems.push({
630
692
  action: "download",
631
693
  remoteFile,
632
694
  localPath,
633
695
  isNew: false,
634
- localSnapshot: item.localSnapshot,
696
+ localSnapshot: currentSnapshot ?? item.localSnapshot,
635
697
  });
636
698
  run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
637
699
  return null;
@@ -654,9 +716,24 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
654
716
  }
655
717
  let remoteFetched = false;
656
718
  let converged = false;
719
+ let executionLocal = null;
657
720
  try {
658
721
  const downloaded = await downloadFile(run.ctx, remoteFile.key, conflictAbs);
659
722
  remoteFetched = true;
723
+ executionLocal = readCurrentLocalFileState(localPath);
724
+ if (executionLocal === null) {
725
+ // The path changed kind, disappeared, or kept moving while the remote
726
+ // probe was in flight. Leave both sides untouched and let the next
727
+ // per-file pass classify that new state; a stale conflict is not useful.
728
+ try {
729
+ fs.rmSync(conflictAbs, { force: true });
730
+ }
731
+ catch {
732
+ /* best-effort cleanup */
733
+ }
734
+ counters.filesSkipped++;
735
+ return null;
736
+ }
660
737
  if (fs.lstatSync(conflictAbs).isSymbolicLink()) {
661
738
  const target = readlinkOrNull(conflictAbs);
662
739
  if (target === null) {
@@ -670,11 +747,12 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
670
747
  return null;
671
748
  }
672
749
  else {
673
- converged = hashSymlinkTarget(target) === item.localHash;
750
+ converged = hashSymlinkTarget(target) === executionLocal.hash;
674
751
  }
675
752
  }
676
753
  else {
677
- converged = (downloaded.contentHash ?? hashFile(conflictAbs)) === item.localHash;
754
+ converged =
755
+ (downloaded.contentHash ?? hashFile(conflictAbs)) === executionLocal.hash;
678
756
  }
679
757
  }
680
758
  catch (probeErr) {
@@ -702,6 +780,10 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
702
780
  });
703
781
  }
704
782
  if (converged) {
783
+ if (executionLocal === null) {
784
+ counters.filesSkipped++;
785
+ return null;
786
+ }
705
787
  if (remoteFetched) {
706
788
  try {
707
789
  fs.rmSync(conflictAbs, { force: true });
@@ -710,10 +792,11 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
710
792
  /* best-effort cleanup; a stray identical mirror is harmless */
711
793
  }
712
794
  }
713
- updateEntry(run.journal, remoteFile.key, item.localHash, item.localSize, "down", localPath, {
795
+ updateEntry(run.journal, remoteFile.key, executionLocal.hash, executionLocal.size, "down", localPath, {
714
796
  remoteEtag: remoteFile.etag,
715
- mtimeMs: item.localMtime.getTime(),
716
- kind: item.localSnapshot.kind === "symlink" ? "symlink" : "file",
797
+ mtimeMs: executionLocal.mtimeMs,
798
+ ctimeMs: executionLocal.ctimeMs,
799
+ kind: executionLocal.kind,
717
800
  });
718
801
  run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
719
802
  counters.filesSkipped++;
@@ -721,11 +804,20 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
721
804
  }
722
805
  counters.conflicts++;
723
806
  counters.conflictPaths.push(remoteFile.key);
807
+ // A successful probe always decides from the local bytes captured after the
808
+ // GET. Only a failed probe falls back to the preview snapshot so the existing
809
+ // error-path behavior remains conservative.
810
+ const localHash = executionLocal?.hash ?? item.localHash;
811
+ const localSize = executionLocal?.size ?? item.localSize;
812
+ const localMtime = executionLocal?.mtime ?? item.localMtime;
813
+ const localSnapshot = executionLocal?.snapshot ?? item.localSnapshot;
814
+ const localKind = executionLocal?.kind ??
815
+ (item.localSnapshot.kind === "symlink" ? "symlink" : "file");
724
816
  const resolution = await resolveConflict({
725
817
  path: remoteFile.key,
726
- localHash: item.localHash,
818
+ localHash,
727
819
  remoteModified: remoteFile.lastModified,
728
- localModified: item.localMtime,
820
+ localModified: localMtime,
729
821
  direction: "pull",
730
822
  }, run.options.onConflict);
731
823
  run.emit({
@@ -734,7 +826,9 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
734
826
  direction: "pull",
735
827
  resolution,
736
828
  });
737
- if (resolution !== "abort" && resolution !== "overwrite") {
829
+ if (resolution !== "abort" &&
830
+ resolution !== "overwrite" &&
831
+ resolution !== "keep") {
738
832
  if (remoteFetched) {
739
833
  try {
740
834
  appendConflictEntry(run.hqRoot, {
@@ -744,8 +838,12 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
744
838
  detectedAt,
745
839
  side: "pull",
746
840
  machineId,
747
- localHash: item.localHash,
841
+ localHash,
748
842
  remoteHash: remoteFile.etag ? normalizeEtag(remoteFile.etag) : "",
843
+ // The holding resolutions leave local alone and this sidecar keeps
844
+ // the cloud copy. `keep` is indexed only after its local rename
845
+ // succeeds below, so this row can never describe the wrong side.
846
+ preserved: "remote",
749
847
  });
750
848
  }
751
849
  catch (mirrorErr) {
@@ -757,7 +855,7 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
757
855
  }
758
856
  }
759
857
  }
760
- else if (remoteFetched) {
858
+ else if (resolution !== "keep" && remoteFetched) {
761
859
  try {
762
860
  fs.rmSync(conflictAbs, { force: true });
763
861
  }
@@ -785,22 +883,103 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
785
883
  changedPaths: [...counters.changedPaths, ...scopeRun.changedPaths],
786
884
  };
787
885
  }
788
- if (resolution === "keep" || resolution === "skip") {
886
+ // Hold the local body where it is, recording the remote etag so the conflict
887
+ // cannot re-fire (#137) and arming the journal-honesty flag because the held
888
+ // copy never matched that etag. The flag stops the currency-gated delete
889
+ // planner propagating a delete whose currency would falsely match on HEAD.
890
+ // Any genuine future download clears it by replacing the entry.
891
+ const holdLocal = () => {
789
892
  counters.filesSkipped++;
790
- updateEntry(run.journal, remoteFile.key, item.localHash, item.localSize, "down", localPath, {
893
+ updateEntry(run.journal, remoteFile.key, localHash, localSize, "down", localPath, {
791
894
  remoteEtag: remoteFile.etag,
792
- mtimeMs: item.localMtime.getTime(),
793
- kind: item.localSnapshot.kind === "symlink" ? "symlink" : "file",
895
+ mtimeMs: localMtime.getTime(),
896
+ kind: localKind,
897
+ });
898
+ const heldEntry = getEntry(run.journal, remoteFile.key);
899
+ if (heldEntry)
900
+ heldEntry.localDiverges = true;
901
+ return null;
902
+ };
903
+ // `publish-local` is a PUSH-leg strategy: it means "send my body up". On the
904
+ // pull leg there is nothing to publish, so it holds the local body and arms
905
+ // the honesty flag, which is what the push leg then promotes under an
906
+ // If-Match fence. Falling through to the download branch instead would
907
+ // overwrite the very local copy the operator elected, which is the data loss
908
+ // this strategy exists to prevent.
909
+ //
910
+ // `skip` is the interactive "leave this one, I will deal with it later". A
911
+ // resolution that says leave it alone may not rename the operator's file, so
912
+ // it holds too — it is deliberately NOT bundled with `keep` any more.
913
+ if (resolution === "skip" || resolution === "publish-local") {
914
+ return holdLocal();
915
+ }
916
+ // `keep` ADOPTS the cloud body as the working copy and preserves the local
917
+ // one beside it under the `.conflict-` name.
918
+ //
919
+ // It used to mean the opposite — hold local, stamp the remote etag, arm
920
+ // `localDiverges` — and that combination was terminal. The push leg refuses
921
+ // to send a body the journal calls divergent; the pull leg sees a remote
922
+ // whose etag already matches and leaves it alone; and every later `keep`
923
+ // re-armed the flag. The file converged in neither direction, and because the
924
+ // desktop app hardcodes `--on-conflict keep` that was the default outcome for
925
+ // every real conflict. Adopting the remote clears the divergence by
926
+ // construction: the entry then describes the body that is actually on disk.
927
+ // The local bytes are not destroyed, only renamed.
928
+ if (resolution === "keep") {
929
+ if (!remoteFetched) {
930
+ // The probe never produced a body (its failure is already emitted above).
931
+ // Hold rather than move aside a file we have nothing to replace it with.
932
+ return holdLocal();
933
+ }
934
+ try {
935
+ // The sidecar currently holds the fetched probe copy of the remote.
936
+ // Remove it explicitly before renaming: POSIX rename replaces a file at
937
+ // the destination, but Windows rename rejects an occupied destination.
938
+ // The download queued below re-fetches it. One extra GET buys the entire
939
+ // tested download path —
940
+ // symlink materialization, path codec, mtime and etag journalling —
941
+ // instead of a hand-rolled three-way swap with its own crash window.
942
+ fs.rmSync(conflictAbs, { force: true });
943
+ fs.renameSync(localPath, conflictAbs);
944
+ }
945
+ catch (preserveErr) {
946
+ run.emit({
947
+ type: "error",
948
+ path: remoteFile.key,
949
+ message: `conflict keep could not preserve the local copy: ${preserveErr instanceof Error ? preserveErr.message : String(preserveErr)}`,
950
+ });
951
+ return holdLocal();
952
+ }
953
+ try {
954
+ appendConflictEntry(run.hqRoot, {
955
+ id: buildConflictId(originalRelative, detectedAt),
956
+ originalPath: originalRelative,
957
+ conflictPath: conflictRelative,
958
+ detectedAt,
959
+ side: "pull",
960
+ machineId,
961
+ localHash,
962
+ remoteHash: remoteFile.etag ? normalizeEtag(remoteFile.etag) : "",
963
+ preserved: "local",
964
+ });
965
+ }
966
+ catch (mirrorErr) {
967
+ run.emit({
968
+ type: "error",
969
+ path: remoteFile.key,
970
+ message: `conflict mirror index write failed: ${mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)}`,
971
+ });
972
+ }
973
+ downloadItems.push({
974
+ action: "download",
975
+ remoteFile,
976
+ localPath,
977
+ isNew: false,
978
+ // The local body was just moved aside, so the pre-replace guard must
979
+ // expect absence. Passing the original snapshot would make that guard
980
+ // abort the very download it exists to protect.
981
+ localSnapshot: { kind: "absent" },
794
982
  });
795
- // Journal-honesty: we recorded the remote etag so this conflict can't
796
- // re-fire (#137), but the KEPT local copy diverges from that remote — it
797
- // never matched it. Flag the entry so the currency-gated delete planner
798
- // refuses to propagate a delete for it (its currency would falsely match on
799
- // HEAD, and the delete would destroy the divergent remote version). Any
800
- // genuine future download clears the flag by replacing the entry.
801
- const keptEntry = getEntry(run.journal, remoteFile.key);
802
- if (keptEntry)
803
- keptEntry.localDiverges = true;
804
983
  return null;
805
984
  }
806
985
  downloadItems.push({
@@ -808,7 +987,7 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
808
987
  remoteFile,
809
988
  localPath,
810
989
  isNew: false,
811
- localSnapshot: item.localSnapshot,
990
+ localSnapshot,
812
991
  });
813
992
  return null;
814
993
  }
@@ -891,6 +1070,12 @@ async function downloadOne(run, downloadItem, counters) {
891
1070
  updateEntry(run.journal, remoteFile.key, hash, size, "down", localPath, {
892
1071
  remoteEtag: remoteFile.etag,
893
1072
  mtimeMs: localLstat.mtimeMs,
1073
+ // Stamped from the lstat already in hand so the next push can skip this
1074
+ // file on stat alone. The two conflict-resolution `updateEntry` calls
1075
+ // above deliberately do NOT stamp ctime: neither has a post-write stat
1076
+ // available, and a `keep`/`publish-local` entry is marked
1077
+ // `localDiverges` immediately after, which the fast path refuses anyway.
1078
+ ctimeMs: localLstat.ctimeMs,
894
1079
  ...(createdBySub !== undefined ? { createdBySub } : {}),
895
1080
  kind: isLocalSymlink ? "symlink" : "file",
896
1081
  });
@@ -952,7 +1137,7 @@ async function downloadOne(run, downloadItem, counters) {
952
1137
  run.emit({
953
1138
  type: "error",
954
1139
  path: remoteFile.key,
955
- message: err instanceof Error ? err.message : String(err),
1140
+ message: describeError(err),
956
1141
  });
957
1142
  }
958
1143
  return;
@@ -1185,6 +1370,12 @@ function finalizePullRun(run, plan, scopeRun, counters) {
1185
1370
  * -files loop this event exists to end. `SyncResult.filesOutOfScope` keeps its
1186
1371
  * original both-causes meaning; only this advisory is narrowed.
1187
1372
  */
1373
+ // NOTE: since scoped pulls list only their in-scope prefixes (see
1374
+ // `listRemoteFilesForScope`), out-of-scope remote keys usually never enter the
1375
+ // plan and this event fires only for keys a broad-postfilter listing (or a
1376
+ // legacy caller passing a full listing) still surfaced. The withheld-count
1377
+ // telemetry traded away here is the cost of not materializing the full remote
1378
+ // listing on every scoped pull.
1188
1379
  function emitScopeMaterializationGap(run, plan) {
1189
1380
  if (run.syncMode === "all")
1190
1381
  return;
@@ -1314,6 +1505,36 @@ function matchesLocalSnapshot(localPath, expected) {
1314
1505
  return ((actual.kind !== "file" && actual.kind !== "symlink") ||
1315
1506
  actual.hash === expected.hash);
1316
1507
  }
1508
+ /**
1509
+ * Read the local side at the execution seam, not from the whole-run preview.
1510
+ * A second hash check closes the read-to-stat window: when an active writer
1511
+ * moves the file while we inspect it, the caller defers this path instead of
1512
+ * manufacturing a decision from a mixed snapshot.
1513
+ */
1514
+ function readCurrentLocalFileState(localPath) {
1515
+ const snapshot = snapshotLocalState(localPath);
1516
+ if (snapshot === null || (snapshot.kind !== "file" && snapshot.kind !== "symlink")) {
1517
+ return null;
1518
+ }
1519
+ let lstat;
1520
+ try {
1521
+ lstat = fs.lstatSync(localPath);
1522
+ }
1523
+ catch {
1524
+ return null;
1525
+ }
1526
+ if (!matchesLocalSnapshot(localPath, snapshot))
1527
+ return null;
1528
+ return {
1529
+ hash: snapshot.hash,
1530
+ size: snapshot.kind === "symlink" ? 0 : lstat.size,
1531
+ mtime: lstat.mtime,
1532
+ mtimeMs: lstat.mtimeMs,
1533
+ ctimeMs: lstat.ctimeMs,
1534
+ kind: snapshot.kind,
1535
+ snapshot,
1536
+ };
1537
+ }
1317
1538
  function hasCurrentLocalDeleteIntent(entry) {
1318
1539
  const intent = entry?.localDeleteIntent;
1319
1540
  return !!(intent &&
@@ -2133,6 +2354,17 @@ excludePrefixes = [], onHeartbeat) {
2133
2354
  const posixKey = toPosixKey(key);
2134
2355
  if (remoteKeySet.has(posixKey))
2135
2356
  continue;
2357
+ // Listing-scope guard (targeted sync / scoped listing). The remote LIST
2358
+ // now covers ONLY `prefixSet` (scoped modes list per-prefix; a targeted
2359
+ // pull narrows further), so "absent from the LIST" is evidence of a remote
2360
+ // delete ONLY for keys the LIST could have contained. A journal key
2361
+ // outside the listed scope was simply never asked about — treating it as
2362
+ // remote-deleted would mass-delete every out-of-scope local file on the
2363
+ // first scoped pull. Out-of-scope journal entries stay the scope-shrink
2364
+ // pass's responsibility (quarantine semantics), never this sweep's.
2365
+ // `all` mode passes `[""]`, which covers every key — no behavior change.
2366
+ if (!isCoveredByAny(posixKey, prefixSet))
2367
+ continue;
2136
2368
  if (posixKey.startsWith(SKILLS_KEY_PREFIX) &&
2137
2369
  remoteCanonicalSkillKeys.has(canonicalVaultKeySpelling(posixKey)) &&
2138
2370
  // An EXACT FILE_TOMBSTONE for the journaled key is an authoritative