@indigoai-us/hq-cloud 6.14.23 → 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 (110) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +11 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner-planning.d.ts +11 -0
  5. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  6. package/dist/bin/sync-runner-planning.js +37 -2
  7. package/dist/bin/sync-runner-planning.js.map +1 -1
  8. package/dist/bin/sync-runner-planning.test.js +1 -0
  9. package/dist/bin/sync-runner-planning.test.js.map +1 -1
  10. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-watch-loop.js +17 -1
  12. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  13. package/dist/bin/sync-runner.d.ts +6 -0
  14. package/dist/bin/sync-runner.d.ts.map +1 -1
  15. package/dist/bin/sync-runner.js +14 -2
  16. package/dist/bin/sync-runner.js.map +1 -1
  17. package/dist/bin/sync-runner.test.js +155 -1
  18. package/dist/bin/sync-runner.test.js.map +1 -1
  19. package/dist/cli/doctor.d.ts +119 -0
  20. package/dist/cli/doctor.d.ts.map +1 -0
  21. package/dist/cli/doctor.js +485 -0
  22. package/dist/cli/doctor.js.map +1 -0
  23. package/dist/cli/doctor.test.d.ts +13 -0
  24. package/dist/cli/doctor.test.d.ts.map +1 -0
  25. package/dist/cli/doctor.test.js +485 -0
  26. package/dist/cli/doctor.test.js.map +1 -0
  27. package/dist/cli/index.d.ts +2 -0
  28. package/dist/cli/index.d.ts.map +1 -1
  29. package/dist/cli/index.js +4 -0
  30. package/dist/cli/index.js.map +1 -1
  31. package/dist/cli/share.js +6 -1
  32. package/dist/cli/share.js.map +1 -1
  33. package/dist/cli/sync.d.ts +29 -0
  34. package/dist/cli/sync.d.ts.map +1 -1
  35. package/dist/cli/sync.js +140 -2
  36. package/dist/cli/sync.js.map +1 -1
  37. package/dist/cli/sync.test.js +223 -1
  38. package/dist/cli/sync.test.js.map +1 -1
  39. package/dist/cognito-auth.test.js +7 -0
  40. package/dist/cognito-auth.test.js.map +1 -1
  41. package/dist/index.d.ts +2 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +3 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/lib/conflict-file.d.ts.map +1 -1
  46. package/dist/lib/conflict-file.js +5 -1
  47. package/dist/lib/conflict-file.js.map +1 -1
  48. package/dist/lib/conflict-index.d.ts.map +1 -1
  49. package/dist/lib/conflict-index.js +7 -2
  50. package/dist/lib/conflict-index.js.map +1 -1
  51. package/dist/lib/machine-id.test.js +14 -0
  52. package/dist/lib/machine-id.test.js.map +1 -1
  53. package/dist/local-path-codec.d.ts +18 -0
  54. package/dist/local-path-codec.d.ts.map +1 -1
  55. package/dist/local-path-codec.js +63 -0
  56. package/dist/local-path-codec.js.map +1 -1
  57. package/dist/local-path-codec.test.d.ts +14 -0
  58. package/dist/local-path-codec.test.d.ts.map +1 -0
  59. package/dist/local-path-codec.test.js +102 -0
  60. package/dist/local-path-codec.test.js.map +1 -0
  61. package/dist/machine-auth.test.js +7 -0
  62. package/dist/machine-auth.test.js.map +1 -1
  63. package/dist/s3.d.ts +5 -0
  64. package/dist/s3.d.ts.map +1 -1
  65. package/dist/s3.js +129 -3
  66. package/dist/s3.js.map +1 -1
  67. package/dist/s3.symlink-materialize.test.d.ts +18 -0
  68. package/dist/s3.symlink-materialize.test.d.ts.map +1 -0
  69. package/dist/s3.symlink-materialize.test.js +394 -0
  70. package/dist/s3.symlink-materialize.test.js.map +1 -0
  71. package/dist/s3.test.js +19 -0
  72. package/dist/s3.test.js.map +1 -1
  73. package/dist/skill-telemetry.d.ts.map +1 -1
  74. package/dist/skill-telemetry.js +23 -2
  75. package/dist/skill-telemetry.js.map +1 -1
  76. package/dist/skill-telemetry.test.js +17 -0
  77. package/dist/skill-telemetry.test.js.map +1 -1
  78. package/dist/watcher.d.ts.map +1 -1
  79. package/dist/watcher.js +10 -1
  80. package/dist/watcher.js.map +1 -1
  81. package/dist/watcher.test.js +28 -0
  82. package/dist/watcher.test.js.map +1 -1
  83. package/package.json +1 -1
  84. package/src/bin/sync-runner-company.ts +10 -0
  85. package/src/bin/sync-runner-planning.test.ts +1 -0
  86. package/src/bin/sync-runner-planning.ts +46 -3
  87. package/src/bin/sync-runner-watch-loop.ts +19 -1
  88. package/src/bin/sync-runner.test.ts +174 -0
  89. package/src/bin/sync-runner.ts +17 -1
  90. package/src/cli/doctor.test.ts +581 -0
  91. package/src/cli/doctor.ts +640 -0
  92. package/src/cli/index.ts +14 -0
  93. package/src/cli/share.ts +6 -1
  94. package/src/cli/sync.test.ts +281 -1
  95. package/src/cli/sync.ts +184 -2
  96. package/src/cognito-auth.test.ts +5 -0
  97. package/src/index.ts +10 -0
  98. package/src/lib/conflict-file.ts +6 -1
  99. package/src/lib/conflict-index.ts +7 -2
  100. package/src/lib/machine-id.test.ts +10 -0
  101. package/src/local-path-codec.test.ts +138 -0
  102. package/src/local-path-codec.ts +66 -0
  103. package/src/machine-auth.test.ts +5 -0
  104. package/src/s3.symlink-materialize.test.ts +492 -0
  105. package/src/s3.test.ts +24 -0
  106. package/src/s3.ts +148 -4
  107. package/src/skill-telemetry.test.ts +19 -0
  108. package/src/skill-telemetry.ts +31 -2
  109. package/src/watcher.test.ts +33 -0
  110. 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";
