@indigoai-us/hq-cloud 6.14.8 → 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.
Files changed (67) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +8 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner-planning.d.ts +1 -1
  5. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  6. package/dist/bin/sync-runner-planning.js +15 -1
  7. package/dist/bin/sync-runner-planning.js.map +1 -1
  8. package/dist/bin/sync-runner.d.ts +12 -0
  9. package/dist/bin/sync-runner.d.ts.map +1 -1
  10. package/dist/bin/sync-runner.js +26 -0
  11. package/dist/bin/sync-runner.js.map +1 -1
  12. package/dist/bin/sync-runner.test.js +68 -1
  13. package/dist/bin/sync-runner.test.js.map +1 -1
  14. package/dist/cli/share.d.ts.map +1 -1
  15. package/dist/cli/share.js +89 -14
  16. package/dist/cli/share.js.map +1 -1
  17. package/dist/cli/share.test.js +105 -3
  18. package/dist/cli/share.test.js.map +1 -1
  19. package/dist/cli/sync-scope.test.js +3 -1
  20. package/dist/cli/sync-scope.test.js.map +1 -1
  21. package/dist/cli/sync.d.ts +7 -7
  22. package/dist/cli/sync.d.ts.map +1 -1
  23. package/dist/cli/sync.js +166 -82
  24. package/dist/cli/sync.js.map +1 -1
  25. package/dist/cli/sync.test.js +292 -27
  26. package/dist/cli/sync.test.js.map +1 -1
  27. package/dist/journal.d.ts +7 -1
  28. package/dist/journal.d.ts.map +1 -1
  29. package/dist/journal.js +22 -1
  30. package/dist/journal.js.map +1 -1
  31. package/dist/manifest-reconcile.d.ts +27 -0
  32. package/dist/manifest-reconcile.d.ts.map +1 -0
  33. package/dist/manifest-reconcile.js +120 -0
  34. package/dist/manifest-reconcile.js.map +1 -0
  35. package/dist/manifest-reconcile.test.d.ts +2 -0
  36. package/dist/manifest-reconcile.test.d.ts.map +1 -0
  37. package/dist/manifest-reconcile.test.js +97 -0
  38. package/dist/manifest-reconcile.test.js.map +1 -0
  39. package/dist/personal-vault.d.ts +6 -0
  40. package/dist/personal-vault.d.ts.map +1 -1
  41. package/dist/personal-vault.js +12 -0
  42. package/dist/personal-vault.js.map +1 -1
  43. package/dist/s3.d.ts +15 -2
  44. package/dist/s3.d.ts.map +1 -1
  45. package/dist/s3.js +54 -19
  46. package/dist/s3.js.map +1 -1
  47. package/dist/s3.test.js +12 -1
  48. package/dist/s3.test.js.map +1 -1
  49. package/dist/types.d.ts +13 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/package.json +1 -1
  52. package/src/bin/sync-runner-company.ts +7 -0
  53. package/src/bin/sync-runner-planning.ts +16 -2
  54. package/src/bin/sync-runner.test.ts +82 -1
  55. package/src/bin/sync-runner.ts +43 -0
  56. package/src/cli/share.test.ts +129 -3
  57. package/src/cli/share.ts +114 -15
  58. package/src/cli/sync-scope.test.ts +3 -1
  59. package/src/cli/sync.test.ts +352 -26
  60. package/src/cli/sync.ts +226 -103
  61. package/src/journal.ts +28 -0
  62. package/src/manifest-reconcile.test.ts +107 -0
  63. package/src/manifest-reconcile.ts +160 -0
  64. package/src/personal-vault.ts +15 -0
  65. package/src/s3.test.ts +18 -0
  66. package/src/s3.ts +67 -30
  67. package/src/types.ts +13 -0
@@ -47,7 +47,7 @@ import type {
47
47
  PendingInviteByEmail,
48
48
  TelemetryEventsBatch,
49
49
  } from "../vault-client.js";
50
- import { VaultAuthError } from "../vault-client.js";
50
+ import { VaultAuthError, VaultNotFoundError } from "../vault-client.js";
51
51
  import type { PushEvent } from "../sync/push-event.js";
