@indigoai-us/hq-cloud 6.14.28 → 6.14.30

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 (93) hide show
  1. package/dist/bin/sync-runner-company.d.ts +4 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +3 -0
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner-planning.d.ts +7 -6
  6. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  7. package/dist/bin/sync-runner-planning.js +1 -1
  8. package/dist/bin/sync-runner-planning.js.map +1 -1
  9. package/dist/bin/sync-runner-planning.test.js +23 -0
  10. package/dist/bin/sync-runner-planning.test.js.map +1 -1
  11. package/dist/bin/sync-runner-rollup.d.ts.map +1 -1
  12. package/dist/bin/sync-runner-rollup.js +3 -1
  13. package/dist/bin/sync-runner-rollup.js.map +1 -1
  14. package/dist/bin/sync-runner-rollup.test.d.ts +2 -0
  15. package/dist/bin/sync-runner-rollup.test.d.ts.map +1 -0
  16. package/dist/bin/sync-runner-rollup.test.js +24 -0
  17. package/dist/bin/sync-runner-rollup.test.js.map +1 -0
  18. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  19. package/dist/bin/sync-runner-watch-loop.js +15 -1
  20. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  21. package/dist/bin/sync-runner.d.ts +11 -7
  22. package/dist/bin/sync-runner.d.ts.map +1 -1
  23. package/dist/bin/sync-runner.js +51 -9
  24. package/dist/bin/sync-runner.js.map +1 -1
  25. package/dist/bin/sync-runner.test.js +97 -0
  26. package/dist/bin/sync-runner.test.js.map +1 -1
  27. package/dist/cli/doctor.d.ts.map +1 -1
  28. package/dist/cli/doctor.js +3 -1
  29. package/dist/cli/doctor.js.map +1 -1
  30. package/dist/cli/reindex.d.ts.map +1 -1
  31. package/dist/cli/reindex.js +24 -13
  32. package/dist/cli/reindex.js.map +1 -1
  33. package/dist/cli/rescue-core.js +3 -1
  34. package/dist/cli/rescue-core.js.map +1 -1
  35. package/dist/cli/share.d.ts +35 -0
  36. package/dist/cli/share.d.ts.map +1 -1
  37. package/dist/cli/share.js +29 -11
  38. package/dist/cli/share.js.map +1 -1
  39. package/dist/cli/share.test.js +46 -0
  40. package/dist/cli/share.test.js.map +1 -1
  41. package/dist/ignore.d.ts.map +1 -1
  42. package/dist/ignore.js +5 -0
  43. package/dist/ignore.js.map +1 -1
  44. package/dist/ignore.test.js +9 -0
  45. package/dist/ignore.test.js.map +1 -1
  46. package/dist/outcome-telemetry.d.ts +167 -0
  47. package/dist/outcome-telemetry.d.ts.map +1 -0
  48. package/dist/outcome-telemetry.js +479 -0
  49. package/dist/outcome-telemetry.js.map +1 -0
  50. package/dist/outcome-telemetry.test.d.ts +9 -0
  51. package/dist/outcome-telemetry.test.d.ts.map +1 -0
  52. package/dist/outcome-telemetry.test.js +412 -0
  53. package/dist/outcome-telemetry.test.js.map +1 -0
  54. package/dist/qmd-reindex.d.ts +48 -35
  55. package/dist/qmd-reindex.d.ts.map +1 -1
  56. package/dist/qmd-reindex.js +188 -60
  57. package/dist/qmd-reindex.js.map +1 -1
  58. package/dist/qmd-reindex.test.d.ts +3 -3
  59. package/dist/qmd-reindex.test.js +203 -41
  60. package/dist/qmd-reindex.test.js.map +1 -1
  61. package/dist/telemetry.d.ts +5 -4
  62. package/dist/telemetry.d.ts.map +1 -1
  63. package/dist/telemetry.js +187 -13
  64. package/dist/telemetry.js.map +1 -1
  65. package/dist/telemetry.test.js +157 -0
  66. package/dist/telemetry.test.js.map +1 -1
  67. package/dist/vault-client.d.ts +34 -0
  68. package/dist/vault-client.d.ts.map +1 -1
  69. package/dist/vault-client.js +23 -0
  70. package/dist/vault-client.js.map +1 -1
  71. package/package.json +1 -1
  72. package/src/bin/sync-runner-company.ts +4 -0
  73. package/src/bin/sync-runner-planning.test.ts +26 -0
  74. package/src/bin/sync-runner-planning.ts +8 -7
  75. package/src/bin/sync-runner-rollup.test.ts +37 -0
  76. package/src/bin/sync-runner-rollup.ts +3 -1
  77. package/src/bin/sync-runner-watch-loop.ts +22 -1
  78. package/src/bin/sync-runner.test.ts +111 -0
  79. package/src/bin/sync-runner.ts +62 -17
  80. package/src/cli/doctor.ts +3 -1
  81. package/src/cli/reindex.ts +24 -12
  82. package/src/cli/rescue-core.ts +3 -1
  83. package/src/cli/share.test.ts +60 -0
  84. package/src/cli/share.ts +29 -11
  85. package/src/ignore.test.ts +10 -0
  86. package/src/ignore.ts +5 -0
  87. package/src/outcome-telemetry.test.ts +498 -0
  88. package/src/outcome-telemetry.ts +639 -0
  89. package/src/qmd-reindex.test.ts +226 -40
  90. package/src/qmd-reindex.ts +209 -61
  91. package/src/telemetry.test.ts +194 -0
  92. package/src/telemetry.ts +233 -14
  93. package/src/vault-client.ts +55 -0
@@ -0,0 +1,639 @@
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
+ }