@indigoai-us/hq-cloud 6.15.31 → 6.15.32

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 (125) hide show
  1. package/dist/bandwidth.d.ts +128 -0
  2. package/dist/bandwidth.d.ts.map +1 -0
  3. package/dist/bandwidth.js +254 -0
  4. package/dist/bandwidth.js.map +1 -0
  5. package/dist/bandwidth.test.d.ts +2 -0
  6. package/dist/bandwidth.test.d.ts.map +1 -0
  7. package/dist/bandwidth.test.js +243 -0
  8. package/dist/bandwidth.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  10. package/dist/bin/sync-runner-company.js +13 -24
  11. package/dist/bin/sync-runner-company.js.map +1 -1
  12. package/dist/bin/sync-runner-company.test.js +71 -0
  13. package/dist/bin/sync-runner-company.test.js.map +1 -1
  14. package/dist/bin/sync-runner-watch-loop.d.ts +10 -0
  15. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  16. package/dist/bin/sync-runner-watch-loop.js +181 -16
  17. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  18. package/dist/bin/sync-runner-watch-routes.d.ts +16 -1
  19. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  20. package/dist/bin/sync-runner-watch-routes.js +40 -2
  21. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  22. package/dist/bin/sync-runner-watch-routes.test.js +53 -1
  23. package/dist/bin/sync-runner-watch-routes.test.js.map +1 -1
  24. package/dist/bin/sync-runner.d.ts +15 -3
  25. package/dist/bin/sync-runner.d.ts.map +1 -1
  26. package/dist/bin/sync-runner.js +66 -13
  27. package/dist/bin/sync-runner.js.map +1 -1
  28. package/dist/bin/sync-runner.test.js +433 -13
  29. package/dist/bin/sync-runner.test.js.map +1 -1
  30. package/dist/cli/conflict.d.ts +25 -2
  31. package/dist/cli/conflict.d.ts.map +1 -1
  32. package/dist/cli/conflict.js +29 -4
  33. package/dist/cli/conflict.js.map +1 -1
  34. package/dist/cli/share.d.ts +40 -4
  35. package/dist/cli/share.d.ts.map +1 -1
  36. package/dist/cli/share.js +478 -86
  37. package/dist/cli/share.js.map +1 -1
  38. package/dist/cli/share.test.js +845 -150
  39. package/dist/cli/share.test.js.map +1 -1
  40. package/dist/cli/sync-scope.test.js +147 -18
  41. package/dist/cli/sync-scope.test.js.map +1 -1
  42. package/dist/cli/sync.d.ts +20 -0
  43. package/dist/cli/sync.d.ts.map +1 -1
  44. package/dist/cli/sync.js +261 -29
  45. package/dist/cli/sync.js.map +1 -1
  46. package/dist/cli/sync.test.js +519 -90
  47. package/dist/cli/sync.test.js.map +1 -1
  48. package/dist/journal-delta-writes.test.d.ts +2 -0
  49. package/dist/journal-delta-writes.test.d.ts.map +1 -0
  50. package/dist/journal-delta-writes.test.js +140 -0
  51. package/dist/journal-delta-writes.test.js.map +1 -0
  52. package/dist/journal.d.ts +1 -0
  53. package/dist/journal.d.ts.map +1 -1
  54. package/dist/journal.js +155 -25
  55. package/dist/journal.js.map +1 -1
  56. package/dist/journal.test.js +29 -11
  57. package/dist/journal.test.js.map +1 -1
  58. package/dist/object-io.d.ts +34 -13
  59. package/dist/object-io.d.ts.map +1 -1
  60. package/dist/object-io.js +154 -29
  61. package/dist/object-io.js.map +1 -1
  62. package/dist/operation-lock.d.ts +12 -0
  63. package/dist/operation-lock.d.ts.map +1 -1
  64. package/dist/operation-lock.js +103 -21
  65. package/dist/operation-lock.js.map +1 -1
  66. package/dist/operation-lock.test.js +90 -0
  67. package/dist/operation-lock.test.js.map +1 -1
  68. package/dist/outcome-telemetry.d.ts +7 -1
  69. package/dist/outcome-telemetry.d.ts.map +1 -1
  70. package/dist/outcome-telemetry.js +15 -9
  71. package/dist/outcome-telemetry.js.map +1 -1
  72. package/dist/outcome-telemetry.test.js +15 -0
  73. package/dist/outcome-telemetry.test.js.map +1 -1
  74. package/dist/outposts/client.d.ts +2 -2
  75. package/dist/outposts/client.d.ts.map +1 -1
  76. package/dist/outposts/client.js +4 -3
  77. package/dist/outposts/client.js.map +1 -1
  78. package/dist/outposts/command.d.ts.map +1 -1
  79. package/dist/outposts/command.js +9 -6
  80. package/dist/outposts/command.js.map +1 -1
  81. package/dist/outposts/command.test.js +59 -2
  82. package/dist/outposts/command.test.js.map +1 -1
  83. package/dist/outposts/remote-command.d.ts +8 -8
  84. package/dist/outposts/remote-command.d.ts.map +1 -1
  85. package/dist/outposts/remote-command.js +10 -8
  86. package/dist/outposts/remote-command.js.map +1 -1
  87. package/dist/prefix-coalesce.d.ts +14 -0
  88. package/dist/prefix-coalesce.d.ts.map +1 -1
  89. package/dist/prefix-coalesce.js +29 -0
  90. package/dist/prefix-coalesce.js.map +1 -1
  91. package/dist/prefix-coalesce.test.js +22 -1
  92. package/dist/prefix-coalesce.test.js.map +1 -1
  93. package/dist/skill-telemetry.d.ts +6 -1
  94. package/dist/skill-telemetry.d.ts.map +1 -1
  95. package/dist/skill-telemetry.js +14 -8
  96. package/dist/skill-telemetry.js.map +1 -1
  97. package/dist/skill-telemetry.test.js +43 -0
  98. package/dist/skill-telemetry.test.js.map +1 -1
  99. package/dist/sts-credential-refresh.test.js +87 -2
  100. package/dist/sts-credential-refresh.test.js.map +1 -1
  101. package/dist/sync/state-store.d.ts.map +1 -1
  102. package/dist/sync/state-store.js +20 -1
  103. package/dist/sync/state-store.js.map +1 -1
  104. package/dist/sync/state-store.test.js +64 -0
  105. package/dist/sync/state-store.test.js.map +1 -1
  106. package/dist/sync-core.d.ts.map +1 -1
  107. package/dist/sync-core.js +7 -2
  108. package/dist/sync-core.js.map +1 -1
  109. package/dist/sync-core.test.js +36 -0
  110. package/dist/sync-core.test.js.map +1 -1
  111. package/dist/telemetry.d.ts +30 -6
  112. package/dist/telemetry.d.ts.map +1 -1
  113. package/dist/telemetry.js +331 -135
  114. package/dist/telemetry.js.map +1 -1
  115. package/dist/telemetry.test.js +403 -0
  116. package/dist/telemetry.test.js.map +1 -1
  117. package/dist/types.d.ts +26 -3
  118. package/dist/types.d.ts.map +1 -1
  119. package/dist/watcher.d.ts +76 -0
  120. package/dist/watcher.d.ts.map +1 -1
  121. package/dist/watcher.js +105 -3
  122. package/dist/watcher.js.map +1 -1
  123. package/dist/watcher.test.js +165 -1
  124. package/dist/watcher.test.js.map +1 -1
  125. package/package.json +1 -1
@@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
5
5
  import * as fs from "fs";
6
6
  import * as path from "path";
7
7
  import * as os from "os";
8
+ import * as crypto from "crypto";
8
9
  import { clearContextCache } from "../context.js";
9
10
  import { lockPathFor } from "../operation-lock.js";
10
11
  // Re-export node:fs as a mutable module so unreadable-link regressions can
