@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
@@ -71,6 +71,19 @@ function makeEntityContext(overrides: Partial<EntityContext> = {}): EntityContex
71
71
  };
72
72
  }
73
73
 
74
+ function deleteIntent(
75
+ remoteEtag: string,
76
+ localHash: string,
77
+ localKind: "file" | "symlink" = "file",
78
+ ) {
79
+ return {
80
+ version: 1 as const,
81
+ remoteEtag,
82
+ localHash,
83
+ localKind,
84
+ };
85
+ }
86
+
74
87
  describe("wrapFilterWithIgnoreVisibility", () => {
75
88
  it("records noteworthy base-ignore exclusions while counting all rejections", () => {
76
89
  const hqRoot = path.join("/tmp", "hqroot");
@@ -1950,6 +1963,12 @@ describe("share", () => {
1950
1963
  syncedAt: new Date().toISOString(),
1951
1964
  direction: "up",
1952
1965
  remoteEtag: "knowledge-etag",
1966
+ kind: "symlink",
1967
+ localDeleteIntent: deleteIntent(
1968
+ "knowledge-etag",
1969
+ "irrelevant-not-checked",
1970
+ "symlink",
1971
+ ),
1953
1972
  },
1954
1973
  },
1955
1974
  }),
@@ -2112,6 +2131,53 @@ describe("share", () => {
2112
2131
  },
2113
2132
  );
2114
2133
 
2134
+ it("personalMode excludes generated namespaced skill wrappers while preserving local skills", async () => {
2135
+ const generatedSource = path.join(
2136
+ tmpDir,
2137
+ "companies",
2138
+ "acme",
2139
+ "skills",
2140
+ "demo",
2141
+ "scripts",
2142
+ );
2143
+ const generatedWrapper = path.join(
2144
+ tmpDir,
2145
+ ".claude",
2146
+ "skills",
2147
+ "acme:demo",
2148
+ );
2149
+ const localSkill = path.join(tmpDir, ".claude", "skills", "local-skill");
2150
+ fs.mkdirSync(generatedSource, { recursive: true });
2151
+ fs.writeFileSync(path.join(generatedSource, "run.sh"), "#!/bin/sh\n");
2152
+ fs.mkdirSync(generatedWrapper, { recursive: true });
2153
+ fs.symlinkSync(generatedSource, path.join(generatedWrapper, "scripts"), "dir");
2154
+ fs.mkdirSync(localSkill, { recursive: true });
2155
+ fs.writeFileSync(path.join(localSkill, "SKILL.md"), "# local\n");
2156
+
2157
+ const result = await share({
2158
+ paths: [path.join(tmpDir, ".claude")],
2159
+ entityContext: makeEntityContext({
2160
+ uid: "prs_01PERSONALTEST",
2161
+ slug: "__hq_personal_vault__",
2162
+ bucketName: "hq-vault-prs-01PERSONALTEST",
2163
+ }),
2164
+ hqRoot: tmpDir,
2165
+ personalMode: true,
2166
+ journalSlug: "personal-generated-wrapper",
2167
+ });
2168
+
2169
+ expect(result.filesUploaded).toBe(1);
2170
+ expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
2171
+ expect(uploadSymlink).not.toHaveBeenCalled();
2172
+ expect(uploadFile).toHaveBeenCalledWith(
2173
+ expect.anything(),
2174
+ path.join(localSkill, "SKILL.md"),
2175
+ ".claude/skills/local-skill/SKILL.md",
2176
+ undefined,
2177
+ expect.anything(),
2178
+ );
2179
+ });
2180
+
2115
2181
  it(
2116
2182
  "personal-vault override does NOT apply to company vaults: `owned-only` on cmp_ entity still " +
2117
2183
  "refuses to tombstone a direction:'down' entry (multi-user curation intact)",
@@ -2189,6 +2255,11 @@ describe("share", () => {
2189
2255
  syncedAt: new Date().toISOString(),
2190
2256
  direction: "up",
2191
2257
  remoteEtag: "gone-etag",
2258
+ kind: "file",
2259
+ localDeleteIntent: deleteIntent(
2260
+ "gone-etag",
2261
+ "irrelevant-not-checked-here",
2262
+ ),
2192
2263
  },
2193
2264
  },
2194
2265
  }),
@@ -2231,6 +2302,9 @@ describe("share", () => {
2231
2302
  size: 42,
2232
2303
  syncedAt: new Date().toISOString(),
2233
2304
  direction: "up",
2305
+ remoteEtag: "removed-etag",
2306
+ kind: "file",
2307
+ localDeleteIntent: deleteIntent("removed-etag", "h"),
2234
2308
  },
2235
2309
  },
2236
2310
  }),