@@ -4613,3 +4614,282 @@ describe("scope-invalid key hardening (companies/ prefix in a company vault —
4613
4614
  expect(fs.existsSync(path.join(tmpDir, "companies", "localco", "notes.md"))).toBe(true);
4614
4615
  });
4615
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
+ });
package/src/cli/sync.ts CHANGED
@@ -19,7 +19,12 @@ import {
19
19
  } from "../telemetry-events.js";
20
20
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
21
21
  import { createSyncProgressRecorder } from "../sync-progress.js";
22
- import { localPathForVaultKey, vaultKeyForLocalPath } from "../local-path-codec.js";
22
+ import {
23
+ canonicalVaultKeySpelling,
24
+ localPathForVaultKey,
25
+ vaultKeyForLocalPath,
26
+ } from "../local-path-codec.js";
27
+ import { SKILLS_KEY_PREFIX } from "./doctor.js";
23
28
  import {
24
29
  downloadFile,
25
30
  listRemoteFiles,
@@ -401,6 +406,27 @@ export type SyncProgressEvent =
401
406
  path: string;
402
407
  /** Server-compatible invalid-key code, such as INVALID_KEY_CONTROL_CHARS. */
403
408
  errorCode?: string;
409
+ }
410
+ | {
411
+ /**
412
+ * Emitted by the PULL leg once per remote key skipped because its
413
+ * spelling percent-decodes to the same logical path as an
414
+ * already-journaled key with a DIFFERENT spelling — the mixed-version
415
+ * amplifier (a pre-codec peer minting `%3A`/`%253A` twins of a
416
+ * canonical `:` key). Downloading the twin would materialize a junk
417
+ * local dir and fork the journal into a second key family for one
418
+ * logical path, so the established spelling wins and the twin is
419
+ * skipped. Like `skip-size-limit`, this is deliberately NOT
420
+ * `type: "error"` (policy hq-sync-deliberate-skip-not-fatal-error-exit2:
421
+ * a recurring benign per-file skip emitted as an error lands in the
422
+ * runner's `errors[]`, forces exit 2 on every pass, and the menubar
423
+ * Sentry-alerts each time). The US-003 vault doctor is the cleanup
424
+ * path that collapses the junk family itself.
425
+ */
426
+ type: "skip-junk-key-spelling";
427
+ path: string;
428
+ /** The already-journaled spelling that owns this logical path. */
429
+ journaledKey: string;
404
430
  };
