@indigoai-us/hq-cloud 6.14.50 → 6.15.1

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 (340) hide show
  1. package/dist/bin/sync-mutation.d.ts +14 -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 +64 -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/reindex-knowledge.d.ts +7 -0
  34. package/dist/cli/reindex-knowledge.d.ts.map +1 -0
  35. package/dist/cli/reindex-knowledge.js +389 -0
  36. package/dist/cli/reindex-knowledge.js.map +1 -0
  37. package/dist/cli/reindex-knowledge.test.d.ts +12 -0
  38. package/dist/cli/reindex-knowledge.test.d.ts.map +1 -0
  39. package/dist/cli/reindex-knowledge.test.js +260 -0
  40. package/dist/cli/reindex-knowledge.test.js.map +1 -0
  41. package/dist/cli/reindex.d.ts.map +1 -1
  42. package/dist/cli/reindex.js +8 -0
  43. package/dist/cli/reindex.js.map +1 -1
  44. package/dist/cli/rescue-settings-reconcile.test.js +47 -0
  45. package/dist/cli/rescue-settings-reconcile.test.js.map +1 -1
  46. package/dist/cli/sync.d.ts +27 -0
  47. package/dist/cli/sync.d.ts.map +1 -1
  48. package/dist/cli/sync.js +52 -0
  49. package/dist/cli/sync.js.map +1 -1
  50. package/dist/cli/sync.test.js +31 -1
  51. package/dist/cli/sync.test.js.map +1 -1
  52. package/dist/index.d.ts +2 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +1 -0
  55. package/dist/index.js.map +1 -1
  56. package/dist/skill-telemetry.d.ts +6 -0
  57. package/dist/skill-telemetry.d.ts.map +1 -1
  58. package/dist/skill-telemetry.js +14 -2
  59. package/dist/skill-telemetry.js.map +1 -1
  60. package/dist/skill-telemetry.test.js +79 -0
  61. package/dist/skill-telemetry.test.js.map +1 -1
  62. package/dist/sync/candidate-uploader.d.ts +88 -0
  63. package/dist/sync/candidate-uploader.d.ts.map +1 -0
  64. package/dist/sync/candidate-uploader.js +212 -0
  65. package/dist/sync/candidate-uploader.js.map +1 -0
  66. package/dist/sync/candidate-uploader.test.d.ts +2 -0
  67. package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
  68. package/dist/sync/candidate-uploader.test.js +132 -0
  69. package/dist/sync/candidate-uploader.test.js.map +1 -0
  70. package/dist/sync/delta-client.d.ts +73 -0
  71. package/dist/sync/delta-client.d.ts.map +1 -0
  72. package/dist/sync/delta-client.js +201 -0
  73. package/dist/sync/delta-client.js.map +1 -0
  74. package/dist/sync/delta-client.test.d.ts +2 -0
  75. package/dist/sync/delta-client.test.d.ts.map +1 -0
  76. package/dist/sync/delta-client.test.js +97 -0
  77. package/dist/sync/delta-client.test.js.map +1 -0
  78. package/dist/sync/durable-apply.d.ts +76 -0
  79. package/dist/sync/durable-apply.d.ts.map +1 -0
  80. package/dist/sync/durable-apply.js +530 -0
  81. package/dist/sync/durable-apply.js.map +1 -0
  82. package/dist/sync/durable-apply.test.d.ts +2 -0
  83. package/dist/sync/durable-apply.test.d.ts.map +1 -0
  84. package/dist/sync/durable-apply.test.js +180 -0
  85. package/dist/sync/durable-apply.test.js.map +1 -0
  86. package/dist/sync/event-sync.d.ts +33 -1
  87. package/dist/sync/event-sync.d.ts.map +1 -1
  88. package/dist/sync/event-sync.js +149 -1
  89. package/dist/sync/event-sync.js.map +1 -1
  90. package/dist/sync/event-sync.test.js +142 -1
  91. package/dist/sync/event-sync.test.js.map +1 -1
  92. package/dist/sync/index.d.ts +2 -0
  93. package/dist/sync/index.d.ts.map +1 -1
  94. package/dist/sync/index.js +1 -0
  95. package/dist/sync/index.js.map +1 -1
  96. package/dist/sync/multipart-uploader.d.ts +99 -0
  97. package/dist/sync/multipart-uploader.d.ts.map +1 -0
  98. package/dist/sync/multipart-uploader.js +447 -0
  99. package/dist/sync/multipart-uploader.js.map +1 -0
  100. package/dist/sync/multipart-uploader.test.d.ts +2 -0
  101. package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
  102. package/dist/sync/multipart-uploader.test.js +119 -0
  103. package/dist/sync/multipart-uploader.test.js.map +1 -0
  104. package/dist/sync/mutation-client.d.ts +82 -0
  105. package/dist/sync/mutation-client.d.ts.map +1 -0
  106. package/dist/sync/mutation-client.js +221 -0
  107. package/dist/sync/mutation-client.js.map +1 -0
  108. package/dist/sync/mutation-client.test.d.ts +2 -0
  109. package/dist/sync/mutation-client.test.d.ts.map +1 -0
  110. package/dist/sync/mutation-client.test.js +51 -0
  111. package/dist/sync/mutation-client.test.js.map +1 -0
  112. package/dist/sync/push-receiver.d.ts +45 -0
  113. package/dist/sync/push-receiver.d.ts.map +1 -1
  114. package/dist/sync/push-receiver.js +101 -0
  115. package/dist/sync/push-receiver.js.map +1 -1
  116. package/dist/sync/push-receiver.test.js +54 -2
  117. package/dist/sync/push-receiver.test.js.map +1 -1
  118. package/dist/sync/scope-inventory-client.d.ts +69 -0
  119. package/dist/sync/scope-inventory-client.d.ts.map +1 -0
  120. package/dist/sync/scope-inventory-client.js +210 -0
  121. package/dist/sync/scope-inventory-client.js.map +1 -0
  122. package/dist/sync/scope-inventory-client.test.d.ts +2 -0
  123. package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
  124. package/dist/sync/scope-inventory-client.test.js +94 -0
  125. package/dist/sync/scope-inventory-client.test.js.map +1 -0
  126. package/dist/sync/snapshot-client.d.ts +98 -0
  127. package/dist/sync/snapshot-client.d.ts.map +1 -0
  128. package/dist/sync/snapshot-client.js +402 -0
  129. package/dist/sync/snapshot-client.js.map +1 -0
  130. package/dist/sync/snapshot-client.test.d.ts +2 -0
  131. package/dist/sync/snapshot-client.test.d.ts.map +1 -0
  132. package/dist/sync/snapshot-client.test.js +169 -0
  133. package/dist/sync/snapshot-client.test.js.map +1 -0
  134. package/dist/sync/uploader-finalization.d.ts +97 -0
  135. package/dist/sync/uploader-finalization.d.ts.map +1 -0
  136. package/dist/sync/uploader-finalization.js +273 -0
  137. package/dist/sync/uploader-finalization.js.map +1 -0
  138. package/dist/sync/uploader-finalization.test.d.ts +2 -0
  139. package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
  140. package/dist/sync/uploader-finalization.test.js +92 -0
  141. package/dist/sync/uploader-finalization.test.js.map +1 -0
  142. package/dist/telemetry.d.ts +11 -1
  143. package/dist/telemetry.d.ts.map +1 -1
  144. package/dist/telemetry.js +21 -2
  145. package/dist/telemetry.js.map +1 -1
  146. package/dist/telemetry.test.js +80 -0
  147. package/dist/telemetry.test.js.map +1 -1
  148. package/package.json +6 -1
  149. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
  150. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
  151. package/.github/workflows/ci.yml +0 -84
  152. package/.github/workflows/publish.yml +0 -56
  153. package/.github/workflows/unreleased-commits-nag.yml +0 -256
  154. package/eslint.config.js +0 -67
  155. package/pnpm-workspace.yaml +0 -2
  156. package/scripts/presign-transport-e2e.mjs +0 -250
  157. package/scripts/vault-rebaseline.sh +0 -323
  158. package/scripts/vault-rescue.sh +0 -332
  159. package/src/active-company.test.ts +0 -188
  160. package/src/active-company.ts +0 -168
  161. package/src/agent-codex-instructions.test.ts +0 -332
  162. package/src/agent-codex-instructions.ts +0 -309
  163. package/src/auth.ts +0 -146
  164. package/src/backup-prune.test.ts +0 -98
  165. package/src/backup-prune.ts +0 -182
  166. package/src/bin/backup-prune-runner.ts +0 -33
  167. package/src/bin/rescue-runner.ts +0 -25
  168. package/src/bin/sync-runner-company.ts +0 -695
  169. package/src/bin/sync-runner-events.test.ts +0 -143
  170. package/src/bin/sync-runner-events.ts +0 -55
  171. package/src/bin/sync-runner-planning.test.ts +0 -311
  172. package/src/bin/sync-runner-planning.ts +0 -258
  173. package/src/bin/sync-runner-rollup.test.ts +0 -37
  174. package/src/bin/sync-runner-rollup.ts +0 -97
  175. package/src/bin/sync-runner-telemetry.ts +0 -15
  176. package/src/bin/sync-runner-watch-loop.ts +0 -1235
  177. package/src/bin/sync-runner-watch-routes.test.ts +0 -71
  178. package/src/bin/sync-runner-watch-routes.ts +0 -184
  179. package/src/bin/sync-runner.test.ts +0 -8767
  180. package/src/bin/sync-runner.ts +0 -2190
  181. package/src/cli/accept.ts +0 -124
  182. package/src/cli/conflict.ts +0 -119
  183. package/src/cli/doctor.test.ts +0 -581
  184. package/src/cli/doctor.ts +0 -642
  185. package/src/cli/index.ts +0 -49
  186. package/src/cli/invite.test.ts +0 -250
  187. package/src/cli/invite.ts +0 -214
  188. package/src/cli/promote.ts +0 -157
  189. package/src/cli/reindex.test.ts +0 -957
  190. package/src/cli/reindex.ts +0 -970
  191. package/src/cli/rescue-classify-ordering.test.ts +0 -548
  192. package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
  193. package/src/cli/rescue-core.ts +0 -3011
  194. package/src/cli/rescue-drift-reconcile.test.ts +0 -179
  195. package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
  196. package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
  197. package/src/cli/rescue-hq-root-guard.test.ts +0 -232
  198. package/src/cli/rescue-journal-reconcile.test.ts +0 -215
  199. package/src/cli/rescue-mtime-preserve.test.ts +0 -203
  200. package/src/cli/rescue-settings-reconcile.test.ts +0 -583
  201. package/src/cli/rescue-snapshot.test.ts +0 -57
  202. package/src/cli/rescue-snapshot.ts +0 -51
  203. package/src/cli/rescue.reindex.test.ts +0 -63
  204. package/src/cli/rescue.test.ts +0 -131
  205. package/src/cli/rescue.ts +0 -182
  206. package/src/cli/share.test.ts +0 -7843
  207. package/src/cli/share.ts +0 -3663
  208. package/src/cli/sync-scope.test.ts +0 -652
  209. package/src/cli/sync.test.ts +0 -5207
  210. package/src/cli/sync.ts +0 -3470
  211. package/src/cli/tombstones.ts +0 -106
  212. package/src/cli/watch-event-push-conflict.test.ts +0 -234
  213. package/src/client-info.test.ts +0 -214
  214. package/src/client-info.ts +0 -121
  215. package/src/cognito-auth.test.ts +0 -712
  216. package/src/cognito-auth.ts +0 -1422
  217. package/src/company-resolver.test.ts +0 -618
  218. package/src/company-resolver.ts +0 -521
  219. package/src/context.test.ts +0 -583
  220. package/src/context.ts +0 -378
  221. package/src/daemon-worker.ts +0 -26
  222. package/src/daemon.ts +0 -99
  223. package/src/entity-resolver.test.ts +0 -315
  224. package/src/entity-resolver.ts +0 -180
  225. package/src/ignore.test.ts +0 -466
  226. package/src/ignore.ts +0 -469
  227. package/src/index.ts +0 -439
  228. package/src/journal.test.ts +0 -968
  229. package/src/journal.ts +0 -765
  230. package/src/lib/cloud-authoritative.test.ts +0 -45
  231. package/src/lib/cloud-authoritative.ts +0 -59
  232. package/src/lib/conflict-file.ts +0 -86
  233. package/src/lib/conflict-index.ts +0 -289
  234. package/src/lib/conflict.test.ts +0 -348
  235. package/src/lib/describe-error.test.ts +0 -100
  236. package/src/lib/describe-error.ts +0 -58
  237. package/src/lib/exit-codes.ts +0 -24
  238. package/src/lib/machine-id.test.ts +0 -231
  239. package/src/lib/machine-id.ts +0 -175
  240. package/src/lib/net-errors.test.ts +0 -65
  241. package/src/lib/net-errors.ts +0 -86
  242. package/src/lib/readlink-safe.test.ts +0 -43
  243. package/src/lib/readlink-safe.ts +0 -29
  244. package/src/local-path-codec.test.ts +0 -138
  245. package/src/local-path-codec.ts +0 -161
  246. package/src/machine-auth.test.ts +0 -1323
  247. package/src/manifest-reconcile.test.ts +0 -1123
  248. package/src/manifest-reconcile.ts +0 -518
  249. package/src/object-io.test.ts +0 -1221
  250. package/src/object-io.ts +0 -1306
  251. package/src/operation-lock.test.ts +0 -484
  252. package/src/operation-lock.ts +0 -680
  253. package/src/outcome-telemetry.test.ts +0 -498
  254. package/src/outcome-telemetry.ts +0 -639
  255. package/src/personal-vault-exclusions.test.ts +0 -308
  256. package/src/personal-vault-exclusions.ts +0 -354
  257. package/src/personal-vault.test.ts +0 -756
  258. package/src/personal-vault.ts +0 -496
  259. package/src/prefix-coalesce.test.ts +0 -240
  260. package/src/prefix-coalesce.ts +0 -273
  261. package/src/public-surface.test.ts +0 -117
  262. package/src/qmd-reindex.test.ts +0 -877
  263. package/src/qmd-reindex.ts +0 -842
  264. package/src/read-only-state-dir.test.ts +0 -188
  265. package/src/remote-pull.test.ts +0 -1130
  266. package/src/remote-pull.ts +0 -618
  267. package/src/s3.symlink-materialize.test.ts +0 -492
  268. package/src/s3.test.ts +0 -1789
  269. package/src/s3.ts +0 -1532
  270. package/src/schemas/signal-types.test.ts +0 -82
  271. package/src/schemas/signal-types.ts +0 -38
  272. package/src/schemas/source-channels.test.ts +0 -82
  273. package/src/schemas/source-channels.ts +0 -53
  274. package/src/scope-shrink.test.ts +0 -633
  275. package/src/scope-shrink.ts +0 -481
  276. package/src/signals/get.test.ts +0 -310
  277. package/src/signals/get.ts +0 -75
  278. package/src/signals/internals.ts +0 -195
  279. package/src/signals/list.test.ts +0 -420
  280. package/src/signals/list.ts +0 -79
  281. package/src/signals/parse.ts +0 -8
  282. package/src/signals/types.ts +0 -91
  283. package/src/skill-telemetry.test.ts +0 -1825
  284. package/src/skill-telemetry.ts +0 -1439
  285. package/src/sources/get.test.ts +0 -293
  286. package/src/sources/get.ts +0 -66
  287. package/src/sources/internals.ts +0 -198
  288. package/src/sources/list.test.ts +0 -402
  289. package/src/sources/list.ts +0 -84
  290. package/src/sources/parse.ts +0 -43
  291. package/src/sources/types.ts +0 -84
  292. package/src/sync/event-sync.test.ts +0 -594
  293. package/src/sync/event-sync.ts +0 -545
  294. package/src/sync/feature-flags.test.ts +0 -378
  295. package/src/sync/feature-flags.ts +0 -62
  296. package/src/sync/index.ts +0 -76
  297. package/src/sync/lease-client.test.ts +0 -128
  298. package/src/sync/lease-client.ts +0 -207
  299. package/src/sync/logger.test.ts +0 -242
  300. package/src/sync/logger.ts +0 -79
  301. package/src/sync/metrics.test.ts +0 -462
  302. package/src/sync/metrics.ts +0 -213
  303. package/src/sync/pull-scope.ts +0 -265
  304. package/src/sync/push-event.test.ts +0 -266
  305. package/src/sync/push-event.ts +0 -224
  306. package/src/sync/push-receiver.test.ts +0 -566
  307. package/src/sync/push-receiver.ts +0 -1048
  308. package/src/sync/push-transport.ts +0 -231
  309. package/src/sync/realtime-rollout.test.ts +0 -86
  310. package/src/sync/realtime-rollout.ts +0 -262
  311. package/src/sync/state-store.test.ts +0 -194
  312. package/src/sync/state-store.ts +0 -727
  313. package/src/sync-core.ts +0 -58
  314. package/src/sync-progress.test.ts +0 -94
  315. package/src/sync-progress.ts +0 -140
  316. package/src/telemetry-events.test.ts +0 -88
  317. package/src/telemetry-events.ts +0 -205
  318. package/src/telemetry.test.ts +0 -1280
  319. package/src/telemetry.ts +0 -1109
  320. package/src/types.ts +0 -314
  321. package/src/vault-client.test.ts +0 -1380
  322. package/src/vault-client.ts +0 -1694
  323. package/src/version.ts +0 -24
  324. package/src/watch-roots.test.ts +0 -278
  325. package/src/watch-roots.ts +0 -162
  326. package/src/watcher-event-gate.test.ts +0 -212
  327. package/src/watcher.test.ts +0 -1079
  328. package/src/watcher.ts +0 -1741
  329. package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
  330. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
  331. package/test/e2e/sync/transient-company-leg.test.ts +0 -384
  332. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
  333. package/test/e2e/watcher-real-chokidar.test.ts +0 -165
  334. package/test/e2e/watcher-recursive-backend.test.ts +0 -181
  335. package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
  336. package/test/invite-flow.integration.test.ts +0 -244
  337. package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
  338. package/test/share-sync.integration.test.ts +0 -213
  339. package/tsconfig.json +0 -19
  340. package/vitest.config.ts +0 -22