@@ -2318,6 +2392,9 @@ describe("share", () => {
2318
2392
  size: 1,
2319
2393
  syncedAt: new Date().toISOString(),
2320
2394
  direction: "up",
2395
+ remoteEtag: "in-scope-etag",
2396
+ kind: "file",
2397
+ localDeleteIntent: deleteIntent("in-scope-etag", "h"),
2321
2398
  },
2322
2399
  "other/also-gone.md": {
2323
2400
  hash: "h",
@@ -2371,6 +2448,9 @@ describe("share", () => {
2371
2448
  size: 5,
2372
2449
  syncedAt: new Date().toISOString(),
2373
2450
  direction: "up",
2451
+ remoteEtag: "flaky-etag",
2452
+ kind: "file",
2453
+ localDeleteIntent: deleteIntent("flaky-etag", "h"),
2374
2454
  },
2375
2455
  },
2376
2456
  }),
@@ -2446,6 +2526,9 @@ describe("share", () => {
2446
2526
  "i-uploaded.md": {
2447
2527
  hash: "h", size: 1, syncedAt: new Date().toISOString(),
2448
2528
  direction: "up",
2529
+ remoteEtag: "uploaded-etag",
2530
+ kind: "file",
2531
+ localDeleteIntent: deleteIntent("uploaded-etag", "h"),
2449
2532
  },
2450
2533
  },
2451
2534
  }),
@@ -2482,6 +2565,9 @@ describe("share", () => {
2482
2565
  "pulled.md": {
2483
2566
  hash: "h", size: 1, syncedAt: new Date().toISOString(),
2484
2567
  direction: "down",
2568
+ remoteEtag: "pulled-etag",
2569
+ kind: "file",
2570
+ localDeleteIntent: deleteIntent("pulled-etag", "h"),
2485
2571
  },
2486
2572
  },
2487
2573
  }),
@@ -2526,6 +2612,9 @@ describe("share", () => {
2526
2612
  "active/current.md": {
2527
2613
  hash: "h", size: 1, syncedAt: new Date().toISOString(),
2528
2614
  direction: "up",
2615
+ remoteEtag: "active-etag",
2616
+ kind: "file",
2617
+ localDeleteIntent: deleteIntent("active-etag", "h"),
2529
2618
  },
2530
2619
  },
2531
2620
  }),
@@ -2586,6 +2675,8 @@ describe("share", () => {
2586
2675
  hash: "h", size: 100, syncedAt: new Date().toISOString(),
2587
2676
  direction: "down",
2588
2677
  remoteEtag: "abc123",
2678
+ kind: "file",
2679
+ localDeleteIntent: deleteIntent("abc123", "h"),
2589
2680
  },
2590
2681
  },
2591
2682
  }),
@@ -2637,6 +2728,8 @@ describe("share", () => {
2637
2728
  syncedAt: new Date().toISOString(),
2638
2729
  direction: "down",
2639
2730
  remoteEtag: "abc123",
2731
+ kind: "file",
2732
+ localDeleteIntent: deleteIntent("abc123", "h"),
2640
2733
  removedAt: new Date().toISOString(),
2641
2734
  removedReason: "local-delete",
2642
2735
  },
@@ -2688,6 +2781,8 @@ describe("share", () => {
2688
2781
  hash: "h", size: 50, syncedAt: new Date().toISOString(),
2689
2782
  direction: "down",
2690
2783
  remoteEtag: "stale-etag",
2784
+ kind: "file",
2785
+ localDeleteIntent: deleteIntent("stale-etag", "h"),
2691
2786
  },
2692
2787
  },
2693
2788
  }),
@@ -2733,6 +2828,64 @@ describe("share", () => {
2733
2828
  expect(result.filesTombstoned).toBe(0);
2734
2829
  });
2735
2830
 
