@indigoai-us/hq-cloud 6.14.23 → 6.14.25

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 (131) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +11 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner-planning.d.ts +11 -0
  5. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  6. package/dist/bin/sync-runner-planning.js +37 -2
  7. package/dist/bin/sync-runner-planning.js.map +1 -1
  8. package/dist/bin/sync-runner-planning.test.js +1 -0
  9. package/dist/bin/sync-runner-planning.test.js.map +1 -1
  10. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-watch-loop.js +17 -1
  12. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  13. package/dist/bin/sync-runner.d.ts +6 -0
  14. package/dist/bin/sync-runner.d.ts.map +1 -1
  15. package/dist/bin/sync-runner.js +14 -2
  16. package/dist/bin/sync-runner.js.map +1 -1
  17. package/dist/bin/sync-runner.test.js +155 -1
  18. package/dist/bin/sync-runner.test.js.map +1 -1
  19. package/dist/cli/doctor.d.ts +119 -0
  20. package/dist/cli/doctor.d.ts.map +1 -0
  21. package/dist/cli/doctor.js +485 -0
  22. package/dist/cli/doctor.js.map +1 -0
  23. package/dist/cli/doctor.test.d.ts +13 -0
  24. package/dist/cli/doctor.test.d.ts.map +1 -0
  25. package/dist/cli/doctor.test.js +485 -0
  26. package/dist/cli/doctor.test.js.map +1 -0
  27. package/dist/cli/index.d.ts +2 -0
  28. package/dist/cli/index.d.ts.map +1 -1
  29. package/dist/cli/index.js +4 -0
  30. package/dist/cli/index.js.map +1 -1
  31. package/dist/cli/share.js +6 -1
  32. package/dist/cli/share.js.map +1 -1
  33. package/dist/cli/sync.d.ts +29 -0
  34. package/dist/cli/sync.d.ts.map +1 -1
  35. package/dist/cli/sync.js +140 -2
  36. package/dist/cli/sync.js.map +1 -1
  37. package/dist/cli/sync.test.js +223 -1
  38. package/dist/cli/sync.test.js.map +1 -1
  39. package/dist/cognito-auth.test.js +7 -0
  40. package/dist/cognito-auth.test.js.map +1 -1
  41. package/dist/company-resolver.d.ts +31 -1
  42. package/dist/company-resolver.d.ts.map +1 -1
  43. package/dist/company-resolver.js +329 -21
  44. package/dist/company-resolver.js.map +1 -1
  45. package/dist/company-resolver.test.js +371 -1
  46. package/dist/company-resolver.test.js.map +1 -1
  47. package/dist/index.d.ts +2 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +3 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/lib/conflict-file.d.ts.map +1 -1
  52. package/dist/lib/conflict-file.js +5 -1
  53. package/dist/lib/conflict-file.js.map +1 -1
  54. package/dist/lib/conflict-index.d.ts.map +1 -1
  55. package/dist/lib/conflict-index.js +7 -2
  56. package/dist/lib/conflict-index.js.map +1 -1
  57. package/dist/lib/machine-id.test.js +14 -0
  58. package/dist/lib/machine-id.test.js.map +1 -1
  59. package/dist/local-path-codec.d.ts +18 -0
  60. package/dist/local-path-codec.d.ts.map +1 -1
  61. package/dist/local-path-codec.js +63 -0
  62. package/dist/local-path-codec.js.map +1 -1
  63. package/dist/local-path-codec.test.d.ts +14 -0
  64. package/dist/local-path-codec.test.d.ts.map +1 -0
  65. package/dist/local-path-codec.test.js +102 -0
  66. package/dist/local-path-codec.test.js.map +1 -0
  67. package/dist/machine-auth.test.js +7 -0
  68. package/dist/machine-auth.test.js.map +1 -1
  69. package/dist/s3.d.ts +5 -0
  70. package/dist/s3.d.ts.map +1 -1
  71. package/dist/s3.js +129 -3
  72. package/dist/s3.js.map +1 -1
  73. package/dist/s3.symlink-materialize.test.d.ts +18 -0
  74. package/dist/s3.symlink-materialize.test.d.ts.map +1 -0
  75. package/dist/s3.symlink-materialize.test.js +394 -0
  76. package/dist/s3.symlink-materialize.test.js.map +1 -0
  77. package/dist/s3.test.js +19 -0
  78. package/dist/s3.test.js.map +1 -1
  79. package/dist/skill-telemetry.d.ts.map +1 -1
  80. package/dist/skill-telemetry.js +24 -3
  81. package/dist/skill-telemetry.js.map +1 -1
  82. package/dist/skill-telemetry.test.js +17 -0
  83. package/dist/skill-telemetry.test.js.map +1 -1
  84. package/dist/telemetry.d.ts +10 -1
  85. package/dist/telemetry.d.ts.map +1 -1
  86. package/dist/telemetry.js +95 -4
  87. package/dist/telemetry.js.map +1 -1
  88. package/dist/telemetry.test.js +177 -0
  89. package/dist/telemetry.test.js.map +1 -1
  90. package/dist/vault-client.d.ts +42 -0
  91. package/dist/vault-client.d.ts.map +1 -1
  92. package/dist/vault-client.js +40 -0
  93. package/dist/vault-client.js.map +1 -1
  94. package/dist/watcher.d.ts.map +1 -1
  95. package/dist/watcher.js +10 -1
  96. package/dist/watcher.js.map +1 -1
  97. package/dist/watcher.test.js +28 -0
  98. package/dist/watcher.test.js.map +1 -1
  99. package/package.json +1 -1
  100. package/src/bin/sync-runner-company.ts +10 -0
  101. package/src/bin/sync-runner-planning.test.ts +1 -0
  102. package/src/bin/sync-runner-planning.ts +46 -3
  103. package/src/bin/sync-runner-watch-loop.ts +19 -1
  104. package/src/bin/sync-runner.test.ts +174 -0
  105. package/src/bin/sync-runner.ts +17 -1
  106. package/src/cli/doctor.test.ts +581 -0
  107. package/src/cli/doctor.ts +640 -0
  108. package/src/cli/index.ts +14 -0
  109. package/src/cli/share.ts +6 -1
  110. package/src/cli/sync.test.ts +281 -1
  111. package/src/cli/sync.ts +184 -2
  112. package/src/cognito-auth.test.ts +5 -0
  113. package/src/company-resolver.test.ts +432 -1
  114. package/src/company-resolver.ts +362 -23
  115. package/src/index.ts +10 -0
  116. package/src/lib/conflict-file.ts +6 -1
  117. package/src/lib/conflict-index.ts +7 -2
  118. package/src/lib/machine-id.test.ts +10 -0
  119. package/src/local-path-codec.test.ts +138 -0
  120. package/src/local-path-codec.ts +66 -0
  121. package/src/machine-auth.test.ts +5 -0
  122. package/src/s3.symlink-materialize.test.ts +492 -0
  123. package/src/s3.test.ts +24 -0
  124. package/src/s3.ts +148 -4
  125. package/src/skill-telemetry.test.ts +19 -0
  126. package/src/skill-telemetry.ts +32 -3
  127. package/src/telemetry.test.ts +241 -0
  128. package/src/telemetry.ts +124 -6
  129. package/src/vault-client.ts +68 -0
  130. package/src/watcher.test.ts +33 -0
  131. package/src/watcher.ts +10 -1
