@indigoai-us/hq-cloud 6.14.1 → 6.14.3

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 (175) hide show
  1. package/dist/bin/sync-runner-company.d.ts +4 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +50 -1
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner-watch-loop.d.ts +3 -2
  6. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  7. package/dist/bin/sync-runner-watch-loop.js +21 -7
  8. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  9. package/dist/bin/sync-runner.d.ts +6 -2
  10. package/dist/bin/sync-runner.d.ts.map +1 -1
  11. package/dist/bin/sync-runner.js +162 -11
  12. package/dist/bin/sync-runner.js.map +1 -1
  13. package/dist/bin/sync-runner.test.js +155 -17
  14. package/dist/bin/sync-runner.test.js.map +1 -1
  15. package/dist/cli/accept.d.ts.map +1 -1
  16. package/dist/cli/accept.js +29 -0
  17. package/dist/cli/accept.js.map +1 -1
  18. package/dist/cli/invite.d.ts.map +1 -1
  19. package/dist/cli/invite.js +33 -0
  20. package/dist/cli/invite.js.map +1 -1
  21. package/dist/cli/promote.d.ts.map +1 -1
  22. package/dist/cli/promote.js +33 -0
  23. package/dist/cli/promote.js.map +1 -1
  24. package/dist/cli/reindex.d.ts +23 -11
  25. package/dist/cli/reindex.d.ts.map +1 -1
  26. package/dist/cli/reindex.js +85 -12
  27. package/dist/cli/reindex.js.map +1 -1
  28. package/dist/cli/reindex.test.js +95 -13
  29. package/dist/cli/reindex.test.js.map +1 -1
  30. package/dist/cli/share.js +47 -4
  31. package/dist/cli/share.js.map +1 -1
  32. package/dist/cli/share.test.js +123 -2
  33. package/dist/cli/share.test.js.map +1 -1
  34. package/dist/cli/sync-scope.test.js +84 -0
  35. package/dist/cli/sync-scope.test.js.map +1 -1
  36. package/dist/cli/sync.d.ts +44 -3
  37. package/dist/cli/sync.d.ts.map +1 -1
  38. package/dist/cli/sync.js +156 -7
  39. package/dist/cli/sync.js.map +1 -1
  40. package/dist/cli/sync.test.js +133 -0
  41. package/dist/cli/sync.test.js.map +1 -1
  42. package/dist/company-resolver.d.ts +21 -0
  43. package/dist/company-resolver.d.ts.map +1 -1
  44. package/dist/company-resolver.js +31 -6
  45. package/dist/company-resolver.js.map +1 -1
  46. package/dist/company-resolver.test.js +44 -1
  47. package/dist/company-resolver.test.js.map +1 -1
  48. package/dist/ignore.d.ts +10 -0
  49. package/dist/ignore.d.ts.map +1 -1
  50. package/dist/ignore.js +23 -2
  51. package/dist/ignore.js.map +1 -1
  52. package/dist/ignore.test.js +13 -1
  53. package/dist/ignore.test.js.map +1 -1
  54. package/dist/index.d.ts +5 -3
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +5 -2
  57. package/dist/index.js.map +1 -1
  58. package/dist/journal.d.ts +12 -0
  59. package/dist/journal.d.ts.map +1 -1
  60. package/dist/journal.js +12 -0
  61. package/dist/journal.js.map +1 -1
  62. package/dist/lib/exit-codes.d.ts +25 -0
  63. package/dist/lib/exit-codes.d.ts.map +1 -0
  64. package/dist/lib/exit-codes.js +25 -0
  65. package/dist/lib/exit-codes.js.map +1 -0
  66. package/dist/object-io.d.ts.map +1 -1
  67. package/dist/object-io.js +45 -14
  68. package/dist/object-io.js.map +1 -1
  69. package/dist/object-io.test.js +61 -1
  70. package/dist/object-io.test.js.map +1 -1
  71. package/dist/operation-lock.d.ts +36 -6
  72. package/dist/operation-lock.d.ts.map +1 -1
  73. package/dist/operation-lock.js +34 -11
  74. package/dist/operation-lock.js.map +1 -1
  75. package/dist/operation-lock.test.js +30 -0
  76. package/dist/operation-lock.test.js.map +1 -1
  77. package/dist/personal-vault.d.ts +29 -1
  78. package/dist/personal-vault.d.ts.map +1 -1
  79. package/dist/personal-vault.js +48 -2
  80. package/dist/personal-vault.js.map +1 -1
  81. package/dist/personal-vault.test.js +44 -1
  82. package/dist/personal-vault.test.js.map +1 -1
  83. package/dist/s3.d.ts +1 -0
  84. package/dist/s3.d.ts.map +1 -1
  85. package/dist/s3.js +75 -2
  86. package/dist/s3.js.map +1 -1
  87. package/dist/s3.test.js +56 -1
  88. package/dist/s3.test.js.map +1 -1
  89. package/dist/scope-shrink.d.ts +12 -0
  90. package/dist/scope-shrink.d.ts.map +1 -1
  91. package/dist/scope-shrink.js +12 -0
  92. package/dist/scope-shrink.js.map +1 -1
  93. package/dist/scope-shrink.test.js +31 -0
  94. package/dist/scope-shrink.test.js.map +1 -1
  95. package/dist/skill-telemetry.d.ts +41 -0
  96. package/dist/skill-telemetry.d.ts.map +1 -1
  97. package/dist/skill-telemetry.js +111 -7
  98. package/dist/skill-telemetry.js.map +1 -1
  99. package/dist/skill-telemetry.test.js +187 -5
  100. package/dist/skill-telemetry.test.js.map +1 -1
  101. package/dist/sync/event-sync.d.ts +3 -0
  102. package/dist/sync/event-sync.d.ts.map +1 -1
  103. package/dist/sync/event-sync.js +23 -0
  104. package/dist/sync/event-sync.js.map +1 -1
  105. package/dist/sync/pull-scope.d.ts +21 -0
  106. package/dist/sync/pull-scope.d.ts.map +1 -1
  107. package/dist/sync/pull-scope.js +46 -13
  108. package/dist/sync/pull-scope.js.map +1 -1
  109. package/dist/telemetry-events.d.ts +31 -0
  110. package/dist/telemetry-events.d.ts.map +1 -0
  111. package/dist/telemetry-events.js +140 -0
  112. package/dist/telemetry-events.js.map +1 -0
  113. package/dist/telemetry-events.test.d.ts +2 -0
  114. package/dist/telemetry-events.test.d.ts.map +1 -0
  115. package/dist/telemetry-events.test.js +73 -0
  116. package/dist/telemetry-events.test.js.map +1 -0
  117. package/dist/telemetry.js +1 -1
  118. package/dist/telemetry.js.map +1 -1
  119. package/dist/vault-client.d.ts +38 -5
  120. package/dist/vault-client.d.ts.map +1 -1
  121. package/dist/vault-client.js +69 -6
  122. package/dist/vault-client.js.map +1 -1
  123. package/dist/vault-client.test.js +31 -1
  124. package/dist/vault-client.test.js.map +1 -1
  125. package/dist/watcher.d.ts +7 -0
  126. package/dist/watcher.d.ts.map +1 -1
  127. package/dist/watcher.js +49 -1
  128. package/dist/watcher.js.map +1 -1
  129. package/dist/watcher.test.js +49 -0
  130. package/dist/watcher.test.js.map +1 -1
  131. package/package.json +1 -1
  132. package/pnpm-workspace.yaml +2 -0
  133. package/scripts/presign-transport-e2e.mjs +2 -2
  134. package/src/bin/sync-runner-company.ts +65 -1
  135. package/src/bin/sync-runner-watch-loop.ts +24 -8
  136. package/src/bin/sync-runner.test.ts +194 -16
  137. package/src/bin/sync-runner.ts +209 -11
  138. package/src/cli/accept.ts +27 -0
  139. package/src/cli/invite.ts +31 -0
  140. package/src/cli/promote.ts +31 -0
  141. package/src/cli/reindex.test.ts +113 -13
  142. package/src/cli/reindex.ts +123 -23
  143. package/src/cli/share.test.ts +139 -2
  144. package/src/cli/share.ts +48 -2
  145. package/src/cli/sync-scope.test.ts +90 -0
  146. package/src/cli/sync.test.ts +154 -0
  147. package/src/cli/sync.ts +224 -5
  148. package/src/company-resolver.test.ts +51 -0
  149. package/src/company-resolver.ts +40 -5
  150. package/src/ignore.test.ts +18 -1
  151. package/src/ignore.ts +24 -2
  152. package/src/index.ts +24 -1
  153. package/src/journal.ts +12 -0
  154. package/src/lib/exit-codes.ts +24 -0
  155. package/src/object-io.test.ts +66 -1
  156. package/src/object-io.ts +51 -14
  157. package/src/operation-lock.test.ts +33 -0
  158. package/src/operation-lock.ts +46 -11
  159. package/src/personal-vault.test.ts +57 -0
  160. package/src/personal-vault.ts +48 -2
  161. package/src/s3.test.ts +87 -0
  162. package/src/s3.ts +93 -2
  163. package/src/scope-shrink.test.ts +32 -0
  164. package/src/scope-shrink.ts +12 -0
  165. package/src/skill-telemetry.test.ts +247 -4
  166. package/src/skill-telemetry.ts +134 -6
  167. package/src/sync/event-sync.ts +37 -0
  168. package/src/sync/pull-scope.ts +62 -13
  169. package/src/telemetry-events.test.ts +88 -0
  170. package/src/telemetry-events.ts +205 -0
  171. package/src/telemetry.ts +1 -1
  172. package/src/vault-client.test.ts +39 -1
  173. package/src/vault-client.ts +115 -14
  174. package/src/watcher.test.ts +70 -0
  175. package/src/watcher.ts +68 -0