52
52
 
53
53
  // ---------------------------------------------------------------------------
@@ -219,6 +219,7 @@ function makeDeps(overrides: Partial<RunnerDeps> = {}): TestDeps {
219
219
  embedded: false,
220
220
  pendingDirty: false,
221
221
  })),
222
+ reconcileManifest: vi.fn().mockResolvedValue(undefined),
222
223
  ...overrides,
223
224
  stdout,
224
225
  stderr,
@@ -926,6 +927,86 @@ describe("fanout-plan", () => {
926
927
  expect(plan.companies).toEqual([{ uid: "cmp_ghost", slug: "cmp_ghost" }]);
927
928
  });
928
929
 
930
+ it("skips a ghost membership whose entity is gone so it cannot force exit 2", async () => {
931
+ const sync = vi.fn().mockResolvedValue(defaultSyncResult());
932
+ const deps = makeDeps({
933
+ createVaultClient: () =>
934
+ makeVaultStub({
935
+ memberships: [
936
+ { companyUid: "cmp_live" },
937
+ { companyUid: "cmp_ghost" },
938
+ ],
939
+ entityGet: (uid: string) => {
940
+ if (uid === "cmp_ghost") {
941
+ return Promise.reject(new VaultNotFoundError("company deleted"));
942
+ }
943
+ return Promise.resolve({
944
+ uid,
945
+ slug: "live",
946
+ type: "company",
947
+ status: "active",
948
+ createdAt: "2026-01-01T00:00:00Z",
949
+ });
950
+ },
951
+ }),
952
+ sync,
953
+ });
954
+
955
+ const code = await runRunner(["--companies"], deps);
956
+
957
+ expect(code).toBe(0);
958
+ expect(sync).toHaveBeenCalledTimes(1);
959
+ expect(sync).toHaveBeenCalledWith(
960
+ expect.objectContaining({ company: "cmp_live" }),
961
+ );
962
+ const plan = deps.stdout
963
+ .events()
964
+ .find((e) => e.type === "fanout-plan") as Extract<
965
+ RunnerEvent,
966
+ { type: "fanout-plan" }
967
+ >;
968
+ expect(plan.companies).toEqual([{ uid: "cmp_live", slug: "live" }]);
969
+ });
970
+
971
+ it("reconciles after the personal leg completes", async () => {
972
+ const order: string[] = [];
973
+ const deps = makeDeps({
974
+ createVaultClient: () =>
975
+ makeVaultStub({
976
+ memberships: [{ companyUid: "cmp_acme" }],
977
+ entityGet: (uid: string) =>
978
+ Promise.resolve({
979
+ uid,
980
+ slug: "acme",
981
+ type: "company",
982
+ status: "active",
983
+ createdAt: "2026-01-01T00:00:00Z",
984
+ }),
985
+ listPersons: () =>
986
+ Promise.resolve([
987
+ {
988
+ uid: "prs_me",
989
+ slug: "me",
990
+ type: "person",
991
+ status: "active",
992
+ bucketName: "hq-vault-prs-me",
993
+ createdAt: "2026-01-01T00:00:00Z",
994
+ } as EntityInfo,
995
+ ]),
996
+ }),
997
+ sync: vi.fn().mockImplementation(async (options: SyncOptions) => {
998
+ order.push(options.personalMode === true ? "personal" : "company");
999
+ return defaultSyncResult();
1000
+ }),
1001
+ reconcileManifest: vi.fn().mockImplementation(async () => {
1002
+ order.push("reconcile");
1003
+ }),
1004
+ });
1005
+
1006
+ expect(await runRunner(["--companies"], deps)).toBe(0);
1007
+ expect(order).toEqual(["company", "personal", "reconcile"]);
1008
+ });
1009
+
929
1010
  it("includes entity.name on plan entries when available", async () => {
930
1011
  const deps = makeDeps({
931
1012
  createVaultClient: () =>
@@ -139,6 +139,10 @@ import {
139
139
  emitFanoutPlan,
140
140
  resolveMembershipsForRun,
141
141
  } from "./sync-runner-planning.js";
142
+ import {
143
+ reconcileCompanyManifest,
144
+ type ManifestReconcileOptions,
145
+ } from "../manifest-reconcile.js";
142
146
  import { executeCompanyFanout } from "./sync-runner-company.js";
143
147
  import { rollupAllComplete } from "./sync-runner-rollup.js";
144
148
  import { emitTelemetry } from "./sync-runner-telemetry.js";
@@ -351,6 +355,10 @@ export type RunnerEvent =
351
355
  | { type: "new-files"; company: string; files: Array<{ path: string; bytes: number; addedBy: string | null }> }
352
356
  | { type: "scope-excluded"; company: string; count: number; samplePaths: string[] }
353
357
  | { type: "ignore-excluded"; company: string; count: number; totalExcluded: number; samplePaths: string[] }
358
+ | ({ type: "skip-invalid-scoped-key"; company: string } & Omit<
359
+ Extract<SyncProgressEvent, { type: "skip-invalid-scoped-key" }>,
360
+ "type"
361
+ >)
354
362
  | ({
355
363
  type: "complete";
356
364
  company: string;
@@ -603,6 +611,14 @@ export interface RunnerDeps {
603
611
  hqRoot: string,
604
612
  options: QmdReindexOptions,
605
613
  ) => ReturnType<typeof reindexAfterSync>;
614
+ /**
615
+ * Merge successfully pulled cloud companies into the local manifest after
616
+ * the complete fanout. Injectable so runner tests do not touch a developer's
617
+ * HQ root.
618
+ */
619
+ reconcileManifest?: (
620
+ options: ManifestReconcileOptions,
621
+ ) => Promise<void>;
606
622
  /** Internal: set when runRunner is invoked under the per-root operation lock. */
607
623
  operationLockAlreadyHeld?: boolean;
608
624
  /** Share function (push phase). Defaults to `cli/share.share`. */
@@ -1311,6 +1327,33 @@ export async function runRunner(
1311
1327
  const { errors, allConflicts, meaningfulOutcomeCount } = fanout;
1312
1328
  const rollup = rollupAllComplete(plan, fanout.stateByCompany);
1313
1329
 
1330
+ // The personal-vault target runs last and can pull an older copy of the
1331
+ // manifest. Reconcile only after the entire pull fanout has settled so the
1332
+ // successfully materialized cloud directories restore their own entries.
1333
+ if (parsed.direction !== "push") {
1334
+ try {
1335
+ const completedCompanySlugs = new Set(
1336
+ Array.from(fanout.stateByCompany.entries())
1337
+ .filter(([, state]) => state.status === "complete")
1338
+ .map(([slug]) => slug),
1339
+ );
1340
+ await (deps.reconcileManifest ?? reconcileCompanyManifest)({
1341
+ hqRoot: parsed.hqRoot,
1342
+ targets: plan,
1343
+ completedCompanySlugs,
1344
+ getEntity: client.entity.get,
1345
+ });
1346
+ } catch (err) {
1347
+ reportDiagnostic({
1348
+ component: "manifest-reconcile",
1349
+ event: "runner.manifest_reconcile.failed",
1350
+ message: "post-fanout manifest reconciliation failed",
1351
+ err,
1352
+ context: { hqRoot: parsed.hqRoot },
1353
+ });
1354
+ }
1355
+ }
1356
+
1314
1357
  if (rollup.needsReindex) {
1315
1358
  try {
1316
1359
  const reindexOptions = {
@@ -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
  }),
@@ -2189,6 +2208,11 @@ describe("share", () => {
2189
2208
  syncedAt: new Date().toISOString(),
2190
2209
  direction: "up",
2191
2210
  remoteEtag: "gone-etag",
2211
+ kind: "file",
2212
+ localDeleteIntent: deleteIntent(
2213
+ "gone-etag",
2214
+ "irrelevant-not-checked-here",
2215
+ ),
2192
2216
  },
2193
2217
  },
2194
2218
  }),
@@ -2231,6 +2255,9 @@ describe("share", () => {
2231
2255
  size: 42,
2232
2256
  syncedAt: new Date().toISOString(),
2233
2257
  direction: "up",
2258
+ remoteEtag: "removed-etag",
2259
+ kind: "file",
2260
+ localDeleteIntent: deleteIntent("removed-etag", "h"),
2234
2261
  },
2235
2262
  },
2236
2263
  }),
@@ -2318,6 +2345,9 @@ describe("share", () => {
2318
2345
  size: 1,
2319
2346
  syncedAt: new Date().toISOString(),
2320
2347
  direction: "up",
2348
+ remoteEtag: "in-scope-etag",
2349
+ kind: "file",
2350
+ localDeleteIntent: deleteIntent("in-scope-etag", "h"),
2321
2351
  },
2322
2352
  "other/also-gone.md": {
2323
2353
  hash: "h",
@@ -2371,6 +2401,9 @@ describe("share", () => {
2371
2401
  size: 5,
2372
2402
  syncedAt: new Date().toISOString(),
2373
2403
  direction: "up",
2404
+ remoteEtag: "flaky-etag",
2405
+ kind: "file",
2406
+ localDeleteIntent: deleteIntent("flaky-etag", "h"),
2374
2407
  },
2375
2408
  },
2376
2409
  }),
@@ -2446,6 +2479,9 @@ describe("share", () => {
2446
2479
  "i-uploaded.md": {
2447
2480
  hash: "h", size: 1, syncedAt: new Date().toISOString(),
2448
2481
  direction: "up",
2482
+ remoteEtag: "uploaded-etag",
2483
+ kind: "file",
2484
+ localDeleteIntent: deleteIntent("uploaded-etag", "h"),
2449
2485
  },
2450
2486
  },
2451
2487
  }),
@@ -2482,6 +2518,9 @@ describe("share", () => {
2482
2518
  "pulled.md": {
2483
2519
  hash: "h", size: 1, syncedAt: new Date().toISOString(),
2484
2520
  direction: "down",
2521
+ remoteEtag: "pulled-etag",
2522
+ kind: "file",
2523
+ localDeleteIntent: deleteIntent("pulled-etag", "h"),
2485
2524
  },
2486
2525
  },
2487
2526
  }),
@@ -2526,6 +2565,9 @@ describe("share", () => {
2526
2565
  "active/current.md": {
2527
2566
  hash: "h", size: 1, syncedAt: new Date().toISOString(),
2528
2567
  direction: "up",
2568
+ remoteEtag: "active-etag",
2569
+ kind: "file",
2570
+ localDeleteIntent: deleteIntent("active-etag", "h"),
2529
2571
  },
2530
2572
  },
2531
2573
  }),
@@ -2586,6 +2628,8 @@ describe("share", () => {
2586
2628
  hash: "h", size: 100, syncedAt: new Date().toISOString(),
2587
2629
  direction: "down",
2588
2630
  remoteEtag: "abc123",
2631
+ kind: "file",
2632
+ localDeleteIntent: deleteIntent("abc123", "h"),
2589
2633
  },
2590
2634
  },
2591
2635
  }),
