@indigoai-us/hq-cloud 5.38.0 → 5.40.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.
@@ -205,7 +205,7 @@ describe("argv parsing", () => {
205
205
  const deps = makeDeps();
206
206
  const code = await runRunner([], deps);
207
207
  expect(code).toBe(1);
208
- expect(deps.stderr.raw()).toContain("--companies or --company");
208
+ expect(deps.stderr.raw()).toContain("--personal");
209
209
  expect(deps.stdout.events()).toEqual([]);
210
210
  });
211
211
 
@@ -279,15 +279,23 @@ describe("auth", () => {
279
279
  expect(deps.stdout.events()).toEqual([]);
280
280
  });
281
281
 
282
- it("emits error event on stderr and returns 1 on non-auth discovery failure", async () => {
282
+ it("emits error event on stderr and returns 1 on non-auth discovery failure (after 3 attempts)", async () => {
283
+ let calls = 0;
283
284
  const deps = makeDeps({
284
285
  createVaultClient: () => ({
285
286
  ...makeVaultStub(),
286
- listMyMemberships: () => Promise.reject(new Error("network down")),
287
+ listMyMemberships: () => {
288
+ calls++;
289
+ return Promise.reject(new Error("network down"));
290
+ },
287
291
  }),
288
292
  });
289
293
  const code = await runRunner(["--companies"], deps);
290
294
  expect(code).toBe(1);
295
+ // Memberships retry burns all 3 attempts before surfacing the error —
296
+ // see listMembershipsWithRetry. Auth errors short-circuit (no retry);
297
+ // anything else (network, 5xx) retries twice more before giving up.
298
+ expect(calls).toBe(3);
291
299
  // error events go to stderr, not stdout
292
300
  const events = deps.stderr.events();
293
301
  expect(events).toHaveLength(1);
@@ -300,6 +308,102 @@ describe("auth", () => {
300
308
  });
301
309
  });
302
310
 
311
+ // ---------------------------------------------------------------------------
312
+ // memberships retry (3x with backoff, then abort)
313
+ // ---------------------------------------------------------------------------
314
+ //
315
+ // `listMyMemberships()` is the single API call that drives every fanout
316
+ // target in --companies mode. A transient network blip on this one call
317
+ // shouldn't kill the whole sync — retry 3 times with backoff before
318
+ // surfacing the error. Auth failures short-circuit (no retries — the
319
+ // caller needs to re-vend, retries won't help).
320
+ //
321
+ // Single-company mode bypasses memberships entirely (the caller already
322
+ // told us which company), so no retry path is needed there.
323
+
324
+ describe("memberships retry", () => {
325
+ it("succeeds on 1st attempt with no retry", async () => {
326
+ let listCalls = 0;
327
+ const stub = makeVaultStub();
328
+ stub.listMyMemberships = () => {
329
+ listCalls++;
330
+ return Promise.resolve([{ companyUid: "cmp_acme" }] as Membership[]);
331
+ };
332
+ const deps = makeDeps({ createVaultClient: () => stub });
333
+ const code = await runRunner(["--companies"], deps);
334
+ expect(code).toBe(0);
335
+ expect(listCalls).toBe(1);
336
+ });
337
+
338
+ it("succeeds on 3rd attempt after 2 transient failures", async () => {
339
+ let listCalls = 0;
340
+ const stub = makeVaultStub();
341
+ stub.listMyMemberships = () => {
342
+ listCalls++;
343
+ if (listCalls < 3) {
344
+ return Promise.reject(new Error("ECONNRESET"));
345
+ }
346
+ return Promise.resolve([{ companyUid: "cmp_acme" }] as Membership[]);
347
+ };
348
+ const deps = makeDeps({ createVaultClient: () => stub });
349
+ const code = await runRunner(["--companies"], deps);
350
+ expect(code).toBe(0);
351
+ expect(listCalls).toBe(3);
352
+ // No error event should reach stderr — the retry succeeded.
353
+ expect(deps.stderr.events().some((e) => e.type === "error")).toBe(false);
354
+ });
355
+
356
+ it("exhausts 3 retries on persistent failure, then emits error event + exit 1", async () => {
357
+ let listCalls = 0;
358
+ const stub = makeVaultStub();
359
+ stub.listMyMemberships = () => {
360
+ listCalls++;
361
+ return Promise.reject(new Error("network down"));
362
+ };
363
+ const deps = makeDeps({ createVaultClient: () => stub });
364
+ const code = await runRunner(["--companies"], deps);
365
+ expect(code).toBe(1);
366
+ expect(listCalls).toBe(3);
367
+ const events = deps.stderr.events();
368
+ expect(events).toHaveLength(1);
369
+ expect(events[0]).toMatchObject({
370
+ type: "error",
371
+ message: expect.stringContaining("network down"),
372
+ path: "(discovery)",
373
+ });
374
+ });
375
+
376
+ it("VaultAuthError short-circuits retry (no retries on auth failure)", async () => {
377
+ let listCalls = 0;
378
+ const stub = makeVaultStub();
379
+ stub.listMyMemberships = () => {
380
+ listCalls++;
381
+ return Promise.reject(new VaultAuthError("token expired"));
382
+ };
383
+ const deps = makeDeps({ createVaultClient: () => stub });
384
+ const code = await runRunner(["--companies"], deps);
385
+ expect(code).toBe(0);
386
+ // Auth failures don't retry — re-vending creds is the caller's job.
387
+ expect(listCalls).toBe(1);
388
+ expect(deps.stderr.events()).toEqual([
389
+ { type: "auth-error", message: "token expired" },
390
+ ]);
391
+ });
392
+
393
+ it("single-company mode bypasses listMyMemberships entirely (no retry path triggered)", async () => {
394
+ let listCalls = 0;
395
+ const stub = makeVaultStub();
396
+ stub.listMyMemberships = () => {
397
+ listCalls++;
398
+ return Promise.reject(new Error("should not be called"));
399
+ };
400
+ const deps = makeDeps({ createVaultClient: () => stub });
401
+ const code = await runRunner(["--company", "cmp_explicit"], deps);
402
+ expect(code).toBe(0);
403
+ expect(listCalls).toBe(0);
404
+ });
405
+ });
406
+
303
407
  // ---------------------------------------------------------------------------
304
408
  // claim-dance (first sign-in)
305
409
  // ---------------------------------------------------------------------------
@@ -478,6 +582,227 @@ describe("target resolution", () => {
478
582
  });
479
583
  });
