@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,1825 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import { promises as fs } from "node:fs";
3
- import * as os from "node:os";
4
- import * as path from "node:path";
5
- import { createHash } from "node:crypto";
6
- import {
7
- extractSkillEvents,
8
- extractCodexSkillEvents,
9
- extractCodexSkillToolEvents,
10
- codexRowTurnId,
11
- parseCodexSessionMeta,
12
- collectAndSendSkillTelemetry,
13
- computeSkillVersion,
14
- MAX_DECODE_BYTES,
15
- readFileRegion,
16
- } from "./skill-telemetry.js";
17
- import { encodeLocalVaultSegment } from "./local-path-codec.js";
18
- import type { SkillInvocationBatch } from "./vault-client.js";
19
-
20
- describe("extractSkillEvents", () => {
21
- it("extracts a user-typed slash command with args", () => {
22
- const row = {
23
- type: "user",
24
- sessionId: "sess-1",
25
- timestamp: "2026-06-03T10:10:07.220Z",
26
- cwd: "/home/ec2-user/hq",
27
- uuid: "u-1",
28
- message: {
29
- role: "user",
30
- content:
31
- "<command-message>personal:worktree</command-message> <command-name>/personal:worktree</command-name> <command-args>pull latest</command-args>",
32
- },
33
- };
34
- const events = extractSkillEvents(row);
35
- expect(events).toEqual([
36
- {
37
- skill: "personal:worktree",
38
- source: "typed",
39
- sessionId: "sess-1",
40
- timestamp: "2026-06-03T10:10:07.220Z",
41
- cwd: "/home/ec2-user/hq",
42
- uuid: "u-1",
43
- hasArgs: true,
44
- },
45
- ]);
46
- });
47
-
48
- it("marks hasArgs false when command-args is absent or empty", () => {
49
- const row = {
50
- type: "user",
51
- message: { role: "user", content: "<command-name>/deploy</command-name>" },
52
- };
53
- expect(extractSkillEvents(row)[0]).toMatchObject({ skill: "deploy", source: "typed", hasArgs: false });
54
- });
55
-
56
- it("extracts a model-invoked Skill tool_use using the block id for dedup", () => {
57
- const row = {
58
- type: "assistant",
59
- sessionId: "sess-2",
60
- timestamp: "2026-06-03T14:00:00.000Z",
61
- cwd: "/home/ec2-user/hq",
62
- uuid: "row-uuid",
63
- message: {
64
- role: "assistant",
65
- content: [
66
- { type: "text", text: "ok" },
67
- { type: "tool_use", id: "toolu_abc", name: "Skill", input: { skill: "indigo:hello-world" } },
68
- ],
69
- },
70
- };
71
- expect(extractSkillEvents(row)).toEqual([
72
- {
73
- skill: "indigo:hello-world",
74
- source: "model",
75
- sessionId: "sess-2",
76
- timestamp: "2026-06-03T14:00:00.000Z",
77
- cwd: "/home/ec2-user/hq",
78
- uuid: "toolu_abc",
79
- hasArgs: false,
80
- },
81
- ]);
82
- });
83
-
84
- it("sets hasArgs true for a model invocation with args, without capturing the args text", () => {
85
- const row = {
86
- type: "assistant",
87
- message: {
88
- role: "assistant",
89
- content: [{ type: "tool_use", name: "Skill", input: { skill: "code-review", args: "high" } }],
90
- },
91
- };
92
- const ev = extractSkillEvents(row)[0];
93
- expect(ev).toMatchObject({ skill: "code-review", source: "model", hasArgs: true });
94
- // Privacy: raw args must never appear on the extracted event.
95
- expect(JSON.stringify(ev)).not.toContain("high");
96
- });
97
-
98
- it("ignores non-Skill tool_use blocks and non-invocation rows", () => {
99
- expect(
100
- extractSkillEvents({
101
- type: "assistant",
102
- message: { role: "assistant", content: [{ type: "tool_use", name: "Bash", input: { command: "ls" } }] },
103
- }),
104
- ).toEqual([]);
105
- expect(extractSkillEvents({ type: "user", message: { content: "just a normal message" } })).toEqual([]);
106
- expect(extractSkillEvents(null)).toEqual([]);
107
- expect(extractSkillEvents("nope")).toEqual([]);
108
- });
109
- });
110
-
111
- describe("extractCodexSkillEvents (Codex CLI rollouts)", () => {
112
- const ctx = { sessionId: "019ea34e-4a2b-7241-b8e3-1023abb57c6b", cwd: "/home/ec2-user/hq" };
113
-
114
- it("extracts a typed slash skill from a user_message, attributing session ctx", () => {
115
- const row = {
116
- timestamp: "2026-06-07T18:17:57.722Z",
117
- type: "event_msg",
118
- payload: { type: "user_message", message: "/indigo:hello-world", images: [], local_images: [], text_elements: [] },
119
- };
120
- expect(extractCodexSkillEvents(row, ctx)).toEqual([
121
- {
122
- skill: "indigo:hello-world",
123
- source: "typed",
124
- sessionId: ctx.sessionId,
125
- timestamp: "2026-06-07T18:17:57.722Z",
126
- cwd: ctx.cwd,
127
- uuid: `codex:${ctx.sessionId}:2026-06-07T18:17:57.722Z:indigo:hello-world`,
128
- hasArgs: false,
129
- },
130
- ]);
131
- });
132
-
133
- it("sets hasArgs true when the command has trailing args, without leaking them", () => {
134
- const row = {
135
- timestamp: "2026-06-07T18:18:00.000Z",
136
- type: "event_msg",
137
- payload: { type: "user_message", message: "/indigo:signals refresh secret-arg-value" },
138
- };
139
- const ev = extractCodexSkillEvents(row, ctx)[0];
140
- expect(ev).toMatchObject({ skill: "indigo:signals", source: "typed", hasArgs: true });
141
- expect(JSON.stringify(ev)).not.toContain("secret-arg-value");
142
- });
143
-
144
- it("rejects file-path-like and non-command user messages", () => {
145
- const mk = (message: string) => ({ type: "event_msg", payload: { type: "user_message", message } });
146
- expect(extractCodexSkillEvents(mk("/etc/hosts please read this"), ctx)).toEqual([]);
147
- expect(extractCodexSkillEvents(mk("just a normal prompt"), ctx)).toEqual([]);
148
- expect(extractCodexSkillEvents(mk(""), ctx)).toEqual([]);
149
- });
150
-
151
- it("ignores non-user_message rows (session_meta, lifecycle, assistant)", () => {
152
- expect(
153
- extractCodexSkillEvents({ type: "session_meta", payload: { id: "x", cwd: "/y" } }, ctx),
154
- ).toEqual([]);
155
- expect(
156
- extractCodexSkillEvents({ type: "event_msg", payload: { type: "task_started" } }, ctx),
157
- ).toEqual([]);
158
- // The duplicate `response_item` (role:user) Codex also writes must NOT
159
- // double-count — only the `event_msg` user_message is the signal.
160
- expect(
161
- extractCodexSkillEvents(
162
- { type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "/indigo:hello-world" }] } },
163
- ctx,
164
- ),
165
- ).toEqual([]);
166
- expect(extractCodexSkillEvents(null, ctx)).toEqual([]);
167
- });
168
-
169
- it("omits the synthesized uuid when session ctx is missing (safe-fail)", () => {
170
- const row = { timestamp: "2026-06-07T18:17:57.722Z", type: "event_msg", payload: { type: "user_message", message: "/deploy" } };
171
- expect(extractCodexSkillEvents(row, {})[0]).toMatchObject({ skill: "deploy", source: "typed", uuid: undefined });
172
- });
173
-
174
- it("parseCodexSessionMeta reads id + cwd, and returns null for other rows", () => {
175
- expect(
176
- parseCodexSessionMeta({ type: "session_meta", payload: { id: "sess-x", cwd: "/home/ec2-user/hq", originator: "codex_exec" } }),
177
- ).toEqual({ sessionId: "sess-x", cwd: "/home/ec2-user/hq" });
178
- expect(parseCodexSessionMeta({ type: "event_msg", payload: {} })).toBeNull();
179
- expect(parseCodexSessionMeta(null)).toBeNull();
180
- });
181
- });
182
-
183
- describe("extractCodexSkillToolEvents (Codex model-driven SKILL.md reads)", () => {
184
- const ctx = { sessionId: "sess-x", cwd: "/home/ec2-user/hq" };
185
-
186
- // Codex surfaces a completed exec as `exec_command_end` with `command` as
187
- // `["/bin/zsh","-lc","<cmd>"]`, a `turn_id`, the run `cwd`, and a `parsed_cmd`
188
- // it tags `type: "read"` for inspection commands.
189
- function execEnd(cmd: string, over: Record<string, unknown> = {}) {
190
- return {
191
- timestamp: "2026-04-27T07:51:30.395Z",
192
- type: "event_msg",
193
- payload: {
194
- type: "exec_command_end",
195
- call_id: "call_1",
196
- turn_id: "turn-A",
197
- command: ["/bin/zsh", "-lc", cmd],
198
- cwd: "/home/ec2-user/hq",
199
- parsed_cmd: [{ type: "read", cmd }],
200
- ...over,
201
- },
202
- };
203
- }
204
-
205
- it("extracts a skill load from a sed read of SKILL.md, tagged source:model", () => {
206
- const ev = extractCodexSkillToolEvents(
207
- execEnd("sed -n '1,240p' .agents/skills/learn/SKILL.md"),
208
- ctx,
209
- );
210
- expect(ev).toEqual([
211
- {
212
- skill: "learn",
213
- source: "model",
214
- sessionId: "sess-x",
215
- timestamp: "2026-04-27T07:51:30.395Z",
216
- cwd: "/home/ec2-user/hq",
217
- uuid: "codex:skill:sess-x:turn-A:learn",
218
- hasArgs: false,
219
- },
220
- ]);
221
- });
222
-
223
- it("captures the directory above SKILL.md as the skill name, even when nested (bridge layout)", () => {
224
- const ev = extractCodexSkillToolEvents(
225
- execEnd("rg -n description /Users/me/.codex/skills/hq/indigo:hello-world/SKILL.md"),
226
- ctx,
227
- )[0];
228
- // skills/hq/<name>/SKILL.md → <name>, not "hq".
229
- expect(ev.skill).toBe("indigo:hello-world");
230
- expect(ev.source).toBe("model");
231
- });
232
-
233
- it("reconstructs <slug>:<name> when reading a company skill's real path", () => {
234
- // Codex loads a company skill by reading companies/<slug>/skills/<name>/
235
- // SKILL.md; the slug must be recovered so the run attributes to the company
236
- // rather than being dropped to a bare, unattributed name.
237
- const ev = extractCodexSkillToolEvents(
238
- execEnd("sed -n '1,240p' companies/indigo/skills/signals/SKILL.md"),
239
- ctx,
240
- )[0];
241
- expect(ev.skill).toBe("indigo:signals");
242
- expect(ev.source).toBe("model");
243
- // The dedup/idempotency uuid keys off the reconstructed token.
244
- expect(ev.uuid).toBe("codex:skill:sess-x:turn-A:indigo:signals");
245
- });
246
-
247
- it("reconstructs the slug for any company and absolute path", () => {
248
- expect(
249
- extractCodexSkillToolEvents(
250
- execEnd("cat /home/ec2-user/hq/companies/beta/skills/support/SKILL.md"),
251
- ctx,
252
- )[0].skill,
253
- ).toBe("beta:support");
254
- // Hyphenated slug + hyphenated skill name both survive.
255
- expect(
256
- extractCodexSkillToolEvents(
257
- execEnd("rg -n x companies/indigo-auto/skills/call-prep-brief/SKILL.md"),
258
- ctx,
259
- )[0].skill,
260
- ).toBe("indigo-auto:call-prep-brief");
261
- });
262
-
263
- it("leaves an already-namespaced symlink read untouched (no double prefix)", () => {
264
- // Reading via the .claude/skills/<slug>:<name>/ symlink already carries the
265
- // prefix in the captured directory — it must NOT get a second one.
266
- expect(
267
- extractCodexSkillToolEvents(
268
- execEnd("cat .claude/skills/indigo:signals/SKILL.md"),
269
- ctx,
270
- )[0].skill,
271
- ).toBe("indigo:signals");
272
- });
273
-
274
- it("does not fabricate a company prefix for a non-company skill", () => {
275
- // A core skill read from its bare directory stays bare — the reconstruction
276
- // only triggers on the companies/<slug>/skills/ shape.
277
- expect(
278
- extractCodexSkillToolEvents(
279
- execEnd("cat .claude/skills/deploy/SKILL.md"),
280
- ctx,
281
- )[0].skill,
282
- ).toBe("deploy");
283
- });
284
-
285
- it("matches normal Codex skill paths without backtracking ambiguity", () => {
286
- expect(
287
- extractCodexSkillToolEvents(
288
- execEnd("cat /home/u/.claude/skills/hq/land/SKILL.md"),
289
- ctx,
290
- )[0].skill,
291
- ).toBe("land");
292
- expect(
293
- extractCodexSkillToolEvents(
294
- execEnd("cat /home/u/.claude/skills/a/b/c/SKILL.md"),
295
- ctx,
296
- )[0].skill,
297
- ).toBe("c");
298
- });
299
-
300
- it("returns quickly for pathological non-matching skills paths", () => {
301
- const cmd = "/home/u/.claude/skills/" + "a/".repeat(2000) + "z z";
302
- const started = Date.now();
303
- const events = extractCodexSkillToolEvents(execEnd(cmd), ctx);
304
- const elapsedMs = Date.now() - started;
305
- expect(events).toEqual([]);
306
- expect(elapsedMs).toBeLessThan(200);
307
- });
308
-
309
- it("absolute and relative SKILL.md paths both resolve", () => {
310
- expect(
311
- extractCodexSkillToolEvents(execEnd("cat /abs/path/.agents/skills/quiz/SKILL.md"), ctx)[0].skill,
312
- ).toBe("quiz");
313
- expect(
314
- extractCodexSkillToolEvents(execEnd("nl -ba .agents/skills/summary/SKILL.md"), ctx)[0].skill,
315
- ).toBe("summary");
316
- });
317
-
318
- it("dedups a turn's repeated reads of the same skill via the shared seen set", () => {
319
- const seen = new Set<string>();
320
- const first = extractCodexSkillToolEvents(
321
- execEnd("sed -n '1,240p' .agents/skills/learn/SKILL.md"),
322
- ctx,
323
- seen,
324
- );
325
- const second = extractCodexSkillToolEvents(
326
- execEnd("rg -n 'frequency' .agents/skills/learn/SKILL.md"),
327
- ctx,
328
- seen,
329
- );
330
- expect(first).toHaveLength(1);
331
- expect(second).toEqual([]); // same (session, turn, skill) → collapsed
332
- });
333
-
334
- it("does NOT dedup the same skill across different turns", () => {
335
- const seen = new Set<string>();
336
- const a = extractCodexSkillToolEvents(
337
- execEnd("sed -n '1,240p' .agents/skills/learn/SKILL.md", { turn_id: "turn-A" }),
338
- ctx,
339
- seen,
340
- );
341
- const b = extractCodexSkillToolEvents(
342
- execEnd("sed -n '1,240p' .agents/skills/learn/SKILL.md", { turn_id: "turn-B" }),
343
- ctx,
344
- seen,
345
- );
346
- expect(a).toHaveLength(1);
347
- expect(b).toHaveLength(1);
348
- expect(b[0].uuid).toBe("codex:skill:sess-x:turn-B:learn");
349
- });
350
-
351
- it("excludes edits: parsed_cmd not a read, or a write redirect into SKILL.md", () => {
352
- // Codex classifies the exec as something other than a read.
353
- expect(
354
- extractCodexSkillToolEvents(
355
- execEnd("apply_patch .agents/skills/learn/SKILL.md", { parsed_cmd: [{ type: "write" }] }),
356
- ctx,
357
- ),
358
- ).toEqual([]);
359
- // A shell write into the skill file, even with a read verb elsewhere.
360
- expect(
361
- extractCodexSkillToolEvents(
362
- execEnd("cat tmp > .agents/skills/learn/SKILL.md", { parsed_cmd: undefined }),
363
- ctx,
364
- ),
365
- ).toEqual([]);
366
- });
367
-
368
- it("prefers the row cwd, falling back to session ctx cwd", () => {
369
- expect(
370
- extractCodexSkillToolEvents(
371
- execEnd("sed -n '1,5p' .agents/skills/learn/SKILL.md", { cwd: "/home/ec2-user/hq/sub" }),
372
- ctx,
373
- )[0].cwd,
374
- ).toBe("/home/ec2-user/hq/sub");
375
- expect(
376
- extractCodexSkillToolEvents(
377
- execEnd("sed -n '1,5p' .agents/skills/learn/SKILL.md", { cwd: undefined }),
378
- ctx,
379
- )[0].cwd,
380
- ).toBe(ctx.cwd);
381
- });
382
-
383
- it("uuid falls back to timestamp without a turn_id, and is omitted without a session", () => {
384
- const noTurn = extractCodexSkillToolEvents(
385
- execEnd("sed -n '1,5p' .agents/skills/learn/SKILL.md", { turn_id: undefined }),
386
- ctx,
387
- )[0];
388
- expect(noTurn.uuid).toBe("codex:skill:sess-x:2026-04-27T07:51:30.395Z:learn");
389
- const noSession = extractCodexSkillToolEvents(
390
- execEnd("sed -n '1,5p' .agents/skills/learn/SKILL.md"),
391
- {},
392
- )[0];
393
- expect(noSession.uuid).toBeUndefined();
394
- });
395
-
396
- it("ignores non-exec rows and execs that touch no SKILL.md", () => {
397
- expect(extractCodexSkillToolEvents(execEnd("sed -n '1,5p' src/index.ts"), ctx)).toEqual([]);
398
- expect(
399
- extractCodexSkillToolEvents({ type: "event_msg", payload: { type: "task_started" } }, ctx),
400
- ).toEqual([]);
401
- expect(
402
- extractCodexSkillToolEvents({ type: "event_msg", payload: { type: "user_message", message: "/deploy" } }, ctx),
403
- ).toEqual([]);
404
- expect(extractCodexSkillToolEvents(null, ctx)).toEqual([]);
405
- });
406
-
407
- // The OTHER exec shape some Codex CLI versions emit (observed on 0.128): a
408
- // `response_item` `function_call` named `exec_command`, with the command +
409
- // workdir in `arguments` (a JSON string) and NO turn_id/parsed_cmd on the row.
410
- function fnCall(cmd: string, args: Record<string, unknown> = {}, over: Record<string, unknown> = {}) {
411
- return {
412
- timestamp: "2026-06-10T16:39:41.260Z",
413
- type: "response_item",
414
- payload: {
415
- type: "function_call",
416
- name: "exec_command",
417
- call_id: "call_abc",
418
- arguments: JSON.stringify({ cmd, workdir: "/home/ec2-user/hq", ...args }),
419
- ...over,
420
- },
421
- };
422
- }
423
-
424
- it("extracts a skill load from the function_call exec shape, turn from ctx", () => {
425
- const ev = extractCodexSkillToolEvents(
426
- fnCall("sed -n '1,80p' .claude/skills/indigo:hello-world/SKILL.md"),
427
- { ...ctx, turnId: "turn-Z" },
428
- );
429
- expect(ev).toEqual([
430
- {
431
- skill: "indigo:hello-world",
432
- source: "model",
433
- sessionId: "sess-x",
434
- timestamp: "2026-06-10T16:39:41.260Z",
435
- cwd: "/home/ec2-user/hq", // from arguments.workdir
436
- uuid: "codex:skill:sess-x:turn-Z:indigo:hello-world",
437
- hasArgs: false,
438
- },
439
- ]);
440
- });
441
-
442
- it("function_call: read-intent decided by command text (no parsed_cmd present)", () => {
443
- // A read verb → counted.
444
- expect(
445
- extractCodexSkillToolEvents(fnCall("wc -l .claude/skills/quiz/SKILL.md"), ctx)[0].skill,
446
- ).toBe("quiz");
447
- // A write redirect into the skill file → excluded.
448
- expect(
449
- extractCodexSkillToolEvents(fnCall("printf x > .claude/skills/quiz/SKILL.md"), ctx),
450
- ).toEqual([]);
451
- });
452
-
453
- it("function_call: ignores non-exec tool names (e.g. apply_patch edits)", () => {
454
- expect(
455
- extractCodexSkillToolEvents(
456
- fnCall("anything", {}, { name: "apply_patch" }),
457
- ctx,
458
- ),
459
- ).toEqual([]);
460
- });
461
-
462
- it("function_call: falls back to ctx.cwd when arguments has no workdir", () => {
463
- expect(
464
- extractCodexSkillToolEvents(
465
- fnCall("sed -n '1,5p' .agents/skills/learn/SKILL.md", { workdir: undefined }),
466
- ctx,
467
- )[0].cwd,
468
- ).toBe(ctx.cwd);
469
- });
470
-
471
- it("dedups across the two exec shapes within one turn (no double count)", () => {
472
- const seen = new Set<string>();
473
- // exec_command_end form for skill `learn` in turn-A …
474
- const a = extractCodexSkillToolEvents(
475
- execEnd("sed -n '1,240p' .agents/skills/learn/SKILL.md", { turn_id: "turn-A" }),
476
- ctx,
477
- seen,
478
- );
479
- // … then the function_call form for the same skill in the same turn.
480
- const b = extractCodexSkillToolEvents(
481
- fnCall("rg -n x .agents/skills/learn/SKILL.md"),
482
- { ...ctx, turnId: "turn-A" },
483
- seen,
484
- );
485
- expect(a).toHaveLength(1);
486
- expect(b).toEqual([]); // same (session, turn, skill) → collapsed
487
- });
488
-
489
- it("codexRowTurnId reads turn_id from a row's payload, else undefined", () => {
490
- expect(codexRowTurnId({ type: "turn_context", payload: { turn_id: "turn-9" } })).toBe("turn-9");
491
- expect(codexRowTurnId({ type: "event_msg", payload: { type: "exec_command_end", turn_id: "turn-7" } })).toBe("turn-7");
492
- expect(codexRowTurnId({ type: "response_item", payload: { type: "function_call" } })).toBeUndefined();
493
- expect(codexRowTurnId(null)).toBeUndefined();
494
- });
495
- });
496
-
497
- describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
498
- function stubClient(captured: SkillInvocationBatch[]) {
499
- return {
500
- async getTelemetryOptIn() {
501
- return { enabled: true, updatedAt: null };
502
- },
503
- async postSkillInvocations(batch: SkillInvocationBatch) {
504
- captured.push(batch);
505
- return { ok: true, written: batch.events.length, skipped: [] };
506
- },
507
- };
508
- }
509
-
510
- function row(obj: Record<string, unknown>): string {
511
- return JSON.stringify(obj);
512
- }
513
-
514
- it("emits only invocations whose cwd equals hqRoot", async () => {
515
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-"));
516
- const projects = path.join(tmp, "projects");
517
- const hqRoot = "/home/ec2-user/hq";
518
-
519
- // Project A — sessions run from the HQ root.
520
- const dirA = path.join(projects, "-home-ec2-user-hq");
521
- await fs.mkdir(dirA, { recursive: true });
522
- await fs.writeFile(
523
- path.join(dirA, "a.jsonl"),
524
- [
525
- row({ type: "user", sessionId: "s1", timestamp: "2026-06-03T10:00:00Z", cwd: hqRoot, uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }),
526
- row({ type: "assistant", sessionId: "s1", timestamp: "2026-06-03T10:01:00Z", cwd: hqRoot, message: { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Skill", input: { skill: "indigo:hello-world" } }] } }),
527
- ].join("\n") + "\n",
528
- "utf-8",
529
- );
530
-
531
- // Project B — a different repo on the same machine. Must be excluded.
532
- const dirB = path.join(projects, "-home-ec2-user-other");
533
- await fs.mkdir(dirB, { recursive: true });
534
- await fs.writeFile(
535
- path.join(dirB, "b.jsonl"),
536
- row({ type: "user", sessionId: "s2", timestamp: "2026-06-03T10:02:00Z", cwd: "/home/ec2-user/other", uuid: "u2", message: { role: "user", content: "<command-name>/secret-skill</command-name>" } }) + "\n",
537
- "utf-8",
538
- );
539
-
540
- const captured: SkillInvocationBatch[] = [];
541
- const result = await collectAndSendSkillTelemetry({
542
- client: stubClient(captured),
543
- machineId: "m-test",
544
- installerVersion: "test",
545
- hqRoot,
546
- claudeProjectsRoot: projects,
547
- codexSessionsRoot: path.join(tmp, "codex"),
548
- cursorPath: path.join(tmp, "cursor.json"),
549
- });
550
-
551
- const skills = captured.flatMap((b) => b.events.map((e) => e.skill));
552
- expect(result.eventsSent).toBe(2);
553
- expect(skills.sort()).toEqual(["deploy", "indigo:hello-world"]);
554
- // The other-repo invocation never leaves the machine.
555
- expect(skills).not.toContain("secret-skill");
556
-
557
- await fs.rm(tmp, { recursive: true, force: true });
558
- });
559
-
560
- it("commits the cursor to EOF on a clean run — a rerun re-sends nothing", async () => {
561
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-"));
562
- const projects = path.join(tmp, "projects");
563
- const dir = path.join(projects, "-p");
564
- await fs.mkdir(dir, { recursive: true });
565
- await fs.writeFile(
566
- path.join(dir, "s.jsonl"),
567
- row({ type: "user", sessionId: "s1", timestamp: "2026-06-04T09:00:00Z", cwd: "/x", uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }) + "\n",
568
- "utf-8",
569
- );
570
- const cursorPath = path.join(tmp, "cursor.json");
571
- const captured: SkillInvocationBatch[] = [];
572
- const client = stubClient(captured);
573
-
574
- const r1 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, codexSessionsRoot: path.join(tmp, "codex"), cursorPath });
575
- expect(r1.eventsSent).toBe(1);
576
- const r2 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, codexSessionsRoot: path.join(tmp, "codex"), cursorPath });
577
- expect(r2.eventsSent).toBe(0); // cursor at EOF → nothing re-sent
578
-
579
- await fs.rm(tmp, { recursive: true, force: true });
580
- });
581
-
582
- it("does NOT advance the cursor when the POST fails — re-sends on the next run", async () => {
583
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-"));
584
- const projects = path.join(tmp, "projects");
585
- const dir = path.join(projects, "-p");
586
- await fs.mkdir(dir, { recursive: true });
587
- await fs.writeFile(
588
- path.join(dir, "s.jsonl"),
589
- row({ type: "user", sessionId: "s1", timestamp: "2026-06-04T09:00:00Z", cwd: "/x", uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }) + "\n",
590
- "utf-8",
591
- );
592
- const cursorPath = path.join(tmp, "cursor.json");
593
- const captured: SkillInvocationBatch[] = [];
594
- let failNext = true;
595
- const client = {
596
- async getTelemetryOptIn() {
597
- return { enabled: true, updatedAt: null };
598
- },
599
- async postSkillInvocations(batch: SkillInvocationBatch) {
600
- if (failNext) {
601
- failNext = false;
602
- throw new Error("network down");
603
- }
604
- captured.push(batch);
605
- return { ok: true, written: batch.events.length, skipped: [] };
606
- },
607
- };
608
-
609
- const r1 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, codexSessionsRoot: path.join(tmp, "codex"), cursorPath });
610
- expect(r1.eventsSent).toBe(0); // post threw → nothing committed
611
- expect(captured).toHaveLength(0);
612
-
613
- const r2 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, codexSessionsRoot: path.join(tmp, "codex"), cursorPath });
614
- expect(r2.eventsSent).toBe(1); // re-scanned from offset 0 and delivered
615
- expect(captured.flatMap((b) => b.events.map((e) => e.skill))).toEqual(["deploy"]);
616
-
617
- await fs.rm(tmp, { recursive: true, force: true });
618
- });
619
-
620
- it("R-F19: bounds a single oversized sanitized skill row before POST", async () => {
621
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-r-f19-"));
622
- const projects = path.join(tmp, "projects");
623
- const dir = path.join(projects, "-p");
624
- await fs.mkdir(dir, { recursive: true });
625
- const hugeSkill = `deploy-${"x".repeat(400_000)}`;
626
- await fs.writeFile(
627
- path.join(dir, "s.jsonl"),
628
- row({
629
- type: "user",
630
- sessionId: "s-r-f19-singleton",
631
- timestamp: "2026-06-19T11:00:00.000Z",
632
- cwd: "/x",
633
- uuid: "u-r-f19-singleton",
634
- message: {
635
- role: "user",
636
- content: `<command-name>/${hugeSkill}</command-name>`,
637
- },
638
- }) + "\n",
639
- "utf-8",
640
- );
641
-
642
- const captured: SkillInvocationBatch[] = [];
643
- const logs: string[] = [];
644
- const result = await collectAndSendSkillTelemetry({
645
- client: stubClient(captured),
646
- machineId: "m",
647
- installerVersion: "t",
648
- claudeProjectsRoot: projects,
649
- codexSessionsRoot: path.join(tmp, "codex"),
650
- cursorPath: path.join(tmp, "cursor.json"),
651
- log: (msg) => logs.push(msg),
652
- });
653
-
654
- expect(result.eventsSent).toBe(1);
655
- expect(captured).toHaveLength(1);
656
- const wireSize = Buffer.byteLength(JSON.stringify(captured[0]), "utf-8");
657
- expect(wireSize).toBeLessThanOrEqual(240 * 1024);
658
- expect(captured[0].events[0].skill).not.toBe(hugeSkill);
659
- expect(logs.some((line) => line.includes("oversized row truncated"))).toBe(
660
- true,
661
- );
662
-
663
- await fs.rm(tmp, { recursive: true, force: true });
664
- });
665
-
666
- it("R-F19: a failed earlier skill batch prevents cursor advancement past it", async () => {
667
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-r-f19-"));
668
- const projects = path.join(tmp, "projects");
669
- const dir = path.join(projects, "-p");
670
- await fs.mkdir(dir, { recursive: true });
671
- const file = path.join(dir, "s.jsonl");
672
- await fs.writeFile(
673
- file,
674
- Array.from({ length: 101 }, (_, i) =>
675
- row({
676
- type: "user",
677
- sessionId: "s-r-f19-prefix",
678
- timestamp: `2026-06-19T12:${String(Math.floor(i / 60)).padStart(2, "0")}:${String(i % 60).padStart(2, "0")}.000Z`,
679
- cwd: "/x",
680
- uuid: `u-r-f19-${i}`,
681
- message: {
682
- role: "user",
683
- content: `<command-name>/skill-${i}</command-name>`,
684
- },
685
- }),
686
- ).join("\n") + "\n",
687
- "utf-8",
688
- );
689
- const cursorPath = path.join(tmp, "cursor.json");
690
- const captured: SkillInvocationBatch[] = [];
691
- let calls = 0;
692
- const client = {
693
- async getTelemetryOptIn() {
694
- return { enabled: true, updatedAt: null };
695
- },
696
- async postSkillInvocations(batch: SkillInvocationBatch) {
697
- calls++;
698
- if (calls === 1) throw new Error("first batch failed");
699
- captured.push(batch);
700
- return { ok: true, written: batch.events.length, skipped: [] };
701
- },
702
- };
703
- const opts = {
704
- client,
705
- machineId: "m",
706
- installerVersion: "t",
707
- claudeProjectsRoot: projects,
708
- codexSessionsRoot: path.join(tmp, "codex"),
709
- cursorPath,
710
- };
711
-
712
- const first = await collectAndSendSkillTelemetry(opts);
713
- expect(first.eventsSent).toBe(0);
714
- expect(calls).toBe(1);
715
- const cursorAfterFailure = JSON.parse(
716
- await fs.readFile(cursorPath, "utf-8"),
717
- ) as { files: Record<string, { offset: number }> };
718
- expect(cursorAfterFailure.files[file]?.offset ?? 0).toBe(0);
719
-
720
- const second = await collectAndSendSkillTelemetry(opts);
721
- expect(second.eventsSent).toBe(101);
722
- expect(captured.flatMap((b) => b.events.map((e) => e.skill))).toContain(
723
- "skill-0",
724
- );
725
-
726
- await fs.rm(tmp, { recursive: true, force: true });
727
- });
728
-
729
- it("picks up only newly-appended events on a later run (incremental offset)", async () => {
730
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-"));
731
- const projects = path.join(tmp, "projects");
732
- const dir = path.join(projects, "-p");
733
- await fs.mkdir(dir, { recursive: true });
734
- const file = path.join(dir, "s.jsonl");
735
- await fs.writeFile(
736
- file,
737
- row({ type: "user", sessionId: "s1", timestamp: "2026-06-04T09:00:00Z", cwd: "/x", uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }) + "\n",
738
- "utf-8",
739
- );
740
- const cursorPath = path.join(tmp, "cursor.json");
741
- const captured: SkillInvocationBatch[] = [];
742
- const client = stubClient(captured);
743
-
744
- await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, codexSessionsRoot: path.join(tmp, "codex"), cursorPath });
745
-
746
- // New invocation appended after the first clean run.
747
- await fs.appendFile(
748
- file,
749
- row({ type: "assistant", sessionId: "s1", timestamp: "2026-06-04T09:05:00Z", cwd: "/x", message: { role: "assistant", content: [{ type: "tool_use", id: "t9", name: "Skill", input: { skill: "land" } }] } }) + "\n",
750
- "utf-8",
751
- );
752
-
753
- const r2 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, codexSessionsRoot: path.join(tmp, "codex"), cursorPath });
754
- expect(r2.eventsSent).toBe(1);
755
- // Only the appended event — not the original — is re-sent.
756
- const lastBatch = captured[captured.length - 1];
757
- expect(lastBatch.events.map((e) => e.skill)).toEqual(["land"]);
758
-
759
- await fs.rm(tmp, { recursive: true, force: true });
760
- });
761
-
762
- it("HQ-DESKTOP-47: settles at complete UTF-8 lines and drains a bounded backlog without gaps", async () => {
763
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-bounded-"));
764
- const projects = path.join(tmp, "projects");
765
- const dir = path.join(projects, "-p");
766
- await fs.mkdir(dir, { recursive: true });
767
- const file = path.join(dir, "s.jsonl");
768
- const lines = [
769
- row({ type: "user", sessionId: "s1", timestamp: "2026-08-02T10:00:00Z", cwd: "/x", uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }),
770
- row({ type: "user", sessionId: "s1", timestamp: "2026-08-02T10:01:00Z", cwd: "/x", uuid: "u2", message: { role: "user", content: "<command-name>/plán</command-name>" } }),
771
- row({ type: "user", sessionId: "s1", timestamp: "2026-08-02T10:02:00Z", cwd: "/x", uuid: "u3", message: { role: "user", content: "<command-name>/ship</command-name>" } }),
772
- ];
773
- await fs.writeFile(file, `${lines.join("\n")}\n`, "utf-8");
774
-
775
- const cursorPath = path.join(tmp, "cursor.json");
776
- const captured: SkillInvocationBatch[] = [];
777
- const client = stubClient(captured);
778
- const base = {
779
- client,
780
- machineId: "m",
781
- installerVersion: "t",
782
- claudeProjectsRoot: projects,
783
- codexSessionsRoot: path.join(tmp, "codex"),
784
- cursorPath,
785
- };
786
- const cursorOffset = async (): Promise<number> => {
787
- const cursor = JSON.parse(await fs.readFile(cursorPath, "utf-8")) as {
788
- files: Record<string, { offset: number }>;
789
- };
790
- return cursor.files[file]?.offset ?? 0;
791
- };
792
-
793
- // Stop in the middle of the two-byte á. The first pass must commit only
794
- // the preceding complete line rather than a character-counted position.
795
- const accentOffset = lines[1].indexOf("á");
796
- const firstBudget =
797
- Buffer.byteLength(`${lines[0]}\n${lines[1].slice(0, accentOffset)}`, "utf-8") + 1;
798
- const logs: string[] = [];
799
- const first = await collectAndSendSkillTelemetry({
800
- ...base,
801
- maxScanBytesPerSource: firstBudget,
802
- log: (message) => logs.push(message),
803
- });
804
- const afterFirst = await cursorOffset();
805
- expect(first.eventsSent).toBe(1);
806
- expect(afterFirst).toBe(Buffer.byteLength(`${lines[0]}\n`, "utf-8"));
807
- expect(logs.some((message) => message.includes("scan budget reached"))).toBe(true);
808
-
809
- // The second pass reaches the next complete line but not the third; the
810
- // third drains the remainder. Together they must equal an unbounded scan.
811
- const second = await collectAndSendSkillTelemetry({
812
- ...base,
813
- maxScanBytesPerSource: Buffer.byteLength(`${lines[1]}\n`, "utf-8") + 1,
814
- });
815
- const afterSecond = await cursorOffset();
816
- const third = await collectAndSendSkillTelemetry({
817
- ...base,
818
- maxScanBytesPerSource: 1024 * 1024,
819
- });
820
- const afterThird = await cursorOffset();
821
-
822
- expect(second.eventsSent).toBe(1);
823
- expect(third.eventsSent).toBe(1);
824
- expect(afterFirst).toBeLessThan(afterSecond);
825
- expect(afterSecond).toBeLessThan(afterThird);
826
- expect(afterThird).toBe(Buffer.byteLength(`${lines.join("\n")}\n`, "utf-8"));
827
- expect(captured.flatMap((batch) => batch.events.map((event) => event.skill))).toEqual([
828
- "deploy",
829
- "plán",
830
- "ship",
831
- ]);
832
-
833
- await fs.rm(tmp, { recursive: true, force: true });
834
- });
835
-
836
- it("HQ-DESKTOP-47: a transcript below the injected budget keeps the one-pass EOF behaviour", async () => {
837
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-small-budget-"));
838
- const projects = path.join(tmp, "projects");
839
- const dir = path.join(projects, "-p");
840
- await fs.mkdir(dir, { recursive: true });
841
- const file = path.join(dir, "s.jsonl");
842
- const transcript = row({ type: "user", sessionId: "s1", timestamp: "2026-08-02T11:00:00Z", cwd: "/x", uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }) + "\n";
843
- await fs.writeFile(file, transcript, "utf-8");
844
-
845
- const cursorPath = path.join(tmp, "cursor.json");
846
- const captured: SkillInvocationBatch[] = [];
847
- const result = await collectAndSendSkillTelemetry({
848
- client: stubClient(captured),
849
- machineId: "m",
850
- installerVersion: "t",
851
- claudeProjectsRoot: projects,
852
- codexSessionsRoot: path.join(tmp, "codex"),
853
- cursorPath,
854
- maxScanBytesPerSource: Buffer.byteLength(transcript, "utf-8") + 1,
855
- });
856
- const cursor = JSON.parse(await fs.readFile(cursorPath, "utf-8")) as {
857
- files: Record<string, { offset: number }>;
858
- };
859
-
860
- expect(result.eventsSent).toBe(1);
861
- expect(cursor.files[file]?.offset).toBe(Buffer.byteLength(transcript, "utf-8"));
862
- expect(captured.flatMap((batch) => batch.events.map((event) => event.skill))).toEqual([
863
- "deploy",
864
- ]);
865
-
866
- await fs.rm(tmp, { recursive: true, force: true });
867
- });
868
-
869
- it("HQ-DESKTOP-47: a truncated bounded backlog resets to the replacement file", async () => {
870
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-budget-rotate-"));
871
- const projects = path.join(tmp, "projects");
872
- const dir = path.join(projects, "-p");
873
- await fs.mkdir(dir, { recursive: true });
874
- const file = path.join(dir, "s.jsonl");
875
- const oldLine = row({ type: "user", sessionId: "old", timestamp: "2026-08-02T12:00:00Z", cwd: "/x", uuid: "old-1", message: { role: "user", content: "<command-name>/deploy</command-name>" } });
876
- const oldTail = row({ type: "user", sessionId: "old", timestamp: "2026-08-02T12:01:00Z", cwd: "/x", uuid: "old-2", message: { role: "user", content: "<command-name>/ship</command-name>" } });
877
- await fs.writeFile(file, `${oldLine}\n${oldTail}\n`, "utf-8");
878
-
879
- const cursorPath = path.join(tmp, "cursor.json");
880
- const captured: SkillInvocationBatch[] = [];
881
- const base = {
882
- client: stubClient(captured),
883
- machineId: "m",
884
- installerVersion: "t",
885
- claudeProjectsRoot: projects,
886
- codexSessionsRoot: path.join(tmp, "codex"),
887
- cursorPath,
888
- };
889
- await collectAndSendSkillTelemetry({
890
- ...base,
891
- maxScanBytesPerSource: Buffer.byteLength(`${oldLine}\n`, "utf-8") + 1,
892
- });
893
-
894
- const replacement = row({ type: "user", sessionId: "new", timestamp: "2026-08-02T12:02:00Z", cwd: "/x", uuid: "new-1", message: { role: "user", content: "<command-name>/land</command-name>" } }) + "\n";
895
- expect(Buffer.byteLength(replacement, "utf-8")).toBeLessThan(
896
- Buffer.byteLength(`${oldLine}\n`, "utf-8"),
897
- );
898
- await fs.writeFile(file, replacement, "utf-8");
899
-
900
- const result = await collectAndSendSkillTelemetry({
901
- ...base,
902
- maxScanBytesPerSource: 1024 * 1024,
903
- });
904
- const cursor = JSON.parse(await fs.readFile(cursorPath, "utf-8")) as {
905
- files: Record<string, { offset: number }>;
906
- };
907
-
908
- expect(result.eventsSent).toBe(1);
909
- expect(cursor.files[file]?.offset).toBe(Buffer.byteLength(replacement, "utf-8"));
910
- expect(captured.flatMap((batch) => batch.events.map((event) => event.skill))).toEqual([
911
- "deploy",
912
- "land",
913
- ]);
914
-
915
- await fs.rm(tmp, { recursive: true, force: true });
916
- });
917
-
918
- it("HQ-DESKTOP-47: continues past a single JSONL record larger than the scan budget", async () => {
919
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-large-line-"));
920
- const projects = path.join(tmp, "projects");
921
- const dir = path.join(projects, "-p");
922
- await fs.mkdir(dir, { recursive: true });
923
- const file = path.join(dir, "s.jsonl");
924
- const oversized = row({ type: "user", sessionId: "s1", timestamp: "2026-08-02T13:00:00Z", cwd: "/x", uuid: "u1", message: { role: "user", content: `<command-name>/deploy</command-name><command-args>${"x".repeat(900)}</command-args>` } });
925
- const tail = row({ type: "user", sessionId: "s1", timestamp: "2026-08-02T13:01:00Z", cwd: "/x", uuid: "u2", message: { role: "user", content: "<command-name>/land</command-name>" } });
926
- await fs.writeFile(file, `${oversized}\n${tail}\n`, "utf-8");
927
-
928
- const cursorPath = path.join(tmp, "cursor.json");
929
- const captured: SkillInvocationBatch[] = [];
930
- const base = {
931
- client: stubClient(captured),
932
- machineId: "m",
933
- installerVersion: "t",
934
- claudeProjectsRoot: projects,
935
- codexSessionsRoot: path.join(tmp, "codex"),
936
- cursorPath,
937
- maxScanBytesPerSource: 128,
938
- };
939
- const offsets: number[] = [];
940
- for (let pass = 0; pass < 12; pass++) {
941
- await collectAndSendSkillTelemetry(base);
942
- const cursor = JSON.parse(await fs.readFile(cursorPath, "utf-8")) as {
943
- files: Record<string, { offset: number; pendingLine?: { scannedOffset: number } }>;
944
- };
945
- offsets.push(cursor.files[file]?.offset ?? 0);
946
- }
947
-
948
- expect(offsets.some((offset) => offset > 0)).toBe(true);
949
- expect(offsets[0]).toBe(0); // first pass only discovers a partial record
950
- expect(captured.flatMap((batch) => batch.events.map((event) => event.skill))).toEqual([
951
- "deploy",
952
- "land",
953
- ]);
954
-
955
- await fs.rm(tmp, { recursive: true, force: true });
956
- });
957
-
958
- it("HQ-DESKTOP-47: rotates a shared runtime budget so a backlog cannot starve a later session", async () => {
959
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-fair-"));
960
- const projects = path.join(tmp, "projects");
961
- const backlogDir = path.join(projects, "a-backlog");
962
- const currentDir = path.join(projects, "b-current");
963
- await fs.mkdir(backlogDir, { recursive: true });
964
- await fs.mkdir(currentDir, { recursive: true });
965
- const backlogLine = row({ type: "user", sessionId: "old", timestamp: "2026-08-02T14:00:00Z", cwd: "/x", uuid: "old-1", message: { role: "user", content: "<command-name>/deploy</command-name>" } });
966
- await fs.writeFile(
967
- path.join(backlogDir, "old.jsonl"),
968
- `${Array.from({ length: 4 }, () => backlogLine).join("\n")}\n`,
969
- "utf-8",
970
- );
971
- await fs.writeFile(
972
- path.join(currentDir, "current.jsonl"),
973
- row({ type: "user", sessionId: "new", timestamp: "2026-08-02T14:01:00Z", cwd: "/x", uuid: "new-1", message: { role: "user", content: "<command-name>/land</command-name>" } }) + "\n",
974
- "utf-8",
975
- );
976
-
977
- const captured: SkillInvocationBatch[] = [];
978
- const cursorPath = path.join(tmp, "cursor.json");
979
- const budget = Buffer.byteLength(`${backlogLine}\n`, "utf-8") + 1;
980
- const base = {
981
- client: stubClient(captured),
982
- machineId: "m",
983
- installerVersion: "t",
984
- claudeProjectsRoot: projects,
985
- codexSessionsRoot: path.join(tmp, "codex"),
986
- cursorPath,
987
- maxScanBytesPerSource: budget,
988
- };
989
-
990
- await collectAndSendSkillTelemetry(base);
991
- await collectAndSendSkillTelemetry(base);
992
-
993
- expect(captured.flatMap((batch) => batch.events.map((event) => event.skill))).toContain(
994
- "land",
995
- );
996
-
997
- await fs.rm(tmp, { recursive: true, force: true });
998
- });
999
-
1000
- it("captures every project when hqRoot is omitted", async () => {
1001
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-"));
1002
- const projects = path.join(tmp, "projects");
1003
- const dir = path.join(projects, "-proj");
1004
- await fs.mkdir(dir, { recursive: true });
1005
- await fs.writeFile(
1006
- path.join(dir, "c.jsonl"),
1007
- row({ type: "user", sessionId: "s3", timestamp: "2026-06-03T10:03:00Z", cwd: "/anywhere", uuid: "u3", message: { role: "user", content: "<command-name>/anywhere-skill</command-name>" } }) + "\n",
1008
- "utf-8",
1009
- );
1010
-
1011
- const captured: SkillInvocationBatch[] = [];
1012
- const result = await collectAndSendSkillTelemetry({
1013
- client: stubClient(captured),
1014
- machineId: "m-test",
1015
- installerVersion: "test",
1016
- claudeProjectsRoot: projects,
1017
- codexSessionsRoot: path.join(tmp, "codex"),
1018
- cursorPath: path.join(tmp, "cursor.json"),
1019
- });
1020
-
1021
- expect(result.eventsSent).toBe(1);
1022
- expect(captured[0].events[0].skill).toBe("anywhere-skill");
1023
-
1024
- await fs.rm(tmp, { recursive: true, force: true });
1025
- });
1026
-
1027
- // ── Codex CLI rollouts ────────────────────────────────────────────────────
1028
-
1029
- // Build a realistic Codex rollout: a leading session_meta, the environment
1030
- // boilerplate Codex writes (which must be ignored), then the typed commands.
1031
- function codexRollout(sessionId: string, cwd: string, messages: string[]): string {
1032
- const lines = [
1033
- row({ timestamp: "2026-06-07T18:16:56.480Z", type: "session_meta", payload: { id: sessionId, cwd, originator: "codex_exec", cli_version: "0.137.0", source: "exec" } }),
1034
- row({ timestamp: "2026-06-07T18:16:56.718Z", type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "<environment_context>\n <cwd>" + cwd + "</cwd>\n</environment_context>" }] } }),
1035
- ...messages.flatMap((message, i) => [
1036
- // Codex writes BOTH a response_item (role:user) and an event_msg for the
1037
- // same input; only the event_msg must be counted (no double-count).
1038
- row({ timestamp: `2026-06-07T18:17:0${i}.000Z`, type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: message }] } }),
1039
- row({ timestamp: `2026-06-07T18:17:0${i}.000Z`, type: "event_msg", payload: { type: "user_message", message, images: [], local_images: [], text_elements: [] } }),
1040
- ]),
1041
- ];
1042
- return lines.join("\n") + "\n";
1043
- }
1044
-
1045
- it("captures Codex typed slash skills scoped to hqRoot, excluding other cwds + dupes", async () => {
1046
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-codex-"));
1047
- const codex = path.join(tmp, "sessions", "2026", "06", "07");
1048
- await fs.mkdir(codex, { recursive: true });
1049
- const hqRoot = "/home/ec2-user/hq";
1050
-
1051
- await fs.writeFile(
1052
- path.join(codex, "rollout-2026-06-07T18-16-56-aaaa.jsonl"),
1053
- codexRollout("sess-codex-1", hqRoot, ["/indigo:hello-world", "/indigo:signals refresh"]),
1054
- "utf-8",
1055
- );
1056
- // A second rollout from a different repo — must be excluded by hqRoot scope.
1057
- await fs.writeFile(
1058
- path.join(codex, "rollout-2026-06-07T18-20-00-bbbb.jsonl"),
1059
- codexRollout("sess-codex-2", "/home/ec2-user/other", ["/secret-skill"]),
1060
- "utf-8",
1061
- );
1062
-
1063
- const captured: SkillInvocationBatch[] = [];
1064
- const result = await collectAndSendSkillTelemetry({
1065
- client: stubClient(captured),
1066
- machineId: "m",
1067
- installerVersion: "t",
1068
- hqRoot,
1069
- claudeProjectsRoot: path.join(tmp, "no-claude"),
1070
- codexSessionsRoot: path.join(tmp, "sessions"),
1071
- cursorPath: path.join(tmp, "cursor.json"),
1072
- });
1073
-
1074
- const events = captured.flatMap((b) => b.events);
1075
- // No double-count from the duplicate response_item rows.
1076
- expect(result.eventsSent).toBe(2);
1077
- expect(events.map((e) => e.skill).sort()).toEqual(["indigo:hello-world", "indigo:signals"]);
1078
- // Codex invocations are recorded as the typed source.
1079
- expect(events.every((e) => e.source === "typed")).toBe(true);
1080
- // The other-repo Codex invocation never leaves the machine.
1081
- expect(events.map((e) => e.skill)).not.toContain("secret-skill");
1082
-
1083
- await fs.rm(tmp, { recursive: true, force: true });
1084
- });
1085
-
1086
- it("captures Claude and Codex skills together in a single run", async () => {
1087
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-both-"));
1088
- const projects = path.join(tmp, "projects", "-home-ec2-user-hq");
1089
- const codex = path.join(tmp, "sessions", "2026", "06", "07");
1090
- await fs.mkdir(projects, { recursive: true });
1091
- await fs.mkdir(codex, { recursive: true });
1092
- const hqRoot = "/home/ec2-user/hq";
1093
-
1094
- await fs.writeFile(
1095
- path.join(projects, "a.jsonl"),
1096
- row({ type: "assistant", sessionId: "s1", timestamp: "2026-06-07T10:00:00Z", cwd: hqRoot, message: { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Skill", input: { skill: "indigo:crm-management" } }] } }) + "\n",
1097
- "utf-8",
1098
- );
1099
- await fs.writeFile(
1100
- path.join(codex, "rollout-x.jsonl"),
1101
- codexRollout("sess-codex-1", hqRoot, ["/indigo:hello-world"]),
1102
- "utf-8",
1103
- );
1104
-
1105
- const captured: SkillInvocationBatch[] = [];
1106
- const result = await collectAndSendSkillTelemetry({
1107
- client: stubClient(captured),
1108
- machineId: "m",
1109
- installerVersion: "t",
1110
- hqRoot,
1111
- claudeProjectsRoot: path.join(tmp, "projects"),
1112
- codexSessionsRoot: path.join(tmp, "sessions"),
1113
- cursorPath: path.join(tmp, "cursor.json"),
1114
- });
1115
-
1116
- expect(result.eventsSent).toBe(2);
1117
- const events = captured.flatMap((b) => b.events);
1118
- expect(events.find((e) => e.skill === "indigo:crm-management")?.source).toBe("model");
1119
- expect(events.find((e) => e.skill === "indigo:hello-world")?.source).toBe("typed");
1120
-
1121
- await fs.rm(tmp, { recursive: true, force: true });
1122
- });
1123
-
1124
- it("reads Codex session ctx from the top on an incremental (cursor mid-file) run", async () => {
1125
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-incr-"));
1126
- const codex = path.join(tmp, "sessions", "2026", "06", "07");
1127
- await fs.mkdir(codex, { recursive: true });
1128
- const hqRoot = "/home/ec2-user/hq";
1129
- const file = path.join(codex, "rollout-incr.jsonl");
1130
-
1131
- await fs.writeFile(file, codexRollout("sess-incr", hqRoot, ["/indigo:hello-world"]), "utf-8");
1132
- const cursorPath = path.join(tmp, "cursor.json");
1133
- const captured: SkillInvocationBatch[] = [];
1134
- const client = stubClient(captured);
1135
- const base = { client, machineId: "m", installerVersion: "t", hqRoot, claudeProjectsRoot: path.join(tmp, "no-claude"), codexSessionsRoot: path.join(tmp, "sessions"), cursorPath };
1136
-
1137
- const r1 = await collectAndSendSkillTelemetry(base);
1138
- expect(r1.eventsSent).toBe(1);
1139
-
1140
- // Append a new typed command AFTER the session_meta line. The cursor now
1141
- // sits mid-file, so the new event's cwd/sessionId must still come from the
1142
- // session_meta at the top — proving readCodexSessionContext reads offset 0.
1143
- await fs.appendFile(
1144
- file,
1145
- row({ timestamp: "2026-06-07T18:30:00.000Z", type: "event_msg", payload: { type: "user_message", message: "/indigo:action-items" } }) + "\n",
1146
- "utf-8",
1147
- );
1148
-
1149
- const r2 = await collectAndSendSkillTelemetry(base);
1150
- expect(r2.eventsSent).toBe(1);
1151
- const last = captured[captured.length - 1].events[0];
1152
- expect(last.skill).toBe("indigo:action-items");
1153
- // cwd survived → it passed the hqRoot scope filter (would be dropped if the
1154
- // session_meta weren't re-read from the top on this mid-file run).
1155
- expect(last.cwd).toBe(hqRoot);
1156
- expect(last.sessionId).toBe("sess-incr");
1157
-
1158
- await fs.rm(tmp, { recursive: true, force: true });
1159
- });
1160
-
1161
- // A Codex rollout that drives a skill the way the model actually does: by
1162
- // reading its SKILL.md via exec_command. `reads` is a list of
1163
- // [skill, turnId, cmdTail] tuples turned into exec_command_end rows.
1164
- function codexModelRollout(
1165
- sessionId: string,
1166
- cwd: string,
1167
- reads: Array<[skill: string, turnId: string, extra?: Record<string, unknown>]>,
1168
- ): string {
1169
- const lines = [
1170
- row({ timestamp: "2026-06-08T10:00:00.000Z", type: "session_meta", payload: { id: sessionId, cwd, originator: "codex_exec" } }),
1171
- ...reads.map(([skill, turnId, extra], i) =>
1172
- row({
1173
- timestamp: `2026-06-08T10:0${i}:00.000Z`,
1174
- type: "event_msg",
1175
- payload: {
1176
- type: "exec_command_end",
1177
- call_id: `call_${i}`,
1178
- turn_id: turnId,
1179
- command: ["/bin/zsh", "-lc", `sed -n '1,240p' ${cwd}/.agents/skills/${skill}/SKILL.md`],
1180
- cwd,
1181
- parsed_cmd: [{ type: "read" }],
1182
- ...(extra ?? {}),
1183
- },
1184
- }),
1185
- ),
1186
- ];
1187
- return lines.join("\n") + "\n";
1188
- }
1189
-
1190
- it("captures Codex model-driven skill loads, deduped per turn", async () => {
1191
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-codexmodel-"));
1192
- const codex = path.join(tmp, "sessions", "2026", "06", "08");
1193
- await fs.mkdir(codex, { recursive: true });
1194
- const hqRoot = "/home/ec2-user/hq";
1195
-
1196
- await fs.writeFile(
1197
- path.join(codex, "rollout-model.jsonl"),
1198
- codexModelRollout("sess-model", hqRoot, [
1199
- ["learn", "turn-A"], // turn-A read #1
1200
- ["learn", "turn-A"], // turn-A read #2 — same turn+skill, must collapse
1201
- ["learn", "turn-B"], // turn-B — distinct turn, counts again
1202
- ["quiz", "turn-B"], // different skill, same turn — counts
1203
- ]),
1204
- "utf-8",
1205
- );
1206
-
1207
- const captured: SkillInvocationBatch[] = [];
1208
- const result = await collectAndSendSkillTelemetry({
1209
- client: stubClient(captured),
1210
- machineId: "m",
1211
- installerVersion: "t",
1212
- hqRoot,
1213
- claudeProjectsRoot: path.join(tmp, "no-claude"),
1214
- codexSessionsRoot: path.join(tmp, "sessions"),
1215
- cursorPath: path.join(tmp, "cursor.json"),
1216
- });
1217
-
1218
- const events = captured.flatMap((b) => b.events);
1219
- // 4 reads → 3 events: the two turn-A `learn` reads collapse to one.
1220
- expect(result.eventsSent).toBe(3);
1221
- expect(events.every((e) => e.source === "model")).toBe(true);
1222
- expect(events.map((e) => `${e.skill}@${e.uuid}`).sort()).toEqual([
1223
- "learn@codex:skill:sess-model:turn-A:learn",
1224
- "learn@codex:skill:sess-model:turn-B:learn",
1225
- "quiz@codex:skill:sess-model:turn-B:quiz",
1226
- ]);
1227
-
1228
- await fs.rm(tmp, { recursive: true, force: true });
1229
- });
1230
-
1231
- it("captures the function_call exec shape, attributing the turn from turn_context", async () => {
1232
- // Mirrors a real Codex 0.128 session: SKILL.md reads arrive as
1233
- // `response_item` `function_call` execs (no per-row turn_id), preceded by a
1234
- // `turn_context` that names the turn. The collector must thread that turn id.
1235
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-fncall-"));
1236
- const codex = path.join(tmp, "sessions", "2026", "06", "10");
1237
- await fs.mkdir(codex, { recursive: true });
1238
- const hqRoot = "/home/ec2-user/hq";
1239
-
1240
- const lines = [
1241
- row({ timestamp: "2026-06-10T16:38:32.000Z", type: "session_meta", payload: { id: "sess-fn", cwd: hqRoot, originator: "codex_exec" } }),
1242
- row({ timestamp: "2026-06-10T16:38:44.000Z", type: "turn_context", payload: { turn_id: "turn-1", cwd: hqRoot } }),
1243
- // Two reads of the same skill in turn-1 — the wc then the sed, as Codex does.
1244
- row({ timestamp: "2026-06-10T16:39:26.000Z", type: "response_item", payload: { type: "function_call", name: "exec_command", call_id: "c1", arguments: JSON.stringify({ cmd: "wc -l .claude/skills/indigo:hello-world/SKILL.md", workdir: hqRoot }) } }),
1245
- row({ timestamp: "2026-06-10T16:39:41.000Z", type: "response_item", payload: { type: "function_call", name: "exec_command", call_id: "c2", arguments: JSON.stringify({ cmd: "sed -n '1,80p' .claude/skills/indigo:hello-world/SKILL.md", workdir: hqRoot }) } }),
1246
- ].join("\n") + "\n";
1247
- await fs.writeFile(path.join(codex, "rollout-fn.jsonl"), lines, "utf-8");
1248
-
1249
- const captured: SkillInvocationBatch[] = [];
1250
- const result = await collectAndSendSkillTelemetry({
1251
- client: stubClient(captured),
1252
- machineId: "m",
1253
- installerVersion: "t",
1254
- hqRoot,
1255
- claudeProjectsRoot: path.join(tmp, "no-claude"),
1256
- codexSessionsRoot: path.join(tmp, "sessions"),
1257
- cursorPath: path.join(tmp, "cursor.json"),
1258
- });
1259
-
1260
- const events = captured.flatMap((b) => b.events);
1261
- // Two reads, one turn, one skill → exactly one event, attributed to turn-1.
1262
- expect(result.eventsSent).toBe(1);
1263
- expect(events[0]).toMatchObject({
1264
- skill: "indigo:hello-world",
1265
- source: "model",
1266
- cwd: hqRoot,
1267
- uuid: "codex:skill:sess-fn:turn-1:indigo:hello-world",
1268
- });
1269
-
1270
- await fs.rm(tmp, { recursive: true, force: true });
1271
- });
1272
-
1273
- it("HQ-DESKTOP-47: preserves Codex turn dedup across a bounded resume", async () => {
1274
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-codex-budget-"));
1275
- const codex = path.join(tmp, "sessions", "2026", "08", "02");
1276
- await fs.mkdir(codex, { recursive: true });
1277
- const hqRoot = "/home/ec2-user/hq";
1278
- const lines = [
1279
- row({ timestamp: "2026-08-02T15:00:00.000Z", type: "session_meta", payload: { id: "sess-budget", cwd: hqRoot } }),
1280
- row({ timestamp: "2026-08-02T15:00:01.000Z", type: "turn_context", payload: { turn_id: "turn-budget", cwd: hqRoot } }),
1281
- row({ timestamp: "2026-08-02T15:00:02.000Z", type: "response_item", payload: { type: "function_call", name: "exec_command", arguments: JSON.stringify({ cmd: "sed -n '1,80p' .claude/skills/deploy/SKILL.md", workdir: hqRoot }) } }),
1282
- row({ timestamp: "2026-08-02T15:00:03.000Z", type: "response_item", payload: { type: "function_call", name: "exec_command", arguments: JSON.stringify({ cmd: "wc -l .claude/skills/deploy/SKILL.md", workdir: hqRoot }) } }),
1283
- ];
1284
- const file = path.join(codex, "rollout-budget.jsonl");
1285
- await fs.writeFile(file, `${lines.join("\n")}\n`, "utf-8");
1286
-
1287
- const captured: SkillInvocationBatch[] = [];
1288
- const cursorPath = path.join(tmp, "cursor.json");
1289
- const base = {
1290
- client: stubClient(captured),
1291
- machineId: "m",
1292
- installerVersion: "t",
1293
- hqRoot,
1294
- claudeProjectsRoot: path.join(tmp, "no-claude"),
1295
- codexSessionsRoot: path.join(tmp, "sessions"),
1296
- cursorPath,
1297
- };
1298
- // Commit the turn_context but stop in the next function_call row.
1299
- await collectAndSendSkillTelemetry({
1300
- ...base,
1301
- maxScanBytesPerSource: Buffer.byteLength(`${lines[0]}\n${lines[1]}\n`, "utf-8") + 1,
1302
- });
1303
- await collectAndSendSkillTelemetry({ ...base, maxScanBytesPerSource: 1024 * 1024 });
1304
- await collectAndSendSkillTelemetry({ ...base, maxScanBytesPerSource: 1024 * 1024 });
1305
-
1306
- const events = captured.flatMap((batch) => batch.events);
1307
- expect(events).toHaveLength(1);
1308
- expect(events[0]).toMatchObject({
1309
- skill: "deploy",
1310
- uuid: "codex:skill:sess-budget:turn-budget:deploy",
1311
- });
1312
-
1313
- await fs.rm(tmp, { recursive: true, force: true });
1314
- });
1315
-
1316
- it("does not count SKILL.md edits (apply_patch / write) as skill usage", async () => {
1317
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-edit-"));
1318
- const codex = path.join(tmp, "sessions", "2026", "06", "08");
1319
- await fs.mkdir(codex, { recursive: true });
1320
- const hqRoot = "/home/ec2-user/hq";
1321
-
1322
- await fs.writeFile(
1323
- path.join(codex, "rollout-edit.jsonl"),
1324
- codexModelRollout("sess-edit", hqRoot, [
1325
- ["learn", "turn-A", { parsed_cmd: [{ type: "write" }] }], // an edit — excluded
1326
- ]),
1327
- "utf-8",
1328
- );
1329
-
1330
- const captured: SkillInvocationBatch[] = [];
1331
- const result = await collectAndSendSkillTelemetry({
1332
- client: stubClient(captured),
1333
- machineId: "m",
1334
- installerVersion: "t",
1335
- hqRoot,
1336
- claudeProjectsRoot: path.join(tmp, "no-claude"),
1337
- codexSessionsRoot: path.join(tmp, "sessions"),
1338
- cursorPath: path.join(tmp, "cursor.json"),
1339
- });
1340
-
1341
- expect(result.eventsSent).toBe(0);
1342
- await fs.rm(tmp, { recursive: true, force: true });
1343
- });
1344
-
1345
- it("scope now counts skills run from HQ subfolders (worktrees), still excludes siblings", async () => {
1346
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-scope-"));
1347
- const projects = path.join(tmp, "projects");
1348
- const codex = path.join(tmp, "sessions", "2026", "06", "08");
1349
- await fs.mkdir(projects, { recursive: true });
1350
- await fs.mkdir(codex, { recursive: true });
1351
- const hqRoot = "/home/ec2-user/hq";
1352
- const worktree = `${hqRoot}/.claude/worktrees/wt1`;
1353
- const sibling = `${hqRoot}-other`; // shares the prefix but is a different repo
1354
-
1355
- // Claude skill run from a worktree under the HQ root — newly counted.
1356
- const dirWt = path.join(projects, "wt");
1357
- await fs.mkdir(dirWt, { recursive: true });
1358
- await fs.writeFile(
1359
- path.join(dirWt, "wt.jsonl"),
1360
- row({ type: "assistant", sessionId: "s-wt", timestamp: "2026-06-08T10:00:00Z", cwd: worktree, message: { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Skill", input: { skill: "handoff" } }] } }) + "\n",
1361
- "utf-8",
1362
- );
1363
- // Claude skill run from a sibling repo sharing the prefix — must stay excluded.
1364
- const dirSib = path.join(projects, "sib");
1365
- await fs.mkdir(dirSib, { recursive: true });
1366
- await fs.writeFile(
1367
- path.join(dirSib, "sib.jsonl"),
1368
- row({ type: "assistant", sessionId: "s-sib", timestamp: "2026-06-08T10:01:00Z", cwd: sibling, message: { role: "assistant", content: [{ type: "tool_use", id: "t2", name: "Skill", input: { skill: "sibling-skill" } }] } }) + "\n",
1369
- "utf-8",
1370
- );
1371
- // Codex model load from a worktree under the HQ root — newly counted.
1372
- await fs.writeFile(
1373
- path.join(codex, "rollout-wt.jsonl"),
1374
- codexModelRollout("sess-wt", worktree, [["quiz", "turn-A"]]),
1375
- "utf-8",
1376
- );
1377
-
1378
- const captured: SkillInvocationBatch[] = [];
1379
- const result = await collectAndSendSkillTelemetry({
1380
- client: stubClient(captured),
1381
- machineId: "m",
1382
- installerVersion: "t",
1383
- hqRoot,
1384
- claudeProjectsRoot: projects,
1385
- codexSessionsRoot: path.join(tmp, "sessions"),
1386
- cursorPath: path.join(tmp, "cursor.json"),
1387
- });
1388
-
1389
- const skills = captured.flatMap((b) => b.events.map((e) => e.skill)).sort();
1390
- expect(result.eventsSent).toBe(2);
1391
- expect(skills).toEqual(["handoff", "quiz"]);
1392
- expect(skills).not.toContain("sibling-skill");
1393
-
1394
- await fs.rm(tmp, { recursive: true, force: true });
1395
- });
1396
- });
1397
-
1398
- // ── companyUid edge attribution (US-002) ────────────────────────────────────────
1399
-
1400
- describe("collectAndSendSkillTelemetry — companyUid attribution", () => {
1401
- const COMPANY_UID = "cmp_01INDIGOSKILL";
1402
-
1403
- function stubClient(captured: SkillInvocationBatch[]) {
1404
- return {
1405
- async getTelemetryOptIn() {
1406
- return { enabled: true, updatedAt: null };
1407
- },
1408
- async postSkillInvocations(batch: SkillInvocationBatch) {
1409
- captured.push(batch);
1410
- return { ok: true, written: batch.events.length, skipped: [] };
1411
- },
1412
- };
1413
- }
1414
-
1415
- function row(obj: Record<string, unknown>): string {
1416
- return JSON.stringify(obj);
1417
- }
1418
-
1419
- /** Write a manifest mapping `repos/private/hq-cloud` → COMPANY_UID under hqRoot. */
1420
- async function writeManifest(hqRoot: string): Promise<void> {
1421
- await fs.mkdir(path.join(hqRoot, "companies"), { recursive: true });
1422
- await fs.writeFile(
1423
- path.join(hqRoot, "companies", "manifest.yaml"),
1424
- `companies:\n indigo:\n repos:\n - repos/private/hq-cloud\n cloud_uid: ${COMPANY_UID}\n`,
1425
- "utf-8",
1426
- );
1427
- }
1428
-
1429
- it("stamps companyUid when cwd is inside a company repo, omits it otherwise", async () => {
1430
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-co-"));
1431
- const hqRoot = tmp; // real dir so the manifest's repo path resolves under it
1432
- await writeManifest(hqRoot);
1433
- const projects = path.join(tmp, "projects");
1434
- const dir = path.join(projects, "-p");
1435
- await fs.mkdir(dir, { recursive: true });
1436
-
1437
- const inRepo = path.join(hqRoot, "repos/private/hq-cloud");
1438
- const atRoot = hqRoot; // inside scope, but not inside any mapped repo
1439
- await fs.writeFile(
1440
- path.join(dir, "s.jsonl"),
1441
- [
1442
- // Core skill, cwd inside the company repo → attributed by cwd.
1443
- row({ type: "user", sessionId: "s1", timestamp: "2026-06-10T10:00:00Z", cwd: inRepo, uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }),
1444
- // Company skill `indigo:hello-world` run at hqRoot (maps to no repo) →
1445
- // attributed by SKILL NAMESPACE to the indigo company, regardless of cwd.
1446
- row({ type: "assistant", sessionId: "s1", timestamp: "2026-06-10T10:01:00Z", cwd: atRoot, message: { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Skill", input: { skill: "indigo:hello-world" } }] } }),
1447
- // Core skill at hqRoot (no company repo, no namespace) → unattributed.
1448
- row({ type: "user", sessionId: "s1", timestamp: "2026-06-10T10:02:00Z", cwd: atRoot, uuid: "u3", message: { role: "user", content: "<command-name>/handoff</command-name>" } }),
1449
- ].join("\n") + "\n",
1450
- "utf-8",
1451
- );
1452
-
1453
- const captured: SkillInvocationBatch[] = [];
1454
- const result = await collectAndSendSkillTelemetry({
1455
- client: stubClient(captured),
1456
- machineId: "m",
1457
- installerVersion: "t",
1458
- hqRoot,
1459
- claudeProjectsRoot: projects,
1460
- codexSessionsRoot: path.join(tmp, "codex"),
1461
- cursorPath: path.join(tmp, "cursor.json"),
1462
- });
1463
-
1464
- expect(result.eventsSent).toBe(3);
1465
- const events = captured.flatMap((b) => b.events);
1466
- const bySkill = new Map(events.map((e) => [e.skill, e]));
1467
- // Core skill attributed by its cwd (inside the company repo).
1468
- expect(bySkill.get("deploy")!.companyUid).toBe(COMPANY_UID);
1469
- // Company skill attributed by its namespace, even though cwd maps to no repo.
1470
- expect(bySkill.get("indigo:hello-world")!.companyUid).toBe(COMPANY_UID);
1471
- // Core skill at hqRoot with no owning repo → stays unattributed.
1472
- expect("companyUid" in bySkill.get("handoff")!).toBe(false);
1473
- // Never the reserved sentinel.
1474
- for (const e of events) expect(e.companyUid).not.toBe("unattributed");
1475
-
1476
- await fs.rm(tmp, { recursive: true, force: true });
1477
- });
1478
-
1479
- it("parses the manifest once per run (cache), not per event", async () => {
1480
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-cache-"));
1481
- const hqRoot = tmp;
1482
- await writeManifest(hqRoot);
1483
- const projects = path.join(tmp, "projects");
1484
- const dir = path.join(projects, "-p");
1485
- await fs.mkdir(dir, { recursive: true });
1486
- const inRepo = path.join(hqRoot, "repos/private/hq-cloud");
1487
- await fs.writeFile(
1488
- path.join(dir, "s.jsonl"),
1489
- Array.from({ length: 4 }, (_, i) =>
1490
- row({ type: "user", sessionId: "s1", timestamp: `2026-06-10T10:0${i}:00Z`, cwd: inRepo, uuid: `u${i}`, message: { role: "user", content: "<command-name>/deploy</command-name>" } }),
1491
- ).join("\n") + "\n",
1492
- "utf-8",
1493
- );
1494
-
1495
- const manifestPath = path.join(hqRoot, "companies", "manifest.yaml");
1496
- const realReadFile = fs.readFile;
1497
- let manifestReads = 0;
1498
- const spy = vi
1499
- .spyOn(fs, "readFile")
1500
- .mockImplementation((async (p: Parameters<typeof realReadFile>[0], ...rest: unknown[]) => {
1501
- if (typeof p === "string" && p === manifestPath) manifestReads++;
1502
- // @ts-expect-error pass-through to the real implementation
1503
- return realReadFile(p, ...rest);
1504
- }) as typeof realReadFile);
1505
-
1506
- const captured: SkillInvocationBatch[] = [];
1507
- try {
1508
- await collectAndSendSkillTelemetry({
1509
- client: stubClient(captured),
1510
- machineId: "m",
1511
- installerVersion: "t",
1512
- hqRoot,
1513
- claudeProjectsRoot: projects,
1514
- codexSessionsRoot: path.join(tmp, "codex"),
1515
- cursorPath: path.join(tmp, "cursor.json"),
1516
- });
1517
- } finally {
1518
- spy.mockRestore();
1519
- }
1520
-
1521
- expect(manifestReads).toBe(1);
1522
- const events = captured.flatMap((b) => b.events);
1523
- expect(events).toHaveLength(4);
1524
- for (const e of events) expect(e.companyUid).toBe(COMPANY_UID);
1525
-
1526
- await fs.rm(tmp, { recursive: true, force: true });
1527
- });
1528
- });
1529
-
1530
- describe("readFileRegion — HQ-SYNC-WEB-15 (SIGABRT on >2GiB single read)", () => {
1531
- const INT32_MAX = 2 ** 31 - 1;
1532
-
1533
- it("keeps the skill scanner's decode ceiling below V8's fatal 2 GiB boundary", () => {
1534
- expect(MAX_DECODE_BYTES).toBeLessThan(2 ** 31);
1535
- });
1536
-
1537
- it("never issues a single read whose length exceeds Int32, even for a multi-GiB region", async () => {
1538
- // The original code did `fh.read(buf, 0, currentSize - offset, offset)` in
1539
- // one shot. Once a session log's unread tail passed ~2 GiB, the length
1540
- // argument was no longer an Int32, so Node's native fs.read asserted
1541
- // (`args[3]->IsInt32()`) and SIGABRT'd the whole runner. Simulate a 3 GiB
1542
- // region with a recording fake handle (no real allocation) and prove every
1543
- // native read length stays within Int32.
1544
- const THREE_GIB = 3 * 1024 * 1024 * 1024;
1545
- const lengths: number[] = [];
1546
- let cursor = 0;
1547
- const fakeFh = {
1548
- read: vi.fn(
1549
- async (_buf: Buffer, _off: number, length: number, position: number) => {
1550
- lengths.push(length);
1551
- expect(position).toBe(cursor); // contiguous, in order
1552
- cursor += length;
1553
- return { bytesRead: length };
1554
- },
1555
- ),
1556
- };
1557
-
1558
- const total = await readFileRegion(
1559
- fakeFh as unknown as Parameters<typeof readFileRegion>[0],
1560
- Buffer.alloc(0), // recording fake never touches the buffer
1561
- 0,
1562
- THREE_GIB,
1563
- );
1564
-
1565
- expect(total).toBe(THREE_GIB);
1566
- expect(lengths.length).toBeGreaterThan(1); // it actually chunked
1567
- for (const len of lengths) {
1568
- expect(len).toBeGreaterThan(0);
1569
- expect(len).toBeLessThanOrEqual(INT32_MAX);
1570
- }
1571
- expect(lengths.reduce((a, b) => a + b, 0)).toBe(THREE_GIB);
1572
- });
1573
-
1574
- it("reads an entire real region correctly across multiple chunks", async () => {
1575
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "readregion-"));
1576
- const filePath = path.join(tmp, "data.bin");
1577
- const payload = Buffer.from("abcdefghijklmnopqrstuvwxyz0123456789", "utf-8");
1578
- await fs.writeFile(filePath, payload);
1579
-
1580
- const fh = await fs.open(filePath, "r");
1581
- try {
1582
- const buf = Buffer.alloc(payload.length);
1583
- // Tiny chunk size forces the loop to iterate many times.
1584
- const read = await readFileRegion(fh, buf, 0, payload.length, 7);
1585
- expect(read).toBe(payload.length);
1586
- expect(buf.equals(payload)).toBe(true);
1587
- } finally {
1588
- await fh.close();
1589
- }
1590
-
1591
- // Reading a sub-region from an offset also lands correctly.
1592
- const fh2 = await fs.open(filePath, "r");
1593
- try {
1594
- const buf = Buffer.alloc(10);
1595
- const read = await readFileRegion(fh2, buf, 5, 10, 4);
1596
- expect(read).toBe(10);
1597
- expect(buf.toString("utf-8")).toBe(payload.toString("utf-8", 5, 15));
1598
- } finally {
1599
- await fh2.close();
1600
- }
1601
-
1602
- await fs.rm(tmp, { recursive: true, force: true });
1603
- });
1604
-
1605
- it("stops cleanly on a short/zero read (file truncated since stat)", async () => {
1606
- // Ask for 100 bytes but the file only holds 30: the handle yields bytes up
1607
- // to what's left, then signals EOF with a 0-byte read.
1608
- let remaining = 30;
1609
- const fakeFh = {
1610
- read: vi.fn(async (_b: Buffer, _o: number, length: number) => {
1611
- const n = Math.min(length, remaining);
1612
- remaining -= n;
1613
- return { bytesRead: n };
1614
- }),
1615
- };
1616
- const got = await readFileRegion(
1617
- fakeFh as unknown as Parameters<typeof readFileRegion>[0],
1618
- Buffer.alloc(0),
1619
- 0,
1620
- 100,
1621
- 8,
1622
- );
1623
- expect(got).toBe(30);
1624
- });
1625
- });
1626
-
1627
- describe("computeSkillVersion (US-015)", () => {
1628
- function sha256(bytes: string): string {
1629
- return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
1630
- }
1631
-
1632
- it("hashes .claude/skills/<skill>/SKILL.md to a sha256:<hex> marker", async () => {
1633
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-"));
1634
- const body = "---\nname: deploy\n---\nDeploy things.\n";
1635
- const dir = path.join(tmp, ".claude", "skills", "deploy");
1636
- await fs.mkdir(dir, { recursive: true });
1637
- await fs.writeFile(path.join(dir, "SKILL.md"), body, "utf-8");
1638
-
1639
- expect(await computeSkillVersion(tmp, "deploy")).toBe(sha256(body));
1640
- await fs.rm(tmp, { recursive: true, force: true });
1641
- });
1642
-
1643
- it("resolves a namespaced skill by its full invocation name (colon in dir name)", async () => {
1644
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-"));
1645
- const body = "hello world skill\n";
1646
- const dir = path.join(tmp, ".claude", "skills", "indigo:hello-world");
1647
- await fs.mkdir(dir, { recursive: true });
1648
- await fs.writeFile(path.join(dir, "SKILL.md"), body, "utf-8");
1649
-
1650
- expect(await computeSkillVersion(tmp, "indigo:hello-world")).toBe(sha256(body));
1651
- await fs.rm(tmp, { recursive: true, force: true });
1652
- });
1653
-
1654
- it("resolves an ALREADY-ENCODED local segment without double-encoding (win32 Codex path capture)", async () => {
1655
- // Codex telemetry extraction captures the on-disk wrapper name from a
1656
- // win32 path (`indigo%3Ahello-world`); encoding that again would look
1657
- // under `indigo%253A...` and miss. Normalization decodes to canonical
1658
- // first, then re-encodes for THIS host.
1659
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-"));
1660
- const body = "encoded-name skill";
1661
- const hostDirName = encodeLocalVaultSegment("indigo:hello-world");
1662
- const dir = path.join(tmp, ".claude", "skills", hostDirName);
1663
- await fs.mkdir(dir, { recursive: true });
1664
- await fs.writeFile(path.join(dir, "SKILL.md"), body, "utf-8");
1665
-
1666
- expect(await computeSkillVersion(tmp, "indigo%3Ahello-world")).toBe(sha256(body));
1667
- // A decoded separator can never smuggle past the traversal guard.
1668
- expect(await computeSkillVersion(tmp, "a%2Fb")).toBeUndefined();
1669
- await fs.rm(tmp, { recursive: true, force: true });
1670
- });
1671
-
1672
- it("falls back to .agents/skills/ (Codex bridge layout)", async () => {
1673
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-"));
1674
- const body = "codex-bridged skill\n";
1675
- const dir = path.join(tmp, ".agents", "skills", "learn");
1676
- await fs.mkdir(dir, { recursive: true });
1677
- await fs.writeFile(path.join(dir, "SKILL.md"), body, "utf-8");
1678
-
1679
- expect(await computeSkillVersion(tmp, "learn")).toBe(sha256(body));
1680
- await fs.rm(tmp, { recursive: true, force: true });
1681
- });
1682
-
1683
- it("returns undefined for a missing hqRoot, a missing file, or a traversal attempt", async () => {
1684
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-"));
1685
- expect(await computeSkillVersion(undefined, "deploy")).toBeUndefined();
1686
- expect(await computeSkillVersion(tmp, "does-not-exist")).toBeUndefined();
1687
- // Path-traversal / separator-bearing names never map to a SKILL.md here.
1688
- expect(await computeSkillVersion(tmp, "../../etc/passwd")).toBeUndefined();
1689
- expect(await computeSkillVersion(tmp, "a/b")).toBeUndefined();
1690
- await fs.rm(tmp, { recursive: true, force: true });
1691
- });
1692
- });
1693
-
1694
- describe("collectAndSendSkillTelemetry — skillVersion emission (US-015)", () => {
1695
- function stubClient(captured: SkillInvocationBatch[]) {
1696
- return {
1697
- async getTelemetryOptIn() {
1698
- return { enabled: true, updatedAt: null };
1699
- },
1700
- async postSkillInvocations(batch: SkillInvocationBatch) {
1701
- captured.push(batch);
1702
- return { ok: true, written: batch.events.length, skipped: [] };
1703
- },
1704
- };
1705
- }
1706
- function row(obj: Record<string, unknown>): string {
1707
- return JSON.stringify(obj);
1708
- }
1709
-
1710
- it("stamps the SKILL.md content hash onto the shipped event", async () => {
1711
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-emit-"));
1712
- // hqRoot IS the session cwd, so the invocation passes the scope filter and
1713
- // the resolver can find the skill under hqRoot/.claude/skills.
1714
- const hqRoot = path.join(tmp, "hq");
1715
- const skillBody = "---\nname: deploy\n---\nDeploy.\n";
1716
- const skillDir = path.join(hqRoot, ".claude", "skills", "deploy");
1717
- await fs.mkdir(skillDir, { recursive: true });
1718
- await fs.writeFile(path.join(skillDir, "SKILL.md"), skillBody, "utf-8");
1719
-
1720
- const projects = path.join(tmp, "projects");
1721
- const dir = path.join(projects, "-hq");
1722
- await fs.mkdir(dir, { recursive: true });
1723
- await fs.writeFile(
1724
- path.join(dir, "s.jsonl"),
1725
- row({
1726
- type: "user",
1727
- sessionId: "s1",
1728
- timestamp: "2026-07-09T09:00:00Z",
1729
- cwd: hqRoot,
1730
- uuid: "u1",
1731
- message: { role: "user", content: "<command-name>/deploy</command-name>" },
1732
- }) + "\n",
1733
- "utf-8",
1734
- );
1735
-
1736
- const captured: SkillInvocationBatch[] = [];
1737
- const result = await collectAndSendSkillTelemetry({
1738
- client: stubClient(captured),
1739
- machineId: "m",
1740
- installerVersion: "t",
1741
- hqRoot,
1742
- claudeProjectsRoot: projects,
1743
- codexSessionsRoot: path.join(tmp, "codex"),
1744
- cursorPath: path.join(tmp, "cursor.json"),
1745
- });
1746
-
1747
- expect(result.eventsSent).toBe(1);
1748
- const ev = captured[0].events[0];
1749
- expect(ev.skill).toBe("deploy");
1750
- expect(ev.skillVersion).toBe(
1751
- `sha256:${createHash("sha256").update(skillBody).digest("hex")}`,
1752
- );
1753
- await fs.rm(tmp, { recursive: true, force: true });
1754
- });
1755
-
1756
- it("emits the injected resolver's marker (wiring is decoupled from disk)", async () => {
1757
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-inj-"));
1758
- const projects = path.join(tmp, "projects");
1759
- const dir = path.join(projects, "-p");
1760
- await fs.mkdir(dir, { recursive: true });
1761
- await fs.writeFile(
1762
- path.join(dir, "s.jsonl"),
1763
- row({
1764
- type: "assistant",
1765
- sessionId: "s2",
1766
- timestamp: "2026-07-09T10:00:00Z",
1767
- cwd: "/x",
1768
- message: {
1769
- role: "assistant",
1770
- content: [{ type: "tool_use", id: "t1", name: "Skill", input: { skill: "code-review" } }],
1771
- },
1772
- }) + "\n",
1773
- "utf-8",
1774
- );
1775
-
1776
- const captured: SkillInvocationBatch[] = [];
1777
- const result = await collectAndSendSkillTelemetry({
1778
- client: stubClient(captured),
1779
- machineId: "m",
1780
- installerVersion: "t",
1781
- claudeProjectsRoot: projects,
1782
- codexSessionsRoot: path.join(tmp, "codex"),
1783
- cursorPath: path.join(tmp, "cursor.json"),
1784
- resolveSkillVersion: (skill) => `sha256:stub-for-${skill}`,
1785
- });
1786
-
1787
- expect(result.eventsSent).toBe(1);
1788
- expect(captured[0].events[0].skillVersion).toBe("sha256:stub-for-code-review");
1789
- await fs.rm(tmp, { recursive: true, force: true });
1790
- });
1791
-
1792
- it("omits skillVersion when the SKILL.md can't be resolved (additive/back-compat)", async () => {
1793
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-none-"));
1794
- const projects = path.join(tmp, "projects");
1795
- const dir = path.join(projects, "-p");
1796
- await fs.mkdir(dir, { recursive: true });
1797
- await fs.writeFile(
1798
- path.join(dir, "s.jsonl"),
1799
- row({
1800
- type: "user",
1801
- sessionId: "s3",
1802
- timestamp: "2026-07-09T11:00:00Z",
1803
- cwd: "/x",
1804
- uuid: "u3",
1805
- message: { role: "user", content: "<command-name>/unknown-skill</command-name>" },
1806
- }) + "\n",
1807
- "utf-8",
1808
- );
1809
-
1810
- // No hqRoot → the default resolver can't locate any SKILL.md → field omitted.
1811
- const captured: SkillInvocationBatch[] = [];
1812
- const result = await collectAndSendSkillTelemetry({
1813
- client: stubClient(captured),
1814
- machineId: "m",
1815
- installerVersion: "t",
1816
- claudeProjectsRoot: projects,
1817
- codexSessionsRoot: path.join(tmp, "codex"),
1818
- cursorPath: path.join(tmp, "cursor.json"),
1819
- });
1820
-
1821
- expect(result.eventsSent).toBe(1);
1822
- expect("skillVersion" in captured[0].events[0]).toBe(false);
1823
- await fs.rm(tmp, { recursive: true, force: true });
1824
- });
1825
- });