@@ -2637,6 +2681,8 @@ describe("share", () => {
2637
2681
  syncedAt: new Date().toISOString(),
2638
2682
  direction: "down",
2639
2683
  remoteEtag: "abc123",
2684
+ kind: "file",
2685
+ localDeleteIntent: deleteIntent("abc123", "h"),
2640
2686
  removedAt: new Date().toISOString(),
2641
2687
  removedReason: "local-delete",
2642
2688
  },
@@ -2688,6 +2734,8 @@ describe("share", () => {
2688
2734
  hash: "h", size: 50, syncedAt: new Date().toISOString(),
2689
2735
  direction: "down",
2690
2736
  remoteEtag: "stale-etag",
2737
+ kind: "file",
2738
+ localDeleteIntent: deleteIntent("stale-etag", "h"),
2691
2739
  },
2692
2740
  },
2693
2741
  }),
@@ -2733,6 +2781,64 @@ describe("share", () => {
2733
2781
  expect(result.filesTombstoned).toBe(0);
2734
2782
  });
2735
2783
 
2784
+ it("currency-gated: preserves a file recreated after the delete plan", async () => {
2785
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2786
+ fs.mkdirSync(companyRoot, { recursive: true });
2787
+ const recreatedPath = path.join(companyRoot, "racy.md");
2788
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
2789
+ fs.writeFileSync(
2790
+ journalPath,
2791
+ JSON.stringify({
2792
+ version: "2",
2793
+ lastSync: new Date().toISOString(),
2794
+ files: {
2795
+ "racy.md": {
2796
+ hash: "synced-hash",
2797
+ size: 10,
2798
+ syncedAt: new Date().toISOString(),
2799
+ direction: "up",
2800
+ remoteEtag: "racy-etag",
2801
+ kind: "file",
2802
+ localDeleteIntent: deleteIntent("racy-etag", "synced-hash"),
2803
+ },
2804
+ },
2805
+ pulls: [],
2806
+ }),
2807
+ );
2808
+
2809
+ // The currency HEAD establishes the remote revision, then a local writer
2810
+ // recreates the file before the planned DeleteObject can run.
2811
+ vi.mocked(headRemoteFile).mockImplementationOnce(async () => {
2812
+ fs.writeFileSync(recreatedPath, "recreated after planning");
2813
+ return { lastModified: new Date(), etag: '"racy-etag"', size: 10 };
2814
+ });
2815
+
2816
+ const events: Array<{ type: string; path?: string; reason?: string }> = [];
2817
+ const result = await share({
2818
+ paths: [companyRoot],
2819
+ company: "acme",
2820
+ vaultConfig: mockConfig,
2821
+ hqRoot: tmpDir,
2822
+ skipUnchanged: true,
2823
+ propagateDeletes: true,
2824
+ propagateDeletePolicy: "currency-gated",
2825
+ onEvent: (e) => events.push(e as { type: string }),
2826
+ });
2827
+
2828
+ expect(result.filesDeleted).toBe(0);
2829
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
2830
+ expect(fs.readFileSync(recreatedPath, "utf-8")).toBe("recreated after planning");
2831
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2832
+ expect(journal.files["racy.md"]).toBeDefined();
2833
+ expect(events).toContainEqual(
2834
+ expect.objectContaining({
2835
+ type: "delete-refused-stale-etag",
2836
+ path: "racy.md",
2837
+ reason: "intent-changed",
2838
+ }),
2839
+ );
2840
+ });
2841
+
2736
2842
  it("currency-gated: refuses delete for a divergent-local entry (journal-honesty — never delete a remote the local never genuinely matched)", async () => {
2737
2843
  const companyRoot = path.join(tmpDir, "companies", "acme");
2738
2844
  fs.mkdirSync(companyRoot, { recursive: true });
@@ -2814,6 +2920,8 @@ describe("share", () => {
2814
2920
  hash: "h", size: 25, syncedAt: new Date().toISOString(),
2815
2921
  direction: "down",
2816
2922
  remoteEtag: "doesnt-matter",
2923
+ kind: "file",
2924
+ localDeleteIntent: deleteIntent("doesnt-matter", "h"),
2817
2925
  },
2818
2926
  },
2819
2927
  }),
