@indigoai-us/hq-cloud 6.14.22 → 6.14.24

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 (120) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +20 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner-events.test.js +22 -0
  5. package/dist/bin/sync-runner-events.test.js.map +1 -1
  6. package/dist/bin/sync-runner-planning.d.ts +11 -0
  7. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  8. package/dist/bin/sync-runner-planning.js +37 -2
  9. package/dist/bin/sync-runner-planning.js.map +1 -1
  10. package/dist/bin/sync-runner-planning.test.js +1 -0
  11. package/dist/bin/sync-runner-planning.test.js.map +1 -1
  12. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  13. package/dist/bin/sync-runner-watch-loop.js +17 -1
  14. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  15. package/dist/bin/sync-runner.d.ts +33 -1
  16. package/dist/bin/sync-runner.d.ts.map +1 -1
  17. package/dist/bin/sync-runner.js +28 -3
  18. package/dist/bin/sync-runner.js.map +1 -1
  19. package/dist/bin/sync-runner.test.js +206 -2
  20. package/dist/bin/sync-runner.test.js.map +1 -1
  21. package/dist/cli/doctor.d.ts +119 -0
  22. package/dist/cli/doctor.d.ts.map +1 -0
  23. package/dist/cli/doctor.js +485 -0
  24. package/dist/cli/doctor.js.map +1 -0
  25. package/dist/cli/doctor.test.d.ts +13 -0
  26. package/dist/cli/doctor.test.d.ts.map +1 -0
  27. package/dist/cli/doctor.test.js +485 -0
  28. package/dist/cli/doctor.test.js.map +1 -0
  29. package/dist/cli/index.d.ts +2 -0
  30. package/dist/cli/index.d.ts.map +1 -1
  31. package/dist/cli/index.js +4 -0
  32. package/dist/cli/index.js.map +1 -1
  33. package/dist/cli/share.js +6 -1
  34. package/dist/cli/share.js.map +1 -1
  35. package/dist/cli/share.test.js +1 -0
  36. package/dist/cli/share.test.js.map +1 -1
  37. package/dist/cli/sync.d.ts +54 -0
  38. package/dist/cli/sync.d.ts.map +1 -1
  39. package/dist/cli/sync.js +223 -3
  40. package/dist/cli/sync.js.map +1 -1
  41. package/dist/cli/sync.test.js +394 -1
  42. package/dist/cli/sync.test.js.map +1 -1
  43. package/dist/cognito-auth.test.js +7 -0
  44. package/dist/cognito-auth.test.js.map +1 -1
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +3 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/lib/conflict-file.d.ts.map +1 -1
  50. package/dist/lib/conflict-file.js +5 -1
  51. package/dist/lib/conflict-file.js.map +1 -1
  52. package/dist/lib/conflict-index.d.ts +7 -0
  53. package/dist/lib/conflict-index.d.ts.map +1 -1
  54. package/dist/lib/conflict-index.js +10 -2
  55. package/dist/lib/conflict-index.js.map +1 -1
  56. package/dist/lib/conflict.test.js +31 -0
  57. package/dist/lib/conflict.test.js.map +1 -1
  58. package/dist/lib/machine-id.test.js +14 -0
  59. package/dist/lib/machine-id.test.js.map +1 -1
  60. package/dist/local-path-codec.d.ts +18 -0
  61. package/dist/local-path-codec.d.ts.map +1 -1
  62. package/dist/local-path-codec.js +63 -0
  63. package/dist/local-path-codec.js.map +1 -1
  64. package/dist/local-path-codec.test.d.ts +14 -0
  65. package/dist/local-path-codec.test.d.ts.map +1 -0
  66. package/dist/local-path-codec.test.js +102 -0
  67. package/dist/local-path-codec.test.js.map +1 -0
  68. package/dist/machine-auth.test.js +7 -0
  69. package/dist/machine-auth.test.js.map +1 -1
  70. package/dist/s3.d.ts +5 -0
  71. package/dist/s3.d.ts.map +1 -1
  72. package/dist/s3.js +129 -3
  73. package/dist/s3.js.map +1 -1
  74. package/dist/s3.symlink-materialize.test.d.ts +18 -0
  75. package/dist/s3.symlink-materialize.test.d.ts.map +1 -0
  76. package/dist/s3.symlink-materialize.test.js +394 -0
  77. package/dist/s3.symlink-materialize.test.js.map +1 -0
  78. package/dist/s3.test.js +19 -0
  79. package/dist/s3.test.js.map +1 -1
  80. package/dist/skill-telemetry.d.ts.map +1 -1
  81. package/dist/skill-telemetry.js +23 -2
  82. package/dist/skill-telemetry.js.map +1 -1
  83. package/dist/skill-telemetry.test.js +17 -0
  84. package/dist/skill-telemetry.test.js.map +1 -1
  85. package/dist/watcher.d.ts.map +1 -1
  86. package/dist/watcher.js +10 -1
  87. package/dist/watcher.js.map +1 -1
  88. package/dist/watcher.test.js +28 -0
  89. package/dist/watcher.test.js.map +1 -1
  90. package/package.json +1 -1
  91. package/src/bin/sync-runner-company.ts +18 -0
  92. package/src/bin/sync-runner-events.test.ts +31 -0
  93. package/src/bin/sync-runner-planning.test.ts +1 -0
  94. package/src/bin/sync-runner-planning.ts +46 -3
  95. package/src/bin/sync-runner-watch-loop.ts +19 -1
  96. package/src/bin/sync-runner.test.ts +236 -1
  97. package/src/bin/sync-runner.ts +57 -2
  98. package/src/cli/doctor.test.ts +581 -0
  99. package/src/cli/doctor.ts +640 -0
  100. package/src/cli/index.ts +14 -0
  101. package/src/cli/share.test.ts +1 -0
  102. package/src/cli/share.ts +6 -1
  103. package/src/cli/sync.test.ts +473 -1
  104. package/src/cli/sync.ts +310 -3
  105. package/src/cognito-auth.test.ts +5 -0
  106. package/src/index.ts +10 -0
  107. package/src/lib/conflict-file.ts +6 -1
  108. package/src/lib/conflict-index.ts +18 -2
  109. package/src/lib/conflict.test.ts +33 -0
  110. package/src/lib/machine-id.test.ts +10 -0
  111. package/src/local-path-codec.test.ts +138 -0
  112. package/src/local-path-codec.ts +66 -0
  113. package/src/machine-auth.test.ts +5 -0
  114. package/src/s3.symlink-materialize.test.ts +492 -0
  115. package/src/s3.test.ts +24 -0
  116. package/src/s3.ts +148 -4
  117. package/src/skill-telemetry.test.ts +19 -0
  118. package/src/skill-telemetry.ts +31 -2
  119. package/src/watcher.test.ts +33 -0
  120. package/src/watcher.ts +10 -1
