@indigoai-us/hq-cloud 6.14.0 → 6.14.2

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 (167) 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 +39 -10
  27. package/dist/cli/reindex.js.map +1 -1
  28. package/dist/cli/reindex.test.js +40 -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 +4 -2
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +4 -1
  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/s3.d.ts +1 -0
  78. package/dist/s3.d.ts.map +1 -1
  79. package/dist/s3.js +75 -2
  80. package/dist/s3.js.map +1 -1
  81. package/dist/s3.test.js +56 -1
  82. package/dist/s3.test.js.map +1 -1
  83. package/dist/scope-shrink.d.ts +12 -0
  84. package/dist/scope-shrink.d.ts.map +1 -1
  85. package/dist/scope-shrink.js +12 -0
  86. package/dist/scope-shrink.js.map +1 -1
  87. package/dist/scope-shrink.test.js +31 -0
  88. package/dist/scope-shrink.test.js.map +1 -1
  89. package/dist/skill-telemetry.d.ts +41 -0
  90. package/dist/skill-telemetry.d.ts.map +1 -1
  91. package/dist/skill-telemetry.js +111 -7
  92. package/dist/skill-telemetry.js.map +1 -1
  93. package/dist/skill-telemetry.test.js +187 -5
  94. package/dist/skill-telemetry.test.js.map +1 -1
  95. package/dist/sync/event-sync.d.ts +3 -0
  96. package/dist/sync/event-sync.d.ts.map +1 -1
  97. package/dist/sync/event-sync.js +23 -0
  98. package/dist/sync/event-sync.js.map +1 -1
  99. package/dist/sync/pull-scope.d.ts +21 -0
  100. package/dist/sync/pull-scope.d.ts.map +1 -1
  101. package/dist/sync/pull-scope.js +46 -13
  102. package/dist/sync/pull-scope.js.map +1 -1
  103. package/dist/telemetry-events.d.ts +31 -0
  104. package/dist/telemetry-events.d.ts.map +1 -0
  105. package/dist/telemetry-events.js +140 -0
  106. package/dist/telemetry-events.js.map +1 -0
  107. package/dist/telemetry-events.test.d.ts +2 -0
  108. package/dist/telemetry-events.test.d.ts.map +1 -0
  109. package/dist/telemetry-events.test.js +73 -0
  110. package/dist/telemetry-events.test.js.map +1 -0
  111. package/dist/telemetry.js +1 -1
  112. package/dist/telemetry.js.map +1 -1
  113. package/dist/vault-client.d.ts +38 -5
  114. package/dist/vault-client.d.ts.map +1 -1
  115. package/dist/vault-client.js +69 -6
  116. package/dist/vault-client.js.map +1 -1
  117. package/dist/vault-client.test.js +31 -1
  118. package/dist/vault-client.test.js.map +1 -1
  119. package/dist/watcher.d.ts +7 -0
  120. package/dist/watcher.d.ts.map +1 -1
  121. package/dist/watcher.js +53 -1
  122. package/dist/watcher.js.map +1 -1
  123. package/dist/watcher.test.js +57 -0
  124. package/dist/watcher.test.js.map +1 -1
  125. package/package.json +1 -1
  126. package/pnpm-workspace.yaml +2 -0
  127. package/scripts/presign-transport-e2e.mjs +2 -2
  128. package/src/bin/sync-runner-company.ts +65 -1
  129. package/src/bin/sync-runner-watch-loop.ts +24 -8
  130. package/src/bin/sync-runner.test.ts +194 -16
  131. package/src/bin/sync-runner.ts +209 -11
  132. package/src/cli/accept.ts +27 -0
  133. package/src/cli/invite.ts +31 -0
  134. package/src/cli/promote.ts +31 -0
  135. package/src/cli/reindex.test.ts +44 -13
  136. package/src/cli/reindex.ts +74 -21
  137. package/src/cli/share.test.ts +139 -2
  138. package/src/cli/share.ts +48 -2
  139. package/src/cli/sync-scope.test.ts +90 -0
  140. package/src/cli/sync.test.ts +154 -0
  141. package/src/cli/sync.ts +224 -5
  142. package/src/company-resolver.test.ts +51 -0
  143. package/src/company-resolver.ts +40 -5
  144. package/src/ignore.test.ts +18 -1
  145. package/src/ignore.ts +24 -2
  146. package/src/index.ts +22 -1
  147. package/src/journal.ts +12 -0
  148. package/src/lib/exit-codes.ts +24 -0
  149. package/src/object-io.test.ts +66 -1
  150. package/src/object-io.ts +51 -14
  151. package/src/operation-lock.test.ts +33 -0
  152. package/src/operation-lock.ts +46 -11
  153. package/src/s3.test.ts +87 -0
  154. package/src/s3.ts +93 -2
  155. package/src/scope-shrink.test.ts +32 -0
  156. package/src/scope-shrink.ts +12 -0
  157. package/src/skill-telemetry.test.ts +247 -4
  158. package/src/skill-telemetry.ts +134 -6
  159. package/src/sync/event-sync.ts +37 -0
  160. package/src/sync/pull-scope.ts +62 -13
  161. package/src/telemetry-events.test.ts +88 -0
  162. package/src/telemetry-events.ts +205 -0
  163. package/src/telemetry.ts +1 -1
  164. package/src/vault-client.test.ts +39 -1
  165. package/src/vault-client.ts +115 -14
  166. package/src/watcher.test.ts +82 -0
  167. package/src/watcher.ts +69 -0