@@ -13,6 +14,13 @@ vi.mock("fs", async (importOriginal) => {
13
14
  const actual = await importOriginal();
14
15
  return { ...actual };
15
16
  });
17
+ // Same mutable-namespace trick as `fs` above: `skip` is an interactive-only
18
+ // resolution, so the only way to exercise its arm is to stand in for the
19
+ // prompt. Re-exporting the real module keeps every other call site genuine.
20
+ vi.mock("./conflict.js", async (importOriginal) => {
21
+ const actual = await importOriginal();
22
+ return { ...actual };
23
+ });
16
24
  describe("sync mutation stdin boundary", () => {
17
25
  it("accepts only the exact sidecar command and emits one discriminated upsert result", async () => {
18
26
  expect(SYNC_MUTATION_STDIN_COMMAND).toEqual(["sync", "mutation", "--stdin-json"]);
@@ -115,10 +123,37 @@ vi.mock("./reindex.js", () => ({
115
123
  import { sync, reportNewFilesToNotify, applyRealtimeDeltaPage, isSyncMutationStdinCommand, runSyncMutationStdinJson, SYNC_MUTATION_STDIN_COMMAND, win32SymlinkFlavorBroken, } from "./sync.js";
116
124
  import { share } from "./share.js";
117
125
  import * as s3Module from "../s3.js";
126
+ import * as conflictModule from "./conflict.js";
118
127
  import { reindex } from "./reindex.js";
119
128
  import { readSyncProgress } from "../sync-progress.js";
120
129
  import { VaultAuthError } from "../vault-client.js";
121
- import { readJournal, getEntry } from "../journal.js";
130
+ import { readJournal, getEntry, listJournals } from "../journal.js";
131
+ /**
132
+ * Read a journal the way production does.
133
+ *
134
+ * These tests used to `JSON.parse` `sync-journal.<slug>.json` directly, back
135
+ * when that file held a full copy of the journal. It is now a small locator —
136
+ * the authority is the v3 state store — so the assertions below go through
137
+ * `readJournal`, which is what the sync engine itself uses. The slug is
138
+ * recovered from the path so each call site keeps naming the journal it means.
139
+ */
140
+ function journalAt(journalPath) {
141
+ const slug = path
142
+ .basename(journalPath)
143
+ .replace(/^sync-journal\./, "")
144
+ .replace(/\.json$/, "");
145
+ return readJournal(slug);
146
+ }
147
+ /**
148
+ * The single `.conflict-` sidecar in a directory. Asserting through this rather
149
+ * than re-globbing inline keeps "exactly one was minted" enforced everywhere a
150
+ * test reaches for the preserved side.
151
+ */
152
+ function sidecarIn(dir) {
153
+ const found = fs.readdirSync(dir).filter((f) => f.includes(".conflict-"));
154
+ expect(found).toHaveLength(1);
155
+ return path.join(dir, found[0]);
156
+ }
122
157
  const mockConfig = {
123
158
  apiUrl: "https://vault-api.test",
124
159
  authToken: "test-jwt-token",
@@ -466,7 +501,7 @@ describe("sync", () => {
466
501
  const result = await sync({ vaultConfig: mockConfig, hqRoot: tmpDir });
467
502
  expect(result.filesDownloaded).toBe(2);
468
503
  });
469
- it("detects conflicts with local changes and keeps local on --on-conflict keep", async () => {
504
+ it("detects conflicts with local changes and adopts the cloud copy on --on-conflict keep", async () => {
470
505
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
471
506
  fs.mkdirSync(companyDocs, { recursive: true });
472
507
  fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
@@ -490,39 +525,386 @@ describe("sync", () => {
490
525
  });
491
526
  expect(result.conflicts).toBe(1);
492
527
  expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
493
- expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
494
- expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local version");
528
+ // `keep` adopts the cloud body at the original name…
529
+ expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("mock file content");
530
+ // …and the local one survives beside it.
531
+ expect(fs.readFileSync(sidecarIn(companyDocs), "utf-8")).toBe("local version");
495
532
  });
496
- it("conflict keep marks the entry localDiverges (journal-honesty: kept copy never matched the recorded remote etag)", async () => {
533
+ it("rechecks the current local body when executing a planned conflict", async () => {
497
534
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
498
535
  fs.mkdirSync(companyDocs, { recursive: true });
499
- fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
536
+ const localPath = path.join(companyDocs, "handoff.md");
537
+ fs.writeFileSync(localPath, "local body seen by the planner");
500
538
  fs.writeFileSync(journalPath, JSON.stringify({
501
539
  version: "1",
502
540
  lastSync: new Date().toISOString(),
503
541
  files: {
504
542
  "docs/handoff.md": {
505
- hash: "old-hash-from-last-sync",
506
- size: 20,
543
+ hash: "common-base-hash",
544
+ size: 16,
507
545
  syncedAt: new Date(Date.now() - 3600000).toISOString(),
508
546
  direction: "down",
547
+ remoteEtag: "common-base-etag",
509
548
  },
510
549
  },
511
550
  }));
551
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
552
+ {
553
+ key: "docs/handoff.md",
554
+ size: "mock file content".length,
555
+ lastModified: new Date(),
556
+ etag: '"new-remote-etag"',
557
+ },
558
+ ]);
559
+ let changedAfterPlan = false;
512
560
  const result = await sync({
513
561
  company: "acme",
514
562
  onConflict: "keep",
515
563
  vaultConfig: mockConfig,
516
564
  hqRoot: tmpDir,
565
+ onEvent: (event) => {
566
+ if (event.type === "plan" && !changedAfterPlan) {
567
+ changedAfterPlan = true;
568
+ // The local writer converged this path with the cloud after the
569
+ // whole-run preview classified it, but before its action executes.
570
+ fs.writeFileSync(localPath, "mock file content");
571
+ }
572
+ },
573
+ });
574
+ expect(changedAfterPlan).toBe(true);
575
+ expect(result.conflicts).toBe(0);
576
+ expect(result.conflictPaths).toEqual([]);
577
+ expect(fs.readdirSync(companyDocs).filter((name) => name.includes(".conflict-")))
578
+ .toEqual([]);
579
+ const entry = journalAt(journalPath).files["docs/handoff.md"];
580
+ expect(entry).toMatchObject({
581
+ hash: crypto.createHash("sha256").update("mock file content").digest("hex"),
582
+ remoteEtag: "new-remote-etag",
583
+ direction: "down",
517
584
  });
585
+ expect(entry.localDiverges).toBeFalsy();
586
+ });
587
+ it("conflict skip marks the entry localDiverges (journal-honesty: the held copy never matched the recorded remote etag)", async () => {
588
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
589
+ fs.mkdirSync(companyDocs, { recursive: true });
590
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
591
+ fs.writeFileSync(journalPath, JSON.stringify({
592
+ version: "1",
593
+ lastSync: new Date().toISOString(),
594
+ files: {
595
+ "docs/handoff.md": {
596
+ hash: "old-hash-from-last-sync",
597
+ size: 20,
598
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
599
+ direction: "down",
600
+ },
601
+ },
602
+ }));
603
+ // `keep` no longer holds, so the honesty flag now belongs to the arms that
604
+ // still do. `skip` is the interactive one and is exercised through the
605
+ // prompt stand-in.
606
+ const resolveSpy = vi
607
+ .spyOn(conflictModule, "resolveConflict")
608
+ .mockResolvedValue("skip");
609
+ let result;
610
+ try {
611
+ result = await sync({
612
+ company: "acme",
613
+ vaultConfig: mockConfig,
614
+ hqRoot: tmpDir,
615
+ });
616
+ }
617
+ finally {
618
+ resolveSpy.mockRestore();
619
+ }
518
620
  expect(result.conflicts).toBe(1);
519
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
621
+ const journal = journalAt(journalPath);
520
622
  // remoteEtag still recorded so the conflict can't re-fire (#137 invariant)…
521
623
  expect(journal.files["docs/handoff.md"].remoteEtag).toBeTruthy();
522
- // …but the kept-local copy is flagged divergent so a later currency-gated
624
+ // …but the held local copy is flagged divergent so a later currency-gated
523
625
  // delete cannot fire on a journal that lies about local==remote.
524
626
  expect(journal.files["docs/handoff.md"].localDiverges).toBe(true);
525
627
  });
628
+ // `publish-local` reaches the PULL leg too, because the strategy is set once
629
+ // for a bidirectional run. It has no meaning here — there is nothing to
630
+ // publish while deciding what to do with an incoming object — so it MUST
631
+ // degrade to `keep`. If it instead fell through to the download branch it
632
+ // would overwrite the very local body the operator elected to publish, which
633
+ // is precisely the data loss the strategy exists to prevent.
634
+ it("conflict publish-local on the pull leg keeps local and never downloads over it", async () => {
635
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
636
+ fs.mkdirSync(companyDocs, { recursive: true });
637
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "my elected local version");
638
+ fs.writeFileSync(journalPath, JSON.stringify({
639
+ version: "1",
640
+ lastSync: new Date().toISOString(),
641
+ files: {
642
+ "docs/handoff.md": {
643
+ hash: "old-hash-from-last-sync",
644
+ size: 20,
645
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
646
+ direction: "down",
647
+ },
648
+ },
649
+ }));
650
+ const result = await sync({
651
+ company: "acme",
652
+ onConflict: "publish-local",
653
+ vaultConfig: mockConfig,
654
+ hqRoot: tmpDir,
655
+ });
656
+ expect(result.conflicts).toBe(1);
657
+ expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("my elected local version");
658
+ // Identical bookkeeping to `keep`: the etag is stamped so the conflict
659
+ // cannot re-fire, and the honesty flag is armed so the push leg knows this
660
+ // is the divergent body it may promote under an If-Match fence.
661
+ const journal = journalAt(journalPath);
662
+ expect(journal.files["docs/handoff.md"].remoteEtag).toBeTruthy();
663
+ expect(journal.files["docs/handoff.md"].localDiverges).toBe(true);
664
+ });
665
+ // --- `keep` semantics, revised 2026-08-21 -------------------------------
666
+ //
667
+ // `keep` now means "the CLOUD copy becomes the working copy; my local body is
668
+ // preserved beside it as a `.conflict-` sidecar." It used to mean the
669
+ // opposite — hold local, stamp the remote etag, arm `localDiverges` — and
670
+ // that combination was terminal: push refused the divergent local, pull saw
671
+ // the remote unchanged so it never replaced the entry, and every subsequent
672
+ // `keep` re-armed the flag. The file could converge in neither direction, and
673
+ // because the desktop app hardcodes `--on-conflict keep`, that was the
674
+ // default outcome for every real conflict.
675
+ //
676
+ // Electing the cloud body clears the divergence BY CONSTRUCTION — the entry
677
+ // then genuinely records what sits on disk — while the local bytes survive
678
+ // under the sidecar for the operator to re-apply. Nothing is destroyed; the
679
+ // working copy changes and the old one is renamed.
680
+ it("conflict keep takes the cloud body at the original path and preserves local in a `.conflict-` sidecar", async () => {
681
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
682
+ fs.mkdirSync(companyDocs, { recursive: true });
683
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
684
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
685
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
686
+ ]);
687
+ fs.writeFileSync(journalPath, JSON.stringify({
688
+ version: "1",
689
+ lastSync: new Date().toISOString(),
690
+ files: {
691
+ "docs/handoff.md": {
692
+ hash: "old-hash-from-last-sync",
693
+ size: 20,
694
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
695
+ direction: "down",
696
+ },
697
+ },
698
+ }));
699
+ const result = await sync({
700
+ company: "acme",
701
+ onConflict: "keep",
702
+ vaultConfig: mockConfig,
703
+ hqRoot: tmpDir,
704
+ });
705
+ expect(result.conflicts).toBe(1);
706
+ expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
707
+ // The cloud body is the working copy now.
708
+ expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("mock file content");
709
+ // …and the local body is not lost — it is sitting beside it.
710
+ const sidecars = fs
711
+ .readdirSync(companyDocs)
712
+ .filter((f) => f.includes(".conflict-"));
713
+ expect(sidecars).toHaveLength(1);
714
+ expect(fs.readFileSync(path.join(companyDocs, sidecars[0]), "utf-8")).toBe("local version");
715
+ });
716
+ it("conflict keep clears the fetched sidecar before rename for Windows compatibility", async () => {
717
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
718
+ fs.mkdirSync(companyDocs, { recursive: true });
719
+ const originalPath = path.join(companyDocs, "handoff.md");
720
+ fs.writeFileSync(originalPath, "local version");
721
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
722
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
723
+ ]);
724
+ fs.writeFileSync(journalPath, JSON.stringify({
725
+ version: "1",
726
+ lastSync: new Date().toISOString(),
727
+ files: {
728
+ "docs/handoff.md": {
729
+ hash: "old-hash-from-last-sync",
730
+ size: 20,
731
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
732
+ direction: "down",
733
+ },
734
+ },
735
+ }));
736
+ // Node's Windows rename does not replace an occupied destination. Model
737
+ // that rule only for the local→sidecar move; unrelated atomic index and
738
+ // journal renames keep using the host filesystem's normal behaviour.
739
+ const realRenameSync = fs.renameSync;
740
+ const renameSpy = vi.spyOn(fs, "renameSync").mockImplementation((from, to) => {
741
+ if (String(from) === originalPath &&
742
+ String(to).includes(".conflict-") &&
743
+ fs.existsSync(to)) {
744
+ throw errnoError("EEXIST");
745
+ }
746
+ return realRenameSync(from, to);
747
+ });
748
+ try {
749
+ await sync({
750
+ company: "acme",
751
+ onConflict: "keep",
752
+ vaultConfig: mockConfig,
753
+ hqRoot: tmpDir,
754
+ });
755
+ }
756
+ finally {
757
+ renameSpy.mockRestore();
758
+ }
759
+ expect(fs.readFileSync(originalPath, "utf-8")).toBe("mock file content");
760
+ expect(fs.readFileSync(sidecarIn(companyDocs), "utf-8")).toBe("local version");
761
+ });
762
+ it("conflict keep converges the journal on the REMOTE body and leaves localDiverges unset", async () => {
763
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
764
+ fs.mkdirSync(companyDocs, { recursive: true });
765
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
766
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
767
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
768
+ ]);
769
+ fs.writeFileSync(journalPath, JSON.stringify({
770
+ version: "1",
771
+ lastSync: new Date().toISOString(),
772
+ files: {
773
+ "docs/handoff.md": {
774
+ hash: "old-hash-from-last-sync",
775
+ size: 20,
776
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
777
+ direction: "down",
778
+ },
779
+ },
780
+ }));
781
+ await sync({
782
+ company: "acme",
783
+ onConflict: "keep",
784
+ vaultConfig: mockConfig,
785
+ hqRoot: tmpDir,
786
+ });
787
+ const entry = journalAt(journalPath).files["docs/handoff.md"];
788
+ expect(entry.remoteEtag).toBeTruthy();
789
+ // The whole point of the change: the entry describes the body that is
790
+ // actually on disk, so the honesty flag has nothing to report. A set flag
791
+ // here would re-create the deadlock this test exists to prevent.
792
+ expect(entry.localDiverges).toBeFalsy();
793
+ });
794
+ it("conflict keep records the REMOTE body's hash, so the entry is pushable instead of permanently divergent", async () => {
795
+ // This is the deadlock in one assertion. The old `keep` wrote the LOCAL
796
+ // hash next to the REMOTE etag, which is a self-contradictory entry: the
797
+ // push leg refuses to send a body the journal calls divergent, and the pull
798
+ // leg sees a remote whose etag already matches and leaves it alone. Writing
799
+ // the remote hash is what makes the entry describable — and therefore
800
+ // sendable — again.
801
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
802
+ fs.mkdirSync(companyDocs, { recursive: true });
803
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
804
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
805
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
806
+ ]);
807
+ fs.writeFileSync(journalPath, JSON.stringify({
808
+ version: "1",
809
+ lastSync: new Date().toISOString(),
810
+ files: {
811
+ "docs/handoff.md": {
812
+ hash: "old-hash-from-last-sync",
813
+ size: 20,
814
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
815
+ direction: "down",
816
+ },
817
+ },
818
+ }));
819
+ await sync({
820
+ company: "acme",
821
+ onConflict: "keep",
822
+ vaultConfig: mockConfig,
823
+ hqRoot: tmpDir,
824
+ });
825
+ // Pinned to the REMOTE body specifically, not to "whatever is on disk" —
826
+ // the old behaviour also left those two agreeing (it recorded the local
827
+ // hash and left the local file in place), so a disk-relative assertion
828
+ // would pass against the very bug this guards.
829
+ const remoteHash = crypto
830
+ .createHash("sha256")
831
+ .update("mock file content")
832
+ .digest("hex");
833
+ const entry = journalAt(journalPath).files["docs/handoff.md"];
834
+ expect(entry.hash).toBe(remoteHash);
835
+ });
836
+ it("conflict keep indexes the sidecar as the LOCAL body so /resolve-conflicts offers the right side", async () => {
837
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
838
+ fs.mkdirSync(companyDocs, { recursive: true });
839
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
840
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
841
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
842
+ ]);
843
+ fs.writeFileSync(journalPath, JSON.stringify({
844
+ version: "1",
845
+ lastSync: new Date().toISOString(),
846
+ files: {
847
+ "docs/handoff.md": {
848
+ hash: "old-hash-from-last-sync",
849
+ size: 20,
850
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
851
+ direction: "down",
852
+ },
853
+ },
854
+ }));
855
+ await sync({
856
+ company: "acme",
857
+ onConflict: "keep",
858
+ vaultConfig: mockConfig,
859
+ hqRoot: tmpDir,
860
+ });
861
+ const index = JSON.parse(fs.readFileSync(path.join(tmpDir, ".hq-conflicts", "index.json"), "utf-8"));
862
+ expect(index.conflicts).toHaveLength(1);
863
+ const entry = index.conflicts[0];
864
+ expect(entry.originalPath).toBe("companies/acme/docs/handoff.md");
865
+ expect(entry.preserved).toBe("local");
866
+ // The sidecar it points at holds the LOCAL body — the side the operator
867
+ // may want back. Reading the recorded path is the contract /resolve-conflicts
868
+ // relies on, so assert through it rather than re-globbing the directory.
869
+ expect(fs.readFileSync(path.join(tmpDir, entry.conflictPath), "utf-8")).toBe("local version");
870
+ });
871
+ it("interactive `skip` still leaves local in place — skip is not keep", async () => {
872
+ // `skip` means "do not touch this one, I will deal with it later". It is a
873
+ // separate arm from `keep` and must NOT adopt the cloud body; a strategy
874
+ // that says "leave it alone" may not rename the operator's file.
875
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
876
+ fs.mkdirSync(companyDocs, { recursive: true });
877
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
878
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
879
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"divergent"' },
880
+ ]);
881
+ fs.writeFileSync(journalPath, JSON.stringify({
882
+ version: "1",
883
+ lastSync: new Date().toISOString(),
884
+ files: {
885
+ "docs/handoff.md": {
886
+ hash: "old-hash-from-last-sync",
887
+ size: 20,
888
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
889
+ direction: "down",
890
+ },
891
+ },
892
+ }));
893
+ const resolveSpy = vi
894
+ .spyOn(conflictModule, "resolveConflict")
895
+ .mockResolvedValue("skip");
896
+ try {
897
+ await sync({
898
+ company: "acme",
899
+ vaultConfig: mockConfig,
900
+ hqRoot: tmpDir,
901
+ });
902
+ }
903
+ finally {
904
+ resolveSpy.mockRestore();
905
+ }
906
+ expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local version");
907
+ });
526
908
  it("a genuine download clears a stale localDiverges flag (entry is now truly local==remote)", async () => {
527
909
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
528
910
  fs.mkdirSync(companyDocs, { recursive: true });
@@ -548,7 +930,7 @@ describe("sync", () => {
548
930
  ]);
549
931
  await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
550
932
  expect(fs.existsSync(path.join(companyDocs, "handoff.md"))).toBe(true);
551
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
933
+ const journal = journalAt(journalPath);
552
934
  expect(journal.files["docs/handoff.md"]?.localDiverges).toBeFalsy();
553
935
  });
