@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
@@ -0,0 +1,205 @@
1
+ import type {
2
+ TelemetryEventsBatch,
3
+ TelemetryEventsIngestResult,
4
+ } from "./vault-client.js";
5
+
6
+ const MAX_EVENTS_PER_BATCH = 50;
7
+ const DEFAULT_TIMEOUT_MS = 1500;
8
+ const MAX_PROPERTIES = 40;
9
+ const MAX_ARRAY_VALUES = 20;
10
+
11
+ type TelemetryPrimitive = string | number;
12
+ export type TelemetryProperties = Record<
13
+ string,
14
+ TelemetryPrimitive | TelemetryPrimitive[] | null | undefined
15
+ >;
16
+
17
+ export interface TelemetryEvent {
18
+ eventName: string;
19
+ source: string;
20
+ occurredAt?: string;
21
+ companyUid?: string;
22
+ agentUid?: string;
23
+ sessionId?: string;
24
+ schemaVersion?: 1;
25
+ properties?: TelemetryProperties;
26
+ }
27
+
28
+ export interface TelemetryClaims {
29
+ "custom:entityType"?: unknown;
30
+ "custom:entityUid"?: unknown;
31
+ }
32
+
33
+ export interface CloudTelemetryClient {
34
+ postTelemetryEvents?: (
35
+ batch: TelemetryEventsBatch,
36
+ options?: { timeoutMs?: number },
37
+ ) => Promise<TelemetryEventsIngestResult>;
38
+ }
39
+
40
+ export interface EmitCloudTelemetryOptions {
41
+ claims?: TelemetryClaims | null;
42
+ timeoutMs?: number;
43
+ log?: (message: string) => void;
44
+ }
45
+
46
+ const SAFE_KEY = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
47
+ const SAFE_LABEL = /^[A-Za-z0-9._:-]{1,128}$/;
48
+ const BLOCKED_STRING_PROPERTY_KEY =
49
+ /(path|filename|name|email|secret|token|text|message|prompt|body|content|cwd|dir|url|uri|prefix|link)/i;
50
+
51
+ export function agentUidFromClaims(
52
+ claims: TelemetryClaims | null | undefined,
53
+ ): string | undefined {
54
+ if (
55
+ claims?.["custom:entityType"] === "agent" &&
56
+ typeof claims["custom:entityUid"] === "string" &&
57
+ claims["custom:entityUid"].startsWith("agt_")
58
+ ) {
59
+ return claims["custom:entityUid"];
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ export async function emitCloudTelemetry(
65
+ client: CloudTelemetryClient | null | undefined,
66
+ events: TelemetryEvent | TelemetryEvent[],
67
+ options: EmitCloudTelemetryOptions = {},
68
+ ): Promise<void> {
69
+ if (!client?.postTelemetryEvents) return;
70
+
71
+ const derivedAgentUid = agentUidFromClaims(options.claims);
72
+ const now = new Date().toISOString();
73
+ const sanitized = (Array.isArray(events) ? events : [events])
74
+ .slice(0, MAX_EVENTS_PER_BATCH)
75
+ .map((event) => sanitizeEvent(event, now, derivedAgentUid))
76
+ .filter((event): event is TelemetryEventsBatch["events"][number] => event !== null);
77
+
78
+ if (sanitized.length === 0) return;
79
+
80
+ const timeoutMs = normalizeTimeout(options.timeoutMs);
81
+ let timeout: ReturnType<typeof setTimeout> | undefined;
82
+ const timeoutPromise = new Promise<never>((_, reject) => {
83
+ timeout = setTimeout(
84
+ () => reject(new Error(`telemetry events timed out after ${timeoutMs}ms`)),
85
+ timeoutMs,
86
+ );
87
+ timeout.unref?.();
88
+ });
89
+
90
+ const postPromise = client
91
+ .postTelemetryEvents({ events: sanitized }, { timeoutMs })
92
+ .catch((err) => {
93
+ throw err;
94
+ });
95
+ postPromise.catch(() => undefined);
96
+
97
+ try {
98
+ await Promise.race([postPromise, timeoutPromise]);
99
+ } catch (err) {
100
+ warn(options.log, `postTelemetryEvents failed (${describeError(err)})`);
101
+ } finally {
102
+ if (timeout !== undefined) clearTimeout(timeout);
103
+ }
104
+ }
105
+
106
+ function sanitizeEvent(
107
+ event: TelemetryEvent,
108
+ fallbackOccurredAt: string,
109
+ derivedAgentUid: string | undefined,
110
+ ): TelemetryEventsBatch["events"][number] | null {
111
+ const eventName = safeLabel(event.eventName);
112
+ const source = safeLabel(event.source);
113
+ if (!eventName || !source) return null;
114
+
115
+ const occurredAt = safeIso(event.occurredAt) ?? fallbackOccurredAt;
116
+ const properties = sanitizeProperties(event.properties);
117
+ const companyUid = safeLabel(event.companyUid);
118
+ const agentUid = safeLabel(event.agentUid) ?? derivedAgentUid;
119
+ const sessionId = safeLabel(event.sessionId);
120
+
121
+ return {
122
+ eventName,
123
+ app: "hq-cloud",
124
+ source,
125
+ occurredAt,
126
+ ...(companyUid ? { companyUid } : {}),
127
+ ...(agentUid ? { agentUid } : {}),
128
+ ...(sessionId ? { sessionId } : {}),
129
+ schemaVersion: 1,
130
+ ...(properties ? { properties } : {}),
131
+ };
132
+ }
133
+
134
+ function sanitizeProperties(
135
+ properties: TelemetryProperties | undefined,
136
+ ): Record<string, unknown> | undefined {
137
+ if (!properties) return undefined;
138
+ const out: Record<string, unknown> = {};
139
+ for (const [key, value] of Object.entries(properties)) {
140
+ if (Object.keys(out).length >= MAX_PROPERTIES) break;
141
+ if (!SAFE_KEY.test(key)) continue;
142
+ const sanitized = sanitizePropertyValue(key, value);
143
+ if (sanitized !== undefined) out[key] = sanitized;
144
+ }
145
+ return Object.keys(out).length > 0 ? out : undefined;
146
+ }
147
+
148
+ function sanitizePropertyValue(
149
+ key: string,
150
+ value: unknown,
151
+ ): TelemetryPrimitive | TelemetryPrimitive[] | undefined {
152
+ if (value === null || value === undefined) return undefined;
153
+ if (Array.isArray(value)) {
154
+ const values = value
155
+ .slice(0, MAX_ARRAY_VALUES)
156
+ .map((item) => sanitizeScalar(key, item))
157
+ .filter((item): item is TelemetryPrimitive => item !== undefined);
158
+ return values.length > 0 ? values : undefined;
159
+ }
160
+ return sanitizeScalar(key, value);
161
+ }
162
+
163
+ function sanitizeScalar(
164
+ key: string,
165
+ value: unknown,
166
+ ): TelemetryPrimitive | undefined {
167
+ if (typeof value === "number") {
168
+ return Number.isFinite(value) ? value : undefined;
169
+ }
170
+ if (typeof value !== "string") return undefined;
171
+ if (BLOCKED_STRING_PROPERTY_KEY.test(key)) return undefined;
172
+ return safeLabel(value);
173
+ }
174
+
175
+ function safeLabel(value: unknown): string | undefined {
176
+ if (typeof value !== "string") return undefined;
177
+ const trimmed = value.trim();
178
+ if (!SAFE_LABEL.test(trimmed)) return undefined;
179
+ return trimmed;
180
+ }
181
+
182
+ function safeIso(value: unknown): string | undefined {
183
+ if (typeof value !== "string") return undefined;
184
+ const parsed = Date.parse(value);
185
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined;
186
+ }
187
+
188
+ function normalizeTimeout(value: number | undefined): number {
189
+ if (value !== undefined && Number.isFinite(value) && value > 0) {
190
+ return Math.trunc(value);
191
+ }
192
+ return DEFAULT_TIMEOUT_MS;
193
+ }
194
+
195
+ function warn(log: ((message: string) => void) | undefined, message: string): void {
196
+ try {
197
+ (log ?? console.warn)(`[telemetry-events] ${message}`);
198
+ } catch {
199
+ /* diagnostics must never affect the caller */
200
+ }
201
+ }
202
+
203
+ function describeError(err: unknown): string {
204
+ return err instanceof Error ? err.message : String(err);
205
+ }
package/src/telemetry.ts CHANGED
@@ -342,7 +342,7 @@ export async function collectAndSendTelemetry(
342
342
  // When `hqRoot` is omitted the map is empty → every event stays unattributed.
343
343
  const repoCompanyMap: RepoCompanyMap = opts.hqRoot
344
344
  ? await buildRepoCompanyMap(opts.hqRoot)
345
- : { entries: [] };
345
+ : { entries: [], bySlug: new Map() };
346
346
 
347
347
  // 1. Opt-in check (server-authoritative, with local fallback).
348
348
  let enabled: boolean;
@@ -16,6 +16,7 @@ import {
16
16
  type EntityInfo,
17
17
  type ExplicitGrant,
18
18
  type MembershipSyncConfig,
19
+ type TelemetryEventsBatch,
19
20
  } from "./vault-client.js";
20
21
 
21
22
  // ---------------------------------------------------------------------------
@@ -275,6 +276,40 @@ describe("API surface", () => {
275
276
  });
276
277
  });
277
278
 
279
+ it("postTelemetryEvents sends action events without personUid", async () => {
280
+ fetchSpy.mockResolvedValueOnce(
281
+ jsonResponse(200, { ok: true, written: 1, skipped: [] }),
282
+ );
283
+ const batch: TelemetryEventsBatch = {
284
+ events: [
285
+ {
286
+ eventName: "sync_runner_completed",
287
+ app: "hq-cloud",
288
+ source: "sync-runner",
289
+ occurredAt: "2026-07-07T12:00:00.000Z",
290
+ companyUid: "cmp_abc",
291
+ sessionId: "sync_123",
292
+ schemaVersion: 1,
293
+ properties: {
294
+ result: "completed",
295
+ fileCount: 2,
296
+ },
297
+ },
298
+ ],
299
+ };
300
+
301
+ const result = await client.postTelemetryEvents(batch);
302
+
303
+ expect(result).toEqual({ ok: true, written: 1, skipped: [] });
304
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
305
+ const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
306
+ expect(url).toBe("https://vault.test.example.com/v1/telemetry/events");
307
+ expect((init.method as string).toUpperCase()).toBe("POST");
308
+ const body = JSON.parse(init.body as string) as Record<string, unknown>;
309
+ expect(body).toEqual(batch);
310
+ expect(JSON.stringify(body)).not.toContain("personUid");
311
+ });
312
+
278
313
  it("updateRole sends correct payload", async () => {
279
314
  fetchSpy.mockResolvedValueOnce(
280
315
  jsonResponse(200, {
@@ -1067,7 +1102,10 @@ describe("setMembershipSyncConfig (US-004)", () => {
1067
1102
  await expect(
1068
1103
  client.setMembershipSyncConfig("psn_1#cmp_abc", { syncMode: "all" }),
1069
1104
  ).rejects.toThrow(/ETIMEDOUT/);
1070
- expect(fetchSpy).toHaveBeenCalledTimes(4);
1105
+ const syncConfigCalls = fetchSpy.mock.calls.filter(([url]) =>
1106
+ String(url).includes("/v1/memberships/psn_1%23cmp_abc/sync-config"),
1107
+ );
1108
+ expect(syncConfigCalls).toHaveLength(4);
1071
1109
  });
1072
1110
  });
1073
1111
 
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { ClientInfo, VaultServiceConfig } from "./types.js";
10
10
  import { buildClientHeaders } from "./client-info.js";
11
+ import { emitCloudTelemetry } from "./telemetry-events.js";
11
12
  import { z } from "zod";
12
13
 
13
14
  // ---------------------------------------------------------------------------
@@ -432,6 +433,34 @@ export interface UsageIngestResult {
432
433
  skipped: Array<{ index: number; code: string; error: string }>;
433
434
  }
434
435
 
436
+ // ---------------------------------------------------------------------------
437
+ // Cloud action telemetry (hq-pro `/v1/telemetry/events`)
438
+ // ---------------------------------------------------------------------------
439
+
440
+ export interface RawTelemetryEventInput {
441
+ eventName: string;
442
+ app: "hq-cloud";
443
+ source: string;
444
+ occurredAt: string;
445
+ companyUid?: string;
446
+ agentUid?: string;
447
+ sessionId?: string;
448
+ schemaVersion?: 1;
449
+ properties?: Record<string, unknown>;
450
+ }
451
+
452
+ export interface TelemetryEventsBatch {
453
+ /**
454
+ * Action events emitted by sync/CLI surfaces. `personUid` MUST NOT appear in
455
+ * either the batch or any event — hq-pro resolves the caller server-side from
456
+ * the Cognito JWT and rejects client-supplied person identifiers.
457
+ */
458
+ events: RawTelemetryEventInput[];
459
+ }
460
+
461
+ /** Same wire shape as `UsageIngestResult`; aliased for call-site clarity. */
462
+ export type TelemetryEventsIngestResult = UsageIngestResult;
463
+
435
464
  // ---------------------------------------------------------------------------
436
465
  // Skill-invocation telemetry (hq-pro `/v1/skill-invocations`)
437
466
  // ---------------------------------------------------------------------------
@@ -442,11 +471,11 @@ export interface SkillInvocationBatch {
442
471
  /**
443
472
  * Skill-invocation event rows. Each row contains only the fields in the
444
473
  * server's KEEP allowlist (skill, source, sessionId, timestamp, uuid, cwd,
445
- * hasArgs, and the optional companyUid edge-attribution field — US-002).
446
- * Raw argument text is never included see the privacy note in
447
- * `./skill-telemetry.ts`. Any extra field is rejected by hq-pro with
448
- * `unexpected-event-field`, so the extractor in `./skill-telemetry.ts` is the
449
- * only thing allowed to produce these.
474
+ * hasArgs, the optional companyUid edge-attribution field — US-002, and the
475
+ * optional skillVersion content-hash markerUS-015). Raw argument text is
476
+ * never included — see the privacy note in `./skill-telemetry.ts`. Any extra
477
+ * field is rejected by hq-pro with `unexpected-event-field`, so the extractor
478
+ * in `./skill-telemetry.ts` is the only thing allowed to produce these.
450
479
  */
451
480
  events: Array<Record<string, unknown>>;
452
481
  }
@@ -461,6 +490,11 @@ export type SkillInvocationIngestResult = UsageIngestResult;
461
490
  const MAX_RETRIES = 3;
462
491
  const BASE_DELAY_MS = 500;
463
492
 
493
+ interface VaultRequestOptions {
494
+ timeoutMs?: number;
495
+ maxRetries?: number;
496
+ }
497
+
464
498
  function isTransient(status: number): boolean {
465
499
  return status === 429 || status >= 500;
466
500
  }
@@ -769,6 +803,12 @@ function summarizeZodIssues(issues: readonly z.core.$ZodIssue[]): string {
769
803
  .join("; ");
770
804
  }
771
805
 
806
+ function companyUidFromMembershipId(membershipId: string): string | undefined {
807
+ const parts = membershipId.split("#");
808
+ const candidate = parts.length > 1 ? parts[parts.length - 1] : undefined;
809
+ return candidate?.startsWith("cmp_") ? candidate : undefined;
810
+ }
811
+
772
812
  // ---------------------------------------------------------------------------
773
813
  // VaultClient
774
814
  // ---------------------------------------------------------------------------
@@ -1019,12 +1059,39 @@ export class VaultClient {
1019
1059
  membershipId: string,
1020
1060
  partial: SetMembershipSyncConfigInput,
1021
1061
  ): Promise<MembershipSyncConfig> {
1022
- return this.request(
1023
- "PUT",
1024
- `/v1/memberships/${encodeURIComponent(membershipId)}/sync-config`,
1025
- partial,
1026
- membershipSyncConfigSchema,
1027
- );
1062
+ const companyUid = companyUidFromMembershipId(membershipId);
1063
+ try {
1064
+ const result = await this.request(
1065
+ "PUT",
1066
+ `/v1/memberships/${encodeURIComponent(membershipId)}/sync-config`,
1067
+ partial,
1068
+ membershipSyncConfigSchema,
1069
+ );
1070
+ void emitCloudTelemetry(this, {
1071
+ eventName: "membership_sync_config_changed",
1072
+ source: "vault-client",
1073
+ ...(companyUid ? { companyUid } : {}),
1074
+ properties: {
1075
+ syncMode: result.syncMode,
1076
+ customPathCount: result.customPaths?.length ?? 0,
1077
+ result: "success",
1078
+ defaultState: result.isDefault ? "default" : "configured",
1079
+ },
1080
+ });
1081
+ return result;
1082
+ } catch (err) {
1083
+ void emitCloudTelemetry(this, {
1084
+ eventName: "membership_sync_config_changed",
1085
+ source: "vault-client",
1086
+ ...(companyUid ? { companyUid } : {}),
1087
+ properties: {
1088
+ syncMode: partial.syncMode,
1089
+ customPathCount: partial.customPaths?.length ?? 0,
1090
+ result: "failure",
1091
+ },
1092
+ });
1093
+ throw err;
1094
+ }
1028
1095
  }
1029
1096
 
1030
1097
  // -- Entity operations ----------------------------------------------------
@@ -1265,6 +1332,25 @@ export class VaultClient {
1265
1332
  return this.post("/v1/skill-invocations", batch, usageIngestResultSchema);
1266
1333
  }
1267
1334
 
1335
+ /**
1336
+ * `POST /v1/telemetry/events` — upload a capped batch of hq-cloud ACTION
1337
+ * events.
1338
+ *
1339
+ * Same trust model as `postUsage`: `personUid` MUST NOT appear in the body;
1340
+ * hq-pro resolves the caller from the JWT and validates any `companyUid`
1341
+ * against the caller's memberships. This endpoint is for server/CLI action
1342
+ * telemetry and is not gated by the personal `/v1/usage` opt-in flag.
1343
+ */
1344
+ async postTelemetryEvents(
1345
+ batch: TelemetryEventsBatch,
1346
+ options: { timeoutMs?: number } = {},
1347
+ ): Promise<TelemetryEventsIngestResult> {
1348
+ return this.post("/v1/telemetry/events", batch, usageIngestResultSchema, {
1349
+ timeoutMs: options.timeoutMs ?? 1500,
1350
+ maxRetries: 0,
1351
+ });
1352
+ }
1353
+
1268
1354
  // -- HTTP primitives with retry -------------------------------------------
1269
1355
 
1270
1356
  private async get<T>(path: string, schema: VaultResponseSchema<T>): Promise<T> {
@@ -1275,8 +1361,9 @@ export class VaultClient {
1275
1361
  path: string,
1276
1362
  body: unknown | undefined,
1277
1363
  schema: VaultResponseSchema<T>,
1364
+ options?: VaultRequestOptions,
1278
1365
  ): Promise<T> {
1279
- return this.request("POST", path, body, schema);
1366
+ return this.request("POST", path, body, schema, options);
1280
1367
  }
1281
1368
 
1282
1369
  private async request<T>(
@@ -1284,10 +1371,12 @@ export class VaultClient {
1284
1371
  path: string,
1285
1372
  body: unknown,
1286
1373
  schema: VaultResponseSchema<T>,
1374
+ options: VaultRequestOptions = {},
1287
1375
  ): Promise<T> {
1288
1376
  let lastError: Error | undefined;
1377
+ const maxRetries = options.maxRetries ?? MAX_RETRIES;
1289
1378
 
1290
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1379
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1291
1380
  if (attempt > 0) {
1292
1381
  const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
1293
1382
  await sleep(delay);
@@ -1300,6 +1389,16 @@ export class VaultClient {
1300
1389
  };
1301
1390
 
1302
1391
  const init: RequestInit = { method, headers };
1392
+ const controller =
1393
+ options.timeoutMs !== undefined && options.timeoutMs > 0
1394
+ ? new AbortController()
1395
+ : undefined;
1396
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1397
+ if (controller) {
1398
+ timeout = setTimeout(() => controller.abort(), options.timeoutMs);
1399
+ timeout.unref?.();
1400
+ init.signal = controller.signal;
1401
+ }
1303
1402
 
1304
1403
  if (body !== undefined) {
1305
1404
  headers["Content-Type"] = "application/json";
@@ -1311,8 +1410,10 @@ export class VaultClient {
1311
1410
  res = await fetch(`${this.apiUrl}${path}`, init);
1312
1411
  } catch (err) {
1313
1412
  lastError = err instanceof Error ? err : new Error(String(err));
1314
- if (attempt < MAX_RETRIES) continue;
1413
+ if (attempt < maxRetries) continue;
1315
1414
  throw lastError;
1415
+ } finally {
1416
+ if (timeout !== undefined) clearTimeout(timeout);
1316
1417
  }
1317
1418
 
1318
1419
  if (res.ok) {
@@ -331,6 +331,76 @@ describe("US-002: createWatchPathFilter — personal-vault exclusions", () => {
331
331
  expect(personal(path.join(ROOT, "workspace"), false)).toBe(false);
332
332
  });
333
333
 
334
+ // ── workspace/agency subtree carve-out (personal mode) ──────────────────
335
+ // The personal push uploads workspace/agency/** (computeAgencySyncPaths), so
336
+ // the personal watcher must emit those changes event-driven, not leave them
337
+ // for the 60s poll. Mirrors the continuity-pointer carve-out, for a subtree.
338
+ it("DOES emit for workspace/agency files in personalMode (synced subtree)", () => {
339
+ expect(
340
+ personal(path.join(ROOT, "workspace/agency/indigo/nick/chat.jsonl")),
341
+ ).toBe(true);
342
+ expect(
343
+ personal(path.join(ROOT, "workspace/agency/indigo/nick/status.json")),
344
+ ).toBe(true);
345
+ // The agency root itself, as a file event, also emits.
346
+ expect(personal(path.join(ROOT, "workspace/agency"))).toBe(true);
347
+ });
348
+
349
+ it("allows chokidar to DESCEND to workspace/agency in personalMode (dir probes)", () => {
350
+ // workspace/ and workspace/agency queried as dirs must return true so the
351
+ // chokidar backend descends instead of pruning workspace/ at the top.
352
+ expect(personal(path.join(ROOT, "workspace"), true)).toBe(true);
353
+ expect(personal(path.join(ROOT, "workspace/agency"), true)).toBe(true);
354
+ expect(
355
+ personal(path.join(ROOT, "workspace/agency/indigo"), true),
356
+ ).toBe(true);
357
+ });
358
+
359
+ it("agency carve-out is exact-segment (does NOT leak to workspace/agencyX)", () => {
360
+ // Only workspace/agency (and its subtree) is re-included; a sibling whose
361
+ // name merely starts with 'agency' stays excluded like other workspace litter.
362
+ expect(personal(path.join(ROOT, "workspace/agencyX/foo.txt"))).toBe(false);
363
+ expect(personal(path.join(ROOT, "workspace/agency-notes.md"))).toBe(false);
364
+ });
365
+
366
+ it("agency carve-out does not re-include other workspace/ litter", () => {
367
+ // Regression: the new carve-out must not widen the workspace exclusion for
368
+ // anything other than agency + the continuity pointer.
369
+ expect(personal(path.join(ROOT, "workspace/locks/x.lock"))).toBe(false);
370
+ expect(personal(path.join(ROOT, "workspace/threads/T-old.json"))).toBe(false);
371
+ expect(personal(path.join(ROOT, "workspace/worktrees/x/a.ts"))).toBe(false);
372
+ });
373
+
374
+ // ── workspace/.session-logs subtree carve-out (personal mode) ────────────
375
+ // The personal push uploads workspace/.session-logs/** (reindex-captured
376
+ // Claude Code transcripts, computeSessionLogsSyncPaths), so the personal
377
+ // watcher must emit those changes event-driven. Mirrors the agency carve-out.
378
+ it("DOES emit for workspace/.session-logs files in personalMode (synced subtree)", () => {
379
+ expect(
380
+ personal(path.join(ROOT, "workspace/.session-logs/session.jsonl")),
381
+ ).toBe(true);
382
+ expect(
383
+ personal(path.join(ROOT, "workspace/.session-logs/tool-results/r.txt")),
384
+ ).toBe(true);
385
+ // The subtree root itself, as a file event, also emits.
386
+ expect(personal(path.join(ROOT, "workspace/.session-logs"))).toBe(true);
387
+ });
388
+
389
+ it("allows chokidar to DESCEND to workspace/.session-logs in personalMode (dir probes)", () => {
390
+ expect(personal(path.join(ROOT, "workspace"), true)).toBe(true);
391
+ expect(personal(path.join(ROOT, "workspace/.session-logs"), true)).toBe(true);
392
+ expect(
393
+ personal(path.join(ROOT, "workspace/.session-logs/tool-results"), true),
394
+ ).toBe(true);
395
+ });
396
+
397
+ it("session-logs carve-out is exact-segment (does NOT leak to a similarly-named sibling)", () => {
398
+ expect(
399
+ personal(path.join(ROOT, "workspace/.session-logs-old/foo.txt")),
400
+ ).toBe(false);
401
+ expect(personal(path.join(ROOT, "workspace/.session-logsX.md"))).toBe(false);
402
+ });
403
+
334
404
  it("does not apply the carve-out in non-personal mode", () => {
335
405
  // In non-personal mode the excluded-top-level layer never runs, so the
336
406
  // carve-out is irrelevant; handoff.json passes via the ordinary ignore
package/src/watcher.ts CHANGED
@@ -14,12 +14,19 @@ import { watch } from "chokidar";
14
14
  import { createIgnoreFilter } from "./ignore.js";
15
15
  import { isPersonalVaultExcluded } from "./personal-vault-exclusions.js";
16
16
  import {
17
+ AGENCY_SYNC_REL,
17
18
  CONTINUITY_POINTER_REL,
19
+ SESSION_LOGS_SYNC_REL,
18
20
  PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
19
21
  } from "./personal-vault.js";
20
22
  import type { PushEvent } from "./sync/push-event.js";
21
23
  import type { PushTransport } from "./sync/push-transport.js";
22
24
  import type { EventDrivenPushFlagProvider } from "./sync/feature-flags.js";
25
+ import {
26
+ emitCloudTelemetry,
27
+ type CloudTelemetryClient,
28
+ type TelemetryClaims,
29
+ } from "./telemetry-events.js";
23
30
 
24
31
  const DEBOUNCE_MS = 2000;
25
32
 
@@ -446,6 +453,36 @@ export function createWatchPathFilter(
446
453
  if (rel === CONTINUITY_POINTER_REL && !isDir) return true;
447
454
  if (isDir && CONTINUITY_POINTER_REL.startsWith(rel + "/")) return true;
448
455
 
456
+ // Agency-workspace carve-out: `workspace/agency/` (the whole subtree) IS
457
+ // synced via computeAgencySyncPaths (mirrored on the push side), so it must
458
+ // re-include BEFORE the top-level `workspace/` rejection below — same
459
+ // reason as the continuity pointer above, but for an entire subtree rather
460
+ // than one file. Without this, a `--personal --watch` runner never emits
461
+ // agency changes and they wait for the 60s poll. Paths at/under agency
462
+ // emit (handleEvent always probes isDir=false); agency itself and its
463
+ // ancestor dir (`workspace`) return true for the dir probe so chokidar
464
+ // descends to reach them instead of pruning `workspace/`.
465
+ if (rel === AGENCY_SYNC_REL || rel.startsWith(AGENCY_SYNC_REL + "/")) {
466
+ return true;
467
+ }
468
+ if (isDir && AGENCY_SYNC_REL.startsWith(rel + "/")) return true;
469
+
470
+ // Session-logs carve-out: `workspace/.session-logs/` (the whole subtree)
471
+ // IS synced via computeSessionLogsSyncPaths (mirrored on the push side),
472
+ // so it must re-include BEFORE the top-level `workspace/` rejection below
473
+ // — identical shape to the agency carve-out just above. Without this, a
474
+ // `--personal --watch` runner never emits reindex-captured session-log
475
+ // changes and they wait for the 60s poll. Paths at/under it emit; the
476
+ // subtree root and its ancestor dir (`workspace`) return true for the dir
477
+ // probe so chokidar descends to reach them instead of pruning `workspace/`.
478
+ if (
479
+ rel === SESSION_LOGS_SYNC_REL ||
480
+ rel.startsWith(SESSION_LOGS_SYNC_REL + "/")
481
+ ) {
482
+ return true;
483
+ }
484
+ if (isDir && SESSION_LOGS_SYNC_REL.startsWith(rel + "/")) return true;
485
+
449
486
  // Layer 3: excluded top-level buckets (.git/companies/repos/workspace).
450
487
  const topLevel = rel.split("/")[0];
451
488
  if (excludedTopLevel.has(topLevel)) return false;
@@ -810,6 +847,9 @@ export interface PushEventEmitterOptions {
810
847
  * no log (the daemon stays quiet unless wired with a logger).
811
848
  */
812
849
  logger?: EmitterLogger;
850
+ /** Optional hq-pro ACTION telemetry sink. Best-effort; never affects publish. */
851
+ telemetryClient?: CloudTelemetryClient | null;
852
+ telemetryClaims?: TelemetryClaims | null;
813
853
  }
814
854
 
815
855
  /**
@@ -834,6 +874,8 @@ export class PushEventEmitter {
834
874
  private readonly now: () => Date;
835
875
  private readonly onError: (err: Error, ctx: { relativePath?: string }) => void;
836
876
  private readonly logger: EmitterLogger | undefined;
877
+ private readonly telemetryClient: CloudTelemetryClient | null | undefined;
878
+ private readonly telemetryClaims: TelemetryClaims | null | undefined;
837
879
  private internalSeq = 0;
838
880
  private readonly nextSeq: () => number;
839
881
  private publishTail: Promise<void> = Promise.resolve();
@@ -845,6 +887,8 @@ export class PushEventEmitter {
845
887
  this.flagProvider = opts.flagProvider;
846
888
  this.now = opts.now ?? (() => new Date());
847
889
  this.logger = opts.logger;
890
+ this.telemetryClient = opts.telemetryClient;
891
+ this.telemetryClaims = opts.telemetryClaims;
848
892
  this.onError =
849
893
  opts.onError ??
850
894
  ((err, ctx) =>
@@ -945,6 +989,7 @@ export class PushEventEmitter {
945
989
  this.onError(err instanceof Error ? err : new Error(String(err)), {
946
990
  relativePath,
947
991
  });
992
+ this.emitPublishTelemetry("push_event_failed", "unknown", "capture");
948
993
  return;
949
994
  }
950
995
 
@@ -980,12 +1025,35 @@ export class PushEventEmitter {
980
1025
 
981
1026
  try {
982
1027
  await this.transport.publish(event);
1028
+ this.emitPublishTelemetry("push_event_published", event.kind, "publish");
983
1029
  } catch (err) {
984
1030
  // Push failure (network / non-2xx / timeout). The cadence poll is the
985
1031
  // safety net — log + continue, never throw.
986
1032
  this.onError(err instanceof Error ? err : new Error(String(err)), {
987
1033
  relativePath,
988
1034
  });
1035
+ this.emitPublishTelemetry("push_event_failed", event.kind, "publish");
989
1036
  }
990
1037
  }
1038
+
1039
+ private emitPublishTelemetry(
1040
+ eventName: "push_event_published" | "push_event_failed",
1041
+ kind: string,
1042
+ stage: string,
1043
+ ): void {
1044
+ void emitCloudTelemetry(
1045
+ this.telemetryClient,
1046
+ {
1047
+ eventName,
1048
+ source: "watcher",
1049
+ properties: {
1050
+ kind,
1051
+ count: 1,
1052
+ result: eventName === "push_event_published" ? "success" : "failure",
1053
+ stage,
1054
+ },
1055
+ },
1056
+ { claims: this.telemetryClaims },
1057
+ );
1058
+ }
991
1059
  }