@indigoai-us/hq-cloud 6.14.9 → 6.14.11

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 (45) hide show
  1. package/dist/cli/share.d.ts.map +1 -1
  2. package/dist/cli/share.js +89 -14
  3. package/dist/cli/share.js.map +1 -1
  4. package/dist/cli/share.test.js +131 -3
  5. package/dist/cli/share.test.js.map +1 -1
  6. package/dist/cli/sync.d.ts +1 -1
  7. package/dist/cli/sync.d.ts.map +1 -1
  8. package/dist/cli/sync.js +138 -41
  9. package/dist/cli/sync.js.map +1 -1
  10. package/dist/cli/sync.test.js +271 -25
  11. package/dist/cli/sync.test.js.map +1 -1
  12. package/dist/journal.d.ts +7 -1
  13. package/dist/journal.d.ts.map +1 -1
  14. package/dist/journal.js +22 -1
  15. package/dist/journal.js.map +1 -1
  16. package/dist/personal-vault-exclusions.d.ts +20 -4
  17. package/dist/personal-vault-exclusions.d.ts.map +1 -1
  18. package/dist/personal-vault-exclusions.js +37 -3
  19. package/dist/personal-vault-exclusions.js.map +1 -1
  20. package/dist/personal-vault-exclusions.test.js +19 -0
  21. package/dist/personal-vault-exclusions.test.js.map +1 -1
  22. package/dist/personal-vault.d.ts +6 -0
  23. package/dist/personal-vault.d.ts.map +1 -1
  24. package/dist/personal-vault.js +12 -0
  25. package/dist/personal-vault.js.map +1 -1
  26. package/dist/s3.d.ts +23 -1
  27. package/dist/s3.d.ts.map +1 -1
  28. package/dist/s3.js +74 -3
  29. package/dist/s3.js.map +1 -1
  30. package/dist/s3.test.js +71 -1
  31. package/dist/s3.test.js.map +1 -1
  32. package/dist/types.d.ts +13 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/cli/share.test.ts +176 -3
  36. package/src/cli/share.ts +114 -15
  37. package/src/cli/sync.test.ts +330 -24
  38. package/src/cli/sync.ts +184 -50
  39. package/src/journal.ts +28 -0
  40. package/src/personal-vault-exclusions.test.ts +23 -0
  41. package/src/personal-vault-exclusions.ts +39 -3
  42. package/src/personal-vault.ts +15 -0
  43. package/src/s3.test.ts +91 -0
  44. package/src/s3.ts +88 -2
  45. package/src/types.ts +13 -0
package/src/cli/sync.ts CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  migratePersonalVaultJournal,
50
50
  } from "../journal.js";
51
51
  import { PERSONAL_VAULT_MANIFEST_KEY } from "../personal-vault.js";
52
+ import { isPersonalVaultExcluded } from "../personal-vault-exclusions.js";
52
53
  import {
53
54
  buildScopeShrinkPlan,
54
55
  applyScopeShrink,
@@ -221,7 +222,13 @@ export type SyncProgressEvent =
221
222
  path: string;
222
223
  journalEtag: string;
223
224
  remoteEtag: string;
224
- reason: "stale-etag" | "legacy-no-etag" | "bulk-asymmetry" | "divergent-local";
225
+ reason:
226
+ | "stale-etag"
227
+ | "legacy-no-etag"
228
+ | "bulk-asymmetry"
229
+ | "divergent-local"
230
+ | "missing-delete-intent"
231
+ | "intent-changed";
225
232
  }
