@indigoai-us/hq-cloud 5.24.0 → 5.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/bin/sync-runner.d.ts +51 -16
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +67 -3
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +58 -15
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/share.d.ts +9 -0
  8. package/dist/cli/share.d.ts.map +1 -1
  9. package/dist/cli/share.js +54 -1
  10. package/dist/cli/share.js.map +1 -1
  11. package/dist/cli/share.test.js +6 -3
  12. package/dist/cli/share.test.js.map +1 -1
  13. package/dist/cli/sync.d.ts +21 -0
  14. package/dist/cli/sync.d.ts.map +1 -1
  15. package/dist/cli/sync.js.map +1 -1
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +3 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/personal-vault-exclusions.d.ts +128 -0
  21. package/dist/personal-vault-exclusions.d.ts.map +1 -0
  22. package/dist/personal-vault-exclusions.js +231 -0
  23. package/dist/personal-vault-exclusions.js.map +1 -0
  24. package/dist/personal-vault-exclusions.test.d.ts +22 -0
  25. package/dist/personal-vault-exclusions.test.d.ts.map +1 -0
  26. package/dist/personal-vault-exclusions.test.js +198 -0
  27. package/dist/personal-vault-exclusions.test.js.map +1 -0
  28. package/package.json +1 -1
  29. package/src/bin/sync-runner.test.ts +71 -15
  30. package/src/bin/sync-runner.ts +100 -19
  31. package/src/cli/share.test.ts +8 -3
  32. package/src/cli/share.ts +66 -1
  33. package/src/cli/sync.ts +22 -0
  34. package/src/index.ts +10 -0
  35. package/src/personal-vault-exclusions.test.ts +256 -0
  36. package/src/personal-vault-exclusions.ts +277 -0
@@ -11,7 +11,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
11
11
  import * as fs from "fs";
12
12
  import * as os from "os";
13
13
  import * as path from "path";