@@ -55,6 +55,7 @@
55
55
  * swallowed so telemetry never aborts or delays a sync.
56
56
  */
57
57
 
58
+ import { createHash } from "node:crypto";
58
59
  import { promises as fs } from "node:fs";
59
60
  import * as os from "node:os";
60
61
  import * as path from "node:path";
@@ -62,6 +63,7 @@ import * as path from "node:path";
62
63
  import {
63
64
  buildRepoCompanyMap,
64
65
  resolveCompanyForCwd,
66
+ resolveCompanyForSkill,
65
67
  type RepoCompanyMap,
66
68
  } from "./company-resolver.js";
67
69
  import type {
@@ -100,6 +102,16 @@ export interface CollectSkillTelemetryOptions {
100
102
  cursorPath?: string;
101
103
  /** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */
102
104
  menubarPath?: string;
105
+ /**
106
+ * Override skillVersion resolution (skills-first-class US-015). Given a skill
107
+ * name, return its content-hash version marker (`sha256:<hex>`) or undefined.
108
+ * Defaults to hashing `<hqRoot>/.claude/skills/<skill>/SKILL.md` via
109
+ * {@link computeSkillVersion}. Injected in tests to decouple emission from the
110
+ * on-disk skills tree.
111
+ */
112
+ resolveSkillVersion?: (
113
+ skill: string,
114
+ ) => Promise<string | undefined> | string | undefined;
103
115
  /** Diagnostic sink. No-op by default. */
104
116
  log?: (msg: string) => void;
105
117
  }
@@ -123,6 +135,15 @@ export interface SkillEvent {
123
135
  uuid?: string;
124
136
  cwd?: string;
125
137
  hasArgs: boolean;
138
+ /**
139
+ * Content-version marker (skills-first-class US-015) — the sha256 of the
140
+ * skill's SKILL.md, `sha256:<hex>`. The version half of the analytics-v2 usage
141
+ * join key (`skill_uid` + `skillVersion`), so a run maps to the version that
142
+ * produced it. Resolved + stamped at capture (see `computeSkillVersion` +
143
+ * `collectAndSendSkillTelemetry`); OPTIONAL — absent when the SKILL.md can't
144
+ * be located, and additive on the wire (old clients/rows simply omit it).
145
+ */
146
+ skillVersion?: string;
126
147
  }
127
148
 
128
149
  // Privacy switch — keep false (see file header). When false, raw argument text
@@ -348,6 +369,25 @@ export function extractCodexSkillEvents(
348
369
  const CODEX_SKILL_FILE =
349
370
  /(?:^|\/)skills\/(?:[^/\s'"]+\/)*?([^/\s'"]+)\/SKILL\.md\b/;
350
371
 
372
+ /** A company skill read via its REAL path `companies/<slug>/skills/<name>/
373
+ * SKILL.md`. Codex loads a skill by *reading* its file, and a company skill's
374
+ * real file lives under `companies/<slug>/skills/` — so, unlike the namespaced
375
+ * `.claude/skills/<slug>:<name>/` symlink (whose captured directory already
376
+ * carries the `<slug>:` prefix), this path names the owning company in a
377
+ * SEPARATE `<slug>` segment that {@link CODEX_SKILL_FILE} discards, leaving a
378
+ * bare `<name>` that no prefix-based attribution can place (the company view
379
+ * then misses the run). Group 1 = `<slug>`, group 2 = `<name>`: we rebuild the
380
+ * canonical `<slug>:<name>` invocation token from them so the event flows
381
+ * through the SAME attribution as every other runtime — the client
382
+ * `resolveCompanyForSkill` and the server rollup both key off that `<slug>:`
383
+ * prefix — and shares one `skill_uid` with the Claude/typed forms. The slug
384
+ * segment excludes `:` (a real company slug has none); skill discovery is one
385
+ * level deep, so `<name>` is a single segment. The leading boundary admits a
386
+ * RELATIVE `companies/…` path too (preceded by whitespace/quote/start, not just
387
+ * `/`), since Codex often reads it relative to the run cwd. */
388
+ const CODEX_COMPANY_SKILL_FILE =
389
+ /(?:^|[\s/'"])companies\/([^/\s'":]+)\/skills\/([^/\s'"]+)\/SKILL\.md\b/;
390
+
351
391
  /** Pull the shell command string out of a Codex `exec_command_end` `command`,
352
392
  * which is `["/bin/zsh", "-lc", "<cmd>"]` (array) on the runtimes we see, but
353
393
  * tolerate a bare string too. */
@@ -500,7 +540,13 @@ export function extractCodexSkillToolEvents(
500
540
  if (cls === "nonread") return [];
501
541
  if (cls === "unknown" && !CODEX_READ_VERB.test(cmd)) return [];
502
542
 
503
- const skill = m[1].trim();
543
+ // Prefer the canonical `<slug>:<name>` token when the read is of a company
544
+ // skill's real file (`companies/<slug>/skills/<name>/SKILL.md`) — the slug is
545
+ // in the path but dropped by CODEX_SKILL_FILE, so reconstruct it here to keep
546
+ // the run attributable. Otherwise keep the last-segment name (core/personal/
547
+ // namespaced skills already carry their prefix in the captured directory).
548
+ const co = CODEX_COMPANY_SKILL_FILE.exec(cmd);
549
+ const skill = co ? `${co[1]}:${co[2]}` : m[1].trim();
504
550
  if (!skill) return [];
505
551
 
506
552
  const sessionId = ctx.sessionId;
@@ -541,6 +587,55 @@ export function extractCodexSkillToolEvents(
541
587
  ];
542
588
  }
543
589
 
590
+ /**
591
+ * Resolve a skill's content-version marker ("skillVersion", skills-first-class
592
+ * US-015) — the sha256 of its SKILL.md as `sha256:<hex>` (the canonical
593
+ * content-hash shape used across hq-cloud, e.g. `watcher.ts`). This is the
594
+ * version half of the analytics-v2 usage join key (`skill_uid` + `skillVersion`),
595
+ * so a captured run maps to the exact version that produced it.
596
+ *
597
+ * Resolution is best-effort against `<hqRoot>/.claude/skills/<skill>/SKILL.md` —
598
+ * the canonical single-source skills location, which holds an entry (a real dir
599
+ * or a symlink) for every skill by its FULL invocation name incl. namespace
600
+ * (`indigo:hello-world`, `personal:worktree`, `deploy`, …). A `.agents/skills/`
601
+ * fallback covers the Codex bridge layout. `readFile` follows symlinks, so a
602
+ * company/personal skill surfaced into `.claude/skills/` resolves transparently.
603
+ * Returns undefined when `hqRoot` is absent or the file can't be located/read,
604
+ * so the wire field is simply OMITTED — additive + backwards-compatible, exactly
605
+ * like the optional `companyUid` attribution.
606
+ *
607
+ * "At invocation time" is approximated by the SKILL.md's content AT CAPTURE TIME:
608
+ * the collector scans historical session logs during sync, so the file's current
609
+ * bytes are the closest available proxy for the version that ran. A skill edited
610
+ * between run and sync can differ — acceptable for a usage-rollup join key (the
611
+ * overwhelming majority of runs sync before any edit to the skill).
612
+ */
613
+ export async function computeSkillVersion(
614
+ hqRoot: string | undefined,
615
+ skill: string,
616
+ ): Promise<string | undefined> {
617
+ if (!hqRoot || !skill) return undefined;
618
+ // A skill token maps to exactly one directory name here; reject anything that
619
+ // could escape the skills dir (path traversal / separators). Namespaced names
620
+ // use `:` (not a separator) and pass.
621
+ if (skill.includes("/") || skill.includes("\\") || skill.includes("..")) {
622
+ return undefined;
623
+ }
624
+ const candidates = [
625
+ path.join(hqRoot, ".claude", "skills", skill, "SKILL.md"),
626
+ path.join(hqRoot, ".agents", "skills", skill, "SKILL.md"),
627
+ ];
628
+ for (const candidate of candidates) {
629
+ try {
630
+ const bytes = await fs.readFile(candidate);
631
+ return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
632
+ } catch {
633
+ // Not at this candidate — try the next.
634
+ }
635
+ }
636
+ return undefined;
637
+ }
638
+
544
639
  /** Shape the event for the wire. Drops raw args unless explicitly enabled.
545
640
  *
546
641
  * `companyUid` (US-002): the caller resolves the event's `cwd` → owning company
@@ -548,7 +643,11 @@ export function extractCodexSkillToolEvents(
548
643
  * server's KEEP allowlist (`apps/hq-pro/src/vault-service/handlers/
549
644
  * skill-invocations.ts`). When undefined (cwd maps to no company repo) the
550
645
  * field is OMITTED — the server treats absence as unattributed/personal. The
551
- * reserved value `unattributed` is never produced. */
646
+ * reserved value `unattributed` is never produced.
647
+ *
648
+ * `skillVersion` (US-015): the caller stamps `ev.skillVersion` from
649
+ * `computeSkillVersion` before shaping; a `sha256:<hex>` marker (or omitted when
650
+ * unresolved). Also on the server KEEP allowlist; additive + backwards-compatible. */
552
651
  function toWireRow(ev: SkillEvent, companyUid?: string): Record<string, unknown> {
553
652
  const row: Record<string, unknown> = {
554
653
  skill: ev.skill,
@@ -559,6 +658,7 @@ function toWireRow(ev: SkillEvent, companyUid?: string): Record<string, unknown>
559
658
  if (ev.timestamp !== undefined) row.timestamp = ev.timestamp;
560
659
  if (ev.uuid !== undefined) row.uuid = ev.uuid;
561
660
  if (ev.cwd !== undefined) row.cwd = ev.cwd;
661
+ if (ev.skillVersion !== undefined) row.skillVersion = ev.skillVersion;
562
662
  if (companyUid !== undefined) row.companyUid = companyUid;
563
663
  // INCLUDE_ARGS_PREVIEW is intentionally a compile-time constant `false`;
564
664
  // the guarded branch documents the (currently disabled) egress path.
@@ -754,7 +854,27 @@ export async function collectAndSendSkillTelemetry(
754
854
  // When `hqRoot` is omitted the map is empty → every event stays unattributed.
755
855
  const repoCompanyMap: RepoCompanyMap = opts.hqRoot
756
856
  ? await buildRepoCompanyMap(opts.hqRoot)
757
- : { entries: [] };
857
+ : { entries: [], bySlug: new Map() };
858
+
859
+ // skillVersion resolution (US-015): resolve each skill's SKILL.md content hash
860
+ // ONCE per run (skills repeat across a session) and stamp it onto every event.
861
+ // Injectable for tests; defaults to hashing under hqRoot. Errors degrade to
862
+ // undefined so a resolver failure never blocks telemetry (additive field).
863
+ const resolveSkillVersion =
864
+ opts.resolveSkillVersion ??
865
+ ((skill: string) => computeSkillVersion(opts.hqRoot, skill));
866
+ const skillVersionCache = new Map<string, string | undefined>();
867
+ const skillVersionFor = async (skill: string): Promise<string | undefined> => {
868
+ if (skillVersionCache.has(skill)) return skillVersionCache.get(skill);
869
+ let version: string | undefined;
870
+ try {
871
+ version = await resolveSkillVersion(skill);
872
+ } catch {
873
+ version = undefined;
874
+ }
875
+ skillVersionCache.set(skill, version);
876
+ return version;
877
+ };
758
878
 
759
879
  // 1. Opt-in check — reuse the same gate as token telemetry.
760
880
  let enabled: boolean;
@@ -909,9 +1029,17 @@ export async function collectAndSendSkillTelemetry(
909
1029
  continue;
910
1030
  }
911
1031
  }
912
- // Resolve cwd owning company (cmp_* uid) from the per-run map.
913
- // Unresolved undefined companyUid omitted (unattributed/personal).
914
- const companyUid = resolveCompanyForCwd(ev.cwd, repoCompanyMap);
1032
+ // Attribute the event to a company (cmp_* uid) from the per-run map.
1033
+ // A company SKILL (`<slug>:<skill>`) belongs to ITS company regardless of
1034
+ // the cwd it ran in, so skill-based resolution takes precedence; a core
1035
+ // skill falls back to the cwd's owning company. Unresolved by both →
1036
+ // undefined → companyUid omitted (unattributed/personal).
1037
+ const companyUid =
1038
+ resolveCompanyForSkill(ev.skill, repoCompanyMap) ??
1039
+ resolveCompanyForCwd(ev.cwd, repoCompanyMap);
1040
+ // Stamp the skill's content-version marker (US-015). Unresolved → the
1041
+ // field is omitted from the wire row (additive).
1042
+ ev.skillVersion = await skillVersionFor(ev.skill);
915
1043
  const wireRow = toWireRow(ev, companyUid);
916
1044
  const wasOversized = jsonBytes(wireRow) > maxRowBytes;
917
1045
  const bounded = boundRowForPost(wireRow, maxRowBytes);
@@ -48,6 +48,11 @@ import {
48
48
  } from "./push-receiver.js";
49
49
  import { PushEventEmitter } from "../watcher.js";
50
50
  import type { TreeChangeBatch } from "../watcher.js";
51
+ import {
52
+ emitCloudTelemetry,
53
+ type CloudTelemetryClient,
54
+ type TelemetryClaims,
55
+ } from "../telemetry-events.js";
51
56
  import {
52
57
  createSyncLatencyMetricPublisher,
53
58
  type SyncMetricCredentials,
@@ -415,6 +420,8 @@ export interface StartEventSyncOptions {
415
420
  subscribe?: (deviceId: string) => Promise<SubscribeSyncResponse>;
416
421
  buildSqs?: (resp: SubscribeSyncResponse) => SqsClientLike;
417
422
  transport?: HttpPushTransport;
423
+ telemetryClient?: CloudTelemetryClient | null;
424
+ telemetryClaims?: TelemetryClaims | null;
418
425
  now?: () => number;
419
426
  }
420
427
 
@@ -473,6 +480,8 @@ export async function startEventSync(
473
480
  log(
474
481
  `event-sync: publish failed for ${ctx.relativePath ?? "?"}: ${err.message}`,
475
482
  ),
483
+ telemetryClient: opts.telemetryClient,
484
+ telemetryClaims: opts.telemetryClaims,
476
485
  });
477
486
 
478
487
  // ── receive leg (US-018) ──
@@ -515,6 +524,20 @@ export async function startEventSync(
515
524
  log(
516
525
  `event-sync: live (tenant=${tenantId} device=${deviceId} queue=${initial.queueUrl})`,
517
526
  );
527
+ void emitCloudTelemetry(
528
+ opts.telemetryClient,
529
+ {
530
+ eventName: "event_sync_live",
531
+ source: "event-sync",
532
+ properties: {
533
+ deviceId,
534
+ startupResult: "live",
535
+ receiveState: "enabled",
536
+ publishState: "enabled",
537
+ },
538
+ },
539
+ { claims: opts.telemetryClaims },
540
+ );
518
541
 
519
542
  return {
520
543
  publishBatch: (batch) => void emitter.emitForBatch(batch),
@@ -529,6 +552,20 @@ export async function startEventSync(
529
552
  log(
530
553
  `event-sync: startup failed, continuing poll-only: ${err instanceof Error ? err.message : String(err)}`,
531
554
  );
555
+ void emitCloudTelemetry(
556
+ opts.telemetryClient,
557
+ {
558
+ eventName: "event_sync_live",
559
+ source: "event-sync",
560
+ properties: {
561
+ deviceId: opts.deviceId,
562
+ startupResult: "poll_only",
563
+ receiveState: "disabled",
564
+ publishState: "disabled",
565
+ },
566
+ },
567
+ { claims: opts.telemetryClaims },
568
+ );
532
569
  return null;
533
570
  }
534
571
  }
@@ -18,6 +18,20 @@
18
18
  * The two surfaces drifting apart is exactly what caused the wedge, so the
19
19
  * resolver is defined ONCE here and consumed by both. Any future change to how
20
20
  * scope is derived now lands for the runner and the CLI in lockstep.
21
+ *
22
+ * SESSIONS ARE PUSH-ONLY (US-006). Session transcripts are pushed by clients
23
+ * into the company vault under `sessions/{personUid}/...`, but the sync engine
24
+ * NEVER auto-pulls that prefix onto grantee laptops — in EVERY sync mode,
25
+ * including `all` (which otherwise means a full-bucket pull) and an owner's
26
+ * role-wide scope. That never-pull contract is expressed HERE as
27
+ * `PullScope.excludePrefixes` (always carrying {@link SESSIONS_SCOPE_PREFIX}),
28
+ * NOT in `ignore.ts` (which only filters the push-side walk — sessions MUST
29
+ * still upload). A specific session is materialized on demand via `hq files
30
+ * get`, which pins the key so it survives scope-shrink pruning. The exclude set
31
+ * is subtracted from the pull's download filter but is deliberately NOT fed
32
+ * into scope-shrink, so a locally-authored (`direction:"up"`) or pinned
33
+ * on-demand-materialized session is never orphaned. See `computePullPlan`
34
+ * (subtraction) and `scope-shrink.ts` (retention).
21
35
  */
22
36
  import * as fs from "fs";
23
37
  import * as path from "path";
@@ -55,6 +69,41 @@ export interface PullScope {
55
69
  syncMode: SyncMode;
56
70
  /** Coalesced company-relative prefixes; omitted/undefined for `all`. */
57
71
  prefixSet?: string[];
72
+ /**
73
+ * Company-relative prefixes subtracted from the effective PULL scope in
74
+ * EVERY mode — the push-only set. Always carries {@link SESSIONS_SCOPE_PREFIX}
75
+ * (US-006): session transcripts are pushed into the vault but never
76
+ * auto-pulled onto grantee laptops, even under `all`/owner scope. A key is
77
+ * downloaded iff it is covered by the inclusion `prefixSet` (or `all`) AND
78
+ * NOT covered by any `excludePrefixes` entry. Distinct from `prefixSet` and
79
+ * deliberately NOT fed into scope-shrink, so a locally-authored or pinned
80
+ * on-demand-materialized session is never pruned. Stamped on every
81
+ * `resolvePullScope` return via {@link sessionsPushOnlyScope}.
82
+ */
83
+ excludePrefixes: string[];
84
+ }
85
+
86
+ /**
87
+ * Company-relative prefix under which session transcripts are pushed
88
+ * (`sessions/{personUid}/...`). Push-only by contract (US-006): a client
89
+ * uploads its own session transcripts here, but the sync engine NEVER
90
+ * auto-pulls this prefix onto grantee laptops — in ANY sync mode, including
91
+ * `all`. Materializing a specific session is an on-demand action (`hq files
92
+ * get`), which pins the key so it survives scope-shrink pruning.
93
+ */
94
+ export const SESSIONS_SCOPE_PREFIX = "sessions/";
95
+
96
+ /**
97
+ * Stamp the push-only session exclusion onto a resolved scope. Centralized so
98
+ * NO `resolvePullScope` return site can silently regress to a full session pull
99
+ * (the architect's flagged risk): the exclusion rides EVERY mode — `all`,
100
+ * owner→all, agent→all, config-all, shared, custom, and every degrade-to-`all`
101
+ * path — because each return is built through this helper.
102
+ */
103
+ function sessionsPushOnlyScope(
104
+ scope: Omit<PullScope, "excludePrefixes">,
105
+ ): PullScope {
106
+ return { ...scope, excludePrefixes: [SESSIONS_SCOPE_PREFIX] };
58
107
  }
59
108
 
60
109
  /**
@@ -81,11 +130,11 @@ export async function resolvePullScope(
81
130
  // When omitted, pins are simply not unioned (no behavior change).
82
131
  hqRoot?: string,
83
132
  ): Promise<PullScope> {
84
- if (!client.getMembershipSyncConfig) return { syncMode: "all" };
133
+ if (!client.getMembershipSyncConfig) return sessionsPushOnlyScope({ syncMode: "all" });
85
134
  try {
86
135
  const memberships = await client.listMyMemberships();
87
136
  const m = memberships.find((x) => x.companyUid === companyUid);
88
- if (!m) return { syncMode: "all" };
137
+ if (!m) return sessionsPushOnlyScope({ syncMode: "all" });
89
138
  // OWNER role-bypass (feedback_67bdb8a4 / feedback_a46c3b37). An owner's
90
139
  // effective vault access is the WHOLE company by role — the data plane
91
140
  // resolves owner to `admin` on every key (`resolveEffectivePermission`'s
@@ -110,7 +159,7 @@ export async function resolvePullScope(
110
159
  // below — a member/guest's explicit grants ARE their full footprint, and a
111
160
  // (conditionally bypassed) admin stays grant-scoped so a company that set
112
161
  // `admin.viewOthersFiles=false` keeps its admins narrowed.
113
- if (m.role === "owner") return { syncMode: "all" };
162
+ if (m.role === "owner") return sessionsPushOnlyScope({ syncMode: "all" });
114
163
  // Agent memberships (`agt_…#cmp_…`) belong to an agent identity that owns NO
115
164
  // person entity. hq-pro's per-membership sync-config endpoint sits behind an
116
165
  // up-front person-gate, so an agent caller is rejected with
@@ -124,9 +173,9 @@ export async function resolvePullScope(
124
173
  // 403 spam. (A server-side alternative would be to exempt sync-config from
125
174
  // the up-front person-gate and let its own `authorizeSyncConfigAccess`
126
175
  // handle agent ownership; tracked as a possible follow-up.)
127
- if (m.membershipKey?.startsWith("agt_")) return { syncMode: "all" };
176
+ if (m.membershipKey?.startsWith("agt_")) return sessionsPushOnlyScope({ syncMode: "all" });
128
177
  const cfg = await client.getMembershipSyncConfig(m.membershipKey);
129
- if (cfg.syncMode === "all") return { syncMode: "all" };
178
+ if (cfg.syncMode === "all") return sessionsPushOnlyScope({ syncMode: "all" });
130
179
 
131
180
  // Pins are company-relative prefixes a user explicitly materialized via
132
181
  // `hq files get`. They're unioned into the scope so a scoped pull keeps
@@ -143,15 +192,15 @@ export async function resolvePullScope(
143
192
  // `coalescePrefixes` (which drops empties) to "nothing", which would
144
193
  // prune the whole tree. An everything-scope is semantically `all`.
145
194
  if (customPrefixes.some((p) => p.prefix === "")) {
146
- return { syncMode: "all" };
195
+ return sessionsPushOnlyScope({ syncMode: "all" });
147
196
  }
148
- return {
197
+ return sessionsPushOnlyScope({
149
198
  syncMode: "custom",
150
199
  prefixSet: coalescePrefixes([
151
200
  ...customPrefixes,
152
201
  ...pinPrefixes.map(pathToScopePrefix),
153
202
  ]),
154
- };
203
+ });
155
204
  }
156
205
  // shared: scope to the caller's explicit grants. Real grant paths are
157
206
  // inconsistent — full (`companies/<slug>/x/*`), slug-anchored
@@ -165,7 +214,7 @@ export async function resolvePullScope(
165
214
  // and scope-shrink would prune every clean local file. Degrade to `all`
166
215
  // instead. A genuinely-empty grant list (the method exists and returns
167
216
  // []) is a real "nothing shared with me" and is allowed to narrow.
168
- if (!client.listMyExplicitGrants) return { syncMode: "all" };
217
+ if (!client.listMyExplicitGrants) return sessionsPushOnlyScope({ syncMode: "all" });
169
218
  const grants = await client.listMyExplicitGrants(companyUid);
170
219
  const sharedPrefixes = grants.map((g) =>
171
220
  grantPathToScopePrefix(g.path, slug),
@@ -174,18 +223,18 @@ export async function resolvePullScope(
174
223
  // `coalescePrefixes` drops empties (collapsing "everything" to "nothing"),
175
224
  // treat any such grant as full-access `all` rather than risk pruning.
176
225
  if (sharedPrefixes.some((p) => p.prefix === "")) {
177
- return { syncMode: "all" };
226
+ return sessionsPushOnlyScope({ syncMode: "all" });
178
227
  }
179
- return {
228
+ return sessionsPushOnlyScope({
180
229
  syncMode: "shared",
181
230
  prefixSet: coalescePrefixes([
182
231
  ...sharedPrefixes,
183
232
  ...pinPrefixes.map(pathToScopePrefix),
184
233
  ]),
185
- };
234
+ });
186
235
  } catch {
187
236
  // Degrade to `all` — never prune on a resolution failure.
188
- return { syncMode: "all" };
237
+ return sessionsPushOnlyScope({ syncMode: "all" });
189
238
  }
190
239
  }
191
240
 
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ emitCloudTelemetry,
5
+ type CloudTelemetryClient,
6
+ type TelemetryEvent,
7
+ } from "./telemetry-events.js";
8
+ import type { TelemetryEventsBatch } from "./vault-client.js";
9
+
10
+ describe("emitCloudTelemetry", () => {
11
+ it("is best-effort, stamps metadata, caps batches, and sanitizes properties", async () => {
12
+ const posts: TelemetryEventsBatch[] = [];
13
+ const client: CloudTelemetryClient = {
14
+ postTelemetryEvents: vi.fn(async (batch) => {
15
+ posts.push(batch);
16
+ throw new Error("vault unavailable");
17
+ }),
18
+ };
19
+ const logs: string[] = [];
20
+ const events: TelemetryEvent[] = Array.from({ length: 55 }, (_, i) => ({
21
+ eventName: `sync_completed_${i}`,
22
+ source: "hq-sync",
23
+ companyUid: "cmp_abc",
24
+ sessionId: "sync_123",
25
+ properties:
26
+ i === 0
27
+ ? ({
28
+ fileCount: 2,
29
+ result: "success",
30
+ companyUid: "cmp_abc",
31
+ labels: ["completed", "has spaces", "cmp_abc"],
32
+ path: "/Users/alice/company/secret.md",
33
+ filename: "secret.md",
34
+ secretToken: "tok_123",
35
+ message: "free text should not leave the process",
36
+ unsafeLabel: "contains spaces",
37
+ nanCount: Number.NaN,
38
+ enabled: true,
39
+ nested: { value: "nope" },
40
+ "bad-key": "nope",
41
+ } as unknown as TelemetryEvent["properties"])
42
+ : { fileCount: i },
43
+ }));
44
+
45
+ await expect(
46
+ emitCloudTelemetry(client, events, {
47
+ claims: {
48
+ "custom:entityType": "agent",
49
+ "custom:entityUid": "agt_runner",
50
+ },
51
+ timeoutMs: 25,
52
+ log: (message) => logs.push(message),
53
+ }),
54
+ ).resolves.toBeUndefined();
55
+
56
+ expect(client.postTelemetryEvents).toHaveBeenCalledTimes(1);
57
+ expect(client.postTelemetryEvents).toHaveBeenCalledWith(
58
+ { events: expect.any(Array) },
59
+ { timeoutMs: 25 },
60
+ );
61
+ expect(posts).toHaveLength(1);
62
+ expect(posts[0].events).toHaveLength(50);
63
+
64
+ const first = posts[0].events[0];
65
+ expect(first).toMatchObject({
66
+ eventName: "sync_completed_0",
67
+ app: "hq-cloud",
68
+ source: "hq-sync",
69
+ companyUid: "cmp_abc",
70
+ agentUid: "agt_runner",
71
+ sessionId: "sync_123",
72
+ schemaVersion: 1,
73
+ properties: {
74
+ fileCount: 2,
75
+ result: "success",
76
+ companyUid: "cmp_abc",
77
+ labels: ["completed", "cmp_abc"],
78
+ },
79
+ });
80
+ expect(Number.isNaN(Date.parse(first.occurredAt))).toBe(false);
81
+ expect(JSON.stringify(posts[0])).not.toContain("personUid");
82
+ expect(JSON.stringify(posts[0])).not.toContain("/Users/alice");
83
+ expect(JSON.stringify(posts[0])).not.toContain("secret.md");
84
+ expect(JSON.stringify(posts[0])).not.toContain("free text");
85
+ expect(JSON.stringify(posts[0])).not.toContain("tok_123");
86
+ expect(logs.join("\n")).toContain("postTelemetryEvents failed");
87
+ });
88
+ });