@indigoai-us/hq-cloud 6.14.24 → 6.14.26

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.
@@ -52,8 +52,12 @@ import yaml from "js-yaml";
52
52
  * repo root is the longest prefix of (or equal to) the cwd.
53
53
  */
54
54
  export interface RepoCompanyMap {
55
- /** Absolute, normalized repo root → owning company `cmp_*` uid. */
56
- entries: Array<{ repoRoot: string; companyUid: string }>;
55
+ /**
56
+ * Absolute, normalized repo root → owning company `cmp_*` uid. `matchKey` is
57
+ * the precomputed `canonicalPath(repoRoot)` used for the containment test, so
58
+ * per-event matching stays a plain string compare.
59
+ */
60
+ entries: Array<{ repoRoot: string; matchKey: string; companyUid: string }>;
57
61
  /**
58
62
  * Company SLUG → owning company `cmp_*` uid, from the manifest keys. Backs
59
63
  * skill-based attribution: a company skill is invoked as `<slug>:<skill>`
@@ -62,11 +66,29 @@ export interface RepoCompanyMap {
62
66
  * so a slug resolves to exactly one cloud-backed company here.
63
67
  */
64
68
  bySlug: Map<string, string>;
69
+ /**
70
+ * Whether the HQ filesystem folds case, PROBED at build time rather than
71
+ * inferred from `process.platform`. APFS can be case-sensitive on macOS and
72
+ * Windows supports per-directory case sensitivity, so the OS is only a hint;
73
+ * getting this wrong in the folding direction risks cross-tenant
74
+ * misattribution. Defaults to `false` (exact matching) when the probe fails.
75
+ */
76
+ foldsCase: boolean;
77
+ /**
78
+ * Match keys that two or more companies claimed. They are excluded from
79
+ * `entries` (fail closed), and resolution must ALSO refuse to reach them via
80
+ * the worktree fallback — otherwise an ambiguous `foo-wt-team` would collapse
81
+ * to `foo` and hand the event to whichever company owns `foo`, quietly
82
+ * undoing the protection.
83
+ */
84
+ ambiguous: Set<string>;
65
85
  }
66
86
 
67
87
  interface ManifestCompany {
68
88
  repos?: unknown;
69
89
  cloud_uid?: unknown;
90
+ /** HQ-root-relative company directory (e.g. `companies/indigo`). */
91
+ path?: unknown;
70
92
  }
71
93
 
