@indigoai-us/hq-cloud 6.14.9 → 6.14.10

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/src/cli/sync.ts CHANGED
@@ -221,7 +221,13 @@ export type SyncProgressEvent =
221
221
  path: string;
222
222
  journalEtag: string;
223
223
  remoteEtag: string;
224
- reason: "stale-etag" | "legacy-no-etag" | "bulk-asymmetry" | "divergent-local";
224
+ reason:
225
+ | "stale-etag"
226
+ | "legacy-no-etag"
227
+ | "bulk-asymmetry"
228
+ | "divergent-local"
229
+ | "missing-delete-intent"
230
+ | "intent-changed";
225
231
  }
226
232
  | {
227
233
  /**
@@ -1202,11 +1208,19 @@ function executeFileTombstoneDelete(
1202
1208
  counters: PullCounters,
1203
1209
  ): void {
1204
1210
  const tombstoneKey = item.remoteFile.key;
1205
- const tombstonePath = resolveContainedVaultPath(run.companyRoot, tombstoneKey);
1206
- if (tombstonePath === null) return;
1211
+ const tombstonePath = item.localPath;
1212
+ if (!isDownloadWritePathStillContained(run.companyRoot, tombstoneKey, tombstonePath)) {
1213
+ run.emit({
1214
+ type: "error",
1215
+ path: tombstoneKey,
1216
+ message: "tombstone-suppress unlink skipped: local parent escaped the sync root",
1217
+ });
1218
+ return;
1219
+ }
1207
1220
  try {
1208
1221
  const lstat = fs.lstatSync(tombstonePath);
1209
- if (tombstoneTargetDiverged(run.journal, tombstoneKey, tombstonePath, lstat)) {
1222
+ if (!matchesLocalSnapshot(tombstonePath, item.localSnapshot)) {
1223
+ reportLocalSnapshotConflict(run, counters, tombstoneKey);
1210
1224
  return;
1211
1225
  }
1212
1226
  if (lstat.isSymbolicLink() || lstat.isFile()) {
@@ -1252,7 +1266,13 @@ async function executeConflictItem(
1252
1266
  // the `.conflict-` mirror loop AND the journal false-stamp (remote etag over
1253
1267
  // divergent local content) that the keep path produced for these files.
1254
1268
  if (isCloudAuthoritative(vaultKeyForLocalPath(run.hqRoot, localPath))) {
1255
- downloadItems.push({ action: "download", remoteFile, localPath, isNew: false });
1269
+ downloadItems.push({
1270
+ action: "download",
1271
+ remoteFile,
1272
+ localPath,
1273
+ isNew: false,
1274
+ localSnapshot: item.localSnapshot,
1275
+ });
1256
1276
  run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
1257
1277
  return null;
1258
1278
  }
@@ -1315,6 +1335,7 @@ async function executeConflictItem(
1315
1335
  "down",
1316
1336
  remoteFile.etag,
1317
1337
  item.localMtime.getTime(),
1338
+ item.localSnapshot.kind === "symlink" ? "symlink" : "file",
1318
1339
  );
1319
1340
  run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
1320
1341
  counters.filesSkipped++;
@@ -1404,6 +1425,7 @@ async function executeConflictItem(
1404
1425
  "down",
1405
1426
  remoteFile.etag,
1406
1427
  item.localMtime.getTime(),
1428
+ item.localSnapshot.kind === "symlink" ? "symlink" : "file",
1407
1429
  );
1408
1430
  // Journal-honesty: we recorded the remote etag so this conflict can't
1409
1431
  // re-fire (#137), but the KEPT local copy diverges from that remote — it
@@ -1421,6 +1443,7 @@ async function executeConflictItem(
1421
1443
  remoteFile,
1422
1444
  localPath,
1423
1445
  isNew: false,
1446
+ localSnapshot: item.localSnapshot,
1424
1447
  });
1425
1448
  return null;
1426
1449
  }
@@ -1494,6 +1517,13 @@ async function downloadOne(
1494
1517
  run.ctx,
1495
1518
  remoteFile.key,
1496
1519
  localPath,
1520
+ {
1521
+ beforeReplace: () => {
1522
+ if (!matchesLocalSnapshot(localPath, downloadItem.localSnapshot)) {
1523
+ throw new LocalSnapshotChangedError(remoteFile.key);
1524
+ }
1525
+ },
1526
+ },
1497
1527
  );
1498
1528
  reportModeWarnings(run, modeWarnings);
1499
1529
  const author = metadata?.["created-by"] ?? null;
@@ -1515,6 +1545,7 @@ async function downloadOne(
1515
1545
  remoteFile.etag,
1516
1546
  localLstat.mtimeMs,
1517
1547
  createdBySub,
1548
+ isLocalSymlink ? "symlink" : "file",
1518
1549
  );
1519
1550
 
1520
1551
  const priorEntry = getEntry(run.journal, remoteFile.key);
@@ -1531,7 +1562,9 @@ async function downloadOne(
1531
1562
  counters.bytesDownloaded += size;
1532
1563
  counters.changedPaths.push(remoteFile.key);
1533
1564
  } catch (err) {
1534
- if (isAccessDenied(err)) {
1565
+ if (err instanceof LocalSnapshotChangedError) {
1566
+ reportLocalSnapshotConflict(run, counters, remoteFile.key);
1567
+ } else if (isAccessDenied(err)) {
1535
1568
  counters.filesSkipped++;
1536
1569
  } else {
1537
1570
  run.emit({
@@ -1622,13 +1655,13 @@ async function verifyPlannedJournalTombstones(
1622
1655
  // never legitimately exist in a company vault, so treating it as verified
1623
1656
  // is safe: the executor cleans the local copy + journal entry instead of
1624
1657
  // the push leg ever re-uploading it (tombstone-not-reupload).
1625
- const autoVerified: string[] = [];
1626
- const toHeadVerify: string[] = [];
1627
- for (const key of plan.tombstones) {
1628
- if (run.options.personalMode !== true && key.startsWith("companies/")) {
1629
- autoVerified.push(key);
1658
+ const autoVerified: PullPlan["tombstones"] = [];
1659
+ const toHeadVerify: PullPlan["tombstones"] = [];
1660
+ for (const tombstone of plan.tombstones) {
1661
+ if (run.options.personalMode !== true && tombstone.key.startsWith("companies/")) {
1662
+ autoVerified.push(tombstone);
1630
1663
  } else {
1631
- toHeadVerify.push(key);
1664
+ toHeadVerify.push(tombstone);
1632
1665
  }
1633
1666
  }
1634
1667
  plan.tombstones = toHeadVerify;
@@ -1637,22 +1670,22 @@ async function verifyPlannedJournalTombstones(
1637
1670
  return;
1638
1671
  }
1639
1672
 
1640
- await primeObjectTransport(run.ctx, "get", plan.tombstones);
1673
+ await primeObjectTransport(run.ctx, "get", plan.tombstones.map((t) => t.key));
1641
1674
 
1642
1675
  const HEAD_VERIFY_CONCURRENCY = 5;
1643
- const verified: string[] = [];
1676
+ const verified: PullPlan["tombstones"] = [];
1644
1677
  for (let i = 0; i < plan.tombstones.length; i += HEAD_VERIFY_CONCURRENCY) {
1645
1678
  const batch = plan.tombstones.slice(i, i + HEAD_VERIFY_CONCURRENCY);
1646
1679
  const results = await Promise.all(
1647
- batch.map(async (key) => {
1680
+ batch.map(async (tombstone) => {
1648
1681
  try {
1649
- const head = await headRemoteFile(run.ctx, key);
1650
- return head === null ? key : null;
1682
+ const head = await headRemoteFile(run.ctx, tombstone.key);
1683
+ return head === null ? tombstone : null;
1651
1684
  } catch (err) {
1652
1685
  if (isAccessDenied(err)) return null;
1653
1686
  run.emit({
1654
1687
  type: "error",
1655
- path: key,
1688
+ path: tombstone.key,
1656
1689
  message: `tombstone HEAD verify failed (deferring): ${
1657
1690
  err instanceof Error ? err.message : String(err)
1658
1691
  }`,
@@ -1661,8 +1694,8 @@ async function verifyPlannedJournalTombstones(
1661
1694
  }
1662
1695
  }),
1663
1696
  );
1664
- for (const k of results) {
1665
- if (k !== null) verified.push(k);
1697
+ for (const tombstone of results) {
1698
+ if (tombstone !== null) verified.push(tombstone);
1666
1699
  }
1667
1700
  }
1668
1701
  plan.tombstones = [...autoVerified, ...verified];
@@ -1673,13 +1706,21 @@ function executeJournalTombstoneDeletes(
1673
1706
  plan: PullPlan,
1674
1707
  counters: PullCounters,
1675
1708
  ): void {
1676
- for (const key of plan.tombstones) {
1677
- const localPath = resolveContainedVaultPath(run.companyRoot, key);
1678
- if (localPath === null) continue;
1709
+ for (const tombstone of plan.tombstones) {
1710
+ const { key, localPath, localSnapshot } = tombstone;
1711
+ if (!isDownloadWritePathStillContained(run.companyRoot, key, localPath)) {
1712
+ run.emit({
1713
+ type: "error",
1714
+ path: key,
1715
+ message: "tombstone unlink skipped: local parent escaped the sync root",
1716
+ });
1717
+ continue;
1718
+ }
1679
1719
  let removedSomething = false;
1680
1720
  try {
1681
1721
  const lstat = fs.lstatSync(localPath);
1682
- if (tombstoneTargetDiverged(run.journal, key, localPath, lstat)) {
1722
+ if (!matchesLocalSnapshot(localPath, localSnapshot)) {
1723
+ reportLocalSnapshotConflict(run, counters, key);
1683
1724
  continue;
1684
1725
  }
1685
1726
  if (lstat.isSymbolicLink() || lstat.isFile()) {
@@ -1880,29 +1921,64 @@ function isDownloadWritePathStillContained(
1880
1921
  return resolved !== null && path.resolve(resolved) === path.resolve(localPath);
1881
1922
  }
1882
1923
 
1883
- function tombstoneTargetDiverged(
1884
- journal: SyncJournal,
1885
- key: string,
1886
- localPath: string,
1887
- lstat: fs.Stats,
1888
- ): boolean {
1889
- const journalEntry = journal.files[key];
1890
- if (!journalEntry?.hash) {
1891
- return lstat.isSymbolicLink() || lstat.isFile();
1892
- }
1893
-
1924
+ function snapshotLocalState(localPath: string): LocalSnapshot | null {
1894
1925
  try {
1926
+ const lstat = fs.lstatSync(localPath);
1895
1927
  if (lstat.isSymbolicLink()) {
1896
- return hashSymlinkTarget(fs.readlinkSync(localPath)) !== journalEntry.hash;
1928
+ return { kind: "symlink", hash: hashSymlinkTarget(fs.readlinkSync(localPath)) };
1897
1929
  }
1898
1930
  if (lstat.isFile()) {
1899
- return hashFile(localPath) !== journalEntry.hash;
1931
+ return { kind: "file", hash: hashFile(localPath) };
1900
1932
  }
1901
- } catch {
1902
- return true;
1933
+ if (lstat.isDirectory()) return { kind: "directory" };
1934
+ return { kind: "other" };
1935
+ } catch (err: unknown) {
1936
+ const code =
1937
+ err && typeof err === "object" && "code" in err
1938
+ ? (err as { code?: string }).code
1939
+ : undefined;
1940
+ return code === "ENOENT" ? { kind: "absent" } : null;
1903
1941
  }
1942
+ }
1943
+
1944
+ function matchesLocalSnapshot(localPath: string, expected: LocalSnapshot): boolean {
1945
+ const actual = snapshotLocalState(localPath);
1946
+ if (actual === null || actual.kind !== expected.kind) return false;
1947
+ return (
1948
+ (actual.kind !== "file" && actual.kind !== "symlink") ||
1949
+ actual.hash === (expected as Extract<LocalSnapshot, { hash: string }>).hash
1950
+ );
1951
+ }
1952
+
1953
+ function hasCurrentLocalDeleteIntent(
1954
+ entry: SyncJournal["files"][string] | undefined,
1955
+ ): boolean {
1956
+ const intent = entry?.localDeleteIntent;
1957
+ return !!(
1958
+ intent &&
1959
+ intent.version === 1 &&
1960
+ entry.remoteEtag &&
1961
+ entry.kind &&
1962
+ intent.remoteEtag === entry.remoteEtag &&
1963
+ intent.localHash === entry.hash &&
1964
+ intent.localKind === entry.kind
1965
+ );
1966
+ }
1967
+
1968
+ function reportLocalSnapshotConflict(
1969
+ run: PullRunContext,
1970
+ counters: PullCounters,
1971
+ key: string,
1972
+ ): void {
1973
+ counters.conflicts++;
1974
+ counters.conflictPaths.push(key);
1975
+ run.emit({ type: "conflict", path: key, direction: "pull", resolution: "skip" });
1976
+ }
1904
1977
 
1905
- return false;
1978
+ class LocalSnapshotChangedError extends Error {
1979
+ constructor(key: string) {
1980
+ super(`local state changed while downloading ${key}; replanning required`);
1981
+ }
1906
1982
  }
1907
1983
 
1908
1984
  /**
@@ -1911,8 +1987,18 @@ function tombstoneTargetDiverged(
1911
1987
  * decide what to do. `localHash` is carried on `conflict` items so the
1912
1988
  * executor can hand it to `resolveConflict` without re-hashing.
1913
1989
  */
1990
+ type LocalSnapshot =
1991
+ | { kind: "absent" | "directory" | "other" }
1992
+ | { kind: "file" | "symlink"; hash: string };
1993
+
1914
1994
  type PullPlanItem =
1915
- | { action: "download"; remoteFile: RemoteFile; localPath: string; isNew: boolean }
1995
+ | {
1996
+ action: "download";
1997
+ remoteFile: RemoteFile;
1998
+ localPath: string;
1999
+ isNew: boolean;
2000
+ localSnapshot: LocalSnapshot;
2001
+ }
1916
2002
  | { action: "skip-ignored"; remoteFile: RemoteFile; localPath: string }
1917
2003
  | { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
1918
2004
  | { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
@@ -1941,7 +2027,12 @@ type PullPlanItem =
1941
2027
  // (keys ABSENT from the LIST, HEAD-verified before delete), these are
1942
2028
  // suppressed purely on the FILE_TOMBSTONE authority and skip HEAD-verify (the
1943
2029
  // remote object is present by definition).
1944
- | { action: "tombstone-delete"; remoteFile: RemoteFile; localPath: string }
2030
+ | {
2031
+ action: "tombstone-delete";
2032
+ remoteFile: RemoteFile;
2033
+ localPath: string;
2034
+ localSnapshot: LocalSnapshot;
2035
+ }
1945
2036
  | {
1946
2037
  action: "conflict";
1947
2038
  remoteFile: RemoteFile;
@@ -1963,6 +2054,7 @@ type PullPlanItem =
1963
2054
  // and silently fail to stamp the journal — leaving the
1964
2055
  // conflict to re-fire forever.
1965
2056
  localSize: number;
2057
+ localSnapshot: LocalSnapshot;
1966
2058
  };
1967
2059
 
1968
2060
  interface PullPlan {
@@ -1984,7 +2076,11 @@ interface PullPlan {
1984
2076
  * propagating the peer's push-side delete cross-machine (Bug #9).
1985
2077
  * Carried on the plan so the executor can iterate without re-walking.
1986
2078
  */
1987
- tombstones: string[];
2079
+ tombstones: Array<{
2080
+ key: string;
2081
+ localPath: string;
2082
+ localSnapshot: LocalSnapshot;
2083
+ }>;
1988
2084
  /**
1989
2085
  * Count of `tombstone-delete` items — remote keys present in the LIST but
1990
2086
  * suppressed by a FILE_TOMBSTONE (delete-resync). Surfaced on the plan event's
@@ -2221,6 +2317,7 @@ function computePullPlan(
2221
2317
  const localTombstoneSuppresses =
2222
2318
  journalEntry !== undefined &&
2223
2319
  isTombstone(journalEntry) &&
2320
+ hasCurrentLocalDeleteIntent(journalEntry) &&
2224
2321
  journalEntry.removedAt !== undefined &&
2225
2322
  !isRemoteRecreateAfterTombstone(remoteFile, {
2226
2323
  deletedAt: journalEntry.removedAt,
@@ -2239,6 +2336,7 @@ function computePullPlan(
2239
2336
  // dangling link on every run.
2240
2337
  // 3. A regular file: indistinguishable from current behaviour.
2241
2338
  let localLstat: fs.Stats | null = null;
2339
+ let plannedLocalSnapshot: LocalSnapshot = { kind: "absent" };
2242
2340
  let localPathBlockedByFileAncestor = false;
2243
2341
  try {
2244
2342
  localLstat = fs.lstatSync(localPath);
@@ -2345,6 +2443,10 @@ function computePullPlan(
2345
2443
  : hashFile(localPath);
2346
2444
  }
2347
2445
  const localChanged = !!journalEntry && journalEntry.hash !== localHash;
2446
+ plannedLocalSnapshot = {
2447
+ kind: isLocalSymlink ? "symlink" : "file",
2448
+ hash: localHash,
2449
+ };
2348
2450
  const remoteChanged =
2349
2451
  !!journalEntry && hasRemoteChanged(remoteFile, journalEntry);
2350
2452
 
@@ -2367,9 +2469,15 @@ function computePullPlan(
2367
2469
  localHash,
2368
2470
  localMtime: localLstat!.mtime,
2369
2471
  localSize: isLocalSymlink ? 0 : localLstat!.size,
2472
+ localSnapshot: plannedLocalSnapshot,
2370
2473
  });
2371
2474
  } else {
2372
- items.push({ action: "tombstone-delete", remoteFile, localPath });
2475
+ items.push({
2476
+ action: "tombstone-delete",
2477
+ remoteFile,
2478
+ localPath,
2479
+ localSnapshot: plannedLocalSnapshot,
2480
+ });
2373
2481
  }
2374
2482
  continue;
2375
2483
  }
@@ -2395,6 +2503,7 @@ function computePullPlan(
2395
2503
  // every sync.
2396
2504
  localMtime: localLstat!.mtime,
2397
2505
  localSize: isLocalSymlink ? 0 : localLstat!.size,
2506
+ localSnapshot: plannedLocalSnapshot,
2398
2507
  });
2399
2508
  continue;
2400
2509
  }
@@ -2454,6 +2563,7 @@ function computePullPlan(
2454
2563
  localHash,
2455
2564
  localMtime: localLstat!.mtime,
2456
2565
  localSize: localLstat!.size,
2566
+ localSnapshot: plannedLocalSnapshot,
2457
2567
  });
2458
2568
  continue;
2459
2569
  }
@@ -2483,7 +2593,12 @@ function computePullPlan(
2483
2593
  if (journalEntry?.removedReason === "local-delete") {
2484
2594
  continue;
2485
2595
  }
2486
- items.push({ action: "tombstone-delete", remoteFile, localPath });
2596
+ items.push({
2597
+ action: "tombstone-delete",
2598
+ remoteFile,
2599
+ localPath,
2600
+ localSnapshot: plannedLocalSnapshot,
2601
+ });
2487
2602
  continue;
2488
2603
  }
2489
2604
 
@@ -2496,15 +2611,20 @@ function computePullPlan(
2496
2611
  !localExists &&
2497
2612
  journalEntry !== undefined &&
2498
2613
  !isTombstone(journalEntry) &&
2499
- !journalEntry.localDiverges &&
2500
- journalEntry.remoteEtag !== undefined &&
2614
+ hasCurrentLocalDeleteIntent(journalEntry) &&
2501
2615
  normalizeEtag(remoteFile.etag) === journalEntry.remoteEtag
2502
2616
  ) {
2503
2617
  intentionalDeleteCandidates.push({ remoteFile, localPath });
2504
2618
  continue;
2505
2619
  }
2506
2620
 
2507
- items.push({ action: "download", remoteFile, localPath, isNew: !localExists });
2621
+ items.push({
2622
+ action: "download",
2623
+ remoteFile,
2624
+ localPath,
2625
+ isNew: !localExists,
2626
+ localSnapshot: plannedLocalSnapshot,
2627
+ });
2508
2628
  }
2509
2629
 
2510
2630
  // ── Intentional-delete bulk-asymmetry decision (pull-leg producer) ─────────
@@ -2526,6 +2646,7 @@ function computePullPlan(
2526
2646
  remoteFile: cand.remoteFile,
2527
2647
  localPath: cand.localPath,
2528
2648
  isNew: true,
2649
+ localSnapshot: { kind: "absent" },
2529
2650
  });
2530
2651
  } else {
2531
2652
  tombstoneEntry(journal, cand.remoteFile.key, "local-delete");
@@ -2585,7 +2706,7 @@ function computePullPlan(
2585
2706
  // `propagateDeletes` plan in share.ts.
2586
2707
  const remoteKeySet = new Set<string>();
2587
2708
  for (const rf of remoteFiles) remoteKeySet.add(rf.key);
2588
- const tombstones: string[] = [];
2709
+ const tombstones: PullPlan["tombstones"] = [];
2589
2710
  for (const key of Object.keys(journal.files)) {
2590
2711
  // Compare membership in POSIX space. A pre-5.47.2 Windows journal key can
2591
2712
  // carry backslash separators; the remote LIST is always forward-slash, so a
@@ -2665,7 +2786,9 @@ function computePullPlan(
2665
2786
  // we can't read local state, so we can't safely decide.
2666
2787
  if (code !== "ENOENT") continue;
2667
2788
  }
2668
- tombstones.push(key);
2789
+ const localSnapshot = snapshotLocalState(localPath);
2790
+ if (localSnapshot === null) continue;
2791
+ tombstones.push({ key, localPath, localSnapshot });
2669
2792
  }
2670
2793
 
2671
2794
  return {
package/src/journal.ts CHANGED
@@ -425,6 +425,7 @@ export function updateEntry(
425
425
  remoteEtag?: string,
426
426
  mtimeMs?: number,
427
427
  createdBySub?: string,
428
+ kind?: "file" | "symlink",
428
429
  ): void {
429
430
  const entry: JournalEntry = {
430
431
  hash,
@@ -444,6 +445,9 @@ export function updateEntry(
444
445
  if (createdBySub !== undefined && createdBySub !== "") {
445
446
  entry.createdBySub = createdBySub;
446
447
  }
448
+ if (kind !== undefined) {
449
+ entry.kind = kind;
450
+ }
447
451
  journal.files[relativePath] = entry;
448
452
  journal.lastSync = new Date().toISOString();
449
453
  }
@@ -593,6 +597,30 @@ export function tombstoneEntry(
593
597
  entry.removedReason = reason;
594
598
  }
595
599
 
600
+ /**
601
+ * Mark a journal entry for a user-authorized local delete. Callers must
602
+ * capture the local hash and kind before removing the object; an absent path
603
+ * alone is never deletion intent.
604
+ */
605
+ export function markLocalDeleteIntent(
606
+ journal: SyncJournal,
607
+ relativePath: string,
608
+ localHash: string,
609
+ localKind: "file" | "symlink",
610
+ ): boolean {
611
+ const entry = journal.files[relativePath];
612
+ if (!entry?.remoteEtag || entry.hash !== localHash || entry.kind !== localKind) {
613
+ return false;
614
+ }
615
+ entry.localDeleteIntent = {
616
+ version: 1,
617
+ remoteEtag: entry.remoteEtag,
618
+ localHash,
619
+ localKind,
620
+ };
621
+ return true;
622
+ }
623
+
596
624
  /** True if the entry is a tombstone (set by `tombstoneEntry`). */
597
625
  export function isTombstone(entry: JournalEntry | undefined): boolean {
598
626
  return !!entry && typeof entry.removedAt === "string";
@@ -105,6 +105,21 @@ export interface PersonalVaultOptions {
105
105
  */
106
106
  export const PERSONAL_VAULT_MANIFEST_KEY = "companies/manifest.yaml";
107
107
 
108
+ /**
109
+ * Release-owned scaffold and reindex-generated wrappers must never be
110
+ * delete-propagated from the personal vault. They are recreated locally by a
111
+ * release or `hq reindex`, so an ordinary local absence is not user intent.
112
+ */
113
+ export function isPersonalVaultDeletionExcluded(key: string): boolean {
114
+ const normalized = key.split("\\").join("/").replace(/^\.\//, "");
115
+ return (
116
+ normalized === "core" ||
117
+ normalized.startsWith("core/") ||
118
+ normalized === ".claude/skills" ||
119
+ normalized.startsWith(".claude/skills/")
120
+ );
121
+ }
122
+
108
123
  export function computePersonalVaultPaths(
109
124
  hqRoot: string,
110
125
  opts: PersonalVaultOptions = {},
package/src/s3.ts CHANGED
@@ -809,6 +809,7 @@ export async function downloadFile(
809
809
  ctx: EntityContext,
810
810
  key: string,
811
811
  localPath: string,
812
+ options: { beforeReplace?: () => void } = {},
812
813
  ): Promise<{
813
814
  metadata?: Record<string, string>;
814
815
  contentHash?: string;
@@ -902,6 +903,7 @@ export async function downloadFile(
902
903
  const tempPath = downloadTempPath(localPath);
903
904
  try {
904
905
  fs.symlinkSync(symlinkTarget, tempPath);
906
+ options.beforeReplace?.();
905
907
  fs.renameSync(tempPath, localPath);
906
908
  } catch (err) {
907
909
  removeTempPath(tempPath);
@@ -1011,6 +1013,7 @@ export async function downloadFile(
1011
1013
  }
1012
1014
  }
1013
1015
 
1016
+ options.beforeReplace?.();
1014
1017
  fs.renameSync(tempPath, localPath);
1015
1018
  tempReady = false;
1016
1019
  } finally {
package/src/types.ts CHANGED
@@ -26,6 +26,8 @@ export interface JournalEntry {
26
26
  size: number;
27
27
  syncedAt: string;
28
28
  direction: "up" | "down";
29
+ /** Kind of local object represented by this entry. */
30
+ kind?: "file" | "symlink";
29
31
  /**
30
32
  * Cognito `sub` of the file's author, captured from the object's
31
33
  * `created-by-sub` S3 user-metadata at download time (zero extra network —
@@ -72,6 +74,17 @@ export interface JournalEntry {
72
74
  */
73
75
  removedAt?: string;
74
76
  removedReason?: "scope_shrink" | "narrow_apply" | "manual" | "local-delete";
77
+ /**
78
+ * Explicit local-delete intent. Unlike the legacy `removedReason` marker,
79
+ * this is bound to the exact synced remote revision and local object shape
80
+ * that the user approved for deletion.
81
+ */
82
+ localDeleteIntent?: {
83
+ version: 1;
84
+ remoteEtag: string;
85
+ localHash: string;
86
+ localKind: "file" | "symlink";
87
+ };
75
88
  /**
76
89
  * Durable automatic-pull retention marker. Set when a scope shrink keeps an
77
90
  * out-of-scope caller-authored or unknown-author entry on disk instead of