@@ -53,7 +53,8 @@ vi.mock("./reindex.js", () => ({
53
53
  reindex: vi.fn(() => ({ status: 0 })),
54
54
  }));
55
55
 
56
- import { sync, reportNewFilesToNotify } from "./sync.js";
56
+ import { sync, reportNewFilesToNotify, win32SymlinkFlavorBroken } from "./sync.js";
57
+ import type { SyncProgressEvent } from "./sync.js";
57
58
  import { share } from "./share.js";
58
59
  import * as s3Module from "../s3.js";
59
60
  import { reindex } from "./reindex.js";
@@ -3783,6 +3784,198 @@ describe("sync", () => {
3783
3784
  expect(errOutput).not.toContain("stale personal-overlay marker");
3784
3785
  });
3785
3786
 
3787
+ it("directory-overlay marker whose children are already materialized: skips cleanly, no 'rm -rf' advice, children still pull (feedback_d2082110)", async () => {
3788
+ // Reporter feedback_d2082110 (cody@jonesroadbeauty.com): the DPP vault
3789
+ // stored a cloud symlink marker at `skills/cody-copywriter` while the local
3790
+ // path was a real directory holding that overlay's 13 materialized child
3791
+ // objects. The vault stores a directory overlay as a symlink RECORD at the
3792
+ // key itself AND the directory's contents as separate child objects beneath
3793
+ // it. Pre-fix, the pull planner hit the generic dir-vs-object collision
3794
+ // branch and emitted, on EVERY sync cycle, a "manual reconciliation required
3795
+ // (rm -rf the local directory to pull)" warning — advice that here would
3796
+ // DELETE the materialized (and possibly locally-authored) contents to install
3797
+ // an inert link. The marker "never resolved" and the noisy warning recurred.
3798
+ // Fix: recognize that the marker's key ALSO carries child objects in the same
3799
+ // LIST (a real directory never syncs as a single object at its own key), keep
3800
+ // the local directory, skip the marker quietly, and let the children pull via
3801
+ // their own plan items.
3802
+ const markerKey = "skills/cody-copywriter";
3803
+ const childKey = "skills/cody-copywriter/SKILL.md";
3804
+ const localDir = path.join(tmpDir, "companies", "acme", "skills", "cody-copywriter");
3805
+ fs.mkdirSync(localDir, { recursive: true });
3806
+ // A child that is already materialized on this device (stands in for the
3807
+ // reporter's 13 present child files) — it must survive untouched.
3808
+ fs.writeFileSync(path.join(localDir, "materialized.md"), "already here");
3809
+
3810
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
3811
+ { key: markerKey, size: 30, lastModified: new Date(), etag: '"overlay-marker"' },
3812
+ { key: childKey, size: 18, lastModified: new Date(), etag: '"child-obj"' },
3813
+ ]);
3814
+ // Deterministic child materialization, independent of the ambient default
3815
+ // downloadFile mock (which earlier tests in this suite may have replaced).
3816
+ // The marker is skipped by the planner, so this Once impl serves the single
3817
+ // child download.
3818
+ vi.mocked(s3Module.downloadFile).mockImplementationOnce(
3819
+ async (_ctx: unknown, _key: string, localPath: string) => {
3820
+ const dir = path.dirname(localPath);
3821
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
3822
+ fs.writeFileSync(localPath, "child content");
3823
+ return { metadata: {} };
3824
+ },
3825
+ );
3826
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
3827
+
3828
+ let crashed = false;
3829
+ let result;
3830
+ try {
3831
+ result = await sync({
3832
+ company: "acme",
3833
+ vaultConfig: mockConfig,
3834
+ hqRoot: tmpDir,
3835
+ });
3836
+ } catch {
3837
+ crashed = true;
3838
+ }
3839
+ const errOutput = errSpy.mock.calls.map((c) => c.join(" ")).join("\n");
3840
+ errSpy.mockRestore();
3841
+
3842
+ expect(crashed).toBe(false);
3843
+ expect(result).toBeDefined();
3844
+ // The marker is a benign skip; the child object still downloads.
3845
+ expect(result!.filesDownloaded).toBe(1);
3846
+ expect(result!.filesSkipped).toBeGreaterThanOrEqual(1);
3847
+ // Local directory and its already-materialized contents are untouched...
3848
+ expect(fs.readFileSync(path.join(localDir, "materialized.md"), "utf-8")).toBe(
3849
+ "already here",
3850
+ );
3851
+ // ...and the child materialized under the kept directory.
3852
+ expect(fs.existsSync(path.join(localDir, "SKILL.md"))).toBe(true);
3853
+ // The dangerous, recurring reconciliation advice is GONE...
3854
+ expect(errOutput).not.toContain("rm -rf");
3855
+ expect(errOutput).not.toContain("manual");
3856
+ // ...replaced by a calm, no-action-needed note naming the marker.
3857
+ expect(errOutput).toContain("directory-overlay marker");
3858
+ expect(errOutput).toContain(markerKey);
3859
+ });
3860
+
3861
+ it("company dir-vs-object collision with NO children in the LIST still warns to reconcile (boundary)", async () => {
3862
+ // Boundary guard for the feedback_d2082110 fix: the quiet overlay-marker
3863
+ // skip is scoped to a remote object whose key ALSO has child objects under
3864
+ // it (a directory overlay). A single remote object at a company key that is
3865
+ // locally a real directory but has NO children in the LIST is a genuine
3866
+ // file-vs-directory structural collision the operator must resolve, so the
3867
+ // original warning + skip-local-only classification must remain intact.
3868
+ const key = "skills/lonely-file";
3869
+ const localDir = path.join(tmpDir, "companies", "acme", "skills", "lonely-file");
3870
+ fs.mkdirSync(localDir, { recursive: true });
3871
+ fs.writeFileSync(path.join(localDir, "inside.md"), "local content");
3872
+
3873
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
3874
+ { key, size: 40, lastModified: new Date(), etag: '"single-obj"' },
3875
+ ]);
3876
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
3877
+
3878
+ let crashed = false;
3879
+ let result;
3880
+ try {
3881
+ result = await sync({
3882
+ company: "acme",
3883
+ vaultConfig: mockConfig,
3884
+ hqRoot: tmpDir,
3885
+ });
3886
+ } catch {
3887
+ crashed = true;
3888
+ }
3889
+ const errOutput = errSpy.mock.calls.map((c) => c.join(" ")).join("\n");
3890
+ errSpy.mockRestore();
3891
+
3892
+ expect(crashed).toBe(false);
3893
+ expect(result!.filesDownloaded).toBe(0);
3894
+ expect(result!.filesSkipped).toBe(1);
3895
+ expect(fs.existsSync(path.join(localDir, "inside.md"))).toBe(true);
3896
+ // Generic collision path preserved: reconciliation warning still emitted,
3897
+ // NOT the overlay-marker note.
3898
+ expect(errOutput).toContain("manual");
3899
+ expect(errOutput).not.toContain("directory-overlay marker");
3900
+ });
3901
+
3902
+ it("shared-mode leg surfaces the materialization gap and names the shared→all lever (feedback_d2082110)", async () => {
3903
+ // Reporter feedback_d2082110 (cody@jonesroadbeauty.com): four memberships
3904
+ // were configured as `shared` rather than `all`, so not all vault content
3905
+ // materialized on the second device — a SILENT gap the reporter had to
3906
+ // notice and work around by flipping each membership to `all` by hand.
3907
+ // `SyncResult.filesOutOfScope` counted the skipped keys but nothing SURFACED
3908
+ // the gap or the lever. Fix: a `shared`/`custom` leg that skips one or more
3909
+ // keys as out-of-scope emits a single `scope-materialization-gap` event so
3910
+ // full materialization becomes a deliberate, visible choice.
3911
+ const events: Array<Record<string, unknown>> = [];
3912
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
3913
+ { key: "knowledge/present.md", size: 12, lastModified: new Date(), etag: '"in-scope"' },
3914
+ { key: "projects/secret.md", size: 20, lastModified: new Date(), etag: '"out-of-scope"' },
3915
+ ]);
3916
+ // Deterministic materialization of the single in-scope download, independent
3917
+ // of the ambient default downloadFile mock (earlier tests in this suite may
3918
+ // have replaced it — clearAllMocks resets call history, not implementations).
3919
+ vi.mocked(s3Module.downloadFile).mockImplementationOnce(
3920
+ async (_ctx: unknown, _key: string, localPath: string) => {
3921
+ const dir = path.dirname(localPath);
3922
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
3923
+ fs.writeFileSync(localPath, "in-scope content");
3924
+ return { metadata: {} };
3925
+ },
3926
+ );
3927
+
3928
+ const result = await sync({
3929
+ company: "acme",
3930
+ vaultConfig: mockConfig,
3931
+ hqRoot: tmpDir,
3932
+ syncMode: "shared",
3933
+ prefixSet: ["knowledge/"],
3934
+ onEvent: (e) => events.push(e as unknown as Record<string, unknown>),
3935
+ });
3936
+
3937
+ // The in-scope key pulled; the out-of-scope key was NOT materialized.
3938
+ expect(result.filesDownloaded).toBe(1);
3939
+ expect(result.filesOutOfScope).toBe(1);
3940
+ expect(
3941
+ fs.existsSync(path.join(tmpDir, "companies", "acme", "knowledge", "present.md")),
3942
+ ).toBe(true);
3943
+ expect(
3944
+ fs.existsSync(path.join(tmpDir, "companies", "acme", "projects", "secret.md")),
3945
+ ).toBe(false);
3946
+
3947
+ // The gap is now SURFACED with the count, the active mode, and the lever.
3948
+ const gap = events.find((e) => e.type === "scope-materialization-gap");
3949
+ expect(gap).toBeDefined();
3950
+ expect(gap!.count).toBe(1);
3951
+ expect(gap!.syncMode).toBe("shared");
3952
+ expect(gap!.samplePaths).toEqual(["projects/secret.md"]);
3953
+ });
3954
+
3955
+ it("all-mode leg (default) never emits a materialization gap — no scoping, no noise", async () => {
3956
+ // Boundary for the feedback_d2082110 gap surface: in `all` mode every key is
3957
+ // in scope, so the gap event must stay silent even when the LIST is fully
3958
+ // downloaded. Prevents the guidance from firing on the common full-access
3959
+ // path where there is nothing for the operator to act on.
3960
+ const events: Array<Record<string, unknown>> = [];
3961
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
3962
+ { key: "knowledge/a.md", size: 12, lastModified: new Date(), etag: '"a"' },
3963
+ { key: "projects/b.md", size: 20, lastModified: new Date(), etag: '"b"' },
3964
+ ]);
3965
+
3966
+ const result = await sync({
3967
+ company: "acme",
3968
+ vaultConfig: mockConfig,
3969
+ hqRoot: tmpDir,
3970
+ // syncMode omitted → defaults to "all".
3971
+ onEvent: (e) => events.push(e as unknown as Record<string, unknown>),
3972
+ });
3973
+
3974
+ expect(result.filesDownloaded).toBe(2);
3975
+ expect(result.filesOutOfScope).toBe(0);
3976
+ expect(events.find((e) => e.type === "scope-materialization-gap")).toBeUndefined();
3977
+ });
3978
+
3786
3979
  it("pulls a remote symlink record under an .hqinclude dir-only allowlist pattern", async () => {
3787
3980
  // Codex round-7 P1 follow-up: pre-fix, computePullPlan called
3788
3981
  // shouldSync(localPath) with the default isDir=false. LIST gives
@@ -4421,3 +4614,282 @@ describe("scope-invalid key hardening (companies/ prefix in a company vault —
4421
4614
  expect(fs.existsSync(path.join(tmpDir, "companies", "localco", "notes.md"))).toBe(true);
4422
4615
  });
4423
4616
  });