226
233
  | {
227
234
  /**
@@ -1202,11 +1209,19 @@ function executeFileTombstoneDelete(
1202
1209
  counters: PullCounters,
1203
1210
  ): void {
1204
1211
  const tombstoneKey = item.remoteFile.key;
1205
- const tombstonePath = resolveContainedVaultPath(run.companyRoot, tombstoneKey);
1206
- if (tombstonePath === null) return;
1212
+ const tombstonePath = item.localPath;
1213
+ if (!isDownloadWritePathStillContained(run.companyRoot, tombstoneKey, tombstonePath)) {
1214
+ run.emit({
1215
+ type: "error",
1216
+ path: tombstoneKey,
1217
+ message: "tombstone-suppress unlink skipped: local parent escaped the sync root",
1218
+ });
1219
+ return;
1220
+ }
1207
1221
  try {
1208
1222
  const lstat = fs.lstatSync(tombstonePath);
1209
- if (tombstoneTargetDiverged(run.journal, tombstoneKey, tombstonePath, lstat)) {
1223
+ if (!matchesLocalSnapshot(tombstonePath, item.localSnapshot)) {
1224
+ reportLocalSnapshotConflict(run, counters, tombstoneKey);
1210
1225
  return;
1211
1226
  }
1212
1227
  if (lstat.isSymbolicLink() || lstat.isFile()) {
@@ -1252,7 +1267,13 @@ async function executeConflictItem(
1252
1267
  // the `.conflict-` mirror loop AND the journal false-stamp (remote etag over
1253
1268
  // divergent local content) that the keep path produced for these files.
1254
1269
  if (isCloudAuthoritative(vaultKeyForLocalPath(run.hqRoot, localPath))) {
1255
- downloadItems.push({ action: "download", remoteFile, localPath, isNew: false });
1270
+ downloadItems.push({
1271
+ action: "download",
1272
+ remoteFile,
1273
+ localPath,
1274
+ isNew: false,
1275
+ localSnapshot: item.localSnapshot,
1276
+ });
1256
1277
  run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
1257
1278
  return null;
1258
1279
  }
@@ -1315,6 +1336,7 @@ async function executeConflictItem(
1315
1336
  "down",
1316
1337
  remoteFile.etag,
1317
1338
  item.localMtime.getTime(),
1339
+ item.localSnapshot.kind === "symlink" ? "symlink" : "file",
1318
1340
  );
1319
1341
  run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
1320
1342
  counters.filesSkipped++;
@@ -1404,6 +1426,7 @@ async function executeConflictItem(
1404
1426
  "down",
1405
1427
  remoteFile.etag,
1406
1428
  item.localMtime.getTime(),
1429
+ item.localSnapshot.kind === "symlink" ? "symlink" : "file",
1407
1430
  );
1408
1431
  // Journal-honesty: we recorded the remote etag so this conflict can't
1409
1432
  // re-fire (#137), but the KEPT local copy diverges from that remote — it
@@ -1421,6 +1444,7 @@ async function executeConflictItem(
1421
1444
  remoteFile,
1422
1445
  localPath,
1423
1446
  isNew: false,
1447
+ localSnapshot: item.localSnapshot,
1424
1448
  });
1425
1449
  return null;
1426
1450
  }
@@ -1494,6 +1518,13 @@ async function downloadOne(
1494
1518
  run.ctx,
1495
1519
  remoteFile.key,
1496
1520
  localPath,
1521
+ {
1522
+ beforeReplace: () => {
1523
+ if (!matchesLocalSnapshot(localPath, downloadItem.localSnapshot)) {
1524
+ throw new LocalSnapshotChangedError(remoteFile.key);
1525
+ }
1526
+ },
1527
+ },
1497
1528
  );
1498
1529
  reportModeWarnings(run, modeWarnings);
1499
1530
  const author = metadata?.["created-by"] ?? null;
@@ -1515,6 +1546,7 @@ async function downloadOne(
1515
1546
  remoteFile.etag,
1516
1547
  localLstat.mtimeMs,
1517
1548
  createdBySub,
1549
+ isLocalSymlink ? "symlink" : "file",
1518
1550
  );
1519
1551
 
1520
1552
  const priorEntry = getEntry(run.journal, remoteFile.key);
@@ -1531,7 +1563,9 @@ async function downloadOne(
1531
1563
  counters.bytesDownloaded += size;
1532
1564
  counters.changedPaths.push(remoteFile.key);
1533
1565
  } catch (err) {
1534
- if (isAccessDenied(err)) {
1566
+ if (err instanceof LocalSnapshotChangedError) {
1567
+ reportLocalSnapshotConflict(run, counters, remoteFile.key);
1568
+ } else if (isAccessDenied(err)) {
1535
1569
  counters.filesSkipped++;
1536
1570
  } else {
1537
1571
  run.emit({
@@ -1622,13 +1656,13 @@ async function verifyPlannedJournalTombstones(
1622
1656
  // never legitimately exist in a company vault, so treating it as verified
1623
1657
  // is safe: the executor cleans the local copy + journal entry instead of
1624
1658
  // 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);
1659
+ const autoVerified: PullPlan["tombstones"] = [];
1660
+ const toHeadVerify: PullPlan["tombstones"] = [];
1661
+ for (const tombstone of plan.tombstones) {
1662
+ if (run.options.personalMode !== true && tombstone.key.startsWith("companies/")) {
1663
+ autoVerified.push(tombstone);
1630
1664
  } else {
1631
- toHeadVerify.push(key);
1665
+ toHeadVerify.push(tombstone);
1632
1666
  }
1633
1667
  }
1634
1668
  plan.tombstones = toHeadVerify;
@@ -1637,22 +1671,22 @@ async function verifyPlannedJournalTombstones(
1637
1671
  return;
1638
1672
  }
1639
1673
 
1640
- await primeObjectTransport(run.ctx, "get", plan.tombstones);
1674
+ await primeObjectTransport(run.ctx, "get", plan.tombstones.map((t) => t.key));
1641
1675
 
1642
1676
  const HEAD_VERIFY_CONCURRENCY = 5;
1643
- const verified: string[] = [];
1677
+ const verified: PullPlan["tombstones"] = [];
1644
1678
  for (let i = 0; i < plan.tombstones.length; i += HEAD_VERIFY_CONCURRENCY) {
1645
1679
  const batch = plan.tombstones.slice(i, i + HEAD_VERIFY_CONCURRENCY);
1646
1680
  const results = await Promise.all(
1647
- batch.map(async (key) => {
1681
+ batch.map(async (tombstone) => {
1648
1682
  try {
1649
- const head = await headRemoteFile(run.ctx, key);
1650
- return head === null ? key : null;
1683
+ const head = await headRemoteFile(run.ctx, tombstone.key);
1684
+ return head === null ? tombstone : null;
1651
1685
  } catch (err) {
1652
1686
  if (isAccessDenied(err)) return null;
1653
1687
  run.emit({
1654
1688
  type: "error",
1655
- path: key,
1689
+ path: tombstone.key,
1656
1690
  message: `tombstone HEAD verify failed (deferring): ${
1657
1691
  err instanceof Error ? err.message : String(err)
1658
1692
  }`,
@@ -1661,8 +1695,8 @@ async function verifyPlannedJournalTombstones(
1661
1695
  }
1662
1696
  }),
1663
1697
  );
1664
- for (const k of results) {
1665
- if (k !== null) verified.push(k);
1698
+ for (const tombstone of results) {
1699
+ if (tombstone !== null) verified.push(tombstone);
1666
1700
  }
1667
1701
  }
1668
1702
  plan.tombstones = [...autoVerified, ...verified];
@@ -1673,13 +1707,21 @@ function executeJournalTombstoneDeletes(
1673
1707
  plan: PullPlan,
1674
1708
  counters: PullCounters,
1675
1709
  ): void {
1676
- for (const key of plan.tombstones) {
1677
- const localPath = resolveContainedVaultPath(run.companyRoot, key);
1678
- if (localPath === null) continue;
1710
+ for (const tombstone of plan.tombstones) {
1711
+ const { key, localPath, localSnapshot } = tombstone;
1712
+ if (!isDownloadWritePathStillContained(run.companyRoot, key, localPath)) {
1713
+ run.emit({
1714
+ type: "error",
1715
+ path: key,
1716
+ message: "tombstone unlink skipped: local parent escaped the sync root",
1717
+ });
1718
+ continue;
1719
+ }
1679
1720
  let removedSomething = false;
1680
1721
  try {
1681
1722
  const lstat = fs.lstatSync(localPath);
1682
- if (tombstoneTargetDiverged(run.journal, key, localPath, lstat)) {
1723
+ if (!matchesLocalSnapshot(localPath, localSnapshot)) {
1724
+ reportLocalSnapshotConflict(run, counters, key);
1683
1725
  continue;
1684
1726
  }
1685
1727
  if (lstat.isSymbolicLink() || lstat.isFile()) {
@@ -1880,29 +1922,64 @@ function isDownloadWritePathStillContained(
1880
1922
  return resolved !== null && path.resolve(resolved) === path.resolve(localPath);
1881
1923
  }
1882
1924
 
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
-
1925
+ function snapshotLocalState(localPath: string): LocalSnapshot | null {
1894
1926
  try {
1927
+ const lstat = fs.lstatSync(localPath);
1895
1928
  if (lstat.isSymbolicLink()) {
1896
- return hashSymlinkTarget(fs.readlinkSync(localPath)) !== journalEntry.hash;
1929
+ return { kind: "symlink", hash: hashSymlinkTarget(fs.readlinkSync(localPath)) };
1897
1930
  }
1898
1931
  if (lstat.isFile()) {
1899
- return hashFile(localPath) !== journalEntry.hash;
1932
+ return { kind: "file", hash: hashFile(localPath) };
1900
1933
  }
1901
- } catch {
1902
- return true;
1934
+ if (lstat.isDirectory()) return { kind: "directory" };
1935
+ return { kind: "other" };
1936
+ } catch (err: unknown) {
1937
+ const code =
1938
+ err && typeof err === "object" && "code" in err
1939
+ ? (err as { code?: string }).code
1940
+ : undefined;
1941
+ return code === "ENOENT" ? { kind: "absent" } : null;
1903
1942
  }
1943
+ }
1944
+
1945
+ function matchesLocalSnapshot(localPath: string, expected: LocalSnapshot): boolean {
1946
+ const actual = snapshotLocalState(localPath);
1947
+ if (actual === null || actual.kind !== expected.kind) return false;
1948
+ return (
1949
+ (actual.kind !== "file" && actual.kind !== "symlink") ||
1950
+ actual.hash === (expected as Extract<LocalSnapshot, { hash: string }>).hash
1951
+ );
1952
+ }
1953
+
1954
+ function hasCurrentLocalDeleteIntent(
1955
+ entry: SyncJournal["files"][string] | undefined,
1956
+ ): boolean {
1957
+ const intent = entry?.localDeleteIntent;
1958
+ return !!(
1959
+ intent &&
1960
+ intent.version === 1 &&
1961
+ entry.remoteEtag &&
1962
+ entry.kind &&
1963
+ intent.remoteEtag === entry.remoteEtag &&
1964
+ intent.localHash === entry.hash &&
1965
+ intent.localKind === entry.kind
1966
+ );
1967
+ }
1904
1968
 
1905
- return false;
1969
+ function reportLocalSnapshotConflict(
1970
+ run: PullRunContext,
1971
+ counters: PullCounters,
1972
+ key: string,
1973
+ ): void {
1974
+ counters.conflicts++;
1975
+ counters.conflictPaths.push(key);
1976
+ run.emit({ type: "conflict", path: key, direction: "pull", resolution: "skip" });
1977
+ }
1978
+
1979
+ class LocalSnapshotChangedError extends Error {
1980
+ constructor(key: string) {
1981
+ super(`local state changed while downloading ${key}; replanning required`);
1982
+ }
1906
1983
  }
1907
1984
 
1908
1985
  /**
@@ -1911,8 +1988,18 @@ function tombstoneTargetDiverged(
1911
1988
  * decide what to do. `localHash` is carried on `conflict` items so the
1912
1989
  * executor can hand it to `resolveConflict` without re-hashing.
1913
1990
  */
1991
+ type LocalSnapshot =
1992
+ | { kind: "absent" | "directory" | "other" }
1993
+ | { kind: "file" | "symlink"; hash: string };
1994
+
1914
1995
  type PullPlanItem =
1915
- | { action: "download"; remoteFile: RemoteFile; localPath: string; isNew: boolean }
1996
+ | {
1997
+ action: "download";
1998
+ remoteFile: RemoteFile;
1999
+ localPath: string;
2000
+ isNew: boolean;
2001
+ localSnapshot: LocalSnapshot;
2002
+ }
1916
2003
  | { action: "skip-ignored"; remoteFile: RemoteFile; localPath: string }
1917
2004
  | { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
1918
2005
  | { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
@@ -1941,7 +2028,12 @@ type PullPlanItem =
1941
2028
  // (keys ABSENT from the LIST, HEAD-verified before delete), these are
1942
2029
  // suppressed purely on the FILE_TOMBSTONE authority and skip HEAD-verify (the
1943
2030
  // remote object is present by definition).
1944
- | { action: "tombstone-delete"; remoteFile: RemoteFile; localPath: string }
2031
+ | {
2032
+ action: "tombstone-delete";
2033
+ remoteFile: RemoteFile;
2034
+ localPath: string;
2035
+ localSnapshot: LocalSnapshot;
2036
+ }
1945
2037
  | {
1946
2038
  action: "conflict";
1947
2039
  remoteFile: RemoteFile;
@@ -1963,6 +2055,7 @@ type PullPlanItem =
1963
2055
  // and silently fail to stamp the journal — leaving the
1964
2056
  // conflict to re-fire forever.
1965
2057
  localSize: number;
2058
+ localSnapshot: LocalSnapshot;
1966
2059
  };
1967
2060
 
1968
2061
  interface PullPlan {
@@ -1984,7 +2077,11 @@ interface PullPlan {
1984
2077
  * propagating the peer's push-side delete cross-machine (Bug #9).
1985
2078
  * Carried on the plan so the executor can iterate without re-walking.
1986
2079
  */
1987
- tombstones: string[];
2080
+ tombstones: Array<{
2081
+ key: string;
2082
+ localPath: string;
2083
+ localSnapshot: LocalSnapshot;
2084
+ }>;
1988
2085
  /**
1989
2086
  * Count of `tombstone-delete` items — remote keys present in the LIST but
1990
2087
  * suppressed by a FILE_TOMBSTONE (delete-resync). Surfaced on the plan event's
@@ -2120,6 +2217,16 @@ function computePullPlan(
2120
2217
  continue;
2121
2218
  }
2122
2219
 
2220
+ // Personal-vault policy must be symmetric across both transfer legs.
2221
+ // The push walker refuses derived/machine-local/sensitive paths, but old
2222
+ // objects can remain in the bucket from clients that predate an exclusion.
2223
+ // Refuse those objects here too so a pull cannot rematerialize them (or,
2224
+ // for generated skill wrappers, fight reindex over a directory link).
2225
+ if (personalMode && isPersonalVaultExcluded(remoteFile.key)) {
2226
+ items.push({ action: "skip-excluded-policy", remoteFile, localPath });
2227
+ continue;
2228
+ }
2229
+
2123
2230
  if (
2124
2231
  personalMode &&
2125
2232
  remoteFile.key.startsWith("companies/") &&
@@ -2221,6 +2328,7 @@ function computePullPlan(
2221
2328
  const localTombstoneSuppresses =
2222
2329
  journalEntry !== undefined &&
2223
2330
  isTombstone(journalEntry) &&
2331
+ hasCurrentLocalDeleteIntent(journalEntry) &&
2224
2332
  journalEntry.removedAt !== undefined &&
2225
2333
  !isRemoteRecreateAfterTombstone(remoteFile, {
2226
2334
  deletedAt: journalEntry.removedAt,
@@ -2239,6 +2347,7 @@ function computePullPlan(
2239
2347
  // dangling link on every run.
2240
2348
  // 3. A regular file: indistinguishable from current behaviour.
2241
2349
  let localLstat: fs.Stats | null = null;
2350
+ let plannedLocalSnapshot: LocalSnapshot = { kind: "absent" };
2242
2351
  let localPathBlockedByFileAncestor = false;
2243
2352
  try {
2244
2353
  localLstat = fs.lstatSync(localPath);
@@ -2345,6 +2454,10 @@ function computePullPlan(
2345
2454
  : hashFile(localPath);
2346
2455
  }
2347
2456
  const localChanged = !!journalEntry && journalEntry.hash !== localHash;
2457
+ plannedLocalSnapshot = {
2458
+ kind: isLocalSymlink ? "symlink" : "file",
2459
+ hash: localHash,
2460
+ };
2348
2461
  const remoteChanged =
2349
2462
  !!journalEntry && hasRemoteChanged(remoteFile, journalEntry);
2350
2463
 
@@ -2367,9 +2480,15 @@ function computePullPlan(
2367
2480
  localHash,
2368
2481
  localMtime: localLstat!.mtime,
2369
2482
  localSize: isLocalSymlink ? 0 : localLstat!.size,
2483
+ localSnapshot: plannedLocalSnapshot,
2370
2484
  });
2371
2485
  } else {
2372
- items.push({ action: "tombstone-delete", remoteFile, localPath });
2486
+ items.push({
2487
+ action: "tombstone-delete",
2488
+ remoteFile,
2489
+ localPath,
2490
+ localSnapshot: plannedLocalSnapshot,
2491
+ });
2373
2492
  }
2374
2493
  continue;
2375
2494
  }
@@ -2395,6 +2514,7 @@ function computePullPlan(
2395
2514
  // every sync.
2396
2515
  localMtime: localLstat!.mtime,
2397
2516
  localSize: isLocalSymlink ? 0 : localLstat!.size,
2517
+ localSnapshot: plannedLocalSnapshot,
2398
2518
  });
2399
2519
  continue;
2400
2520
  }
@@ -2454,6 +2574,7 @@ function computePullPlan(
2454
2574
  localHash,
2455
2575
  localMtime: localLstat!.mtime,
2456
2576
  localSize: localLstat!.size,
2577
+ localSnapshot: plannedLocalSnapshot,
2457
2578
  });
2458
2579
  continue;
2459
2580
  }
@@ -2483,7 +2604,12 @@ function computePullPlan(
2483
2604
  if (journalEntry?.removedReason === "local-delete") {
2484
2605
  continue;
2485
2606
  }
2486
- items.push({ action: "tombstone-delete", remoteFile, localPath });
2607
+ items.push({
2608
+ action: "tombstone-delete",
2609
+ remoteFile,
2610
+ localPath,
2611
+ localSnapshot: plannedLocalSnapshot,
2612
+ });
2487
2613
  continue;
2488
2614
  }
2489
2615
 
@@ -2496,15 +2622,20 @@ function computePullPlan(
2496
2622
  !localExists &&
2497
2623
  journalEntry !== undefined &&
2498
2624
  !isTombstone(journalEntry) &&
2499
- !journalEntry.localDiverges &&
2500
- journalEntry.remoteEtag !== undefined &&
2625
+ hasCurrentLocalDeleteIntent(journalEntry) &&
2501
2626
  normalizeEtag(remoteFile.etag) === journalEntry.remoteEtag
2502
2627
  ) {
2503
2628
  intentionalDeleteCandidates.push({ remoteFile, localPath });
2504
2629
  continue;
2505
2630
  }
2506
2631
 
2507
- items.push({ action: "download", remoteFile, localPath, isNew: !localExists });
2632
+ items.push({
2633
+ action: "download",
2634
+ remoteFile,
2635
+ localPath,
2636
+ isNew: !localExists,
2637
+ localSnapshot: plannedLocalSnapshot,
2638
+ });
2508
2639
  }
2509
2640
 
2510
2641
  // ── Intentional-delete bulk-asymmetry decision (pull-leg producer) ─────────
@@ -2526,6 +2657,7 @@ function computePullPlan(
2526
2657
  remoteFile: cand.remoteFile,
2527
2658
  localPath: cand.localPath,
2528
2659
  isNew: true,
2660
+ localSnapshot: { kind: "absent" },
2529
2661
  });
2530
2662
  } else {
2531
2663
  tombstoneEntry(journal, cand.remoteFile.key, "local-delete");
@@ -2585,7 +2717,7 @@ function computePullPlan(
2585
2717
  // `propagateDeletes` plan in share.ts.
2586
2718
  const remoteKeySet = new Set<string>();
2587
2719
  for (const rf of remoteFiles) remoteKeySet.add(rf.key);
2588
- const tombstones: string[] = [];
2720
+ const tombstones: PullPlan["tombstones"] = [];
2589
2721
  for (const key of Object.keys(journal.files)) {
2590
2722
  // Compare membership in POSIX space. A pre-5.47.2 Windows journal key can
2591
2723
  // carry backslash separators; the remote LIST is always forward-slash, so a
@@ -2665,7 +2797,9 @@ function computePullPlan(
2665
2797
  // we can't read local state, so we can't safely decide.
2666
2798
  if (code !== "ENOENT") continue;
2667
2799
  }
2668
- tombstones.push(key);
2800
+ const localSnapshot = snapshotLocalState(localPath);
2801
+ if (localSnapshot === null) continue;
2802
+ tombstones.push({ key, localPath, localSnapshot });
2669
2803
  }
2670
2804
 
2671
2805
  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";
@@ -183,6 +183,29 @@ describe("OS / build cruft exclusions", () => {
183
183
  });
184
184
  });
185
185
 
186
+ describe("generated namespaced skill-wrapper exclusion", () => {
187
+ it.each([
188
+ ".claude/skills/indigo:call-prep-brief/scripts",
189
+ ".claude/skills/indigo:team-agent-telemetry-report/agents",
190
+ ".claude/skills/indigo%3Astandup-brief/template",
191
+ ".claude/skills/core:review/SKILL.md",
192
+ ])("excludes the derived link-farm path %s", (p) => {
193
+ expect(matchPersonalVaultExclusion(p)?.id).toBe("generated-skill-wrapper");
194
+ });
195
+
196
+ it.each([
197
+ ".claude/skills/deep-plan/SKILL.md",
198
+ "personal/skills/indigo:custom/SKILL.md",
199
+ ".claude/skills/indigo-call-prep/scripts",
200
+ ".claude/skills/:missing-namespace/SKILL.md",
201
+ ".claude/skills/missing-skill:/SKILL.md",
202
+ ])("does not exclude non-wrapper content %s", (p) => {
203
+ expect(matchPersonalVaultExclusion(p)?.id).not.toBe(
204
+ "generated-skill-wrapper",
205
+ );
206
+ });
207
+ });
208
+
186
209
  // ── _testing internals ───────────────────────────────────────────────────────
187
210
 
188
211
  describe("_testing internals", () => {
@@ -29,7 +29,13 @@
29
29
  * handled by `EPHEMERAL_PATH_PATTERN` in share.ts). Same logic
30
30
  * applies: these are local-only safety backups.
31
31
  *
32
- * 5. OS / build cruft. `.DS_Store`, `node_modules/`, `dist/`, `.next/`,
32
+ * 5. Reindex-generated skill wrappers. `.claude/skills/<ns>:<skill>/`
33
+ * (encoded as `<ns>%3A<skill>` on Windows) is a derived link farm over
34
+ * canonical `core/`, `personal/`, package, or company skill sources.
35
+ * Syncing the links duplicates their owners and makes personal pulls
36
+ * fight reindex over directory links.
37
+ *
38
+ * 6. OS / build cruft. `.DS_Store`, `node_modules/`, `dist/`, `.next/`,
33
39
  * `build/`. Universally noise inside HQ (personal scope should not
34
40
  * contain code projects, but defense-in-depth catches anyone who
35
41
  * symlinks a project subtree in).
@@ -43,14 +49,16 @@
43
49
  * Application scope: personal vault only. Company vaults have separate
44
50
  * first-push protection (settings/, data/, workers/, .git/ exclusion in
45
51
  * `src-tauri/src/util/ignore.rs`) and may legitimately ship `output/` or
46
- * `.env*` paths inside their data folders. Wired in `share.ts` only when
47
- * `options.personalMode === true`.
52
+ * `.env*` paths inside their data folders. Wired only when `personalMode`
53
+ * is true.
48
54
  *
49
55
  * Wire-points (parallel to `EPHEMERAL_PATH_PATTERN`):
50
56
  * - `collectFiles` / `walkDir` (push) — wrap `shouldSync` so excluded
51
57
  * relative paths are rejected before upload.
52
58
  * - `computeDeletePlan` (delete) — same `shouldSync` wrap, so journal
53
59
  * entries matching an exclusion are skipped on the delete pass too.
60
+ * - `computePullPlan` (pull) — reject legacy remote objects before any
61
+ * local inspection or download so excluded state cannot rematerialize.
54
62
  */
55
63
 
56
64
  import * as path from "path";
@@ -79,6 +87,7 @@ export interface PersonalVaultExclusion {
79
87
  | "machine-local"
80
88
  | "scratch"
81
89
  | "conflict-mirror"
90
+ | "generated-mirror"
82
91
  | "os-cruft"
83
92
  | "build-output";
84
93
  /**
@@ -104,6 +113,25 @@ function basename(relPath: string): string {
104
113
  return i === -1 ? relPath : relPath.slice(i + 1);
105
114
  }
106
115
 
116
+ /**
117
+ * Reindex owns every namespaced wrapper under `.claude/skills/`. The wrapper
118
+ * segment is `<namespace>:<skill>` on POSIX and `<namespace>%3A<skill>` after
119
+ * the Win32 local-path codec escapes the reserved colon. Require non-empty
120
+ * names on both sides so hand-authored malformed paths are not swept up.
121
+ */
122
+ function isGeneratedSkillWrapperPath(relPath: string): boolean {
123
+ const parts = relPath.split("/");
124
+ if (parts[0] !== ".claude" || parts[1] !== "skills" || parts.length < 3) {
125
+ return false;
126
+ }
127
+ const wrapper = parts[2] ?? "";
128
+ const literalColon = wrapper.indexOf(":");
129
+ if (literalColon > 0 && literalColon < wrapper.length - 1) return true;
130
+
131
+ const encodedColon = wrapper.toUpperCase().indexOf("%3A");
132
+ return encodedColon > 0 && encodedColon + 3 < wrapper.length;
133
+ }
134
+
107
135
  export const PERSONAL_VAULT_DEFAULT_EXCLUSIONS: readonly PersonalVaultExclusion[] = [
108
136
  // ── secrets ───────────────────────────────────────────────────────────
109
137
  {
@@ -175,6 +203,13 @@ export const PERSONAL_VAULT_DEFAULT_EXCLUSIONS: readonly PersonalVaultExclusion[
175
203
  pattern: "**/.hq-conflicts/**",
176
204
  test: (p) => hasSegment(p, ".hq-conflicts"),
177
205
  },
206
+ // ── reindex-generated link farm ───────────────────────────────────────
207
+ {
208
+ id: "generated-skill-wrapper",
209
+ category: "generated-mirror",
210
+ pattern: ".claude/skills/<namespace>:<skill>/**",
211
+ test: (p) => isGeneratedSkillWrapperPath(p),
212
+ },
178
213
  // ── OS / build cruft ──────────────────────────────────────────────────
179
214
  {
180
215
  id: "ds-store",
@@ -285,4 +320,5 @@ export function wrapFilterWithPersonalVaultDefaults(
285
320
  export const _testing = {
286
321
  hasSegment,
287
322
  basename,
323
+ isGeneratedSkillWrapperPath,
288
324
  };
@@ -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 = {},