405
431
 
406
432
  export interface SyncOptions {
@@ -1211,6 +1237,18 @@ async function executeConflictExecutor(
1211
1237
  counters.filesOutOfScope++;
1212
1238
  continue;
1213
1239
  }
1240
+ if (item.action === "skip-junk-key-spelling") {
1241
+ // Deliberate, recoverable skip — counted as skipped and surfaced via a
1242
+ // dedicated warning event, never `type: "error"` (see the event doc /
1243
+ // policy hq-sync-deliberate-skip-not-fatal-error-exit2).
1244
+ counters.filesSkipped++;
1245
+ run.emit({
1246
+ type: "skip-junk-key-spelling",
1247
+ path: item.remoteFile.key,
1248
+ journaledKey: item.journaledKey,
1249
+ });
1250
+ continue;
1251
+ }
1214
1252
  if (item.action === "tombstone-delete") {
1215
1253
  executeFileTombstoneDelete(run, item, counters);
1216
1254
  continue;
@@ -2111,6 +2149,14 @@ type PullPlanItem =
2111
2149
  // the remote LIST (and accessible per STS) but deliberately not downloaded
2112
2150
  // because the membership's sync scope doesn't cover them.
2113
2151
  | { action: "skip-out-of-scope"; remoteFile: RemoteFile; localPath: string }
2152
+ | {
2153
+ // Mixed-version amplifier guard — see the `skip-junk-key-spelling`
2154
+ // event doc. Carries the journaled spelling for the warning surface.
2155
+ action: "skip-junk-key-spelling";
2156
+ remoteFile: RemoteFile;
2157
+ localPath: string;
2158
+ journaledKey: string;
2159
+ }
2114
2160
  // Remote key present in the LIST but carrying a FILE_TOMBSTONE that marks it
2115
2161
  // intentionally deleted (and the remote object is NOT a newer re-create). The
2116
2162
  // executor deletes any local copy and drops the journal entry — the
@@ -2208,6 +2254,39 @@ interface PullPlan {
2208
2254
  const PULL_INTENTIONAL_DELETE_MIN_ABS = 10;
2209
2255
  const PULL_INTENTIONAL_DELETE_RATIO = 0.1;
2210
2256
 
2257
+ /**
2258
+ * win32 only: true when an existing local symlink's TARGET exists but the
2259
+ * link cannot be traversed as that target's type — the file-flavor-link-at-
2260
+ * a-directory shape minted while the target was absent (or by an older
2261
+ * client). A dangling link is NOT broken-flavored (there is nothing to
2262
+ * repair until the target appears). POSIX links are flavorless: always
2263
+ * false off win32.
2264
+ */
2265
+ export function win32SymlinkFlavorBroken(
2266
+ localPath: string,
2267
+ win32: boolean = process.platform === "win32",
2268
+ ): boolean {
2269
+ if (!win32) return false;
2270
+ let target: string;
2271
+ try {
2272
+ target = fs.readlinkSync(localPath);
2273
+ } catch {
2274
+ return false;
2275
+ }
2276
+ const resolved = path.resolve(path.dirname(localPath), target);
2277
+ let targetIsDir: boolean;
2278
+ try {
2279
+ targetIsDir = fs.statSync(resolved).isDirectory();
2280
+ } catch {
2281
+ return false; // dangling target — nothing to repair yet
2282
+ }
2283
+ try {
2284
+ return fs.statSync(localPath).isDirectory() !== targetIsDir;
2285
+ } catch {
2286
+ return true; // target exists but the link cannot traverse: wrong flavor
2287
+ }
2288
+ }
2289
+
2211
2290
  function computePullPlan(
2212
2291
  remoteFiles: RemoteFile[],
2213
2292
  journal: SyncJournal,
@@ -2245,6 +2324,24 @@ function computePullPlan(
2245
2324
  localPath: string;
2246
2325
  }> = [];
2247
2326
 
2327
+ // Junk-spelling guard (mixed-version amplifier): index every journaled key
2328
+ // by its canonical (percent-decode fixpoint) form so a remote twin minted
2329
+ // under a different spelling is recognized before it materializes. When
2330
+ // several journaled spellings already share one canonical form (a
2331
+ // pre-guard fork), the exactly-canonical spelling is preferred as the
2332
+ // representative; entries journaled under their own exact spelling are
2333
+ // never gated by this map (see the guard below), so pre-existing families
2334
+ // keep syncing untouched until the vault doctor (US-003) collapses them.
2335
+ const journaledKeyByCanonical = new Map<string, string>();
2336
+ for (const journaledKey of Object.keys(journal.files)) {
2337
+ if (!journaledKey.startsWith(SKILLS_KEY_PREFIX)) continue;
2338
+ const canonical = canonicalVaultKeySpelling(journaledKey);
2339
+ const existing = journaledKeyByCanonical.get(canonical);
2340
+ if (existing === undefined || journaledKey === canonical) {
2341
+ journaledKeyByCanonical.set(canonical, journaledKey);
2342
+ }
2343
+ }
2344
+
2248
2345
  // Remote keys that ALSO appear as an ancestor of another remote key — i.e.
2249
2346
  // keys that carry child objects under `${key}/…` in this same LIST. The
2250
2347
  // vault stores a directory OVERLAY (a symlink into another tree, e.g. a
@@ -2406,6 +2503,43 @@ function computePullPlan(
2406
2503
  continue;
2407
2504
  }
2408
2505
 
2506
+ // Junk-spelling guard — SKILL-FARM KEYS ONLY. The amplifier is specific
2507
+ // to `.claude/skills/<ns>:<skill>` wrapper spellings; outside that prefix,
2508
+ // keys whose percent-decode fixpoints collide (`notes/a b.md` vs a genuine
2509
+ // `notes/a%20b.md`) are DISTINCT files that must both pull.
2510
+ // Within the farm: a remote key that is NOT journaled under its own
2511
+ // spelling but whose percent-decode fixpoint matches an already-journaled
2512
+ // key with a different spelling is a mixed-version twin (`%3A`/`%253A`
2513
+ // generations of a canonical `:` key). Materializing it would mint junk
2514
+ // local dirs and fork the journal into a second key family for the same
2515
+ // logical path — skip it (loudly, never fatally; see the
2516
+ // `skip-junk-key-spelling` event doc) and leave the journaled spelling
2517
+ // authoritative for this machine.
2518
+ if (
2519
+ journalEntry === undefined &&
2520
+ remoteFile.key.startsWith(SKILLS_KEY_PREFIX) &&
2521
+ // Gate NON-canonical remote spellings only. A remote key that already
2522
+ // IS the canonical spelling must always be downloadable — after the
2523
+ // doctor collapses the vault, a machine whose journal still holds only
2524
+ // the junk spelling would otherwise skip the canonical object forever
2525
+ // (and the delete-resync twin protection keeps that stale junk entry),
2526
+ // deadlocking it out of every future update.
2527
+ canonicalVaultKeySpelling(remoteFile.key) !== remoteFile.key
2528
+ ) {
2529
+ const journaledTwin = journaledKeyByCanonical.get(
2530
+ canonicalVaultKeySpelling(remoteFile.key),
2531
+ );
2532
+ if (journaledTwin !== undefined && journaledTwin !== remoteFile.key) {
2533
+ items.push({
2534
+ action: "skip-junk-key-spelling",
2535
+ remoteFile,
2536
+ localPath,
2537
+ journaledKey: journaledTwin,
2538
+ });
2539
+ continue;
2540
+ }
2541
+ }
2542
+
2409
2543
  // ── FILE_TOMBSTONE consult (delete-resync) ───────────────────────────────
2410
2544
  // A remote object present in the LIST may be an intentionally-deleted key
2411
2545
  // (a peer re-pushed it, or its delete-marker hasn't propagated to this
@@ -2652,6 +2786,25 @@ function computePullPlan(
2652
2786
  continue;
2653
2787
  }
2654
2788
  if (journalEntry && !localChanged && !remoteChanged) {
2789
+ // Flavor-health repair (win32 skill farm): a link journaled while its
2790
+ // target was absent was minted file-flavor; once the target directory
2791
+ // exists the hashes still match (hash = sha256(target)) so this
2792
+ // branch would skip it forever, leaving the wrapper broken. Re-download
2793
+ // instead — the materializer's flavor-aware path recreates it 'dir'.
2794
+ if (
2795
+ isLocalSymlink &&
2796
+ remoteFile.key.startsWith(SKILLS_KEY_PREFIX) &&
2797
+ win32SymlinkFlavorBroken(localPath)
2798
+ ) {
2799
+ items.push({
2800
+ action: "download",
2801
+ remoteFile,
2802
+ localPath,
2803
+ isNew: false,
2804
+ localSnapshot: plannedLocalSnapshot,
2805
+ });
2806
+ continue;
2807
+ }
2655
2808
  items.push({ action: "skip-unchanged", remoteFile, localPath });
2656
2809
  continue;
2657
2810
  }
@@ -2845,7 +2998,20 @@ function computePullPlan(
2845
2998
  // as a local delete + journal removal. Symmetric to the push side's
2846
2999
  // `propagateDeletes` plan in share.ts.
2847
3000
  const remoteKeySet = new Set<string>();
2848
- for (const rf of remoteFiles) remoteKeySet.add(rf.key);
3001
+ // Skill-farm twin protection (companion to the junk-spelling pull guard):
3002
+ // when the LIST holds only a junk spelling of a journaled canonical key,
3003
+ // the logical object is still remotely present — a raw has() would class
3004
+ // the canonical journal entry as remote-deleted, unlink the good local
3005
+ // wrapper, and drop the journal entry (after which the next pull would
3006
+ // materialize the junk spelling the guard just refused). Track canonical
3007
+ // forms of listed skill keys and consult them below.
3008
+ const remoteCanonicalSkillKeys = new Set<string>();
3009
+ for (const rf of remoteFiles) {
3010
+ remoteKeySet.add(rf.key);
3011
+ if (rf.key.startsWith(SKILLS_KEY_PREFIX)) {
3012
+ remoteCanonicalSkillKeys.add(canonicalVaultKeySpelling(rf.key));
3013
+ }
3014
+ }
2849
3015
  const tombstones: PullPlan["tombstones"] = [];
2850
3016
  for (const key of Object.keys(journal.files)) {
2851
3017
  // Compare membership in POSIX space. A pre-5.47.2 Windows journal key can
@@ -2855,6 +3021,18 @@ function computePullPlan(
2855
3021
  // POSIX compare is defense-in-depth (ridge data-loss, feedback_b8d09d0f).
2856
3022
  const posixKey = toPosixKey(key);
2857
3023
  if (remoteKeySet.has(posixKey)) continue;
3024
+ if (
3025
+ posixKey.startsWith(SKILLS_KEY_PREFIX) &&
3026
+ remoteCanonicalSkillKeys.has(canonicalVaultKeySpelling(posixKey)) &&
3027
+ // An EXACT FILE_TOMBSTONE for the journaled key is an authoritative
3028
+ // delete and wins over the twin protection — a lingering junk
3029
+ // spelling in the LIST must not keep a deliberately-deleted wrapper
3030
+ // alive forever. (The key is absent from the LIST here, so there is
3031
+ // no newer re-create to defer to.)
3032
+ fileTombstones.get(posixKey) === undefined
3033
+ ) {
3034
+ continue;
3035
+ }
2858
3036
  const localPath = resolveContainedVaultPath(companyRoot, key);
2859
3037
  if (localPath === null) continue;
2860
3038
  // PersonalMode key gating — mirror the download branch. Local (non-cloud)
@@ -3040,5 +3218,9 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
3040
3218
  console.warn(
3041
3219
  ` ! ${event.path} skipped — invalid vault key (${event.errorCode ?? "INVALID_KEY_COMPANIES_SCOPED"})`,
3042
3220
  );
3221
+ } else if (event.type === "skip-junk-key-spelling") {
3222
+ console.warn(
3223
+ ` ! ${event.path} skipped — junk key spelling; this logical path is already synced as ${event.journaledKey} (mixed-version peer artifact; the vault doctor collapses the family)`,
3224
+ );
3043
3225
  }
3044
3226
  }
@@ -14,18 +14,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
14
  // Sandbox HOME *before* importing the module — it reads os.homedir() at load
15
15
  // time to compute the cache file path.
16
16
  let originalHome: string | undefined;
17
+ let originalUserProfile: string | undefined;
17
18
  let tmpHome: string;
18
19
 
19
20
  beforeEach(() => {
20
21
  originalHome = process.env.HOME;
22
+ originalUserProfile = process.env.USERPROFILE;
21
23
  tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "hq-cognito-auth-test-"));
22
24
  process.env.HOME = tmpHome;
25
+ process.env.USERPROFILE = tmpHome;
23
26
  vi.resetModules();
24
27
  });
25
28
 
26
29
  afterEach(() => {
27
30
  if (originalHome === undefined) delete process.env.HOME;
28
31
  else process.env.HOME = originalHome;
32
+ if (originalUserProfile === undefined) delete process.env.USERPROFILE;
33
+ else process.env.USERPROFILE = originalUserProfile;
29
34
  fs.rmSync(tmpHome, { recursive: true, force: true });
30
35
  vi.unstubAllGlobals();
31
36
  vi.restoreAllMocks();
package/src/index.ts CHANGED
@@ -300,6 +300,16 @@ export type { ReindexOptions, ReindexResult } from "./cli/index.js";
300
300
  export { rescue, buildRescueArgs } from "./cli/index.js";
301
301
  export type { RescueOptions, RescueResult } from "./cli/index.js";
302
302
 
303
+ // `hq sync doctor` — skill-key dedupe + local farm GC (US-003). Consumed by
304
+ // @indigoai-us/hq-cli's `sync doctor` subcommand.
305
+ export { syncDoctor, SKILLS_KEY_PREFIX } from "./cli/index.js";
306
+ export type {
307
+ SyncDoctorOptions,
308
+ SyncDoctorResult,
309
+ SyncDoctorPlan,
310
+ DoctorStore,
311
+ } from "./cli/index.js";
312
+
303
313
  export type {
304
314
  EntityContext,
305
315
  VaultCredentials,
@@ -18,6 +18,8 @@
18
18
  import * as fs from "fs";
19
19
  import * as path from "path";
20
20
 
21
+ import { localPathForVaultKey } from "../local-path-codec.js";
22
+
21
23
  export { readShortMachineId, getOrCreateMachineId } from "./machine-id.js";
22
24
 
23
25
  /**
@@ -58,7 +60,10 @@ export function writeConflictFile(
58
60
  conflictRelative: string,
59
61
  contents: Buffer,
60
62
  ): void {
61
- const abs = path.join(hqRoot, conflictRelative);
63
+ // Codec boundary: conflict paths are canonical (hq-root-relative,
64
+ // forward-slash) keys, so the mirror must land at the encoded local name
65
+ // on win32 — a raw join of a colon key can never be created there.
66
+ const abs = localPathForVaultKey(hqRoot, conflictRelative);
62
67
  fs.mkdirSync(path.dirname(abs), { recursive: true });
63
68
  fs.writeFileSync(abs, contents);
64
69
  }
@@ -22,6 +22,7 @@
22
22
  import * as crypto from "crypto";
23
23
  import * as fs from "fs";
24
24
  import * as path from "path";
25
+ import { localPathForVaultKey } from "../local-path-codec.js";
25
26
  import type { ConflictIndex, ConflictIndexEntry } from "../types.js";
26
27
 
27
28
  const CONFLICTS_DIR = ".hq-conflicts";
@@ -215,8 +216,12 @@ export function pruneConflictIndex(hqRoot: string): PruneConflictIndexResult {
215
216
  const mirrorsToRemove: string[] = [];
216
217
 
217
218
  for (const entry of index.conflicts) {
218
- const mirrorAbs = path.join(hqRoot, entry.conflictPath);
219
- const originalAbs = path.join(hqRoot, entry.originalPath);
219
+ // Codec boundary: index rows store canonical (hq-root-relative) keys, so
220
+ // both probes must resolve through the codec — a raw join of a win32
221
+ // colon key always lstat-fails, which would mis-classify a live encoded
222
+ // mirror as an orphaned row and silently drop it.
223
+ const mirrorAbs = localPathForVaultKey(hqRoot, entry.conflictPath);
224
+ const originalAbs = localPathForVaultKey(hqRoot, entry.originalPath);
220
225
 
221
226
  let mirrorStat: fs.Stats;
222
227
  try {