2831
+ it("currency-gated: preserves a file recreated after the delete plan", async () => {
2832
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2833
+ fs.mkdirSync(companyRoot, { recursive: true });
2834
+ const recreatedPath = path.join(companyRoot, "racy.md");
2835
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
2836
+ fs.writeFileSync(
2837
+ journalPath,
2838
+ JSON.stringify({
2839
+ version: "2",
2840
+ lastSync: new Date().toISOString(),
2841
+ files: {
2842
+ "racy.md": {
2843
+ hash: "synced-hash",
2844
+ size: 10,
2845
+ syncedAt: new Date().toISOString(),
2846
+ direction: "up",
2847
+ remoteEtag: "racy-etag",
2848
+ kind: "file",
2849
+ localDeleteIntent: deleteIntent("racy-etag", "synced-hash"),
2850
+ },
2851
+ },
2852
+ pulls: [],
2853
+ }),
2854
+ );
2855
+
2856
+ // The currency HEAD establishes the remote revision, then a local writer
2857
+ // recreates the file before the planned DeleteObject can run.
2858
+ vi.mocked(headRemoteFile).mockImplementationOnce(async () => {
2859
+ fs.writeFileSync(recreatedPath, "recreated after planning");
2860
+ return { lastModified: new Date(), etag: '"racy-etag"', size: 10 };
2861
+ });
2862
+
2863
+ const events: Array<{ type: string; path?: string; reason?: string }> = [];
2864
+ const result = await share({
2865
+ paths: [companyRoot],
2866
+ company: "acme",
2867
+ vaultConfig: mockConfig,
2868
+ hqRoot: tmpDir,
2869
+ skipUnchanged: true,
2870
+ propagateDeletes: true,
2871
+ propagateDeletePolicy: "currency-gated",
2872
+ onEvent: (e) => events.push(e as { type: string }),
2873
+ });
2874
+
2875
+ expect(result.filesDeleted).toBe(0);
2876
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
2877
+ expect(fs.readFileSync(recreatedPath, "utf-8")).toBe("recreated after planning");
2878
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2879
+ expect(journal.files["racy.md"]).toBeDefined();
2880
+ expect(events).toContainEqual(
2881
+ expect.objectContaining({
2882
+ type: "delete-refused-stale-etag",
2883
+ path: "racy.md",
2884
+ reason: "intent-changed",
2885
+ }),
2886
+ );
2887
+ });
2888
+
2736
2889
  it("currency-gated: refuses delete for a divergent-local entry (journal-honesty — never delete a remote the local never genuinely matched)", async () => {
2737
2890
  const companyRoot = path.join(tmpDir, "companies", "acme");
2738
2891
  fs.mkdirSync(companyRoot, { recursive: true });
@@ -2814,6 +2967,8 @@ describe("share", () => {
2814
2967
  hash: "h", size: 25, syncedAt: new Date().toISOString(),
2815
2968
  direction: "down",
2816
2969
  remoteEtag: "doesnt-matter",
2970
+ kind: "file",
2971
+ localDeleteIntent: deleteIntent("doesnt-matter", "h"),
2817
2972
  },
2818
2973
  },
2819
2974
  }),
@@ -2894,12 +3049,13 @@ describe("share", () => {
2894
3049
 
2895
3050
  expect(result.filesDeleted).toBe(0);
2896
3051
  expect(deleteRemoteFile).not.toHaveBeenCalled();
2897
- // HEAD must NOT be called — we short-circuit on the missing journal etag.
3052
+ // HEAD must NOT be called — the explicit-intent gate rejects legacy
3053
+ // entries before any remote operation.
2898
3054
  expect(headRemoteFile).not.toHaveBeenCalled();
2899
3055
  const refused = events.find((e) => e.type === "delete-refused-stale-etag");
2900
3056
  expect(refused).toMatchObject({
2901
- journalEtag: "<legacy-no-etag>",
2902
- reason: "legacy-no-etag",
3057
+ journalEtag: "<missing-delete-intent>",
3058
+ reason: "missing-delete-intent",
2903
3059
  });
2904
3060
  expect(result.filesRefusedStale).toBe(1);
2905
3061
  });