480
584
 
585
+ // ---------------------------------------------------------------------------
586
+ // --personal mode (personal-vault-only, skip company fanout)
587
+ // ---------------------------------------------------------------------------
588
+ //
589
+ // `--personal` is mutually exclusive with `--companies` and `--company`. It
590
+ // runs ONLY the personal-vault target — no listMyMemberships call, no
591
+ // claim-dance, no cloud-company fanout. Designed as the replacement
592
+ // pathway for Rust's `personal.rs::run_personal_first_push` (the menubar's
593
+ // first-push), so the entire personal-vault walker lives in one place
594
+ // (hq-cloud TS) and not in two duplicate engines.
595
+
596
+ describe("--personal mode", () => {
597
+ it("argv: --personal + --companies is rejected", async () => {
598
+ const deps = makeDeps();
599
+ const code = await runRunner(["--personal", "--companies"], deps);
600
+ expect(code).toBe(1);
601
+ expect(deps.stderr.raw()).toContain("mutually exclusive");
602
+ });
603
+
604
+ it("argv: --personal + --company X is rejected", async () => {
605
+ const deps = makeDeps();
606
+ const code = await runRunner(["--personal", "--company", "cmp_x"], deps);
607
+ expect(code).toBe(1);
608
+ expect(deps.stderr.raw()).toContain("mutually exclusive");
609
+ });
610
+
611
+ it("argv: --personal + --skip-personal is rejected (contradictory)", async () => {
612
+ const deps = makeDeps();
613
+ const code = await runRunner(["--personal", "--skip-personal"], deps);
614
+ expect(code).toBe(1);
615
+ expect(deps.stderr.raw()).toContain("contradictory");
616
+ });
617
+
618
+ it("does NOT call listMyMemberships (skips company-discovery API entirely)", async () => {
619
+ const listSpy = vi.fn();
620
+ const deps = makeDeps({
621
+ createVaultClient: () => ({
622
+ ...makeVaultStub({
623
+ listPersons: () =>
624
+ Promise.resolve([
625
+ {
626
+ uid: "ent_person_1",
627
+ type: "person",
628
+ bucketName: "hq-vault-personal-1",
629
+ status: "active",
630
+ } as unknown as EntityInfo,
631
+ ]),
632
+ }),
633
+ listMyMemberships: listSpy as unknown as () => Promise<Membership[]>,
634
+ }),
635
+ });
636
+ const code = await runRunner(["--personal"], deps);
637
+ expect(code).toBe(0);
638
+ expect(listSpy).not.toHaveBeenCalled();
639
+ });
640
+
641
+ it("does NOT run claim-dance (skips pending-invites + ensurePerson)", async () => {
642
+ const claimSpy = vi.fn();
643
+ const ensureSpy = vi.fn();
644
+ const deps = makeDeps({
645
+ createVaultClient: () => ({
646
+ ...makeVaultStub({
647
+ listPersons: () =>
648
+ Promise.resolve([
649
+ {
650
+ uid: "ent_person_1",
651
+ type: "person",
652
+ bucketName: "hq-vault-personal-1",
653
+ status: "active",
654
+ } as unknown as EntityInfo,
655
+ ]),
656
+ }),
657
+ claimPendingInvitesByEmail:
658
+ claimSpy as unknown as (uid: string) => Promise<void>,
659
+ ensureMyPersonEntity:
660
+ ensureSpy as unknown as VaultClientSurface["ensureMyPersonEntity"],
661
+ }),
662
+ getIdTokenClaims: () => ({
663
+ sub: "sub-abc",
664
+ email: "user@example.com",
665
+ name: "User Name",
666
+ }),
667
+ });
668
+ const code = await runRunner(["--personal"], deps);
669
+ expect(code).toBe(0);
670
+ expect(claimSpy).not.toHaveBeenCalled();
671
+ expect(ensureSpy).not.toHaveBeenCalled();
672
+ });
673
+
674
+ it("emits fanout-plan with exactly one personal entry", async () => {
675
+ const deps = makeDeps({
676
+ createVaultClient: () =>
677
+ makeVaultStub({
678
+ listPersons: () =>
679
+ Promise.resolve([
680
+ {
681
+ uid: "ent_person_1",
682
+ type: "person",
683
+ bucketName: "hq-vault-personal-1",
684
+ status: "active",
685
+ } as unknown as EntityInfo,
686
+ ]),
687
+ }),
688
+ });
689
+ const code = await runRunner(["--personal"], deps);
690
+ expect(code).toBe(0);
691
+ const plan = deps.stdout
692
+ .events()
693
+ .find((e) => e.type === "fanout-plan") as Extract<
694
+ RunnerEvent,
695
+ { type: "fanout-plan" }
696
+ >;
697
+ expect(plan).toBeDefined();
698
+ expect(plan.companies).toHaveLength(1);
699
+ expect(plan.companies[0]).toMatchObject({ slug: "personal" });
700
+ });
701
+
702
+ it("calls syncFn ONCE with personalMode=true (default direction=pull)", async () => {
703
+ const deps = makeDeps({
704
+ createVaultClient: () =>
705
+ makeVaultStub({
706
+ listPersons: () =>
707
+ Promise.resolve([
708
+ {
709
+ uid: "ent_person_1",
710
+ type: "person",
711
+ bucketName: "hq-vault-personal-1",
712
+ status: "active",
713
+ } as unknown as EntityInfo,
714
+ ]),
715
+ }),
716
+ });
717
+ const code = await runRunner(["--personal"], deps);
718
+ expect(code).toBe(0);
719
+ expect(deps.sync).toHaveBeenCalledTimes(1);
720
+ const call = (deps.sync as ReturnType<typeof vi.fn>).mock
721
+ .calls[0][0] as SyncOptions;
722
+ expect(call.personalMode).toBe(true);
723
+ });
724
+
725
+ it("--personal --direction push calls shareFn with personalMode + computePersonalVaultPaths", async () => {
726
+ const shareSpy = vi.fn().mockResolvedValue({
727
+ filesUploaded: 0,
728
+ bytesUploaded: 0,
729
+ filesSkipped: 0,
730
+ filesDeleted: 0,
731
+ conflictPaths: [],
732
+ aborted: false,
733
+ });
734
+ const deps = makeDeps({
735
+ share: shareSpy as unknown as RunnerDeps["share"],
736
+ createVaultClient: () =>
737
+ makeVaultStub({
738
+ listPersons: () =>
739
+ Promise.resolve([
740
+ {
741
+ uid: "ent_person_1",
742
+ type: "person",
743
+ bucketName: "hq-vault-personal-1",
744
+ status: "active",
745
+ } as unknown as EntityInfo,
746
+ ]),
747
+ }),
748
+ });
749
+ const code = await runRunner(["--personal", "--direction", "push"], deps);
750
+ expect(code).toBe(0);
751
+ expect(shareSpy).toHaveBeenCalledTimes(1);
752
+ const opts = shareSpy.mock.calls[0][0];
753
+ expect(opts.personalMode).toBe(true);
754
+ // paths come from computePersonalVaultPaths — non-empty array of
755
+ // absolute hqRoot-anchored paths. Don't pin the exact contents
756
+ // (depends on hqRoot's local layout); just confirm the shape.
757
+ expect(Array.isArray(opts.paths)).toBe(true);
758
+ });
759
+
760
+ it("no person entity → emits setup-needed event, exit 0 (same shape as --companies)", async () => {
761
+ const deps = makeDeps({
762
+ createVaultClient: () =>
763
+ makeVaultStub({
764
+ listPersons: () => Promise.resolve([]),
765
+ }),
766
+ });
767
+ const code = await runRunner(["--personal"], deps);
768
+ expect(code).toBe(0);
769
+ expect(
770
+ deps.stdout.events().some((e) => e.type === "setup-needed"),
771
+ ).toBe(true);
772
+ expect(deps.sync).not.toHaveBeenCalled();
773
+ });
774
+
775
+ it("--personal honors --direction both (calls both shareFn and syncFn)", async () => {
776
+ const shareSpy = vi.fn().mockResolvedValue({
777
+ filesUploaded: 0,
778
+ bytesUploaded: 0,
779
+ filesSkipped: 0,
780
+ filesDeleted: 0,
781
+ conflictPaths: [],
782
+ aborted: false,
783
+ });
784
+ const deps = makeDeps({
785
+ share: shareSpy as unknown as RunnerDeps["share"],
786
+ createVaultClient: () =>
787
+ makeVaultStub({
788
+ listPersons: () =>
789
+ Promise.resolve([
790
+ {
791
+ uid: "ent_person_1",
792
+ type: "person",
793
+ bucketName: "hq-vault-personal-1",
794
+ status: "active",
795
+ } as unknown as EntityInfo,
796
+ ]),
797
+ }),
798
+ });
799
+ const code = await runRunner(["--personal", "--direction", "both"], deps);
800
+ expect(code).toBe(0);
801
+ expect(shareSpy).toHaveBeenCalledTimes(1);
802
+ expect(deps.sync).toHaveBeenCalledTimes(1);
803
+ });
804
+ });
805
+
481
806
  // ---------------------------------------------------------------------------
