@indigoai-us/hq-cloud 6.15.0 → 6.15.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 (329) hide show
  1. package/dist/bin/sync-mutation.d.ts +16 -0
  2. package/dist/bin/sync-mutation.d.ts.map +1 -0
  3. package/dist/bin/sync-mutation.js +60 -0
  4. package/dist/bin/sync-mutation.js.map +1 -0
  5. package/dist/bin/sync-mutation.test.d.ts +2 -0
  6. package/dist/bin/sync-mutation.test.d.ts.map +1 -0
  7. package/dist/bin/sync-mutation.test.js +165 -0
  8. package/dist/bin/sync-mutation.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts +8 -0
  10. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-company.js +16 -0
  12. package/dist/bin/sync-runner-company.js.map +1 -1
  13. package/dist/bin/sync-runner-company.test.d.ts +2 -0
  14. package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-company.test.js +36 -0
  16. package/dist/bin/sync-runner-company.test.js.map +1 -0
  17. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-watch-loop.js +98 -8
  19. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  20. package/dist/bin/sync-runner.d.ts +17 -0
  21. package/dist/bin/sync-runner.d.ts.map +1 -1
  22. package/dist/bin/sync-runner.js.map +1 -1
  23. package/dist/bin/sync-runner.test.js +109 -0
  24. package/dist/bin/sync-runner.test.js.map +1 -1
  25. package/dist/cli/conflict-recovery.test.d.ts +2 -0
  26. package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
  27. package/dist/cli/conflict-recovery.test.js +201 -0
  28. package/dist/cli/conflict-recovery.test.js.map +1 -0
  29. package/dist/cli/conflict.d.ts +60 -0
  30. package/dist/cli/conflict.d.ts.map +1 -1
  31. package/dist/cli/conflict.js +333 -0
  32. package/dist/cli/conflict.js.map +1 -1
  33. package/dist/cli/sync.d.ts +35 -0
  34. package/dist/cli/sync.d.ts.map +1 -1
  35. package/dist/cli/sync.js +100 -0
  36. package/dist/cli/sync.js.map +1 -1
  37. package/dist/cli/sync.test.js +85 -1
  38. package/dist/cli/sync.test.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/skill-telemetry.d.ts +6 -0
  44. package/dist/skill-telemetry.d.ts.map +1 -1
  45. package/dist/skill-telemetry.js +14 -2
  46. package/dist/skill-telemetry.js.map +1 -1
  47. package/dist/skill-telemetry.test.js +79 -0
  48. package/dist/skill-telemetry.test.js.map +1 -1
  49. package/dist/sync/candidate-uploader.d.ts +88 -0
  50. package/dist/sync/candidate-uploader.d.ts.map +1 -0
  51. package/dist/sync/candidate-uploader.js +212 -0
  52. package/dist/sync/candidate-uploader.js.map +1 -0
  53. package/dist/sync/candidate-uploader.test.d.ts +2 -0
  54. package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
  55. package/dist/sync/candidate-uploader.test.js +132 -0
  56. package/dist/sync/candidate-uploader.test.js.map +1 -0
  57. package/dist/sync/delta-client.d.ts +73 -0
  58. package/dist/sync/delta-client.d.ts.map +1 -0
  59. package/dist/sync/delta-client.js +201 -0
  60. package/dist/sync/delta-client.js.map +1 -0
  61. package/dist/sync/delta-client.test.d.ts +2 -0
  62. package/dist/sync/delta-client.test.d.ts.map +1 -0
  63. package/dist/sync/delta-client.test.js +97 -0
  64. package/dist/sync/delta-client.test.js.map +1 -0
  65. package/dist/sync/durable-apply.d.ts +76 -0
  66. package/dist/sync/durable-apply.d.ts.map +1 -0
  67. package/dist/sync/durable-apply.js +530 -0
  68. package/dist/sync/durable-apply.js.map +1 -0
  69. package/dist/sync/durable-apply.test.d.ts +2 -0
  70. package/dist/sync/durable-apply.test.d.ts.map +1 -0
  71. package/dist/sync/durable-apply.test.js +180 -0
  72. package/dist/sync/durable-apply.test.js.map +1 -0
  73. package/dist/sync/event-sync.d.ts +33 -1
  74. package/dist/sync/event-sync.d.ts.map +1 -1
  75. package/dist/sync/event-sync.js +149 -1
  76. package/dist/sync/event-sync.js.map +1 -1
  77. package/dist/sync/event-sync.test.js +142 -1
  78. package/dist/sync/event-sync.test.js.map +1 -1
  79. package/dist/sync/index.d.ts +2 -0
  80. package/dist/sync/index.d.ts.map +1 -1
  81. package/dist/sync/index.js +1 -0
  82. package/dist/sync/index.js.map +1 -1
  83. package/dist/sync/multipart-uploader.d.ts +99 -0
  84. package/dist/sync/multipart-uploader.d.ts.map +1 -0
  85. package/dist/sync/multipart-uploader.js +447 -0
  86. package/dist/sync/multipart-uploader.js.map +1 -0
  87. package/dist/sync/multipart-uploader.test.d.ts +2 -0
  88. package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
  89. package/dist/sync/multipart-uploader.test.js +119 -0
  90. package/dist/sync/multipart-uploader.test.js.map +1 -0
  91. package/dist/sync/mutation-client.d.ts +85 -0
  92. package/dist/sync/mutation-client.d.ts.map +1 -0
  93. package/dist/sync/mutation-client.js +245 -0
  94. package/dist/sync/mutation-client.js.map +1 -0
  95. package/dist/sync/mutation-client.test.d.ts +2 -0
  96. package/dist/sync/mutation-client.test.d.ts.map +1 -0
  97. package/dist/sync/mutation-client.test.js +51 -0
  98. package/dist/sync/mutation-client.test.js.map +1 -0
  99. package/dist/sync/push-receiver.d.ts +45 -0
  100. package/dist/sync/push-receiver.d.ts.map +1 -1
  101. package/dist/sync/push-receiver.js +101 -0
  102. package/dist/sync/push-receiver.js.map +1 -1
  103. package/dist/sync/push-receiver.test.js +54 -2
  104. package/dist/sync/push-receiver.test.js.map +1 -1
  105. package/dist/sync/scope-inventory-client.d.ts +69 -0
  106. package/dist/sync/scope-inventory-client.d.ts.map +1 -0
  107. package/dist/sync/scope-inventory-client.js +210 -0
  108. package/dist/sync/scope-inventory-client.js.map +1 -0
  109. package/dist/sync/scope-inventory-client.test.d.ts +2 -0
  110. package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
  111. package/dist/sync/scope-inventory-client.test.js +94 -0
  112. package/dist/sync/scope-inventory-client.test.js.map +1 -0
  113. package/dist/sync/snapshot-client.d.ts +98 -0
  114. package/dist/sync/snapshot-client.d.ts.map +1 -0
  115. package/dist/sync/snapshot-client.js +402 -0
  116. package/dist/sync/snapshot-client.js.map +1 -0
  117. package/dist/sync/snapshot-client.test.d.ts +2 -0
  118. package/dist/sync/snapshot-client.test.d.ts.map +1 -0
  119. package/dist/sync/snapshot-client.test.js +169 -0
  120. package/dist/sync/snapshot-client.test.js.map +1 -0
  121. package/dist/sync/uploader-finalization.d.ts +97 -0
  122. package/dist/sync/uploader-finalization.d.ts.map +1 -0
  123. package/dist/sync/uploader-finalization.js +273 -0
  124. package/dist/sync/uploader-finalization.js.map +1 -0
  125. package/dist/sync/uploader-finalization.test.d.ts +2 -0
  126. package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
  127. package/dist/sync/uploader-finalization.test.js +92 -0
  128. package/dist/sync/uploader-finalization.test.js.map +1 -0
  129. package/dist/telemetry.d.ts +11 -1
  130. package/dist/telemetry.d.ts.map +1 -1
  131. package/dist/telemetry.js +21 -2
  132. package/dist/telemetry.js.map +1 -1
  133. package/dist/telemetry.test.js +80 -0
  134. package/dist/telemetry.test.js.map +1 -1
  135. package/package.json +6 -1
  136. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
  137. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
  138. package/.github/workflows/ci.yml +0 -84
  139. package/.github/workflows/publish.yml +0 -56
  140. package/.github/workflows/unreleased-commits-nag.yml +0 -256
  141. package/eslint.config.js +0 -67
  142. package/pnpm-workspace.yaml +0 -2
  143. package/scripts/presign-transport-e2e.mjs +0 -250
  144. package/scripts/vault-rebaseline.sh +0 -323
  145. package/scripts/vault-rescue.sh +0 -332
  146. package/src/active-company.test.ts +0 -188
  147. package/src/active-company.ts +0 -168
  148. package/src/agent-codex-instructions.test.ts +0 -332
  149. package/src/agent-codex-instructions.ts +0 -309
  150. package/src/auth.ts +0 -146
  151. package/src/backup-prune.test.ts +0 -98
  152. package/src/backup-prune.ts +0 -182
  153. package/src/bin/backup-prune-runner.ts +0 -33
  154. package/src/bin/rescue-runner.ts +0 -25
  155. package/src/bin/sync-runner-company.ts +0 -695
  156. package/src/bin/sync-runner-events.test.ts +0 -143
  157. package/src/bin/sync-runner-events.ts +0 -55
  158. package/src/bin/sync-runner-planning.test.ts +0 -311
  159. package/src/bin/sync-runner-planning.ts +0 -258
  160. package/src/bin/sync-runner-rollup.test.ts +0 -37
  161. package/src/bin/sync-runner-rollup.ts +0 -97
  162. package/src/bin/sync-runner-telemetry.ts +0 -15
  163. package/src/bin/sync-runner-watch-loop.ts +0 -1235
  164. package/src/bin/sync-runner-watch-routes.test.ts +0 -71
  165. package/src/bin/sync-runner-watch-routes.ts +0 -184
  166. package/src/bin/sync-runner.test.ts +0 -8767
  167. package/src/bin/sync-runner.ts +0 -2190
  168. package/src/cli/accept.ts +0 -124
  169. package/src/cli/conflict.ts +0 -119
  170. package/src/cli/doctor.test.ts +0 -581
  171. package/src/cli/doctor.ts +0 -642
  172. package/src/cli/index.ts +0 -49
  173. package/src/cli/invite.test.ts +0 -250
  174. package/src/cli/invite.ts +0 -214
  175. package/src/cli/promote.ts +0 -157
  176. package/src/cli/reindex-knowledge.test.ts +0 -307
  177. package/src/cli/reindex-knowledge.ts +0 -450
  178. package/src/cli/reindex.test.ts +0 -957
  179. package/src/cli/reindex.ts +0 -979
  180. package/src/cli/rescue-classify-ordering.test.ts +0 -548
  181. package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
  182. package/src/cli/rescue-core.ts +0 -3011
  183. package/src/cli/rescue-drift-reconcile.test.ts +0 -179
  184. package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
  185. package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
  186. package/src/cli/rescue-hq-root-guard.test.ts +0 -232
  187. package/src/cli/rescue-journal-reconcile.test.ts +0 -215
  188. package/src/cli/rescue-mtime-preserve.test.ts +0 -203
  189. package/src/cli/rescue-settings-reconcile.test.ts +0 -637
  190. package/src/cli/rescue-snapshot.test.ts +0 -57
  191. package/src/cli/rescue-snapshot.ts +0 -51
  192. package/src/cli/rescue.reindex.test.ts +0 -63
  193. package/src/cli/rescue.test.ts +0 -131
  194. package/src/cli/rescue.ts +0 -182
  195. package/src/cli/share.test.ts +0 -7843
  196. package/src/cli/share.ts +0 -3663
  197. package/src/cli/sync-scope.test.ts +0 -652
  198. package/src/cli/sync.test.ts +0 -5207
  199. package/src/cli/sync.ts +0 -3470
  200. package/src/cli/tombstones.ts +0 -106
  201. package/src/cli/watch-event-push-conflict.test.ts +0 -234
  202. package/src/client-info.test.ts +0 -214
  203. package/src/client-info.ts +0 -121
  204. package/src/cognito-auth.test.ts +0 -712
  205. package/src/cognito-auth.ts +0 -1422
  206. package/src/company-resolver.test.ts +0 -618
  207. package/src/company-resolver.ts +0 -521
  208. package/src/context.test.ts +0 -583
  209. package/src/context.ts +0 -378
  210. package/src/daemon-worker.ts +0 -26
  211. package/src/daemon.ts +0 -99
  212. package/src/entity-resolver.test.ts +0 -315
  213. package/src/entity-resolver.ts +0 -180
  214. package/src/ignore.test.ts +0 -466
  215. package/src/ignore.ts +0 -469
  216. package/src/index.ts +0 -439
  217. package/src/journal.test.ts +0 -968
  218. package/src/journal.ts +0 -765
  219. package/src/lib/cloud-authoritative.test.ts +0 -45
  220. package/src/lib/cloud-authoritative.ts +0 -59
  221. package/src/lib/conflict-file.ts +0 -86
  222. package/src/lib/conflict-index.ts +0 -289
  223. package/src/lib/conflict.test.ts +0 -348
  224. package/src/lib/describe-error.test.ts +0 -100
  225. package/src/lib/describe-error.ts +0 -58
  226. package/src/lib/exit-codes.ts +0 -24
  227. package/src/lib/machine-id.test.ts +0 -231
  228. package/src/lib/machine-id.ts +0 -175
  229. package/src/lib/net-errors.test.ts +0 -65
  230. package/src/lib/net-errors.ts +0 -86
  231. package/src/lib/readlink-safe.test.ts +0 -43
  232. package/src/lib/readlink-safe.ts +0 -29
  233. package/src/local-path-codec.test.ts +0 -138
  234. package/src/local-path-codec.ts +0 -161
  235. package/src/machine-auth.test.ts +0 -1323
  236. package/src/manifest-reconcile.test.ts +0 -1123
  237. package/src/manifest-reconcile.ts +0 -518
  238. package/src/object-io.test.ts +0 -1221
  239. package/src/object-io.ts +0 -1306
  240. package/src/operation-lock.test.ts +0 -484
  241. package/src/operation-lock.ts +0 -680
  242. package/src/outcome-telemetry.test.ts +0 -498
  243. package/src/outcome-telemetry.ts +0 -639
  244. package/src/personal-vault-exclusions.test.ts +0 -308
  245. package/src/personal-vault-exclusions.ts +0 -354
  246. package/src/personal-vault.test.ts +0 -756
  247. package/src/personal-vault.ts +0 -496
  248. package/src/prefix-coalesce.test.ts +0 -240
  249. package/src/prefix-coalesce.ts +0 -273
  250. package/src/public-surface.test.ts +0 -117
  251. package/src/qmd-reindex.test.ts +0 -877
  252. package/src/qmd-reindex.ts +0 -842
  253. package/src/read-only-state-dir.test.ts +0 -188
  254. package/src/remote-pull.test.ts +0 -1130
  255. package/src/remote-pull.ts +0 -618
  256. package/src/s3.symlink-materialize.test.ts +0 -492
  257. package/src/s3.test.ts +0 -1789
  258. package/src/s3.ts +0 -1532
  259. package/src/schemas/signal-types.test.ts +0 -82
  260. package/src/schemas/signal-types.ts +0 -38
  261. package/src/schemas/source-channels.test.ts +0 -82
  262. package/src/schemas/source-channels.ts +0 -53
  263. package/src/scope-shrink.test.ts +0 -633
  264. package/src/scope-shrink.ts +0 -481
  265. package/src/signals/get.test.ts +0 -310
  266. package/src/signals/get.ts +0 -75
  267. package/src/signals/internals.ts +0 -195
  268. package/src/signals/list.test.ts +0 -420
  269. package/src/signals/list.ts +0 -79
  270. package/src/signals/parse.ts +0 -8
  271. package/src/signals/types.ts +0 -91
  272. package/src/skill-telemetry.test.ts +0 -1825
  273. package/src/skill-telemetry.ts +0 -1439
  274. package/src/sources/get.test.ts +0 -293
  275. package/src/sources/get.ts +0 -66
  276. package/src/sources/internals.ts +0 -198
  277. package/src/sources/list.test.ts +0 -402
  278. package/src/sources/list.ts +0 -84
  279. package/src/sources/parse.ts +0 -43
  280. package/src/sources/types.ts +0 -84
  281. package/src/sync/event-sync.test.ts +0 -594
  282. package/src/sync/event-sync.ts +0 -545
  283. package/src/sync/feature-flags.test.ts +0 -378
  284. package/src/sync/feature-flags.ts +0 -62
  285. package/src/sync/index.ts +0 -76
  286. package/src/sync/lease-client.test.ts +0 -128
  287. package/src/sync/lease-client.ts +0 -207
  288. package/src/sync/logger.test.ts +0 -242
  289. package/src/sync/logger.ts +0 -79
  290. package/src/sync/metrics.test.ts +0 -462
  291. package/src/sync/metrics.ts +0 -213
  292. package/src/sync/pull-scope.ts +0 -265
  293. package/src/sync/push-event.test.ts +0 -266
  294. package/src/sync/push-event.ts +0 -224
  295. package/src/sync/push-receiver.test.ts +0 -566
  296. package/src/sync/push-receiver.ts +0 -1048
  297. package/src/sync/push-transport.ts +0 -231
  298. package/src/sync/realtime-rollout.test.ts +0 -86
  299. package/src/sync/realtime-rollout.ts +0 -262
  300. package/src/sync/state-store.test.ts +0 -194
  301. package/src/sync/state-store.ts +0 -727
  302. package/src/sync-core.ts +0 -58
  303. package/src/sync-progress.test.ts +0 -94
  304. package/src/sync-progress.ts +0 -140
  305. package/src/telemetry-events.test.ts +0 -88
  306. package/src/telemetry-events.ts +0 -205
  307. package/src/telemetry.test.ts +0 -1280
  308. package/src/telemetry.ts +0 -1109
  309. package/src/types.ts +0 -314
  310. package/src/vault-client.test.ts +0 -1380
  311. package/src/vault-client.ts +0 -1694
  312. package/src/version.ts +0 -24
  313. package/src/watch-roots.test.ts +0 -278
  314. package/src/watch-roots.ts +0 -162
  315. package/src/watcher-event-gate.test.ts +0 -212
  316. package/src/watcher.test.ts +0 -1079
  317. package/src/watcher.ts +0 -1741
  318. package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
  319. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
  320. package/test/e2e/sync/transient-company-leg.test.ts +0 -384
  321. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
  322. package/test/e2e/watcher-real-chokidar.test.ts +0 -165
  323. package/test/e2e/watcher-recursive-backend.test.ts +0 -181
  324. package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
  325. package/test/invite-flow.integration.test.ts +0 -244
  326. package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
  327. package/test/share-sync.integration.test.ts +0 -213
  328. package/tsconfig.json +0 -19
  329. package/vitest.config.ts +0 -22
