@indigoai-us/hq-cloud 6.11.19 → 6.12.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 (62) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +9 -11
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner.d.ts.map +1 -1
  6. package/dist/bin/sync-runner.js.map +1 -1
  7. package/dist/bin/sync-runner.test.js +35 -64
  8. package/dist/bin/sync-runner.test.js.map +1 -1
  9. package/dist/cli/rescue-core.js +1 -1
  10. package/dist/cli/rescue-core.js.map +1 -1
  11. package/dist/cli/share.js +25 -19
  12. package/dist/cli/share.js.map +1 -1
  13. package/dist/cli/share.test.js +63 -9
  14. package/dist/cli/share.test.js.map +1 -1
  15. package/dist/cli/sync.d.ts +11 -25
  16. package/dist/cli/sync.d.ts.map +1 -1
  17. package/dist/cli/sync.js +16 -17
  18. package/dist/cli/sync.js.map +1 -1
  19. package/dist/cli/sync.test.js +40 -42
  20. package/dist/cli/sync.test.js.map +1 -1
  21. package/dist/company-resolver.test.js.map +1 -1
  22. package/dist/lib/conflict-file.js +1 -1
  23. package/dist/lib/conflict-file.js.map +1 -1
  24. package/dist/personal-vault.d.ts +23 -24
  25. package/dist/personal-vault.d.ts.map +1 -1
  26. package/dist/personal-vault.js +38 -30
  27. package/dist/personal-vault.js.map +1 -1
  28. package/dist/personal-vault.test.d.ts +9 -6
  29. package/dist/personal-vault.test.d.ts.map +1 -1
  30. package/dist/personal-vault.test.js +90 -79
  31. package/dist/personal-vault.test.js.map +1 -1
  32. package/dist/prefix-coalesce.d.ts.map +1 -1
  33. package/dist/prefix-coalesce.js +0 -3
  34. package/dist/prefix-coalesce.js.map +1 -1
  35. package/dist/s3.test.js +0 -1
  36. package/dist/s3.test.js.map +1 -1
  37. package/dist/sync/push-receiver.test.js.map +1 -1
  38. package/dist/vault-client.d.ts +1 -1
  39. package/dist/vault-client.d.ts.map +1 -1
  40. package/dist/vault-client.js +1 -1
  41. package/dist/vault-client.js.map +1 -1
  42. package/dist/vault-client.test.js +15 -0
  43. package/dist/vault-client.test.js.map +1 -1
  44. package/eslint.config.js +67 -0
  45. package/package.json +5 -1
  46. package/src/bin/sync-runner-company.ts +9 -12
  47. package/src/bin/sync-runner.test.ts +35 -74
  48. package/src/bin/sync-runner.ts +0 -1
  49. package/src/cli/rescue-core.ts +1 -1
  50. package/src/cli/share.test.ts +79 -11
  51. package/src/cli/share.ts +24 -20
  52. package/src/cli/sync.test.ts +40 -42
  53. package/src/cli/sync.ts +25 -42
  54. package/src/company-resolver.test.ts +1 -1
  55. package/src/lib/conflict-file.ts +1 -1
  56. package/src/personal-vault.test.ts +91 -83
  57. package/src/personal-vault.ts +45 -41
  58. package/src/prefix-coalesce.ts +0 -4
  59. package/src/s3.test.ts +0 -2
  60. package/src/sync/push-receiver.test.ts +1 -1
  61. package/src/vault-client.test.ts +20 -0
  62. package/src/vault-client.ts +2 -2
@@ -993,7 +993,7 @@ describe("share", () => {
993
993
  expect(putKeys).not.toContain("projects/.gitkeep");
994
994
  });
995
995
 