@@ -2925,6 +3081,8 @@ describe("share", () => {
2925
3081
  hash: "h", size: 200, syncedAt: new Date().toISOString(),
2926
3082
  direction: "down",
2927
3083
  remoteEtag: "upstream-etag",
3084
+ kind: "file",
3085
+ localDeleteIntent: deleteIntent("upstream-etag", "h"),
2928
3086
  },
2929
3087
  },
2930
3088
  }),
@@ -3059,6 +3217,8 @@ describe("share", () => {
3059
3217
  hash: "h", size: 5, syncedAt: new Date().toISOString(),
3060
3218
  direction: "up",
3061
3219
  remoteEtag: "regular-etag",
3220
+ kind: "file",
3221
+ localDeleteIntent: deleteIntent("regular-etag", "h"),
3062
3222
  },
3063
3223
  },
3064
3224
  }),
@@ -3385,6 +3545,8 @@ describe("share", () => {
3385
3545
  hash: "h", size: 1, syncedAt: new Date().toISOString(),
3386
3546
  direction: "up",
3387
3547
  remoteEtag: "abc",
3548
+ kind: "file",
3549
+ localDeleteIntent: deleteIntent("abc", "h"),
3388
3550
  },
3389
3551
  },
3390
3552
  }),
@@ -3892,6 +4054,8 @@ describe("share", () => {
3892
4054
  syncedAt: string;
3893
4055
  direction: "up" | "down";
3894
4056
  remoteEtag: string;
4057
+ kind: "file";
4058
+ localDeleteIntent: ReturnType<typeof deleteIntent>;
3895
4059
  }
3896
4060
  > = {};
3897
4061
  for (let i = 0; i < total; i++) {
@@ -3902,6 +4066,8 @@ describe("share", () => {
3902
4066
  syncedAt: new Date().toISOString(),
3903
4067
  direction,
3904
4068
  remoteEtag: `etag-${i}`,
4069
+ kind: "file",
4070
+ localDeleteIntent: deleteIntent(`etag-${i}`, "h"),
3905
4071
  };
3906
4072
  if (i >= missing) {
3907
4073
  fs.writeFileSync(path.join(companyRoot, key), "hi");
@@ -4135,17 +4301,22 @@ describe("share", () => {
4135
4301
  const files: Record<string, {
4136
4302
  hash: string; size: number; syncedAt: string;
4137
4303
  direction: "up" | "down"; remoteEtag: string;
4304
+ kind: "file"; localDeleteIntent: ReturnType<typeof deleteIntent>;
4138
4305
  }> = {};
4139
4306
  const now = new Date().toISOString();
4140
4307
  for (let i = 0; i < 50; i++) {
4141
4308
  files[`d-${i.toString().padStart(4, "0")}.md`] = {
4142
4309
  hash: "h", size: 5, syncedAt: now,
4143
4310
  direction: "down", remoteEtag: `down-etag-${i}`,
4311
+ kind: "file",
4312
+ localDeleteIntent: deleteIntent(`down-etag-${i}`, "h"),
4144
4313
  };
4145
4314
  }
4146
4315
  files["only-up.md"] = {
4147
4316
  hash: "h", size: 5, syncedAt: now,
4148
4317
  direction: "up", remoteEtag: "up-etag",
4318
+ kind: "file",
4319
+ localDeleteIntent: deleteIntent("up-etag", "h"),
4149
4320
  };
4150
4321
  const journalPath = path.join(stateDir, "sync-journal.acme.json");
4151
4322
  fs.writeFileSync(journalPath, JSON.stringify({ version: "1", lastSync: now, files }));
@@ -4293,6 +4464,8 @@ describe("currency-gated: journal version 2 fixtures", () => {
4293
4464
  syncedAt: new Date().toISOString(),
4294
4465
  direction: "down",
4295
4466
  remoteEtag: "v2-etag",
4467
+ kind: "file",
4468
+ localDeleteIntent: deleteIntent("v2-etag", "h"),
4296
4469
  },
4297
4470
  },
4298
4471
  pulls: [],
package/src/cli/share.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  wrapFilterWithPersonalVaultDefaults,
42
42
  type PersonalVaultExclusion,
43
43
  } from "../personal-vault-exclusions.js";
44
+ import { isPersonalVaultDeletionExcluded } from "../personal-vault.js";
44
45
  import { resolveConflict } from "./conflict.js";
45
46
  import type { ConflictStrategy } from "./conflict.js";
46
47
  import type { SyncProgressEvent } from "./sync.js";
@@ -1101,7 +1102,7 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
1101
1102
  run.journal,
1102
1103
  run.options.decommissionPrefixes ?? [],
1103
1104
  run.propagateDeletePolicy,
1104
- new Set([...deletePlan.toDelete, ...deletePlan.toTombstone]),
1105
+ new Set([...deletePlan.toDelete.map((item) => item.key), ...deletePlan.toTombstone]),
1105
1106
  )
1106
1107
  : [];
1107
1108
 
@@ -1240,7 +1241,11 @@ async function executeUploads(
1240
1241
  // second HQ root that shares this machine's sync journal) would otherwise read as
1241
1242
  // `localChanged && !remoteChanged`, slip past the conflict-scoped guard below, and
1242
1243
  // rewind the server's value — the `.last-run` watermark clobber. Skip before HEAD.
1243
- if (isCloudAuthoritative(relativePath)) {
1244
+ // board.json is the one push-side exception: local board appends are
1245
+ // legitimate, but must flow through the conditional PUT fence below. It
1246
+ // remains cloud-authoritative on pull so server automation still wins a
1247
+ // pull conflict.
1248
+ if (relativePath !== "board.json" && isCloudAuthoritative(relativePath)) {
1244
1249
  run.emit({ type: "reconciled", path: relativePath, direction: "push" });
1245
1250
  counters.filesSkipped++;
1246
1251
  return;
@@ -1293,6 +1298,8 @@ async function executeUploads(
1293
1298
  "up",
1294
1299
  remoteMeta.etag,
1295
1300
  lstat.mtimeMs,
1301
+ undefined,
1302
+ item.kind,
1296
1303
  );
1297
1304
  run.emit({ type: "reconciled", path: relativePath, direction: "push" });
1298
1305
  counters.filesSkipped++;
@@ -1350,7 +1357,17 @@ async function executeUploads(
1350
1357
  ? await uploadSymlink(run.ctx, item.target, relativePath, run.options.author, pc)
1351
1358
  : await uploadFile(run.ctx, absolutePath, relativePath, run.options.author, pc);
1352
1359
 
1353
- updateEntry(run.journal, relativePath, localHash, size, "up", etag, mtimeMs);
1360
+ updateEntry(
1361
+ run.journal,
1362
+ relativePath,
1363
+ localHash,
1364
+ size,
1365
+ "up",
1366
+ etag,
1367
+ mtimeMs,
1368
+ undefined,
1369
+ item.kind,
1370
+ );
1354
1371
  if (run.message) {
1355
1372
  run.journal.files[relativePath] = {
1356
1373
  ...run.journal.files[relativePath],
@@ -1501,14 +1518,39 @@ async function executeDeletes(
1501
1518
  counters: ShareCounters,
1502
1519
  filesRefusedStalePaths: string[],
1503
1520
  ): Promise<void> {
1504
- const deleteKeys = [...deletePlan.toDelete, ...decommissionPlan];
1521
+ const deleteItems = [
1522
+ ...deletePlan.toDelete.map((item) => ({ ...item, decommission: false })),
1523
+ ...decommissionPlan.map((key) => ({ key, intentVersion: null, decommission: true })),
1524
+ ];
1525
+ const deleteKeys = deleteItems.map((item) => item.key);
1505
1526
  await primeObjectTransport(run.ctx, "delete", deleteKeys);
1506
- for (const relativePath of deleteKeys) {
1527
+ for (const item of deleteItems) {
1528
+ const { key: relativePath } = item;
1507
1529
  if (run.vaultConfig && isExpiringSoon(run.ctx.expiresAt)) {
1508
1530
  run.ctx = await refreshEntityContext(run.companyRef, run.vaultConfig);
1509
1531
  }
1510
1532
  try {
1511
1533
  const entry = run.journal.files[relativePath];
1534
+ if (
1535
+ !item.decommission &&
1536
+ item.intentVersion !== null &&
1537
+ (!hasCurrentLocalDeleteIntent(entry) ||
1538
+ entry!.localDeleteIntent!.version !== item.intentVersion ||
1539
+ !isLocallyAbsent(path.join(run.syncRoot, relativePath)))
1540
+ ) {
1541
+ counters.filesRefusedStale++;
1542
+ if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
1543
+ filesRefusedStalePaths.push(relativePath);
1544
+ }
1545
+ run.emit({
1546
+ type: "delete-refused-stale-etag",
1547
+ path: relativePath,
1548
+ journalEtag: entry?.remoteEtag ?? "<missing-delete-intent>",
1549
+ remoteEtag: "<not-checked>",
1550
+ reason: "intent-changed",
1551
+ });
1552
+ continue;
1553
+ }
1512
1554
  const size = entry?.size ?? 0;
1513
1555
  await deleteRemoteFile(run.ctx, relativePath);
1514
1556
  removeEntry(run.journal, relativePath);
@@ -2027,7 +2069,9 @@ type RefusedStaleReason =
2027
2069
  | "stale-etag"
2028
2070
  | "legacy-no-etag"
2029
2071
  | "bulk-asymmetry"
2030
- | "divergent-local";
2072
+ | "divergent-local"
2073
+ | "missing-delete-intent"
2074
+ | "intent-changed";
2031
2075
 
2032
2076
  /**
2033
2077
  * Bulk-asymmetry circuit-breaker — refuses to convert a suspiciously-large
@@ -2091,7 +2135,7 @@ function isBulkAsymmetryOverride(): boolean {
2091
2135
  * uses. Emitted as `delete-refused-stale-etag` events.
2092
2136
  */
2093
2137
  interface DeletePlan {
2094
- toDelete: string[];
2138
+ toDelete: Array<{ key: string; intentVersion: 1 | null }>;
2095
2139
  toTombstone: string[];
2096
2140
  refusedStale: Array<{
2097
2141
  key: string;
@@ -2113,6 +2157,34 @@ interface DeletePlan {
2113
2157
  };
2114
2158
  }
2115
2159
 
2160
+ function hasCurrentLocalDeleteIntent(
2161
+ entry: SyncJournal["files"][string] | undefined,
2162
+ ): boolean {
2163
+ const intent = entry?.localDeleteIntent;
2164
+ return !!(
2165
+ intent &&
2166
+ intent.version === 1 &&
2167
+ entry.remoteEtag &&
2168
+ entry.kind &&
2169
+ intent.remoteEtag === entry.remoteEtag &&
2170
+ intent.localHash === entry.hash &&
2171
+ intent.localKind === entry.kind
2172
+ );
2173
+ }
2174
+
2175
+ function isLocallyAbsent(localPath: string): boolean {
2176
+ try {
2177
+ fs.lstatSync(localPath);
2178
+ return false;
2179
+ } catch (err: unknown) {
2180
+ const code =
2181
+ err && typeof err === "object" && "code" in err
2182
+ ? (err as { code?: string }).code
2183
+ : undefined;
2184
+ return code === "ENOENT";
2185
+ }
2186
+ }
2187
+
2116
2188
  /**
2117
2189
  * Concurrency cap for the per-file HEAD-O-meter (currency-gated). Sequential
2118
2190
  * HEADs would add ~N×(50-200ms) to a sync — for the 261-mirror real-world
@@ -2216,7 +2288,7 @@ async function computeDeletePlan(
2216
2288
  // all), or queue it for HEAD (currency-gated). Keeping this synchronous
2217
2289
  // means the HEAD pass below sees a single, deduplicated candidate list
2218
2290
  // and the journal-mutation buckets are already settled before any I/O.
2219
- type HeadCandidate = { key: string; journalEtag: string };
2291
+ type HeadCandidate = { key: string; journalEtag: string; intentVersion: 1 };
2220
2292
  const headCandidates: HeadCandidate[] = [];
2221
2293
  // Litter drain bucket — kept separate from `plan.toDelete` so the
2222
2294
  // bulk-asymmetry breaker (which moves toDelete + headCandidates into
@@ -2263,6 +2335,10 @@ async function computeDeletePlan(
2263
2335
  }
2264
2336
  if (presentLocally) continue;
2265
2337
 
2338
+ if (!companyScoped && isPersonalVaultDeletionExcluded(relativeKey)) {
2339
+ continue;
2340
+ }
2341
+
2266
2342
  // Vault-litter drain (6.0.2): conflict mirrors + rescue drift markers
2267
2343
  // ALWAYS drain, bypassing `shouldSync` (which would skip them when the
2268
2344
  // parent path is in personal-vault default exclusions like
@@ -2316,10 +2392,23 @@ async function computeDeletePlan(
2316
2392
  continue;
2317
2393
  }
2318
2394
 
2395
+ if (!hasCurrentLocalDeleteIntent(entry)) {
2396
+ plan.refusedStale.push({
2397
+ key: relativeKey,
2398
+ journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
2399
+ remoteEtag: "<not-checked>",
2400
+ reason: "missing-delete-intent",
2401
+ });
2402
+ continue;
2403
+ }
2404
+
2319
2405
  if (policy === "all") {
2320
2406
  // policy:"all" is the explicit-opt-out emergency-reconcile mode; the
2321
2407
  // bulk-asymmetry guard skips this branch (caller asserted intent).
2322
- plan.toDelete.push(relativeKey);
2408
+ plan.toDelete.push({
2409
+ key: relativeKey,
2410
+ intentVersion: entry.localDeleteIntent!.version,
2411
+ });
2323
2412
  continue;
2324
2413
  }
2325
2414
  bulkCandidatePicks++;
@@ -2332,7 +2421,10 @@ async function computeDeletePlan(
2332
2421
  bulkCandidatePicks--;
2333
2422
  continue;
2334
2423
  }
2335
- plan.toDelete.push(relativeKey);
2424
+ plan.toDelete.push({
2425
+ key: relativeKey,
2426
+ intentVersion: entry.localDeleteIntent!.version,
2427
+ });
2336
2428
  continue;
2337
2429
  }
2338
2430
  // currency-gated: queue for HEAD unless the entry is legacy (no etag).
@@ -2346,7 +2438,11 @@ async function computeDeletePlan(
2346
2438
  });
2347
2439
  continue;
2348
2440
  }
2349
- headCandidates.push({ key: relativeKey, journalEtag });
2441
+ headCandidates.push({
2442
+ key: relativeKey,
2443
+ journalEtag,
2444
+ intentVersion: entry.localDeleteIntent!.version,
2445
+ });
2350
2446
  }
2351
2447
 
2352
2448
  // Bulk-asymmetry circuit-breaker. See `BULK_ASYMMETRY_*` constants for
@@ -2375,7 +2471,7 @@ async function computeDeletePlan(
2375
2471
  samplePaths.push(key);
2376
2472
  }
2377
2473
  };
2378
- for (const key of plan.toDelete) pushRefused(key);
2474
+ for (const item of plan.toDelete) pushRefused(item.key);
2379
2475
  for (const c of headCandidates) pushRefused(c.key);
2380
2476
  plan.toDelete = [];
2381
2477
  plan.bulkAsymmetry = {
@@ -2389,7 +2485,7 @@ async function computeDeletePlan(
2389
2485
  // of user content; litter cleanup is orthogonal and should never be
2390
2486
  // refused for the same reason new-litter producer-side exclusions
2391
2487
  // shouldn't block it.
2392
- plan.toDelete.push(...litterToDelete);
2488
+ plan.toDelete.push(...litterToDelete.map((key) => ({ key, intentVersion: null })));
2393
2489
  return plan;
2394
2490
  }
2395
2491
 
@@ -2436,7 +2532,10 @@ async function computeDeletePlan(
2436
2532
  }
2437
2533
  const currentEtag = normalizeEtag(remote.etag);
2438
2534
  if (currentEtag === candidate.journalEtag) {
2439
- plan.toDelete.push(candidate.key);
2535
+ plan.toDelete.push({
2536
+ key: candidate.key,
2537
+ intentVersion: candidate.intentVersion,
2538
+ });
2440
2539
  } else {
2441
2540
  plan.refusedStale.push({
2442
2541
  key: candidate.key,
@@ -2453,7 +2552,7 @@ async function computeDeletePlan(
2453
2552
  // delete loop tombstones each key identically (DeleteObject + remove from
2454
2553
  // journal). Merged at the end so the bulk-asymmetry breaker above had its
2455
2554
  // chance to NOT include litter in either the numerator or the sweep.
2456
- plan.toDelete.push(...litterToDelete);
2555
+ plan.toDelete.push(...litterToDelete.map((key) => ({ key, intentVersion: null })));
2457
2556
 
2458
2557
  return plan;
2459
2558
  }