482
807
  // fanout-plan
483
808
  // ---------------------------------------------------------------------------
@@ -340,6 +340,46 @@ export interface VaultClientSurface {
340
340
  };
341
341
  }
342
342
 
343
+ /**
344
+ * Backoff schedule (in ms) between attempts 2 and 3 of
345
+ * `listMembershipsWithRetry`. Short on purpose — memberships is a single
346
+ * API call gating the whole runner, and a transient blip (DNS hiccup,
347
+ * idle ALB connection reset) usually clears in <50ms. If the network is
348
+ * genuinely down, three attempts in <200ms total fail fast enough that
349
+ * the tray can show its error banner before the user notices a delay.
350
+ */
351
+ const MEMBERSHIPS_RETRY_BACKOFFS_MS: readonly number[] = [50, 100];
352
+
353
+ /**
354
+ * Call `listMyMemberships()` with up to 3 attempts and a small linear
355
+ * backoff between them. The single network call that drives every cloud
356
+ * company target plus the personal-vault slot — a one-off network blip
357
+ * shouldn't kill the whole sync run.
358
+ *
359
+ * Auth failures (VaultAuthError) bypass retry entirely: re-vending creds
360
+ * is the caller's job, not retryable in-process. Re-throwing immediately
361
+ * preserves the existing auth-error event semantics in the outer
362
+ * try/catch.
363
+ */
364
+ async function listMembershipsWithRetry(
365
+ client: VaultClientSurface,
366
+ ): Promise<Membership[]> {
367
+ let lastErr: unknown;
368
+ for (let attempt = 0; attempt < 3; attempt++) {
369
+ if (attempt > 0) {
370
+ const delayMs = MEMBERSHIPS_RETRY_BACKOFFS_MS[attempt - 1] ?? 100;
371
+ await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
372
+ }
373
+ try {
374
+ return await client.listMyMemberships();
375
+ } catch (err) {
376
+ if (err instanceof VaultAuthError) throw err;
377
+ lastErr = err;
378
+ }
379
+ }
380
+ throw lastErr;
381
+ }
382
+
343
383
  /** Minimal shape of the claims we read off the Cognito idToken. */