996
- it("scoped push defense-in-depth: a 403 on HEAD skips that key (named error) instead of aborting the company", async () => {
996
+ it("scoped push defense-in-depth: a 403 on HEAD skips that key as scope-excluded without an error event", async () => {
997
997
  // Belt-and-suspenders: even if a key slips past the prefix filter (a grant
998
998
  // that changed mid-run, a pin outside the grant, prefix-coalesce
999
999
  // imprecision), the server's correct 403 on the HEAD must NOT abort the
@@ -1017,7 +1017,7 @@ describe("share", () => {
1017
1017
  return null;
1018
1018
  });
1019
1019
 
1020
- const events: Array<{ type?: string; path?: string; message?: string }> = [];
1020
+ const events: Array<{ type?: string; path?: string; message?: string; count?: number; samplePaths?: string[] }> = [];
1021
1021
  const result = await share({
1022
1022
  paths: [okFile, blockedFile],
1023
1023
  company: "acme",
@@ -1031,21 +1031,85 @@ describe("share", () => {
1031
1031
  expect(result.filesUploaded).toBe(1);
1032
1032
  const putKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
1033
1033
  expect(putKeys).toEqual(["in-scope.md"]);
1034
- // The blocked key surfaced as a path-named, scope-clear error event.
1034
+ expect(result.filesExcludedByScope).toBe(1);
1035
+ const scopeEv = events.find((e) => e.type === "scope-excluded") as
1036
+ | { count: number; samplePaths: string[] }
1037
+ | undefined;
1038
+ expect(scopeEv).toBeDefined();
1039
+ expect(scopeEv!.count).toBe(1);
1040
+ expect(scopeEv!.samplePaths).toEqual(["out-of-reach.md"]);
1041
+ expect(events.some((e) => e.type === "error")).toBe(false);
1042
+ });
1043
+
1044
+ it("scoped push defense-in-depth: a 403 on PUT skips that key as scope-excluded without an error event", async () => {
1045
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1046
+ fs.mkdirSync(companyRoot, { recursive: true });
1047
+ const blockedFile = path.join(companyRoot, "put-denied.md");
1048
+ fs.writeFileSync(blockedFile, "denied\n");
1049
+
1050
+ vi.mocked(uploadFile).mockImplementation(async (_ctx, _abs, key) => {
1051
+ if (key === "put-denied.md") {
1052
+ const err = new Error("access denied");
1053
+ (err as { name: string }).name = "AccessDenied";
1054
+ throw err;
1055
+ }
1056
+ return { etag: '"upload-etag"' };
1057
+ });
1058
+
1059
+ const events: Array<{ type?: string; path?: string; message?: string; count?: number; samplePaths?: string[] }> = [];
1060
+ const result = await share({
1061
+ paths: [blockedFile],
1062
+ company: "acme",
1063
+ vaultConfig: mockConfig,
1064
+ hqRoot: tmpDir,
1065
+ onEvent: (e) => events.push(e as { type?: string }),
1066
+ });
1067
+
1068
+ expect(result.aborted).toBe(false);
1069
+ expect(result.filesUploaded).toBe(0);
1070
+ expect(result.filesExcludedByScope).toBe(1);
1071
+ const scopeEv = events.find((e) => e.type === "scope-excluded") as
1072
+ | { count: number; samplePaths: string[] }
1073
+ | undefined;
1074
+ expect(scopeEv).toBeDefined();
1075
+ expect(scopeEv!.count).toBe(1);
1076
+ expect(scopeEv!.samplePaths).toEqual(["put-denied.md"]);
1077
+ expect(events.some((e) => e.type === "error")).toBe(false);
1078
+ });
1079
+
1080
+ it("scoped push defense-in-depth: a non-access-denied PUT failure still emits an error event", async () => {
1081
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1082
+ fs.mkdirSync(companyRoot, { recursive: true });
1083
+ const failedFile = path.join(companyRoot, "put-failed.md");
1084
+ fs.writeFileSync(failedFile, "failed\n");
1085
+
1086
+ vi.mocked(uploadFile).mockRejectedValueOnce(new Error("S3 down"));
1087
+
1088
+ const events: Array<{ type?: string; path?: string; message?: string }> = [];
1089
+ const result = await share({
1090
+ paths: [failedFile],
1091
+ company: "acme",
1092
+ vaultConfig: mockConfig,
1093
+ hqRoot: tmpDir,
1094
+ onEvent: (e) => events.push(e as { type?: string }),
1095
+ });
1096
+
1097
+ expect(result.filesUploaded).toBe(0);
1098
+ expect(result.filesExcludedByScope).toBe(0);
1035
1099
  const errs = events.filter((e) => e.type === "error") as Array<{ path?: string; message?: string }>;
1036
1100
  expect(errs).toHaveLength(1);
1037
- expect(errs[0].path).toBe("out-of-reach.md");
1038
- expect(errs[0].message).toMatch(/outside granted ACL scope/i);
1101
+ expect(errs[0].path).toBe("put-failed.md");
1102
+ expect(errs[0].message).toBe("S3 down");
1039
1103
  });
1040
1104
 
1041
- it("a Forbidden HEAD (presigned-transport denial) skips the key — NEVER an unconditional PUT", async () => {
1105
+ it("a Forbidden HEAD (presigned-transport denial) skips the key as scope-excluded — NEVER an unconditional PUT", async () => {
1042
1106
  // Regression for the 2026-06-10..12 vault regression storm: the presigned
1043
1107
  // transport used to map per-key denials and signed-GET 403s to `null`,
1044
1108
  // which `processUploadItem` read as "no remote object" — skipping every
1045
1109
  // conflict guard and blind-PUTting this machine's (possibly stale) bytes
1046
1110
  // over a newer remote. The transport now throws `name: "Forbidden"`
1047
1111
  // (SDK HeadObject parity); this locks the share side of that contract:
1048
- // a Forbidden HEAD must skip the key, emit a named error, and issue NO PUT.
1112
+ // a Forbidden HEAD must skip the key as scope-excluded and issue NO PUT.
1049
1113
  const companyRoot = path.join(tmpDir, "companies", "acme");
1050
1114
  fs.mkdirSync(companyRoot, { recursive: true });
1051
1115
  const staleFile = path.join(companyRoot, "stale-local.md");
@@ -1059,7 +1123,7 @@ describe("share", () => {
1059
1123
  throw err;
1060
1124
  });
1061
1125
 
1062
- const events: Array<{ type?: string; path?: string; message?: string }> = [];
1126
+ const events: Array<{ type?: string; path?: string; message?: string; count?: number; samplePaths?: string[] }> = [];
1063
1127
  const result = await share({
1064
1128
  paths: [staleFile],
1065
1129
  company: "acme",
@@ -1072,9 +1136,13 @@ describe("share", () => {
1072
1136
  expect(vi.mocked(uploadFile)).not.toHaveBeenCalled();
1073
1137
  expect(result.filesUploaded).toBe(0);
1074
1138
  expect(result.aborted).toBe(false);
1075
- const errs2 = events.filter((e) => e.type === "error") as Array<{ path?: string }>;
1076
- expect(errs2).toHaveLength(1);
1077
- expect(errs2[0].path).toBe("stale-local.md");
1139
+ expect(result.filesExcludedByScope).toBe(1);
1140
+ const scopeEv = events.find((e) => e.type === "scope-excluded") as
1141
+ | { count: number; samplePaths: string[] }
1142
+ | undefined;
1143
+ expect(scopeEv).toBeDefined();
1144
+ expect(scopeEv!.samplePaths).toEqual(["stale-local.md"]);
1145
+ expect(events.some((e) => e.type === "error")).toBe(false);
1078
1146
  });
1079
1147
 
1080
1148
  it("uploads (no conflict) when only the local side changed since last sync", async () => {
package/src/cli/share.ts CHANGED
@@ -1156,14 +1156,7 @@ async function executeUploads(
1156
1156
  remoteMeta = await headRemoteFile(run.ctx, relativePath);
1157
1157
  } catch (headErr) {
1158
1158
  if (isAccessDenied(headErr)) {
1159
- run.emit({
1160
- type: "error",
1161
- path: relativePath,
1162
- message:
1163
- "skipped: outside granted ACL scope (server returned 403 " +
1164
- "SCOPE_EXCEEDS_PARENT / access denied on HEAD). Grant this path " +
1165
- "to push it, or it stays local-only.",
1166
- });
1159
+ run.scopeExcludedSet.add(relativePath);
1167
1160
  return;
1168
1161
  }
1169
1162
  throw headErr;
@@ -1325,16 +1318,14 @@ async function executeUploads(
1325
1318
  counters.filesSkipped++;
1326
1319
  return;
1327
1320
  }
1321
+ if (isAccessDenied(err)) {
1322
+ run.scopeExcludedSet.add(relativePath);
1323
+ return;
1324
+ }
1328
1325
  run.emit({
1329
1326
  type: "error",
1330
1327
  path: relativePath,
1331
- message: isAccessDenied(err)
1332
- ? "skipped: outside granted ACL scope (server returned 403 " +
1333
- "SCOPE_EXCEEDS_PARENT / access denied on PUT). Grant this path " +
1334
- "to push it, or it stays local-only."
1335
- : err instanceof Error
1336
- ? err.message
1337
- : String(err),
1328
+ message: err instanceof Error ? err.message : String(err),
1338
1329
  });
1339
1330
  }
1340
1331
  };
@@ -2256,12 +2247,25 @@ async function computeDeletePlan(
2256
2247
  for (let i = 0; i < headCandidates.length; i += DELETE_PLAN_HEAD_CONCURRENCY) {
2257
2248
  const chunk = headCandidates.slice(i, i + DELETE_PLAN_HEAD_CONCURRENCY);
2258
2249
  const results = await Promise.all(
2259
- chunk.map(async (c) => ({
2260
- candidate: c,
2261
- remote: await headRemoteFile(ctx, c.key),
2262
- })),
2250
+ chunk.map(async (c) => {
2251
+ try {
2252
+ return {
2253
+ candidate: c,
2254
+ remote: await headRemoteFile(ctx, c.key),
2255
+ scopeExcluded: false,
2256
+ };
2257
+ } catch (err) {
2258
+ if (isAccessDenied(err)) {
2259
+ return { candidate: c, remote: null, scopeExcluded: true };
2260
+ }
2261
+ throw err;
2262
+ }
2263
+ }),
2263
2264
  );
2264
- for (const { candidate, remote } of results) {
2265
+ for (const { candidate, remote, scopeExcluded } of results) {
2266
+ if (scopeExcluded) {
2267
+ continue;
2268
+ }
2265
2269
  if (remote === null) {
2266
2270
  plan.toTombstone.push(candidate.key);
2267
2271
  continue;
@@ -895,7 +895,7 @@ describe("sync", () => {
895
895
  expect(fs.existsSync(journalPath)).toBe(false);
896
896
  });
897
897
 
898
- it("personalMode: true skips companies/* keys and downloads root keys to hqRoot", async () => {
898
+ it("personalMode: downloads local companies/* keys and root keys to hqRoot by default", async () => {
899
899
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
900
900
  { key: "companies/foo/bar.md", size: 50, lastModified: new Date(), etag: '"xyz789"' },
901
901
  { key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"abc000"' },
@@ -906,22 +906,24 @@ describe("sync", () => {
906
906
  vaultConfig: mockConfig,
907
907
  hqRoot: tmpDir,
908
908
  personalMode: true,
909
+ // No teamSyncedSlugs → foo is a local (non-cloud) company, downloaded.
909
910
  });
910
911
 
911
- // Exact counts (regression-tight)
912
- expect(result.filesSkipped).toBe(1);
913
- expect(result.filesDownloaded).toBe(1);
912
+ // Exact counts (regression-tight): local company + root key both land.
913
+ expect(result.filesSkipped).toBe(0);
914
+ expect(result.filesDownloaded).toBe(2);
914
915
 
915
- // companies/* must NOT land anywhere
916
+ // companies/foo (local, non-cloud) lands at <hqRoot>/companies/foo/bar.md,
917
+ // NOT double-nested under <hqRoot>/companies/<slug>/companies/...
918
+ expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "bar.md"))).toBe(true);
916
919
  expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "companies", "foo", "bar.md"))).toBe(false);
917
- expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "bar.md"))).toBe(false);
918
920
 
919
921
  // docs/readme.md MUST land at <hqRoot>/docs/readme.md (NOT <hqRoot>/companies/<slug>/docs/readme.md)
920
922
  expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
921
923
  expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "readme.md"))).toBe(false);
922
924
  });
923
925
 
924
- it("personalMode: downloads + journals companies/manifest.yaml (carve-out round-trips) while still skipping other companies/* keys", async () => {
926
+ it("personalMode: downloads + journals companies/manifest.yaml (carve-out round-trips) while still skipping team-synced orphan keys", async () => {
925
927
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
926
928
  { key: "companies/foo/bar.md", size: 50, lastModified: new Date(), etag: '"xyz789"' },
927
929
  { key: "companies/manifest.yaml", size: 40, lastModified: new Date(), etag: '"man111"' },
@@ -932,10 +934,12 @@ describe("sync", () => {
932
934
  vaultConfig: mockConfig,
933
935
  hqRoot: tmpDir,
934
936
  personalMode: true,
937
+ // foo is now cloud-backed (team-synced) → its key is a stale orphan.
938
+ teamSyncedSlugs: new Set(["foo"]),
935
939
  });
936
940
 
937
- // The manifest is the lone companies/* exemption: it downloads; other
938
- // companies/* keys are still dropped.
941
+ // The manifest always downloads (routing source-of-truth carve-out);
942
+ // the team-synced orphan key is dropped.
939
943
  expect(result.filesSkipped).toBe(1);
940
944
  expect(result.filesDownloaded).toBe(1);
941
945
  expect(fs.existsSync(path.join(tmpDir, "companies", "manifest.yaml"))).toBe(true);
@@ -1080,13 +1084,12 @@ describe("sync", () => {
1080
1084
  expect(kept.thread_path).toBe("workspace/threads/T-machineB.json");
1081
1085
  });
1082
1086
 
1083
- it("personalMode + includeLocalCompanies: downloads companies/{cloud-false-slug}/... keys when slug NOT in teamSyncedSlugs", async () => {
1084
- // The symmetric flip for the cloud:false personal-bucket fallback.
1085
- // Machine A pushed `companies/free-co/notes.md` to the personal bucket
1086
- // (because the operator set `HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1`
1087
- // AND `company.yaml` declares `cloud: false`). Machine B subscribes:
1088
- // pull must allow these keys through the personalMode filter, otherwise
1089
- // the feature is push-only and the destination machine never sees them.
1087
+ it("personalMode: downloads companies/{local-slug}/... keys by default when slug NOT in teamSyncedSlugs", async () => {
1088
+ // Local (non-cloud) companies sync to the personal vault by default.
1089
+ // Machine A pushed `companies/free-co/notes.md` to the personal bucket;
1090
+ // machine B subscribes: pull must allow these keys through the
1091
+ // personalMode filter, otherwise the feature is push-only and the
1092
+ // destination machine never sees them.
1090
1093
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1091
1094
  { key: "companies/free-co/notes.md", size: 50, lastModified: new Date(), etag: '"abc"' },
1092
1095
  { key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"def"' },
@@ -1097,7 +1100,6 @@ describe("sync", () => {
1097
1100
  vaultConfig: mockConfig,
1098
1101
  hqRoot: tmpDir,
1099
1102
  personalMode: true,
1100
- includeLocalCompanies: true,
1101
1103
  teamSyncedSlugs: new Set(), // empty → no slug is "orphan"
1102
1104
  });
1103
1105
 
@@ -1107,21 +1109,20 @@ describe("sync", () => {
1107
1109
  expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
1108
1110
  });
1109
1111
 
1110
- it("personalMode + includeLocalCompanies: drops companies/{team-synced-slug}/... keys as orphans (symmetric to push-side decommission)", async () => {
1111
- // The orphan-cleanup half. Once a company has been promoted from
1112
- // cloud:false fallback to its own team bucket, the operator gains
1113
- // an active Membership for it (slug enters teamSyncedSlugs). Any
1114
- // leftover `companies/{that-slug}/...` keys in the personal bucket
1115
- // are stale residue downloading them would clash with the team-
1116
- // bucket pull at the same disk path. Filter drops them silently.
1117
- // Push-side `decommissionPrefixes` eventually removes them from the
1112
+ it("personalMode: drops companies/{team-synced-slug}/... keys as orphans (company became cloud-true)", async () => {
1113
+ // The orphan-cleanup half. Once a company becomes cloud-true, the
1114
+ // operator gains an active Membership for it (slug enters
1115
+ // teamSyncedSlugs). Any leftover `companies/{that-slug}/...` keys in
1116
+ // the personal bucket are stale residue — downloading them would clash
1117
+ // with the team-bucket pull at the same disk path. Filter drops them
1118
+ // silently. Push-side `decommissionPrefixes` removes them from the
1118
1119
  // bucket; this filter keeps the local tree clean in the meantime
1119
1120
  // (especially important for pull-only mode where decommission never
1120
1121
  // runs).
1121
1122
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1122
1123
  // Orphan: this slug is now team-synced — drop the key.
1123
1124
  { key: "companies/acme/file.md", size: 50, lastModified: new Date(), etag: '"old"' },
1124
- // Legitimate cloud:false content: download.
1125
+ // Legitimate local content: download.
1125
1126
  { key: "companies/free-co/notes.md", size: 30, lastModified: new Date(), etag: '"new"' },
1126
1127
  ]);
1127
1128
 
@@ -1130,7 +1131,6 @@ describe("sync", () => {
1130
1131
  vaultConfig: mockConfig,
1131
1132
  hqRoot: tmpDir,
1132
1133
  personalMode: true,
1133
- includeLocalCompanies: true,
1134
1134
  teamSyncedSlugs: new Set(["acme"]),
1135
1135
  });
1136
1136
 
@@ -1142,18 +1142,16 @@ describe("sync", () => {
1142
1142
  expect(fs.existsSync(path.join(tmpDir, "companies", "free-co", "notes.md"))).toBe(true);
1143
1143
  });
1144
1144
 
1145
- it("personalMode WITHOUT includeLocalCompanies: legacy behavior preserved ALL companies/... keys dropped", async () => {
1146
- // Regression for the legacy contract. Pre-5.20 (and any operator who
1147
- // hasn't opted into HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1), the
1148
- // personal bucket should never contain `companies/...` keys. If they
1149
- // do exist (stale data, manual S3 console intervention, an old bug),
1150
- // the pull-side filter drops them as a safety net. This test pins
1151
- // that contract under the new option shape: includeLocalCompanies
1152
- // omitted → behaves identically to the original filter, even if
1153
- // teamSyncedSlugs is supplied.
1145
+ it("personalMode: companies/manifest.yaml always downloads (routing source-of-truth), even when other slugs are team-synced", async () => {
1146
+ // The manifest is the one `companies/...` key carved into the personal
1147
+ // vault unconditionally. It must round-trip on pull even when the
1148
+ // operator has team-synced slugs (its key segment `manifest.yaml` is
1149
+ // not a real slug and must never be treated as an orphan).
1154
1150
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1155
- { key: "companies/anything/file.md", size: 50, lastModified: new Date(), etag: '"x"' },
1156
- { key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"y"' },
1151
+ { key: "companies/manifest.yaml", size: 40, lastModified: new Date(), etag: '"m"' },
1152
+ // A team-synced orphan that must still be dropped.
1153
+ { key: "companies/acme/file.md", size: 50, lastModified: new Date(), etag: '"o"' },
1154
+ { key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"d"' },
1157
1155
  ]);
1158
1156
 
1159
1157
  const result = await sync({
@@ -1161,13 +1159,13 @@ describe("sync", () => {
1161
1159
  vaultConfig: mockConfig,
1162
1160
  hqRoot: tmpDir,
1163
1161
  personalMode: true,
1164
- // includeLocalCompanies omitted → defaults to false
1165
- teamSyncedSlugs: new Set(["unrelated"]),
1162
+ teamSyncedSlugs: new Set(["acme"]),
1166
1163
  });
1167
1164
 
1168
- expect(result.filesDownloaded).toBe(1);
1165
+ expect(result.filesDownloaded).toBe(2);
1169
1166
  expect(result.filesSkipped).toBe(1);
1170
- expect(fs.existsSync(path.join(tmpDir, "companies", "anything", "file.md"))).toBe(false);
1167
+ expect(fs.existsSync(path.join(tmpDir, "companies", "manifest.yaml"))).toBe(true);
1168
+ expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "file.md"))).toBe(false);
1171
1169
  expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
1172
1170
  });
1173
1171
 
package/src/cli/sync.ts CHANGED
@@ -323,35 +323,21 @@ export interface SyncOptions {
323
323
  onEvent?: (event: SyncProgressEvent) => void;
324
324
  /**
325
325
  * When true, the caller is syncing against the caller's person-entity
326
- * bucket. Pulled keys whose path starts with `companies/` are dropped
327
- * by default (belt-and-braces the person bucket should never contain
328
- * those, and the runner must not write them into the user's company
329
- * folders). See `includeLocalCompanies` for the opt-in escape hatch.
326
+ * bucket. Pulled keys whose path starts with `companies/` are local
327
+ * (non-cloud) companies that sync to the personal vault by default
328
+ * they are allowed through EXCEPT for slugs in `teamSyncedSlugs` (which
329
+ * are cloud-backed orphans, see below). `companies/manifest.yaml` is
330
+ * always allowed (routing source-of-truth).
330
331
  */
331
332
  personalMode?: boolean;
332
- /**
333
- * Opt-in: when `personalMode === true`, allow `companies/{slug}/...` keys
334
- * through the pull filter EXCEPT for slugs in `teamSyncedSlugs` (which
335
- * are orphan remnants from a company that was promoted to its own team
336
- * bucket and remain pre-decommission). Mirrors the push-side
337
- * `HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1` gate: when an operator opts
338
- * into the cloud:false → personal-bucket fallback on their machine,
339
- * pull must also know about the new key shape on the OTHER machine that
340
- * subscribes to those keys. Without this, the feature is push-only —
341
- * the destination machine never sees the keys.
342
- *
343
- * Default false preserves the pre-5.20 behavior (drop all
344
- * `companies/...` keys in personalMode).
345
- */
346
- includeLocalCompanies?: boolean;
347
333
  /**
348
334
  * Slugs of companies the operator has an active team-bucket Membership
349
- * for. Only consulted when `personalMode === true` AND
350
- * `includeLocalCompanies === true`: keys under `companies/{slug}/...`
351
- * for any slug in this set are dropped as orphans from a pre-promotion
352
- * personal-bucket fallback. The push-side decommission cycle eventually
353
- * removes these from the bucket; this filter prevents them from
354
- * re-downloading into the same disk paths the team-bucket pull manages.
335
+ * for. Only consulted when `personalMode === true`: keys under
336
+ * `companies/{slug}/...` for any slug in this set are dropped as orphans
337
+ * from a pre-promotion personal-bucket fallback (the company became
338
+ * cloud-true). The push-side decommission cycle removes these from the
339
+ * bucket; this filter prevents them from re-downloading into the same
340
+ * disk paths the team-bucket pull manages.
355
341
  */
356
342
  teamSyncedSlugs?: ReadonlySet<string>;
357
343
  /**
@@ -798,7 +784,6 @@ function planPull(run: PullRunContext): PullPlan {
798
784
  run.companyRoot,
799
785
  run.shouldSync,
800
786
  run.options.personalMode === true,
801
- run.options.includeLocalCompanies === true,
802
787
  run.options.teamSyncedSlugs ?? null,
803
788
  run.currentPrefixSet,
804
789
  run.fileTombstones,
@@ -1729,7 +1714,6 @@ function computePullPlan(
1729
1714
  companyRoot: string,
1730
1715
  shouldSync: (filePath: string, isDir?: boolean) => boolean,
1731
1716
  personalMode: boolean,
1732
- includeLocalCompanies: boolean,
1733
1717
  teamSyncedSlugs: ReadonlySet<string> | null,
1734
1718
  // Coalesced, company-relative prefixes the pull is scoped to (US-005).
1735
1719
  // `[""]` (the `all`-mode value) covers everything via `isCoveredByAny`, so
@@ -1774,18 +1758,13 @@ function computePullPlan(
1774
1758
  // fall through to download + journal like any personal file.
1775
1759
  remoteFile.key !== PERSONAL_VAULT_MANIFEST_KEY
1776
1760
  ) {
1777
- // Default: drop every `companies/...` key the legacy contract
1778
- // is that the personal bucket should never contain them.
1779
- //
1780
- // EXCEPTION: when the operator has opted into the cloud:false →
1781
- // personal-bucket fallback (HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1
1782
- // on this machine), keys under `companies/{slug}/...` are
1783
- // legitimate they're content a peer machine pushed for a
1784
- // company whose `company.yaml` declares `cloud: false`. Allow
1785
- // them through EXCEPT for slugs the operator has an active team-
1786
- // bucket Membership for: those are orphan remnants from before
1787
- // the company was promoted, and downloading them would clash
1788
- // with the team-bucket pull at the same disk path.
1761
+ // Local (non-cloud) companies sync to the personal vault: keys under
1762
+ // `companies/{slug}/...` are legitimate content a peer machine pushed
1763
+ // for a company that is not cloud-backed. Allow them through EXCEPT
1764
+ // for slugs the operator has an active team-bucket Membership for:
1765
+ // those are orphan remnants from before the company became cloud-true,
1766
+ // and downloading them would clash with the team-bucket pull at the
1767
+ // same disk path.
1789
1768
  //
1790
1769
  // Symmetric to the push-side `decommissionPrefixes` logic in
1791
1770
  // share.ts — both target the same orphan class, both honor the
@@ -1794,7 +1773,7 @@ function computePullPlan(
1794
1773
  const slug = remoteFile.key.split("/")[1] ?? "";
1795
1774
  const isTeamSyncedOrphan =
1796
1775
  teamSyncedSlugs !== null && slug !== "" && teamSyncedSlugs.has(slug);
1797
- if (!includeLocalCompanies || isTeamSyncedOrphan) {
1776
+ if (isTeamSyncedOrphan) {
1798
1777
  items.push({ action: "skip-personal-mode", remoteFile, localPath });
1799
1778
  continue;
1800
1779
  }
@@ -2138,12 +2117,16 @@ function computePullPlan(
2138
2117
  if (remoteKeySet.has(posixKey)) continue;
2139
2118
  const localPath = resolveContainedVaultPath(companyRoot, key);
2140
2119
  if (localPath === null) continue;
2141
- // PersonalMode key gating — mirror the download branch.
2120
+ // PersonalMode key gating — mirror the download branch. Local (non-cloud)
2121
+ // company keys are tombstone-eligible (a peer's delete should propagate),
2122
+ // but team-synced orphans are left alone (the team-bucket pull owns those
2123
+ // disk paths), and the manifest — the routing source-of-truth — is never
2124
+ // auto-tombstoned out of the personal vault.
2142
2125
  if (personalMode && key.startsWith("companies/")) {
2143
2126
  const slug = key.split("/")[1] ?? "";
2144
2127
  const isTeamSyncedOrphan =
2145
2128
  teamSyncedSlugs !== null && slug !== "" && teamSyncedSlugs.has(slug);
2146
- if (!includeLocalCompanies || isTeamSyncedOrphan) continue;
2129
+ if (isTeamSyncedOrphan || key === PERSONAL_VAULT_MANIFEST_KEY) continue;
2147
2130
  }
2148
2131
  // Ephemeral keys are filtered both directions; never tombstone-
2149
2132
  // propagate a conflict-mirror.
@@ -4,7 +4,7 @@
4
4
  * the trailing-slash sibling boundary, and the unattributed (no-match) path.
5
5
  */
6
6
 
7
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
7
+ import { describe, it, expect, afterEach } from "vitest";
8
8
  import * as fs from "fs";
9
9
  import * as os from "os";
10
10
  import * as path from "path";
@@ -76,6 +76,6 @@ export function buildConflictId(
76
76
  detectedAt: string,
77
77
  ): string {
78
78
  const safeTs = detectedAt.replace(/:/g, "-").replace(/\.\d+/, "");
79
- const safePath = originalRelative.replace(/[\/\\.]/g, "-");
79
+ const safePath = originalRelative.replace(/[/\\.]/g, "-");
80
80
  return `${safePath}-${safeTs}`;
81
81
  }