@indigoai-us/hq-cloud 5.31.0 → 5.33.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 (69) hide show
  1. package/dist/bin/sync-runner.d.ts.map +1 -1
  2. package/dist/bin/sync-runner.js +79 -20
  3. package/dist/bin/sync-runner.js.map +1 -1
  4. package/dist/bin/sync-runner.test.js +292 -0
  5. package/dist/bin/sync-runner.test.js.map +1 -1
  6. package/dist/cli/share.d.ts +26 -0
  7. package/dist/cli/share.d.ts.map +1 -1
  8. package/dist/cli/share.js +132 -11
  9. package/dist/cli/share.js.map +1 -1
  10. package/dist/cli/share.test.js +236 -6
  11. package/dist/cli/share.test.js.map +1 -1
  12. package/dist/cli/sync.d.ts +28 -2
  13. package/dist/cli/sync.d.ts.map +1 -1
  14. package/dist/cli/sync.js +26 -5
  15. package/dist/cli/sync.js.map +1 -1
  16. package/dist/cli/sync.test.js +82 -0
  17. package/dist/cli/sync.test.js.map +1 -1
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/lib/conflict-file.d.ts +7 -6
  23. package/dist/lib/conflict-file.d.ts.map +1 -1
  24. package/dist/lib/conflict-file.js +7 -27
  25. package/dist/lib/conflict-file.js.map +1 -1
  26. package/dist/lib/conflict.test.d.ts +4 -3
  27. package/dist/lib/conflict.test.d.ts.map +1 -1
  28. package/dist/lib/conflict.test.js +5 -33
  29. package/dist/lib/conflict.test.js.map +1 -1
  30. package/dist/lib/describe-error.d.ts +21 -0
  31. package/dist/lib/describe-error.d.ts.map +1 -0
  32. package/dist/lib/describe-error.js +53 -0
  33. package/dist/lib/describe-error.js.map +1 -0
  34. package/dist/lib/describe-error.test.d.ts +2 -0
  35. package/dist/lib/describe-error.test.d.ts.map +1 -0
  36. package/dist/lib/describe-error.test.js +89 -0
  37. package/dist/lib/describe-error.test.js.map +1 -0
  38. package/dist/lib/machine-id.d.ts +108 -0
  39. package/dist/lib/machine-id.d.ts.map +1 -0
  40. package/dist/lib/machine-id.js +170 -0
  41. package/dist/lib/machine-id.js.map +1 -0
  42. package/dist/lib/machine-id.test.d.ts +8 -0
  43. package/dist/lib/machine-id.test.d.ts.map +1 -0
  44. package/dist/lib/machine-id.test.js +195 -0
  45. package/dist/lib/machine-id.test.js.map +1 -0
  46. package/dist/personal-vault.d.ts +63 -7
  47. package/dist/personal-vault.d.ts.map +1 -1
  48. package/dist/personal-vault.js +112 -8
  49. package/dist/personal-vault.js.map +1 -1
  50. package/dist/personal-vault.test.d.ts +14 -0
  51. package/dist/personal-vault.test.d.ts.map +1 -0
  52. package/dist/personal-vault.test.js +191 -0
  53. package/dist/personal-vault.test.js.map +1 -0
  54. package/package.json +1 -1
  55. package/src/bin/sync-runner.test.ts +364 -0
  56. package/src/bin/sync-runner.ts +83 -18
  57. package/src/cli/share.test.ts +269 -6
  58. package/src/cli/share.ts +169 -11
  59. package/src/cli/sync.test.ts +91 -0
  60. package/src/cli/sync.ts +57 -5
  61. package/src/index.ts +3 -0
  62. package/src/lib/conflict-file.ts +7 -27
  63. package/src/lib/conflict.test.ts +4 -40
  64. package/src/lib/describe-error.test.ts +100 -0
  65. package/src/lib/describe-error.ts +58 -0
  66. package/src/lib/machine-id.test.ts +221 -0
  67. package/src/lib/machine-id.ts +175 -0
  68. package/src/personal-vault.test.ts +231 -0
  69. package/src/personal-vault.ts +134 -8