14
- import { runRunner, resolveDeletePolicy } from "./sync-runner.js";
14
+ import { runRunner, resolveDeletePolicy, resolveSkipPersonal } from "./sync-runner.js";
15
15
  import type {
16
16
  RunnerEvent,
17
17
  RunnerDeps,
@@ -80,6 +80,7 @@ function defaultShareResult(overrides: Partial<ShareResult> = {}): ShareResult {
80
80
  filesDeleted: 0,
81
81
  filesTombstoned: 0,
82
82
  filesRefusedStale: 0,
83
+ filesExcludedByPolicy: 0,
83
84
  conflictPaths: [],
84
85
  aborted: false,
85
86
  ...overrides,
@@ -687,7 +688,9 @@ describe("per-company fanout", () => {
687
688
  const complete = deps.stdout
688
689
  .events()
689
690
  .find((e) => e.type === "complete") as Extract<RunnerEvent, { type: "complete" }>;
690
- // Pull-only run: upload counters are 0.
691
+ // Pull-only run: upload counters are 0. Push-side counters added in
692
+ // 5.25 (filesTombstoned/filesRefusedStale/filesExcludedByPolicy) are
693
+ // also 0 because no push leg ran.
691
694
  expect(complete).toEqual({
692
695
  type: "complete",
693
696
  company: "acme",
@@ -699,6 +702,9 @@ describe("per-company fanout", () => {
699
702
  aborted: result.aborted,
700
703
  filesUploaded: 0,
701
704
  bytesUploaded: 0,
705
+ filesTombstoned: 0,
706
+ filesRefusedStale: 0,
707
+ filesExcludedByPolicy: 0,
702
708
  newFiles: result.newFiles,
703
709
  newFilesCount: result.newFilesCount,
704
710
  });
@@ -1709,11 +1715,11 @@ beforeEach(() => {
1709
1715
  // ── resolveDeletePolicy: env-var contract ───────────────────────────────────
1710
1716
  //
1711
1717
  // `HQ_SYNC_DELETE_POLICY` is the documented rollback knob for the
1712
- // currency-gated default landed in 5.24. The helper centralizes the
1713
- // allowlist + default so every callsite (both the personal and company push
1714
- // paths in the runner) gets identical semantics. Tests pin the four expected
1715
- // behaviors so a future regression on the allowlist or default surfaces here
1716
- // instead of in the field.
1718
+ // currency-gated default that became default in 5.25 (after a 5.24 soak).
1719
+ // The helper centralizes the allowlist + default so every callsite (both
1720
+ // the personal and company push paths in the runner) gets identical
1721
+ // semantics. Tests pin the four expected behaviors so a future regression
1722
+ // on the allowlist or default surfaces here instead of in the field.
1717
1723
 
1718
1724
  describe("resolveDeletePolicy", () => {
1719
1725
  let originalEnv: string | undefined;
@@ -1731,12 +1737,11 @@ describe("resolveDeletePolicy", () => {
1731
1737
  }
1732
1738
  });
1733
1739
 
1734
- it("defaults to 'owned-only' in 5.24 (staged-default rollout, flip in 5.25)", () => {
1735
- // 5.24 ships the currency-gated CODE PATH but holds the default flip
1736
- // for a soak window. When the default flips to 'currency-gated' in
1737
- // 5.25, this assertion changes — both edits live next to each other so
1738
- // the rollout sequence is obvious from the diff.
1739
- expect(resolveDeletePolicy()).toBe("owned-only");
1740
+ it("defaults to 'currency-gated' in 5.25 (post-soak default flip)", () => {
1741
+ // 5.24 shipped the code path with `owned-only` as default; 5.25 flips
1742
+ // the default to `currency-gated` after the soak window. Rollback knob
1743
+ // is `HQ_SYNC_DELETE_POLICY=owned-only` for anyone surprised.
1744
+ expect(resolveDeletePolicy()).toBe("currency-gated");
1740
1745
  });
1741
1746
 
1742
1747
  it.each(["currency-gated", "owned-only", "all"] as const)(
@@ -1749,11 +1754,62 @@ describe("resolveDeletePolicy", () => {
1749
1754
 
1750
1755
  it("falls back to default on unknown env values (no silent corruption)", () => {
1751
1756
  process.env.HQ_SYNC_DELETE_POLICY = "yolo";
1752
- expect(resolveDeletePolicy()).toBe("owned-only");
1757
+ expect(resolveDeletePolicy()).toBe("currency-gated");
1753
1758
  });
1754
1759
 
1755
1760
  it("treats empty string as unset → default", () => {
1756
1761
  process.env.HQ_SYNC_DELETE_POLICY = "";
1757
- expect(resolveDeletePolicy()).toBe("owned-only");
1762
+ expect(resolveDeletePolicy()).toBe("currency-gated");
1758
1763
  });
1759
1764
  });
1765
+
1766
+ // ── resolveSkipPersonal: flag-OR-env combination ───────────────────────────
1767
+ //
1768
+ // Two inputs combine: the `--skip-personal` CLI flag and the
1769
+ // `HQ_SYNC_SKIP_PERSONAL` env var. Either being truthy skips personal sync.
1770
+ // The flag is the explicit-for-this-invocation knob (menubar passes it when
1771
+ // the user toggled "Sync personal vault" off); the env is the persistent
1772
+ // child-process default. Both surfaces tested so a regression on either
1773
+ // short-circuit path surfaces here.
1774
+
1775
+ describe("resolveSkipPersonal", () => {
1776
+ let originalEnv: string | undefined;
1777
+
1778
+ beforeEach(() => {
1779
+ originalEnv = process.env.HQ_SYNC_SKIP_PERSONAL;
1780
+ delete process.env.HQ_SYNC_SKIP_PERSONAL;
1781
+ });
1782
+
1783
+ afterEach(() => {
1784
+ if (originalEnv === undefined) {
1785
+ delete process.env.HQ_SYNC_SKIP_PERSONAL;
1786
+ } else {
1787
+ process.env.HQ_SYNC_SKIP_PERSONAL = originalEnv;
1788
+ }
1789
+ });
1790
+
1791
+ it("defaults to false (personal sync enabled, current behavior)", () => {
1792
+ expect(resolveSkipPersonal(false)).toBe(false);
1793
+ });
1794
+
1795
+ it("flag=true short-circuits to true regardless of env", () => {
1796
+ process.env.HQ_SYNC_SKIP_PERSONAL = "no"; // explicit "no" in env
1797
+ expect(resolveSkipPersonal(true)).toBe(true);
1798
+ });
1799
+
1800
+ it.each(["1", "true", "yes", "TRUE", "Yes"])(
1801
+ "env value '%s' (truthy) -> true",
1802
+ (val) => {
1803
+ process.env.HQ_SYNC_SKIP_PERSONAL = val;
1804
+ expect(resolveSkipPersonal(false)).toBe(true);
1805
+ },
1806
+ );
1807
+
1808
+ it.each(["0", "false", "no", "", "unset-equiv"])(
1809
+ "env value '%s' (falsy) -> false",
1810
+ (val) => {
1811
+ process.env.HQ_SYNC_SKIP_PERSONAL = val;
1812
+ expect(resolveSkipPersonal(false)).toBe(false);
1813
+ },
1814
+ );
1815
+ });
@@ -14,6 +14,10 @@
14
14
  * --company <slug-or-uid> Sync a single company (alternative to --companies)
15
15
  * --on-conflict <strategy> abort | overwrite | keep (default: abort)
16
16
  * --hq-root <path> Local HQ directory (default: $HOME/hq)
17
+ * --skip-personal Drop the personal target from the --companies
18
+ * fanout. Combined with HQ_SYNC_SKIP_PERSONAL env
19
+ * (either truthy disables personal sync). No-op
20
+ * outside --companies mode.
17
21
  * --json Ignored — ndjson on stdout is the default and
18
22
  * only output mode. Accepted for symmetry with the
19
23
  * AppBar's argv in case someone passes it.
@@ -122,21 +126,24 @@ const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
122
126
 
123
127
  /**
124
128
  * Delete-propagation policy honored by the push leg of bidirectional sync.
125
- * Default `"owned-only"` in 5.24 — the pre-5.24 behavior is preserved so
126
- * existing users see zero change in delete-propagation semantics. The
127
- * stricter-and-safer `"currency-gated"` policy ships in 5.24 as opt-in
128
- * (set `HQ_SYNC_DELETE_POLICY=currency-gated` to enable per-file ETag
129
- * verification before any local-delete propagates to S3). Default flip
130
- * to `"currency-gated"` is scheduled for 5.25 after a soak period during
131
- * which currency-gated behavior is observed on at least one active
132
- * machine.
129
+ *
130
+ * Default `"currency-gated"` in 5.25 flipped from `"owned-only"` after
131
+ * one machine (Indigo / corey) ran the 5.24 code path through real syncs
132
+ * for a week without surfacing surprise behavior. Currency-gated does a
133
+ * per-file ETag HEAD before propagating any local-delete to S3: if the
134
+ * remote object's current ETag no longer matches the journal's last-
135
+ * recorded one, the delete is refused and the next pull leg re-pulls the
136
+ * file via the standard 3-way merge path. This is strictly safer than
137
+ * `owned-only` (which propagates any local-delete the journal can prove
138
+ * came from this device) — the only delete-class that changes behavior
139
+ * is "deleted locally + modified remotely by another device", which
140
+ * previously destroyed remote work and now becomes a pull-and-conflict.
133
141
  *
134
142
  * Env override `HQ_SYNC_DELETE_POLICY=owned-only|all|currency-gated` is
135
- * also the rollback knob — once 5.25 flips the default, anyone surprised
136
- * by the new behavior flips the env back to `owned-only` without
137
- * re-deploying. `all` is the unsafe-mirror mode previously used by the
138
- * runner pre-5.20 — included only as an emergency reconcile lever, not a
139
- * recommended default.
143
+ * also the rollback knob — anyone surprised by 5.25's flip can revert
144
+ * to `owned-only` without redeploying. `all` is the unsafe-mirror mode
145
+ * previously used by the runner pre-5.20 included only as an
146
+ * emergency reconcile lever, not a recommended default.
140
147
  */
141
148
  export type DeletePropagationPolicy = "currency-gated" | "owned-only" | "all";
142
149
 
@@ -145,7 +152,33 @@ export function resolveDeletePolicy(): DeletePropagationPolicy {
145
152
  if (env === "owned-only" || env === "all" || env === "currency-gated") {
146
153
  return env;
147
154
  }
148
- return "owned-only";
155
+ return "currency-gated";
156
+ }
157
+
158
+ /**
159
+ * Resolve whether to skip the personal target in a `--companies` fanout.
160
+ *
161
+ * Two inputs combine: the `--skip-personal` CLI flag (parsed into
162
+ * `ParsedArgs.skipPersonal`) and the `HQ_SYNC_SKIP_PERSONAL` env var. Either
163
+ * being truthy skips the personal target — flag wins on conflict (CLI
164
+ * flag is the explicit-for-this-invocation knob, env is the persistent
165
+ * default usually set by the menubar in the spawned child process).
166
+ *
167
+ * Env truthy values: `1`, `true`, `yes` (case-insensitive). Anything else
168
+ * (including missing) is treated as falsy — same shape as classic
169
+ * Unix opt-in env conventions; conservative to avoid surprising opt-outs.
170
+ *
171
+ * Use case: the menubar app exposes a "Sync personal vault" toggle in
172
+ * Settings (default ON, matching the auto-provisioning UX). When the user
173
+ * flips it off, the menubar spawns `hq sync` with this env set so the
174
+ * fanout drops the personal target before walking the user's entire HQ
175
+ * tree (a sync that would otherwise scan thousands of files, including
176
+ * the new personal-vault default exclusions, just to do nothing useful).
177
+ */
178
+ export function resolveSkipPersonal(flag: boolean): boolean {
179
+ if (flag) return true;
180
+ const env = (process.env.HQ_SYNC_SKIP_PERSONAL ?? "").toLowerCase();
181
+ return env === "1" || env === "true" || env === "yes";
149
182
  }
150
183
 
151
184
  // Personal-vault scope (exclusion list + path computer) lives in
@@ -197,12 +230,19 @@ export type RunnerEvent =
197
230
  /**
198
231
  * Upload counters. Always emitted (0 when the run was pull-only) so
199
232
  * downstream consumers don't need to conditionally read the field.
200
- * Tauri's `SyncCompleteEvent` ignores extra fields today; adding them
201
- * to the Rust struct is a follow-up when the UI needs to surface push
202
- * totals.
203
233
  */
204
234
  filesUploaded: number;
205
235
  bytesUploaded: number;
236
+ /**
237
+ * Push-side counters added in 5.25. Always emitted as numbers (0
238
+ * when no push leg ran). Tauri's `SyncCompleteEvent` carries them
239
+ * as Option<u32> for back-compat with <5.25 engines that don't
240
+ * include them; structural-typing-wise, the union just adds
241
+ * properties on top of `SyncResult`.
242
+ */
243
+ filesTombstoned: number;
244
+ filesRefusedStale: number;
245
+ filesExcludedByPolicy: number;
206
246
  } & SyncResult)
207
247
  | {
208
248
  type: "all-complete";
@@ -402,6 +442,13 @@ interface ParsedArgs {
402
442
  watch: boolean;
403
443
  /** Auto-sync (Beta): ms between remote-pull passes. Required when watch=true. */
404
444
  pollRemoteMs?: number;
445
+ /**
446
+ * Drop the personal target from the fanout. Combined with the
447
+ * `HQ_SYNC_SKIP_PERSONAL` env var by `resolveSkipPersonal()` — either
448
+ * truthy disables personal sync for this run. No-op outside `--companies`
449
+ * mode (single-company runs never visit the personal target).
450
+ */
451
+ skipPersonal: boolean;
405
452
  }
406
453
 
407
454
  function parseArgs(argv: string[]): ParsedArgs | { error: string } {
@@ -412,6 +459,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
412
459
  let direction: Direction = "pull";
413
460
  let watch = false;
414
461
  let pollRemoteMs: number | undefined;
462
+ let skipPersonal = false;
415
463
 
416
464
  for (let i = 0; i < argv.length; i++) {
417
465
  const arg = argv[i];
@@ -465,6 +513,12 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
465
513
  case "--json":
466
514
  // Accepted but ignored — ndjson is the only output mode.
467
515
  break;
516
+ case "--skip-personal":
517
+ // Drop the personal target from the fanout. No-op outside
518
+ // --companies mode. Combined with HQ_SYNC_SKIP_PERSONAL env via
519
+ // resolveSkipPersonal().
520
+ skipPersonal = true;
521
+ break;
468
522
  default:
469
523
  return { error: `Unknown argument: ${arg}` };
470
524
  }
@@ -480,7 +534,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
480
534
  return { error: "--poll-remote-ms requires --watch" };
481
535
  }
482
536
 
483
- return { companies, company, onConflict, hqRoot, direction, watch, pollRemoteMs };
537
+ return { companies, company, onConflict, hqRoot, direction, watch, pollRemoteMs, skipPersonal };
484
538
  }
485
539
 
486
540
  // ---------------------------------------------------------------------------
@@ -698,7 +752,15 @@ export async function runRunner(
698
752
  plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
699
753
  }
700
754
 
701
- if (parsed.companies) {
755
+ if (parsed.companies && !resolveSkipPersonal(parsed.skipPersonal)) {
756
+ // Personal-target fanout slot. Skipped entirely when --skip-personal
757
+ // (or HQ_SYNC_SKIP_PERSONAL=1) is set — see resolveSkipPersonal doc for
758
+ // the rationale (menubar opt-out for users who only want company sync).
759
+ // When skipped, the fanout-plan event below carries only company
760
+ // memberships and no "personal" slug; downstream consumers (menubar
761
+ // workspaces row, status surfaces) should already tolerate that
762
+ // shape since pre-5.25 fanout often had it (a user with no person
763
+ // entity yet, or before the canonical-person-entity machinery landed).
702
764
  const persons = await client.entity.listByType("person");
703
765
  const pick = pickCanonicalPersonEntity(persons);
704
766
  if (pick?.bucketName) {
@@ -829,6 +891,7 @@ export async function runRunner(
829
891
  filesDeleted: 0,
830
892
  filesTombstoned: 0,
831
893
  filesRefusedStale: 0,
894
+ filesExcludedByPolicy: 0,
832
895
  conflictPaths: [],
833
896
  aborted: false,
834
897
  };
@@ -930,6 +993,17 @@ export async function runRunner(
930
993
  filesUploaded: pushResult.filesUploaded,
931
994
  bytesUploaded: pushResult.bytesUploaded,
932
995
  filesSkipped: pullResult.filesSkipped + pushResult.filesSkipped,
996
+ // Push-side counters surfaced on `complete` so the menubar's
997
+ // `SyncCompleteEvent` (which carries them as Option<u32> for
998
+ // back-compat with pre-5.25 engines) can render the new totals.
999
+ // Always emitted as numbers (0 when no push leg ran) so Rust's
1000
+ // serde decodes them as `Some(0)` rather than `None` — distinct
1001
+ // from the legacy-engine `None` and useful when the UI wants to
1002
+ // distinguish "engine ran, nothing tombstoned" from "engine
1003
+ // didn't report".
1004
+ filesTombstoned: pushResult.filesTombstoned,
1005
+ filesRefusedStale: pushResult.filesRefusedStale,
1006
+ filesExcludedByPolicy: pushResult.filesExcludedByPolicy,
933
1007
  // Sourced from the merged path list so push-side conflicts are
934
1008
  // counted too — `ShareResult` doesn't expose a numeric counter,
935
1009
  // and using `pullResult.conflicts` alone silently dropped any
@@ -967,6 +1041,13 @@ export async function runRunner(
967
1041
  filesUploaded: state.filesUploaded,
968
1042
  bytesUploaded: state.bytesUploaded,
969
1043
  filesSkipped: 0,
1044
+ // Mid-flight throw: we have no clean ShareResult to read these
1045
+ // from. Report 0 so the event shape stays stable; the partial
1046
+ // counts above already reflect what actually moved before the
1047
+ // throw.
1048
+ filesTombstoned: 0,
1049
+ filesRefusedStale: 0,
1050
+ filesExcludedByPolicy: 0,
970
1051
  conflicts: 0,
971
1052
  conflictPaths: [],
972
1053
  aborted: true,
@@ -595,9 +595,14 @@ describe("share", () => {
595
595
  vaultConfig: mockConfig,
596
596
  hqRoot: tmpDir,
597
597
  onEvent: (e) => {
598
- // Only file-level events carry `.path`. The Stage-1 `plan` event is
599
- // surfaced separately and tested in its own block.
600
- if (e.type === "plan" || e.type === "new-files") return;
598
+ // Only file-level events carry `.path`. The Stage-1 `plan` event +
599
+ // the new-files event + the personal-vault-out-of-policy summary
600
+ // event are surfaced separately and tested in their own blocks.
601
+ if (
602
+ e.type === "plan" ||
603
+ e.type === "new-files" ||
604
+ e.type === "personal-vault-out-of-policy"
605
+ ) return;
601
606
  events.push({
602
607
  type: e.type,
603
608
  path: e.path,
package/src/cli/share.ts CHANGED
@@ -21,6 +21,10 @@ import {
21
21
  normalizeEtag,
22
22
  } from "../journal.js";
23
23
  import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
24
+ import {
25
+ wrapFilterWithPersonalVaultDefaults,
26
+ type PersonalVaultExclusion,
27
+ } from "../personal-vault-exclusions.js";
24
28
  import { resolveConflict } from "./conflict.js";
25
29
  import type { ConflictStrategy } from "./conflict.js";
26
30
  import type { SyncProgressEvent } from "./sync.js";
@@ -386,6 +390,15 @@ export interface ShareResult {
386
390
  * `currency-gated`.
387
391
  */
388
392
  filesRefusedStale: number;
393
+ /**
394
+ * Number of paths blocked by `PERSONAL_VAULT_DEFAULT_EXCLUSIONS` during this
395
+ * run (push leg, personalMode=true). Includes both files that would have
396
+ * uploaded and journal entries that would have been included in the delete
397
+ * plan; deduplicated across walks. Always 0 outside personalMode. Mirrors
398
+ * the `count` field of the `personal-vault-out-of-policy` event (which is
399
+ * emitted exactly once if this is > 0).
400
+ */
401
+ filesExcludedByPolicy: number;
389
402
  /**
390
403
  * Paths (company-relative) that were detected as push conflicts. Mirrors
391
404
  * `SyncResult.conflictPaths` so push and pull surface conflicts the same
@@ -463,7 +476,34 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
463
476
  const syncRoot = options.personalMode === true
464
477
  ? hqRoot
465
478
  : path.join(hqRoot, "companies", ctx.slug);
466
- const shouldSync = createIgnoreFilter(hqRoot);
479
+
480
+ // Personal-vault default exclusions (introduced in 5.25): wrap the base
481
+ // ignore filter so paths matching `PERSONAL_VAULT_DEFAULT_EXCLUSIONS` are
482
+ // rejected before they upload OR enter the delete plan. Refuses & warns —
483
+ // an already-leaked remote object stays put as an orphan; a separate one-
484
+ // shot purge handles legacy litter.
485
+ //
486
+ // Out-of-policy hits are deduplicated in `excludedSet` so the same path
487
+ // hitting the filter from both the upload walk and the delete-plan walk
488
+ // counts once. `excludedById` powers the per-rule breakdown on the
489
+ // `personal-vault-out-of-policy` event so UI can render which class
490
+ // (secret / machine-local / scratch / …) did the work.
491
+ //
492
+ // Company-mode syncs skip this wrap entirely — company vaults have their
493
+ // own first-push protection (settings/, data/, workers/, .git/) defined
494
+ // in hq-sync's Rust util/ignore.rs, and a company may legitimately ship
495
+ // `output/` or `.env*` paths inside its `companies/{slug}/data/` folder.
496
+ const ignoreFilter = createIgnoreFilter(hqRoot);
497
+ const excludedSet = new Set<string>();
498
+ const excludedById: Record<string, number> = {};
499
+ const onExcluded = (rel: string, match: PersonalVaultExclusion) => {
500
+ if (excludedSet.has(rel)) return;
501
+ excludedSet.add(rel);
502
+ excludedById[match.id] = (excludedById[match.id] ?? 0) + 1;
503
+ };
504
+ const shouldSync = options.personalMode === true
505
+ ? wrapFilterWithPersonalVaultDefaults(ignoreFilter, syncRoot, onExcluded)
506
+ : ignoreFilter;
467
507
  const journalSlug = options.journalSlug ?? ctx.slug;
468
508
  const journal = readJournal(journalSlug);
469
509
 
@@ -599,6 +639,12 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
599
639
  // ShareResult shape stable for consumers that destructure.
600
640
  filesTombstoned,
601
641
  filesRefusedStale,
642
+ // Exclusions are computed during the upload walk which has
643
+ // already completed by the time we hit a per-file conflict-
644
+ // abort, so the count is meaningful here. No event emit on
645
+ // abort (matches the existing convention: abort short-circuits
646
+ // before the end-of-run telemetry emits).
647
+ filesExcludedByPolicy: excludedSet.size,
602
648
  conflictPaths,
603
649
  aborted: true,
604
650
  };
@@ -727,6 +773,24 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
727
773
  journal.lastSync = new Date().toISOString();
728
774
  writeJournal(journalSlug, journal);
729
775
 
776
+ // Personal-vault out-of-policy summary. Emit at most once, only when at
777
+ // least one path was excluded. Sample is capped at 10 to keep the event
778
+ // small (Set iteration order = insertion order, so samples are the first
779
+ // ten paths encountered during the walk — deterministic, not random).
780
+ if (excludedSet.size > 0) {
781
+ const samplePaths: string[] = [];
782
+ for (const p of excludedSet) {
783
+ samplePaths.push(p);
784
+ if (samplePaths.length >= 10) break;
785
+ }
786
+ emit({
787
+ type: "personal-vault-out-of-policy",
788
+ count: excludedSet.size,
789
+ samplePaths,
790
+ byId: { ...excludedById },
791
+ });
792
+ }
793
+
730
794
  return {
731
795
  filesUploaded,
732
796
  bytesUploaded,
@@ -734,6 +798,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
734
798
  filesDeleted,
735
799
  filesTombstoned,
736
800
  filesRefusedStale,
801
+ filesExcludedByPolicy: excludedSet.size,
737
802
  conflictPaths,
738
803
  aborted: false,
739
804
  };
package/src/cli/sync.ts CHANGED
@@ -120,6 +120,28 @@ export type SyncProgressEvent =
120
120
  journalEtag: string;
121
121
  remoteEtag: string;
122
122
  reason: "stale-etag" | "legacy-no-etag";
123
+ }
124
+ | {
125
+ /**
126
+ * Emitted at most ONCE per `share()` call (push leg of a sync run) when
127
+ * `personalMode === true` and the personal-vault default-exclusion list
128
+ * blocked one or more files that would otherwise have uploaded. Gives
129
+ * the UI a single summary signal — "N files quietly excluded by default
130
+ * policy" — without firing one event per excluded file (which would
131
+ * dominate the event stream on first-sync of a dirty tree).
132
+ *
133
+ * `count` is the total number of paths the exclusion filter rejected
134
+ * (deduplicated across the walk). `samplePaths` carries up to 10
135
+ * forward-slash-separated relative paths for diagnostic display. `byId`
136
+ * is a per-exclusion-rule breakdown so the UI can render which class
137
+ * of exclusion did the work (secret / machine-local / scratch / …).
138
+ *
139
+ * Not emitted when `count === 0` — silent on a clean tree.
140
+ */
141
+ type: "personal-vault-out-of-policy";
142
+ count: number;
143
+ samplePaths: string[];
144
+ byId: Record<string, number>;
123
145
  };
124
146
 
125
147
  export interface SyncOptions {
package/src/index.ts CHANGED
@@ -108,6 +108,16 @@ export {
108
108
  computePersonalVaultPaths,
109
109
  } from "./personal-vault.js";
110
110
 
111
+ // Personal-vault default-exclusions (5.25+) — second-tier deep-walk filter
112
+ // for secrets, machine-local state, scratch dirs, OS/build cruft.
113
+ export {
114
+ PERSONAL_VAULT_DEFAULT_EXCLUSIONS,
115
+ isPersonalVaultExcluded,
116
+ matchPersonalVaultExclusion,
117
+ wrapFilterWithPersonalVaultDefaults,
118
+ } from "./personal-vault-exclusions.js";
119
+ export type { PersonalVaultExclusion } from "./personal-vault-exclusions.js";
120
+
111
121
  // VaultClient SDK (VLT-7)
112
122
  export { VaultClient, pickCanonicalPersonEntity } from "./vault-client.js";
113
123
  export {