@indigoai-us/hq-cloud 6.2.3 → 6.2.4

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.
@@ -28,34 +28,6 @@ import { spawnSync } from "child_process";
28
28
  import * as fs from "fs";
29
29
  import * as os from "os";
30
30
  import * as path from "path";
31
- import {
32
- readJournal,
33
- writeJournal,
34
- hashFile,
35
- updateEntry,
36
- isTombstone,
37
- PERSONAL_VAULT_JOURNAL_SLUG,
38
- } from "../journal.js";
39
-
40
- /**
41
- * Top-level roots the rescue NEVER overlays (preserved across wipe+overlay).
42
- * The post-rescue journal-baseline reconcile must skip these: any local file
43
- * under them that differs from the journal is USER content / a pending edit,
44
- * not rescue output, and must never be silently marked synced. Everything else
45
- * (core/, .claude/, root files) is release-managed scaffold the rescue rewrites.
46
- */
47
- const RECONCILE_PRESERVED_ROOTS: ReadonlySet<string> = new Set([
48
- "personal",
49
- "data",
50
- "companies",
51
- "workspace",
52
- "repos",
53
- ".git",
54
- ".github",
55
- ".leak-scan",
56
- ".hq",
57
- ".hq-conflicts",
58
- ]);
59
31
 
60
32
  export interface RunRescueResult {
61
33
  status: number;
@@ -589,6 +561,16 @@ function doRescue(
589
561
  }
590
562
 
591
563
  const srcSha = run("git", ["rev-parse", "HEAD"], { cwd: srcDir, env }).stdout.trim();
564
+ // Committer timestamp of the source SHA — the DETERMINISTIC stamp value for
565
+ // core/core.yaml's `last_sync_at`. Stamping wall-clock "now" made every
566
+ // rescue run on every machine produce a byte-different core.yaml for the
567
+ // SAME release, which is exactly the genuine local≠remote divergence that
568
+ // minted `core.yaml.conflict-*` mirrors across machines. Same SHA in →
569
+ // identical bytes out, on any machine, any day.
570
+ const srcCommitIsoRaw = run("git", ["show", "-s", "--format=%cI", "HEAD"], {
571
+ cwd: srcDir,
572
+ env,
573
+ }).stdout.trim();
592
574
  out(`==> Source SHA: ${srcSha}\n`);
593
575
 
594
576
  // --- Restore file mtimes from git history ---
@@ -847,8 +829,23 @@ function doRescue(
847
829
  }
848
830
 
849
831
  // --- Stamp sync-point provenance into core/core.yaml ---
850
- if (cfg.narrowPaths.length === 0 && isFileFollow(coreYaml)) {
851
- const nowUtc = utcStamp(new Date(), "colon");
832
+ // `last_sync_at` = the source commit's committer time, NOT wall-clock now:
833
+ // the stamp must be a pure function of srcSha so every machine rescuing the
834
+ // same release writes byte-identical core.yaml (see srcCommitIsoRaw). An
835
+ // unparseable commit timestamp skips the stamp entirely — a nondeterministic
836
+ // stamp is worse than no stamp (it re-creates cross-machine conflict mirrors).
837
+ const srcCommitDate = new Date(srcCommitIsoRaw);
838
+ if (cfg.narrowPaths.length === 0 && isFileFollow(coreYaml) && Number.isNaN(srcCommitDate.getTime())) {
839
+ err(
840
+ ` WARN: could not parse source commit timestamp (${JSON.stringify(srcCommitIsoRaw)}); skipping core/core.yaml stamp to keep it deterministic\n`,
841
+ );
842
+ }
843
+ if (
844
+ cfg.narrowPaths.length === 0 &&
845
+ isFileFollow(coreYaml) &&
846
+ !Number.isNaN(srcCommitDate.getTime())
847
+ ) {
848
+ const nowUtc = utcStamp(srcCommitDate, "colon");
852
849
  if (yqAvailable) {
853
850
  const stampEnv: NodeJS.ProcessEnv = {
854
851
  ...env,
@@ -914,55 +911,14 @@ function doRescue(
914
911
  }
915
912
  }
916
913
 
917
- // --- Reconcile the sync-journal baseline for everything the rescue changed ---
918
- // The overlay AND the post-overlay core.yaml stamp above rewrite scaffold
919
- // files from upstream, but their personal-vault sync-journal entries still
920
- // carry the PRE-rescue hash. The next sync then reads localHash != journal.hash
921
- // ("local changed") and when the vault also moved (etag churn / another
922
- // machine) mints a false `.conflict-*` mirror. That is the root cause of the
923
- // conflict-file litter.
924
- //
925
- // Re-stamp the baseline by diffing the journal against current local hashes:
926
- // robust (no brittle rsync-itemize parse — that undercounted) and complete
927
- // (catches overlay-relaid files AND the post-overlay core.yaml stamp). Runs
928
- // here, after every in-`doRescue` mutation. Scoped to NON-preserved roots:
929
- // the rescue never overlays personal/, data/, companies/, etc., so any
930
- // local!=journal there is a USER pending edit that must NOT be marked synced.
931
- // Best-effort: a reconcile failure must never fail the rescue.
932
- try {
933
- const journal = readJournal(PERSONAL_VAULT_JOURNAL_SLUG);
934
- let restamped = 0;
935
- for (const rel of Object.keys(journal.files)) {
936
- const top = rel.split("/")[0];
937
- if (RECONCILE_PRESERVED_ROOTS.has(top)) continue;
938
- const prev = journal.files[rel];
939
- if (isTombstone(prev)) continue;
940
- const abs = path.join(hqRoot, rel);
941
- let st: fs.Stats;
942
- try {
943
- st = fs.lstatSync(abs);
944
- } catch {
945
- continue; // gone locally — leave it to the sync's tombstone path
946
- }
947
- if (st.isSymbolicLink() || !st.isFile()) continue;
948
- const h = hashFile(abs);
949
- if (h === prev.hash) continue; // unchanged — no-op
950
- updateEntry(journal, rel, h, st.size, prev.direction, prev.remoteEtag, st.mtimeMs);
951
- restamped++;
952
- }
953
- if (restamped > 0) {
954
- writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, journal);
955
- out(
956
- `==> Reconciled sync-journal baseline for ${restamped} changed scaffold file(s)\n`,
957
- );
958
- }
959
- } catch (e) {
960
- out(
961
- ` (sync-journal reconcile skipped: ${
962
- e instanceof Error ? e.message : String(e)
963
- })\n`,
964
- );
965
- }
914
+ // NOTE (6.2.4): the post-rescue "sync-journal baseline reconcile" that lived
915
+ // here (6.2.1 itemize-parse, 6.2.3 hash-diff) is intentionally GONE. Masking
916
+ // rescue-changed scaffold as already-synced starved the push leg — the vault
917
+ // silently kept stale scaffold bytes while the journal claimed convergence.
918
+ // Correct semantics: leave rescue-changed files visible as "local changed";
919
+ // the next bidirectional sync pushes them up (vault converges to the rescue
920
+ // output), and the pull-side byte-identical convergence probe (6.2.0)
921
+ // absorbs cross-machine races without minting `.conflict-*` mirrors.
966
922
 
967
923
  // --- File count summary ---
968
924
  out("\n");
@@ -1,20 +1,23 @@
1
1
  /**
2
- * Regression for the rescue -> sync-journal stale-baseline bug (and its
3
- * follow-ups). The rescue overlay + the post-overlay core.yaml stamp rewrite
4
- * scaffold files from upstream, but their personal-vault journal entries keep
5
- * the PRE-rescue hash. The next sync then reads localHash != journal.hash
6
- * ("local changed") and mints a false `.conflict-*` mirror.
2
+ * Regression for the rescue <-> sync-journal interaction (6.2.4 semantics).
7
3
  *
8
- * runRescue now reconciles the baseline by DIFFING the journal against current
9
- * local hashes (robust the earlier rsync-itemize regex undercounted), running
10
- * AFTER every in-doRescue mutation (so it also catches the core.yaml stamp), and
11
- * scoped to NON-preserved roots so a user's pending edit in personal/ is never
12
- * marked synced.
4
+ * History: 6.2.1/6.2.3 "reconciled" the journal after a rescue by re-stamping
5
+ * changed scaffold entries to the current local hash. That MASKED the rescue's
6
+ * changes from the sync engine: the push leg saw localChanged=false and never
7
+ * uploaded the regenerated scaffold, so the vault silently kept stale bytes
8
+ * while the journal claimed convergence (verified live 2026-06-10: 4 scaffold
9
+ * files byte-diverged from the vault under a 0-conflict sync).
13
10
  *
14
- * This test seeds stale baselines for several overlaid scaffold files + core.yaml
15
- * and a pending personal/ edit, runs a real rescue, and asserts: every changed
16
- * scaffold entry is re-stamped to current local (no false conflict), while the
17
- * personal/ pending edit is left untouched (still uploads).
11
+ * Correct semantics (this test): the rescue must NOT touch the personal-vault
12
+ * sync journal at all. Rescue-changed scaffold stays visible as "local
13
+ * changed", the next bidirectional sync pushes it up, and the pull-side
14
+ * byte-identical convergence probe (6.2.0) absorbs cross-machine races.
15
+ *
16
+ * Also covered: the core/core.yaml provenance stamp must be DETERMINISTIC —
17
+ * a pure function of the source SHA (committer time, not wall-clock now) — so
18
+ * every machine rescuing the same release writes byte-identical core.yaml.
19
+ * The wall-clock stamp was the one genuine cross-machine divergence engine
20
+ * behind `core.yaml.conflict-*` mirrors.
18
21
  */
19
22
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
20
23
  import { execFileSync } from "child_process";
@@ -57,10 +60,12 @@ function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
57
60
 
58
61
  const SCAFFOLD = ["core/a.md", "core/docs/b.md", ".claude/c.sh", "core/core.yaml"];
59
62
 
60
- describe.skipIf(!gitAvailable)("rescue reconciles sync-journal baseline (complete + robust)", () => {
63
+ describe.skipIf(!gitAvailable)("rescue leaves the sync journal untouched + stamps core.yaml deterministically", () => {
61
64
  let workDir: string, upstream: string, hqRoot: string, stateDir: string, floorSha: string;
62
65
  let env: NodeJS.ProcessEnv;
63
66
  let savedStateDir: string | undefined;
67
+ let headCommitterIso: string;
68
+ let seededHashes: Record<string, string>;
64
69
 
65
70
  const git = (cwd: string, ...args: string[]) =>
66
71
  execFileSync("git", args, {
@@ -92,6 +97,7 @@ describe.skipIf(!gitAvailable)("rescue reconciles sync-journal baseline (complet
92
97
  w(upstream, "core/docs/b.md", "v2\n");
93
98
  w(upstream, ".claude/c.sh", "v2\n");
94
99
  git(upstream, "add", "-A"); git(upstream, "commit", "-m", "head");
100
+ headCommitterIso = git(upstream, "show", "-s", "--format=%cI", "HEAD");
95
101
 
96
102
  // --- local HQ root: scaffold == floor (overlaid to v2); a pending personal/ edit ---
97
103
  hqRoot = path.join(workDir, "hq");
@@ -102,7 +108,7 @@ describe.skipIf(!gitAvailable)("rescue reconciles sync-journal baseline (complet
102
108
  w(hqRoot, "core/core.yaml", "version: 1\n");
103
109
  w(hqRoot, "personal/edited.md", "USER_LOCAL\n"); // local pending edit
104
110
 
105
- // --- state dir + seeded journal: stale baselines for scaffold + a divergent personal/ entry ---
111
+ // --- state dir + seeded journal: pre-rescue baselines for scaffold + a divergent personal/ entry ---
106
112
  stateDir = path.join(workDir, "state");
107
113
  fs.mkdirSync(stateDir, { recursive: true });
108
114
  savedStateDir = process.env.HQ_STATE_DIR;
@@ -116,7 +122,12 @@ describe.skipIf(!gitAvailable)("rescue reconciles sync-journal baseline (complet
116
122
  mtimeMs: fs.statSync(path.join(hqRoot, rel)).mtimeMs,
117
123
  });
118
124
  const files: Record<string, unknown> = {};
119
- for (const rel of SCAFFOLD) files[rel] = entry(rel, hashFile(path.join(hqRoot, rel))); // hash of v1/version:1
125
+ seededHashes = {};
126
+ for (const rel of SCAFFOLD) {
127
+ const h = hashFile(path.join(hqRoot, rel)); // hash of v1/version:1
128
+ seededHashes[rel] = h;
129
+ files[rel] = entry(rel, h);
130
+ }
120
131
  // personal/ pending edit: journal records a DIFFERENT (already-synced) hash than local.
121
132
  files["personal/edited.md"] = { ...entry("personal/edited.md", "remote-side-hash-differs-from-local") };
122
133
  writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, {
@@ -147,7 +158,7 @@ exec ${JSON.stringify(realGit)} "$@"
147
158
  if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
148
159
  });
149
160
 
150
- it("re-stamps EVERY changed scaffold file (incl. core.yaml), and never touches the personal/ pending edit", () => {
161
+ it("leaves EVERY journal entry untouched so the push leg uploads rescue output (vault converges)", () => {
151
162
  const r = runRescueCapture(
152
163
  ["--hq-root", hqRoot, "--source", "test/repo", "--ref", "main", "--floor-sha", floorSha, "--yes", "--no-backup"],
153
164
  env,
@@ -161,19 +172,44 @@ exec ${JSON.stringify(realGit)} "$@"
161
172
 
162
173
  const j = readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files;
163
174
 
164
- // INVARIANT: after the reconcile, every scaffold entry's baseline == current
165
- // local hash -> localChanged is false -> no false conflict on the next sync.
166
- // core.yaml is included BECAUSE the reconcile runs AFTER its yq/py stamp.
175
+ // INVARIANT: the rescue never rewrites journal baselines. Every scaffold
176
+ // entry still carries its PRE-rescue hash -> localChanged=true on the next
177
+ // sync -> the push leg uploads the regenerated scaffold and the VAULT
178
+ // converges to the rescue output. (Masking these — 6.2.1/6.2.3 — left the
179
+ // vault silently stale.)
167
180
  for (const rel of SCAFFOLD) {
168
- expect(j[rel].hash, `${rel} not reconciled`).toBe(hashFile(path.join(hqRoot, rel)));
169
- expect(j[rel].remoteEtag, `${rel} remote side touched`).toBe("seed-etag"); // clean push/converge, not conflict
181
+ expect(j[rel].hash, `${rel} baseline was rewritten`).toBe(seededHashes[rel]);
182
+ expect(j[rel].remoteEtag, `${rel} remote side touched`).toBe("seed-etag");
183
+ // and the local file genuinely changed, so the entry is push-visible
184
+ expect(j[rel].hash).not.toBe(hashFile(path.join(hqRoot, rel)));
170
185
  }
171
186
 
172
- // SAFETY: the personal/ pending edit is preserved (NOT marked synced) so it
173
- // still uploads — its baseline stays the divergent seeded hash.
187
+ // SAFETY: the personal/ pending edit is untouched too still uploads.
174
188
  expect(j["personal/edited.md"].hash).toBe("remote-side-hash-differs-from-local");
175
189
  expect(j["personal/edited.md"].hash).not.toBe(hashFile(path.join(hqRoot, "personal/edited.md")));
176
190
 
177
- expect(r.stdout).toContain("Reconciled sync-journal baseline");
191
+ // the masking reconcile is gone
192
+ expect(r.stdout).not.toContain("Reconciled sync-journal baseline");
193
+ });
194
+
195
+ it("stamps core.yaml deterministically: byte-identical across runs, last_sync_at = source committer time", () => {
196
+ const coreYaml = path.join(hqRoot, "core/core.yaml");
197
+ const firstRun = fs.readFileSync(coreYaml, "utf-8");
198
+
199
+ // last_sync_at must be derived from the HEAD commit, not wall-clock now.
200
+ // utcStamp(_, "colon") renders UTC as YYYY-MM-DDTHH:MM:SSZ — compare on
201
+ // the epoch second, not the string, to stay offset-agnostic.
202
+ const stamped = /last_sync_at:\s*["']?([0-9TZ:.-]+)["']?/.exec(firstRun);
203
+ expect(stamped, `no last_sync_at in:\n${firstRun}`).not.toBeNull();
204
+ expect(new Date(stamped![1]).getTime()).toBe(new Date(headCommitterIso).getTime());
205
+
206
+ // a second rescue of the SAME SHA must write byte-identical core.yaml —
207
+ // this is the cross-machine `core.yaml.conflict-*` mirror regression.
208
+ const r2 = runRescueCapture(
209
+ ["--hq-root", hqRoot, "--source", "test/repo", "--ref", "main", "--floor-sha", floorSha, "--yes", "--no-backup"],
210
+ env,
211
+ );
212
+ expect(r2.status, r2.stdout).toBe(0);
213
+ expect(fs.readFileSync(coreYaml, "utf-8")).toBe(firstRun);
178
214
  });
179
215
  });
package/src/cli/share.ts CHANGED
@@ -30,6 +30,8 @@ import {
30
30
  updateEntry,
31
31
  removeEntry,
32
32
  normalizeEtag,
33
+ PERSONAL_VAULT_JOURNAL_SLUG,
34
+ migratePersonalVaultJournal,
33
35
  } from "../journal.js";
34
36
  import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
35
37
  import {
@@ -755,6 +757,10 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
755
757
  ? wrapFilterWithPersonalVaultDefaults(ignoreFilter, syncRoot, onExcluded)
756
758
  : ignoreFilter;
757
759
  const journalSlug = options.journalSlug ?? ctx.slug;
760
+ // Seed the canonical personal-vault journal from the legacy `personal` file
761
+ // exactly once — engine-side so every consumer (sync-runner, hq-cli) gets
762
+ // it; see the matching guard in sync.ts.
763
+ if (journalSlug === PERSONAL_VAULT_JOURNAL_SLUG) migratePersonalVaultJournal();
758
764
  const journal = readJournal(journalSlug);
759
765
 
760
766
  let filesUploaded = 0;
package/src/cli/sync.ts CHANGED
@@ -31,6 +31,8 @@ import {
31
31
  lastPullRecord,
32
32
  appendPullRecord,
33
33
  generatePullId,
34
+ PERSONAL_VAULT_JOURNAL_SLUG,
35
+ migratePersonalVaultJournal,
34
36
  } from "../journal.js";
35
37
  import {
36
38
  buildScopeShrinkPlan,
@@ -526,6 +528,12 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
526
528
  const shouldSync = createIgnoreFilter(hqRoot);
527
529
  const journalSlug = options.journalSlug ?? ctx.slug;
528
530
  const startedAt = new Date().toISOString();
531
+ // Personal-vault callers must never start from an empty journal when only
532
+ // the legacy `personal` file exists (mass re-download/etag churn). Seeding
533
+ // here — inside the engine — covers every consumer (sync-runner already
534
+ // seeds; hq-cli historically didn't, which split the vault's bookkeeping
535
+ // across two journal files and re-flagged synced files as conflicts).
536
+ if (journalSlug === PERSONAL_VAULT_JOURNAL_SLUG) migratePersonalVaultJournal();
529
537
  // Migrate v1 → v2 in place so the scope-shrink / pull-record machinery has
530
538
  // its fields, and GC any tombstones past the 30-day retention window before
531
539
  // we re-evaluate orphans (so a long-pruned path can re-download cleanly).
package/src/index.ts CHANGED
@@ -41,6 +41,13 @@ export {
41
41
  gcTombstones,
42
42
  TOMBSTONE_TTL_MS,
43
43
  JOURNAL_VERSION_CURRENT,
44
+ // Canonical personal-vault journal slug + its one-time legacy seed. Exported
45
+ // so downstream CLIs (hq-cli `sync --personal`) journal the personal vault
46
+ // under the SAME slug as hq-sync-runner. hq-cli hardcoding the legacy
47
+ // `"personal"` slug split the vault's bookkeeping across two journal files —
48
+ // each surface re-flagged the other's already-synced files as conflicts.
49
+ PERSONAL_VAULT_JOURNAL_SLUG,
50
+ migratePersonalVaultJournal,
44
51
  } from "./journal.js";
45
52
 
46
53
  // Prefix coalescing helper (US-005)
@@ -33,6 +33,11 @@ describe("public package surface contract (@indigoai-us/hq-cloud)", () => {
33
33
  "VendInput",
34
34
  "VendResult",
35
35
  "VendCredentials",
36
+ // Canonical personal-vault journal slug (+ legacy seed) — consumed by
37
+ // hq-cli `sync --personal` so the CLI and hq-sync-runner journal the
38
+ // vault under ONE slug (split-brain regression, 2026-06-10).
39
+ "PERSONAL_VAULT_JOURNAL_SLUG",
40
+ "migratePersonalVaultJournal",
36
41
  ] as const;
37
42
 
38
43
  it.each(SYNC_BROWSE_NAMES)(