@@ -1799,6 +1799,249 @@ describe("share", () => {
1799
1799
  // exclusion list itself is enforced by the runner (sync-runner.ts) by only
1800
1800
  // passing in the allowed top-level directories — share() trusts its
1801
1801
  // `paths` input.
1802
+
1803
+ // ── Decommission prefixes ──────────────────────────────────────────────────
1804
+ //
1805
+ // `decommissionPrefixes` is the "company was promoted to its own team
1806
+ // bucket, sweep its orphaned keys from this bucket" escape hatch. Unlike
1807
+ // `propagateDeletes`, it does NOT require the local file to be missing —
1808
+ // the caller asserts these keys no longer belong here regardless of
1809
+ // local state. Honors the same `propagateDeletePolicy` safety property.
1810
+
1811
+ it("decommissionPrefixes: deletes journal entries under the prefix even when the local file is still present", async () => {
1812
+ // Personal-bucket journal recording a key under companies/foo/. The
1813
+ // local file is intentionally LEFT on disk to prove decommission
1814
+ // doesn't depend on local-absence — the team bucket is now the
1815
+ // canonical home but on-disk content is unchanged.
1816
+ fs.mkdirSync(path.join(tmpDir, "companies", "foo"), { recursive: true });
1817
+ fs.writeFileSync(path.join(tmpDir, "companies", "foo", "notes.md"), "still here");
1818
+ fs.mkdirSync(path.join(tmpDir, "knowledge"), { recursive: true });
1819
+ fs.writeFileSync(path.join(tmpDir, "knowledge", "x.md"), "knowledge");
1820
+
1821
+ const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
1822
+ fs.writeFileSync(
1823
+ personalJournalPath,
1824
+ JSON.stringify({
1825
+ version: "1",
1826
+ lastSync: new Date().toISOString(),
1827
+ files: {
1828
+ "companies/foo/notes.md": {
1829
+ hash: "h", size: 11, syncedAt: new Date().toISOString(),
1830
+ direction: "up",
1831
+ },
1832
+ // Sibling out of decommission scope; must survive untouched.
1833
+ "knowledge/x.md": {
1834
+ hash: "k", size: 9, syncedAt: new Date().toISOString(),
1835
+ direction: "up",
1836
+ },
1837
+ },
1838
+ }),
1839
+ );
1840
+
1841
+ const result = await share({
1842
+ paths: [path.join(tmpDir, "knowledge")],
1843
+ company: "acme",
1844
+ vaultConfig: mockConfig,
1845
+ hqRoot: tmpDir,
1846
+ personalMode: true,
1847
+ journalSlug: "personal",
1848
+ skipUnchanged: true,
1849
+ // Pin owned-only — this test asserts the direction-of-origin gate
1850
+ // applies symmetrically to decommission. Once the default flips to
1851
+ // `currency-gated` (scheduled for 5.25+), the implicit-default form
1852
+ // would force a HEAD call we don't want to mock here.
1853
+ propagateDeletePolicy: "owned-only",
1854
+ decommissionPrefixes: ["companies/foo"],
1855
+ });
1856
+
1857
+ expect(result.filesDeleted).toBe(1);
1858
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "companies/foo/notes.md");
1859
+ expect(deleteRemoteFile).not.toHaveBeenCalledWith(expect.anything(), "knowledge/x.md");
1860
+ // Local file remains on disk — decommission is a remote-only sweep.
1861
+ expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "notes.md"))).toBe(true);
1862
+ // Journal entry pruned in lockstep with the remote delete.
1863
+ const journalAfter = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
1864
+ expect(journalAfter.files["companies/foo/notes.md"]).toBeUndefined();
1865
+ expect(journalAfter.files["knowledge/x.md"]).toBeDefined();
1866
+ });
1867
+
1868
+ it("decommissionPrefixes: under owned-only policy, skips direction:'down' entries", async () => {
1869
+ // The orphan was pulled from another machine, never uploaded by this
1870
+ // one. owned-only refuses to remotely erase another machine's upload
1871
+ // even if the prefix list claims it. Mirrors propagateDeletes's
1872
+ // owned-only contract — see test "propagateDeletes: under owned-only".
1873
+ fs.mkdirSync(path.join(tmpDir, "companies", "foo"), { recursive: true });
1874
+ const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
1875
+ fs.writeFileSync(
1876
+ personalJournalPath,
1877
+ JSON.stringify({
1878
+ version: "1",
1879
+ lastSync: new Date().toISOString(),
1880
+ files: {
1881
+ "companies/foo/from-peer.md": {
1882
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
1883
+ direction: "down",
1884
+ },
1885
+ },
1886
+ }),
1887
+ );
1888
+
1889
+ const result = await share({
1890
+ paths: [],
1891
+ company: "acme",
1892
+ vaultConfig: mockConfig,
1893
+ hqRoot: tmpDir,
1894
+ personalMode: true,
1895
+ journalSlug: "personal",
1896
+ skipUnchanged: true,
1897
+ propagateDeletePolicy: "owned-only",
1898
+ decommissionPrefixes: ["companies/foo"],
1899
+ });
1900
+
1901
+ expect(result.filesDeleted).toBe(0);
1902
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
1903
+ // Journal entry preserved — the pulled-from-peer record stays so a
1904
+ // future pull can still reconcile it correctly.
1905
+ const journalAfter = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
1906
+ expect(journalAfter.files["companies/foo/from-peer.md"]).toBeDefined();
1907
+ });
1908
+
1909
+ it("decommissionPrefixes: dedupes with propagateDeletes plan — a key in both still emits one DeleteObject", async () => {
1910
+ // If the local file is ALSO missing under the prefix, the same key
1911
+ // would be eligible via both propagateDeletes (local gone) and
1912
+ // decommissionPrefixes (prefix match). The dedup guarantee is that
1913
+ // we delete once, not twice.
1914
+ fs.mkdirSync(path.join(tmpDir, "companies", "foo"), { recursive: true });
1915
+ // Note: NO file at companies/foo/notes.md — local-absence is what
1916
+ // makes propagateDeletes plan it; the prefix is what makes
1917
+ // decommission plan it. The Set-based dedup must collapse them.
1918
+ const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
1919
+ fs.writeFileSync(
1920
+ personalJournalPath,
1921
+ JSON.stringify({
1922
+ version: "1",
1923
+ lastSync: new Date().toISOString(),
1924
+ files: {
1925
+ "companies/foo/notes.md": {
1926
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
1927
+ direction: "up",
1928
+ },
1929
+ },
1930
+ }),
1931
+ );
1932
+
1933
+ const result = await share({
1934
+ paths: [path.join(tmpDir, "companies", "foo")],
1935
+ company: "acme",
1936
+ vaultConfig: mockConfig,
1937
+ hqRoot: tmpDir,
1938
+ personalMode: true,
1939
+ journalSlug: "personal",
1940
+ skipUnchanged: true,
1941
+ propagateDeletes: true,
1942
+ // Use "all" here so propagateDeletes plans the (already-missing)
1943
+ // key regardless of its direction — the dedup assertion is what
1944
+ // this test exists to pin.
1945
+ propagateDeletePolicy: "all",
1946
+ decommissionPrefixes: ["companies/foo"],
1947
+ });
1948
+
1949
+ expect(result.filesDeleted).toBe(1);
1950
+ expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
1951
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "companies/foo/notes.md");
1952
+ });
1953
+
1954
+ it("decommissionPrefixes: under currency-gated policy, skips direction:'down' entries (same as owned-only)", async () => {
1955
+ // currency-gated gates `propagateDeletes` on per-file HEAD currency
1956
+ // proof. Decommission is unconditional by design — no HEAD check
1957
+ // adds value — but we still want the direction-of-origin safety net
1958
+ // so a peer-written entry isn't blasted away on the peer's behalf.
1959
+ // Asserts `computeDecommissionPlan` collapses currency-gated to
1960
+ // owned-only semantics (the requireOwned branch).
1961
+ fs.mkdirSync(path.join(tmpDir, "companies", "foo"), { recursive: true });
1962
+ const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
1963
+ fs.writeFileSync(
1964
+ personalJournalPath,
1965
+ JSON.stringify({
1966
+ version: "1",
1967
+ lastSync: new Date().toISOString(),
1968
+ files: {
1969
+ "companies/foo/from-peer.md": {
1970
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
1971
+ direction: "down",
1972
+ remoteEtag: "abc",
1973
+ },
1974
+ },
1975
+ }),
1976
+ );
1977
+
1978
+ const result = await share({
1979
+ paths: [],
1980
+ company: "acme",
1981
+ vaultConfig: mockConfig,
1982
+ hqRoot: tmpDir,
1983
+ personalMode: true,
1984
+ journalSlug: "personal",
1985
+ skipUnchanged: true,
1986
+ propagateDeletePolicy: "currency-gated",
1987
+ decommissionPrefixes: ["companies/foo"],
1988
+ });
1989
+
1990
+ expect(result.filesDeleted).toBe(0);
1991
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
1992
+ });
1993
+
1994
+ it("decommissionPrefixes: yields to toTombstone (key already 404 on remote) — single 'deleted' event, no double-count", async () => {
1995
+ // Regression for an audit finding: a key in BOTH decommissionPlan
1996
+ // AND deletePlan.toTombstone (because the remote was already 404 at
1997
+ // HEAD time) used to double-emit a "deleted" event (one from the
1998
+ // DeleteObject loop, one from the tombstone loop) and increment
1999
+ // both filesDeleted and filesTombstoned. Fix: decommissionPlan
2000
+ // dedups against toTombstone — the tombstone loop handles it
2001
+ // (journal removal, no S3 call), decommission skips it.
2002
+ fs.mkdirSync(path.join(tmpDir, "companies", "foo"), { recursive: true });
2003
+ // No local file → propagateDeletes would normally plan it. With
2004
+ // headRemoteFile mocked to return null (the default in beforeEach),
2005
+ // currency-gated classifies as toTombstone (remote already 404).
2006
+ const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
2007
+ fs.writeFileSync(
2008
+ personalJournalPath,
2009
+ JSON.stringify({
2010
+ version: "1",
2011
+ lastSync: new Date().toISOString(),
2012
+ files: {
2013
+ "companies/foo/gone.md": {
2014
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
2015
+ direction: "up",
2016
+ remoteEtag: "abc",
2017
+ },
2018
+ },
2019
+ }),
2020
+ );
2021
+
2022
+ const result = await share({
2023
+ paths: [path.join(tmpDir, "companies", "foo")],
2024
+ company: "acme",
2025
+ vaultConfig: mockConfig,
2026
+ hqRoot: tmpDir,
2027
+ personalMode: true,
2028
+ journalSlug: "personal",
2029
+ skipUnchanged: true,
2030
+ propagateDeletes: true,
2031
+ propagateDeletePolicy: "currency-gated",
2032
+ decommissionPrefixes: ["companies/foo"],
2033
+ });
2034
+
2035
+ // No DeleteObject — the tombstone bucket handled it (remote is 404).
2036
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
2037
+ // Exactly one journal-removal counted (tombstone), not two.
2038
+ expect(result.filesTombstoned).toBe(1);
2039
+ expect(result.filesDeleted).toBe(0);
2040
+ // Journal entry is gone.
2041
+ const journalAfter = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
2042
+ expect(journalAfter.files["companies/foo/gone.md"]).toBeUndefined();
2043
+ });
2044
+
1802
2045
  describe("personalMode", () => {
1803
2046
  it("personalMode=true keys files hq-root-relative, not companies/{slug}/-relative", async () => {
1804
2047
  fs.mkdirSync(path.join(tmpDir, ".claude", "skills"), { recursive: true });
@@ -2211,6 +2454,23 @@ describe("isEphemeralPath (conflict-mirror pattern contract)", () => {
2211
2454
  ["foo.conflict-2026-05-19T17-05-56Z-deadbeef.md", true],
2212
2455
  // Non-markdown extensions also valid (sh scripts, ts files, etc.).
2213
2456
  ["foo.sh.conflict-2026-05-19T17-05-56Z-abc123.sh", true],
2457
+ // ── extensionless originals (regression: `path.extname('.gitignore')`
2458
+ // returns '' in Node, so `buildConflictPath` produces no trailing
2459
+ // `.<ext>` segment for hidden-but-extensionless files).
2460
+ [".gitignore.conflict-2026-05-23T19-51-38Z-4dff71", true],
2461
+ [".hqignore.conflict-2026-05-23T19-51-38Z-4dff71", true],
2462
+ [".agents/skills.conflict-2026-05-19T17-07-01Z-0a513b", true],
2463
+ // ── legacy "unknown" machine token (regression: hosts without
2464
+ // `~/.hq/menubar.json` pre-Fix-3 fell through to the literal string
2465
+ // `"unknown"`, which `[a-f0-9]+` refused. Producer side is closed in
2466
+ // `../lib/machine-id.ts`, but the regex still must filter the
2467
+ // already-on-disk legacy files so the next push removes them).
2468
+ [".gitignore.conflict-2026-05-15T15-10-35Z-unknown", true],
2469
+ [".agents/skills.conflict-2026-05-15T15-10-35Z-unknown", true],
2470
+ ["notes.md.conflict-2026-05-15T15-10-35Z-unknown.md", true],
2471
+ [".hq/install-manifest.json.conflict-2026-05-15T15-11-58Z-unknown.json", true],
2472
+ // Multi-dot extension (e.g., archive tarballs that conflicted).
2473
+ ["dump.conflict-2026-05-13T19-40-40Z-abc.tar.gz", true],
2214
2474
  ])("matches conflict mirror: %s", (p, expected) => {
2215
2475
  expect(isEphemeralPath(p)).toBe(expected);
2216
2476
  });
@@ -2224,17 +2484,20 @@ describe("isEphemeralPath (conflict-mirror pattern contract)", () => {
2224
2484
  ["conflict-resolution.md", false],
2225
2485
  ["my-conflict.md", false],
2226
2486
  ["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.
2487
+ // Wrong-case or non-hex/non-"unknown" machine hash.
2232
2488
  ["foo.conflict-2026-05-13T19-40-40Z-ZZZZZZ.md", false],
2233
2489
  // Wrong timestamp format (real conflicts use UTC ISO with Z suffix).
2234
2490
  ["foo.conflict-2026-05-13-abc123.md", false],
2235
- // Missing leading dot before "conflict" (this protects against legitimate
2491
+ // Missing leading dot before "conflict" (protects against legitimate
2236
2492
  // files that happen to contain the word "conflict" mid-name).
2237
2493
  ["fooconflict-2026-05-13T19-40-40Z-abc.md", false],
2494
+ // Extra trailing segments after the machine hash — the `$` anchor +
2495
+ // `[^/]*` ext class ensure a conflict marker can't appear mid-path.
2496
+ ["foo.conflict-2026-05-13T19-40-40Z-abc/extra/path", false],
2497
+ ["foo.conflict-2026-05-13T19-40-40Z-abc-then-more-text", false],
2498
+ // Bare "unknown"-like tokens that aren't the literal sentinel.
2499
+ ["foo.conflict-2026-05-13T19-40-40Z-unknowing.md", false],
2500
+ ["foo.conflict-2026-05-13T19-40-40Z-UNKNOWN.md", false],
2238
2501
  ])("rejects non-mirror: %s", (p, expected) => {
2239
2502
  expect(isEphemeralPath(p)).toBe(expected);
2240
2503
  });
package/src/cli/share.ts CHANGED
@@ -32,8 +32,11 @@ import type { SyncProgressEvent } from "./sync.js";
32
32
  /**
33
33
  * Local-only ephemeral artifacts: conflict-mirror files written by the pull
34
34
  * leg whenever a 3-way merge keeps local AND wants to preserve the remote
35
- * version for inspection. Format: `<orig>.conflict-<ISO-utc>-<machineHash>.<ext>`
36
- * (e.g. `.claude/CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md`).
35
+ * version for inspection. Format: `<orig>.conflict-<ISO-utc>-<machineHash>[.ext]`
36
+ * (e.g. `.claude/CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md`,
37
+ * or `.gitignore.conflict-2026-05-13T19-40-40Z-e5797a` — extensionless
38
+ * originals produce no trailing dot, see `buildConflictPath` in
39
+ * `../lib/conflict-file.ts`).
37
40
  *
38
41
  * These files MUST never round-trip to S3 — they're local-only safety backups
39
42
  * the user reviews and deletes once the merge is resolved. Pre-fix, the push
@@ -42,13 +45,34 @@ import type { SyncProgressEvent } from "./sync.js";
42
45
  * deleted them locally (because pull-confirmation had stamped them as
43
46
  * `direction: "down"`). Net effect: a permanent litter ratchet on remote.
44
47
  *
48
+ * Two known producer-shapes the regex must accommodate (both observed on
49
+ * affected user trees prior to this fix):
50
+ *
51
+ * 1. **`unknown` machine token.** Pre-`<hqRoot>/.hq/machine-id`
52
+ * provisioning (see `../lib/machine-id.ts`), hosts without
53
+ * `~/.hq/menubar.json` — every Linux HQ Pro Outpost, every fresh CLI
54
+ * install — fell through to the literal string `"unknown"` from the
55
+ * old `readShortMachineId()` fallback. The letters `k`, `n`, `o`, `w`
56
+ * live outside `[a-f]`, so the pre-fix `[a-f0-9]+` class refused those
57
+ * filenames. They round-tripped to S3 as ordinary files (which IS the
58
+ * "permanent litter ratchet" this module's contract was supposed to
59
+ * prevent). The new machine-id provisioning closes the producer side,
60
+ * but we still accept `unknown` here so legacy files already on disk
61
+ * are filtered out by the next push.
62
+ *
63
+ * 2. **Extensionless originals.** `path.extname('.gitignore')` returns
64
+ * `''` in Node, so `buildConflictPath` produces no trailing `.<ext>`
65
+ * segment for hidden-but-extensionless files like `.gitignore`,
66
+ * `.hqignore`, or any `.agents/skills`-style entry. The pre-fix `\.`
67
+ * tail was mandatory, so those names slipped through.
68
+ *
45
69
  * Wire-points: (1) push walker — `collectFiles` / `walkDir` skip these so
46
70
  * they never upload; (2) `computeDeletePlan` — skip these so an already-
47
71
  * journaled mirror that's been deleted locally doesn't get included in the
48
72
  * regular delete plan (the dedicated reconcile path handles existing litter).
49
73
  */
50
74
  const EPHEMERAL_PATH_PATTERN =
51
- /\.conflict-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z-[a-f0-9]+\./;
75
+ /\.conflict-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z-(?:[a-f0-9]+|unknown)(?:\.[^/]*)?$/;
52
76
 
53
77
  /**
54
78
  * Cheap pure check — pass the relative key OR a basename; either works. Used
@@ -331,6 +355,32 @@ export interface ShareOptions {
331
355
  * next push to erase it.
332
356
  */
333
357
  propagateDeletePolicy?: "currency-gated" | "owned-only" | "all";
358
+ /**
359
+ * Hq-root-relative key prefixes whose journal entries should be
360
+ * unconditionally decommissioned from the remote bucket and journal,
361
+ * independent of whether the local file is present. Each prefix matches
362
+ * its exact-key form (e.g. "companies/foo") AND any descendant key
363
+ * (e.g. "companies/foo/knowledge/notes.md") — same prefix semantics as
364
+ * `propagateDeletes`'s scope roots.
365
+ *
366
+ * Use case: a company that previously synced to the operator's personal
367
+ * bucket has been promoted to its own team bucket (`/designate-team`).
368
+ * Its keys at `companies/{slug}/...` in the personal bucket are now
369
+ * orphans — the on-disk files still exist (the team bucket is the new
370
+ * canonical home), so the standard `propagateDeletes` gate ("local file
371
+ * missing") never fires. This option asserts "these objects no longer
372
+ * belong in THIS bucket regardless of local state" and uses the same
373
+ * DeleteObject + journal-removal path as `propagateDeletes`.
374
+ *
375
+ * Honors `propagateDeletePolicy` — `"owned-only"` (default) only
376
+ * decommissions journal entries with `direction === "up"`, so a
377
+ * misconfigured caller never erases content pulled from elsewhere.
378
+ *
379
+ * Independent of `propagateDeletes`: callers can opt into decommission
380
+ * without enabling general delete propagation. In practice the runner
381
+ * sets both for the personal slot.
382
+ */
383
+ decommissionPrefixes?: string[];
334
384
  /**
335
385
  * Identity stamped onto each uploaded object's S3 user metadata
336
386
  * (`created-by`, `created-by-sub`, `created-at`). The hq-console vault UI
@@ -548,6 +598,43 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
548
598
  )
549
599
  : { toDelete: [], toTombstone: [], refusedStale: [] };
550
600
 
601
+ // Decommission plan: journal entries under explicit prefixes the caller
602
+ // has asserted no longer belong in this bucket (typically a promoted
603
+ // company's `companies/{slug}/` keys in the personal bucket). Independent
604
+ // of `propagateDeletes` and DOES NOT require the local file to be missing
605
+ // — the caller is making a stronger claim than "local says delete this".
606
+ // Honors the same owned-only safety policy so a misconfigured caller
607
+ // can never erase content the journal records as pulled from elsewhere.
608
+ // Dedupes against `deletePlan` so a key in both plans is only processed
609
+ // once (DeleteObject is idempotent on S3 but the journal-write would
610
+ // race a no-op pass through the loop body).
611
+ const decommissionPlan =
612
+ (options.decommissionPrefixes ?? []).length > 0
613
+ ? computeDecommissionPlan(
614
+ journal,
615
+ options.decommissionPrefixes ?? [],
616
+ propagateDeletePolicy,
617
+ // Dedup against `toDelete` (decommission and propagate-delete
618
+ // would both issue DeleteObject — single call wins) and against
619
+ // `toTombstone` (the remote is already 404; the tombstone loop
620
+ // drops the journal entry without a network call — decommission
621
+ // yields, both for efficiency and to avoid emitting two
622
+ // "deleted" events for the same key).
623
+ //
624
+ // We do NOT dedup against `refusedStale`. A key whose remote
625
+ // ETag drifted (peer wrote a newer version) but which decommission
626
+ // claims should still be removed — the caller has asserted this
627
+ // key doesn't belong in this bucket regardless of peer activity.
628
+ // Under owned-only (default) `computeDecommissionPlan`'s
629
+ // direction:'up' filter already excludes peer-written entries;
630
+ // under policy:'all' the caller has opted out of that safety
631
+ // anyway. The refusedStale loop below filters out keys we're
632
+ // about to decommission to avoid emitting a spurious "kept on
633
+ // remote" event for content we're deleting.
634
+ new Set([...deletePlan.toDelete, ...deletePlan.toTombstone]),
635
+ )
636
+ : [];
637
+
551
638
  emit({
552
639
  type: "plan",
553
640
  // share() is push-only; pull counts are sourced from sync()'s plan event.
@@ -562,8 +649,11 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
562
649
  // Reported count is the deletes we're actually going to issue — does NOT
563
650
  // include tombstones (no S3 call) or refused-stale (no journal change).
564
651
  // Refusals surface as their own event stream so consumers that care can
565
- // render a "kept on remote: N" line separately.
566
- filesToDelete: deletePlan.toDelete.length,
652
+ // render a "kept on remote: N" line separately. `decommissionPlan` adds
653
+ // to this count because every decommission entry IS an issued
654
+ // DeleteObject (different intent than propagate-deletes, same network
655
+ // effect).
656
+ filesToDelete: deletePlan.toDelete.length + decommissionPlan.length,
567
657
  });
568
658
 
569
659
  // Stage 2: execute. Skip items pre-classified as no-ops, then for each
@@ -703,11 +793,16 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
703
793
 
704
794
  // Stage 3: propagate deletes. Three buckets, three actions:
705
795
  //
706
- // 1. `toDelete` write a delete-marker (versioning is enabled on the
707
- // bucket so the delete is soft and prior versions remain recoverable)
708
- // and remove the journal entry so the next sync sees the key as
709
- // truly gone on this machine. A failed DeleteObject leaves both
710
- // the journal entry and remote object intact the next run retries.
796
+ // 1. `toDelete` (PLUS `decommissionPlan` concatenated in) write a
797
+ // delete-marker (versioning is enabled on the bucket so the delete
798
+ // is soft and prior versions remain recoverable) and remove the
799
+ // journal entry so the next sync sees the key as truly gone on
800
+ // this machine. A failed DeleteObject leaves both the journal
801
+ // entry and remote object intact — the next run retries.
802
+ // Decommission keys join this bucket because their effect is
803
+ // identical (DeleteObject + journal removal); the difference is
804
+ // intent only, and the dedupe at plan-computation time ensures we
805
+ // don't double-issue for a key both plans matched.
711
806
  //
712
807
  // 2. `toTombstone` — the remote was 404 at HEAD time (cleaned up out
713
808
  // of band, e.g. someone hand-deleted via console). No DeleteObject
@@ -722,7 +817,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
722
817
  // leg of `sync now` re-pulls naturally via the existing
723
818
  // `hasRemoteChanged` path. Emit a dedicated event so UIs can
724
819
  // surface the refusal without inferring it from absence.
725
- for (const relativePath of deletePlan.toDelete) {
820
+ for (const relativePath of [...deletePlan.toDelete, ...decommissionPlan]) {
726
821
  if (vaultConfig && isExpiringSoon(ctx.expiresAt)) {
727
822
  ctx = await refreshEntityContext(companyRef, vaultConfig);
728
823
  }
@@ -757,7 +852,15 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
757
852
  message: "tombstone (remote already 404)",
758
853
  });
759
854
  }
855
+ // Decommission overrides refusedStale: a key whose peer drifted but
856
+ // which the caller has claimed via `decommissionPrefixes` is processed
857
+ // by the DeleteObject loop above; skip the refusal event here so the
858
+ // event stream doesn't simultaneously claim "we deleted it" and "we
859
+ // kept it on remote" for the same key.
860
+ const decommissionedSet =
861
+ decommissionPlan.length > 0 ? new Set(decommissionPlan) : null;
760
862
  for (const refused of deletePlan.refusedStale) {
863
+ if (decommissionedSet && decommissionedSet.has(refused.key)) continue;
761
864
  filesRefusedStale++;
762
865
  emit({
763
866
  type: "delete-refused-stale-etag",
@@ -1359,3 +1462,58 @@ async function computeDeletePlan(
1359
1462
 
1360
1463
  return plan;
1361
1464
  }
1465
+
1466
+ /**
1467
+ * Walk every journal key that matches one of the supplied `prefixes` and
1468
+ * return the keys eligible for unconditional remote `DeleteObject`. Unlike
1469
+ * `computeDeletePlan` this DOES NOT check whether the local file is missing
1470
+ * — the caller has asserted that these keys no longer belong in this
1471
+ * bucket regardless of local state (typically a company that was promoted
1472
+ * from personal-bucket fallback to its own team bucket).
1473
+ *
1474
+ * An entry is in the plan only when ALL of the following hold:
1475
+ *
1476
+ * 1. Its key matches (or sits beneath) one of the `prefixes`. The match
1477
+ * is exact OR `key.startsWith(prefix + "/")` — same semantics as
1478
+ * `computeDeletePlan`'s scope-root check.
1479
+ * 2. When `policy === "owned-only"`: the journal entry's `direction`
1480
+ * is `"up"` (this machine previously uploaded the file). Mirrors
1481
+ * `computeDeletePlan`'s safety property — a misconfigured prefix
1482
+ * list can never erase content pulled from elsewhere.
1483
+ * 3. The key is NOT already in `alreadyPlanned` (the standard delete
1484
+ * plan), so a single DeleteObject + journal-update pass processes
1485
+ * each key once.
1486
+ *
1487
+ * Empty `prefixes` ⇒ empty plan (caller didn't opt in).
1488
+ */
1489
+ function computeDecommissionPlan(
1490
+ journal: SyncJournal,
1491
+ prefixes: string[],
1492
+ policy: "currency-gated" | "owned-only" | "all",
1493
+ alreadyPlanned: ReadonlySet<string>,
1494
+ ): string[] {
1495
+ if (prefixes.length === 0) return [];
1496
+ // `currency-gated` exists to gate `propagateDeletes` on per-file proof of
1497
+ // currency (HEAD compare). Decommission is unconditional by design — we
1498
+ // don't need a HEAD check to know whether the key belongs here, the
1499
+ // caller has asserted it doesn't. So for the direction-of-origin safety
1500
+ // gate we collapse `currency-gated` to the `owned-only` behavior:
1501
+ // peer-written entries (direction:'down') are skipped. That's the
1502
+ // conservative choice — a peer who wrote into this bucket should
1503
+ // reconcile their own entry, not have us blast it away on their behalf.
1504
+ const requireOwned = policy === "owned-only" || policy === "currency-gated";
1505
+ const out: string[] = [];
1506
+ for (const [relativeKey, entry] of Object.entries(journal.files)) {
1507
+ if (alreadyPlanned.has(relativeKey)) continue;
1508
+ const matches = prefixes.some(
1509
+ (prefix) =>
1510
+ prefix === "" ||
1511
+ relativeKey === prefix ||
1512
+ relativeKey.startsWith(`${prefix}/`),
1513
+ );
1514
+ if (!matches) continue;
1515
+ if (requireOwned && entry.direction !== "up") continue;
1516
+ out.push(relativeKey);
1517
+ }
1518
+ return out;
1519
+ }
@@ -326,6 +326,97 @@ describe("sync", () => {
326
326
  expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "readme.md"))).toBe(false);
327
327
  });
328
328
 
329
+ it("personalMode + includeLocalCompanies: downloads companies/{cloud-false-slug}/... keys when slug NOT in teamSyncedSlugs", async () => {
330
+ // The symmetric flip for the cloud:false → personal-bucket fallback.
331
+ // Machine A pushed `companies/free-co/notes.md` to the personal bucket
332
+ // (because the operator set `HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1`
333
+ // AND `company.yaml` declares `cloud: false`). Machine B subscribes:
334
+ // pull must allow these keys through the personalMode filter, otherwise
335
+ // the feature is push-only and the destination machine never sees them.
336
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
337
+ { key: "companies/free-co/notes.md", size: 50, lastModified: new Date(), etag: '"abc"' },
338
+ { key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"def"' },
339
+ ]);
340
+
341
+ const result = await sync({
342
+ company: "acme",
343
+ vaultConfig: mockConfig,
344
+ hqRoot: tmpDir,
345
+ personalMode: true,
346
+ includeLocalCompanies: true,
347
+ teamSyncedSlugs: new Set(), // empty → no slug is "orphan"
348
+ });
349
+
350
+ expect(result.filesDownloaded).toBe(2);
351
+ expect(result.filesSkipped).toBe(0);
352
+ expect(fs.existsSync(path.join(tmpDir, "companies", "free-co", "notes.md"))).toBe(true);
353
+ expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
354
+ });
355
+
356
+ it("personalMode + includeLocalCompanies: drops companies/{team-synced-slug}/... keys as orphans (symmetric to push-side decommission)", async () => {
357
+ // The orphan-cleanup half. Once a company has been promoted from
358
+ // cloud:false fallback to its own team bucket, the operator gains
359
+ // an active Membership for it (slug enters teamSyncedSlugs). Any
360
+ // leftover `companies/{that-slug}/...` keys in the personal bucket
361
+ // are stale residue — downloading them would clash with the team-
362
+ // bucket pull at the same disk path. Filter drops them silently.
363
+ // Push-side `decommissionPrefixes` eventually removes them from the
364
+ // bucket; this filter keeps the local tree clean in the meantime
365
+ // (especially important for pull-only mode where decommission never
366
+ // runs).
367
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
368
+ // Orphan: this slug is now team-synced — drop the key.
369
+ { key: "companies/acme/file.md", size: 50, lastModified: new Date(), etag: '"old"' },
370
+ // Legitimate cloud:false content: download.
371
+ { key: "companies/free-co/notes.md", size: 30, lastModified: new Date(), etag: '"new"' },
372
+ ]);
373
+
374
+ const result = await sync({
375
+ company: "acme",
376
+ vaultConfig: mockConfig,
377
+ hqRoot: tmpDir,
378
+ personalMode: true,
379
+ includeLocalCompanies: true,
380
+ teamSyncedSlugs: new Set(["acme"]),
381
+ });
382
+
383
+ expect(result.filesDownloaded).toBe(1);
384
+ expect(result.filesSkipped).toBe(1);
385
+ // acme orphan must NOT land — it would clash with the team-bucket pull.
386
+ expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "file.md"))).toBe(false);
387
+ // free-co legitimate content lands at the hq-root-relative path.
388
+ expect(fs.existsSync(path.join(tmpDir, "companies", "free-co", "notes.md"))).toBe(true);
389
+ });
390
+
391
+ it("personalMode WITHOUT includeLocalCompanies: legacy behavior preserved — ALL companies/... keys dropped", async () => {
392
+ // Regression for the legacy contract. Pre-5.20 (and any operator who
393
+ // hasn't opted into HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1), the
394
+ // personal bucket should never contain `companies/...` keys. If they
395
+ // do exist (stale data, manual S3 console intervention, an old bug),
396
+ // the pull-side filter drops them as a safety net. This test pins
397
+ // that contract under the new option shape: includeLocalCompanies
398
+ // omitted → behaves identically to the original filter, even if
399
+ // teamSyncedSlugs is supplied.
400
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
401
+ { key: "companies/anything/file.md", size: 50, lastModified: new Date(), etag: '"x"' },
402
+ { key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"y"' },
403
+ ]);
404
+
405
+ const result = await sync({
406
+ company: "acme",
407
+ vaultConfig: mockConfig,
408
+ hqRoot: tmpDir,
409
+ personalMode: true,
410
+ // includeLocalCompanies omitted → defaults to false
411
+ teamSyncedSlugs: new Set(["unrelated"]),
412
+ });
413
+
414
+ expect(result.filesDownloaded).toBe(1);
415
+ expect(result.filesSkipped).toBe(1);
416
+ expect(fs.existsSync(path.join(tmpDir, "companies", "anything", "file.md"))).toBe(false);
417
+ expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
418
+ });
419
+
329
420
  it("overwrites local on --on-conflict overwrite", async () => {
330
421
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
331
422
  fs.mkdirSync(companyDocs, { recursive: true });