package/src/cli/sync.ts CHANGED
@@ -12,7 +12,11 @@ import type {
12
12
  VaultServiceConfig,
13
13
  SyncJournal,
14
14
  } from "../types.js";
15
- import type { SyncMode } from "../vault-client.js";
15
+ import { VaultClient, type SyncMode } from "../vault-client.js";
16
+ import {
17
+ emitCloudTelemetry,
18
+ type TelemetryClaims,
19
+ } from "../telemetry-events.js";
16
20
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
17
21
  import { createSyncProgressRecorder } from "../sync-progress.js";
18
22
  import {
@@ -333,13 +337,31 @@ export type SyncProgressEvent =
333
337
  * pushed it into the runner's `errors[]` and made every watch pass return
334
338
  * exit 2; the menubar supervisor then reported that as
335
339
  * "auto-sync watcher exited unexpectedly (code=Some(2))" on every tick —
336
- * the fleet-wide HQ-SYNC-4 flood. Surfaced here purely for visibility
340
+ * the HQ-SYNC-4 flood across user machines. Surfaced here purely for visibility
337
341
  * (`bytes` is the offending file's size) so a UI/CLI can tell the user
338
342
  * which file was too big without treating it as a failure.
339
343
  */
340
344
  type: "skip-size-limit";
341
345
  path: string;
342
346
  bytes: number;
347
+ }
348
+ | {
349
+ /**
350
+ * Emitted by the PULL leg once per remote key skipped because it is
351
+ * invalid for the vault's scope — today, a `companies/…`-prefixed key in
352
+ * a company-scoped vault (the server rejects these with
353
+ * INVALID_KEY_COMPANIES_SCOPED; see hq-pro files-acl validateObjectKey).
354
+ * Such keys are bucket poisoning from a client with a stale doubled
355
+ * local tree writing via direct S3 (incident 2026-07-11). Like
356
+ * `skip-size-limit`, this is deliberately NOT `type: "error"` — one bad
357
+ * object must never error a whole company sync (erroring gated
358
+ * heartbeat toolset refreshes fleet-wide in the incident). Surfaced for
359
+ * visibility + cloud telemetry; per policy
360
+ * hq-alert-baseline-calibration the healthy baseline is ZERO such keys,
361
+ * so any occurrence is signal.
362
+ */
363
+ type: "skip-invalid-scoped-key";
364
+ path: string;
343
365
  };
344
366
 
345
367
  export interface SyncOptions {
@@ -409,6 +431,23 @@ export interface SyncOptions {
409
431
  * failure can never silently prune the local tree.
410
432
  */
411
433
  prefixSet?: ScopePrefixInput[];
434
+ /**
435
+ * Company-relative prefixes SUBTRACTED from the effective pull scope in EVERY
436
+ * mode — the push-only set (US-006). Carries `sessions/`: session transcripts
437
+ * are pushed into the vault but never auto-pulled onto grantee laptops, even
438
+ * under `syncMode: "all"` (which otherwise pulls the whole bucket). A remote
439
+ * key downloads iff it is covered by the inclusion scope (`prefixSet`, or all
440
+ * of `all` mode) AND NOT covered by any `excludePrefixes` entry; excluded
441
+ * keys are classified `skip-out-of-scope`.
442
+ *
443
+ * CRITICAL: this set is applied ONLY to the download filter. It is
444
+ * deliberately NOT fed into the scope-shrink pass (which keeps using the
445
+ * inclusion `prefixSet` + pins), so a locally-authored (`direction:"up"`) or
446
+ * pinned on-demand-materialized session is NEVER orphaned/pruned — it is
447
+ * merely not re-pulled. The runner derives this from
448
+ * `resolvePullScope().excludePrefixes`.
449
+ */
450
+ excludePrefixes?: ScopePrefixInput[];
412
451
  /**
413
452
  * When the effective scope shrinks relative to the last pull and the shrink
414
453
  * would orphan locally-modified ("dirty") files, `sync()` aborts with a
@@ -444,6 +483,12 @@ export interface SyncOptions {
444
483
  * it from disk, so it stays pure/hermetic; undefined degrades safely.
445
484
  */
446
485
  callerSub?: string;
486
+ /**
487
+ * Optional decoded Cognito claims used only for action telemetry attribution
488
+ * (agentUid when `custom:entityType=agent`). Sync behavior never branches on
489
+ * this field.
490
+ */
491
+ telemetryClaims?: TelemetryClaims | null;
447
492
  /**
448
493
  * Skip the post-sync `reindex()` refresh (skill wrappers + personal overlay
449
494
  * mirrors + workers registry). By default, when a sync changes on-disk
@@ -547,6 +592,14 @@ interface PullRunContext {
547
592
  remoteFiles: RemoteFile[];
548
593
  syncMode: SyncMode;
549
594
  currentPrefixSet: string[];
595
+ /**
596
+ * Coalesced push-only exclusion prefixes (US-006: `sessions/`), subtracted
597
+ * from the download filter in EVERY mode. Kept SEPARATE from
598
+ * `currentPrefixSet` on purpose: the scope-shrink pass reads only
599
+ * `currentPrefixSet` (+ pins), so excluded-but-authored/pinned session files
600
+ * are never orphaned.
601
+ */
602
+ currentExcludeSet: string[];
550
603
  fileTombstones: ReadonlyMap<string, CompanyTombstone>;
551
604
  }
552
605
 
@@ -625,6 +678,7 @@ export async function reportNewFilesToNotify(
625
678
  companyUid: string,
626
679
  companySlug: string,
627
680
  files: Array<{ path: string; bytes: number; addedBy: string | null }>,
681
+ telemetryClaims?: TelemetryClaims | null,
628
682
  ): Promise<void> {
629
683
  if (files.length === 0) return;
630
684
 
@@ -639,16 +693,19 @@ export async function reportNewFilesToNotify(
639
693
  return;
640
694
  }
641
695
  const base = vaultConfig.apiUrl.replace(/\/+$/, "");
696
+ const telemetryClient = new VaultClient(vaultConfig);
697
+ const batchCount = Math.ceil(files.length / NOTIFY_FILE_ADDED_MAX_BATCH);
642
698
 
643
699
  for (let i = 0; i < files.length; i += NOTIFY_FILE_ADDED_MAX_BATCH) {
644
700
  const batch = files.slice(i, i + NOTIFY_FILE_ADDED_MAX_BATCH);
701
+ const batchIndex = Math.floor(i / NOTIFY_FILE_ADDED_MAX_BATCH) + 1;
645
702
  const controller = new AbortController();
646
703
  const timer = setTimeout(
647
704
  () => controller.abort(),
648
705
  NOTIFY_FILE_ADDED_TIMEOUT_MS,
649
706
  );
650
707
  try {
651
- await fetch(`${base}/v1/notify/file-added`, {
708
+ const response = await fetch(`${base}/v1/notify/file-added`, {
652
709
  method: "POST",
653
710
  headers: {
654
711
  Authorization: `Bearer ${token}`,
@@ -665,16 +722,44 @@ export async function reportNewFilesToNotify(
665
722
  }),
666
723
  signal: controller.signal,
667
724
  });
725
+ void emitCloudTelemetry(telemetryClient, {
726
+ eventName: "new_files_notification_reported",
727
+ source: "hq-sync",
728
+ companyUid,
729
+ properties: {
730
+ result: response.ok ? "success" : "failure",
731
+ fileCount: batch.length,
732
+ batchIndex,
733
+ batchCount,
734
+ statusCode: response.status,
735
+ },
736
+ }, { claims: telemetryClaims });
668
737
  } catch (err) {
669
738
  // Best-effort per chunk: never let notification reporting affect the sync
670
739
  // result, and a failed chunk must not abort the remaining chunks.
671
740
  logNotifyFailure(err);
741
+ void emitCloudTelemetry(telemetryClient, {
742
+ eventName: "new_files_notification_reported",
743
+ source: "hq-sync",
744
+ companyUid,
745
+ properties: {
746
+ result: "failure",
747
+ fileCount: batch.length,
748
+ batchIndex,
749
+ batchCount,
750
+ errorCode: safeErrorName(err),
751
+ },
752
+ }, { claims: telemetryClaims });
672
753
  } finally {
673
754
  clearTimeout(timer);
674
755
  }
675
756
  }
676
757
  }
677
758
 
759
+ function safeErrorName(err: unknown): string {
760
+ return err instanceof Error && err.name ? err.name : "error";
761
+ }
762
+
678
763
  /** Log a non-fatal notify failure without ever throwing out of the logger. */
679
764
  function logNotifyFailure(err: unknown): void {
680
765
  try {
@@ -724,6 +809,7 @@ async function syncWithOperationLockHeld(
724
809
  const plan = planPull(run);
725
810
 
726
811
  emitPullPlan(run.emit, plan);
812
+ reportInvalidScopedKeys(run, plan);
727
813
 
728
814
  const scopePlan = planScopeShrink(run);
729
815
  const scopeRun = executeScopeShrink(run, scopePlan);
@@ -795,6 +881,13 @@ async function buildPullContext(options: SyncOptions): Promise<PullRunContext> {
795
881
  const syncMode: SyncMode = options.syncMode ?? "all";
796
882
  const currentPrefixSet: string[] =
797
883
  syncMode === "all" ? [""] : coalescePrefixes(options.prefixSet ?? []);
884
+ // Push-only exclusions (US-006). Coalesced independently of `currentPrefixSet`
885
+ // and applied to EVERY mode's download filter — including `all` — but kept out
886
+ // of the scope-shrink inputs (see `planScopeShrink`). Empty when the caller
887
+ // passes nothing (legacy behavior: no exclusions).
888
+ const currentExcludeSet: string[] = coalescePrefixes(
889
+ options.excludePrefixes ?? [],
890
+ );
798
891
 
799
892
  const remoteFiles = await listRemoteFiles(ctx);
800
893
  const fileTombstones =
@@ -817,6 +910,7 @@ async function buildPullContext(options: SyncOptions): Promise<PullRunContext> {
817
910
  remoteFiles,
818
911
  syncMode,
819
912
  currentPrefixSet,
913
+ currentExcludeSet,
820
914
  fileTombstones,
821
915
  };
822
916
  }
@@ -831,6 +925,7 @@ function planPull(run: PullRunContext): PullPlan {
831
925
  run.options.teamSyncedSlugs ?? null,
832
926
  run.currentPrefixSet,
833
927
  run.fileTombstones,
928
+ run.currentExcludeSet,
834
929
  );
835
930
  }
836
931
 
@@ -847,6 +942,33 @@ function emitPullPlan(emit: SyncEventEmitter, plan: PullPlan): void {
847
942
  });
848
943
  }
849
944
 
945
+ /**
946
+ * Surface scope-invalid remote keys the planner skipped (see the
947
+ * `invalidScopedKeys` doc on PullPlan). One warning event per key on the
948
+ * existing sync event channel (deliberately NOT `type: "error"` — see the
949
+ * `skip-invalid-scoped-key` event doc), plus one best-effort cloud telemetry
950
+ * event on the channel sync already uses (emitCloudTelemetry). Per policy
951
+ * hq-alert-baseline-calibration the documented healthy baseline is ZERO
952
+ * INVALID_KEY_COMPANIES_SCOPED occurrences fleet-wide, so consumers can alert
953
+ * on any non-zero count with no threshold tuning.
954
+ */
955
+ function reportInvalidScopedKeys(run: PullRunContext, plan: PullPlan): void {
956
+ if (plan.invalidScopedKeys.length === 0) return;
957
+ for (const key of plan.invalidScopedKeys) {
958
+ run.emit({ type: "skip-invalid-scoped-key", path: key });
959
+ }
960
+ void emitCloudTelemetry(new VaultClient(run.vaultConfig), {
961
+ eventName: "sync_invalid_scoped_key_skipped",
962
+ source: "hq-sync",
963
+ companyUid: run.ctx.uid,
964
+ properties: {
965
+ count: plan.invalidScopedKeys.length,
966
+ errorCode: "INVALID_KEY_COMPANIES_SCOPED",
967
+ leg: "pull",
968
+ },
969
+ }, { claims: run.options.telemetryClaims });
970
+ }
971
+
850
972
  function createPullCounters(): PullCounters {
851
973
  return {
852
974
  filesDownloaded: 0,
@@ -870,6 +992,12 @@ function planScopeShrink(run: PullRunContext): ScopeShrinkPlanState {
870
992
  journal: run.journal,
871
993
  hqRoot: run.companyRoot,
872
994
  lastPrefixSet,
995
+ // INCLUSION scope only (+ pins, already folded into currentPrefixSet by the
996
+ // resolver). The push-only exclude set (US-006: `sessions/`) is DELIBERATELY
997
+ // NOT subtracted here: a session file that was authored locally
998
+ // (`direction:"up"`) or pinned + materialized on demand must never be
999
+ // orphaned just because sessions are excluded from the download filter.
1000
+ // Feeding `currentExcludeSet` in here would prune exactly those files.
873
1001
  currentPrefixSet: run.currentPrefixSet,
874
1002
  callerSub: run.options.callerSub,
875
1003
  protectUnknownAuthors: true,
@@ -1429,6 +1557,7 @@ async function emitAndReportNewFiles(
1429
1557
  run.ctx.uid,
1430
1558
  run.ctx.slug,
1431
1559
  enrichedNewFiles,
1560
+ run.options.telemetryClaims,
1432
1561
  );
1433
1562
  }
1434
1563
 
@@ -1438,6 +1567,29 @@ async function verifyPlannedJournalTombstones(
1438
1567
  ): Promise<void> {
1439
1568
  if (plan.tombstones.length === 0) return;
1440
1569
 
1570
+ // Scope-invalid journal keys (company mode, `companies/…` prefix) skip the
1571
+ // HEAD verify: the presign transport's server validator rejects such keys
1572
+ // outright (INVALID_KEY_COMPANIES_SCOPED), so a HEAD can never succeed and
1573
+ // would defer the tombstone forever — leaving the poisoned journal entry
1574
+ // (and its doubled-tree local file) re-firing on every sync. The key can
1575
+ // never legitimately exist in a company vault, so treating it as verified
1576
+ // is safe: the executor cleans the local copy + journal entry instead of
1577
+ // the push leg ever re-uploading it (tombstone-not-reupload).
1578
+ const autoVerified: string[] = [];
1579
+ const toHeadVerify: string[] = [];
1580
+ for (const key of plan.tombstones) {
1581
+ if (run.options.personalMode !== true && key.startsWith("companies/")) {
1582
+ autoVerified.push(key);
1583
+ } else {
1584
+ toHeadVerify.push(key);
1585
+ }
1586
+ }
1587
+ plan.tombstones = toHeadVerify;
1588
+ if (plan.tombstones.length === 0) {
1589
+ plan.tombstones = autoVerified;
1590
+ return;
1591
+ }
1592
+
1441
1593
  await primeObjectTransport(run.ctx, "get", plan.tombstones);
1442
1594
 
1443
1595
  const HEAD_VERIFY_CONCURRENCY = 5;
@@ -1466,7 +1618,7 @@ async function verifyPlannedJournalTombstones(
1466
1618
  if (k !== null) verified.push(k);
1467
1619
  }
1468
1620
  }
1469
- plan.tombstones = verified;
1621
+ plan.tombstones = [...autoVerified, ...verified];
1470
1622
  }
1471
1623
 
1472
1624
  function executeJournalTombstoneDeletes(
@@ -1545,7 +1697,19 @@ function finalizePullRun(
1545
1697
  scopeRun.scopeOrphansRemoved > 0;
1546
1698
  if (!run.options.skipReindex && changedOnDisk) {
1547
1699
  try {
1548
- reindex({ repoRoot: run.hqRoot, skipLock: true });
1700
+ const result = reindex({ repoRoot: run.hqRoot, skipLock: true });
1701
+ void emitCloudTelemetry(new VaultClient(run.vaultConfig), {
1702
+ eventName: "reindex_run",
1703
+ source: "hq-sync",
1704
+ companyUid: run.ctx.uid,
1705
+ properties: {
1706
+ result: result.status === 0 ? "completed" : "failed",
1707
+ statusCode: result.status,
1708
+ lockResult: "skipped",
1709
+ changedCount:
1710
+ counters.changedPaths.length + scopeRun.changedPaths.length,
1711
+ },
1712
+ }, { claims: run.options.telemetryClaims });
1549
1713
  } catch (err) {
1550
1714
  logPostSyncMaintenanceFailure("post-sync reindex", run.hqRoot, err);
1551
1715
  // best-effort: a post-sync refresh failure never fails the sync
@@ -1780,6 +1944,15 @@ interface PullPlan {
1780
1944
  * `filesToDelete` axis; the per-item executor applies the local delete.
1781
1945
  */
1782
1946
  filesToTombstoneDelete: number;
1947
+ /**
1948
+ * Remote keys skipped because they are invalid for the vault's scope
1949
+ * (company mode only: literal `companies/…` keys — bucket poisoning from a
1950
+ * doubled-tree client via direct S3). Counted in `filesExcludedByPolicy`;
1951
+ * carried by key so the caller can log one warning per key and emit
1952
+ * telemetry. Healthy baseline is an empty array (policy
1953
+ * hq-alert-baseline-calibration: zero expected, any occurrence is signal).
1954
+ */
1955
+ invalidScopedKeys: string[];
1783
1956
  }
1784
1957
 
1785
1958
  /**
@@ -1847,8 +2020,18 @@ function computePullPlan(
1847
2020
  // preserves the legacy behavior bit-for-bit. (Named `fileTombstones` to avoid
1848
2021
  // shadowing the local `tombstones` string[] — the journal-vs-LIST delete set.)
1849
2022
  fileTombstones: ReadonlyMap<string, CompanyTombstone> = new Map(),
2023
+ // Push-only exclusion prefixes (US-006: `sessions/`). Keys covered by any of
2024
+ // these are classified `skip-out-of-scope` in EVERY mode — including `all` —
2025
+ // AFTER passing the inclusion check. Empty (the default) preserves legacy
2026
+ // behavior bit-for-bit. Applied ONLY here (the download filter), never to
2027
+ // scope-shrink, so an authored/pinned excluded key is not re-pulled yet also
2028
+ // never orphaned.
2029
+ excludePrefixes: readonly ScopePrefixInput[] = [],
1850
2030
  ): PullPlan {
1851
2031
  const items: PullPlanItem[] = [];
2032
+ // Company-mode remote keys invalid for the vault's scope (`companies/…` in a
2033
+ // company bucket) — collected so the caller can warn + emit telemetry.
2034
+ const invalidScopedKeys: string[] = [];
1852
2035
  // Clean/current/non-divergent files missing locally — collected here, then
1853
2036
  // resolved post-loop by the bulk-asymmetry decision (tombstone vs. restore).
1854
2037
  const intentionalDeleteCandidates: Array<{
@@ -1874,6 +2057,23 @@ function computePullPlan(
1874
2057
  continue;
1875
2058
  }
1876
2059
 
2060
+ // Scope-invalid key filter (incident 2026-07-11). A company vault bucket
2061
+ // is already company-scoped, so a literal `companies/…` key can only be
2062
+ // bucket poisoning — a client with a stale doubled local tree
2063
+ // (companies/{slug}/companies/{slug}/…) pushing via the direct-S3 STS
2064
+ // transport, which bypasses the server validator. The presign download
2065
+ // API rejects these keys (INVALID_KEY_COMPANIES_SCOPED), so attempting to
2066
+ // download them errors the WHOLE company sync. Skip them here — one bad
2067
+ // object must never take down a company sync — and record them on the
2068
+ // plan so the caller can warn + emit telemetry (baseline is zero; any
2069
+ // occurrence is signal). Mirrors the personalMode/malformed-key filters
2070
+ // above. Upload-side counterpart: validateVaultUploadKey in s3.ts.
2071
+ if (!personalMode && remoteFile.key.startsWith("companies/")) {
2072
+ invalidScopedKeys.push(remoteFile.key);
2073
+ items.push({ action: "skip-excluded-policy", remoteFile, localPath });
2074
+ continue;
2075
+ }
2076
+
1877
2077
  if (
1878
2078
  personalMode &&
1879
2079
  remoteFile.key.startsWith("companies/") &&
@@ -1917,6 +2117,20 @@ function computePullPlan(
1917
2117
  continue;
1918
2118
  }
1919
2119
 
2120
+ // Push-only exclusion filter (US-006). A key that PASSED the inclusion check
2121
+ // above is STILL not downloaded if it falls under a push-only exclude prefix
2122
+ // (`sessions/`). This is what makes sessions push-only in EVERY mode — it
2123
+ // subtracts from `all` (whose `prefixSet` is `[""]`, covering everything)
2124
+ // and from an owner's role-wide scope alike. Empty `excludePrefixes` (the
2125
+ // default) makes this a no-op. Deliberately AFTER inclusion and reported on
2126
+ // the same `filesOutOfScope` axis. The matching local file (if a session was
2127
+ // previously pulled, authored locally, or pinned+materialized) is NOT pruned
2128
+ // here or by scope-shrink — see `planScopeShrink`, which never sees this set.
2129
+ if (isCoveredByAny(remoteFile.key, excludePrefixes)) {
2130
+ items.push({ action: "skip-out-of-scope", remoteFile, localPath });
2131
+ continue;
2132
+ }
2133
+
1920
2134
  // LIST gives us no kind signal for the remote object — we don't
1921
2135
  // know whether this key is a regular file or a symlink record
1922
2136
  // until we either HEAD it (expensive — N extra calls per pull) or
@@ -2420,6 +2634,7 @@ function computePullPlan(
2420
2634
  filesOutOfScope,
2421
2635
  filesToTombstoneDelete,
2422
2636
  tombstones,
2637
+ invalidScopedKeys,
2423
2638
  };
2424
2639
  }
2425
2640
 
@@ -2502,5 +2717,9 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
2502
2717
  console.warn(
2503
2718
  ` ! ${event.path} skipped — ${mb} MB exceeds the 50 MB sync size limit`,
2504
2719
  );
2720
+ } else if (event.type === "skip-invalid-scoped-key") {
2721
+ console.warn(
2722
+ ` ! ${event.path} skipped — invalid 'companies/'-prefixed key in a company-scoped vault (bucket poisoning; INVALID_KEY_COMPANIES_SCOPED)`,
2723
+ );
2505
2724
  }
2506
2725
  }
@@ -11,6 +11,7 @@ import * as path from "path";
11
11
  import {
12
12
  buildRepoCompanyMap,
13
13
  resolveCompanyForCwd,
14
+ resolveCompanyForSkill,
14
15
  } from "./company-resolver.js";
15
16
 
16
17
  const INDIGO = "cmp_01INDIGO";
@@ -132,5 +133,55 @@ describe("buildRepoCompanyMap + resolveCompanyForCwd", () => {
132
133
  hqRoot = setupHqRoot(":\n not: [valid: yaml");
133
134
  const map = await buildRepoCompanyMap(hqRoot);
134
135
  expect(map.entries).toHaveLength(0);
136
+ expect(map.bySlug.size).toBe(0);
137
+ });
138
+ });
139
+
140
+ describe("resolveCompanyForSkill", () => {
141
+ let hqRoot: string;
142
+ afterEach(() => {
143
+ if (hqRoot) fs.rmSync(hqRoot, { recursive: true, force: true });
144
+ });
145
+
146
+ it("attributes a company-namespaced skill to its company, regardless of cwd", async () => {
147
+ hqRoot = setupHqRoot();
148
+ const map = await buildRepoCompanyMap(hqRoot);
149
+ // The slug map is populated from the manifest keys with a cloud_uid.
150
+ expect(resolveCompanyForSkill("indigo:signals", map)).toBe(INDIGO);
151
+ expect(resolveCompanyForSkill("hpo:some-skill", map)).toBe(HPO);
152
+ // Multi-segment skill name: only the leading slug is the company.
153
+ expect(resolveCompanyForSkill("indigo:hq-dev-thread", map)).toBe(INDIGO);
154
+ });
155
+
156
+ it("returns undefined for a core (un-namespaced) skill", async () => {
157
+ hqRoot = setupHqRoot();
158
+ const map = await buildRepoCompanyMap(hqRoot);
159
+ expect(resolveCompanyForSkill("startwork", map)).toBeUndefined();
160
+ expect(resolveCompanyForSkill("review", map)).toBeUndefined();
161
+ });
162
+
163
+ it("returns undefined for a non-company namespace (personal/pack) and a leading colon", async () => {
164
+ hqRoot = setupHqRoot();
165
+ const map = await buildRepoCompanyMap(hqRoot);
166
+ // `personal` has no cloud_uid → not in the slug map.
167
+ expect(resolveCompanyForSkill("personal:startwork", map)).toBeUndefined();
168
+ expect(
169
+ resolveCompanyForSkill("hq-pack-engineering:review", map),
170
+ ).toBeUndefined();
171
+ expect(resolveCompanyForSkill(":oops", map)).toBeUndefined();
172
+ });
173
+
174
+ it("returns undefined for a slug whose company is not cloud-backed", async () => {
175
+ hqRoot = setupHqRoot();
176
+ const map = await buildRepoCompanyMap(hqRoot);
177
+ // `liverecover` is in the manifest but carries no cloud_uid.
178
+ expect(resolveCompanyForSkill("liverecover:foo", map)).toBeUndefined();
179
+ });
180
+
181
+ it("handles a missing/undefined skill safely", async () => {
182
+ hqRoot = setupHqRoot();
183
+ const map = await buildRepoCompanyMap(hqRoot);
184
+ expect(resolveCompanyForSkill(undefined, map)).toBeUndefined();
185
+ expect(resolveCompanyForSkill("", map)).toBeUndefined();
135
186
  });
136
187
  });
@@ -54,6 +54,14 @@ import yaml from "js-yaml";
54
54
  export interface RepoCompanyMap {
55
55
  /** Absolute, normalized repo root → owning company `cmp_*` uid. */
56
56
  entries: Array<{ repoRoot: string; companyUid: string }>;
57
+ /**
58
+ * Company SLUG → owning company `cmp_*` uid, from the manifest keys. Backs
59
+ * skill-based attribution: a company skill is invoked as `<slug>:<skill>`
60
+ * (mirroring `companies/<slug>/skills/`), so its usage belongs to the company
61
+ * named by that slug regardless of the cwd it ran in. Manifest keys are unique,
62
+ * so a slug resolves to exactly one cloud-backed company here.
63
+ */
64
+ bySlug: Map<string, string>;
57
65
  }
58
66
 
59
67
  interface ManifestCompany {
@@ -87,23 +95,28 @@ export async function buildRepoCompanyMap(hqRoot: string): Promise<RepoCompanyMa
87
95
  const raw = await fs.readFile(manifestPath, "utf-8");
88
96
  const parsed = yaml.load(raw);
89
97
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
90
- return { entries: [] };
98
+ return { entries: [], bySlug: new Map() };
91
99
  }
92
100
  doc = parsed as ManifestShape;
93
101
  } catch {
94
102
  // Missing / unreadable / unparseable — no attribution this run.
95
- return { entries: [] };
103
+ return { entries: [], bySlug: new Map() };
96
104
  }
97
105
 
98
106
  const companies = doc.companies;
99
- if (!companies || typeof companies !== "object") return { entries: [] };
107
+ if (!companies || typeof companies !== "object") {
108
+ return { entries: [], bySlug: new Map() };
109
+ }
100
110
 
101
111
  const entries: Array<{ repoRoot: string; companyUid: string }> = [];
102
- for (const company of Object.values(companies)) {
112
+ const bySlug = new Map<string, string>();
113
+ for (const [slug, company] of Object.entries(companies)) {
103
114
  if (!company || typeof company !== "object") continue;
104
115
  const cloudUid = company.cloud_uid;
105
116
  // Only cloud-backed companies (with a cmp_* uid) attribute events.
106
117
  if (typeof cloudUid !== "string" || !cloudUid.startsWith("cmp_")) continue;
118
+ // Slug → uid for skill-based attribution (a `<slug>:<skill>` invocation).
119
+ if (typeof slug === "string" && slug.length > 0) bySlug.set(slug, cloudUid);
107
120
  const repos = company.repos;
108
121
  if (!Array.isArray(repos)) continue;
109
122
  for (const repo of repos) {
@@ -118,7 +131,29 @@ export async function buildRepoCompanyMap(hqRoot: string): Promise<RepoCompanyMa
118
131
  // Longest repoRoot first so the first containment match is the most specific
119
132
  // (handles nested repo layouts where one repo path prefixes another).
120
133
  entries.sort((a, b) => b.repoRoot.length - a.repoRoot.length);
121
- return { entries };
134
+ return { entries, bySlug };
135
+ }
136
+
137
+ /**
138
+ * Resolve a skill INVOCATION to its owning company's `cmp_*` uid, or `undefined`
139
+ * when the skill is not a cloud-backed company's skill.
140
+ *
141
+ * A company skill is invoked as `<slug>:<skill>` (the namespaced form of
142
+ * `companies/<slug>/skills/<skill>`); the leading `<slug>` names the owning
143
+ * company. We look that slug up in the manifest's slug→uid map, so a company
144
+ * skill attributes to ITS company no matter which cwd it ran in — this is what
145
+ * surfaces company-skill usage in the company console. A core / `personal:` /
146
+ * `hq-pack-*:` / `anthropic-skills:` skill has no matching manifest company, so
147
+ * it resolves to `undefined` (the cwd resolver remains the fallback).
148
+ */
149
+ export function resolveCompanyForSkill(
150
+ skill: string | undefined,
151
+ map: RepoCompanyMap,
152
+ ): string | undefined {
153
+ if (typeof skill !== "string") return undefined;
154
+ const idx = skill.indexOf(":");
155
+ if (idx <= 0) return undefined; // un-namespaced (core) skill
156
+ return map.bySlug.get(skill.slice(0, idx));
122
157
  }
123
158
 
124
159
  /**
@@ -29,6 +29,8 @@ describe("isExpectedIgnore", () => {
29
29
  expect(isExpectedIgnore("x/.env")).toBe(true);
30
30
  expect(isExpectedIgnore("a/b/.hq-sync.pid")).toBe(true);
31
31
  expect(isExpectedIgnore("y/foo.pyc")).toBe(true);
32
+ // Cloud-only skill hero images (US-014) are an expected by-design exclusion.
33
+ expect(isExpectedIgnore(".claude/skills/foo/.skill-media/hero.png")).toBe(true);
32
34
  });
33
35
 
34
36
  it("keeps nested repos/workspace and ordinary content noteworthy", () => {
@@ -138,7 +140,7 @@ describe("createIgnoreFilter", () => {
138
140
 
139
141
  it("v12–v14 layout (no core/core.yaml): root core.yaml IS the manifest and MUST sync", () => {
140
142
  // Regression for DEV-1729 / the ~98 "no core.yaml in vault, version
141
- // undetectable" fleet users. On v12–v14 roots there is NO `core/core.yaml`
143
+ // undetectable" sync users. On v12–v14 roots there is NO `core/core.yaml`
142
144
  // — the ROOT `core.yaml` is the scaffold/version manifest. The old
143
145
  // unconditional `/core.yaml` exclusion silently dropped it (while
144
146
  // computePersonalVaultPaths still enumerated it), so the vault never
@@ -181,6 +183,21 @@ describe("createIgnoreFilter", () => {
181
183
  expect(shouldSync(path.join(hqRoot, ".claude/skills/foo/SKILL.md"))).toBe(true);
182
184
  });
183
185
 
186
+ it("permissive mode: .skill-media/ (cloud-only skill images) is ignored (US-014)", () => {
187
+ const shouldSync = createIgnoreFilter(hqRoot);
188
+ // Skill hero images are cloud-only — excluded from push AND pull at any depth.
189
+ expect(
190
+ shouldSync(path.join(hqRoot, ".claude/skills/lead-qualifier/.skill-media/hero.png")),
191
+ ).toBe(false);
192
+ expect(
193
+ shouldSync(path.join(hqRoot, "companies/indigo/skills/foo/.skill-media/cover.jpg")),
194
+ ).toBe(false);
195
+ expect(shouldSync(path.join(hqRoot, "skills/bar/.skill-media"), true)).toBe(false);
196
+ // The skill's SKILL.md and other content still sync — the rule is scoped to
197
+ // the media dir only, never the skill.
198
+ expect(shouldSync(path.join(hqRoot, ".claude/skills/lead-qualifier/SKILL.md"))).toBe(true);
199
+ });
200
+
184
201
  it("permissive mode: .hq-* internal state is ignored, .hqignore family still sync", () => {
185
202
  const shouldSync = createIgnoreFilter(hqRoot);
186
203
  // Internal state files that must never round-trip through the bucket.
package/src/ignore.ts CHANGED
@@ -18,6 +18,16 @@
18
18
  * three exclusion layers still subtract on top — so even allowlisted
19
19
  * subtrees won't push `node_modules/` or `.env`. Privacy-by-default for
20
20
  * HQ trees that contain mixed personal + shareable data.
21
+ *
22
+ * SESSIONS ARE PUSH-ONLY, NOT PUSH-IGNORED (US-006). The company-vault
23
+ * `sessions/{personUid}/...` prefix (session transcripts) is intentionally NOT
24
+ * added to `DEFAULT_IGNORES`: push-only means it MUST still upload on the push
25
+ * walk. Its never-AUTO-pull semantics are enforced ONE layer over, in the pull
26
+ * scope resolver — `resolvePullScope().excludePrefixes` (src/sync/pull-scope.ts)
27
+ * subtracts `sessions/` from the effective pull scope in EVERY mode (including
28
+ * `all`), and `computePullPlan` classifies those keys `skip-out-of-scope`.
29
+ * Ignoring `sessions/` HERE would wrongly block the upload too, so the split is
30
+ * deliberate: this module governs the push walk; pull-scope governs never-pull.
21
31
  */
22
32
 
23
33
  import * as fs from "fs";
@@ -93,7 +103,7 @@ export const DEFAULT_IGNORES = [
93
103
  // `core.yaml` is a stale per-machine duplicate that must not round-trip; on
94
104
  // v12–v14 roots there is NO `core/core.yaml` and the ROOT `core.yaml` IS the
95
105
  // scaffold/version manifest, which MUST sync (else the vault has no version
96
- // marker and the file is undetectable fleet-side).
106
+ // marker and the file is undetectable across user installs).
97
107
  // hq modules manifest — local module-resolution state, never synced.
98
108
  "modules/modules.yaml",
99
109
  // per-company identity file — written locally on first sync, never round-tripped.
@@ -130,6 +140,16 @@ export const DEFAULT_IGNORES = [
130
140
  "**/.claude/settings.json",
131
141
  "**/.claude/settings.local.json",
132
142
 
143
+ // Skill hero images (skills-first-class US-014) — cloud-only artifacts stored
144
+ // under a skill's `.skill-media/` dir (e.g. `skills/<name>/.skill-media/hero.png`).
145
+ // They live in the vault/DB and are surfaced by the console, but must NEVER
146
+ // round-trip through the file sync onto agent machines: agents run the skill
147
+ // from SKILL.md and have no use for the image, and pulling it would litter
148
+ // every skill dir with binary blobs. Anchored `**/` so it matches at any depth
149
+ // (hqRoot skills, company skills, embedded knowledge repos) — excludes them
150
+ // from BOTH push and pull. Mirrors the `**/.claude/worktrees/` anchor shape.
151
+ "**/.skill-media/",
152
+
133
153
  // HQ repos directory (managed separately, not synced). Root-anchored so
134
154
  // only hqRoot/repos/ is excluded; bare `repos/` would match any nested
135
155
  // directory named repos (for example companies/<co>/knowledge/repos/),
@@ -247,6 +267,8 @@ export const EXPECTED_IGNORE_SEGMENTS = new Set([
247
267
  ".tmp",
248
268
  ".hq",
249
269
  ".claude",
270
+ // Cloud-only skill hero images (US-014) — never synced onto agent machines.
271
+ ".skill-media",
250
272
  ]);
251
273
 
252
274
  /**
@@ -310,7 +332,7 @@ export function createIgnoreFilter(
310
332
  // stale/duplicate per-machine marker, never the authoritative manifest.
311
333
  // - v12–v14 (core/core.yaml absent): do NOT exclude root `core.yaml` — it
312
334
  // IS the manifest and must round-trip, else the file is undetectable
313
- // fleet-side (the ~98 "no core.yaml in vault" users were all old-layout).
335
+ // across user installs (the ~98 "no core.yaml in vault" users were all old-layout).
314
336
  // Root-anchored (`/core.yaml`) so this never affects `core/core.yaml`.
315
337
  if (fs.existsSync(path.join(hqRoot, "core", "core.yaml"))) {
316
338
  ig.add("/core.yaml");