554
936
  it("leaves a downloaded unreadable link unjournaled without emitting a fatal error", async () => {
@@ -587,7 +969,7 @@ describe("sync", () => {
587
969
  samplePaths: [linkKey],
588
970
  });
589
971
  expect(events.some((event) => event.type === "error")).toBe(false);
590
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
972
+ const journal = journalAt(journalPath);
591
973
  expect(journal.files[linkKey]).toBeUndefined();
592
974
  }
593
975
  finally {
@@ -685,7 +1067,7 @@ describe("sync", () => {
685
1067
  expect(litter).toEqual([]);
686
1068
  // Journal re-stamped off the stale baseline (hash now matches local,
687
1069
  // remoteEtag recorded) so the phantom conflict can't re-fire.
688
- const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1070
+ const journalAfter = journalAt(journalPath);
689
1071
  expect(journalAfter.files["docs/handoff.md"].hash).not.toBe("stale-hash");
690
1072
  expect(journalAfter.files["docs/handoff.md"].remoteEtag).toBeTruthy();
691
1073
  });
@@ -743,7 +1125,7 @@ describe("sync", () => {
743
1125
  });
744
1126
  expect(events.some((event) => event.type === "error")).toBe(false);
745
1127
  expect(fs.readFileSync(localPath, "utf-8")).toBe("local version");
746
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1128
+ const journal = journalAt(journalPath);
747
1129
  expect(journal.files[linkKey].hash).toBe("stale-hash");
748
1130
  }
749
1131
  finally {
@@ -782,13 +1164,10 @@ describe("sync", () => {
782
1164
  });
783
1165
  expect(result.conflicts).toBe(1);
784
1166
  expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
785
- // Local preserved on keep.
786
- expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local version");
787
- // Mirror retained for inspection.
788
- const litter = fs
789
- .readdirSync(companyDocs)
790
- .filter((f) => f.includes(".conflict-"));
791
- expect(litter).toHaveLength(1);
1167
+ // Cloud body adopted at the original name on keep…
1168
+ expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("mock file content");
1169
+ // …and exactly one sidecar retained, holding the displaced local body.
1170
+ expect(fs.readFileSync(sidecarIn(companyDocs), "utf-8")).toBe("local version");
792
1171
  });
