@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,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
- }