4617
+
4618
+ describe("win32SymlinkFlavorBroken (round-8 flavor-health probe)", () => {
4619
+ let tmp: string;
4620
+
4621
+ beforeEach(() => {
4622
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "flavor-probe-"));
4623
+ });
4624
+
4625
+ afterEach(() => {
4626
+ fs.rmSync(tmp, { recursive: true, force: true });
4627
+ });
4628
+
4629
+ it("false for a healthy dir link, false for a dangling target, always false off win32", () => {
4630
+ const target = path.join(tmp, "real-dir");
4631
+ fs.mkdirSync(target);
4632
+ const healthy = path.join(tmp, "healthy-link");
4633
+ fs.symlinkSync(target, healthy, "dir");
4634
+ expect(win32SymlinkFlavorBroken(healthy, true)).toBe(false);
4635
+ expect(win32SymlinkFlavorBroken(healthy, false)).toBe(false);
4636
+
4637
+ const dangling = path.join(tmp, "dangling-link");
4638
+ fs.symlinkSync(path.join(tmp, "missing"), dangling);
4639
+ expect(win32SymlinkFlavorBroken(dangling, true)).toBe(false);
4640
+ });
4641
+
4642
+ it.runIf(process.platform === "win32")(
4643
+ "true for a real FILE-flavor link at a DIRECTORY target (the broken shape the repair branch re-downloads)",
4644
+ () => {
4645
+ // Only constructible on Windows: flavor is a win32-only concept. The
4646
+ // physical shape from the incident — link minted 'file' while the
4647
+ // target was absent, target directory created afterwards.
4648
+ const target = path.join(tmp, "late-dir");
4649
+ const link = path.join(tmp, "wrong-flavor-link");
4650
+ fs.symlinkSync(target, link, "file");
4651
+ fs.mkdirSync(target);
4652
+ expect(win32SymlinkFlavorBroken(link, true)).toBe(true);
4653
+ },
4654
+ );
4655
+ });
4656
+
4657
+ describe("junk key-spelling guard (US-002 mixed-version amplifier)", () => {
4658
+ let tmpDir: string;
4659
+ let stateDir: string;
4660
+ let journalPath: string;
4661
+
4662
+ // Journal keys are company-root-relative; sync() resolves the company to
4663
+ // slug "acme", so entries land in sync-journal.acme.json and materialize
4664
+ // under {hqRoot}/companies/acme/.
4665
+ const canonicalKey = ".claude/skills/indigo:call-prep-brief/scripts";
4666
+ const junkKey = ".claude/skills/indigo%3Acall-prep-brief/scripts";
4667
+
4668
+ function seedJournal(files: Record<string, unknown>): void {
4669
+ fs.writeFileSync(
4670
+ journalPath,
4671
+ JSON.stringify({
4672
+ version: "2",
4673
+ lastSync: new Date().toISOString(),
4674
+ files,
4675
+ pulls: [],
4676
+ }),
4677
+ );
4678
+ }
4679
+
4680
+ function journalEntry(remoteEtag: string): Record<string, unknown> {
4681
+ return {
4682
+ hash: "0".repeat(64),
4683
+ size: 0,
4684
+ syncedAt: new Date().toISOString(),
4685
+ direction: "up",
4686
+ remoteEtag,
4687
+ kind: "symlink",
4688
+ };
4689
+ }
4690
+
4691
+ async function runSync(): Promise<{
4692
+ events: SyncProgressEvent[];
4693
+ result: Awaited<ReturnType<typeof sync>>;
4694
+ }> {
4695
+ const events: SyncProgressEvent[] = [];
4696
+ const result = await sync({
4697
+ company: "acme",
4698
+ vaultConfig: mockConfig,
4699
+ hqRoot: tmpDir,
4700
+ skipReindex: true,
4701
+ onEvent: (e) => events.push(e),
4702
+ });
4703
+ return { events, result };
4704
+ }
4705
+
4706
+ beforeEach(() => {
4707
+ clearContextCache();
4708
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-junkkey-test-"));
4709
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-junkkey-test-"));
4710
+ process.env.HQ_STATE_DIR = stateDir;
4711
+ journalPath = path.join(stateDir, "sync-journal.acme.json");
4712
+ setupFetchMock();
4713
+ });
4714
+
4715
+ afterEach(() => {
4716
+ vi.unstubAllGlobals();
4717
+ vi.clearAllMocks();
4718
+ fs.rmSync(tmpDir, { recursive: true, force: true });
4719
+ fs.rmSync(stateDir, { recursive: true, force: true });
4720
+ delete process.env.HQ_STATE_DIR;
4721
+ });
4722
+
4723
+ it("skips a remote %3A twin of an already-journaled canonical key — loudly, never as an error", async () => {
4724
+ seedJournal({ [canonicalKey]: journalEntry("aaa111") });
4725
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
4726
+ { key: junkKey, size: 20, lastModified: new Date(), etag: '"junk1"' },
4727
+ ]);
4728
+
4729
+ const { events, result } = await runSync();
4730
+
4731
+ // The twin never downloads, never materializes, never journals.
4732
+ expect(vi.mocked(s3Module.downloadFile)).not.toHaveBeenCalled();
4733
+ const skips = events.filter((e) => e.type === "skip-junk-key-spelling");
4734
+ expect(skips).toHaveLength(1);
4735
+ expect(skips[0]).toMatchObject({ path: junkKey, journaledKey: canonicalKey });
4736
+ // Policy hq-sync-deliberate-skip-not-fatal-error-exit2: a deliberate
4737
+ // per-file skip must never surface as a fatal {type:"error"} event.
4738
+ expect(events.filter((e) => e.type === "error")).toHaveLength(0);
4739
+ expect(result.aborted).toBe(false);
4740
+ expect(result.filesSkipped).toBe(1);
4741
+ const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as {
4742
+ files: Record<string, unknown>;
4743
+ };
4744
+ expect(Object.keys(journalAfter.files)).not.toContain(junkKey);
4745
+ });
4746
+
4747
+ it("still pulls a junk-spelling key that is ALREADY journaled under that exact spelling", async () => {
4748
+ // Pre-existing fork (both machines journaled the %3A family before the
4749
+ // guard shipped): the established spelling keeps syncing untouched —
4750
+ // collapsing the family is the vault doctor's job (US-003), not the
4751
+ // planner's.
4752
+ seedJournal({ [junkKey]: journalEntry("aaa111") });
4753
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
4754
+ { key: junkKey, size: 20, lastModified: new Date(), etag: '"bbb222"' },
4755
+ ]);
4756
+
4757
+ const { events, result } = await runSync();
4758
+
4759
+ expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
4760
+ expect(vi.mocked(s3Module.downloadFile).mock.calls[0]![1]).toBe(junkKey);
4761
+ expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(0);
4762
+ expect(result.filesDownloaded).toBe(1);
4763
+ });
4764
+
4765
+ it("downloads a %3A key with NO journaled twin — the guard needs an established spelling to defend", async () => {
4766
+ seedJournal({});
4767
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
4768
+ { key: junkKey, size: 20, lastModified: new Date(), etag: '"junk1"' },
4769
+ ]);
4770
+
4771
+ const { events, result } = await runSync();
4772
+
4773
+ expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
4774
+ expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(0);
4775
+ expect(result.filesDownloaded).toBe(1);
4776
+ });
4777
+
4778
+ it("an exact FILE_TOMBSTONE for the journaled canonical key beats the junk-twin protection (authoritative delete propagates)", async () => {
4779
+ // Round-7 review P2: a deliberately-deleted wrapper must not be kept
4780
+ // alive by a stale junk spelling lingering in the LIST.
4781
+ seedJournal({ [canonicalKey]: journalEntry("aaa111") });
4782
+ setupFetchMock({
4783
+ tombstones: [{ key: canonicalKey, deletedAt: new Date().toISOString() }],
4784
+ });
4785
+ // Earlier suites install persistent keyed headRemoteFile implementations;
4786
+ // the HEAD-verify pass must see the canonical key as absent here.
4787
+ vi.mocked(s3Module.headRemoteFile).mockResolvedValue(null);
4788
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
4789
+ { key: junkKey, size: 20, lastModified: new Date(), etag: '"junk1"' },
4790
+ ]);
4791
+
4792
+ const { events, result } = await runSync();
4793
+
4794
+ // Junk twin still gated; the canonical journal entry tombstones out.
4795
+ expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(1);
4796
+ expect(result.filesTombstoned).toBe(1);
4797
+ const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as {
4798
+ files: Record<string, unknown>;
4799
+ };
4800
+ expect(Object.keys(journalAfter.files)).not.toContain(canonicalKey);
4801
+ });
4802
+
4803
+ it("always downloads a CANONICAL remote key even when the journal holds only its junk twin (post-doctor convergence)", async () => {
4804
+ // Round-6 review P1: after `hq sync doctor` collapses the vault to the
4805
+ // canonical spelling, a machine whose journal still carries the junk
4806
+ // spelling must pick the canonical object up — gating it would deadlock
4807
+ // that machine out of the wrapper (and all future updates) forever.
4808
+ seedJournal({ [junkKey]: journalEntry("aaa111") });
4809
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
4810
+ { key: canonicalKey, size: 20, lastModified: new Date(), etag: '"can1"' },
4811
+ ]);
4812
+
4813
+ const { events, result } = await runSync();
4814
+
4815
+ expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(0);
4816
+ expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
4817
+ expect(vi.mocked(s3Module.downloadFile).mock.calls[0]![1]).toBe(canonicalKey);
4818
+ expect(result.filesDownloaded).toBe(1);
4819
+ const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as {
4820
+ files: Record<string, unknown>;
4821
+ };
4822
+ expect(Object.keys(journalAfter.files)).toContain(canonicalKey);
4823
+ });
4824
+
4825
+ it("does not tombstone a journaled canonical key when the LIST holds only its junk twin", async () => {
4826
+ // Round-4 review P1: skip-junk-key-spelling alone is self-defeating if
4827
+ // the delete-resync pass then classes the canonical journal entry as
4828
+ // remote-deleted (raw remoteKeySet miss) — it would unlink the good
4829
+ // local wrapper and drop the journal entry, and the NEXT pull would
4830
+ // materialize the junk spelling the guard just refused.
4831
+ seedJournal({ [canonicalKey]: journalEntry("aaa111") });
4832
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
4833
+ { key: junkKey, size: 20, lastModified: new Date(), etag: '"junk1"' },
4834
+ ]);
4835
+
4836
+ const { events, result } = await runSync();
4837
+
4838
+ // Junk twin skipped, canonical entry NOT tombstoned.
4839
+ expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(1);
4840
+ const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as {
4841
+ files: Record<string, unknown>;
4842
+ };
4843
+ expect(Object.keys(journalAfter.files)).toContain(canonicalKey);
4844
+ expect(result.filesTombstoned ?? 0).toBe(0);
4845
+ expect(events.filter((e) => e.type === "error")).toHaveLength(0);
4846
+ });
4847
+
4848
+ it("never gates COLON-FREE skill names — foo%20bar and foo bar are distinct skills (round-9)", async () => {
4849
+ seedJournal({ [".claude/skills/foo bar/SKILL.md"]: journalEntry("aaa111") });
4850
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
4851
+ { key: ".claude/skills/foo%20bar/SKILL.md", size: 20, lastModified: new Date(), etag: '"pct1"' },
4852
+ ]);
4853
+
4854
+ const { events, result } = await runSync();
4855
+
4856
+ expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(0);
4857
+ expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
4858
+ expect(result.filesDownloaded).toBe(1);
4859
+ });
4860
+
4861
+ it("never gates keys outside .claude/skills/ — percent-decode collisions there are distinct files", async () => {
4862
+ // `notes/a b.md` and `notes/a%20b.md` canonicalize to the same fixpoint
4863
+ // string, but outside the skill farm they are two legitimate files (the
4864
+ // win32 codec encodes the literal '%' as '%25' locally). The guard must
4865
+ // not skip the unjournaled one.
4866
+ seedJournal({ ["notes/a b.md"]: journalEntry("aaa111") });
4867
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
4868
+ { key: "notes/a%20b.md", size: 20, lastModified: new Date(), etag: '"pct1"' },
4869
+ ]);
4870
+
4871
+ const { events, result } = await runSync();
4872
+
4873
+ expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
4874
+ expect(vi.mocked(s3Module.downloadFile).mock.calls[0]![1]).toBe("notes/a%20b.md");
4875
+ expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(0);
4876
+ expect(result.filesDownloaded).toBe(1);
4877
+ });
4878
+
4879
+ it("keeps syncing BOTH spellings when both are already journaled (no retroactive gating)", async () => {
4880
+ seedJournal({
4881
+ [canonicalKey]: journalEntry("aaa111"),
4882
+ [junkKey]: journalEntry("ccc333"),
4883
+ });
4884
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
4885
+ { key: canonicalKey, size: 20, lastModified: new Date(), etag: '"bbb222"' },
4886
+ { key: junkKey, size: 20, lastModified: new Date(), etag: '"ddd444"' },
4887
+ ]);
4888
+
4889
+ const { events, result } = await runSync();
4890
+
4891
+ expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(2);
4892
+ expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(0);
4893
+ expect(result.filesDownloaded).toBe(2);
4894
+ });
4895
+ });