344
384
  interface IdTokenClaims {
345
385
  sub?: string;
@@ -461,6 +501,16 @@ async function runClaimDance(
461
501
  interface ParsedArgs {
462
502
  companies: boolean;
463
503
  company?: string;
504
+ /**
505
+ * Personal-vault-only mode. Mutually exclusive with `--companies` and
506
+ * `--company`. Skips `listMyMemberships` (and therefore the claim-dance);
507
+ * builds a fanout plan containing ONLY the personal target. Designed as
508
+ * the runner-side entry point that replaces Rust's
509
+ * `personal.rs::run_personal_first_push` first-push walker — so the
510
+ * personal-vault scope (`computePersonalVaultPaths`) lives in exactly
511
+ * one place (this TS engine) and not duplicated across engines.
512
+ */
513
+ personal: boolean;
464
514
  onConflict: ConflictStrategy;
465
515
  hqRoot: string;
466
516
  direction: Direction;
@@ -489,6 +539,7 @@ interface ParsedArgs {
489
539
  function parseArgs(argv: string[]): ParsedArgs | { error: string } {
490
540
  let companies = false;
491
541
  let company: string | undefined;
542
+ let personal = false;
492
543
  let onConflict: ConflictStrategy = "abort";
493
544
  let hqRoot = DEFAULT_HQ_ROOT;
494
545
  let direction: Direction = "pull";
@@ -507,6 +558,14 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
507
558
  company = argv[++i];
508
559
  if (!company) return { error: "--company requires a value" };
509
560
  break;
561
+ case "--personal":
562
+ // Personal-vault-only mode. Skips listMyMemberships + claim-dance
563
+ // entirely; builds a fanout plan containing only the personal target.
564
+ // Replaces Rust's personal.rs::run_personal_first_push walker —
565
+ // the personal-vault scope (computePersonalVaultPaths) is owned
566
+ // by this engine, not duplicated across Rust + TS.
567
+ personal = true;
568
+ break;
510
569
  case "--on-conflict": {
511
570
  const val = argv[++i];
512
571
  if (val !== "abort" && val !== "overwrite" && val !== "keep") {
@@ -569,8 +628,18 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
569
628
  if (companies && company) {
570
629
  return { error: "Pass --companies OR --company <slug>, not both" };
571
630
  }
572
- if (!companies && !company) {
573
- return { error: "Pass --companies or --company <slug>" };
631
+ if (personal && (companies || company)) {
632
+ return {
633
+ error: "--personal is mutually exclusive with --companies / --company",
634
+ };
635
+ }
636
+ if (personal && skipPersonal) {
637
+ return {
638
+ error: "--personal and --skip-personal are contradictory",
639
+ };
640
+ }
641
+ if (!companies && !company && !personal) {
642
+ return { error: "Pass --companies, --company <slug>, or --personal" };
574
643
  }
575
644
  if (pollRemoteMs !== undefined && !watch) {
576
645
  return { error: "--poll-remote-ms requires --watch" };
@@ -582,6 +651,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
582
651
  return {
583
652
  companies,
584
653
  company,
654
+ personal,
585
655
  onConflict,
586
656
  hqRoot,
587
657
  direction,
@@ -734,7 +804,15 @@ export async function runRunner(
734
804
  // ---- resolve targets --------------------------------------------------
735
805
  let memberships: Pick<Membership, "companyUid">[];
736
806
  try {
737
- if (parsed.companies) {
807
+ if (parsed.personal) {
808
+ // Personal-vault-only mode: skip listMyMemberships entirely (and
809
+ // therefore the claim-dance). The fanout plan is built solely from
810
+ // the person-entity lookup below — no cloud-company targets, no
811
+ // /membership/me round-trip. setup-needed is deferred to the
812
+ // person-entity check (firing only when the personal entity is
813
+ // also absent, not when memberships are empty by design).
814
+ memberships = [];
815
+ } else if (parsed.companies) {
738
816
  // Before giving up on memberships, run the claim-dance: new users signed
739
817
  // in via the tray may have email-keyed invites waiting for them. Without
740
818
  // this, an invited user would see "setup-needed" on every tray click.
@@ -742,7 +820,7 @@ export async function runRunner(
742
820
  await runClaimDance(client, claims, stderr);
743
821
  }
744
822
 
745
- memberships = await client.listMyMemberships();
823
+ memberships = await listMembershipsWithRetry(client);
746
824
  if (memberships.length === 0) {
747
825
  // Truly empty — still a valid state (no memberships = nothing to
748
826
  // sync). The tray will show a friendly "create your first company"
@@ -800,7 +878,10 @@ export async function runRunner(
800
878
  plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
801
879
  }
802
880
 
803
- if (parsed.companies && !resolveSkipPersonal(parsed.skipPersonal)) {
881
+ if (
882
+ (parsed.companies || parsed.personal) &&
883
+ !resolveSkipPersonal(parsed.skipPersonal)
884
+ ) {
804
885
  // Personal-target fanout slot. Skipped entirely when --skip-personal
805
886
  // (or HQ_SYNC_SKIP_PERSONAL=1) is set — see resolveSkipPersonal doc for
806
887
  // the rationale (menubar opt-out for users who only want company sync).
@@ -809,6 +890,9 @@ export async function runRunner(
809
890
  // workspaces row, status surfaces) should already tolerate that
810
891
  // shape since pre-5.25 fanout often had it (a user with no person
811
892
  // entity yet, or before the canonical-person-entity machinery landed).
893
+ //
894
+ // `--personal` mode reaches this block with an empty `plan` and
895
+ // empty `memberships`; only the personal target gets added below.
812
896
  const persons = await client.entity.listByType("person");
813
897
  const pick = pickCanonicalPersonEntity(persons);
814
898
  if (pick?.bucketName) {
@@ -824,6 +908,14 @@ export async function runRunner(
824
908
  // root-cause writeup.
825
909
  journalSlug: PERSONAL_VAULT_JOURNAL_SLUG,
826
910
  });
911
+ } else if (parsed.personal) {
912
+ // --personal mode with no canonical personal entity → setup-needed.
913
+ // (In --companies mode this state is silent — companies still sync
914
+ // and the missing personal target just shows an empty workspaces row.
915
+ // In --personal mode it's the ONLY signal, so it gets surfaced as
916
+ // setup-needed with the same shape as the empty-memberships case.)
917
+ emit({ type: "setup-needed" });
918
+ return 0;
827
919
  }
828
920
  }
829
921
 
@@ -228,4 +228,95 @@ describe("personal-vault helpers", () => {
228
228
  fs.rmSync(hqRoot, { recursive: true });
229
229
  expect(computePersonalVaultPaths(hqRoot, { includeLocalCompanies: true })).toEqual([]);
230
230
  });
231
+
232
+ // ── companies/manifest.yaml inclusion ─────────────────────────────────
233
+ //
234
+ // The manifest is the routing source-of-truth (which slugs exist, which
235
+ // are cloud-backed, what their company UIDs are). The vault needs a
236
+ // single canonical copy: without it, a fresh machine joining the
237
+ // personal vault has no way to enumerate which non-cloud companies it
238
+ // expects to pull. Special-cased because companies/ itself is in
239
+ // PERSONAL_VAULT_EXCLUDED_TOP_LEVEL.
240
+ it("manifest: includes companies/manifest.yaml when present (default opts)", () => {
241
+ fs.mkdirSync(path.join(hqRoot, "companies"));
242
+ fs.writeFileSync(
243
+ path.join(hqRoot, "companies", "manifest.yaml"),
244
+ "companies:\n foo: {}\n",
245
+ );
246
+ fs.mkdirSync(path.join(hqRoot, ".claude"));
247
+
248
+ const out = rel(computePersonalVaultPaths(hqRoot));
249
+ expect(out).toContain(path.join("companies", "manifest.yaml"));
250
+ expect(out).toContain(".claude");
251
+ });
252
+
253
+ it("manifest: included even when includeLocalCompanies=false (manifest is unconditional)", () => {
254
+ fs.mkdirSync(path.join(hqRoot, "companies"));
255
+ fs.writeFileSync(
256
+ path.join(hqRoot, "companies", "manifest.yaml"),
257
+ "companies: {}\n",
258
+ );
259
+
260
+ const out = rel(
261
+ computePersonalVaultPaths(hqRoot, { includeLocalCompanies: false }),
262
+ );
263
+ expect(out).toContain(path.join("companies", "manifest.yaml"));
264
+ });
265
+
266
+ it("manifest: absent file is not included (no crash, no phantom entry)", () => {
267
+ fs.mkdirSync(path.join(hqRoot, "companies"));
268
+ // No manifest.yaml written.
269
+
270
+ const out = rel(computePersonalVaultPaths(hqRoot));
271
+ expect(out).not.toContain(path.join("companies", "manifest.yaml"));
272
+ });
273
+
274
+ it("manifest: missing companies/ directory entirely is not an error", () => {
275
+ // Fresh install or atypical layout: no companies/ dir at all.
276
+ fs.mkdirSync(path.join(hqRoot, ".claude"));
277
+ expect(() => computePersonalVaultPaths(hqRoot)).not.toThrow();
278
+ const out = rel(computePersonalVaultPaths(hqRoot));
279
+ expect(out).toContain(".claude");
280
+ expect(out).not.toContain(path.join("companies", "manifest.yaml"));
281
+ });
282
+
283
+ it("manifest: ONLY manifest.yaml is special-cased — other companies/ root files stay excluded", () => {
284
+ // Anti-test: only manifest.yaml gets the bypass. A stray README.md at
285
+ // companies/ root or a directory called `_template` must still be
286
+ // filtered (the latter by company-eligibility, the former silently).
287
+ fs.mkdirSync(path.join(hqRoot, "companies"));
288
+ fs.writeFileSync(
289
+ path.join(hqRoot, "companies", "manifest.yaml"),
290
+ "companies: {}\n",
291
+ );
292
+ fs.writeFileSync(
293
+ path.join(hqRoot, "companies", "README.md"),
294
+ "# companies tree",
295
+ );
296
+
297
+ const out = rel(computePersonalVaultPaths(hqRoot));
298
+ expect(out).toContain(path.join("companies", "manifest.yaml"));
299
+ expect(out).not.toContain(path.join("companies", "README.md"));
300
+ });
301
+
302
+ it("manifest: composes with includeLocalCompanies=true + local company subdirs", () => {
303
+ fs.mkdirSync(path.join(hqRoot, "companies"));
304
+ fs.writeFileSync(
305
+ path.join(hqRoot, "companies", "manifest.yaml"),
306
+ "companies:\n foo: {cloud_uid: null}\n team-acme: {cloud_uid: cmp_01XYZ}\n",
307
+ );
308
+ writeCompany("foo", "cloud: false\n");
309
+ writeCompany("team-acme", "cloud: true\n");
310
+
311
+ const out = rel(
312
+ computePersonalVaultPaths(hqRoot, {
313
+ includeLocalCompanies: true,
314
+ teamSyncedSlugs: new Set(["team-acme"]),
315
+ }),
316
+ );
317
+ // Manifest + non-cloud subdir, NOT team-acme.
318
+ expect(out).toContain(path.join("companies", "manifest.yaml"));
319
+ expect(out).toContain(path.join("companies", "foo"));
320
+ expect(out).not.toContain(path.join("companies", "team-acme"));
321
+ });
231
322
  });
@@ -105,10 +105,25 @@ export function computePersonalVaultPaths(
105
105
  const topLevel = entries
106
106
  .filter((name) => !PERSONAL_VAULT_EXCLUDED_TOP_LEVEL.includes(name))
107
107
  .map((name) => path.join(hqRoot, name));
108
+ // companies/manifest.yaml is the routing source-of-truth (which slugs
109
+ // exist, which are cloud-backed, what their UIDs are). It must travel
110
+ // with every machine's personal vault so a fresh install can enumerate
111
+ // expected non-cloud companies before its first pull. Special-cased
112
+ // because the parent `companies/` is in PERSONAL_VAULT_EXCLUDED_TOP_LEVEL
113
+ // (we never enumerate the whole companies tree wholesale).
114
+ const manifest: string[] = [];
115
+ const manifestPath = path.join(hqRoot, "companies", "manifest.yaml");
116
+ try {
117
+ if (fs.statSync(manifestPath).isFile()) {
118
+ manifest.push(manifestPath);
119
+ }
120
+ } catch {
121
+ // Missing or unreadable — silently omit. Callers tolerate empty arrays.
122
+ }
108
123
  const companySubdirs = opts.includeLocalCompanies === true
109
124
  ? computePersonalCompanySubdirs(hqRoot, opts.teamSyncedSlugs)
110
125
  : [];
111
- return [...topLevel, ...companySubdirs];
126
+ return [...topLevel, ...manifest, ...companySubdirs];
112
127
  }
113
128
 
114
129
  /**