@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
@@ -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,46 @@ 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
+
334
374
  it("does not apply the carve-out in non-personal mode", () => {
335
375
  // In non-personal mode the excluded-top-level layer never runs, so the
336
376
  // carve-out is irrelevant; handoff.json passes via the ordinary ignore
@@ -350,6 +390,48 @@ describe("US-002: createWatchPathFilter — personal-vault exclusions", () => {
350
390
  // mode the ignore stack alone governs and lets it through.
351
391
  expect(nonPersonal(path.join(ROOT, "companies/indigo/board.json"))).toBe(true);
352
392
  });
393
+
394
+ // ── Never-synced heavy buckets pruned in BOTH modes ─────────────────────
395
+ // In --companies mode personalMode=false, so the excluded-top-level layer
396
+ // never ran and the watcher descended into the local git worktrees
397
+ // (workspace/worktrees/, ~200k transient files from agent coding runs) and
398
+ // the on-box repos checkout (repos/). Neither ever vault-syncs in any mode,
399
+ // so watching them only burns inotify watches (near the OS limit) + CPU on
400
+ // build churn it discards, and can trip the overflow→full-reconcile fallback.
401
+ it("non-personal mode EXCLUDES never-synced heavy buckets (workspace/worktrees, repos)", () => {
402
+ expect(
403
+ nonPersonal(path.join(ROOT, "workspace/worktrees/hq-console/src/a.ts")),
404
+ ).toBe(false);
405
+ // The bucket dir itself is pruned so chokidar never descends into it.
406
+ expect(nonPersonal(path.join(ROOT, "workspace/worktrees"), true)).toBe(false);
407
+ expect(
408
+ nonPersonal(path.join(ROOT, "repos/private/hq-cloud/src/x.ts")),
409
+ ).toBe(false);
410
+ expect(nonPersonal(path.join(ROOT, "repos"), true)).toBe(false);
411
+ });
412
+
413
+ it("non-personal mode STILL watches synced content (companies/, workspace/agency, continuity)", () => {
414
+ expect(
415
+ nonPersonal(path.join(ROOT, "companies/indigo/board.json")),
416
+ ).toBe(true);
417
+ expect(
418
+ nonPersonal(path.join(ROOT, "workspace/agency/indigo/nick/status.json")),
419
+ ).toBe(true);
420
+ expect(
421
+ nonPersonal(path.join(ROOT, "workspace/threads/handoff.json")),
422
+ ).toBe(true);
423
+ // chokidar must still descend into workspace/ to reach agency + continuity.
424
+ expect(nonPersonal(path.join(ROOT, "workspace"), true)).toBe(true);
425
+ // A worktrees SIBLING under workspace/ is unaffected (only worktrees pruned).
426
+ expect(
427
+ nonPersonal(path.join(ROOT, "workspace/agency"), true),
428
+ ).toBe(true);
429
+ });
430
+
431
+ it("personal mode still excludes worktrees + repos (regression, unchanged)", () => {
432
+ expect(personal(path.join(ROOT, "workspace/worktrees/x/a.ts"))).toBe(false);
433
+ expect(personal(path.join(ROOT, "repos/private/x/a.ts"))).toBe(false);
434
+ });
353
435
  });
354
436
 
355
437
  describe("US-002: TreeWatcher — debounce coalesce (FakeClock seam)", () => {