@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
package/src/telemetry.ts CHANGED
@@ -7,9 +7,8 @@
7
7
  * (`hq-sync-runner`, `hq-cli`, mobile wrappers) emits telemetry uniformly.
8
8
  *
9
9
  * What it does: after each successful sync (`all-complete` arm of
10
- * `bin/sync-runner.ts`), walks `~/.claude/projects/**\/*.jsonl`, diffs each
11
- * file against a persisted byte-offset cursor at `~/.hq/telemetry-cursor.json`,
12
- * sanitizes new rows through a tight allowlist that matches the server's
10
+ * `bin/sync-runner.ts`), walks Claude session logs plus live and archived Codex
11
+ * rollouts, then diffs each file against the persisted byte-offset cursor at `~/.hq/telemetry-cursor.json`, sanitizes new rows through a tight allowlist that matches the server's
13
12
  * KEEP_FIELDS set in `apps/hq-pro/src/vault-service/handlers/usage.ts`,
14
13
  * batches into server-sized POST bodies, and ships them to `/v1/usage`.
15
14
  *
@@ -23,6 +22,7 @@
23
22
  * transient outage retries automatically on the next sync.
24
23
  */
25
24
 
25
+ import { createHash } from "node:crypto";
26
26
  import { promises as fs } from "node:fs";
27
27
  import * as os from "node:os";
28
28
  import * as path from "node:path";