72
94
  interface ManifestShape {
@@ -79,6 +101,198 @@ function normalizePath(p: string): string {
79
101
  return p.length > 1 ? p.replace(/\/+$/, "") : p;
80
102
  }
81
103
 
104
+ /**
105
+ * Canonical comparison key for a filesystem path.
106
+ *
107
+ * Both sides of the containment test (`repoRoot` and the event `cwd`) run
108
+ * through this so a match does not hinge on the emitting machine's path
109
+ * dialect. Real prod `cwd` values include Windows roots (`C:\Users\eggsa\HQ`)
110
+ * alongside POSIX ones, and the manifest side is built with `path.resolve`,
111
+ * which emits the HOST separator. Comparing those raw meant Windows clients
112
+ * could never match.
113
+ *
114
+ * Two normalizations, both deliberate:
115
+ * - Separators collapse to `/` so `C:\Users\x\HQ` and `C:/Users/x/HQ` agree.
116
+ * - Case is folded only when the HQ filesystem actually folds case, as
117
+ * PROBED by `probeCaseFolding`. Where it does, `.../HQ` and `.../hq` are
118
+ * the same directory and not folding would drop real attribution. Where it
119
+ * does not, they are different directories and folding could attribute a
120
+ * cwd to a company that does not own it.
121
+ *
122
+ * Correct-but-unattributed always beats attributed-to-the-wrong-company, so the
123
+ * probe defaults to NOT folding whenever it cannot determine the answer.
124
+ */
125
+ function canonicalPath(p: string, foldsCase: boolean): string {
126
+ const normalized = normalizePath(p.replace(/\\/g, "/"));
127
+ return foldsCase ? normalized.toLowerCase() : normalized;
128
+ }
129
+
130
+ /**
131
+ * Determine whether the filesystem folds case for entries INSIDE `hqRoot`, by
132
+ * asking it.
133
+ *
134
+ * `process.platform` is only a hint: APFS volumes can be created case-SENSITIVE
135
+ * on macOS, and Windows supports per-directory case sensitivity — in both cases
136
+ * the platform still reads `darwin`/`win32`. Since folding on a case-sensitive
137
+ * filesystem risks cross-tenant misattribution, we probe instead of assume.
138
+ *
139
+ * The probe deliberately targets a CHILD of `hqRoot` (`companies/`, which must
140
+ * exist — the manifest was just read from inside it) rather than `hqRoot`
141
+ * itself. Under Windows per-directory case sensitivity, the lookup of a
142
+ * directory's own name is governed by its PARENT, so probing `hqRoot` would
143
+ * report the parent's behaviour while every path we actually compare lives
144
+ * inside `hqRoot`. Probing a child measures the directory that matters.
145
+ *
146
+ * The probe stats the child with its basename case inverted and compares inode
147
+ * identity. Any failure (missing, permission denied, no inode) returns `false`,
148
+ * the conservative answer.
149
+ */
150
+ async function probeDirFoldsCase(dir: string): Promise<boolean> {
151
+ const base = path.basename(dir);
152
+ const flippedBase = base
153
+ .split("")
154
+ .map((c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()))
155
+ .join("");
156
+ if (flippedBase === base) return false; // no cased characters to test with
157
+ try {
158
+ const [a, b] = await Promise.all([
159
+ fs.stat(dir),
160
+ fs.stat(path.join(path.dirname(dir), flippedBase)),
161
+ ]);
162
+ // Same file identity via the flipped-case name ⇒ this directory folds case.
163
+ return a.ino !== 0 && a.ino === b.ino && a.dev === b.dev;
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Whether case may be folded for EVERY directory we actually match under.
171
+ *
172
+ * Windows configures case sensitivity PER DIRECTORY, so one probe cannot speak
173
+ * for the whole tree: `companies/`, `repos/private/`, and an external symlink
174
+ * target can each behave differently. Folding a path whose parent is
175
+ * case-sensitive is the direction that risks stamping the wrong `companyUid`,
176
+ * so this requires unanimity — if any probe says case-sensitive, or there is
177
+ * nothing to probe, matching stays case-exact everywhere.
178
+ *
179
+ * Pass the ROOTS, not their parents. Case behaviour for a name is a property of
180
+ * the directory the name is looked up IN, so flipping a root's basename
181
+ * measures that root's PARENT — precisely the directory we match under.
182
+ * Flipping the parent's own basename would instead measure the grandparent and
183
+ * could report folding for a parent that is actually case-sensitive.
184
+ *
185
+ * The cost is one `stat` pair per distinct root, concurrently, once per run.
186
+ */
187
+ async function probeCaseFolding(roots: string[]): Promise<boolean> {
188
+ const distinct = [...new Set(roots)];
189
+ if (distinct.length === 0) return false;
190
+ const results = await Promise.all(distinct.map(probeDirFoldsCase));
191
+ return results.every(Boolean);
192
+ }
193
+
194
+ /**
195
+ * Expand one manifest `repos` entry into the absolute roots it may live at.
196
+ *
197
+ * The manifest records repos as BARE NAMES (`hq-cloud`, `hq-console`) — see
198
+ * `companies/manifest.yaml` — while the checkout lives under
199
+ * `<hqRoot>/repos/{private,public}/<name>`. The original implementation did a
200
+ * plain `path.resolve(hqRoot, repo)`, producing `<hqRoot>/hq-cloud`, a path
201
+ * that exists for no repo. Every entry therefore matched no cwd and NO event
202
+ * was ever attributed (verified in prod: 0 of 3200 sampled usage events across
203
+ * 9 people carried a `companyUid`).
204
+ *
205
+ * An entry that already contains a separator is treated as an HQ-root-relative
206
+ * path verbatim, so the documented `repos/private/<name>` form keeps working.
207
+ */
208
+ function repoRootCandidates(hqRoot: string, repo: string): string[] {
209
+ const trimmed = repo.trim().replace(/^\.\//, "");
210
+ if (trimmed.includes("/") || trimmed.includes("\\")) {
211
+ return [path.resolve(hqRoot, trimmed)];
212
+ }
213
+ return [
214
+ path.resolve(hqRoot, "repos", "private", trimmed),
215
+ path.resolve(hqRoot, "repos", "public", trimmed),
216
+ ];
217
+ }
218
+
219
+ /**
220
+ * Sibling git-worktree checkouts of `repoRoot`, per HQ's `<repo>-wt-<branch>`
221
+ * convention (e.g. `repos/private/hq-pro-wt-feature-x`).
222
+ *
223
+ * These matter a lot in practice: a large share of HQ work happens INSIDE a
224
+ * worktree rather than the primary checkout, and the resolver's slash boundary
225
+ * deliberately rejects `…/hq-pro-wt-feature-x` as a mere string-prefix sibling
226
+ * of `…/hq-pro`. Without this expansion every worktree session stays
227
+ * unattributed — which is exactly the emptiness this whole change is fixing.
228
+ *
229
+ * The prefix test honours `foldsCase`: `readdir` returns the casing stored on
230
+ * disk while `name` comes from the manifest, so on a case-folding filesystem a
231
+ * manifest entry `hq-cloud` must still find a checkout stored as
232
+ * `HQ-Cloud-wt-x`. Using a case-sensitive comparison there would drop the
233
+ * worktree even though the later match treats those casings as the same path.
234
+ *
235
+ * Best-effort: an unreadable parent directory yields no worktrees rather than
236
+ * failing the run.
237
+ */
238
+ async function worktreeSiblings(repoRoot: string, foldsCase: boolean): Promise<string[]> {
239
+ const parent = path.dirname(repoRoot);
240
+ const prefix = `${path.basename(repoRoot)}-wt-`;
241
+ const wanted = foldsCase ? prefix.toLowerCase() : prefix;
242
+ try {
243
+ const dirents = await fs.readdir(parent, { withFileTypes: true });
244
+ return dirents
245
+ .filter((d) => {
246
+ if (!d.isDirectory() && !d.isSymbolicLink()) return false;
247
+ const candidate = foldsCase ? d.name.toLowerCase() : d.name;
248
+ return candidate.startsWith(wanted);
249
+ })
250
+ .map((d) => path.join(parent, d.name));
251
+ } catch {
252
+ return [];
253
+ }
254
+ }
255
+
256
+ /**
257
+ * The physical path behind `p`, when it differs from the lexical one.
258
+ *
259
+ * HQ's knowledge-repo layout uses symlinked checkouts, and a process launched
260
+ * inside one can report the RESOLVED path as its `cwd`. Matching only the
261
+ * lexical path would then miss the event entirely, so both are registered.
262
+ * Returns `null` when the path does not exist or already is its own realpath.
263
+ */
264
+ /**
265
+ * Whether `candidate` is strictly beneath `root`.
266
+ *
267
+ * Guards manifest-supplied paths. The HQ root itself is NOT inside itself: it
268
+ * spans every tenant, so claiming it for one company would misattribute all of
269
+ * them.
270
+ */
271
+ function isInsideRoot(root: string, candidate: string): boolean {
272
+ const rel = path.relative(root, candidate);
273
+ return rel.length > 0 && !rel.startsWith("..") && !path.isAbsolute(rel);
274
+ }
275
+
276
+ async function physicalAlias(p: string): Promise<string | null> {
277
+ try {
278
+ const real = await fs.realpath(p);
279
+ return real !== p ? real : null;
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+
285
+ /** Whether the path exists at all (used to keep absent candidates out of the
286
+ * case probe, where they would look indistinguishable from case-sensitive). */
287
+ async function physicalExists(p: string): Promise<boolean> {
288
+ try {
289
+ await fs.stat(p);
290
+ return true;
291
+ } catch {
292
+ return false;
293
+ }
294
+ }
295
+
82
296
  /**
83
297
  * Parse `<hqRoot>/companies/manifest.yaml` ONCE and build the repo-path →
84
298
  * companyUid lookup. Best-effort: a missing/unparseable manifest yields an
@@ -90,26 +304,32 @@ function normalizePath(p: string): string {
90
304
  */
91
305
  export async function buildRepoCompanyMap(hqRoot: string): Promise<RepoCompanyMap> {
92
306
  const manifestPath = path.join(hqRoot, "companies", "manifest.yaml");
307
+ const empty = (foldsCase = false): RepoCompanyMap => ({
308
+ entries: [],
309
+ bySlug: new Map(),
310
+ foldsCase,
311
+ ambiguous: new Set(),
312
+ });
313
+
93
314
  let doc: ManifestShape;
94
315
  try {
95
316
  const raw = await fs.readFile(manifestPath, "utf-8");
96
317
  const parsed = yaml.load(raw);
97
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
98
- return { entries: [], bySlug: new Map() };
99
- }
318
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return empty();
100
319
  doc = parsed as ManifestShape;
101
320
  } catch {
102
321
  // Missing / unreadable / unparseable — no attribution this run.
103
- return { entries: [], bySlug: new Map() };
322
+ return empty();
104
323
  }
105
324
 
106
325
  const companies = doc.companies;
107
- if (!companies || typeof companies !== "object") {
108
- return { entries: [], bySlug: new Map() };
109
- }
326
+ if (!companies || typeof companies !== "object") return empty();
110
327
 
111
- const entries: Array<{ repoRoot: string; companyUid: string }> = [];
112
328
  const bySlug = new Map<string, string>();
329
+ // Roots are collected BEFORE anything is canonicalized, because the case
330
+ // probe needs to know which parent directories we will actually match under.
331
+ const rootsToExpand: Array<{ root: string; companyUid: string }> = [];
332
+
113
333
  for (const [slug, company] of Object.entries(companies)) {
114
334
  if (!company || typeof company !== "object") continue;
115
335
  const cloudUid = company.cloud_uid;
@@ -117,21 +337,108 @@ export async function buildRepoCompanyMap(hqRoot: string): Promise<RepoCompanyMa
117
337
  if (typeof cloudUid !== "string" || !cloudUid.startsWith("cmp_")) continue;
118
338
  // Slug → uid for skill-based attribution (a `<slug>:<skill>` invocation).
119
339
  if (typeof slug === "string" && slug.length > 0) bySlug.set(slug, cloudUid);
340
+
341
+ // The company's own HQ directory (`companies/<slug>`) is company-owned work
342
+ // too — skills, knowledge, and project files all live there, and in prod a
343
+ // large share of sessions run from inside the HQ root rather than a repo
344
+ // checkout. Without this, that work is silently unattributed.
345
+ const declaredPath =
346
+ typeof company.path === "string" && company.path.trim().length > 0
347
+ ? company.path.trim()
348
+ : `companies/${slug}`;
349
+ // `path` is documented as HQ-root-RELATIVE, but the manifest is synced and
350
+ // hand-editable. An absolute path, `.`, or `..` would otherwise register the
351
+ // HQ root — or an arbitrary directory — as owned by this company, stamping
352
+ // its uid on every event beneath. Anything that escapes the root is dropped.
353
+ const companyDir = path.resolve(hqRoot, declaredPath);
354
+ if (isInsideRoot(hqRoot, companyDir)) {
355
+ rootsToExpand.push({ root: companyDir, companyUid: cloudUid });
356
+ }
357
+
120
358
  const repos = company.repos;
121
359
  if (!Array.isArray(repos)) continue;
122
360
  for (const repo of repos) {
123
361
  if (typeof repo !== "string" || repo.trim().length === 0) continue;
124
- // Manifest repo paths are relative to the HQ root (e.g.
125
- // `repos/private/hq-cloud`). Resolve to an absolute, normalized root.
126
- const repoRoot = normalizePath(path.resolve(hqRoot, repo));
127
- entries.push({ repoRoot, companyUid: cloudUid });
362
+ // Manifest `repos` are BARE NAMES; expand to the real checkout roots
363
+ // under `repos/{private,public}/`. See `repoRootCandidates`.
364
+ for (const candidate of repoRootCandidates(hqRoot, repo)) {
365
+ rootsToExpand.push({ root: candidate, companyUid: cloudUid });
366
+ }
128
367
  }
129
368
  }
130
369
 
131
- // Longest repoRoot first so the first containment match is the most specific
132
- // (handles nested repo layouts where one repo path prefixes another).
133
- entries.sort((a, b) => b.repoRoot.length - a.repoRoot.length);
134
- return { entries, bySlug };
370
+ // Discover BEFORE probing, so the probe can cover every directory we will
371
+ // actually match under — including the parents of external symlink targets,
372
+ // which are unknown until the aliases are resolved.
373
+ //
374
+ // Worktree discovery matches its prefix case-INSENSITIVELY here regardless of
375
+ // the filesystem. That deliberately yields a superset: claims are still keyed
376
+ // by the probed `foldsCase` below, so a case-variant that the filesystem
377
+ // considers distinct simply becomes its own (possibly ambiguous) claim rather
378
+ // than being silently missed at discovery time.
379
+ const expansions = await Promise.all(
380
+ rootsToExpand.map(async ({ root, companyUid }) => {
381
+ const [worktrees, real] = await Promise.all([
382
+ worktreeSiblings(root, true),
383
+ physicalAlias(root),
384
+ ]);
385
+ const paths = real ? [...worktrees, real] : [...worktrees];
386
+ const worktreeReals = await Promise.all(worktrees.map(physicalAlias));
387
+ for (const wr of worktreeReals) if (wr) paths.push(wr);
388
+ return { paths, companyUid };
389
+ }),
390
+ );
391
+
392
+ const allPaths = [
393
+ ...rootsToExpand.map((r) => r.root),
394
+ ...expansions.flatMap((e) => e.paths),
395
+ ];
396
+
397
+ // Probe the actual filesystem rather than trusting `process.platform`, over
398
+ // every path we may match under. Only paths that EXIST are probed: a bare
399
+ // repo name expands to both `repos/private` and `repos/public` and normally
400
+ // only one is real, so including the missing one would report
401
+ // case-sensitivity for a directory that is simply absent and wrongly force
402
+ // the whole map exact.
403
+ const existing = await Promise.all(
404
+ allPaths.map(async (p) => ((await physicalExists(p)) ? p : null)),
405
+ );
406
+ const foldsCase = await probeCaseFolding(existing.filter((p): p is string => p !== null));
407
+
408
+ // Collect claims first, resolve conflicts second. A path claimed by two
409
+ // DIFFERENT companies is ambiguous and must not be attributed to whichever
410
+ // one the manifest happened to list first.
411
+ const claims = new Map<string, { repoRoot: string; companyUids: Set<string> }>();
412
+ const claim = (absPath: string, companyUid: string): void => {
413
+ const repoRoot = normalizePath(absPath);
414
+ const matchKey = canonicalPath(repoRoot, foldsCase);
415
+ const existingClaim = claims.get(matchKey);
416
+ if (existingClaim) existingClaim.companyUids.add(companyUid);
417
+ else claims.set(matchKey, { repoRoot, companyUids: new Set([companyUid]) });
418
+ };
419
+ for (const { root, companyUid } of rootsToExpand) claim(root, companyUid);
420
+ for (const { paths, companyUid } of expansions) {
421
+ for (const p of paths) claim(p, companyUid);
422
+ }
423
+
424
+ const entries: Array<{ repoRoot: string; matchKey: string; companyUid: string }> = [];
425
+ const ambiguous = new Set<string>();
426
+ for (const [matchKey, { repoRoot, companyUids }] of claims) {
427
+ // Fail CLOSED on ambiguity. Two companies listing the same bare repo name,
428
+ // or two roots colliding under case folding, cannot be told apart here —
429
+ // and attributing to the wrong tenant is far worse than not attributing.
430
+ if (companyUids.size !== 1) {
431
+ ambiguous.add(matchKey);
432
+ continue;
433
+ }
434
+ entries.push({ repoRoot, matchKey, companyUid: [...companyUids][0] });
435
+ }
436
+
437
+ // Longest root first so the first containment match is the most specific
438
+ // (handles nested layouts where one repo path prefixes another — notably a
439
+ // worktree root, which must win over the company dir that contains it).
440
+ entries.sort((a, b) => b.matchKey.length - a.matchKey.length);
441
+ return { entries, bySlug, foldsCase, ambiguous };
135
442
  }
136
443
 
137
444
  /**
@@ -166,17 +473,49 @@ export function resolveCompanyForSkill(
166
473
  * that merely shares a string prefix (`<repoRoot>-other`) from matching.
167
474
  * Entries are pre-sorted longest-first, so the first match is the most
168
475
  * specific.
476
+ *
477
+ * If nothing matches, one lexical retry strips HQ's `-wt-<branch>` worktree
478
+ * suffix from the path. `buildRepoCompanyMap` discovers worktrees by reading
479
+ * the directory, which only sees the ones that exist RIGHT NOW — but telemetry
480
+ * is read from a cursor and a branch worktree is routinely deleted before the
481
+ * next pass. Those pending rows still carry the worktree cwd, and because a
482
+ * successful POST advances the cursor permanently, failing to resolve them
483
+ * loses that attribution for good. The suffix is documented convention, so
484
+ * deriving it from the recorded path needs no directory to still exist.
169
485
  */
170
486
  export function resolveCompanyForCwd(
171
487
  cwd: string | undefined,
172
488
  map: RepoCompanyMap,
173
489
  ): string | undefined {
174
490
  if (typeof cwd !== "string" || cwd.length === 0) return undefined;
175
- const c = normalizePath(cwd);
176
- for (const { repoRoot, companyUid } of map.entries) {
177
- if (c === repoRoot || c.startsWith(`${repoRoot}/`)) {
178
- return companyUid;
491
+ const foldsCase = map.foldsCase === true;
492
+ const c = canonicalPath(cwd, foldsCase);
493
+
494
+ const match = (candidate: string): string | undefined => {
495
+ for (const { matchKey, companyUid } of map.entries) {
496
+ if (candidate === matchKey || candidate.startsWith(`${matchKey}/`)) {
497
+ return companyUid;
498
+ }
499
+ }
500
+ return undefined;
501
+ };
502
+
503
+ const direct = match(c);
504
+ if (direct) return direct;
505
+
506
+ // The path may have been dropped for AMBIGUITY (claimed by two companies).
507
+ // Falling back would collapse e.g. an ambiguous `foo-wt-team` to `foo` and
508
+ // hand the event to whoever owns `foo`, silently undoing the fail-closed
509
+ // protection. An ambiguous path stays unattributed, full stop.
510
+ const ambiguous = map.ambiguous;
511
+ if (ambiguous) {
512
+ for (const key of ambiguous) {
513
+ if (c === key || c.startsWith(`${key}/`)) return undefined;
179
514
  }
180
515
  }
181
- return undefined;
516
+
517
+ // `<...>/hq-pro-wt-feature-x/src` → `<...>/hq-pro/src`. Only the FIRST such
518
+ // segment is rewritten; `-wt-` deeper in the path is left alone.
519
+ const collapsed = c.replace(/^(.*?\/[^/]+?)-wt-[^/]*(\/.*)?$/, "$1$2");
520
+ return collapsed !== c ? match(collapsed) : undefined;
182
521
  }
@@ -883,7 +883,7 @@ export async function collectAndSendSkillTelemetry(
883
883
  // When `hqRoot` is omitted the map is empty → every event stays unattributed.
884
884
  const repoCompanyMap: RepoCompanyMap = opts.hqRoot
885
885
  ? await buildRepoCompanyMap(opts.hqRoot)
886
- : { entries: [], bySlug: new Map() };
886
+ : { entries: [], bySlug: new Map(), foldsCase: false, ambiguous: new Set<string>() };
887
887
 
888
888
  // skillVersion resolution (US-015): resolve each skill's SKILL.md content hash
889
889
  // ONCE per run (skills repeat across a session) and stamp it onto every event.