@indigoai-us/hq-cloud 5.23.0 → 5.25.0

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 (36) hide show
  1. package/dist/bin/sync-runner.d.ts +58 -3
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +84 -2
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +90 -3
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/share.d.ts +86 -20
  8. package/dist/cli/share.d.ts.map +1 -1
  9. package/dist/cli/share.js +332 -62
  10. package/dist/cli/share.js.map +1 -1
  11. package/dist/cli/share.test.js +490 -6
  12. package/dist/cli/share.test.js.map +1 -1
  13. package/dist/cli/sync.d.ts +48 -0
  14. package/dist/cli/sync.d.ts.map +1 -1
  15. package/dist/cli/sync.js.map +1 -1
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +3 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/personal-vault-exclusions.d.ts +128 -0
  21. package/dist/personal-vault-exclusions.d.ts.map +1 -0
  22. package/dist/personal-vault-exclusions.js +231 -0
  23. package/dist/personal-vault-exclusions.js.map +1 -0
  24. package/dist/personal-vault-exclusions.test.d.ts +22 -0
  25. package/dist/personal-vault-exclusions.test.d.ts.map +1 -0
  26. package/dist/personal-vault-exclusions.test.js +198 -0
  27. package/dist/personal-vault-exclusions.test.js.map +1 -0
  28. package/package.json +1 -1
  29. package/src/bin/sync-runner.test.ts +113 -3
  30. package/src/bin/sync-runner.ts +125 -5
  31. package/src/cli/share.test.ts +585 -6
  32. package/src/cli/share.ts +461 -86
  33. package/src/cli/sync.ts +50 -0
  34. package/src/index.ts +10 -0
  35. package/src/personal-vault-exclusions.test.ts +256 -0
  36. package/src/personal-vault-exclusions.ts +277 -0
@@ -23,7 +23,7 @@ vi.mock("../s3.js", () => ({
23
23
  headRemoteFile: vi.fn().mockResolvedValue(null),
24
24
  }));
25
25
 