@@ -1,639 +0,0 @@
1
- /**
2
- * Outcome-event collector — story-completion + project-shipped emitter
3
- * (outcome-leaderboard US-004).
4
- *
5
- * Sibling to `./telemetry.ts` (token usage) and `./skill-telemetry.ts` (skill
6
- * invocations). Where those diff `~/.claude/projects/**\/*.jsonl` session logs,
7
- * this one diffs HQ PROJECT STATE against a persisted cursor at
8
- * `~/.hq/outcome-telemetry-cursor.json`:
9
- *
10
- * - `prd.json` `userStories[].passes` flipping `false → true`
11
- * → a `story-completed` outcome event.
12
- * - `board.json` project `status` transitioning to a DONE status
13
- * → a `project-shipped` outcome event.
14
- *
15
- * After each successful sync (the `all-complete` arm of `bin/sync-runner.ts`,
16
- * via `defaultCollectTelemetry`), it walks `<hqRoot>/companies/*` for those two
17
- * files, compares each transition-eligible value against the cursor, and POSTs
18
- * new transitions to `/v1/outcome-events`. The cursor is only advanced for the
19
- * transitions the server 2xx'd, so a transient outage retries next sync.
20
- *
21
- * Trust model (identical to `./telemetry.ts`): the caller's `personUid` is
22
- * resolved SERVER-side from the Cognito JWT — never from the body. The wire row
23
- * carries ONLY the outcome type, the ISO-8601 `occurredAt`, the resolved
24
- * `companyUid`, `repo`/`branch` context, a `dedupeKey`, and the type-specific
25
- * refs (`projectName`, and `storyId` for stories). NO prd/board file content
26
- * beyond project name + story id + the status transition ever leaves the machine
27
- * — enforced by the `toWireRow` allowlist, matching the server's KEEP_FIELDS in
28
- * `apps/hq-pro/src/vault-service/handlers/outcome-events.ts`.
29
- *
30
- * companyUid resolution mirrors `./telemetry.ts`: the manifest at
31
- * `<hqRoot>/companies/manifest.yaml` is parsed ONCE per run (`buildRepoCompanyMap`)
32
- * and a project's owning company `cmp_*` uid is looked up by the `companies/<slug>`
33
- * directory the prd/board lives under (`RepoCompanyMap.bySlug`). A project whose
34
- * company is not cloud-backed resolves to no uid and is SKIPPED — an outcome event
35
- * requires a company (the ingest handler makes companyUid a required field).
36
- *
37
- * dedupeKey (idempotency anchor — replays + multi-machine syncs never
38
- * double-count):
39
- * - story-completed → `<companyUid>#<projectName>#<storyId>`
40
- * - project-shipped → `<companyUid>#<projectName>`
41
- * The server's conditional PutItem collapses a re-synced event with the same
42
- * dedupeKey to a single stored item.
43
- *
44
- * Errors are swallowed by design — an outcome collector must never abort or
45
- * delay a sync (matches the existing collectors). The opt-in gate is the same
46
- * `getTelemetryOptIn()` used by usage/skill telemetry.
47
- */
48
-
49
- import { promises as fs } from "node:fs";
50
- import * as os from "node:os";
51
- import * as path from "node:path";
52
-
53
- import {
54
- buildRepoCompanyMap,
55
- type RepoCompanyMap,
56
- } from "./company-resolver.js";
57
- import type {
58
- OutcomeEventsBatch,
59
- OutcomeEventsIngestResult,
60
- TelemetryOptInResponse,
61
- } from "./vault-client.js";
62
-
63
- // ── Public surface ────────────────────────────────────────────────────────────
64
-
65
- /**
66
- * Minimal subset of `VaultClient` the collector needs. Declared as an interface
67
- * so tests can inject a stub without a fetch mock. The real `VaultClient` from
68
- * `./vault-client.js` satisfies this structurally.
69
- */
70
- export interface OutcomeTelemetryClientSurface {
71
- getTelemetryOptIn(): Promise<TelemetryOptInResponse>;
72
- postOutcomeEvents(batch: OutcomeEventsBatch): Promise<OutcomeEventsIngestResult>;
73
- }
74
-
75
- export interface CollectOutcomeTelemetryOptions {
76
- client: OutcomeTelemetryClientSurface;
77
- /**
78
- * HQ root — the collector scans `<hqRoot>/companies/*` for prd.json/board.json
79
- * and resolves each project's owning company via `<hqRoot>/companies/
80
- * manifest.yaml`. REQUIRED: with no hqRoot there is nothing to scan and no
81
- * company to attribute, so the collector no-ops.
82
- */
83
- hqRoot?: string;
84
- /** Override `~/.hq/outcome-telemetry-cursor.json` for tests. */
85
- cursorPath?: string;
86
- /** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */
87
- menubarPath?: string;
88
- /** Repo context stamped on every event (required common field). Defaults to `hq`. */
89
- repo?: string;
90
- /** Branch context stamped on every event (required common field). Defaults to `main`. */
91
- branch?: string;
92
- /** Injectable clock (ISO-8601) for deterministic `occurredAt` in tests. */
93
- now?: () => string;
94
- /** Diagnostic sink. No-op by default. */
95
- log?: (msg: string) => void;
96
- }
97
-
98
- export interface CollectOutcomeTelemetryResult {
99
- /** Whether the opt-in check resolved to true. When false, nothing else ran. */
100
- enabled: boolean;
101
- optInSource: "server" | "menubar-fallback" | "skipped";
102
- /** prd.json + board.json files considered. */
103
- filesScanned: number;
104
- /** Total outcome events successfully POSTed. */
105
- eventsSent: number;
106
- /** Number of `POST /v1/outcome-events` requests made. */
107
- batchesSent: number;
108
- }
109
-
110
- // ── Cursor schema ─────────────────────────────────────────────────────────────
111
-
112
- /**
113
- * The cursor records the LAST-SEEN state we have already emitted for, keyed by
114
- * dedupeKey. A transition is emitted only when the current state differs from
115
- * what the cursor holds — so a story that is already `passes:true` in the cursor
116
- * (emitted on a prior sync) never re-emits, and a story that regresses
117
- * true→false→true re-emits (a genuine new completion). The dedupeKey is the SAME
118
- * key the server dedups on, so client-cursor + server-conditional-put are two
119
- * layers of the same idempotency guarantee.
120
- *
121
- * Each entry ALSO pins a STABLE `occurredAt` for the transition. The server
122
- * stores an outcome row under `personUid` + `occurredAt#type#dedupeKey`, so a
123
- * replay that regenerates `occurredAt` (another machine, or a POST that
124
- * succeeded but whose cursor-write then failed) would land under a DIFFERENT
125
- * sort key and double-count. The `occurredAt` is therefore assigned ONCE when
126
- * the transition is first detected, persisted to the cursor BEFORE the POST, and
127
- * reused verbatim on every subsequent detection of the same logical transition —
128
- * so the same transition always emits the same event time and replays collapse.
129
- */
130
- interface OutcomeCursorEntry {
131
- /** The emitted transition marker (`"story-completed"` / `"project-shipped"`). */
132
- type: string;
133
- /** Stable ISO-8601 event time, assigned on first detection and reused on replay. */
134
- occurredAt: string;
135
- /**
136
- * True once the server 2xx'd this transition. A `pending:true` entry has a
137
- * pinned `occurredAt` (so a replay reuses it) but has NOT yet been confirmed
138
- * emitted, so it is still eligible to (re-)send next sync.
139
- */
140
- pending?: boolean;
141
- }
142
-
143
- interface OutcomeCursor {
144
- version: string;
145
- /** dedupeKey → the emitted/pending transition entry. */
146
- emitted: Record<string, OutcomeCursorEntry>;
147
- }
148
-
149
- function emptyCursor(): OutcomeCursor {
150
- return { version: "2", emitted: {} };
151
- }
152
-
153
- /**
154
- * Coerce a raw parsed `emitted` map into the current entry shape. Older cursors
155
- * (version "1") stored `dedupeKey → typeString`; those entries have no pinned
156
- * `occurredAt`, so they are migrated to `{ type, occurredAt: <legacy sentinel> }`
157
- * and treated as already-confirmed (a v1 cursor only ever held CONFIRMED
158
- * transitions, so migrating them as non-pending preserves "never re-emit").
159
- */
160
- function normalizeEmitted(raw: unknown): Record<string, OutcomeCursorEntry> {
161
- const out: Record<string, OutcomeCursorEntry> = {};
162
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
163
- for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
164
- if (typeof value === "string") {
165
- // Legacy v1 entry: `dedupeKey → type`. No stored occurredAt; it was already
166
- // confirmed-emitted, so keep it as a non-pending marker with no timestamp
167
- // to reuse (empty string means "assign fresh if it somehow re-emits").
168
- out[key] = { type: value, occurredAt: "" };
169
- } else if (value && typeof value === "object" && !Array.isArray(value)) {
170
- const v = value as Record<string, unknown>;
171
- if (typeof v.type === "string") {
172
- out[key] = {
173
- type: v.type,
174
- occurredAt: typeof v.occurredAt === "string" ? v.occurredAt : "",
175
- ...(v.pending === true ? { pending: true } : {}),
176
- };
177
- }
178
- }
179
- }
180
- return out;
181
- }
182
-
183
- async function loadCursor(cursorPath: string): Promise<OutcomeCursor> {
184
- try {
185
- const raw = await fs.readFile(cursorPath, "utf-8");
186
- const parsed = JSON.parse(raw) as Partial<OutcomeCursor>;
187
- if (
188
- parsed &&
189
- typeof parsed === "object" &&
190
- parsed.emitted &&
191
- typeof parsed.emitted === "object" &&
192
- !Array.isArray(parsed.emitted)
193
- ) {
194
- return {
195
- version: parsed.version ?? "2",
196
- emitted: normalizeEmitted(parsed.emitted),
197
- };
198
- }
199
- } catch {
200
- // Missing / unparseable — start fresh.
201
- }
202
- return emptyCursor();
203
- }
204
-
205
- async function saveCursor(cursorPath: string, cursor: OutcomeCursor): Promise<void> {
206
- // Atomic write: tmp + rename (matches ./telemetry.ts).
207
- await fs.mkdir(path.dirname(cursorPath), { recursive: true });
208
- const tmp = `${cursorPath}.tmp`;
209
- await fs.writeFile(tmp, JSON.stringify(cursor, null, 2), "utf-8");
210
- await fs.rename(tmp, cursorPath);
211
- }
212
-
213
- // ── Local opt-in fallback ─────────────────────────────────────────────────────
214
-
215
- async function readLocalTelemetryEnabled(menubarPath: string): Promise<boolean> {
216
- try {
217
- const raw = await fs.readFile(menubarPath, "utf-8");
218
- const parsed = JSON.parse(raw) as { telemetryEnabled?: unknown };
219
- return parsed.telemetryEnabled === true;
220
- } catch {
221
- return false;
222
- }
223
- }
224
-
225
- // ── Transition detection ──────────────────────────────────────────────────────
226
-
227
- /**
228
- * A board project status is treated as SHIPPED for these values. The Indigo
229
- * board uses both `done` and `completed` for finished projects; both count as a
230
- * project-shipped transition. Kept lowercase-compared so a `Done`/`DONE`
231
- * casing variant still matches.
232
- */
233
- const DONE_STATUSES = new Set(["done", "completed", "shipped", "complete"]);
234
-
235
- export function isDoneStatus(status: unknown): boolean {
236
- return typeof status === "string" && DONE_STATUSES.has(status.trim().toLowerCase());
237
- }
238
-
239
- /** A single detected outcome transition, before it is shaped for the wire. */
240
- export interface OutcomeTransition {
241
- type: "story-completed" | "project-shipped";
242
- companyUid: string;
243
- projectName: string;
244
- /** Present only for story-completed. */
245
- storyId?: string;
246
- dedupeKey: string;
247
- }
248
-
249
- interface PrdShape {
250
- name?: unknown;
251
- userStories?: unknown;
252
- }
253
-
254
- interface BoardShape {
255
- projects?: unknown;
256
- }
257
-
258
- /**
259
- * Extract the story-completed transitions from a parsed prd.json. A transition
260
- * is a story whose `passes` is currently `true` — the cursor decides whether it
261
- * is NEW (false→true since last sync) vs already-emitted. `passes` that is not
262
- * boolean `true`, or a story with no string `id`, is not a completion.
263
- *
264
- * `projectName` is the prd's `name`; a prd without one is skipped (the ingest
265
- * requires a non-empty projectName). NOTHING else from the prd — no
266
- * description, acceptance criteria, files, notes — is read.
267
- */
268
- export function extractStoryTransitions(
269
- prd: unknown,
270
- companyUid: string,
271
- ): OutcomeTransition[] {
272
- if (!prd || typeof prd !== "object" || Array.isArray(prd)) return [];
273
- const doc = prd as PrdShape;
274
- const projectName = typeof doc.name === "string" ? doc.name.trim() : "";
275
- if (!projectName) return [];
276
- if (!Array.isArray(doc.userStories)) return [];
277
-
278
- const out: OutcomeTransition[] = [];
279
- for (const story of doc.userStories as unknown[]) {
280
- if (!story || typeof story !== "object" || Array.isArray(story)) continue;
281
- const s = story as Record<string, unknown>;
282
- if (s.passes !== true) continue;
283
- const storyId = typeof s.id === "string" ? s.id.trim() : "";
284
- if (!storyId) continue;
285
- out.push({
286
- type: "story-completed",
287
- companyUid,
288
- projectName,
289
- storyId,
290
- dedupeKey: `${companyUid}#${projectName}#${storyId}`,
291
- });
292
- }
293
- return out;
294
- }
295
-
296
- /**
297
- * Extract the project-shipped transitions from a parsed board.json. A transition
298
- * is a project whose `status` is a DONE status (see `isDoneStatus`). The
299
- * `projectName` is the project's `title` (falling back to `id`); a project with
300
- * neither is skipped. NOTHING else from the board — description, scope, app,
301
- * prd_path, timestamps — is read.
302
- */
303
- export function extractProjectTransitions(
304
- board: unknown,
305
- companyUid: string,
306
- ): OutcomeTransition[] {
307
- if (!board || typeof board !== "object" || Array.isArray(board)) return [];
308
- const doc = board as BoardShape;
309
- if (!Array.isArray(doc.projects)) return [];
310
-
311
- const out: OutcomeTransition[] = [];
312
- for (const project of doc.projects as unknown[]) {
313
- if (!project || typeof project !== "object" || Array.isArray(project)) continue;
314
- const p = project as Record<string, unknown>;
315
- if (!isDoneStatus(p.status)) continue;
316
- const title = typeof p.title === "string" ? p.title.trim() : "";
317
- const id = typeof p.id === "string" ? p.id.trim() : "";
318
- const projectName = title || id;
319
- if (!projectName) continue;
320
- out.push({
321
- type: "project-shipped",
322
- companyUid,
323
- projectName,
324
- dedupeKey: `${companyUid}#${projectName}`,
325
- });
326
- }
327
- return out;
328
- }
329
-
330
- /**
331
- * Shape a detected transition for the wire — the STRICT allowlist that proves
332
- * no prd/board content beyond project name, story id, and the status transition
333
- * is transmitted. Mirrors the server's KEEP_FIELDS + per-type ref rules in
334
- * `apps/hq-pro/src/vault-service/handlers/outcome-events.ts`. `personUid` is
335
- * never produced (resolved server-side from the JWT); any other field would be
336
- * rejected 4xx by the ingest handler.
337
- */
338
- export function toWireRow(
339
- t: OutcomeTransition,
340
- ctx: { occurredAt: string; repo: string; branch: string },
341
- ): Record<string, unknown> {
342
- const row: Record<string, unknown> = {
343
- type: t.type,
344
- occurredAt: ctx.occurredAt,
345
- companyUid: t.companyUid,
346
- repo: ctx.repo,
347
- branch: ctx.branch,
348
- dedupeKey: t.dedupeKey,
349
- projectName: t.projectName,
350
- };
351
- if (t.type === "story-completed" && t.storyId !== undefined) {
352
- row.storyId = t.storyId;
353
- }
354
- return row;
355
- }
356
-
357
- // ── Filesystem scan ────────────────────────────────────────────────────────────
358
-
359
- /**
360
- * Resolve a company DIRECTORY name (`companies/<slug>`) to its owning company's
361
- * `cmp_*` uid via the manifest slug→uid map. A slug with no cloud-backed
362
- * manifest entry resolves to undefined and its projects are skipped.
363
- */
364
- function companyUidForSlug(slug: string, map: RepoCompanyMap): string | undefined {
365
- return map.bySlug.get(slug);
366
- }
367
-
368
- /** List the immediate subdirectory names of `dir` (company slugs). */
369
- async function listCompanySlugs(companiesRoot: string): Promise<string[]> {
370
- try {
371
- const entries = await fs.readdir(companiesRoot, { withFileTypes: true });
372
- return entries.filter((e) => e.isDirectory()).map((e) => e.name);
373
- } catch {
374
- return [];
375
- }
376
- }
377
-
378
- /** Recursively collect every `prd.json` under `root`. Errors are treated as
379
- * absent (missing dir / EACCES), matching the other collectors' walkers. */
380
- async function listPrdFiles(root: string): Promise<string[]> {
381
- const out: string[] = [];
382
- async function walk(dir: string): Promise<void> {
383
- let entries;
384
- try {
385
- entries = await fs.readdir(dir, { withFileTypes: true });
386
- } catch {
387
- return;
388
- }
389
- for (const ent of entries) {
390
- const full = path.join(dir, ent.name);
391
- if (ent.isDirectory()) {
392
- await walk(full);
393
- } else if (ent.isFile() && ent.name === "prd.json") {
394
- out.push(full);
395
- }
396
- }
397
- }
398
- await walk(root);
399
- return out;
400
- }
401
-
402
- async function readJsonFile(filePath: string): Promise<unknown | undefined> {
403
- try {
404
- const raw = await fs.readFile(filePath, "utf-8");
405
- return JSON.parse(raw);
406
- } catch {
407
- return undefined;
408
- }
409
- }
410
-
411
- // ── Field / row bounds ───────────────────────────────────────────────────────
412
-
413
- /**
414
- * Per-string-field character ceiling enforced by the ingest handler (mirrors the
415
- * 2048-char IAM/field limit). A projectName / storyId longer than this makes the
416
- * server reject the WHOLE batch, so an over-long field on ONE local project must
417
- * not be allowed to poison up to 199 other valid outcomes.
418
- */
419
- export const MAX_FIELD_CHARS = 2048;
420
-
421
- /**
422
- * Per-event serialized-byte ceiling (4 KB). The server rejects a whole batch if
423
- * any single event JSON exceeds this, so an event that would blow the limit is
424
- * dropped locally rather than sent.
425
- */
426
- export const MAX_EVENT_BYTES = 4 * 1024;
427
-
428
- /**
429
- * Decide whether a shaped wire row is within the ingest limits. The
430
- * dedupe-relevant fields (companyUid / projectName / storyId, which compose the
431
- * dedupeKey) CANNOT be truncated without changing the identity of the outcome,
432
- * so an over-limit transition is SKIPPED wholesale rather than mangled — one
433
- * malformed local project must never block the valid outcomes in its batch.
434
- *
435
- * Returns `null` when the row is acceptable, or a human-readable reason string
436
- * when it must be skipped (so the caller can log the skip).
437
- */
438
- export function wireRowRejectReason(row: Record<string, unknown>): string | null {
439
- for (const [key, value] of Object.entries(row)) {
440
- if (typeof value === "string" && value.length > MAX_FIELD_CHARS) {
441
- return `field "${key}" exceeds ${MAX_FIELD_CHARS} chars (${value.length})`;
442
- }
443
- }
444
- const bytes = Buffer.byteLength(JSON.stringify(row), "utf-8");
445
- if (bytes > MAX_EVENT_BYTES) {
446
- return `event exceeds ${MAX_EVENT_BYTES} bytes (${bytes})`;
447
- }
448
- return null;
449
- }
450
-
451
- // ── Batching ───────────────────────────────────────────────────────────────────
452
-
453
- // The ingest caps a batch at 500 events; stay comfortably under it. A real sync
454
- // flushes a small number of transitions, so this bound is rarely reached.
455
- const MAX_BATCH_EVENTS = 200;
456
-
457
- // ── Main entry point ──────────────────────────────────────────────────────────
458
-
459
- /**
460
- * Scan HQ project state, detect new story-completed / project-shipped
461
- * transitions since the last sync, and POST them.
462
- *
463
- * Fire-and-forget from the caller's perspective: all errors are caught
464
- * internally and surfaced only via `log`. The cursor advances ONLY for
465
- * transitions whose batch the server accepted, so a failed POST re-sends next
466
- * sync (and the server-side dedupe makes that re-send idempotent).
467
- */
468
- export async function collectAndSendOutcomeTelemetry(
469
- opts: CollectOutcomeTelemetryOptions,
470
- ): Promise<CollectOutcomeTelemetryResult> {
471
- const home = os.homedir();
472
- const cursorPath =
473
- opts.cursorPath ?? path.join(home, ".hq", "outcome-telemetry-cursor.json");
474
- const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
475
- const repo = opts.repo ?? "hq";
476
- const branch = opts.branch ?? "main";
477
- const nowIso = opts.now ?? (() => new Date().toISOString());
478
- const log = opts.log ?? (() => {});
479
-
480
- // 1. Opt-in gate — same server-authoritative check as usage/skill telemetry,
481
- // with the local menubar.json fallback.
482
- let enabled: boolean;
483
- let optInSource: CollectOutcomeTelemetryResult["optInSource"];
484
- try {
485
- const resp = await opts.client.getTelemetryOptIn();
486
- enabled = resp.enabled === true;
487
- optInSource = "server";
488
- } catch (err) {
489
- log(
490
- `[outcome-telemetry] opt-in check failed (${(err as Error).message ?? err}) — falling back to local menubar.json`,
491
- );
492
- enabled = await readLocalTelemetryEnabled(menubarPath);
493
- optInSource = "menubar-fallback";
494
- }
495
-
496
- if (!enabled) {
497
- return { enabled: false, optInSource, filesScanned: 0, eventsSent: 0, batchesSent: 0 };
498
- }
499
-
500
- // With no hqRoot there is nothing to scan and no company to attribute.
501
- if (!opts.hqRoot) {
502
- return { enabled: true, optInSource, filesScanned: 0, eventsSent: 0, batchesSent: 0 };
503
- }
504
-
505
- // 2. Resolve slug→companyUid ONCE per run (mirrors ./telemetry.ts).
506
- const repoCompanyMap: RepoCompanyMap = await buildRepoCompanyMap(opts.hqRoot);
507
-
508
- const companiesRoot = path.join(opts.hqRoot, "companies");
509
- const slugs = await listCompanySlugs(companiesRoot);
510
-
511
- // 3. Detect all current transitions across every cloud-backed company.
512
- const cursor = await loadCursor(cursorPath);
513
- const transitions: OutcomeTransition[] = [];
514
- let filesScanned = 0;
515
-
516
- for (const slug of slugs) {
517
- const companyUid = companyUidForSlug(slug, repoCompanyMap);
518
- // No cloud-backed company for this slug → its projects cannot be attributed
519
- // (companyUid is a required ingest field), so skip the whole directory.
520
- if (companyUid === undefined) continue;
521
-
522
- const companyDir = path.join(companiesRoot, slug);
523
-
524
- // 3a. Board projects (project-shipped). One board.json per company.
525
- const boardPath = path.join(companyDir, "board.json");
526
- const board = await readJsonFile(boardPath);
527
- if (board !== undefined) {
528
- filesScanned++;
529
- for (const transition of extractProjectTransitions(board, companyUid)) {
530
- transitions.push(transition);
531
- }
532
- }
533
-
534
- // 3b. Project stories (story-completed). Many prd.json under projects/.
535
- const prdFiles = await listPrdFiles(path.join(companyDir, "projects"));
536
- for (const prdPath of prdFiles) {
537
- const prd = await readJsonFile(prdPath);
538
- if (prd === undefined) continue;
539
- filesScanned++;
540
- for (const transition of extractStoryTransitions(prd, companyUid)) {
541
- transitions.push(transition);
542
- }
543
- }
544
- }
545
-
546
- // 4. Keep only transitions the cursor has NOT already CONFIRMED emitting.
547
- // Dedupe within this run too (a dedupeKey seen twice in one scan is one
548
- // event). A `pending` cursor entry (occurredAt pinned, POST not yet 2xx'd)
549
- // is still eligible — it re-sends with its ORIGINAL occurredAt.
550
- const seenThisRun = new Set<string>();
551
- const pending: OutcomeTransition[] = [];
552
- for (const t of transitions) {
553
- if (seenThisRun.has(t.dedupeKey)) continue;
554
- seenThisRun.add(t.dedupeKey);
555
- const entry = cursor.emitted[t.dedupeKey];
556
- if (entry && entry.type === t.type && entry.pending !== true) continue; // confirmed already
557
- pending.push(t);
558
- }
559
-
560
- if (pending.length === 0) {
561
- return { enabled: true, optInSource, filesScanned, eventsSent: 0, batchesSent: 0 };
562
- }
563
-
564
- // 5. Assign a STABLE occurredAt per transition and shape the wire row.
565
- // - If the cursor already pins an occurredAt for this dedupeKey+type
566
- // (a prior sync detected it), REUSE it verbatim so a replay lands under
567
- // the same server sort key and collapses to one row.
568
- // - Otherwise assign a fresh occurredAt now and pin it in the cursor.
569
- // Rows that would exceed the ingest field/size limits are SKIPPED (their
570
- // dedupe-identifying fields can't be truncated without changing identity),
571
- // so one malformed local project cannot block the valid outcomes.
572
- const now = nowIso();
573
- interface Sendable {
574
- transition: OutcomeTransition;
575
- row: Record<string, unknown>;
576
- }
577
- const sendable: Sendable[] = [];
578
- for (const t of pending) {
579
- const existing = cursor.emitted[t.dedupeKey];
580
- const occurredAt =
581
- existing && existing.type === t.type && existing.occurredAt
582
- ? existing.occurredAt
583
- : now;
584
- const row = toWireRow(t, { occurredAt, repo, branch });
585
- const reject = wireRowRejectReason(row);
586
- if (reject) {
587
- log(
588
- `[outcome-telemetry] skipping ${t.type} ${t.dedupeKey}: ${reject}`,
589
- );
590
- continue;
591
- }
592
- // Pin the stable occurredAt BEFORE the POST so a crash between POST-success
593
- // and cursor-flush still reuses the same timestamp on the next detection.
594
- cursor.emitted[t.dedupeKey] = { type: t.type, occurredAt, pending: true };
595
- sendable.push({ transition: t, row });
596
- }
597
-
598
- // Persist the pinned-but-pending timestamps before sending, so a re-detection
599
- // after a partial failure reuses the same occurredAt.
600
- await saveCursor(cursorPath, cursor);
601
-
602
- if (sendable.length === 0) {
603
- return { enabled: true, optInSource, filesScanned, eventsSent: 0, batchesSent: 0 };
604
- }
605
-
606
- // 6. Flush in server-sized batches; confirm the cursor per SUCCESSFUL batch.
607
- let eventsSent = 0;
608
- let batchesSent = 0;
609
-
610
- for (let i = 0; i < sendable.length; i += MAX_BATCH_EVENTS) {
611
- const chunk = sendable.slice(i, i + MAX_BATCH_EVENTS);
612
- const events = chunk.map((s) => s.row);
613
- try {
614
- await opts.client.postOutcomeEvents({ events });
615
- batchesSent++;
616
- eventsSent += events.length;
617
- // Confirm these transitions so they never re-send (server-side dedupe also
618
- // collapses a replay if the cursor is later lost), keeping their pinned
619
- // occurredAt.
620
- for (const s of chunk) {
621
- cursor.emitted[s.transition.dedupeKey] = {
622
- type: s.transition.type,
623
- occurredAt: s.row.occurredAt as string,
624
- };
625
- }
626
- } catch (err) {
627
- log(
628
- `[outcome-telemetry] postOutcomeEvents failed (${(err as Error).message ?? err}) — ${chunk.length} rows re-send next sync`,
629
- );
630
- // Cursor entries stay `pending:true` with their pinned occurredAt — next
631
- // sync retries with the SAME timestamp.
632
- }
633
- }
634
-
635
- // 7. Persist the cursor (confirmed transitions dropped `pending`).
636
- await saveCursor(cursorPath, cursor);
637
-
638
- return { enabled: true, optInSource, filesScanned, eventsSent, batchesSent };
639
- }