@indigoai-us/hq-cloud 6.2.3 → 6.2.5

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.
@@ -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 {
@@ -216,6 +218,21 @@ export function isEphemeralPath(p: string): boolean {
216
218
  return EPHEMERAL_PATH_PATTERN.test(p);
217
219
  }
218
220
 
221
+ /**
222
+ * A vault key containing a backslash is never legitimate. HQ keys are POSIX
223
+ * (`toPosixKey` normalizes at every walker since 5.47.2 and `uploadFile`
224
+ * hard-normalizes at the S3 boundary), so a `\` in a remote key can only come
225
+ * from a pre-5.47.2 Windows client whose walker built keys with `path.sep` —
226
+ * verified live 2026-06-10: one such client duplicated 5,711 keys
227
+ * (`skills\demo-hq\SKILL.md`, …) into a company vault, and every up-to-date
228
+ * puller then materialized them as junk single-filename-with-backslash files
229
+ * that churned conflicts forever. The pull walker refuses these keys
230
+ * (skip-excluded-policy), symmetric with the ephemeral-mirror filter above.
231
+ */
232
+ export function isMalformedVaultKey(key: string): boolean {
233
+ return key.includes("\\");
234
+ }
235
+
219
236
  /**
220
237
  * Test-only export. Kept under a `_testing` namespace so the module's public
221
238
  * surface stays focused on `share()` / `ShareOptions` / `ShareResult` while
@@ -755,6 +772,10 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
755
772
  ? wrapFilterWithPersonalVaultDefaults(ignoreFilter, syncRoot, onExcluded)
756
773
  : ignoreFilter;
757
774
  const journalSlug = options.journalSlug ?? ctx.slug;
775
+ // Seed the canonical personal-vault journal from the legacy `personal` file
776
+ // exactly once — engine-side so every consumer (sync-runner, hq-cli) gets
777
+ // it; see the matching guard in sync.ts.
778
+ if (journalSlug === PERSONAL_VAULT_JOURNAL_SLUG) migratePersonalVaultJournal();
758
779
  const journal = readJournal(journalSlug);
759
780
 
760
781
  let filesUploaded = 0;
@@ -1100,6 +1100,42 @@ describe("sync", () => {
1100
1100
  expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
1101
1101
  });
1102
1102
 
1103
+ it("skips remote keys containing backslashes (malformed Windows-client keys)", async () => {
1104
+ // A pre-5.47.2 Windows client built S3 keys with path.sep, duplicating a
1105
+ // company tree under keys like `skills\\demo-hq\\SKILL.md` (verified live
1106
+ // 2026-06-10: 5,711 such keys in one vault). On POSIX, downloading one
1107
+ // creates a junk FILE whose name contains backslashes, which then churns
1108
+ // conflict mirrors on every sync. The pull planner must refuse them at
1109
+ // planning time — same policy bucket as the ephemeral-mirror filter.
1110
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1111
+ // Malformed Windows key — must be filtered, never downloaded.
1112
+ {
1113
+ key: "skills\\demo\\SKILL.md",
1114
+ size: 44,
1115
+ lastModified: new Date(),
1116
+ etag: '"abc"',
1117
+ },
1118
+ // Its legitimate POSIX twin — must still download.
1119
+ { key: "skills/demo/SKILL.md", size: 30, lastModified: new Date(), etag: '"def"' },
1120
+ ]);
1121
+
1122
+ const result = await sync({
1123
+ company: "acme",
1124
+ vaultConfig: mockConfig,
1125
+ hqRoot: tmpDir,
1126
+ });
1127
+
1128
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1129
+ // The malformed key MUST NOT be materialized — neither as a literal
1130
+ // backslash-named file nor as a nested path.
1131
+ expect(fs.existsSync(path.join(companyRoot, "skills\\demo\\SKILL.md"))).toBe(false);
1132
+ // The legitimate twin MUST download.
1133
+ expect(fs.existsSync(path.join(companyRoot, "skills", "demo", "SKILL.md"))).toBe(true);
1134
+
1135
+ expect(result.filesDownloaded).toBe(1);
1136
+ expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
1137
+ });
1138
+
1103
1139
  it("overwrites local on --on-conflict overwrite", async () => {
1104
1140
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
1105
1141
  fs.mkdirSync(companyDocs, { recursive: true });
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,
@@ -40,7 +42,7 @@ import {
40
42
  } from "../scope-shrink.js";
41
43
  import { coalescePrefixes, isCoveredByAny } from "../prefix-coalesce.js";
42
44
  import { createIgnoreFilter } from "../ignore.js";
43
- import { isEphemeralPath } from "./share.js";
45
+ import { isEphemeralPath, isMalformedVaultKey } from "./share.js";
44
46
  import { resolveConflict } from "./conflict.js";
45
47
  import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
46
48
  import {
@@ -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).
@@ -1469,6 +1477,17 @@ function computePullPlan(
1469
1477
  continue;
1470
1478
  }
1471
1479
 
1480
+ // Malformed-key filter — keys with backslash separators pushed by
1481
+ // pre-5.47.2 Windows clients. Downloading one materializes a junk local
1482
+ // file whose NAME contains backslashes (it is not a path on POSIX), which
1483
+ // then churns conflict mirrors forever. Refuse at planning time, same
1484
+ // policy bucket as the ephemeral filter above. The bogus keys themselves
1485
+ // are cleaned server-side; this keeps clean trees clean in the meantime.
1486
+ if (isMalformedVaultKey(remoteFile.key)) {
1487
+ items.push({ action: "skip-excluded-policy", remoteFile, localPath });
1488
+ continue;
1489
+ }
1490
+
1472
1491
  if (personalMode && remoteFile.key.startsWith("companies/")) {
1473
1492
  // Default: drop every `companies/...` key — the legacy contract
1474
1493
  // is that the personal bucket should never contain them.
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)(