26
- import { share } from "./share.js";
26
+ import { share, _testing as shareTesting } from "./share.js";
27
27
  import { deleteRemoteFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
28
28
  import type { EntityContext } from "../types.js";
29
29
 
@@ -595,9 +595,14 @@ describe("share", () => {
595
595
  vaultConfig: mockConfig,
596
596
  hqRoot: tmpDir,
597
597
  onEvent: (e) => {
598
- // Only file-level events carry `.path`. The Stage-1 `plan` event is
599
- // surfaced separately and tested in its own block.
600
- if (e.type === "plan" || e.type === "new-files") return;
598
+ // Only file-level events carry `.path`. The Stage-1 `plan` event +
599
+ // the new-files event + the personal-vault-out-of-policy summary
600
+ // event are surfaced separately and tested in their own blocks.
601
+ if (
602
+ e.type === "plan" ||
603
+ e.type === "new-files" ||
604
+ e.type === "personal-vault-out-of-policy"
605
+ ) return;
601
606
  events.push({
602
607
  type: e.type,
603
608
  path: e.path,
@@ -915,6 +920,10 @@ describe("share", () => {
915
920
  hqRoot: tmpDir,
916
921
  skipUnchanged: true,
917
922
  propagateDeletes: true,
923
+ // Pin owned-only — this test predates the currency-gated default and
924
+ // asserts the direction-of-origin branch. A separate currency-gated
925
+ // test covers the new default semantics for the same scenario.
926
+ propagateDeletePolicy: "owned-only",
918
927
  });
919
928
 
920
929
  expect(result.filesDeleted).toBe(1);
@@ -965,6 +974,9 @@ describe("share", () => {
965
974
  hqRoot: tmpDir,
966
975
  skipUnchanged: true,
967
976
  propagateDeletes: true,
977
+ // Owned-only — predates currency-gated default; asserts the lstat
978
+ // guard which is independent of the policy branch.
979
+ propagateDeletePolicy: "owned-only",
968
980
  });
969
981
 
970
982
  expect(result.filesDeleted).toBe(0);
@@ -1013,6 +1025,8 @@ describe("share", () => {
1013
1025
  hqRoot: tmpDir,
1014
1026
  skipUnchanged: true,
1015
1027
  propagateDeletes: true,
1028
+ // Owned-only — currency-gated semantics covered separately below.
1029
+ propagateDeletePolicy: "owned-only",
1016
1030
  });
1017
1031
 
1018
1032
  expect(result.filesDeleted).toBe(1);
@@ -1054,6 +1068,9 @@ describe("share", () => {
1054
1068
  hqRoot: tmpDir,
1055
1069
  skipUnchanged: true,
1056
1070
  propagateDeletes: true,
1071
+ // Owned-only — currency-gated emits a separate event variant; this test
1072
+ // pins the legacy progress-with-deleted-flag shape.
1073
+ propagateDeletePolicy: "owned-only",
1057
1074
  onEvent: (e) => events.push(e as { type: string }),
1058
1075
  });
1059
1076
 
@@ -1143,6 +1160,9 @@ describe("share", () => {
1143
1160
  hqRoot: tmpDir,
1144
1161
  skipUnchanged: true,
1145
1162
  propagateDeletes: true,
1163
+ // Owned-only — this test asserts scope containment, independent of
1164
+ // the policy branch. Currency-gated covered separately.
1165
+ propagateDeletePolicy: "owned-only",
1146
1166
  });
1147
1167
 
1148
1168
  expect(result.filesDeleted).toBe(1);
@@ -1190,6 +1210,9 @@ describe("share", () => {
1190
1210
  hqRoot: tmpDir,
1191
1211
  skipUnchanged: true,
1192
1212
  propagateDeletes: true,
1213
+ // Owned-only — pinning so the retry-survival assertion is independent
1214
+ // of currency-gated's HEAD-driven bucketing.
1215
+ propagateDeletePolicy: "owned-only",
1193
1216
  onEvent: (e) => events.push(e as { type: string }),
1194
1217
  });
1195
1218
 
@@ -1227,7 +1250,7 @@ describe("share", () => {
1227
1250
  // (i) `direction === "up"` requirement under the default policy.
1228
1251
  // (ii) `shouldSync` must accept the key — same filter the pull uses.
1229
1252
 
1230
- it("propagateDeletes: under owned-only (default), skips direction:'down' entries", async () => {
1253
+ it("propagateDeletes: under owned-only (legacy pre-5.24 default), skips direction:'down' entries", async () => {
1231
1254
  const companyRoot = path.join(tmpDir, "companies", "acme");
1232
1255
  fs.mkdirSync(companyRoot, { recursive: true });
1233
1256
  // No local files. One journal entry pulled from elsewhere (direction:'down')
@@ -1259,7 +1282,8 @@ describe("share", () => {
1259
1282
  hqRoot: tmpDir,
1260
1283
  skipUnchanged: true,
1261
1284
  propagateDeletes: true,
1262
- // propagateDeletePolicy omitted defaults to "owned-only".
1285
+ // Explicit opt-in `currency-gated` is the new (5.24+) default.
1286
+ propagateDeletePolicy: "owned-only",
1263
1287
  });
1264
1288
 
1265
1289
  // Only the 'up' entry is deleted; the 'down' entry is left alone so a
@@ -1338,6 +1362,10 @@ describe("share", () => {
1338
1362
  hqRoot: tmpDir,
1339
1363
  skipUnchanged: true,
1340
1364
  propagateDeletes: true,
1365
+ // Owned-only — filter symmetry is policy-independent; pinning so the
1366
+ // legacy direction-of-origin gate (not currency-gated's HEAD path)
1367
+ // resolves the "delete vs skip" decision for active/current.md.
1368
+ propagateDeletePolicy: "owned-only",
1341
1369
  });
1342
1370
 
1343
1371
  // legacy/old-layout.md is filter-skipped; only active/current.md is
@@ -1347,6 +1375,420 @@ describe("share", () => {
1347
1375
  expect(deleteRemoteFile).not.toHaveBeenCalledWith(expect.anything(), "legacy/old-layout.md");
1348
1376
  });
1349
1377
 
1378
+ // ── Delete propagation: currency-gated policy (5.24+ default) ──────────────
1379
+ //
1380
+ // The pre-5.24 `owned-only` default refused to delete-propagate any journal
1381
+ // entry whose `direction !== "up"`. That made sense as a safety net against
1382
+ // a behind machine's first push erasing peer uploads — but it had a worse
1383
+ // failure mode in practice: every `/update-hq` writes `core/`, `.claude/`,
1384
+ // `.codex/` with `direction: "down"` (they came from upstream), so any
1385
+ // subsequent local delete during a cleanup or upgrade was silently dropped.
1386
+ // Net effect: remote vault accumulated permanent litter the user could not
1387
+ // clean up without manually invoking `policy: "all"` (which has its own
1388
+ // safety problems).
1389
+ //
1390
+ // `currency-gated` solves this by gating on per-file proof of currency: HEAD
1391
+ // the remote, compare ETag, only propagate when this device has the
1392
+ // latest version. Direction-of-origin becomes irrelevant — a file the
1393
+ // upstream `/update-hq` wrote can be cleanly deleted by the device that
1394
+ // wrote it, as long as no other device modified it in the meantime.
1395
+
1396
+ it("currency-gated: propagates delete when remote ETag matches journal", async () => {
1397
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1398
+ fs.mkdirSync(companyRoot, { recursive: true });
1399
+ // direction:"down" file (e.g. arrived via /update-hq), locally deleted.
1400
+ // Under owned-only this would be stuck on remote forever; currency-gated
1401
+ // checks the ETag and propagates the delete cleanly when match holds.
1402
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1403
+ fs.writeFileSync(
1404
+ journalPath,
1405
+ JSON.stringify({
1406
+ version: "1",
1407
+ lastSync: new Date().toISOString(),
1408
+ files: {
1409
+ "core/policies/old.md": {
1410
+ hash: "h", size: 100, syncedAt: new Date().toISOString(),
1411
+ direction: "down",
1412
+ remoteEtag: "abc123",
1413
+ },
1414
+ },
1415
+ }),
1416
+ );
1417
+
1418
+ // HEAD returns the same etag — this device is current for the file.
1419
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
1420
+ lastModified: new Date(),
1421
+ etag: '"abc123"',
1422
+ size: 100,
1423
+ });
1424
+
1425
+ const result = await share({
1426
+ paths: [companyRoot],
1427
+ company: "acme",
1428
+ vaultConfig: mockConfig,
1429
+ hqRoot: tmpDir,
1430
+ skipUnchanged: true,
1431
+ propagateDeletes: true,
1432
+ propagateDeletePolicy: "currency-gated",
1433
+ });
1434
+
1435
+ expect(result.filesDeleted).toBe(1);
1436
+ expect(deleteRemoteFile).toHaveBeenCalledWith(
1437
+ expect.anything(),
1438
+ "core/policies/old.md",
1439
+ );
1440
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1441
+ expect(journal.files["core/policies/old.md"]).toBeUndefined();
1442
+ });
1443
+
1444
+ it("currency-gated: refuses delete + emits stale-etag event when remote moved since last sync", async () => {
1445
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1446
+ fs.mkdirSync(companyRoot, { recursive: true });
1447
+ // Another device modified `shared.md` after this device's last sync.
1448
+ // The journal still records the old etag; HEAD returns the new one.
1449
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1450
+ fs.writeFileSync(
1451
+ journalPath,
1452
+ JSON.stringify({
1453
+ version: "1",
1454
+ lastSync: new Date().toISOString(),
1455
+ files: {
1456
+ "shared.md": {
1457
+ hash: "h", size: 50, syncedAt: new Date().toISOString(),
1458
+ direction: "down",
1459
+ remoteEtag: "stale-etag",
1460
+ },
1461
+ },
1462
+ }),
1463
+ );
1464
+
1465
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
1466
+ lastModified: new Date(),
1467
+ etag: '"fresh-etag"',
1468
+ size: 51,
1469
+ });
1470
+
1471
+ const events: Array<{ type: string; path?: string; journalEtag?: string; remoteEtag?: string }> = [];
1472
+ const result = await share({
1473
+ paths: [companyRoot],
1474
+ company: "acme",
1475
+ vaultConfig: mockConfig,
1476
+ hqRoot: tmpDir,
1477
+ skipUnchanged: true,
1478
+ propagateDeletes: true,
1479
+ propagateDeletePolicy: "currency-gated",
1480
+ onEvent: (e) => events.push(e as { type: string }),
1481
+ });
1482
+
1483
+ // No remote DELETE issued, journal entry preserved (pull will re-pull).
1484
+ expect(result.filesDeleted).toBe(0);
1485
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
1486
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1487
+ expect(journal.files["shared.md"]).toBeDefined();
1488
+
1489
+ // Dedicated event fired so UIs surface the refusal. `reason` field
1490
+ // discriminates "stale-etag" (real drift) from "legacy-no-etag" so
1491
+ // consumers don't have to string-compare placeholder etag values.
1492
+ const refusedEvent = events.find((e) => e.type === "delete-refused-stale-etag");
1493
+ expect(refusedEvent).toMatchObject({
1494
+ type: "delete-refused-stale-etag",
1495
+ path: "shared.md",
1496
+ journalEtag: "stale-etag",
1497
+ remoteEtag: "fresh-etag",
1498
+ reason: "stale-etag",
1499
+ });
1500
+ // Counter exposed on ShareResult so callers don't need to re-count events.
1501
+ expect(result.filesRefusedStale).toBe(1);
1502
+ expect(result.filesTombstoned).toBe(0);
1503
+ });
1504
+
1505
+ it("currency-gated: tombstones journal entry when remote returns 404 (out-of-band cleanup)", async () => {
1506
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1507
+ fs.mkdirSync(companyRoot, { recursive: true });
1508
+ // Remote file was deleted out-of-band (e.g. someone removed it via the
1509
+ // S3 console). Local copy also missing. Currency-gated should drop the
1510
+ // journal entry without issuing a DELETE — the remote is already gone.
1511
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1512
+ fs.writeFileSync(
1513
+ journalPath,
1514
+ JSON.stringify({
1515
+ version: "1",
1516
+ lastSync: new Date().toISOString(),
1517
+ files: {
1518
+ "removed-out-of-band.md": {
1519
+ hash: "h", size: 25, syncedAt: new Date().toISOString(),
1520
+ direction: "down",
1521
+ remoteEtag: "doesnt-matter",
1522
+ },
1523
+ },
1524
+ }),
1525
+ );
1526
+
1527
+ vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
1528
+
1529
+ const events: Array<{ type: string; path?: string; deleted?: boolean; message?: string; bytes?: number }> = [];
1530
+ const result = await share({
1531
+ paths: [companyRoot],
1532
+ company: "acme",
1533
+ vaultConfig: mockConfig,
1534
+ hqRoot: tmpDir,
1535
+ skipUnchanged: true,
1536
+ propagateDeletes: true,
1537
+ propagateDeletePolicy: "currency-gated",
1538
+ onEvent: (e) => events.push(e as { type: string }),
1539
+ });
1540
+
1541
+ // No DeleteObject (remote was already gone), but journal converges.
1542
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
1543
+ // filesDeleted counts actual S3 deletes; tombstones aren't counted here.
1544
+ expect(result.filesDeleted).toBe(0);
1545
+ // Tombstones have their own counter so callers can distinguish them
1546
+ // from real deletes without re-counting events.
1547
+ expect(result.filesTombstoned).toBe(1);
1548
+ expect(result.filesRefusedStale).toBe(0);
1549
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1550
+ expect(journal.files["removed-out-of-band.md"]).toBeUndefined();
1551
+ // Synthetic progress event carries the tombstone marker. Without this,
1552
+ // the tty stream renders tombstones identically to real deletes — operator
1553
+ // can't tell from logs alone that no S3 call was issued.
1554
+ const tombstoneEvent = events.find(
1555
+ (e) => e.type === "progress" && e.deleted === true && e.message?.includes("tombstone"),
1556
+ );
1557
+ expect(tombstoneEvent).toMatchObject({
1558
+ type: "progress",
1559
+ path: "removed-out-of-band.md",
1560
+ bytes: 0,
1561
+ deleted: true,
1562
+ message: expect.stringContaining("tombstone"),
1563
+ });
1564
+ });
1565
+
1566
+ it("currency-gated: refuses delete for legacy journal entry with no recorded remoteEtag", async () => {
1567
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1568
+ fs.mkdirSync(companyRoot, { recursive: true });
1569
+ // Journal entry from before remoteEtag tracking — no etag to compare
1570
+ // against. Refuse the delete in the safe direction; a future sync with
1571
+ // a recorded etag can re-evaluate.
1572
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1573
+ fs.writeFileSync(
1574
+ journalPath,
1575
+ JSON.stringify({
1576
+ version: "1",
1577
+ lastSync: new Date().toISOString(),
1578
+ files: {
1579
+ "legacy-no-etag.md": {
1580
+ hash: "h", size: 5, syncedAt: new Date().toISOString(),
1581
+ direction: "down",
1582
+ // No remoteEtag.
1583
+ },
1584
+ },
1585
+ }),
1586
+ );
1587
+
1588
+ const events: Array<{ type: string; journalEtag?: string }> = [];
1589
+ const result = await share({
1590
+ paths: [companyRoot],
1591
+ company: "acme",
1592
+ vaultConfig: mockConfig,
1593
+ hqRoot: tmpDir,
1594
+ skipUnchanged: true,
1595
+ propagateDeletes: true,
1596
+ propagateDeletePolicy: "currency-gated",
1597
+ onEvent: (e) => events.push(e as { type: string }),
1598
+ });
1599
+
1600
+ expect(result.filesDeleted).toBe(0);
1601
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
1602
+ // HEAD must NOT be called — we short-circuit on the missing journal etag.
1603
+ expect(headRemoteFile).not.toHaveBeenCalled();
1604
+ const refused = events.find((e) => e.type === "delete-refused-stale-etag");
1605
+ expect(refused).toMatchObject({
1606
+ journalEtag: "<legacy-no-etag>",
1607
+ reason: "legacy-no-etag",
1608
+ });
1609
+ expect(result.filesRefusedStale).toBe(1);
1610
+ });
1611
+
1612
+ it("currency-gated: real-world /update-hq scenario — direction:'down' delete propagates when current", async () => {
1613
+ // Regression for the exact bug that motivated the 5.24 default flip:
1614
+ // every `/update-hq` writes core/.claude/.codex from upstream and
1615
+ // journals them as direction:"down". When the user later moves or
1616
+ // deletes one locally (e.g. during cleanup, dir-restructure, or the
1617
+ // next upgrade), pre-5.24 `owned-only` silently kept it on remote
1618
+ // forever. Currency-gated propagates the delete cleanly as long as
1619
+ // this device is current for the file.
1620
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1621
+ fs.mkdirSync(companyRoot, { recursive: true });
1622
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1623
+ fs.writeFileSync(
1624
+ journalPath,
1625
+ JSON.stringify({
1626
+ version: "1",
1627
+ lastSync: new Date().toISOString(),
1628
+ files: {
1629
+ ".claude/commands/retired-command.md": {
1630
+ hash: "h", size: 200, syncedAt: new Date().toISOString(),
1631
+ direction: "down",
1632
+ remoteEtag: "upstream-etag",
1633
+ },
1634
+ },
1635
+ }),
1636
+ );
1637
+
1638
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
1639
+ lastModified: new Date(),
1640
+ etag: '"upstream-etag"',
1641
+ size: 200,
1642
+ });
1643
+
1644
+ const result = await share({
1645
+ paths: [companyRoot],
1646
+ company: "acme",
1647
+ vaultConfig: mockConfig,
1648
+ hqRoot: tmpDir,
1649
+ skipUnchanged: true,
1650
+ propagateDeletes: true,
1651
+ // Explicit opt-in. 5.24 ships the currency-gated CODE PATH but keeps
1652
+ // `owned-only` as the default while it soaks; the default flips to
1653
+ // `currency-gated` in 5.25. This test pins the user-facing semantics
1654
+ // (the /update-hq delete-propagation bug) under the new policy
1655
+ // regardless of which default the surrounding release ships.
1656
+ propagateDeletePolicy: "currency-gated",
1657
+ });
1658
+
1659
+ expect(result.filesDeleted).toBe(1);
1660
+ expect(deleteRemoteFile).toHaveBeenCalledWith(
1661
+ expect.anything(),
1662
+ ".claude/commands/retired-command.md",
1663
+ );
1664
+ });
1665
+
1666
+ // ── Conflict-mirror exclusion (ephemeral pattern) ──────────────────────────
1667
+ //
1668
+ // Conflict mirrors (`*.conflict-<ISO>-<machineHash>.<ext>`) are local-only
1669
+ // safety backups written by the pull leg whenever a 3-way merge keeps
1670
+ // local and wants to preserve the remote version for inspection. They
1671
+ // MUST never round-trip to S3. Pre-fix, the push walker uploaded them,
1672
+ // the journal tracked them, and the owned-only delete policy then refused
1673
+ // to clean them up — permanent ratchet of remote litter.
1674
+
1675
+ it("conflict-mirror exclusion: push walker skips local conflict-mirror files", async () => {
1676
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1677
+ fs.mkdirSync(path.join(companyRoot, "skills"), { recursive: true });
1678
+ // Two files: a normal one and a conflict mirror. Only the normal one
1679
+ // should reach S3; the mirror is local-only state.
1680
+ fs.writeFileSync(
1681
+ path.join(companyRoot, "skills", "real.md"),
1682
+ "real content",
1683
+ );
1684
+ fs.writeFileSync(
1685
+ path.join(companyRoot, "skills", "real.md.conflict-2026-05-13T19-40-40Z-e5797a.md"),
1686
+ "conflict mirror content",
1687
+ );
1688
+
1689
+ await share({
1690
+ paths: [companyRoot],
1691
+ company: "acme",
1692
+ vaultConfig: mockConfig,
1693
+ hqRoot: tmpDir,
1694
+ skipUnchanged: true,
1695
+ });
1696
+
1697
+ expect(uploadFile).toHaveBeenCalledTimes(1);
1698
+ expect(uploadFile).toHaveBeenCalledWith(
1699
+ expect.anything(),
1700
+ expect.stringContaining("real.md"),
1701
+ "skills/real.md",
1702
+ );
1703
+ // Spy was called once total — the conflict mirror never reaches uploadFile.
1704
+ // (Asserted by count above; the explicit non-call below is defensive.)
1705
+ const calls = vi.mocked(uploadFile).mock.calls;
1706
+ expect(calls.every((c) => !String(c[1] ?? "").includes("conflict-"))).toBe(true);
1707
+ });
1708
+
1709
+ it("conflict-mirror exclusion: explicit user-supplied conflict-mirror path is also refused", async () => {
1710
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1711
+ fs.mkdirSync(companyRoot, { recursive: true });
1712
+ const mirrorPath = path.join(
1713
+ companyRoot,
1714
+ "CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md",
1715
+ );
1716
+ fs.writeFileSync(mirrorPath, "mirror content");
1717
+
1718
+ await share({
1719
+ paths: [mirrorPath],
1720
+ company: "acme",
1721
+ vaultConfig: mockConfig,
1722
+ hqRoot: tmpDir,
1723
+ skipUnchanged: true,
1724
+ });
1725
+
1726
+ // Explicit caller path matching the ephemeral pattern is filtered the
1727
+ // same as a walker-discovered one. Belt-and-suspenders against any
1728
+ // tooling that hands a conflict mirror to share() directly.
1729
+ expect(uploadFile).not.toHaveBeenCalled();
1730
+ });
1731
+
1732
+ it("conflict-mirror exclusion: journaled mirror with local-missing is NOT swept by delete plan", async () => {
1733
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1734
+ fs.mkdirSync(companyRoot, { recursive: true });
1735
+ // Simulate the existing-litter state: a conflict mirror that leaked
1736
+ // into the journal in a prior buggy version. Locally missing (user
1737
+ // already deleted it). The regular delete plan must NOT issue a
1738
+ // DeleteObject — that's the dedicated reconcile command's job, and
1739
+ // a sync should not accidentally race a user reviewing the mirror.
1740
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1741
+ fs.writeFileSync(
1742
+ journalPath,
1743
+ JSON.stringify({
1744
+ version: "1",
1745
+ lastSync: new Date().toISOString(),
1746
+ files: {
1747
+ "CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md": {
1748
+ hash: "h", size: 10, syncedAt: new Date().toISOString(),
1749
+ direction: "up",
1750
+ remoteEtag: "mirror-etag",
1751
+ },
1752
+ "regular.md": {
1753
+ hash: "h", size: 5, syncedAt: new Date().toISOString(),
1754
+ direction: "up",
1755
+ remoteEtag: "regular-etag",
1756
+ },
1757
+ },
1758
+ }),
1759
+ );
1760
+
1761
+ // HEAD needed for the non-mirror entry under currency-gated.
1762
+ vi.mocked(headRemoteFile).mockResolvedValue({
1763
+ lastModified: new Date(),
1764
+ etag: '"regular-etag"',
1765
+ size: 5,
1766
+ });
1767
+
1768
+ const result = await share({
1769
+ paths: [companyRoot],
1770
+ company: "acme",
1771
+ vaultConfig: mockConfig,
1772
+ hqRoot: tmpDir,
1773
+ skipUnchanged: true,
1774
+ propagateDeletes: true,
1775
+ });
1776
+
1777
+ expect(result.filesDeleted).toBe(1);
1778
+ expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
1779
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "regular.md");
1780
+ expect(deleteRemoteFile).not.toHaveBeenCalledWith(
1781
+ expect.anything(),
1782
+ expect.stringContaining("conflict-"),
1783
+ );
1784
+ // Mirror's journal entry survives — reconcile command (separate skill)
1785
+ // sweeps it once the user explicitly opts in.
1786
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1787
+ expect(
1788
+ journal.files["CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md"],
1789
+ ).toBeDefined();
1790
+ });
1791
+
1350
1792
  // ── personalMode ───────────────────────────────────────────────────────────
1351
1793
  //
1352
1794
  // The personal vault (slug "personal" in the runner's fanout plan) shares
@@ -1748,3 +2190,140 @@ describe("share", () => {
1748
2190
  });
1749
2191
  });
1750
2192
  });
2193
+
2194
+ // ── Pure-function unit coverage: isEphemeralPath ─────────────────────────────
2195
+ //
2196
+ // EPHEMERAL_PATH_PATTERN is the single source of truth for "this is a conflict
2197
+ // mirror and must never round-trip to S3." Integration tests already cover the
2198
+ // behavior end-to-end (uploadFile is or isn't called), but a direct regex
2199
+ // contract test makes intent unambiguous and catches future drift in the
2200
+ // pattern — much cheaper than reproducing each case through share().
2201
+
2202
+ describe("isEphemeralPath (conflict-mirror pattern contract)", () => {
2203
+ const { isEphemeralPath } = shareTesting;
2204
+
2205
+ it.each([
2206
+ // Canonical: basename and relativeKey both match (the two callsites).
2207
+ [".claude/CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md", true],
2208
+ ["CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md", true],
2209
+ [".claude/commands/adr.md.conflict-2026-05-13T19-40-41Z-e5797a.md", true],
2210
+ // Longer machine hash (no upper bound on `[a-f0-9]+`).
2211
+ ["foo.conflict-2026-05-19T17-05-56Z-deadbeef.md", true],
2212
+ // Non-markdown extensions also valid (sh scripts, ts files, etc.).
2213
+ ["foo.sh.conflict-2026-05-19T17-05-56Z-abc123.sh", true],
2214
+ ])("matches conflict mirror: %s", (p, expected) => {
2215
+ expect(isEphemeralPath(p)).toBe(expected);
2216
+ });
2217
+
2218
+ it.each([
2219
+ // Normal files: regular markdown, scripts, etc.
2220
+ ["README.md", false],
2221
+ [".claude/CLAUDE.md", false],
2222
+ ["companies/acme/knowledge/note.md", false],
2223
+ // Strings containing the word "conflict" but not the timestamp+hash token.
2224
+ ["conflict-resolution.md", false],
2225
+ ["my-conflict.md", false],
2226
+ ["foo.conflict-handler.md", false],
2227
+ // Date-shaped but missing the trailing dot + extension (real conflicts
2228
+ // always carry a file extension; the trailing `\.` in the pattern is the
2229
+ // safety against bare-substring false positives).
2230
+ ["foo.conflict-2026-05-13T19-40-40Z-abc", false],
2231
+ // Wrong-case or non-hex machine hash.
2232
+ ["foo.conflict-2026-05-13T19-40-40Z-ZZZZZZ.md", false],
2233
+ // Wrong timestamp format (real conflicts use UTC ISO with Z suffix).
2234
+ ["foo.conflict-2026-05-13-abc123.md", false],
2235
+ // Missing leading dot before "conflict" (this protects against legitimate
2236
+ // files that happen to contain the word "conflict" mid-name).
2237
+ ["fooconflict-2026-05-13T19-40-40Z-abc.md", false],
2238
+ ])("rejects non-mirror: %s", (p, expected) => {
2239
+ expect(isEphemeralPath(p)).toBe(expected);
2240
+ });
2241
+ });
2242
+
2243
+ // ── Currency-gated coverage against journal version "2" fixtures ─────────────
2244
+ //
2245
+ // Production journals on disk are at `version: "2"` (verified via jq on the
2246
+ // real-world ~/.hq/sync-journal.personal.json). The currency-gated tests above
2247
+ // use v1 fixtures by historical convention — this block pins the behavior at
2248
+ // v2 explicitly so a future schema change can't silently strand the new policy
2249
+ // on a stale fixture format. The bucket logic ignores `version` (only reads
2250
+ // `journal.files[*]`), so this should pass identically — but we want the
2251
+ // regression test on record.
2252
+
2253
+ describe("currency-gated: journal version 2 fixtures", () => {
2254
+ let tmpDir: string;
2255
+ let stateDir: string;
2256
+ let origDataHome: string | undefined;
2257
+
2258
+ beforeEach(() => {
2259
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-share-test-v2-"));
2260
+ stateDir = path.join(tmpDir, ".hq");
2261
+ fs.mkdirSync(stateDir, { recursive: true });
2262
+ origDataHome = process.env.XDG_DATA_HOME;
2263
+ process.env.XDG_DATA_HOME = stateDir;
2264
+ process.env.HQ_STATE_DIR = stateDir;
2265
+ clearContextCache();
2266
+ setupFetchMock();
2267
+ vi.mocked(uploadFile).mockClear();
2268
+ vi.mocked(uploadSymlink).mockClear();
2269
+ vi.mocked(deleteRemoteFile).mockClear();
2270
+ vi.mocked(headRemoteFile).mockReset();
2271
+ vi.mocked(headRemoteFile).mockResolvedValue(null);
2272
+ });
2273
+
2274
+ afterEach(() => {
2275
+ if (origDataHome === undefined) {
2276
+ delete process.env.XDG_DATA_HOME;
2277
+ } else {
2278
+ process.env.XDG_DATA_HOME = origDataHome;
2279
+ }
2280
+ delete process.env.HQ_STATE_DIR;
2281
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2282
+ });
2283
+
2284
+ it("propagates delete on etag match against a v2-shaped journal", async () => {
2285
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2286
+ fs.mkdirSync(companyRoot, { recursive: true });
2287
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
2288
+ // v2-shaped fixture — same field set the production journal uses.
2289
+ fs.writeFileSync(
2290
+ journalPath,
2291
+ JSON.stringify({
2292
+ version: "2",
2293
+ lastSync: new Date().toISOString(),
2294
+ files: {
2295
+ "core/policies/old.md": {
2296
+ hash: "h",
2297
+ size: 100,
2298
+ syncedAt: new Date().toISOString(),
2299
+ direction: "down",
2300
+ remoteEtag: "v2-etag",
2301
+ },
2302
+ },
2303
+ pulls: [],
2304
+ }),
2305
+ );
2306
+
2307
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
2308
+ lastModified: new Date(),
2309
+ etag: '"v2-etag"',
2310
+ size: 100,
2311
+ });
2312
+
2313
+ const result = await share({
2314
+ paths: [companyRoot],
2315
+ company: "acme",
2316
+ vaultConfig: mockConfig,
2317
+ hqRoot: tmpDir,
2318
+ skipUnchanged: true,
2319
+ propagateDeletes: true,
2320
+ propagateDeletePolicy: "currency-gated",
2321
+ });
2322
+
2323
+ expect(result.filesDeleted).toBe(1);
2324
+ expect(deleteRemoteFile).toHaveBeenCalledWith(
2325
+ expect.anything(),
2326
+ "core/policies/old.md",
2327
+ );
2328
+ });
2329
+ });