@@ -2894,12 +3002,13 @@ describe("share", () => {
2894
3002
 
2895
3003
  expect(result.filesDeleted).toBe(0);
2896
3004
  expect(deleteRemoteFile).not.toHaveBeenCalled();
2897
- // HEAD must NOT be called — we short-circuit on the missing journal etag.
3005
+ // HEAD must NOT be called — the explicit-intent gate rejects legacy
3006
+ // entries before any remote operation.
2898
3007
  expect(headRemoteFile).not.toHaveBeenCalled();
2899
3008
  const refused = events.find((e) => e.type === "delete-refused-stale-etag");
2900
3009
  expect(refused).toMatchObject({
2901
- journalEtag: "<legacy-no-etag>",
2902
- reason: "legacy-no-etag",
3010
+ journalEtag: "<missing-delete-intent>",
3011
+ reason: "missing-delete-intent",
2903
3012
  });
2904
3013
  expect(result.filesRefusedStale).toBe(1);
2905
3014
  });
@@ -2925,6 +3034,8 @@ describe("share", () => {
2925
3034
  hash: "h", size: 200, syncedAt: new Date().toISOString(),
2926
3035
  direction: "down",
2927
3036
  remoteEtag: "upstream-etag",
3037
+ kind: "file",
3038
+ localDeleteIntent: deleteIntent("upstream-etag", "h"),
2928
3039
  },
2929
3040
  },