@@ -1,1439 +0,0 @@
1
- /**
2
- * Skill-invocation telemetry collector.
3
- *
4
- * Sibling to `./telemetry.ts` (the token-usage collector). Where that one
5
- * promotes token-accounting fields off each Claude Code session row, this one
6
- * extracts *which skill / slash-command was invoked*, reading the SAME
7
- * `~/.claude/projects/**\/*.jsonl` session logs but with an independent
8
- * byte-offset cursor at `~/.hq/skill-telemetry-cursor.json` and shipping to
9
- * `/v1/skill-invocations`.
10
- *
11
- * Why a separate collector rather than folding into `./telemetry.ts`: the
12
- * token path is proven and its per-batch cursor mechanics are load-bearing.
13
- * Skill events are sparse, so this collector uses a simpler all-or-nothing
14
- * per-run cursor commit (re-delivery is idempotent server-side via the
15
- * composite eventKey). Keeping it standalone means a bug here can never
16
- * regress token telemetry.
17
- *
18
- * Two capture paths, both recoverable from the transcript (verified against
19
- * real sessions):
20
- * - User-typed slash command → a `user` row whose content carries
21
- * `<command-name>/foo</command-name>` (+ optional `<command-args>`).
22
- * - Model-invoked skill → an `assistant` row with a `tool_use` block whose
23
- * `name === "Skill"` and `input.skill` names the skill.
24
- * The two are mutually exclusive per invocation, so there is no double-count.
25
- *
26
- * Codex CLI is captured too, from its own rollout logs at
27
- * `~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl`. Codex records cwd +
28
- * sessionId ONCE in a leading `session_meta` line (not on every row). Two Codex
29
- * paths feed the same wire shape, scope filter, batcher, and per-file cursor:
30
- * - Typed (`source: "typed"`) — a slash command, including an HQ skill, e.g.
31
- * `/indigo:hello-world`, is logged verbatim as a later `event_msg`
32
- * `user_message` (Codex does not expand it). Parallels Claude's typed path.
33
- * - Model-driven (`source: "model"`) — Codex has no discrete "Skill tool_use"
34
- * event like Claude. Instead it *runs* a skill by reading its instruction
35
- * file: the model issues a shell command that reads `…/skills/<name>/
36
- * SKILL.md`. Codex logs that exec in one of two shapes depending on CLI
37
- * version — an `event_msg` `exec_command_end` (with `turn_id`, `cwd`, and a
38
- * `parsed_cmd` it tags `type: "read"`) or a `response_item` `function_call`
39
- * named `exec_command` (command + `workdir` in its `arguments`). Both are
40
- * handled. We treat the read as one invocation of `<name>`. A single use
41
- * re-reads the file several times (line ranges, greps) and a version may log
42
- * both shapes for one exec, so events are deduped per (sessionId, turn_id,
43
- * skill) — at most one per Codex turn. Edits to a SKILL.md travel via
44
- * `apply_patch` (authoring, not using) and never reach this path, so skill
45
- * development is not miscounted as usage.
46
- *
47
- * Privacy: raw `<command-args>` / `input.args` content is NEVER sent to the
48
- * cloud — only a `hasArgs` boolean. This matches the message-stripping posture
49
- * of `./telemetry.ts::sanitizeRow`, which deliberately drops all prompt/tool
50
- * content client-side. Flip `INCLUDE_ARGS_PREVIEW` only with a deliberate
51
- * privacy review and a matching server-side allowlist change.
52
- *
53
- * Trust model + error handling are identical to `./telemetry.ts`: personUid is
54
- * resolved server-side from the JWT (never the body), and all errors are
55
- * swallowed so telemetry never aborts or delays a sync.
56
- */
57
-
58
- import { createHash } from "node:crypto";
59
- import { promises as fs } from "node:fs";
60
- import * as os from "node:os";
61
- import * as path from "node:path";
62
-
63
- import {
64
- buildRepoCompanyMap,
65
- resolveCompanyForCwd,
66
- resolveCompanyForSkill,
67
- type RepoCompanyMap,
68
- } from "./company-resolver.js";
69
- import {
70
- canonicalVaultSegmentSpelling,
71
- encodeLocalVaultSegment,
72
- } from "./local-path-codec.js";
73
- import type {
74
- SkillInvocationBatch,
75
- SkillInvocationIngestResult,
76
- TelemetryOptInResponse,
77
- } from "./vault-client.js";
78
-
79
- // ── Public surface ────────────────────────────────────────────────────────────
80
-
81
- export interface SkillTelemetryClientSurface {
82
- getTelemetryOptIn(): Promise<TelemetryOptInResponse>;
83
- postSkillInvocations(
84
- batch: SkillInvocationBatch,
85
- ): Promise<SkillInvocationIngestResult>;
86
- }
87
-
88
- export interface CollectSkillTelemetryOptions {
89
- client: SkillTelemetryClientSurface;
90
- machineId: string;
91
- installerVersion: string;
92
- /**
93
- * When set, only invocations whose recorded `cwd` equals this path are
94
- * emitted — scoping capture to the HQ project and excluding skill usage in
95
- * unrelated repos on the same machine. The walk still covers all of
96
- * `~/.claude/projects` (so the cursor stays consistent and no session is
97
- * silently missed by a project-dir-name encoding guess), but non-matching
98
- * events are dropped before they are batched. Omit to capture every project.
99
- */
100
- hqRoot?: string;
101
- /** Override `~/.claude/projects` for tests. */
102
- claudeProjectsRoot?: string;
103
- /** Override `~/.codex/sessions` (the Codex CLI rollout root) for tests. */
104
- codexSessionsRoot?: string;
105
- /** Override `~/.hq/skill-telemetry-cursor.json` for tests. */
106
- cursorPath?: string;
107
- /** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */
108
- menubarPath?: string;
109
- /**
110
- * Maximum transcript bytes to inspect per source (Claude or Codex) in one
111
- * collection pass. The next pass resumes from the last complete line.
112
- * Override for deterministic bounded-scan tests.
113
- */
114
- maxScanBytesPerSource?: number;
115
- /**
116
- * Override skillVersion resolution (skills-first-class US-015). Given a skill
117
- * name, return its content-hash version marker (`sha256:<hex>`) or undefined.
118
- * Defaults to hashing `<hqRoot>/.claude/skills/<skill>/SKILL.md` via
119
- * {@link computeSkillVersion}. Injected in tests to decouple emission from the
120
- * on-disk skills tree.
121
- */
122
- resolveSkillVersion?: (
123
- skill: string,
124
- ) => Promise<string | undefined> | string | undefined;
125
- /** Diagnostic sink. No-op by default. */
126
- log?: (msg: string) => void;
127
- }
128
-
129
- export interface CollectSkillTelemetryResult {
130
- enabled: boolean;
131
- optInSource: "server" | "menubar-fallback" | "skipped";
132
- filesScanned: number;
133
- eventsSent: number;
134
- batchesSent: number;
135
- }
136
-
137
- /** A single extracted skill-invocation event. Mirrors the server allowlist in
138
- * `apps/hq-pro/src/vault-service/handlers/skill-invocations.ts` (KEEP_FIELDS).
139
- * Any drift surfaces as `unexpected-event-field` in the ingest result. */
140
- export interface SkillEvent {
141
- skill: string;
142
- source: "typed" | "model";
143
- sessionId?: string;
144
- timestamp?: string;
145
- uuid?: string;
146
- cwd?: string;
147
- hasArgs: boolean;
148
- /**
149
- * Content-version marker (skills-first-class US-015) — the sha256 of the
150
- * skill's SKILL.md, `sha256:<hex>`. The version half of the analytics-v2 usage
151
- * join key (`skill_uid` + `skillVersion`), so a run maps to the version that
152
- * produced it. Resolved + stamped at capture (see `computeSkillVersion` +
153
- * `collectAndSendSkillTelemetry`); OPTIONAL — absent when the SKILL.md can't
154
- * be located, and additive on the wire (old clients/rows simply omit it).
155
- */
156
- skillVersion?: string;
157
- }
158
-
159
- // Privacy switch — keep false (see file header). When false, raw argument text
160
- // never leaves the machine; only the `hasArgs` boolean is emitted.
161
- const INCLUDE_ARGS_PREVIEW = false;
162
-
163
- // ── Cursor schema (independent from the token collector's) ──────────────────────
164
-
165
- type SkillTelemetrySource = "claude" | "codex";
166
-
167
- interface PendingLineCursor {
168
- /** Offset of the unterminated JSONL record's first byte. */
169
- start: number;
170
- /** Furthest byte inspected for that record; no transcript bytes are stored. */
171
- scannedOffset: number;
172
- }
173
-
174
- interface CodexCursorState {
175
- /** Latest turn context, needed by function_call rows after a bounded resume. */
176
- turnId?: string;
177
- /** Dedupe keys from the active turn, so repeated skill reads stay collapsed. */
178
- seen?: string[];
179
- }
180
-
181
- interface CursorEntry {
182
- offset: number;
183
- mtime: number;
184
- pendingLine?: PendingLineCursor;
185
- codex?: CodexCursorState;
186
- }
187
-
188
- interface SkillCursor {
189
- version: string;
190
- files: Record<string, CursorEntry>;
191
- /** Rotate each runtime's file order so one growing backlog cannot starve peers. */
192
- lastScannedBySource?: Partial<Record<SkillTelemetrySource, string>>;
193
- }
194
-
195
- function clonePendingLine(value: unknown): PendingLineCursor | undefined {
196
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
197
- const pending = value as Partial<PendingLineCursor>;
198
- const { start, scannedOffset } = pending;
199
- if (
200
- typeof start !== "number" ||
201
- typeof scannedOffset !== "number" ||
202
- !Number.isSafeInteger(start) ||
203
- !Number.isSafeInteger(scannedOffset) ||
204
- start < 0 ||
205
- scannedOffset < start
206
- ) {
207
- return undefined;
208
- }
209
- return { start, scannedOffset };
210
- }
211
-
212
- function cloneCodexCursorState(value: unknown): CodexCursorState | undefined {
213
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
214
- const state = value as Partial<CodexCursorState>;
215
- const turnId = typeof state.turnId === "string" ? state.turnId : undefined;
216
- const seen = Array.isArray(state.seen)
217
- ? state.seen.filter((entry): entry is string => typeof entry === "string")
218
- : [];
219
- return turnId === undefined && seen.length === 0 ? undefined : { turnId, seen };
220
- }
221
-
222
- function snapshotCodexCursorState(
223
- turnId: string | undefined,
224
- seen: Set<string> | undefined,
225
- ): CodexCursorState | undefined {
226
- if (turnId === undefined && (seen === undefined || seen.size === 0)) return undefined;
227
- return { turnId, ...(seen && seen.size > 0 ? { seen: [...seen] } : {}) };
228
- }
229
-
230
- function rotateFilesFromLast(
231
- files: string[],
232
- lastScanned: string | undefined,
233
- ): string[] {
234
- if (!lastScanned) return files;
235
- const index = files.indexOf(lastScanned);
236
- return index < 0 ? files : [...files.slice(index + 1), ...files.slice(0, index + 1)];
237
- }
238
-
239
- async function loadCursor(cursorPath: string): Promise<SkillCursor> {
240
- try {
241
- const raw = await fs.readFile(cursorPath, "utf-8");
242
- const parsed = JSON.parse(raw) as Partial<SkillCursor>;
243
- if (parsed && typeof parsed === "object" && parsed.files && typeof parsed.files === "object") {
244
- return {
245
- version: parsed.version ?? "1",
246
- files: parsed.files as Record<string, CursorEntry>,
247
- ...(parsed.lastScannedBySource && typeof parsed.lastScannedBySource === "object"
248
- ? {
249
- lastScannedBySource: parsed.lastScannedBySource as Partial<
250
- Record<SkillTelemetrySource, string>
251
- >,
252
- }
253
- : {}),
254
- };
255
- }
256
- } catch {
257
- // Missing / unparseable — start fresh.
258
- }
259
- return { version: "1", files: {} };
260
- }
261
-
262
- async function saveCursor(cursorPath: string, cursor: SkillCursor): Promise<void> {
263
- await fs.mkdir(path.dirname(cursorPath), { recursive: true });
264
- const tmp = `${cursorPath}.tmp`;
265
- await fs.writeFile(tmp, JSON.stringify(cursor, null, 2), "utf-8");
266
- await fs.rename(tmp, cursorPath);
267
- }
268
-
269
- async function readLocalTelemetryEnabled(menubarPath: string): Promise<boolean> {
270
- try {
271
- const raw = await fs.readFile(menubarPath, "utf-8");
272
- const parsed = JSON.parse(raw) as { telemetryEnabled?: unknown };
273
- return parsed.telemetryEnabled === true;
274
- } catch {
275
- return false;
276
- }
277
- }
278
-
279
- // ── Extractor ───────────────────────────────────────────────────────────────
280
-
281
- const CMD_NAME = /<command-name>\s*\/?([^<]+?)\s*<\/command-name>/;
282
- const CMD_ARGS = /<command-args>([\s\S]*?)<\/command-args>/;
283
-
284
- function rowText(content: unknown): string {
285
- if (typeof content === "string") return content;
286
- if (Array.isArray(content)) {
287
- return content
288
- .map((b) => (b && typeof b === "object" && typeof (b as Record<string, unknown>).text === "string"
289
- ? ((b as Record<string, unknown>).text as string)
290
- : ""))
291
- .join(" ");
292
- }
293
- return "";
294
- }
295
-
296
- /**
297
- * Extract zero or more skill-invocation events from a single parsed session
298
- * row. A `user` row yields at most one typed command; an `assistant` row can
299
- * carry multiple `Skill` tool_use blocks (rare, but handled).
300
- */
301
- export function extractSkillEvents(row: unknown): SkillEvent[] {
302
- if (!row || typeof row !== "object" || Array.isArray(row)) return [];
303
- const obj = row as Record<string, unknown>;
304
- const type = obj.type;
305
- const msg =
306
- obj.message && typeof obj.message === "object" && !Array.isArray(obj.message)
307
- ? (obj.message as Record<string, unknown>)
308
- : undefined;
309
- if (!msg) return [];
310
-
311
- const sessionId = typeof obj.sessionId === "string" ? obj.sessionId : undefined;
312
- const timestamp = typeof obj.timestamp === "string" ? obj.timestamp : undefined;
313
- const cwd = typeof obj.cwd === "string" ? obj.cwd : undefined;
314
- const rowUuid = typeof obj.uuid === "string" ? obj.uuid : undefined;
315
-
316
- // Path A — user-typed slash command.
317
- if (type === "user") {
318
- const text = rowText(msg.content);
319
- const m = CMD_NAME.exec(text);
320
- if (!m) return [];
321
- const a = CMD_ARGS.exec(text);
322
- return [
323
- {
324
- skill: m[1].trim(),
325
- source: "typed",
326
- sessionId,
327
- timestamp,
328
- cwd,
329
- uuid: rowUuid,
330
- hasArgs: Boolean(a && a[1].trim()),
331
- },
332
- ];
333
- }
334
-
335
- // Path B — model-invoked Skill tool_use.
336
- if (type === "assistant" && Array.isArray(msg.content)) {
337
- const out: SkillEvent[] = [];
338
- for (const blk of msg.content as unknown[]) {
339
- if (!blk || typeof blk !== "object") continue;
340
- const b = blk as Record<string, unknown>;
341
- if (b.type !== "tool_use" || b.name !== "Skill") continue;
342
- const input =
343
- b.input && typeof b.input === "object" && !Array.isArray(b.input)
344
- ? (b.input as Record<string, unknown>)
345
- : {};
346
- const skill = typeof input.skill === "string" ? input.skill : "";
347
- if (!skill) continue;
348
- const args = input.args;
349
- out.push({
350
- skill,
351
- source: "model",
352
- sessionId,
353
- timestamp,
354
- cwd,
355
- // Prefer the tool_use block id (stable, globally unique) for dedup.
356
- uuid: typeof b.id === "string" ? b.id : rowUuid,
357
- hasArgs: typeof args === "string" ? args.trim().length > 0 : Boolean(args),
358
- });
359
- }
360
- return out;
361
- }
362
-
363
- return [];
364
- }
365
-
366
- // ── Codex rollout extractor ───────────────────────────────────────────────────
367
-
368
- /** Match a leading slash command in a Codex `user_message`: a single token
369
- * (no internal "/", so file paths like "/etc/hosts" are rejected) optionally
370
- * followed by whitespace + args. Group 1 = skill/command, group 2 = args. */
371
- const CODEX_CMD = /^\/([^/\s]+)(?:\s+([\s\S]*))?$/;
372
-
373
- /** Parse cwd + sessionId from a Codex `session_meta` rollout line. Returns null
374
- * for any other line type. */
375
- export function parseCodexSessionMeta(
376
- row: unknown,
377
- ): { sessionId?: string; cwd?: string } | null {
378
- if (!row || typeof row !== "object" || Array.isArray(row)) return null;
379
- const obj = row as Record<string, unknown>;
380
- if (obj.type !== "session_meta") return null;
381
- const payload =
382
- obj.payload && typeof obj.payload === "object" && !Array.isArray(obj.payload)
383
- ? (obj.payload as Record<string, unknown>)
384
- : {};
385
- const sessionId = typeof payload.id === "string" ? payload.id : undefined;
386
- const cwd = typeof payload.cwd === "string" ? payload.cwd : undefined;
387
- return { sessionId, cwd };
388
- }
389
-
390
- /** The `turn_id` a Codex rollout row belongs to, when it carries one
391
- * (`turn_context` and `exec_command_end` do; bare `function_call` execs do
392
- * not). Used to track the running turn so the function_call exec shape can be
393
- * attributed to the turn that preceded it. */
394
- export function codexRowTurnId(row: unknown): string | undefined {
395
- if (!row || typeof row !== "object" || Array.isArray(row)) return undefined;
396
- const payload = (row as Record<string, unknown>).payload;
397
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
398
- const t = (payload as Record<string, unknown>).turn_id;
399
- return typeof t === "string" ? t : undefined;
400
- }
401
-
402
- /**
403
- * Extract a typed skill/slash-command invocation from a Codex `event_msg`
404
- * `user_message` row. Session context (cwd, sessionId) lives in the file's
405
- * leading `session_meta` line and is threaded in via `ctx`. Returns 0 or 1
406
- * event (a Codex user_message carries at most one command).
407
- */
408
- export function extractCodexSkillEvents(
409
- row: unknown,
410
- ctx: { sessionId?: string; cwd?: string },
411
- ): SkillEvent[] {
412
- if (!row || typeof row !== "object" || Array.isArray(row)) return [];
413
- const obj = row as Record<string, unknown>;
414
- if (obj.type !== "event_msg") return [];
415
- const payload =
416
- obj.payload && typeof obj.payload === "object" && !Array.isArray(obj.payload)
417
- ? (obj.payload as Record<string, unknown>)
418
- : undefined;
419
- if (!payload || payload.type !== "user_message") return [];
420
- const message = typeof payload.message === "string" ? payload.message : "";
421
- const m = CODEX_CMD.exec(message.trim());
422
- if (!m) return [];
423
- const skill = m[1].trim();
424
- if (!skill) return [];
425
- const timestamp = typeof obj.timestamp === "string" ? obj.timestamp : undefined;
426
- // Codex events carry no per-event id, so synthesize a stable, content-derived
427
- // uuid (sessionId + timestamp + skill are unique per invocation) for
428
- // idempotent re-delivery — the server dedups on the composite eventKey.
429
- const uuid =
430
- ctx.sessionId !== undefined && timestamp !== undefined
431
- ? `codex:${ctx.sessionId}:${timestamp}:${skill}`
432
- : undefined;
433
- return [
434
- {
435
- skill,
436
- source: "typed",
437
- sessionId: ctx.sessionId,
438
- timestamp,
439
- cwd: ctx.cwd,
440
- uuid,
441
- hasArgs: Boolean(m[2] && m[2].trim()),
442
- },
443
- ];
444
- }
445
-
446
- /** Match a `…/skills/<name>/SKILL.md` path inside a shell command. `skills/`
447
- * may be nested arbitrarily deep (`.agents/skills/…`, `.codex/skills/hq/…`),
448
- * and `<name>` is always the directory immediately above SKILL.md — captured
449
- * as the last segment so the bridge's `skills/hq/<name>/` layout resolves to
450
- * `<name>`, not `hq`. The segment classes deliberately exclude `/` so segment
451
- * boundaries are unambiguous and non-matching `skills/...` paths cannot
452
- * catastrophically backtrack. */
453
- const CODEX_SKILL_FILE =
454
- /(?:^|\/)skills\/(?:[^/\s'"]+\/)*?([^/\s'"]+)\/SKILL\.md\b/;
455
-
456
- /** A company skill read via its REAL path `companies/<slug>/skills/<name>/
457
- * SKILL.md`. Codex loads a skill by *reading* its file, and a company skill's
458
- * real file lives under `companies/<slug>/skills/` — so, unlike the namespaced
459
- * `.claude/skills/<slug>:<name>/` symlink (whose captured directory already
460
- * carries the `<slug>:` prefix), this path names the owning company in a
461
- * SEPARATE `<slug>` segment that {@link CODEX_SKILL_FILE} discards, leaving a
462
- * bare `<name>` that no prefix-based attribution can place (the company view
463
- * then misses the run). Group 1 = `<slug>`, group 2 = `<name>`: we rebuild the
464
- * canonical `<slug>:<name>` invocation token from them so the event flows
465
- * through the SAME attribution as every other runtime — the client
466
- * `resolveCompanyForSkill` and the server rollup both key off that `<slug>:`
467
- * prefix — and shares one `skill_uid` with the Claude/typed forms. The slug
468
- * segment excludes `:` (a real company slug has none); skill discovery is one
469
- * level deep, so `<name>` is a single segment. The leading boundary admits a
470
- * RELATIVE `companies/…` path too (preceded by whitespace/quote/start, not just
471
- * `/`), since Codex often reads it relative to the run cwd. */
472
- const CODEX_COMPANY_SKILL_FILE =
473
- /(?:^|[\s/'"])companies\/([^/\s'":]+)\/skills\/([^/\s'"]+)\/SKILL\.md\b/;
474
-
475
- /** Pull the shell command string out of a Codex `exec_command_end` `command`,
476
- * which is `["/bin/zsh", "-lc", "<cmd>"]` (array) on the runtimes we see, but
477
- * tolerate a bare string too. */
478
- function codexCommandString(command: unknown): string {
479
- if (typeof command === "string") return command;
480
- if (Array.isArray(command)) {
481
- // The interpreter + flags lead; the actual command is the trailing string.
482
- for (let i = command.length - 1; i >= 0; i--) {
483
- if (typeof command[i] === "string") return command[i] as string;
484
- }
485
- }
486
- return "";
487
- }
488
-
489
- /** Classify a Codex exec from its own `parsed_cmd`:
490
- * - `"read"` — every classified sub-command is a read; authoritative.
491
- * - `"nonread"` — at least one write/apply_patch-style entry; authoritative,
492
- * the command text is NOT consulted (Codex's call wins).
493
- * - `"unknown"` — no usable classification (missing/empty); the caller then
494
- * falls back to a read-verb check on the command text.
495
- */
496
- function classifyCodexExec(parsedCmd: unknown): "read" | "nonread" | "unknown" {
497
- if (!Array.isArray(parsedCmd)) return "unknown";
498
- let sawRead = false;
499
- for (const entry of parsedCmd) {
500
- if (!entry || typeof entry !== "object") continue;
501
- const t = (entry as Record<string, unknown>).type;
502
- if (typeof t !== "string") continue;
503
- if (t !== "read") return "nonread";
504
- sawRead = true;
505
- }
506
- return sawRead ? "read" : "unknown";
507
- }
508
-
509
- // A leading read verb in the command — the positive signal that an exec is
510
- // inspecting SKILL.md rather than rewriting it. Anchored after an optional
511
- // `cd …;`/env-var prefix so `sed`, `rg`, `cat`, … are recognized at the head of
512
- // the real command.
513
- const CODEX_READ_VERB =
514
- /(?:^|[;&|]\s*|\bcd\s+[^\s;]+\s*;\s*)(sed|cat|head|tail|nl|rg|grep|less|bat|wc|awk|cut|fold|view|print)\b/;
515
- // Writing into the skill file disqualifies regardless of a read verb elsewhere.
516
- const CODEX_WRITE_TO_SKILL = /(?:>>?|\btee\b)[^\n]*\/SKILL\.md\b/;
517
-
518
- /** Codex tool-call names that run a shell command (the `response_item`
519
- * `function_call` form). Excludes `apply_patch` — that is an edit, not a read. */
520
- const CODEX_EXEC_TOOLS = new Set([
521
- "exec_command",
522
- "shell",
523
- "local_shell",
524
- "local_shell_call",
525
- "bash",
526
- "container.exec",
527
- ]);
528
-
529
- /**
530
- * Normalize a completed Codex exec from either shape the CLI emits (it varies
531
- * by version), returning the command string, the turn it belongs to, the run
532
- * `cwd`, and any `parsed_cmd` classification — or null when the row is neither.
533
- * - `event_msg` / `exec_command_end`: `command` array, own `turn_id` + `cwd`,
534
- * and a `parsed_cmd` Codex tags `type: "read"`.
535
- * - `response_item` / `function_call` (name `exec_command`/`shell`/…): the
536
- * command lives in `arguments` (a JSON string) as `cmd`/`command`, the run
537
- * dir as `workdir`. No `turn_id`/`parsed_cmd` on the row, so the turn comes
538
- * from the scan's running `ctx.turnId` (tracked from `turn_context`) and
539
- * read-intent is decided by the command text.
540
- */
541
- function codexExecParams(
542
- obj: Record<string, unknown>,
543
- payload: Record<string, unknown>,
544
- ctx: { cwd?: string; turnId?: string },
545
- ): { cmd: string; turnId?: string; cwd?: string; parsedCmd: unknown } | null {
546
- if (obj.type === "event_msg" && payload.type === "exec_command_end") {
547
- const cmd = codexCommandString(payload.command);
548
- if (!cmd) return null;
549
- return {
550
- cmd,
551
- turnId: typeof payload.turn_id === "string" ? payload.turn_id : ctx.turnId,
552
- cwd: typeof payload.cwd === "string" ? payload.cwd : ctx.cwd,
553
- parsedCmd: payload.parsed_cmd,
554
- };
555
- }
556
- if (obj.type === "response_item" && payload.type === "function_call") {
557
- const name = typeof payload.name === "string" ? payload.name : "";
558
- if (!CODEX_EXEC_TOOLS.has(name)) return null;
559
- let args: Record<string, unknown> = {};
560
- const raw = payload.arguments;
561
- if (typeof raw === "string") {
562
- try {
563
- const parsed = JSON.parse(raw);
564
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
565
- args = parsed as Record<string, unknown>;
566
- }
567
- } catch {
568
- return null;
569
- }
570
- } else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
571
- args = raw as Record<string, unknown>;
572
- }
573
- const cmdRaw = args.cmd ?? args.command;
574
- const cmd = typeof cmdRaw === "string" ? cmdRaw : codexCommandString(cmdRaw);
575
- if (!cmd) return null;
576
- return {
577
- cmd,
578
- turnId: ctx.turnId,
579
- cwd: typeof args.workdir === "string" ? args.workdir : ctx.cwd,
580
- parsedCmd: undefined,
581
- };
582
- }
583
- return null;
584
- }
585
-
586
- /**
587
- * Extract a model-driven skill invocation from a completed Codex exec — the
588
- * model ran a shell command that *reads* a skill's `SKILL.md`, which is how
589
- * Codex loads and runs a skill (it has no discrete Skill tool_use). Handles both
590
- * Codex exec shapes (see `codexExecParams`). Returns 0 or 1 event tagged
591
- * `source: "model"`.
592
- *
593
- * Dedup is per (sessionId, turn_id, skill): a single skill use re-reads the file
594
- * several times (line ranges, greps) and some Codex versions log both exec
595
- * shapes for one exec, so the caller threads a `seen` Set to collapse them to
596
- * one event per Codex turn. When `seen` is omitted (unit tests), no dedup is
597
- * applied. Session context (sessionId, cwd, and the running turnId) comes via
598
- * `ctx`; the row's own `cwd` is preferred when present.
599
- */
600
- export function extractCodexSkillToolEvents(
601
- row: unknown,
602
- ctx: { sessionId?: string; cwd?: string; turnId?: string },
603
- seen?: Set<string>,
604
- ): SkillEvent[] {
605
- if (!row || typeof row !== "object" || Array.isArray(row)) return [];
606
- const obj = row as Record<string, unknown>;
607
- const payload =
608
- obj.payload && typeof obj.payload === "object" && !Array.isArray(obj.payload)
609
- ? (obj.payload as Record<string, unknown>)
610
- : undefined;
611
- if (!payload) return [];
612
-
613
- const exec = codexExecParams(obj, payload, ctx);
614
- if (!exec) return [];
615
- const { cmd } = exec;
616
- // Real exec commands are tiny; avoid running regexes over pathological rows.
617
- if (cmd.length > 100_000) return [];
618
- const m = CODEX_SKILL_FILE.exec(cmd);
619
- if (!m) return [];
620
- // Confirm the exec is a read of the skill file, not a write to it. Codex's own
621
- // classification leads; only when it is absent does the command text decide.
622
- if (CODEX_WRITE_TO_SKILL.test(cmd)) return [];
623
- const cls = classifyCodexExec(exec.parsedCmd);
624
- if (cls === "nonread") return [];
625
- if (cls === "unknown" && !CODEX_READ_VERB.test(cmd)) return [];
626
-
627
- // Prefer the canonical `<slug>:<name>` token when the read is of a company
628
- // skill's real file (`companies/<slug>/skills/<name>/SKILL.md`) — the slug is
629
- // in the path but dropped by CODEX_SKILL_FILE, so reconstruct it here to keep
630
- // the run attributable. Otherwise keep the last-segment name (core/personal/
631
- // namespaced skills already carry their prefix in the captured directory).
632
- const co = CODEX_COMPANY_SKILL_FILE.exec(cmd);
633
- const skill = co ? `${co[1]}:${co[2]}` : m[1].trim();
634
- if (!skill) return [];
635
-
636
- const sessionId = ctx.sessionId;
637
- const turnId = exec.turnId;
638
- const timestamp = typeof obj.timestamp === "string" ? obj.timestamp : undefined;
639
- const cwd = exec.cwd;
640
-
641
- // Dedup key: one invocation per (session, turn, skill). Fall back to the
642
- // session when no turn_id is present (still collapses a turn's repeat reads
643
- // for the common single-turn case, since they share a timestamp-free key).
644
- const dedupKey = `${sessionId ?? ""}:${turnId ?? ""}:${skill}`;
645
- if (seen) {
646
- if (seen.has(dedupKey)) return [];
647
- seen.add(dedupKey);
648
- }
649
-
650
- // Synthesize a stable uuid so re-delivery across syncs is idempotent
651
- // server-side. Prefer (session, turn) — globally unique per invocation; fall
652
- // back to (session, timestamp) so distinct reads aren't all collapsed when a
653
- // turn_id is somehow absent.
654
- const uuid =
655
- sessionId !== undefined && turnId !== undefined
656
- ? `codex:skill:${sessionId}:${turnId}:${skill}`
657
- : sessionId !== undefined && timestamp !== undefined
658
- ? `codex:skill:${sessionId}:${timestamp}:${skill}`
659
- : undefined;
660
-
661
- return [
662
- {
663
- skill,
664
- source: "model",
665
- sessionId,
666
- timestamp,
667
- cwd,
668
- uuid,
669
- hasArgs: false,
670
- },
671
- ];
672
- }
673
-
674
- /**
675
- * Resolve a skill's content-version marker ("skillVersion", skills-first-class
676
- * US-015) — the sha256 of its SKILL.md as `sha256:<hex>` (the canonical
677
- * content-hash shape used across hq-cloud, e.g. `watcher.ts`). This is the
678
- * version half of the analytics-v2 usage join key (`skill_uid` + `skillVersion`),
679
- * so a captured run maps to the exact version that produced it.
680
- *
681
- * Resolution is best-effort against `<hqRoot>/.claude/skills/<skill>/SKILL.md` —
682
- * the canonical single-source skills location, which holds an entry (a real dir
683
- * or a symlink) for every skill by its FULL invocation name incl. namespace
684
- * (`indigo:hello-world`, `personal:worktree`, `deploy`, …). A `.agents/skills/`
685
- * fallback covers the Codex bridge layout. `readFile` follows symlinks, so a
686
- * company/personal skill surfaced into `.claude/skills/` resolves transparently.
687
- * Returns undefined when `hqRoot` is absent or the file can't be located/read,
688
- * so the wire field is simply OMITTED — additive + backwards-compatible, exactly
689
- * like the optional `companyUid` attribution.
690
- *
691
- * "At invocation time" is approximated by the SKILL.md's content AT CAPTURE TIME:
692
- * the collector scans historical session logs during sync, so the file's current
693
- * bytes are the closest available proxy for the version that ran. A skill edited
694
- * between run and sync can differ — acceptable for a usage-rollup join key (the
695
- * overwhelming majority of runs sync before any edit to the skill).
696
- */
697
- export async function computeSkillVersion(
698
- hqRoot: string | undefined,
699
- skill: string,
700
- ): Promise<string | undefined> {
701
- if (!hqRoot || !skill) return undefined;
702
- // A skill token maps to exactly one directory name here; reject anything that
703
- // could escape the skills dir (path traversal / separators). Namespaced names
704
- // use `:` (not a separator) and pass.
705
- if (skill.includes("/") || skill.includes("\\") || skill.includes("..")) {
706
- return undefined;
707
- }
708
- // Codec boundary: callers hand us EITHER the canonical invocation name
709
- // (`indigo:hello-world`) or an already-encoded local dir segment captured
710
- // from a win32 path (`indigo%3Ahello-world`, Codex telemetry extraction).
711
- // Normalize to canonical first — encoding an already-encoded name would
712
- // look under `indigo%253A...` and miss the real wrapper — then encode for
713
- // THIS host. Re-run the traversal guard on the decoded form: a crafted
714
- // `%2F`/`%5C` escape must not smuggle a separator past the raw check.
715
- const canonicalSkill = canonicalVaultSegmentSpelling(skill);
716
- if (
717
- canonicalSkill.includes("/") ||
718
- canonicalSkill.includes("\\") ||
719
- canonicalSkill.includes("..")
720
- ) {
721
- return undefined;
722
- }
723
- const localSkillDirName = encodeLocalVaultSegment(canonicalSkill);
724
- const candidates = [
725
- path.join(hqRoot, ".claude", "skills", localSkillDirName, "SKILL.md"),
726
- path.join(hqRoot, ".agents", "skills", localSkillDirName, "SKILL.md"),
727
- ];
728
- // Colon-free names do not normalize (amplifier families are namespaced),
729
- // so a win32 capture of a space-named skill's dir (`foo%20bar`) would
730
- // re-encode to `foo%2520bar` and miss — also try the raw segment as-is.
731
- if (skill !== localSkillDirName) {
732
- candidates.push(
733
- path.join(hqRoot, ".claude", "skills", skill, "SKILL.md"),
734
- path.join(hqRoot, ".agents", "skills", skill, "SKILL.md"),
735
- );
736
- }
737
- for (const candidate of candidates) {
738
- try {
739
- const bytes = await fs.readFile(candidate);
740
- return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
741
- } catch {
742
- // Not at this candidate — try the next.
743
- }
744
- }
745
- return undefined;
746
- }
747
-
748
- /** Shape the event for the wire. Drops raw args unless explicitly enabled.
749
- *
750
- * `companyUid` (US-002): the caller resolves the event's `cwd` → owning company
751
- * (`resolveCompanyForCwd`) and passes that `cmp_*` uid here. It is on the
752
- * server's KEEP allowlist (`apps/hq-pro/src/vault-service/handlers/
753
- * skill-invocations.ts`). When undefined (cwd maps to no company repo) the
754
- * field is OMITTED — the server treats absence as unattributed/personal. The
755
- * reserved value `unattributed` is never produced.
756
- *
757
- * `skillVersion` (US-015): the caller stamps `ev.skillVersion` from
758
- * `computeSkillVersion` before shaping; a `sha256:<hex>` marker (or omitted when
759
- * unresolved). Also on the server KEEP allowlist; additive + backwards-compatible. */
760
- function toWireRow(ev: SkillEvent, companyUid?: string): Record<string, unknown> {
761
- const row: Record<string, unknown> = {
762
- skill: ev.skill,
763
- source: ev.source,
764
- hasArgs: ev.hasArgs,
765
- };
766
- if (ev.sessionId !== undefined) row.sessionId = ev.sessionId;
767
- if (ev.timestamp !== undefined) row.timestamp = ev.timestamp;
768
- if (ev.uuid !== undefined) row.uuid = ev.uuid;
769
- if (ev.cwd !== undefined) row.cwd = ev.cwd;
770
- if (ev.skillVersion !== undefined) row.skillVersion = ev.skillVersion;
771
- if (companyUid !== undefined) row.companyUid = companyUid;
772
- // INCLUDE_ARGS_PREVIEW is intentionally a compile-time constant `false`;
773
- // the guarded branch documents the (currently disabled) egress path.
774
- if (INCLUDE_ARGS_PREVIEW) {
775
- // Reserved: a server allowlist change must land before this is enabled.
776
- }
777
- return row;
778
- }
779
-
780
- // ── File walker ───────────────────────────────────────────────────────────────
781
-
782
- async function listJsonlFiles(root: string): Promise<string[]> {
783
- const out: string[] = [];
784
- async function walk(dir: string): Promise<void> {
785
- let entries;
786
- try {
787
- entries = await fs.readdir(dir, { withFileTypes: true });
788
- } catch {
789
- return;
790
- }
791
- for (const ent of entries) {
792
- const full = path.join(dir, ent.name);
793
- if (ent.isDirectory()) {
794
- await walk(full);
795
- } else if (ent.isFile() && ent.name.endsWith(".jsonl")) {
796
- out.push(full);
797
- }
798
- }
799
- }
800
- await walk(root);
801
- return out.sort((a, b) => a.localeCompare(b));
802
- }
803
-
804
- // Codex's `session_meta` is always the first, small line of a rollout. Read a
805
- // bounded prefix from the top so a mid-file (cursor-resumed) scan still has
806
- // session context without slurping a multi-MiB transcript.
807
- const CODEX_META_PREFIX_BYTES = 64 * 1024;
808
-
809
- /**
810
- * Largest length passed to a single `FileHandle.read()`. Node's native fs.read
811
- * asserts `args[3]->IsInt32()` on the length argument, so a single read whose
812
- * length exceeds 2**31-1 (≈2 GiB) does NOT throw a catchable error — it aborts
813
- * the whole process with SIGABRT. A skill/session log whose unread tail had
814
- * grown past ~2 GiB crashed the entire `hq sync` runner this way
815
- * (Sentry HQ-SYNC-WEB-15). 256 MiB stays comfortably within Int32 while keeping
816
- * the read-loop iteration count small.
817
- */
818
- const MAX_FS_READ_CHUNK_BYTES = 256 * 1024 * 1024;
819
-
820
- /**
821
- * A sync pass must never turn an unbounded transcript tail into one V8 string.
822
- * Node aborts the process once Buffer#toString receives a length >= 2**31; this
823
- * conservative cap is also below the smaller practical string ceilings across
824
- * supported Node versions. Keep this assertion close to the call site so a
825
- * future increase cannot reintroduce the native SIGTRAP failure.
826
- */
827
- export const MAX_DECODE_BYTES = 256 * 1024 * 1024;
828
- if (MAX_DECODE_BYTES >= 2 ** 31) {
829
- throw new Error("MAX_DECODE_BYTES must remain below the V8 string length limit");
830
- }
831
-
832
- /** Minimal structural view of the `FileHandle.read` we depend on. */
833
- type ReadableFileHandle = {
834
- read(
835
- buffer: Buffer,
836
- offset: number,
837
- length: number,
838
- position: number,
839
- ): Promise<{ bytesRead: number }>;
840
- };
841
-
842
- /**
843
- * Read `length` bytes from `fh` starting at byte `position` into `buf`, issuing
844
- * native reads no larger than `chunkBytes` so the length argument never exceeds
845
- * Int32 and trips the SIGABRT-on-assert path (HQ-SYNC-WEB-15). Returns the total
846
- * bytes actually read — which may be < `length` if the file was truncated
847
- * between `stat` and this read (a 0-byte read means EOF, so we stop).
848
- */
849
- export async function readFileRegion(
850
- fh: ReadableFileHandle,
851
- buf: Buffer,
852
- position: number,
853
- length: number,
854
- chunkBytes: number = MAX_FS_READ_CHUNK_BYTES,
855
- ): Promise<number> {
856
- let filled = 0;
857
- while (filled < length) {
858
- const chunk = Math.min(chunkBytes, length - filled);
859
- const { bytesRead } = await fh.read(buf, filled, chunk, position + filled);
860
- if (bytesRead === 0) break; // EOF / truncated since stat — stop cleanly.
861
- filled += bytesRead;
862
- }
863
- return filled;
864
- }
865
-
866
- /** Read cwd + sessionId from a Codex rollout's leading `session_meta` line.
867
- * Always reads from offset 0 (independent of the byte cursor). Best-effort:
868
- * any error → empty context (events then fail the hqRoot scope filter, which
869
- * is the safe default). */
870
- async function readCodexSessionContext(
871
- filePath: string,
872
- ): Promise<{ sessionId?: string; cwd?: string }> {
873
- try {
874
- const fh = await fs.open(filePath, "r");
875
- try {
876
- const buf = Buffer.alloc(CODEX_META_PREFIX_BYTES);
877
- const bytesRead = await readFileRegion(fh, buf, 0, CODEX_META_PREFIX_BYTES);
878
- const firstLine = buf.toString("utf-8", 0, bytesRead).split("\n", 1)[0]?.trim();
879
- if (!firstLine) return {};
880
- return parseCodexSessionMeta(JSON.parse(firstLine)) ?? {};
881
- } finally {
882
- await fh.close();
883
- }
884
- } catch {
885
- return {};
886
- }
887
- }
888
-
889
- const MAX_BATCH_EVENTS = 100;
890
- const MAX_BATCH_BYTES = 240 * 1024;
891
- const MAX_SCAN_BYTES_PER_SOURCE = 4 * 1024 * 1024;
892
- const ROW_TRUNCATION_SUFFIX = "...[truncated]";
893
-
894
- function jsonBytes(value: unknown): number {
895
- return Buffer.byteLength(JSON.stringify(value), "utf-8");
896
- }
897
-
898
- function truncateLongestStringField(row: Record<string, unknown>): boolean {
899
- let longestKey: string | undefined;
900
- let longestBytes = 0;
901
- for (const [key, value] of Object.entries(row)) {
902
- if (typeof value !== "string" || value.length === 0) continue;
903
- const bytes = Buffer.byteLength(value, "utf-8");
904
- if (bytes > longestBytes) {
905
- longestBytes = bytes;
906
- longestKey = key;
907
- }
908
- }
909
- if (longestKey === undefined) return false;
910
-
911
- const value = row[longestKey] as string;
912
- const keepChars =
913
- value.length > ROW_TRUNCATION_SUFFIX.length
914
- ? Math.floor((value.length - ROW_TRUNCATION_SUFFIX.length) / 2)
915
- : 0;
916
- const next =
917
- keepChars > 0
918
- ? `${value.slice(0, keepChars)}${ROW_TRUNCATION_SUFFIX}`
919
- : "";
920
- if (next === value) return false;
921
- row[longestKey] = next;
922
- return true;
923
- }
924
-
925
- function boundRowForPost(
926
- row: Record<string, unknown>,
927
- maxRowBytes: number,
928
- ): Record<string, unknown> | null {
929
- if (maxRowBytes < 0) return null;
930
- const bounded = { ...row };
931
- while (jsonBytes(bounded) > maxRowBytes) {
932
- if (!truncateLongestStringField(bounded)) return null;
933
- }
934
- return bounded;
935
- }
936
-
937
- // ── Main entry point ──────────────────────────────────────────────────────────
938
-
939
- /**
940
- * Scan, extract, and POST any new skill-invocation events.
941
- *
942
- * Cursor model (per-batch commit, matching the token collector for robustness):
943
- * each file is scanned from its stored byte offset through a bounded region;
944
- * extracted events carry the byte offset of the line they came from. Events are
945
- * flushed in server-sized batches, and the cursor advances **per successful
946
- * batch** — so if one batch in a large (e.g. first-run backfill) fails, the
947
- * batches that already succeeded stay committed and only the rest re-send next
948
- * sync. A bounded region settles only at a complete-line boundary, preventing
949
- * a cursor from skipping an unread tail or resuming mid-line.
950
- *
951
- * Per-file commit rule:
952
- * - All of a file's events sent OK (including zero-event files) → commit EOF,
953
- * so quiet/non-skill tails are never re-scanned.
954
- * - Some of a file's events failed → commit the max byte offset whose batch
955
- * succeeded (partial progress); the remainder re-sends next sync.
956
- * Server-side dedup on the composite eventKey makes any re-send idempotent.
957
- * Rotation/truncation resets the offset to 0 (re-read from the top).
958
- */
959
- export async function collectAndSendSkillTelemetry(
960
- opts: CollectSkillTelemetryOptions,
961
- ): Promise<CollectSkillTelemetryResult> {
962
- const home = os.homedir();
963
- const claudeProjectsRoot =
964
- opts.claudeProjectsRoot ?? path.join(home, ".claude", "projects");
965
- const codexSessionsRoot =
966
- opts.codexSessionsRoot ?? path.join(home, ".codex", "sessions");
967
- const cursorPath =
968
- opts.cursorPath ?? path.join(home, ".hq", "skill-telemetry-cursor.json");
969
- const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
970
- const log = opts.log ?? (() => {});
971
- // Match the usage collector's per-runtime budget, with an independent hard
972
- // decode ceiling as defence in depth for future callers/configuration.
973
- const maxScanBytesPerSource = Math.min(
974
- opts.maxScanBytesPerSource ?? MAX_SCAN_BYTES_PER_SOURCE,
975
- MAX_DECODE_BYTES,
976
- );
977
-
978
- // Normalize the scope path once (drop a single trailing slash, keeping "/").
979
- const normalizePath = (p: string): string => (p.length > 1 ? p.replace(/\/+$/, "") : p);
980
- const scopeCwd = opts.hqRoot !== undefined ? normalizePath(opts.hqRoot) : undefined;
981
-
982
- // Company attribution (US-002): parse the manifest ONCE per run and reuse the
983
- // repo-path→companyUid map for every event below. No per-event manifest read.
984
- // When `hqRoot` is omitted the map is empty → every event stays unattributed.
985
- const repoCompanyMap: RepoCompanyMap = opts.hqRoot
986
- ? await buildRepoCompanyMap(opts.hqRoot)
987
- : { entries: [], bySlug: new Map(), foldsCase: false, ambiguous: new Set<string>() };
988
-
989
- // skillVersion resolution (US-015): resolve each skill's SKILL.md content hash
990
- // ONCE per run (skills repeat across a session) and stamp it onto every event.
991
- // Injectable for tests; defaults to hashing under hqRoot. Errors degrade to
992
- // undefined so a resolver failure never blocks telemetry (additive field).
993
- const resolveSkillVersion =
994
- opts.resolveSkillVersion ??
995
- ((skill: string) => computeSkillVersion(opts.hqRoot, skill));
996
- const skillVersionCache = new Map<string, string | undefined>();
997
- const skillVersionFor = async (skill: string): Promise<string | undefined> => {
998
- if (skillVersionCache.has(skill)) return skillVersionCache.get(skill);
999
- let version: string | undefined;
1000
- try {
1001
- version = await resolveSkillVersion(skill);
1002
- } catch {
1003
- version = undefined;
1004
- }
1005
- skillVersionCache.set(skill, version);
1006
- return version;
1007
- };
1008
-
1009
- // 1. Opt-in check — reuse the same gate as token telemetry.
1010
- let enabled: boolean;
1011
- let optInSource: CollectSkillTelemetryResult["optInSource"];
1012
- try {
1013
- const resp = await opts.client.getTelemetryOptIn();
1014
- enabled = resp.enabled === true;
1015
- optInSource = "server";
1016
- } catch (err) {
1017
- log(`[skill-telemetry] opt-in check failed (${(err as Error).message ?? err}) — falling back to local menubar.json`);
1018
- enabled = await readLocalTelemetryEnabled(menubarPath);
1019
- optInSource = "menubar-fallback";
1020
- }
1021
-
1022
- if (!enabled) {
1023
- return { enabled: false, optInSource, filesScanned: 0, eventsSent: 0, batchesSent: 0 };
1024
- }
1025
-
1026
- // 2. Cursor + file enumeration. Both runtimes share one cursor (keyed by
1027
- // absolute path — the roots never collide) and one batcher. Each file is
1028
- // tagged with its runtime so the scan picks the right line extractor.
1029
- const cursor = await loadCursor(cursorPath);
1030
- const claudeFiles = await listJsonlFiles(claudeProjectsRoot);
1031
- const codexFiles = await listJsonlFiles(codexSessionsRoot);
1032
- const files: { filePath: string; kind: SkillTelemetrySource }[] = [
1033
- ...rotateFilesFromLast(
1034
- claudeFiles,
1035
- cursor.lastScannedBySource?.claude,
1036
- ).map((f) => ({ filePath: f, kind: "claude" as const })),
1037
- ...rotateFilesFromLast(
1038
- codexFiles,
1039
- cursor.lastScannedBySource?.codex,
1040
- ).map((f) => ({ filePath: f, kind: "codex" as const })),
1041
- ];
1042
-
1043
- // 3. Scan every file from its stored offset, collecting events tagged with
1044
- // the byte offset of the line they came from (for per-batch commit).
1045
- interface FileScan {
1046
- eof: number;
1047
- mtime: number;
1048
- eventCount: number; // events extracted from this file this run
1049
- pendingLine?: PendingLineCursor;
1050
- codex?: CodexCursorState;
1051
- }
1052
- interface Sourced {
1053
- row: Record<string, unknown>;
1054
- filePath: string;
1055
- endOffset: number; // absolute byte offset at the end of the source line
1056
- codex?: CodexCursorState;
1057
- }
1058
-
1059
- const fileScans: Record<string, FileScan> = {};
1060
- const rotationResets: Record<string, CursorEntry> = {};
1061
- const scannedBytes: Record<SkillTelemetrySource, number> = { claude: 0, codex: 0 };
1062
- const lastScannedBySource: Partial<Record<SkillTelemetrySource, string>> = {
1063
- ...cursor.lastScannedBySource,
1064
- };
1065
- const sourced: Sourced[] = [];
1066
- const envelopeBytes = Buffer.byteLength(
1067
- JSON.stringify({ machineId: opts.machineId, installerVersion: opts.installerVersion, events: [] }),
1068
- "utf-8",
1069
- );
1070
- const maxRowBytes = MAX_BATCH_BYTES - envelopeBytes;
1071
-
1072
- for (const { filePath, kind } of files) {
1073
- const sourceBudgetRemaining = maxScanBytesPerSource - scannedBytes[kind];
1074
- if (sourceBudgetRemaining <= 0) continue;
1075
-
1076
- let stat;
1077
- try {
1078
- stat = await fs.stat(filePath);
1079
- } catch {
1080
- continue;
1081
- }
1082
- const currentSize = stat.size;
1083
- const currentMtime = Math.floor(stat.mtimeMs / 1000);
1084
-
1085
- const stored = cursor.files[filePath] ?? { offset: 0, mtime: 0 };
1086
- let offset = stored.offset;
1087
- let pendingLine = clonePendingLine(stored.pendingLine);
1088
- let codexCursor = kind === "codex" ? cloneCodexCursorState(stored.codex) : undefined;
1089
-
1090
- // Rotation / truncation → re-read from the top.
1091
- const rotated =
1092
- currentSize < offset || (stored.mtime > 0 && currentMtime < stored.mtime);
1093
- if (rotated) {
1094
- offset = 0;
1095
- pendingLine = undefined;
1096
- codexCursor = undefined;
1097
- rotationResets[filePath] = { offset: 0, mtime: currentMtime };
1098
- }
1099
- // A malformed/stale pending continuation is never allowed to move the
1100
- // durable complete-line cursor. Start scanning fresh from that cursor.
1101
- if (
1102
- pendingLine &&
1103
- (pendingLine.start !== offset || pendingLine.scannedOffset > currentSize)
1104
- ) {
1105
- pendingLine = undefined;
1106
- }
1107
- const scanOffset = pendingLine?.scannedOffset ?? offset;
1108
-
1109
- // Record the scan even when there are no new bytes — a fully-drained file
1110
- // (eventCount 0, offset already at EOF) should still settle at EOF below.
1111
- fileScans[filePath] = {
1112
- eof: currentSize,
1113
- mtime: currentMtime,
1114
- eventCount: 0,
1115
- ...(pendingLine ? { pendingLine } : {}),
1116
- ...(codexCursor ? { codex: codexCursor } : {}),
1117
- };
1118
-
1119
- if (offset >= currentSize && !rotated) continue;
1120
-
1121
- let content: string;
1122
- let bytesRead = 0;
1123
- let readBuffer = Buffer.alloc(0);
1124
- try {
1125
- const fh = await fs.open(filePath, "r");
1126
- try {
1127
- const length = Math.max(
1128
- 0,
1129
- Math.min(currentSize - scanOffset, sourceBudgetRemaining, MAX_DECODE_BYTES),
1130
- );
1131
- readBuffer = Buffer.alloc(length);
1132
- // Chunked: a single fh.read() with length > Int32 aborts the process
1133
- // with SIGABRT (HQ-SYNC-WEB-15). `MAX_DECODE_BYTES` separately keeps
1134
- // the following toString below V8's fatal >= 2**31 boundary; an
1135
- // oversized configured budget cannot bypass this guard.
1136
- bytesRead = await readFileRegion(fh, readBuffer, scanOffset, length);
1137
- content = readBuffer.toString("utf-8", 0, Math.min(bytesRead, MAX_DECODE_BYTES));
1138
- } finally {
1139
- await fh.close();
1140
- }
1141
- } catch {
1142
- // Could not read — drop the scan so we don't claim progress for it.
1143
- delete fileScans[filePath];
1144
- continue;
1145
- }
1146
- scannedBytes[kind] += bytesRead;
1147
- if (bytesRead > 0) lastScannedBySource[kind] = filePath;
1148
-
1149
- let reachedPhysicalEof = scanOffset + bytesRead >= currentSize;
1150
- if (!reachedPhysicalEof) {
1151
- log(`[skill-telemetry] scan budget reached; deferring unread transcript bytes (${filePath})`);
1152
- }
1153
-
1154
- let completedPendingLine = false;
1155
- if (pendingLine) {
1156
- const newlineIndex = readBuffer.subarray(0, bytesRead).indexOf(0x0a);
1157
- if (newlineIndex < 0 && !reachedPhysicalEof) {
1158
- // The record itself is larger than the normal per-pass budget. Persist
1159
- // only byte positions and continue searching from this new suffix next
1160
- // pass — never the transcript's contents, and never the same prefix.
1161
- fileScans[filePath].eof = offset;
1162
- fileScans[filePath].pendingLine = {
1163
- start: offset,
1164
- scannedOffset: scanOffset + bytesRead,
1165
- };
1166
- log(`[skill-telemetry] continuing oversized JSONL record on a later pass (${filePath})`);
1167
- continue;
1168
- }
1169
-
1170
- const lineEnd =
1171
- newlineIndex >= 0 ? scanOffset + newlineIndex + 1 : scanOffset + bytesRead;
1172
- const lineLength = lineEnd - offset;
1173
- if (lineLength > MAX_DECODE_BYTES) {
1174
- // This guard still makes the failure recoverable rather than allowing a
1175
- // native V8 abort. Keep the byte-only continuation so a later file
1176
- // rotation/truncation can reset it safely; diagnostics reveal the
1177
- // exceptional transcript without leaking its contents.
1178
- fileScans[filePath].eof = offset;
1179
- fileScans[filePath].pendingLine = { start: offset, scannedOffset: lineEnd };
1180
- log(`[skill-telemetry] JSONL record exceeds safe decode limit (${filePath})`);
1181
- continue;
1182
- }
1183
-
1184
- try {
1185
- const fh = await fs.open(filePath, "r");
1186
- try {
1187
- readBuffer = Buffer.alloc(lineLength);
1188
- bytesRead = await readFileRegion(fh, readBuffer, offset, lineLength);
1189
- if (bytesRead !== lineLength) {
1190
- fileScans[filePath].eof = offset;
1191
- fileScans[filePath].pendingLine = {
1192
- start: offset,
1193
- scannedOffset: offset + bytesRead,
1194
- };
1195
- continue;
1196
- }
1197
- content = readBuffer.toString("utf-8", 0, bytesRead);
1198
- } finally {
1199
- await fh.close();
1200
- }
1201
- } catch {
1202
- delete fileScans[filePath];
1203
- continue;
1204
- }
1205
- reachedPhysicalEof = lineEnd >= currentSize;
1206
- completedPendingLine = true;
1207
- pendingLine = undefined;
1208
- delete fileScans[filePath].pendingLine;
1209
- }
1210
-
1211
- // Codex events lack per-row cwd/sessionId — they live in the file's leading
1212
- // `session_meta` line, which we read from the top regardless of the cursor.
1213
- const codexCtx =
1214
- kind === "codex" ? await readCodexSessionContext(filePath) : undefined;
1215
- // Per-file dedup of model-driven Codex skill loads: a single skill use
1216
- // re-reads SKILL.md several times within one turn, so collapse them to one
1217
- // event per (session, turn, skill). Scoped per file = per Codex session.
1218
- const codexSeen =
1219
- kind === "codex" ? new Set(codexCursor?.seen ?? []) : undefined;
1220
- // Running turn id for Codex: the `function_call` exec shape carries no
1221
- // turn_id of its own, so we track the latest one seen (from `turn_context`,
1222
- // which precedes a turn's execs) and attribute those execs to it.
1223
- let codexTurnId: string | undefined = codexCursor?.turnId;
1224
-
1225
- // A region capped before EOF can end part-way through a JSONL row (and even
1226
- // part-way through a multibyte UTF-8 character). Only process its newline-
1227
- // terminated segments; the next pass resumes at the last settled boundary.
1228
- // At physical EOF retain today's behaviour of handling a final newline-less
1229
- // record in the same pass.
1230
- const segments = content.split("\n");
1231
- const completeSegmentCount = reachedPhysicalEof
1232
- ? segments.length
1233
- : Math.max(0, segments.length - 1);
1234
- let cumulative = offset;
1235
- let settledOffset = offset;
1236
- for (let i = 0; i < completeSegmentCount; i++) {
1237
- cumulative += Buffer.byteLength(segments[i], "utf-8");
1238
- if (i < segments.length - 1) cumulative += 1; // the split newline byte
1239
- // For the final physical-EOF segment, use the bytes actually returned by
1240
- // fs rather than re-encoding it. This preserves byte-exact cursor
1241
- // offsets even if a writer left an invalid/incomplete UTF-8 tail.
1242
- const endOffset =
1243
- i === segments.length - 1 ? offset + bytesRead : cumulative;
1244
- settledOffset = endOffset;
1245
-
1246
- const trimmed = segments[i].trim();
1247
- if (trimmed.length === 0) continue;
1248
- let parsed: unknown;
1249
- try {
1250
- parsed = JSON.parse(trimmed);
1251
- } catch {
1252
- continue;
1253
- }
1254
- if (kind === "codex") {
1255
- const t = codexRowTurnId(parsed);
1256
- if (t !== undefined) {
1257
- if (t !== codexTurnId) codexSeen?.clear();
1258
- codexTurnId = t;
1259
- }
1260
- }
1261
- const events =
1262
- kind === "codex"
1263
- ? [
1264
- ...extractCodexSkillEvents(parsed, codexCtx ?? {}),
1265
- ...extractCodexSkillToolEvents(
1266
- parsed,
1267
- { ...(codexCtx ?? {}), turnId: codexTurnId },
1268
- codexSeen,
1269
- ),
1270
- ]
1271
- : extractSkillEvents(parsed);
1272
- for (const ev of events) {
1273
- // Scope filter: only emit invocations made from the HQ project — its
1274
- // root or any path beneath it (worktrees, nested apps), so a session run
1275
- // from `<hqRoot>/.claude/worktrees/…` still counts. Sibling repos that
1276
- // merely share a path prefix (`<hqRoot>-other`) are excluded by the
1277
- // trailing-slash boundary.
1278
- if (scopeCwd !== undefined) {
1279
- const c = ev.cwd === undefined ? undefined : normalizePath(ev.cwd);
1280
- if (c === undefined || (c !== scopeCwd && !c.startsWith(`${scopeCwd}/`))) {
1281
- continue;
1282
- }
1283
- }
1284
- // Attribute the event to a company (cmp_* uid) from the per-run map.
1285
- // A company SKILL (`<slug>:<skill>`) belongs to ITS company regardless of
1286
- // the cwd it ran in, so skill-based resolution takes precedence; a core
1287
- // skill falls back to the cwd's owning company. Unresolved by both →
1288
- // undefined → companyUid omitted (unattributed/personal).
1289
- const companyUid =
1290
- resolveCompanyForSkill(ev.skill, repoCompanyMap) ??
1291
- resolveCompanyForCwd(ev.cwd, repoCompanyMap);
1292
- // Stamp the skill's content-version marker (US-015). Unresolved → the
1293
- // field is omitted from the wire row (additive).
1294
- ev.skillVersion = await skillVersionFor(ev.skill);
1295
- const wireRow = toWireRow(ev, companyUid);
1296
- const wasOversized = jsonBytes(wireRow) > maxRowBytes;
1297
- const bounded = boundRowForPost(wireRow, maxRowBytes);
1298
- if (!bounded) {
1299
- log(
1300
- `[skill-telemetry] oversized row dropped before send (${filePath}:${i + 1})`,
1301
- );
1302
- continue;
1303
- }
1304
- if (wasOversized) {
1305
- log(
1306
- `[skill-telemetry] oversized row truncated before send (${filePath}:${i + 1})`,
1307
- );
1308
- }
1309
- sourced.push({
1310
- row: bounded,
1311
- filePath,
1312
- endOffset,
1313
- ...(kind === "codex"
1314
- ? { codex: snapshotCodexCursorState(codexTurnId, codexSeen) }
1315
- : {}),
1316
- });
1317
- fileScans[filePath].eventCount++;
1318
- }
1319
- }
1320
- // Commit only bytes that reached a complete record boundary. In particular,
1321
- // never claim currentSize merely because the first budgeted region was read.
1322
- fileScans[filePath].eof = settledOffset;
1323
- if (!completedPendingLine && !reachedPhysicalEof) {
1324
- const lastNewline = readBuffer.subarray(0, bytesRead).lastIndexOf(0x0a);
1325
- const partialStart = offset + lastNewline + 1;
1326
- const inspectedThrough = offset + bytesRead;
1327
- if (partialStart < inspectedThrough) {
1328
- fileScans[filePath].pendingLine = {
1329
- start: partialStart,
1330
- scannedOffset: inspectedThrough,
1331
- };
1332
- }
1333
- }
1334
- if (kind === "codex") {
1335
- fileScans[filePath].codex = snapshotCodexCursorState(codexTurnId, codexSeen);
1336
- }
1337
- }
1338
-
1339
- // 4. Flush in server-sized batches, advancing per-file progress on each 2xx.
1340
- let eventsSent = 0;
1341
- let batchesSent = 0;
1342
-
1343
- // Per file: count of events successfully sent + max committed byte offset.
1344
- const sentCount: Record<string, number> = {};
1345
- const committed: Record<string, { offset: number; codex?: CodexCursorState }> = {};
1346
-
1347
- let batch: Sourced[] = [];
1348
- let batchBytes = envelopeBytes;
1349
-
1350
- const flush = async (): Promise<boolean> => {
1351
- if (batch.length === 0) return true;
1352
- const toSend = batch;
1353
- batch = [];
1354
- batchBytes = envelopeBytes;
1355
- try {
1356
- await opts.client.postSkillInvocations({
1357
- machineId: opts.machineId,
1358
- installerVersion: opts.installerVersion,
1359
- events: toSend.map((s) => s.row),
1360
- });
1361
- batchesSent++;
1362
- eventsSent += toSend.length;
1363
- // Advance per-file progress for the events in this (successful) batch.
1364
- for (const s of toSend) {
1365
- sentCount[s.filePath] = (sentCount[s.filePath] ?? 0) + 1;
1366
- const prev = committed[s.filePath];
1367
- if (!prev || s.endOffset > prev.offset) {
1368
- committed[s.filePath] = {
1369
- offset: s.endOffset,
1370
- ...(s.codex ? { codex: s.codex } : {}),
1371
- };
1372
- }
1373
- }
1374
- return true;
1375
- } catch (err) {
1376
- log(`[skill-telemetry] postSkillInvocations failed (${(err as Error).message ?? err}) — these rows re-send next sync`);
1377
- // Cursor not advanced for this batch; eventKey dedups the eventual re-send.
1378
- return false;
1379
- }
1380
- };
1381
-
1382
- let stoppedAfterFailure = false;
1383
- for (const s of sourced) {
1384
- const rowBytes = Buffer.byteLength(JSON.stringify(s.row), "utf-8");
1385
- const addCost = rowBytes + (batch.length > 0 ? 1 : 0);
1386
- if (
1387
- batch.length > 0 &&
1388
- (batch.length >= MAX_BATCH_EVENTS || batchBytes + addCost > MAX_BATCH_BYTES)
1389
- ) {
1390
- if (!(await flush())) {
1391
- stoppedAfterFailure = true;
1392
- break;
1393
- }
1394
- batchBytes = envelopeBytes + rowBytes;
1395
- } else {
1396
- batchBytes += addCost;
1397
- }
1398
- batch.push(s);
1399
- }
1400
- if (!stoppedAfterFailure) await flush();
1401
-
1402
- // 5. Build the new cursor: loaded < rotationResets < per-file commit.
1403
- // A file settles at EOF only when every event extracted from it this run
1404
- // was sent OK (zero-event files included); otherwise it settles at the
1405
- // highest byte offset whose batch succeeded, so the rest re-sends.
1406
- const finalFiles: Record<string, CursorEntry> = { ...cursor.files };
1407
- for (const [fp, entry] of Object.entries(rotationResets)) finalFiles[fp] = entry;
1408
- for (const [fp, scan] of Object.entries(fileScans)) {
1409
- if ((sentCount[fp] ?? 0) >= scan.eventCount) {
1410
- finalFiles[fp] = {
1411
- offset: scan.eof,
1412
- mtime: scan.mtime,
1413
- ...(scan.pendingLine ? { pendingLine: scan.pendingLine } : {}),
1414
- ...(scan.codex ? { codex: scan.codex } : {}),
1415
- };
1416
- } else if (fp in committed) {
1417
- const entry = committed[fp];
1418
- finalFiles[fp] = {
1419
- offset: entry.offset,
1420
- mtime: scan.mtime,
1421
- ...(entry.codex ? { codex: entry.codex } : {}),
1422
- };
1423
- }
1424
- // else: no progress for this file — leave loaded/rotation-reset offset.
1425
- }
1426
- await saveCursor(cursorPath, {
1427
- version: "1",
1428
- files: finalFiles,
1429
- ...(Object.keys(lastScannedBySource).length > 0 ? { lastScannedBySource } : {}),
1430
- });
1431
-
1432
- return {
1433
- enabled: true,
1434
- optInSource,
1435
- filesScanned: files.length,
1436
- eventsSent,
1437
- batchesSent,
1438
- };
1439
- }