793
1172
  it("cloud-authoritative file is PULL-WINS on conflict: overwrites local, no mirror, no false-stamp", async () => {
794
1173
  // company-brief.md is server-regenerated (gardener), so a divergence is not
@@ -901,10 +1280,12 @@ describe("sync", () => {
901
1280
  // Surfaced as a conflict, NOT a silent download.
902
1281
  expect(result.conflicts).toBe(1);
903
1282
  expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
904
- expect(result.filesDownloaded).toBe(0);
905
- // The newer local bytes are intact — nothing was rolled back.
906
- expect(fs.readFileSync(localPath, "utf-8")).toBe(localContent);
907
- // Remote preserved as an inspection mirror rather than overwriting local.
1283
+ expect(result.filesDownloaded).toBe(1);
1284
+ // The newer local bytes are not lost — the rollback is survivable because
1285
+ // `keep` renames them aside rather than discarding them. The guard's job is
1286
+ // to route this through conflict resolution instead of a silent download;
1287
+ // which side ends up at the original name is the strategy's call.
1288
+ expect(fs.readFileSync(sidecarIn(path.dirname(localPath)), "utf-8")).toBe(localContent);
908
1289
  const litter = fs
909
1290
  .readdirSync(companyDocs)
910
1291
  .filter((f) => f.includes(".conflict-"));
@@ -964,8 +1345,8 @@ describe("sync", () => {
964
1345
  });
965
1346
  expect(result.conflicts).toBe(1);
966
1347
  expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
967
- expect(result.filesDownloaded).toBe(0);
968
- expect(fs.readFileSync(localPath, "utf-8")).toBe(localEdit);
1348
+ expect(result.filesDownloaded).toBe(1);
1349
+ expect(fs.readFileSync(sidecarIn(path.dirname(localPath)), "utf-8")).toBe(localEdit);
969
1350
  });
970
1351
  it("anti-rollback guard is direction-gated: a SMALLER remote on a clean CONSUMER copy (direction=down) downloads, not conflict", async () => {
971
1352
  // The device only ever PULLED this file (direction: "down") — a consumer
@@ -1089,18 +1470,16 @@ describe("sync", () => {
1089
1470
  // Surfaced as a conflict, NOT a silent download.
1090
1471
  expect(result.conflicts).toBe(1);
1091
1472
  expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
1092
- expect(result.filesDownloaded).toBe(0);
1093
- // The local edit is intact.
1094
- expect(fs.readFileSync(localPath, "utf-8")).toBe(localContent);
1095
- // Cloud copy preserved alongside it for inspection / `/resolve-conflicts`.
1096
- const litter = fs
1097
- .readdirSync(companyDocs)
1098
- .filter((f) => f.includes(".conflict-"));
1099
- expect(litter).toHaveLength(1);
1100
- expect(fs.readFileSync(path.join(companyDocs, litter[0]), "utf-8")).toBe("mock file content");
1473
+ expect(result.filesDownloaded).toBe(1);
1474
+ // The unjournaled local body is not lost — it is renamed aside, which is
1475
+ // what makes adopting the cloud copy survivable for a file that had no
1476
+ // sync history at all.
1477
+ expect(fs.readFileSync(sidecarIn(path.dirname(localPath)), "utf-8")).toBe(localContent);
1478
+ // The cloud body took the original name.
1479
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
1101
1480
  // And a durable record exists so the overwrite can never be silent.
1102
1481
  const index = JSON.parse(fs.readFileSync(path.join(tmpDir, ".hq-conflicts", "index.json"), "utf-8"));
1103
- expect(index.conflicts.some((c) => c.originalPath === "companies/acme/docs/handoff.md")).toBe(true);
1482
+ expect(index.conflicts.some((c) => c.originalPath === "companies/acme/docs/handoff.md" && c.preserved === "local")).toBe(true);
1104
1483
  });
1105
1484
  it("untracked-local guard is free on first pull: an unjournaled local file that already MATCHES the cloud reconciles silently", async () => {
1106
1485
  // The guard must not manufacture conflicts for the common case — a fresh
@@ -1131,7 +1510,7 @@ describe("sync", () => {
1131
1510
  .filter((f) => f.includes(".conflict-"));
1132
1511
  expect(litter).toHaveLength(0);
1133
1512
  // Journal stamped, so the next pass is a plain unchanged-skip.
1134
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1513
+ const journal = journalAt(journalPath);
1135
1514
  expect(journal.files["docs/handoff.md"]).toBeDefined();
1136
1515
  });
1137
1516
  it("RF-F02EXEC: refuses a download whose parent symlink appears after planning", async () => {
@@ -1291,7 +1670,7 @@ describe("sync", () => {
1291
1670
  });
1292
1671
  expect(fs.readFileSync(untrackedPath, "utf-8")).toBe("brand new local work");
1293
1672
  expect(fs.existsSync(trackedPath)).toBe(false);
1294
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1673
+ const journal = journalAt(journalPath);
1295
1674
  expect(journal.files[untrackedKey]).toBeUndefined();
1296
1675
  expect(journal.files[trackedKey]).toBeUndefined();
1297
1676
  });
@@ -1364,7 +1743,7 @@ describe("sync", () => {
1364
1743
  await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
1365
1744
  expect(s3Module.downloadFile).toHaveBeenCalledWith(expect.anything(), key, expect.anything(), expect.anything());
1366
1745
  expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
1367
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1746
+ const journal = journalAt(journalPath);
1368
1747
  expect(journal.files[key]?.removedAt).toBeUndefined();
1369
1748
  });
1370
1749
  it("does not canonicalize ignored remote-missing journal keys in the tombstone sweep", async () => {
@@ -1412,7 +1791,7 @@ describe("sync", () => {
1412
1791
  // Equivalence: an ignored key is never tombstoned — the local file and its
1413
1792
  // journal entry both survive, exactly as before the fix.
1414
1793
  expect(fs.existsSync(ignoredLocal)).toBe(true);
1415
- const sweptJournal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1794
+ const sweptJournal = journalAt(journalPath);
1416
1795
  expect(sweptJournal.files[ignoredKey]).toBeDefined();
1417
1796
  });
1418
1797
  it("tombstone lookup failure does not resurrect a current intent-backed delete", async () => {
@@ -1446,7 +1825,7 @@ describe("sync", () => {
1446
1825
  await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
1447
1826
  expect(s3Module.downloadFile).not.toHaveBeenCalledWith(expect.anything(), key, expect.anything(), expect.anything());
1448
1827
  expect(fs.existsSync(localPath)).toBe(false);
1449
- expect(JSON.parse(fs.readFileSync(journalPath, "utf8")).files[key]
1828
+ expect(journalAt(journalPath).files[key]
1450
1829
  ?.removedReason).toBe("local-delete");
1451
1830
  });
1452
1831
  it("pull producer: a BULK disappearance is drift-restored, NOT tombstoned (ridge-incident guard in the pull leg)", async () => {
@@ -1482,7 +1861,7 @@ describe("sync", () => {
1482
1861
  await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
1483
1862
  // All restored, none tombstoned.
1484
1863
  expect(s3Module.downloadFile).toHaveBeenCalledTimes(12);
1485
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1864
+ const journal = journalAt(journalPath);
1486
1865
  for (const k of keys) {
1487
1866
  expect(journal.files[k]?.removedAt).toBeUndefined();
1488
1867
  }
@@ -1523,7 +1902,7 @@ describe("sync", () => {
1523
1902
  await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
1524
1903
  expect(s3Module.downloadFile).not.toHaveBeenCalledWith(expect.anything(), key, expect.anything());
1525
1904
  expect(fs.existsSync(localPath)).toBe(false);
1526
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1905
+ const journal = journalAt(journalPath);
1527
1906
  // Entry PRESERVED as a tombstone, not removed.
1528
1907
  expect(journal.files[key]?.removedAt).toBeTruthy();
1529
1908
  expect(journal.files[key]?.removedReason).toBe("local-delete");
@@ -1555,7 +1934,7 @@ describe("sync", () => {
1555
1934
  it("stamps journal.lastSync on every successful run, even when nothing transferred", async () => {
1556
1935
  // First run downloads both remote files and stamps lastSync.
1557
1936
  await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
1558
- const firstStamp = JSON.parse(fs.readFileSync(journalPath, "utf-8")).lastSync;
1937
+ const firstStamp = journalAt(journalPath).lastSync;
1559
1938
  expect(firstStamp).not.toBe("");
1560
1939
  // Second run: same remote, same local — Stage-2 plan is all-skip, so
1561
1940
  // updateEntry never fires. lastSync must still advance, otherwise the
@@ -1563,7 +1942,7 @@ describe("sync", () => {
1563
1942
  await new Promise((r) => setTimeout(r, 5));
1564
1943
  const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
1565
1944
  expect(result.filesDownloaded).toBe(0);
1566
- const secondStamp = JSON.parse(fs.readFileSync(journalPath, "utf-8")).lastSync;
1945
+ const secondStamp = journalAt(journalPath).lastSync;
1567
1946
  expect(new Date(secondStamp).getTime()).toBeGreaterThan(new Date(firstStamp).getTime());
1568
1947
  });
1569
1948
  it("journalSlug: 'personal' routes journal I/O to sync-journal.personal.json", async () => {
@@ -1654,13 +2033,9 @@ describe("sync", () => {
1654
2033
  expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "bar.md"))).toBe(false);
1655
2034
  // The whole point: it now gets a journal baseline, so the push side stops
1656
2035
  // re-firing a transient conflict every sync (the bug this fix closes).
1657
- const journaledManifest = fs
1658
- .readdirSync(stateDir)
1659
- .filter((f) => f.startsWith("sync-journal."))
1660
- .some((f) => {
1661
- const j = JSON.parse(fs.readFileSync(path.join(stateDir, f), "utf8"));
1662
- return j.files?.["companies/manifest.yaml"] != null;
1663
- });
2036
+ // Enumerate through the API: the per-slug JSON files are locators now, so
2037
+ // reading them directly finds no `files` map at all.
2038
+ const journaledManifest = listJournals().some((j) => j.journal.files?.["companies/manifest.yaml"] != null);
1664
2039
  expect(journaledManifest).toBe(true);
1665
2040
  });
1666
2041
  it("personalMode pull lands the session-continuity pointer + active thread file under <hqRoot>/workspace/threads/ so a handoff resumes on machine B (DEV-1778)", async () => {
@@ -1754,9 +2129,10 @@ describe("sync", () => {
1754
2129
  });
1755
2130
  expect(result.conflicts).toBe(1);
1756
2131
  expect(result.conflictPaths).toEqual(["workspace/threads/handoff.json"]);
1757
- expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
1758
- // B's newer local pointer is preserved verbatim — not clobbered by A's.
1759
- const kept = JSON.parse(fs.readFileSync(path.join(threadsLocal, "handoff.json"), "utf-8"));
2132
+ // B's newer local pointer is preserved verbatim — renamed aside rather than
2133
+ // destroyed. DEV-1778 was about the pointer being LOST; a recoverable
2134
+ // sidecar satisfies that, and the machine can still resume from it.
2135
+ const kept = JSON.parse(fs.readFileSync(sidecarIn(threadsLocal), "utf-8"));
1760
2136
  expect(kept.message).toBe("newer local from B");
1761
2137
  expect(kept.thread_path).toBe("workspace/threads/T-machineB.json");
1762
2138
  });
@@ -1913,7 +2289,7 @@ describe("sync", () => {
1913
2289
  expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
1914
2290
  // Journal entry for the deleted path must be gone too — otherwise
1915
2291
  // the next sync would tombstone the same key on every run.
1916
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2292
+ const journal = journalAt(journalPath);
1917
2293
  expect(journal.files["docs/deleted-by-peer.md"]).toBeUndefined();
1918
2294
  expect(journal.files["docs/kept.md"]).toBeDefined();
1919
2295
  });
@@ -1978,7 +2354,7 @@ describe("sync", () => {
1978
2354
  expect(result.filesTombstoned).toBe(0);
1979
2355
  // The journal is healed: the malformed key is gone, the canonical POSIX key
1980
2356
  // points at the live file (so a future pull stays converged, not poisoned).
1981
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2357
+ const journal = journalAt(journalPath);
1982
2358
  expect(journal.files[backslashKey]).toBeUndefined();
1983
2359
  expect(journal.files[posixKey]).toBeDefined();
1984
2360
  expect(Object.keys(journal.files).some((k) => k.includes("\\"))).toBe(false);
@@ -2031,7 +2407,7 @@ describe("sync", () => {
2031
2407
  expect(result.filesTombstoned).toBe(0);
2032
2408
  // Journal entry retained — next sync (push) will re-evaluate and
2033
2409
  // either re-upload the divergent local or surface as a conflict.
2034
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2410
+ const journal = journalAt(journalPath);
2035
2411
  expect(journal.files["docs/edited-locally.md"]).toBeDefined();
2036
2412
  expect(journal.files["docs/edited-locally.md"].hash).toBe(baselineHash);
2037
2413
  });
@@ -2075,7 +2451,7 @@ describe("sync", () => {
2075
2451
  expect(result.filesTombstoned).toBe(0);
2076
2452
  expect(fs.existsSync(racedPath)).toBe(true);
2077
2453
  expect(fs.readFileSync(racedPath, "utf-8")).toBe("concurrent local edit");
2078
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2454
+ const journal = journalAt(journalPath);
2079
2455
  expect(journal.files["docs/racy-delete.md"]).toBeDefined();
2080
2456
  expect(journal.files["docs/racy-delete.md"].hash).toBe(baselineHash);
2081
2457
  });
@@ -2112,7 +2488,7 @@ describe("sync", () => {
2112
2488
  expect(result.filesTombstoned).toBe(0);
2113
2489
  expect(result.conflicts).toBe(1);
2114
2490
  expect(fs.readFileSync(localPath, "utf-8")).toBe(baseline);
2115
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2491
+ const journal = journalAt(journalPath);
2116
2492
  expect(journal.files["docs/created-after-plan.md"]).toBeDefined();
2117
2493
  });
2118
2494
  it("CAS: preserves a concurrent local edit when a staged download is ready to replace it", async () => {
@@ -2196,7 +2572,7 @@ describe("sync", () => {
2196
2572
  propagateDeletePolicy: "all",
2197
2573
  });
2198
2574
  expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
2199
- const journal = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
2575
+ const journal = journalAt(personalJournalPath);
2200
2576
  expect(journal.files["core/core.yaml"]).toBeDefined();
2201
2577
  expect(journal.files[".claude/skills/core:demo/SKILL.md"]).toBeDefined();
2202
2578
  });
@@ -2230,7 +2606,7 @@ describe("sync", () => {
2230
2606
  });
2231
2607
  expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
2232
2608
  expect(result.filesRefusedStale).toBe(2);
2233
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2609
+ const journal = journalAt(journalPath);
2234
2610
  expect(journal.files["docs/a.md"]).toBeDefined();
2235
2611
  expect(journal.files["docs/b.md"]).toBeDefined();
2236
2612
  });
@@ -2258,10 +2634,16 @@ describe("sync", () => {
2258
2634
  },
2259
2635
  pulls: [],
2260
2636
  }));
2261
- vi.mocked(s3Module.headRemoteFile).mockResolvedValueOnce({
2637
+ vi.mocked(s3Module.headRemoteFile)
2638
+ .mockResolvedValueOnce({
2262
2639
  etag: "old-etag",
2263
2640
  size: baseline.length,
2264
2641
  lastModified: new Date(),
2642
+ })
2643
+ .mockResolvedValueOnce({
2644
+ etag: "peer-etag",
2645
+ size: "mock file content".length,
2646
+ lastModified: new Date(),
2265
2647
  });
2266
2648
  const preconditionError = Object.assign(new Error("remote changed"), {
2267
2649
  name: "PreconditionFailed",
@@ -2277,7 +2659,15 @@ describe("sync", () => {
2277
2659
  onConflict: "keep",
2278
2660
  });
2279
2661
  expect(result.conflictPaths).toContain("board.json");
2280
- expect(fs.readFileSync(boardPath, "utf-8")).toBe(localAppend);
2662
+ // The conditional PUT still fails closed, but keep is cloud-wins: the
2663
+ // peer body takes the working name and the local append remains in the
2664
+ // sidecar for explicit resolution.
2665
+ expect(fs.readFileSync(boardPath, "utf-8")).toBe("mock file content");
2666
+ expect(fs.readFileSync(sidecarIn(companyRoot), "utf-8")).toBe(localAppend);
2667
+ expect(journalAt(journalPath).files["board.json"]).toMatchObject({
2668
+ remoteEtag: "peer-etag",
2669
+ direction: "down",
2670
+ });
2281
2671
  expect(s3Module.uploadFile).toHaveBeenCalledWith(expect.anything(), boardPath, "board.json", undefined, { ifMatch: "old-etag" });
2282
2672
  });
2283
2673
  it("does NOT tombstone symlinks whose readlink target has diverged from the journal (Codex P1 round 4)", async () => {
@@ -2322,7 +2712,7 @@ describe("sync", () => {
2322
2712
  expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
2323
2713
  expect(fs.readlinkSync(linkPath)).toBe("new-target.md");
2324
2714
  expect(result.filesTombstoned).toBe(0);
2325
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2715
+ const journal = journalAt(journalPath);
2326
2716
  expect(journal.files["docs/link.md"]).toBeDefined();
2327
2717
  expect(journal.files["docs/link.md"].hash).toBe(baselineHash);
2328
2718
  });
@@ -2389,7 +2779,7 @@ describe("sync", () => {
2389
2779
  // The really-deleted file MUST be tombstoned.
2390
2780
  expect(fs.existsSync(reallyDeletedPath)).toBe(false);
2391
2781
  expect(result.filesTombstoned).toBe(1);
2392
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2782
+ const journal = journalAt(journalPath);
2393
2783
  // Journal entry retained for the invisible key (so next sync at
2394
2784
  // broader scope can re-evaluate).
2395
2785
  expect(journal.files["docs/invisible-to-list.md"]).toBeDefined();
@@ -2463,7 +2853,7 @@ describe("sync", () => {
2463
2853
  expect(result.filesDownloaded).toBe(0);
2464
2854
  expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
2465
2855
  // Journal entry dropped so it converges (no re-tombstone next run).
2466
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2856
+ const journal = journalAt(journalPath);
2467
2857
  expect(journal.files["docs/shared.md"]).toBeUndefined();
2468
2858
  });
2469
2859
  it("delete-resync: a re-created object NEWER than the tombstone DOES sync (tombstone is not permanent)", async () => {
@@ -2555,7 +2945,7 @@ describe("sync", () => {
2555
2945
  expect(result.conflicts).toBeGreaterThanOrEqual(1);
2556
2946
  expect(result.conflictPaths).toContain("docs/edited.md");
2557
2947
  expect(fs.existsSync(editedPath)).toBe(true);
2558
- expect(fs.readFileSync(editedPath, "utf-8")).toBe("my local edits");
2948
+ expect(fs.readFileSync(sidecarIn(path.dirname(editedPath)), "utf-8")).toBe("my local edits");
2559
2949
  expect(result.filesTombstoned).toBe(0);
2560
2950
  });
2561
2951
  it("delete-resync: suppression is per-key and the fetch is company-scoped (no cross-tenant bleed)", async () => {
@@ -2627,7 +3017,7 @@ describe("sync", () => {
2627
3017
  });
2628
3018
  expect(fs.existsSync(deniedPath)).toBe(true);
2629
3019
  expect(result.filesTombstoned).toBe(0);
2630
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
3020
+ const journal = journalAt(journalPath);
2631
3021
  expect(journal.files["docs/out-of-scope.md"]).toBeDefined();
2632
3022
  });
2633
3023
  it("retains the journal entry when tombstone unlink fails with EACCES (Codex P1 — Bug #9 follow-up)", async () => {
@@ -2675,7 +3065,7 @@ describe("sync", () => {
2675
3065
  // Tombstone NOT counted — the delete wasn't honored.
2676
3066
  expect(result.filesTombstoned).toBe(0);
2677
3067
  // Journal entry MUST be retained so the next sync retries.
2678
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
3068
+ const journal = journalAt(journalPath);
2679
3069
  expect(journal.files["docs/locked-by-peer.md"]).toBeDefined();
2680
3070
  expect(journal.files["docs/locked-by-peer.md"].hash).toBe("locked-hash");
2681
3071
  }
@@ -2902,7 +3292,7 @@ describe("sync", () => {
2902
3292
  expect(s3Module.downloadFile).not.toHaveBeenCalled();
2903
3293
  expect(fs.existsSync(escapedPath)).toBe(false);
2904
3294
  expect(fs.existsSync(path.join(companyRoot, traversalKey))).toBe(false);
2905
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
3295
+ const journal = journalAt(journalPath);
2906
3296
  expect(journal.files[traversalKey]).toBeUndefined();
2907
3297
  }
2908
3298
  finally {
@@ -2936,7 +3326,7 @@ describe("sync", () => {
2936
3326
  expect(s3Module.downloadFile).not.toHaveBeenCalled();
2937
3327
  expect(fs.existsSync(escapedPath)).toBe(false);
2938
3328
  expect(fs.existsSync(path.join(companyRoot, remoteKey))).toBe(false);
2939
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
3329
+ const journal = journalAt(journalPath);
2940
3330
  expect(journal.files[remoteKey]).toBeUndefined();
2941
3331
  }
2942
3332
  finally {
@@ -3011,10 +3401,46 @@ describe("sync", () => {
3011
3401
  vaultConfig: mockConfig,
3012
3402
  hqRoot: tmpDir,
3013
3403
  });
3014
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
3404
+ const journal = journalAt(journalPath);
3015
3405
  expect(journal.files["docs/handoff.md"].remoteEtag).toBe("abc123");
3016
3406
  expect(journal.files["knowledge/readme.md"].remoteEtag).toBe("def456");
3017
3407
  });
3408
+ it("persists each downloaded hash before starting the next file", async () => {
3409
+ process.env.HQ_SYNC_TRANSFER_CONCURRENCY = "1";
3410
+ const remoteFiles = [
3411
+ { key: "ordered/first.md", size: 18, lastModified: new Date(), etag: '"first-etag"' },
3412
+ { key: "ordered/second.md", size: 18, lastModified: new Date(), etag: '"second-etag"' },
3413
+ ];
3414
+ const defaultDownload = vi.mocked(s3Module.downloadFile).getMockImplementation();
3415
+ if (!defaultDownload)
3416
+ throw new Error("missing default download mock");
3417
+ try {
3418
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remoteFiles);
3419
+ vi.mocked(s3Module.downloadFile).mockImplementation(async (ctx, key, localPath, options) => {
3420
+ if (key === "ordered/second.md") {
3421
+ const first = journalAt(journalPath).files["ordered/first.md"];
3422
+ expect(first).toMatchObject({
3423
+ hash: crypto.createHash("sha256").update("mock file content").digest("hex"),
3424
+ remoteEtag: "first-etag",
3425
+ direction: "down",
3426
+ });
3427
+ expect(first.localDiverges).toBeFalsy();
3428
+ }
3429
+ return defaultDownload(ctx, key, localPath, options);
3430
+ });
3431
+ await sync({
3432
+ company: "acme",
3433
+ vaultConfig: mockConfig,
3434
+ hqRoot: tmpDir,
3435
+ skipReindex: true,
3436
+ onEvent: () => undefined,
3437
+ });
3438
+ }
3439
+ finally {
3440
+ vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
3441
+ delete process.env.HQ_SYNC_TRANSFER_CONCURRENCY;
3442
+ }
3443
+ });
3018
3444
  it("checkpoints completed downloads so a failed bulk pass resumes only its tail", async () => {
3019
3445
  process.env.HQ_SYNC_TRANSFER_CONCURRENCY = "1";
3020
3446
  const remoteFiles = Array.from({ length: 9 }, (_, index) => ({
@@ -3032,7 +3458,7 @@ describe("sync", () => {
3032
3458
  if (key === "bulk/file-8.md") {
3033
3459
  // This models the next transfer crashing the process. The first
3034
3460
  // eight entries must already be durable before the final writer.
3035
- const checkpoint = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
3461
+ const checkpoint = journalAt(journalPath);
3036
3462
  expect(Object.keys(checkpoint.files)).toHaveLength(8);
3037
3463
  throw new Error("simulated crash after checkpoint");
3038
3464
  }
@@ -3046,7 +3472,7 @@ describe("sync", () => {
3046
3472
  onEvent: () => undefined,
3047
3473
  });
3048
3474
  expect(first.filesDownloaded).toBe(8);
3049
- const checkpoint = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
3475
+ const checkpoint = journalAt(journalPath);
3050
3476
  expect(Object.keys(checkpoint.files)).toHaveLength(8);
3051
3477
  vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
3052
3478
  vi.mocked(s3Module.downloadFile).mockClear();
@@ -3497,7 +3923,7 @@ describe("sync", () => {
3497
3923
  // skip-unchanged works on the next tick.
3498
3924
  const journalMod = await import("../journal.js");
3499
3925
  const expectedHash = journalMod.hashSymlinkTarget(linkTarget);
3500
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
3926
+ const journal = journalAt(journalPath);
3501
3927
  expect(journal.files[linkKey]).toMatchObject({
3502
3928
  hash: expectedHash,
3503
3929
  size: 0,
@@ -3829,7 +4255,7 @@ describe("sync", () => {
3829
4255
  expect(errOutput).toContain("manual");
3830
4256
  expect(errOutput).not.toContain("directory-overlay marker");
3831
4257
  });
3832
- it("shared-mode leg surfaces the materialization gap and names the shared→all lever (feedback_d2082110)", async () => {
4258
+ it("shared-mode leg withholds out-of-scope keys at LIST time (scoped listing supersedes the feedback_d2082110 gap advisory)", async () => {
3833
4259
  // Reporter feedback_d2082110 (cody@jonesroadbeauty.com): four memberships
3834
4260
  // were configured as `shared` rather than `all`, so not all vault content
3835
4261
  // materialized on the second device — a SILENT gap the reporter had to
@@ -3862,16 +4288,19 @@ describe("sync", () => {
3862
4288
  onEvent: (e) => events.push(e),
3863
4289
  });
3864
4290
  // The in-scope key pulled; the out-of-scope key was NOT materialized.
4291
+ // BEHAVIOR CHANGE (targeted-sync scoped listing): the shared leg lists
4292
+ // ONLY its in-scope prefixes now, so the out-of-scope key never reaches
4293
+ // the plan — filesOutOfScope is 0 and the scope-materialization-gap
4294
+ // advisory (which counted plan-visible withheld keys) has nothing to
4295
+ // report. The advisory's classification logic remains for full listings;
4296
+ // the memory win of not materializing the whole vault LIST supersedes the
4297
+ // withheld-count telemetry on scoped legs.
3865
4298
  expect(result.filesDownloaded).toBe(1);
3866
- expect(result.filesOutOfScope).toBe(1);
4299
+ expect(result.filesOutOfScope).toBe(0);
3867
4300
  expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "knowledge", "present.md"))).toBe(true);
3868
4301
  expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "projects", "secret.md"))).toBe(false);
3869
- // The gap is now SURFACED with the count, the active mode, and the lever.
3870
4302
  const gap = events.find((e) => e.type === "scope-materialization-gap");
3871
- expect(gap).toBeDefined();
3872
- expect(gap.count).toBe(1);
3873
- expect(gap.syncMode).toBe("shared");
3874
- expect(gap.samplePaths).toEqual(["projects/secret.md"]);
4303
+ expect(gap).toBeUndefined();
3875
4304
  });
3876
4305
  it("all-mode leg (default) never emits a materialization gap — no scoping, no noise", async () => {
3877
4306
  // Boundary for the feedback_d2082110 gap surface: in `all` mode every key is
@@ -4144,7 +4573,7 @@ describe("sync", () => {
4144
4573
  // The journal MUST be updated to reflect the user's keep
4145
4574
  // decision. Pre-fix: catch swallowed the ENOENT, journal stayed
4146
4575
  // at oldHash + old etag, every subsequent sync re-fired.
4147
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
4576
+ const journal = journalAt(journalPath);
4148
4577
  const entry = journal.files[linkKey];
4149
4578
  expect(entry.hash).toBe(expectedNewHash);
4150
4579
  expect(entry.size).toBe(0);
@@ -4443,7 +4872,7 @@ describe("scope-invalid key hardening (companies/ prefix in a company vault —
4443
4872
  // Cleaned, not re-uploaded: local doubled-tree copy gone, journal dropped.
4444
4873
  expect(fs.existsSync(path.join(doubled, "poison.md"))).toBe(false);
4445
4874
  expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
4446
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
4875
+ const journal = journalAt(journalPath);
4447
4876
  expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
4448
4877
  // No HEAD verify against the invalid key — the server validator would
4449
4878
  // reject it (INVALID_KEY_COMPANIES_SCOPED) and defer the cleanup forever.
@@ -4571,7 +5000,7 @@ describe("junk key-spelling guard (US-002 mixed-version amplifier)", () => {
4571
5000
  expect(events.filter((e) => e.type === "error")).toHaveLength(0);
4572
5001
  expect(result.aborted).toBe(false);
4573
5002
  expect(result.filesSkipped).toBe(1);
4574
- const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
5003
+ const journalAfter = journalAt(journalPath);
4575
5004
  expect(Object.keys(journalAfter.files)).not.toContain(junkKey);
4576
5005
  });
4577
5006
  it("still pulls a junk-spelling key that is ALREADY journaled under that exact spelling", async () => {
@@ -4616,7 +5045,7 @@ describe("junk key-spelling guard (US-002 mixed-version amplifier)", () => {
4616
5045
  // Junk twin still gated; the canonical journal entry tombstones out.
4617
5046
  expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(1);
4618
5047
  expect(result.filesTombstoned).toBe(1);
4619
- const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
5048
+ const journalAfter = journalAt(journalPath);
4620
5049
  expect(Object.keys(journalAfter.files)).not.toContain(canonicalKey);
4621
5050
  });
4622
5051
  it("always downloads a CANONICAL remote key even when the journal holds only its junk twin (post-doctor convergence)", async () => {
@@ -4633,7 +5062,7 @@ describe("junk key-spelling guard (US-002 mixed-version amplifier)", () => {
4633
5062
  expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
4634
5063
  expect(vi.mocked(s3Module.downloadFile).mock.calls[0][1]).toBe(canonicalKey);
4635
5064
  expect(result.filesDownloaded).toBe(1);
4636
- const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
5065
+ const journalAfter = journalAt(journalPath);
4637
5066
  expect(Object.keys(journalAfter.files)).toContain(canonicalKey);
4638
5067
  });
4639
5068
  it("does not tombstone a journaled canonical key when the LIST holds only its junk twin", async () => {
@@ -4649,7 +5078,7 @@ describe("junk key-spelling guard (US-002 mixed-version amplifier)", () => {
4649
5078
  const { events, result } = await runSync();
4650
5079
  // Junk twin skipped, canonical entry NOT tombstoned.
4651
5080
  expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(1);
4652
- const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
5081
+ const journalAfter = journalAt(journalPath);
4653
5082
  expect(Object.keys(journalAfter.files)).toContain(canonicalKey);
4654
5083
  expect(result.filesTombstoned ?? 0).toBe(0);
4655
5084
  expect(events.filter((e) => e.type === "error")).toHaveLength(0);