@indigoai-us/hq-cloud 6.14.40 → 6.14.41

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "6.14.40",
3
+ "version": "6.14.41",
4
4
  "description": "HQ by Indigo cloud sync engine — bidirectional S3 sync for mobile access",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -931,6 +931,109 @@ describe("sync", () => {
931
931
  expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
932
932
  });
933
933
 
934
+ it("untracked-local guard: an UNJOURNALED local file is NOT silently clobbered — routes through conflict and preserves local", async () => {
935
+ // Regression for the 2026-08-01/02 prd.json losses: a locally-edited file
936
+ // whose key has NO journal entry fell straight through the 3-way merge to
937
+ // `download` (localChanged and remoteChanged are both gated on
938
+ // `!!journalEntry`, so both read false). The in-progress local edit was
939
+ // overwritten with the cloud copy with no conflict, no `.conflict-` mirror,
940
+ // and no conflict-index record — the loss was invisible.
941
+ //
942
+ // A key with no journal entry carries NO evidence that local and remote
943
+ // ever agreed, so a blind overwrite is never safe. Route it through the
944
+ // conflict path; the executor's convergence probe still collapses a
945
+ // byte-identical remote to a silent reconcile (covered by the next test),
946
+ // so this costs nothing on the common first-pull case.
947
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
948
+ fs.mkdirSync(companyDocs, { recursive: true });
949
+ const localPath = path.join(companyDocs, "handoff.md");
950
+ // Stand-in for the PRD carrying the in-progress 11th story.
951
+ const localContent = "in-progress local work that must survive\n";
952
+ fs.writeFileSync(localPath, localContent);
953
+
954
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
955
+ { key: "docs/handoff.md", size: 17, lastModified: new Date(), etag: '"cloud-etag"' },
956
+ ]);
957
+
958
+ // Journal has NO entry for this key — the whole point of the regression.
959
+ fs.writeFileSync(
960
+ journalPath,
961
+ JSON.stringify({ version: "1", lastSync: new Date().toISOString(), files: {} }),
962
+ );
963
+
964
+ const result = await sync({
965
+ company: "acme",
966
+ onConflict: "keep",
967
+ vaultConfig: mockConfig,
968
+ hqRoot: tmpDir,
969
+ });
970
+
971
+ // Surfaced as a conflict, NOT a silent download.
972
+ expect(result.conflicts).toBe(1);
973
+ expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
974
+ expect(result.filesDownloaded).toBe(0);
975
+ // The local edit is intact.
976
+ expect(fs.readFileSync(localPath, "utf-8")).toBe(localContent);
977
+ // Cloud copy preserved alongside it for inspection / `/resolve-conflicts`.
978
+ const litter = fs
979
+ .readdirSync(companyDocs)
980
+ .filter((f) => f.includes(".conflict-"));
981
+ expect(litter).toHaveLength(1);
982
+ expect(fs.readFileSync(path.join(companyDocs, litter[0]!), "utf-8")).toBe(
983
+ "mock file content",
984
+ );
985
+ // And a durable record exists so the overwrite can never be silent.
986
+ const index = JSON.parse(
987
+ fs.readFileSync(path.join(tmpDir, ".hq-conflicts", "index.json"), "utf-8"),
988
+ );
989
+ expect(
990
+ index.conflicts.some(
991
+ (c: { originalPath: string }) =>
992
+ c.originalPath === "companies/acme/docs/handoff.md",
993
+ ),
994
+ ).toBe(true);
995
+ });
996
+
997
+ it("untracked-local guard is free on first pull: an unjournaled local file that already MATCHES the cloud reconciles silently", async () => {
998
+ // The guard must not manufacture conflicts for the common case — a fresh
999
+ // machine, or a re-created journal, where local and cloud are already
1000
+ // byte-identical. The executor's convergence probe collapses these to a
1001
+ // reconcile: no conflict, no mirror, journal stamped so the next sync is
1002
+ // a plain skip-unchanged.
1003
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
1004
+ fs.mkdirSync(companyDocs, { recursive: true });
1005
+ const localPath = path.join(companyDocs, "handoff.md");
1006
+ // Byte-identical to what the downloadFile mock writes.
1007
+ fs.writeFileSync(localPath, "mock file content");
1008
+
1009
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1010
+ { key: "docs/handoff.md", size: 17, lastModified: new Date(), etag: '"cloud-etag"' },
1011
+ ]);
1012
+
1013
+ fs.writeFileSync(
1014
+ journalPath,
1015
+ JSON.stringify({ version: "1", lastSync: new Date().toISOString(), files: {} }),
1016
+ );
1017
+
1018
+ const result = await sync({
1019
+ company: "acme",
1020
+ onConflict: "keep",
1021
+ vaultConfig: mockConfig,
1022
+ hqRoot: tmpDir,
1023
+ });
1024
+
1025
+ expect(result.conflicts).toBe(0);
1026
+ expect(result.conflictPaths).toEqual([]);
1027
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
1028
+ const litter = fs
1029
+ .readdirSync(companyDocs)
1030
+ .filter((f) => f.includes(".conflict-"));
1031
+ expect(litter).toHaveLength(0);
1032
+ // Journal stamped, so the next pass is a plain unchanged-skip.
1033
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1034
+ expect(journal.files["docs/handoff.md"]).toBeDefined();
1035
+ });
1036
+
934
1037
  it("RF-F02EXEC: refuses a download whose parent symlink appears after planning", async () => {
935
1038
  const companyRoot = path.join(tmpDir, "companies", "acme");
936
1039
  const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-escape-"));
package/src/cli/sync.ts CHANGED
@@ -2903,6 +2903,48 @@ function computePullPlan(
2903
2903
  continue;
2904
2904
  }
2905
2905
 
2906
+ // ── Untracked-local guard (unjournaled local file) ──────────────
2907
+ // Both `localChanged` and `remoteChanged` are gated on `!!journalEntry`,
2908
+ // so a key with NO journal entry reads "clean on both sides" and falls
2909
+ // all the way through to a plain `download` — silently replacing a local
2910
+ // file that exists on disk. There is no conflict, no `.conflict-` mirror
2911
+ // and no conflict-index record, so the loss is invisible unless someone
2912
+ // happens to know what the file should contain.
2913
+ //
2914
+ // This destroyed in-progress edits to
2915
+ // `companies/*/projects/*/prd.json` three times on 2026-08-01/02, but
2916
+ // nothing about it is prd- or project-specific: it fires for ANY synced
2917
+ // path whose journal entry is absent. Absence is common — a file created
2918
+ // on this machine and on a peer under the same key before either pushed,
2919
+ // a journal shard reset/lost/rebuilt (`readJournal` returns an empty
2920
+ // `files` map for a missing shard, so EVERY divergent local file is in
2921
+ // scope), a run under a different `HQ_STATE_DIR`, or an entry dropped by
2922
+ // scope-shrink or rescue.
2923
+ //
2924
+ // No journal entry means no evidence that local and remote ever agreed,
2925
+ // which is exactly when an overwrite is least safe. Route it through the
2926
+ // conflict path instead: the executor's convergence probe fetches the
2927
+ // remote once and, when the bytes already match (the ordinary
2928
+ // fresh-machine / rebuilt-journal case), reconciles silently — no
2929
+ // conflict, no mirror, journal stamped. Only genuine divergence surfaces,
2930
+ // and there local is PRESERVED as-is with the cloud copy written beside
2931
+ // it under `--on-conflict keep`, or the run halts under `abort`.
2932
+ // Cloud-authoritative paths (board.json, ontology/, signals/, …) are
2933
+ // unaffected — the conflict executor short-circuits those back to a
2934
+ // pull-wins download before any mirror is minted.
2935
+ if (!journalEntry) {
2936
+ items.push({
2937
+ action: "conflict",
2938
+ remoteFile,
2939
+ localPath,
2940
+ localHash,
2941
+ localMtime: localLstat!.mtime,
2942
+ localSize: isLocalSymlink ? 0 : localLstat!.size,
2943
+ localSnapshot: plannedLocalSnapshot,
2944
+ });
2945
+ continue;
2946
+ }
2947
+
2906
2948
  // Mirror the original 3-way merge from the inline loop. Tested by
2907
2949
  // `does NOT flag a pull conflict when only local changed since last
2908
2950
  // sync` and `detects conflicts with local changes…`.