package/src/telemetry.ts CHANGED
@@ -48,6 +48,14 @@ import type {
48
48
  export interface TelemetryClientSurface {
49
49
  getTelemetryOptIn(): Promise<TelemetryOptInResponse>;
50
50
  postUsage(batch: UsageBatch): Promise<UsageIngestResult>;
51
+ /**
52
+ * Optional so an older client (or a narrow test stub) still satisfies the
53
+ * surface — when it is absent the consent self-heal is simply skipped.
54
+ */
55
+ setTelemetryOptIn?(
56
+ enabled: boolean,
57
+ opts?: { onlyIfUnset?: boolean },
58
+ ): Promise<{ applied: boolean } | void>;
51
59
  }
52
60
 
53
61
  export interface CollectTelemetryOptions {
@@ -79,7 +87,7 @@ export interface CollectTelemetryResult {
79
87
  /** Whether the opt-in check resolved to true (either server-side or via the menubar fallback). When false, nothing else ran. */
80
88
  enabled: boolean;
81
89
  /** Source for the `enabled` decision — useful for diagnosing missing-events reports. */
82
- optInSource: "server" | "menubar-fallback" | "skipped";
90
+ optInSource: "server" | "menubar-fallback" | "menubar-reasserted" | "skipped";
83
91
  /** How many `.jsonl` files we considered (before the cursor diff). */
84
92
  filesScanned: number;
85
93
  /** Total events successfully POSTed across all batches. */
@@ -129,12 +137,58 @@ async function saveCursor(cursorPath: string, cursor: TelemetryCursor): Promise<
129
137
  // ── Local opt-in fallback ─────────────────────────────────────────────────────
130
138
 
131
139
  async function readLocalTelemetryEnabled(menubarPath: string): Promise<boolean> {
140
+ return (await readLocalTelemetryPreference(menubarPath)) === true;
141
+ }
142
+
143
+ /**
144
+ * Tri-state read of the locally-stored consent.
145
+ *
146
+ * `~/.hq/menubar.json` → `telemetryEnabled` is written by the installer the
147
+ * moment the user answers the prompt, and that local write ALWAYS succeeds —
148
+ * unlike the paired server write, which fires before the person entity exists
149
+ * and 404s. So this file is frequently the ONLY durable record of the user's
150
+ * actual choice.
151
+ *
152
+ * `undefined` means absent / unreadable / not a boolean — "we hold no answer" —
153
+ * as distinct from `false`, which is a real opt-out. The self-heal path must
154
+ * never conflate the two: it replays an answer, it does not invent one.
155
+ */
156
+ async function readLocalTelemetryPreference(
157
+ menubarPath: string,
158
+ ): Promise<boolean | undefined> {
159
+ return (await readLocalConsentRecord(menubarPath)).enabled;
160
+ }
161
+
162
+ /**
163
+ * The locally-cached consent plus the account it belongs to.
164
+ *
165
+ * `telemetryOptInPersonUid` binds the answer to the `prs_*` that gave it.
166
+ * `menubar.json` is a per-MACHINE file, so when two people sign in under the
167
+ * same OS user it holds whoever answered LAST — replaying it unconditionally
168
+ * would opt in an account that never consented. The binding is what makes the
169
+ * replay safe; an unbound (legacy) record cannot be proven to belong to the
170
+ * current caller and is therefore never replayed.
171
+ */
172
+ async function readLocalConsentRecord(
173
+ menubarPath: string,
174
+ ): Promise<{ enabled?: boolean; personUid?: string }> {
132
175
  try {
133
176
  const raw = await fs.readFile(menubarPath, "utf-8");
134
- const parsed = JSON.parse(raw) as { telemetryEnabled?: unknown };
135
- return parsed.telemetryEnabled === true;
177
+ const parsed = JSON.parse(raw) as {
178
+ telemetryEnabled?: unknown;
179
+ telemetryOptInPersonUid?: unknown;
180
+ };
181
+ return {
182
+ enabled:
183
+ typeof parsed.telemetryEnabled === "boolean" ? parsed.telemetryEnabled : undefined,
184
+ personUid:
185
+ typeof parsed.telemetryOptInPersonUid === "string" &&
186
+ parsed.telemetryOptInPersonUid.length > 0
187
+ ? parsed.telemetryOptInPersonUid
188
+ : undefined,
189
+ };
136
190
  } catch {
137
- return false;
191
+ return {};
138
192
  }
139
193
  }
140
194
 
@@ -422,15 +476,79 @@ export async function collectAndSendTelemetry(
422
476
  // When `hqRoot` is omitted the map is empty → every event stays unattributed.
423
477
  const repoCompanyMap: RepoCompanyMap = opts.hqRoot
424
478
  ? await buildRepoCompanyMap(opts.hqRoot)
425
- : { entries: [], bySlug: new Map() };
479
+ : { entries: [], bySlug: new Map(), foldsCase: false, ambiguous: new Set<string>() };
426
480
 
427
- // 1. Opt-in check (server-authoritative, with local fallback).
481
+ // 1. Opt-in check (server-authoritative, with local fallback + self-heal).
428
482
  let enabled: boolean;
429
483
  let optInSource: CollectTelemetryResult["optInSource"];
430
484
  try {
431
485
  const resp = await opts.client.getTelemetryOptIn();
432
486
  enabled = resp.enabled === true;
433
487
  optInSource = "server";
488
+
489
+ // Self-heal a consent the user gave but that never reached the server.
490
+ //
491
+ // The installer writes the answer to `~/.hq/menubar.json` (always succeeds)
492
+ // AND posts it to `/v1/usage/opt-in` — but that post fires before the
493
+ // person entity exists, so it 404s and the attribute is never written.
494
+ // Absence then reads as `false`, the emitter goes silent, and the person
495
+ // shows as "not opted in" forever. Measured: 22 of 33 active Indigo members
496
+ // had no attribute at all, against only 2 genuine opt-outs.
497
+ //
498
+ // `unset` (never answered) is the ONLY state we heal. An explicit server
499
+ // `false` is a real opt-out and is left strictly alone. We also require a
500
+ // local answer to actually exist — we never invent consent, we only replay
501
+ // the answer the user already gave.
502
+ //
503
+ // And it must be THIS account's answer. `menubar.json` is per-MACHINE, so
504
+ // when two people sign in under the same OS user it holds whoever answered
505
+ // last; replaying that for the second account would opt in someone who
506
+ // never consented. The replay therefore requires the cached record to name
507
+ // the same `prs_*` the server says we are. A legacy record with no binding
508
+ // cannot be proven to belong to this caller and is skipped — for those
509
+ // machines the installer's own post-sign-in upload is the recovery path,
510
+ // and it is correctly account-scoped because it runs right after that
511
+ // person authenticates.
512
+ if (resp.unset === true && opts.client.setTelemetryOptIn) {
513
+ const record = await readLocalConsentRecord(menubarPath);
514
+ const local = record.enabled;
515
+ const boundToCaller =
516
+ record.personUid !== undefined &&
517
+ resp.personUid !== undefined &&
518
+ record.personUid === resp.personUid;
519
+ if (local !== undefined && !boundToCaller) {
520
+ log(
521
+ "[telemetry] skipping consent re-assert: the locally cached answer is not bound to the signed-in account",
522
+ );
523
+ }
524
+ if (local !== undefined && boundToCaller) {
525
+ try {
526
+ // `onlyIfUnset` keeps the replay atomic. Reading `unset` and writing
527
+ // are two requests, so without it another device could record a real
528
+ // opt-out in between and this stale local answer would overwrite it.
529
+ // The server tests "still unset?" as part of the write instead.
530
+ const ack = await opts.client.setTelemetryOptIn(local, { onlyIfUnset: true });
531
+ if (ack && ack.applied === false) {
532
+ // Lost the race — a real answer was recorded first and it stands.
533
+ // Keep the server's answer for this run rather than the local one.
534
+ log(
535
+ "[telemetry] consent was recorded elsewhere before the re-assert landed; deferring to the server",
536
+ );
537
+ } else {
538
+ enabled = local;
539
+ optInSource = "menubar-reasserted";
540
+ log(
541
+ `[telemetry] server had no recorded consent; re-asserted the local install-time answer (enabled=${local})`,
542
+ );
543
+ }
544
+ } catch (err) {
545
+ // Non-fatal: fall through on the server's answer. Next run retries.
546
+ log(
547
+ `[telemetry] failed to re-assert local opt-in (${(err as Error).message ?? err})`,
548
+ );
549
+ }
550
+ }
551
+ }
434
552
  } catch (err) {
435
553
  log(`[telemetry] opt-in check failed (${(err as Error).message ?? err}) — falling back to local menubar.json`);
436
554
  enabled = await readLocalTelemetryEnabled(menubarPath);
@@ -414,6 +414,25 @@ export interface VendChildResult {
414
414
  export interface TelemetryOptInResponse {
415
415
  enabled: boolean;
416
416
  updatedAt: string | null;
417
+ /**
418
+ * `true` when the person row carries NO `telemetryOptIn` attribute — i.e. the
419
+ * consent question has never been answered server-side. Distinct from
420
+ * `enabled: false`, which is a deliberate opt-OUT.
421
+ *
422
+ * Optional because older servers omit it entirely. Absent is treated as
423
+ * `false`, so a client talking to one behaves exactly as before — no
424
+ * self-heal, no surprise writes.
425
+ */
426
+ unset?: boolean;
427
+ /**
428
+ * The `prs_*` uid this answer belongs to — i.e. the authenticated caller.
429
+ *
430
+ * Needed because the local consent cache is a per-MACHINE file: if two people
431
+ * sign in under the same OS user it holds whoever answered last. A client
432
+ * must not replay it for a different account. Optional (older servers omit
433
+ * it), and absence means the replay cannot be proven safe, so it is skipped.
434
+ */
435
+ personUid?: string;
417
436
  }
418
437
 
419
438
  export interface UsageBatch {
@@ -637,9 +656,26 @@ const telemetryOptInResponseSchema: VaultResponseSchema<TelemetryOptInResponse>
637
656
  .object({
638
657
  enabled: z.boolean().default(false),
639
658
  updatedAt: z.string().nullable().default(null),
659
+ personUid: z.string().optional(),
660
+ // `.optional()`, NOT `.default(false)`: an older server omits this field,
661
+ // and materializing it would change the parsed response shape for every
662
+ // legacy caller. Callers test `unset === true`, so absent reads as
663
+ // "not unset" and the self-heal path is skipped, which is the pre-existing
664
+ // behaviour exactly.
665
+ unset: z.boolean().optional(),
640
666
  })
641
667
  .strip();
642
668
 
669
+ /**
670
+ * `POST /v1/usage/opt-in` acknowledgement.
671
+ *
672
+ * `applied` is absent on older servers (which always wrote unconditionally), so
673
+ * it stays optional and callers treat absence as "the write landed".
674
+ */
675
+ const telemetryOptInAckSchema: VaultResponseSchema<{ ok: boolean; applied?: boolean }> = z
676
+ .object({ ok: z.boolean().default(true), applied: z.boolean().optional() })
677
+ .strip();
678
+
643
679
  const usageIngestResultSchema: VaultResponseSchema<UsageIngestResult> = z
644
680
  .object({
645
681
  ok: z.boolean(),
@@ -1353,6 +1389,38 @@ export class VaultClient {
1353
1389
  return this.get("/v1/usage/opt-in", telemetryOptInResponseSchema);
1354
1390
  }
1355
1391
 
1392
+ /**
1393
+ * `POST /v1/usage/opt-in` — record the authenticated caller's consent.
1394
+ *
1395
+ * The installer is the primary writer (it owns the consent prompt). This
1396
+ * client-side setter exists so the sync runner can RE-ASSERT a consent the
1397
+ * user already gave locally but which never reached the server: the
1398
+ * installer's write fires before the person entity exists and 404s on
1399
+ * `no-person-entity`, so the answer survives only in `~/.hq/menubar.json`.
1400
+ * See `./telemetry.ts::collectAndSendTelemetry`, which calls this ONLY when
1401
+ * the server reports `unset` — never over an explicit opt-out.
1402
+ *
1403
+ * `onlyIfUnset` makes the server write conditional on the consent still never
1404
+ * having been recorded. The self-heal MUST pass it: reading `unset` and
1405
+ * replaying the answer are two separate requests, so without the condition
1406
+ * another device could record a real opt-out in between and this replay would
1407
+ * silently overwrite it. A deliberate user choice omits the flag so it always
1408
+ * wins. The response's `applied` reports whether the write landed.
1409
+ */
1410
+ async setTelemetryOptIn(
1411
+ enabled: boolean,
1412
+ opts?: { onlyIfUnset?: boolean },
1413
+ ): Promise<{ applied: boolean }> {
1414
+ const ack = await this.post(
1415
+ "/v1/usage/opt-in",
1416
+ opts?.onlyIfUnset ? { enabled, onlyIfUnset: true } : { enabled },
1417
+ telemetryOptInAckSchema,
1418
+ );
1419
+ // Older servers answer `{ ok: true }` with no `applied`; they also write
1420
+ // unconditionally, so a 2xx there means the write did land.
1421
+ return { applied: ack.applied ?? true };
1422
+ }
1423
+
1356
1424
  /**
1357
1425
  * `POST /v1/usage` — upload a batch of sanitized telemetry events.
1358
1426
  *
@@ -796,3 +796,36 @@ describe("PushEventEmitter — directory and delete tombstone handling", () => {
796
796
  ]);
797
797
  });
798
798
  });
799
+
800
+ describe("TreeWatcher wire-key codec boundary (US-002)", () => {
801
+ it("emits the canonical vault key for an encoded win32 farm name (verbatim local name on POSIX)", () => {
802
+ const hqRoot = path.join(os.tmpdir(), "hq-watch-codec-test-root");
803
+ const clock = new FakeClock();
804
+ const watcher = new TreeWatcher({
805
+ hqRoot,
806
+ clock,
807
+ debounceMs: 100,
808
+ pathFilter: () => true,
809
+ });
810
+ const rels: string[] = [];
811
+ watcher.onChange((_first, batch) => {
812
+ if (batch) rels.push(...batch.paths.values());
813
+ });
814
+
815
+ watcher.handleEvent(
816
+ path.join(hqRoot, ".claude", "skills", "indigo%3Acall-prep-brief"),
817
+ );
818
+ clock.advance(200);
819
+
820
+ // The batch rel is a WIRE identifier (PushEvent.relativePath / targeted
821
+ // push route). On win32 the encoded local farm name must decode back to
822
+ // the canonical key or event-driven push re-mints the junk key family;
823
+ // on POSIX the local name IS the key verbatim (codec is identity there).
824
+ const expectedRel =
825
+ process.platform === "win32"
826
+ ? ".claude/skills/indigo:call-prep-brief"
827
+ : ".claude/skills/indigo%3Acall-prep-brief";
828
+ expect(rels).toEqual([expectedRel]);
829
+ watcher.dispose();
830
+ });
831
+ });
package/src/watcher.ts CHANGED
@@ -12,6 +12,7 @@ import { readFile, stat } from "node:fs/promises";
12
12
  import * as path from "path";
13
13
  import { watch } from "chokidar";
14
14
  import { createIgnoreFilter } from "./ignore.js";
15
+ import { vaultKeyForLocalPath } from "./local-path-codec.js";
15
16
  import { isPersonalVaultExcluded } from "./personal-vault-exclusions.js";
16
17
  import {
17
18
  AGENCY_SYNC_REL,
@@ -734,7 +735,15 @@ export class TreeWatcher {
734
735
  // authoritative emit gate; the chokidar backend's `ignored` pre-filters
735
736
  // but a slipped-through (or synthetic test) path is re-checked here too.
736
737
  if (!this.shouldEmit(abs, isDir)) return;
737
- const rel = path.relative(this.hqRoot, abs).split(path.sep).join("/");
738
+ // Codec boundary: the batch's relative path is a WIRE identifier — it
739
+ // becomes `PushEvent.relativePath` and the targeted-push route key — so
740
+ // a win32 local farm name (`indigo%3Ax`) must decode back to its
741
+ // canonical vault-key spelling (`indigo:x`) here. Emitting the encoded
742
+ // local name verbatim would mint a fresh junk key family on the wire
743
+ // (the mixed-version amplifier). The emit filter above still sees the
744
+ // raw local path: its exclusion patterns live in local-name space and
745
+ // none of them contain codec-reserved characters.
746
+ const rel = vaultKeyForLocalPath(this.hqRoot, abs);
738
747
  if (!this.pending.has(abs)) {
739
748
  const entryBytes = estimatePendingEntryBytes(abs, rel);
740
749
  if (