2930
3041
  }),
@@ -3059,6 +3170,8 @@ describe("share", () => {
3059
3170
  hash: "h", size: 5, syncedAt: new Date().toISOString(),
3060
3171
  direction: "up",
3061
3172
  remoteEtag: "regular-etag",
3173
+ kind: "file",
3174
+ localDeleteIntent: deleteIntent("regular-etag", "h"),
3062
3175
  },
3063
3176
  },
3064
3177
  }),
@@ -3385,6 +3498,8 @@ describe("share", () => {
3385
3498
  hash: "h", size: 1, syncedAt: new Date().toISOString(),
3386
3499
  direction: "up",
3387
3500
  remoteEtag: "abc",
3501
+ kind: "file",
3502
+ localDeleteIntent: deleteIntent("abc", "h"),
3388
3503
  },
3389
3504
  },
3390
3505
  }),
@@ -3892,6 +4007,8 @@ describe("share", () => {
3892
4007
  syncedAt: string;
3893
4008
  direction: "up" | "down";
3894
4009
  remoteEtag: string;
4010
+ kind: "file";
4011
+ localDeleteIntent: ReturnType<typeof deleteIntent>;
3895
4012
  }
3896
4013
  > = {};
3897
4014
  for (let i = 0; i < total; i++) {
@@ -3902,6 +4019,8 @@ describe("share", () => {
3902
4019
  syncedAt: new Date().toISOString(),
3903
4020
  direction,
3904
4021
  remoteEtag: `etag-${i}`,
4022
+ kind: "file",
4023
+ localDeleteIntent: deleteIntent(`etag-${i}`, "h"),
3905
4024
  };
3906
4025
  if (i >= missing) {
3907
4026
  fs.writeFileSync(path.join(companyRoot, key), "hi");
@@ -4135,17 +4254,22 @@ describe("share", () => {
4135
4254
  const files: Record<string, {
4136
4255
  hash: string; size: number; syncedAt: string;
4137
4256
  direction: "up" | "down"; remoteEtag: string;
4257
+ kind: "file"; localDeleteIntent: ReturnType<typeof deleteIntent>;
4138
4258
  }> = {};
4139
4259
  const now = new Date().toISOString();
4140
4260
  for (let i = 0; i < 50; i++) {
4141
4261
  files[`d-${i.toString().padStart(4, "0")}.md`] = {
4142
4262
  hash: "h", size: 5, syncedAt: now,
4143
4263
  direction: "down", remoteEtag: `down-etag-${i}`,
4264
+ kind: "file",
4265
+ localDeleteIntent: deleteIntent(`down-etag-${i}`, "h"),
4144
4266
  };
4145
4267
  }
4146
4268
  files["only-up.md"] = {
4147
4269
  hash: "h", size: 5, syncedAt: now,
4148
4270
  direction: "up", remoteEtag: "up-etag",
4271
+ kind: "file",
4272
+ localDeleteIntent: deleteIntent("up-etag", "h"),
4149
4273
  };
4150
4274
  const journalPath = path.join(stateDir, "sync-journal.acme.json");
4151
4275
  fs.writeFileSync(journalPath, JSON.stringify({ version: "1", lastSync: now, files }));
@@ -4293,6 +4417,8 @@ describe("currency-gated: journal version 2 fixtures", () => {
4293
4417
  syncedAt: new Date().toISOString(),
4294
4418
  direction: "down",
4295
4419
  remoteEtag: "v2-etag",
4420
+ kind: "file",
4421
+ localDeleteIntent: deleteIntent("v2-etag", "h"),
4296
4422
  },
4297
4423
  },
4298
4424
  pulls: [],