@@ -75,6 +75,8 @@ export interface CollectTelemetryOptions {
75
75
  hqRoot?: string;
76
76
  /** Override `~/.claude/projects` for tests. */
77
77
  claudeProjectsRoot?: string;
78
+ /** Override `~/.codex` for tests. Both live and archived rollouts are scanned. */
79
+ codexRoot?: string;
78
80
  /** Override `~/.hq/telemetry-cursor.json` for tests. */
79
81
  cursorPath?: string;
80
82
  /** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */
@@ -98,14 +100,27 @@ export interface CollectTelemetryResult {
98
100
 
99
101
  // ── Cursor schema ─────────────────────────────────────────────────────────────
100
102
 
103
+ interface CodexUsageContext {
104
+ session_id?: string;
105
+ cwd?: string;
106
+ git_branch?: string;
107
+ session_model?: string;
108
+ collaboration_model?: string;
109
+ turn_model?: string;
110
+ }
111
+
101
112
  interface CursorEntry {
102
113
  offset: number;
103
114
  mtime: number;
115
+ /** Runtime context needed to resume a Codex rollout from a byte offset. */
116
+ context?: CodexUsageContext;
104
117
  }
105
118
 
106
119
  interface TelemetryCursor {
107
120
  version: string;
108
121
  files: Record<string, CursorEntry>;
122
+ /** Preserved for cursor compatibility with the desktop collector. */
123
+ codex_next_rollout?: string;
109
124
  }
110
125
 
111
126
  function emptyCursor(): TelemetryCursor {
@@ -117,7 +132,13 @@ async function loadCursor(cursorPath: string): Promise<TelemetryCursor> {
117
132
  const raw = await fs.readFile(cursorPath, "utf-8");
118
133
  const parsed = JSON.parse(raw) as Partial<TelemetryCursor>;
119
134
  if (parsed && typeof parsed === "object" && parsed.files && typeof parsed.files === "object") {
120
- return { version: parsed.version ?? "1", files: parsed.files as Record<string, CursorEntry> };
135
+ return {
136
+ version: parsed.version ?? "1",
137
+ files: parsed.files as Record<string, CursorEntry>,
138
+ ...(typeof parsed.codex_next_rollout === "string"
139
+ ? { codex_next_rollout: parsed.codex_next_rollout }
140
+ : {}),
141
+ };
121
142
  }
122
143
  } catch {
123
144
  // Missing / unparseable — start fresh.
@@ -377,6 +398,163 @@ async function listJsonlFiles(root: string): Promise<string[]> {
377
398
  return out;
378
399
  }
379
400
 
401
+ // ── Codex rollout adapter ────────────────────────────────────────────────────
402
+
403
+ const MAX_ID_BYTES = 256;
404
+ const MAX_TIMESTAMP_BYTES = 128;
405
+ const MAX_PATH_BYTES = 4 * 1024;
406
+ const MAX_MODEL_BYTES = 256;
407
+
408
+ function boundedString(value: unknown, maxBytes: number): string | undefined {
409
+ return typeof value === "string" &&
410
+ value.length > 0 &&
411
+ Buffer.byteLength(value, "utf-8") <= maxBytes
412
+ ? value
413
+ : undefined;
414
+ }
415
+
416
+ function tokenCount(value: unknown): number {
417
+ return typeof value === "number" &&
418
+ Number.isSafeInteger(value) &&
419
+ value >= 0
420
+ ? value
421
+ : 0;
422
+ }
423
+
424
+ function cloneCodexContext(context: CodexUsageContext): CodexUsageContext {
425
+ return { ...context };
426
+ }
427
+
428
+ function codexModel(context: CodexUsageContext): string | undefined {
429
+ return context.turn_model ?? context.collaboration_model ?? context.session_model;
430
+ }
431
+
432
+ function codexGitBranch(payload: Record<string, unknown>): string | undefined {
433
+ const direct =
434
+ boundedString(payload.gitBranch, MAX_PATH_BYTES) ??
435
+ boundedString(payload.git_branch, MAX_PATH_BYTES);
436
+ if (direct) return direct;
437
+ const git = payload.git;
438
+ return git && typeof git === "object" && !Array.isArray(git)
439
+ ? boundedString((git as Record<string, unknown>).branch, MAX_PATH_BYTES)
440
+ : undefined;
441
+ }
442
+
443
+ function collaborationModel(
444
+ payload: Record<string, unknown>,
445
+ ): string | undefined {
446
+ const settings = payload.settings;
447
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
448
+ return undefined;
449
+ }
450
+ return boundedString((settings as Record<string, unknown>).model, MAX_MODEL_BYTES);
451
+ }
452
+
453
+ function stableCodexEventId(
454
+ rolloutIdentity: string,
455
+ startOffset: number,
456
+ endOffset: number,
457
+ ): string {
458
+ const offsets = Buffer.alloc(16);
459
+ offsets.writeBigUInt64BE(BigInt(startOffset), 0);
460
+ offsets.writeBigUInt64BE(BigInt(endOffset), 8);
461
+ return "codex-" + createHash("sha256")
462
+ .update(rolloutIdentity)
463
+ .update(Buffer.from([0]))
464
+ .update(offsets)
465
+ .digest("hex");
466
+ }
467
+
468
+ /** Adapt a Codex rollout record to the Claude-shaped sanitizer input. */
469
+ function codexUsageRow(
470
+ row: unknown,
471
+ context: CodexUsageContext,
472
+ rolloutIdentity: string,
473
+ startOffset: number,
474
+ endOffset: number,
475
+ ): Record<string, unknown> | null {
476
+ if (!row || typeof row !== "object" || Array.isArray(row)) return null;
477
+ const obj = row as Record<string, unknown>;
478
+ const kind = obj.type;
479
+ const rawPayload = obj.payload;
480
+ const payload =
481
+ rawPayload && typeof rawPayload === "object" && !Array.isArray(rawPayload)
482
+ ? (rawPayload as Record<string, unknown>)
483
+ : undefined;
484
+
485
+ if (kind === "session_meta") {
486
+ if (!payload) return null;
487
+ context.session_id = boundedString(payload.id, MAX_ID_BYTES) ?? context.session_id;
488
+ context.cwd = boundedString(payload.cwd, MAX_PATH_BYTES);
489
+ context.git_branch = codexGitBranch(payload);
490
+ context.session_model = boundedString(payload.model, MAX_MODEL_BYTES);
491
+ return null;
492
+ }
493
+ if (kind === "turn_context") {
494
+ context.turn_model = payload
495
+ ? boundedString(payload.model, MAX_MODEL_BYTES)
496
+ : undefined;
497
+ const mode = payload?.collaboration_mode;
498
+ if (mode && typeof mode === "object" && !Array.isArray(mode)) {
499
+ const model = collaborationModel(mode as Record<string, unknown>);
500
+ if (model) context.collaboration_model = model;
501
+ }
502
+ return null;
503
+ }
504
+ if (kind === "collaboration_mode") {
505
+ context.collaboration_model = payload
506
+ ? collaborationModel(payload)
507
+ : undefined;
508
+ return null;
509
+ }
510
+ if (kind !== "event_msg" || !payload) return null;
511
+ if (payload.type === "collaboration_mode") {
512
+ context.collaboration_model = collaborationModel(payload);
513
+ return null;
514
+ }
515
+ if (payload.type !== "token_count") return null;
516
+
517
+ const info = payload.info;
518
+ if (!info || typeof info !== "object" || Array.isArray(info)) return null;
519
+ const last = (info as Record<string, unknown>).last_token_usage;
520
+ if (!last || typeof last !== "object" || Array.isArray(last)) return null;
521
+ const usage = last as Record<string, unknown>;
522
+ const outputTokens =
523
+ tokenCount(usage.output_tokens) + tokenCount(usage.reasoning_output_tokens);
524
+ const model = codexModel(context);
525
+ const normalized: Record<string, unknown> = {
526
+ uuid:
527
+ boundedString(obj.uuid, MAX_ID_BYTES) ??
528
+ stableCodexEventId(rolloutIdentity, startOffset, endOffset),
529
+ message: {
530
+ usage: {
531
+ input_tokens: tokenCount(usage.input_tokens),
532
+ output_tokens: outputTokens,
533
+ ...(typeof usage.cached_input_tokens === "number"
534
+ ? { cache_read_input_tokens: tokenCount(usage.cached_input_tokens) }
535
+ : {}),
536
+ },
537
+ ...(model ? { model } : {}),
538
+ },
539
+ };
540
+ if (context.session_id) normalized.sessionId = context.session_id;
541
+ const timestamp = boundedString(obj.timestamp, MAX_TIMESTAMP_BYTES);
542
+ if (timestamp) normalized.timestamp = timestamp;
543
+ if (context.cwd) normalized.cwd = context.cwd;
544
+ if (context.git_branch) normalized.gitBranch = context.git_branch;
545
+ return normalized;
546
+ }
547
+
548
+ async function listCodexRolloutFiles(codexRoot: string): Promise<string[]> {
549
+ const [live, archived] = await Promise.all([
550
+ listJsonlFiles(path.join(codexRoot, "sessions")),
551
+ listJsonlFiles(path.join(codexRoot, "archived_sessions")),
552
+ ]);
553
+ return [...live, ...archived].filter((file) =>
554
+ path.basename(file).startsWith("rollout-"),
555
+ );
556
+ }
557
+
380
558
  // ── Batching primitives ───────────────────────────────────────────────────────
381
559
 
382
560
  const MAX_BATCH_EVENTS = 100;
@@ -387,6 +565,7 @@ interface RowSource {
387
565
  filePath: string;
388
566
  endOffset: number;
389
567
  mtime: number;
568
+ context?: CodexUsageContext;
390
569
  }
391
570
 
392
571
  /**
@@ -455,7 +634,7 @@ function boundRowForPost(
455
634
  // ── Main entry point ──────────────────────────────────────────────────────────
456
635
 
457
636
  /**
458
- * Scan, sanitize, and POST any new Claude Code session rows.
637
+ * Scan, sanitize, and POST new Claude Code and Codex usage rows.
459
638
  *
460
639
  * Fire-and-forget from the caller's perspective: errors are caught internally
461
640
  * and surfaced only via `log`. The returned summary lets observers (e.g.
@@ -467,6 +646,7 @@ export async function collectAndSendTelemetry(
467
646
  ): Promise<CollectTelemetryResult> {
468
647
  const home = os.homedir();
469
648
  const claudeProjectsRoot = opts.claudeProjectsRoot ?? path.join(home, ".claude", "projects");
649
+ const codexRoot = opts.codexRoot ?? path.join(home, ".codex");
470
650
  const cursorPath = opts.cursorPath ?? path.join(home, ".hq", "telemetry-cursor.json");
471
651
  const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
472
652
  const log = opts.log ?? (() => {});
@@ -565,7 +745,14 @@ export async function collectAndSendTelemetry(
565
745
  const rotationResets: Record<string, CursorEntry> = {};
566
746
  const newlyCommitted: Record<string, CursorEntry> = {};
567
747
 
568
- const files = await listJsonlFiles(claudeProjectsRoot);
748
+ const [claudeFiles, codexFiles] = await Promise.all([
749
+ listJsonlFiles(claudeProjectsRoot),
750
+ listCodexRolloutFiles(codexRoot),
751
+ ]);
752
+ const files: Array<{ filePath: string; kind: "claude" | "codex" }> = [
753
+ ...claudeFiles.map((filePath) => ({ filePath, kind: "claude" as const })),
754
+ ...codexFiles.map((filePath) => ({ filePath, kind: "codex" as const })),
755
+ ];
569
756
 
570
757
  // 3. Walk each file, sanitize new rows, batch, flush at the server contract.
571
758
  //
@@ -599,15 +786,26 @@ export async function collectAndSendTelemetry(
599
786
  batchesSent++;
600
787
  eventsSent += events.length;
601
788
  // Advance cursor to max(endOffset) per file in this batch.
602
- const maxPerFile = new Map<string, { mtime: number; offset: number }>();
789
+ const maxPerFile = new Map<
790
+ string,
791
+ { mtime: number; offset: number; context?: CodexUsageContext }
792
+ >();
603
793
  for (const src of sources) {
604
794
  const cur = maxPerFile.get(src.filePath);
605
795
  if (!cur || src.endOffset > cur.offset) {
606
- maxPerFile.set(src.filePath, { mtime: src.mtime, offset: src.endOffset });
796
+ maxPerFile.set(src.filePath, {
797
+ mtime: src.mtime,
798
+ offset: src.endOffset,
799
+ ...(src.context ? { context: cloneCodexContext(src.context) } : {}),
800
+ });
607
801
  }
608
802
  }
609
803
  for (const [fp, entry] of maxPerFile) {
610
- newlyCommitted[fp] = { offset: entry.offset, mtime: entry.mtime };
804
+ newlyCommitted[fp] = {
805
+ offset: entry.offset,
806
+ mtime: entry.mtime,
807
+ ...(entry.context ? { context: entry.context } : {}),
808
+ };
611
809
  }
612
810
  } catch (err) {
613
811
  log(`[telemetry] postUsage failed (${(err as Error).message ?? err}) — cursor not advanced for ${sources.length} rows`);
@@ -615,7 +813,7 @@ export async function collectAndSendTelemetry(
615
813
  }
616
814
  };
617
815
 
618
- for (const filePath of files) {
816
+ for (const { filePath, kind } of files) {
619
817
  let stat;
620
818
  try {
621
819
  stat = await fs.stat(filePath);
@@ -627,11 +825,14 @@ export async function collectAndSendTelemetry(
627
825
 
628
826
  const stored = cursor.files[filePath] ?? { offset: 0, mtime: 0 };
629
827
  let offset = stored.offset;
828
+ let codexContext: CodexUsageContext | undefined =
829
+ kind === "codex" ? cloneCodexContext(stored.context ?? {}) : undefined;
630
830
 
631
831
  const rotated =
632
832
  currentSize < offset || (stored.mtime > 0 && currentMtime < stored.mtime);
633
833
  if (rotated) {
634
834
  offset = 0;
835
+ if (kind === "codex") codexContext = {};
635
836
  rotationResets[filePath] = { offset: 0, mtime: currentMtime };
636
837
  }
637
838
 
@@ -671,17 +872,28 @@ export async function collectAndSendTelemetry(
671
872
  } catch {
672
873
  continue;
673
874
  }
875
+ const startOffset = i === 0 ? offset : lineEndOffsets[i - 1];
876
+ const sourceRow =
877
+ kind === "codex"
878
+ ? codexUsageRow(
879
+ parsed,
880
+ codexContext!,
881
+ filePath,
882
+ startOffset,
883
+ lineEndOffsets[i],
884
+ )
885
+ : parsed;
674
886
  // Resolve this row's cwd → owning company (cmp_* uid) before sanitizing,
675
887
  // using the per-run map. Unresolved → undefined → companyUid omitted.
676
888
  const rowCwd =
677
- parsed && typeof parsed === "object" && !Array.isArray(parsed)
678
- ? (parsed as Record<string, unknown>).cwd
889
+ sourceRow && typeof sourceRow === "object" && !Array.isArray(sourceRow)
890
+ ? (sourceRow as Record<string, unknown>).cwd
679
891
  : undefined;
680
892
  const companyUid = resolveCompanyForCwd(
681
893
  typeof rowCwd === "string" ? rowCwd : undefined,
682
894
  repoCompanyMap,
683
895
  );
684
- const sanitized = sanitizeRow(parsed, companyUid);
896
+ const sanitized = sanitizeRow(sourceRow, companyUid);
685
897
  if (!sanitized) continue;
686
898
  const maxRowBytes = MAX_BATCH_BYTES - ENVELOPE_BYTES;
687
899
  const wasOversized = jsonBytes(sanitized) > maxRowBytes;
@@ -722,6 +934,7 @@ export async function collectAndSendTelemetry(
722
934
  filePath,
723
935
  endOffset: lineEndOffsets[i],
724
936
  mtime: currentMtime,
937
+ ...(codexContext ? { context: cloneCodexContext(codexContext) } : {}),
725
938
  });
726
939
  }
727
940
  }
@@ -733,7 +946,13 @@ export async function collectAndSendTelemetry(
733
946
  for (const [fp, entry] of Object.entries(rotationResets)) finalFiles[fp] = entry;
734
947
  for (const [fp, entry] of Object.entries(newlyCommitted)) finalFiles[fp] = entry;
735
948
 
736
- await saveCursor(cursorPath, { version: "1", files: finalFiles });
949
+ await saveCursor(cursorPath, {
950
+ version: "1",
951
+ files: finalFiles,
952
+ ...(cursor.codex_next_rollout
953
+ ? { codex_next_rollout: cursor.codex_next_rollout }
954
+ : {}),
955
+ });
737
956
 
738
957
  return {
739
958
  enabled: true,
@@ -513,6 +513,34 @@ export interface SkillInvocationBatch {
513
513
  /** Same wire shape as `UsageIngestResult`; aliased for call-site clarity. */
514
514
  export type SkillInvocationIngestResult = UsageIngestResult;
515
515
 
516
+ // ---------------------------------------------------------------------------
517
+ // Outcome events (hq-pro `/v1/outcome-events`)
518
+ // ---------------------------------------------------------------------------
519
+
520
+ export interface OutcomeEventsBatch {
521
+ /**
522
+ * Outcome-event rows. Each row is a plain object containing only the fields
523
+ * in the server's KEEP allowlist (type, occurredAt, companyUid, repo, branch,
524
+ * dedupeKey, and the type-specific refs projectName / storyId). Unlike the
525
+ * usage/skill batch, this batch carries NO machineId/installerVersion — the
526
+ * ingest handler (`apps/hq-pro/src/vault-service/handlers/outcome-events.ts`)
527
+ * accepts only `{ events }`. `personUid` MUST NOT appear on the batch or any
528
+ * event — hq-pro resolves the caller server-side from the Cognito JWT and
529
+ * REJECTS a body-supplied person identifier. Any field outside the allowlist
530
+ * is rejected with a 4xx `invalid-event`, so the emitter in
531
+ * `./outcome-telemetry.ts` is the only thing allowed to produce these.
532
+ */
533
+ events: Array<Record<string, unknown>>;
534
+ }
535
+
536
+ export interface OutcomeEventsIngestResult {
537
+ ok: boolean;
538
+ /** Rows newly written this request. */
539
+ written: number;
540
+ /** Rows collapsed by the server-side conditional put (idempotent replay). */
541
+ deduped: number;
542
+ }
543
+
516
544
  // ---------------------------------------------------------------------------
517
545
  // Retry config
518
546
  // ---------------------------------------------------------------------------
@@ -692,6 +720,16 @@ const usageIngestResultSchema: VaultResponseSchema<UsageIngestResult> = z
692
720
  })
693
721
  .strip();
694
722
 
723
+ const outcomeEventsIngestResultSchema: VaultResponseSchema<OutcomeEventsIngestResult> = z
724
+ .object({
725
+ ok: z.boolean(),
726
+ written: z.number().nonnegative(),
727
+ // Older/partial servers may omit `deduped`; default to 0 so a 2xx that
728
+ // reports only `written` still decodes (best-effort, matches usage rollout).
729
+ deduped: z.number().nonnegative().optional().transform((v) => v ?? 0),
730
+ })
731
+ .strip() as unknown as VaultResponseSchema<OutcomeEventsIngestResult>;
732
+
695
733
  /**
696
734
  * Accept the canonical v1 response and the previous raw-event aliases, then
697
735
  * normalize at this boundary so telemetry remains best-effort during rollout.
@@ -1446,6 +1484,23 @@ export class VaultClient {
1446
1484
  return this.post("/v1/skill-invocations", batch, usageIngestResultSchema);
1447
1485
  }
1448
1486
 
1487
+ /**
1488
+ * `POST /v1/outcome-events` — upload a batch of delivery-outcome events
1489
+ * (story-completed / project-shipped, per outcome-leaderboard US-004).
1490
+ *
1491
+ * Same trust model as `postUsage`: `personUid` MUST NOT appear on the batch
1492
+ * or any event — hq-pro resolves the caller from the JWT and rejects a
1493
+ * body-supplied person identifier. Gated by the same telemetry opt-in as
1494
+ * `/v1/usage`. Server-side conditional PutItem on the composite dedupeKey
1495
+ * makes re-syncs and multi-machine replays idempotent (they count as
1496
+ * `deduped`, never a double-write).
1497
+ */
1498
+ async postOutcomeEvents(
1499
+ batch: OutcomeEventsBatch,
1500
+ ): Promise<OutcomeEventsIngestResult> {
1501
+ return this.post("/v1/outcome-events", batch, outcomeEventsIngestResultSchema);
1502
+ }
1503
+
1449
1504
  /**
1450
1505
  * `POST /v1/telemetry/events` — upload a capped batch of hq-cloud ACTION
1451
1506
  * events.