@indigoai-us/hq-cloud 6.14.31 → 6.14.32

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.
@@ -103,7 +103,7 @@ describe("reindexAfterSync", () => {
103
103
  acquireReindexLock: noLock,
104
104
  existsSync: () => true,
105
105
  readCompanies: () => ["acme"],
106
- hasIndexableMarkdown: () => true,
106
+ hasIndexableMarkdown: (p) => p === path.join("/hq", "companies", "acme", "knowledge"),
107
107
  });
108
108
  expect(r.qmdAvailable).toBe(true);
109
109
  expect(r.collectionsAdded).toEqual(["acme"]);
@@ -126,7 +126,7 @@ describe("reindexAfterSync", () => {
126
126
  acquireReindexLock: noLock,
127
127
  existsSync: () => true,
128
128
  readCompanies: () => ["acme"],
129
- hasIndexableMarkdown: () => true,
129
+ hasIndexableMarkdown: (p) => p === path.join("/hq", "companies", "acme", "knowledge"),
130
130
  realpathSync: identityRealpath,
131
131
  });
132
132
  expect(r.collectionsAdded).toEqual([]);
@@ -156,7 +156,7 @@ describe("reindexAfterSync", () => {
156
156
  acquireReindexLock: noLock,
157
157
  existsSync: () => true,
158
158
  readCompanies: () => ["acme"],
159
- hasIndexableMarkdown: () => true,
159
+ hasIndexableMarkdown: (p) => p === path.join("/hq", "companies", "acme", "knowledge"),
160
160
  realpathSync: identityRealpath,
161
161
  env: {},
162
162
  embed: true,
@@ -183,7 +183,7 @@ describe("reindexAfterSync", () => {
183
183
  acquireReindexLock: noLock,
184
184
  existsSync: () => true,
185
185
  readCompanies: () => ["acme"],
186
- hasIndexableMarkdown: () => true,
186
+ hasIndexableMarkdown: (p) => p === path.join("/hq", "companies", "acme", "knowledge"),
187
187
  realpathSync: identityRealpath,
188
188
  env: { HQ_QMD_REPAIR_PATH_DRIFT: "1" },
189
189
  embed: true,
@@ -238,6 +238,184 @@ describe("reindexAfterSync", () => {
238
238
  expect(calls).toEqual([["status"], ["collection", "list"], ["update"]]);
239
239
  });
240
240
 
241
+ it("registers populated company projects with the shell name and mask", () => {
242
+ const { exec, calls } = fakeExec({ listStdout: "" });
243
+ const r = reindexAfterSync("/hq", {
244
+ exec,
245
+ acquireReindexLock: noLock,
246
+ existsSync: () => true,
247
+ readCompanies: () => ["acme"],
248
+ hasIndexableMarkdown: () => false,
249
+ hasIndexableProjectContent: () => true,
250
+ });
251
+
252
+ expect(r.collectionsAdded).toEqual(["acme-projects"]);
253
+ expect(calls).toEqual([
254
+ ["status"],
255
+ ["collection", "list"],
256
+ [
257
+ "collection",
258
+ "add",
259
+ path.join("/hq", "companies", "acme", "projects"),
260
+ "--name",
261
+ "acme-projects",
262
+ "--mask",
263
+ "**/*.{md,json}",
264
+ ],
265
+ ["context", "add", "qmd://acme-projects", "Project PRDs and documentation for acme."],
266
+ ["update"],
267
+ ]);
268
+ });
269
+
270
+ it("skips missing and empty company projects dirs", () => {
271
+ const { exec, calls } = fakeExec({ listStdout: "" });
272
+ const emptyProjects = path.join("/hq", "companies", "empty", "projects");
273
+ const r = reindexAfterSync("/hq", {
274
+ exec,
275
+ acquireReindexLock: noLock,
276
+ existsSync: (p) => p !== path.join("/hq", "companies", "missing", "projects"),
277
+ readCompanies: () => ["missing", "empty"],
278
+ hasIndexableMarkdown: () => false,
279
+ hasIndexableProjectContent: (p) => p !== emptyProjects,
280
+ });
281
+
282
+ expect(r.collectionsAdded).toEqual([]);
283
+ expect(calls).toEqual([["status"], ["collection", "list"], ["update"]]);
284
+ });
285
+
286
+ it("registers populated personal knowledge with the shell name and mask", () => {
287
+ const { exec, calls } = fakeExec({ listStdout: "" });
288
+ const personalKnowledge = path.join("/hq", "personal", "knowledge");
289
+ const r = reindexAfterSync("/hq", {
290
+ exec,
291
+ acquireReindexLock: noLock,
292
+ existsSync: () => true,
293
+ readCompanies: () => [],
294
+ hasIndexableMarkdown: (p) => p === personalKnowledge,
295
+ });
296
+
297
+ expect(r.collectionsAdded).toEqual(["personal-knowledge"]);
298
+ expect(calls).toEqual([
299
+ ["status"],
300
+ ["collection", "list"],
301
+ [
302
+ "collection",
303
+ "add",
304
+ personalKnowledge,
305
+ "--name",
306
+ "personal-knowledge",
307
+ "--mask",
308
+ "**/*.md",
309
+ ],
310
+ ["context", "add", "qmd://personal-knowledge", "Personal knowledge base (owner overlay)."],
311
+ ["update"],
312
+ ]);
313
+ });
314
+
315
+ it("skips absent and empty personal knowledge", () => {
316
+ const absent = fakeExec({ listStdout: "" });
317
+ const absentResult = reindexAfterSync("/hq", {
318
+ exec: absent.exec,
319
+ acquireReindexLock: noLock,
320
+ existsSync: (p) => p !== path.join("/hq", "personal", "knowledge"),
321
+ readCompanies: () => [],
322
+ });
323
+ expect(absentResult.collectionsAdded).toEqual([]);
324
+ expect(absent.calls).toEqual([["status"], ["collection", "list"], ["update"]]);
325
+
326
+ const empty = fakeExec({ listStdout: "" });
327
+ const emptyResult = reindexAfterSync("/hq", {
328
+ exec: empty.exec,
329
+ acquireReindexLock: noLock,
330
+ existsSync: () => true,
331
+ readCompanies: () => [],
332
+ hasIndexableMarkdown: () => false,
333
+ });
334
+ expect(emptyResult.collectionsAdded).toEqual([]);
335
+ expect(empty.calls).toEqual([["status"], ["collection", "list"], ["update"]]);
336
+ });
337
+
338
+ it("does not add projects or personal knowledge a second time", () => {
339
+ let listCount = 0;
340
+ const { exec, calls } = fakeExec({
341
+ resultFor: (args) => {
342
+ if (args[0] !== "collection" || args[1] !== "list") return undefined;
343
+ listCount++;
344
+ return {
345
+ status: 0,
346
+ stdout:
347
+ listCount === 1
348
+ ? ""
349
+ : "acme (qmd://acme/)\nacme-projects (qmd://acme-projects/)\npersonal-knowledge (qmd://personal-knowledge/)\n",
350
+ };
351
+ },
352
+ });
353
+ const options = {
354
+ exec,
355
+ acquireReindexLock: noLock,
356
+ existsSync: () => true,
357
+ readCompanies: () => ["acme"],
358
+ hasIndexableMarkdown: () => true,
359
+ hasIndexableProjectContent: () => true,
360
+ realpathSync: identityRealpath,
361
+ };
362
+
363
+ expect(reindexAfterSync("/hq", options).collectionsAdded).toEqual([
364
+ "acme",
365
+ "acme-projects",
366
+ "personal-knowledge",
367
+ ]);
368
+ expect(reindexAfterSync("/hq", options).collectionsAdded).toEqual([]);
369
+ expect(calls.filter((c) => c[0] === "collection" && c[1] === "add")).toHaveLength(3);
370
+ });
371
+
372
+ it("emits the same collection-add commands as the shell for a fixture tree", () => {
373
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-parity-"));
374
+ try {
375
+ const hq = path.join(dir, "hq");
376
+ const acmeKnowledge = path.join(hq, "companies", "acme", "knowledge");
377
+ const acmeProjects = path.join(hq, "companies", "acme", "projects");
378
+ const emptyProjects = path.join(hq, "companies", "empty", "projects");
379
+ const personalKnowledge = path.join(hq, "personal", "knowledge");
380
+ fs.mkdirSync(path.join(hq, "core"), { recursive: true });
381
+ fs.mkdirSync(acmeKnowledge, { recursive: true });
382
+ fs.mkdirSync(acmeProjects, { recursive: true });
383
+ fs.mkdirSync(emptyProjects, { recursive: true });
384
+ fs.mkdirSync(personalKnowledge, { recursive: true });
385
+ fs.writeFileSync(path.join(hq, "core", "core.yaml"), "hqVersion: 1\n");
386
+ fs.writeFileSync(path.join(acmeKnowledge, "note.md"), "# knowledge\n");
387
+ fs.writeFileSync(path.join(acmeProjects, "prd.json"), "{}\n");
388
+ fs.writeFileSync(path.join(personalKnowledge, "note.md"), "# personal\n");
389
+
390
+ const { exec, calls } = fakeExec({ listStdout: "" });
391
+ reindexAfterSync(hq, { exec, acquireReindexLock: noLock });
392
+
393
+ expect(calls.filter((c) => c[0] === "collection" && c[1] === "add")).toEqual([
394
+ ["collection", "add", acmeKnowledge, "--name", "acme", "--mask", "**/*.md"],
395
+ [
396
+ "collection",
397
+ "add",
398
+ acmeProjects,
399
+ "--name",
400
+ "acme-projects",
401
+ "--mask",
402
+ "**/*.{md,json}",
403
+ ],
404
+ [
405
+ "collection",
406
+ "add",
407
+ personalKnowledge,
408
+ "--name",
409
+ "personal-knowledge",
410
+ "--mask",
411
+ "**/*.md",
412
+ ],
413
+ ]);
414
+ } finally {
415
+ fs.rmSync(dir, { recursive: true, force: true });
416
+ }
417
+ });
418
+
241
419
  it("skips qmd entirely when provided changed paths do not touch knowledge markdown", () => {
242
420
  const { exec, calls } = fakeExec({ listStdout: "" });
243
421
  const r = reindexAfterSync("/hq", {
@@ -262,7 +440,7 @@ describe("reindexAfterSync", () => {
262
440
  acquireReindexLock: noLock,
263
441
  existsSync: () => true,
264
442
  readCompanies: () => ["acme"],
265
- hasIndexableMarkdown: () => true,
443
+ hasIndexableMarkdown: (p) => p === path.join("/hq", "companies", "acme", "knowledge"),
266
444
  changedPaths: ["companies/acme/knowledge/note.md"],
267
445
  debounceMs: 60_000,
268
446
  nowMs: 100_000,
@@ -15,9 +15,10 @@
15
15
  * script inside the synced HQ tree (which may be stale).
16
16
  *
17
17
  * What it does, best-effort and idempotent:
18
- * 1. Auto-registers any `companies/<slug>/knowledge` dir that isn't yet a
19
- * qmd collection (kills the manual "map" step). Detects path drift when
20
- * the name exists but points elsewhere; repairs only when
18
+ * 1. Auto-registers populated company knowledge, company project, and
19
+ * personal knowledge dirs that aren't yet qmd collections (kills the
20
+ * manual "map" step). Company knowledge detects path drift when the name
21
+ * exists but points elsewhere; repairs only when
21
22
  * `HQ_QMD_REPAIR_PATH_DRIFT=1` (non-breaking default: detect-only).
22
23
  * 2. Runs an incremental lexical `qmd update` (fast — qmd skips unchanged
23
24
  * files by mtime).
@@ -395,6 +396,8 @@ export interface ReindexOptions {
395
396
  readCompanies?: (companiesDir: string) => string[];
396
397
  /** Returns true if the knowledge dir has at least one indexable .md file. */
397
398
  hasIndexableMarkdown?: (knowledgeDir: string) => boolean;
399
+ /** Returns true if the projects dir has at least one indexable .md or .json file. */
400
+ hasIndexableProjectContent?: (projectsDir: string) => boolean;
398
401
  /** Reindex-lock acquirer override for tests. */
399
402
  acquireReindexLock?: AcquireReindexLock;
400
403
  /** Corrupt-index quarantine override for tests. */
@@ -474,7 +477,7 @@ export function reindexAfterSync(
474
477
  const changedPaths = opts.changedPaths;
475
478
  const dirtyFromChanges =
476
479
  changedPaths === undefined ||
477
- changedPaths.some(isKnowledgeMarkdownPath);
480
+ changedPaths.some(isQmdIndexableContentPath);
478
481
  const registrationMayBeStale =
479
482
  opts.forceCollectionRefresh === true ||
480
483
  changedPaths === undefined ||
@@ -528,6 +531,9 @@ export function reindexAfterSync(
528
531
  result.qmdAvailable = true;
529
532
  const existingCollections = list.stdout;
530
533
  let repairedThisCycle = false;
534
+ const hasIndexableMarkdown = opts.hasIndexableMarkdown ?? defaultHasIndexableMarkdown;
535
+ const hasIndexableProjectContent =
536
+ opts.hasIndexableProjectContent ?? defaultHasIndexableProjectContent;
531
537
 
532
538
  // 1. Auto-register missing / reconcile drifted company knowledge collections.
533
539
  if (registrationMayBeStale) {
@@ -537,7 +543,7 @@ export function reindexAfterSync(
537
543
  for (const slug of slugs) {
538
544
  const knowledgeDir = path.join(companiesDir, slug, "knowledge");
539
545
  if (!existsSync(knowledgeDir)) continue;
540
- const hasMd = (opts.hasIndexableMarkdown ?? defaultHasIndexableMarkdown)(knowledgeDir);
546
+ const hasMd = hasIndexableMarkdown(knowledgeDir);
541
547
  if (!hasMd) continue;
542
548
 
543
549
  const namePresent = existingCollections.includes(`qmd://${slug}/`);
@@ -601,6 +607,58 @@ export function reindexAfterSync(
601
607
  result.collectionsRepaired.push(slug);
602
608
  repairedThisCycle = true;
603
609
  }
610
+
611
+ // 1b. Auto-register company projects collections. Match the shell
612
+ // post-sync convention exactly so prd.json and project docs are searchable.
613
+ for (const slug of slugs) {
614
+ const projectsDir = path.join(companiesDir, slug, "projects");
615
+ if (!existsSync(projectsDir)) continue;
616
+ if (!hasIndexableProjectContent(projectsDir)) continue;
617
+
618
+ const name = `${slug}-projects`;
619
+ if (existingCollections.includes(`qmd://${name}/`)) continue;
620
+ const add = exec([
621
+ "collection",
622
+ "add",
623
+ projectsDir,
624
+ "--name",
625
+ name,
626
+ "--mask",
627
+ "**/*.{md,json}",
628
+ ]);
629
+ if (add.status === 0) {
630
+ exec(["context", "add", `qmd://${name}`, `Project PRDs and documentation for ${slug}.`]);
631
+ result.collectionsAdded.push(name);
632
+ }
633
+ }
634
+
635
+ // 1c. Auto-register the owner overlay's personal knowledge collection.
636
+ const personalKnowledgeDir = path.join(hqRoot, "personal", "knowledge");
637
+ const personalKnowledgeName = "personal-knowledge";
638
+ if (
639
+ existsSync(personalKnowledgeDir) &&
640
+ hasIndexableMarkdown(personalKnowledgeDir) &&
641
+ !existingCollections.includes(`qmd://${personalKnowledgeName}/`)
642
+ ) {
643
+ const add = exec([
644
+ "collection",
645
+ "add",
646
+ personalKnowledgeDir,
647
+ "--name",
648
+ personalKnowledgeName,
649
+ "--mask",
650
+ "**/*.md",
651
+ ]);
652
+ if (add.status === 0) {
653
+ exec([
654
+ "context",
655
+ "add",
656
+ `qmd://${personalKnowledgeName}`,
657
+ "Personal knowledge base (owner overlay).",
658
+ ]);
659
+ result.collectionsAdded.push(personalKnowledgeName);
660
+ }
661
+ }
604
662
  }
605
663
 
606
664
  // 2. Incremental lexical reindex.
@@ -721,9 +779,13 @@ function writeState(statePath: string, state: QmdReindexState): void {
721
779
  }
722
780
  }
723
781
 
724
- function isKnowledgeMarkdownPath(relPath: string): boolean {
782
+ function isQmdIndexableContentPath(relPath: string): boolean {
725
783
  const normalized = relPath.split(path.sep).join("/");
726
- return /^companies\/[^/]+\/knowledge\/.+\.md$/i.test(normalized);
784
+ return (
785
+ /^companies\/[^/]+\/knowledge\/.+\.md$/i.test(normalized) ||
786
+ /^companies\/[^/]+\/projects\/.+\.(?:md|json)$/i.test(normalized) ||
787
+ /^personal\/knowledge\/.+\.md$/i.test(normalized)
788
+ );
727
789
  }
728
790
 
729
791
  function defaultReadCompanies(companiesDir: string): string[] {
@@ -758,3 +820,23 @@ function defaultHasIndexableMarkdown(knowledgeDir: string): boolean {
758
820
  }
759
821
  return false;
760
822
  }
823
+
824
+ function defaultHasIndexableProjectContent(projectsDir: string): boolean {
825
+ // Match the shell's `find -type f \( -name '*.md' -o -name '*.json' \)`
826
+ // guard: project documentation and PRD JSON both make the collection useful.
827
+ const stack = [projectsDir];
828
+ while (stack.length) {
829
+ const dir = stack.pop()!;
830
+ let entries: fs.Dirent[];
831
+ try {
832
+ entries = fs.readdirSync(dir, { withFileTypes: true });
833
+ } catch {
834
+ continue;
835
+ }
836
+ for (const e of entries) {
837
+ if (e.isFile() && (e.name.endsWith(".md") || e.name.endsWith(".json"))) return true;
838
+ if (e.isDirectory()) stack.push(path.join(dir, e.name));
839
+ }
840
+ }
841
+ return false;
842
+ }
@@ -978,6 +978,35 @@ describe("collectAndSendTelemetry — companyUid attribution", () => {
978
978
  expect("companyUid" in client.posts[0].events[0]).toBe(false);
979
979
  });
980
980
 
981
+ it("uses the explicit single-company fallback when cwd is the shared HQ root", async () => {
982
+ const client = makeClient();
983
+ writeJsonl(env, "proj", "s.jsonl", [rowWithCwd("u1", hqRoot)]);
984
+
985
+ await collectAndSendTelemetry({
986
+ ...makeOpts(env, client),
987
+ hqRoot,
988
+ fallbackCompany: "indigo",
989
+ });
990
+
991
+ expect(client.posts).toHaveLength(1);
992
+ expect(client.posts[0].events[0].companyUid).toBe(COMPANY_UID);
993
+ });
994
+
995
+ it("prefers the cwd-resolved company over the single-company fallback", async () => {
996
+ const client = makeClient();
997
+ const inRepo = path.join(hqRoot, "repos/private/hq-cloud");
998
+ writeJsonl(env, "proj", "s.jsonl", [rowWithCwd("u1", inRepo)]);
999
+
1000
+ await collectAndSendTelemetry({
1001
+ ...makeOpts(env, client),
1002
+ hqRoot,
1003
+ fallbackCompany: "cmp_01OTHER",
1004
+ });
1005
+
1006
+ expect(client.posts).toHaveLength(1);
1007
+ expect(client.posts[0].events[0].companyUid).toBe(COMPANY_UID);
1008
+ });
1009
+
981
1010
  it("never sends the reserved 'unattributed' value", async () => {
982
1011
  const client = makeClient();
983
1012
  writeJsonl(env, "proj", "s.jsonl", [
package/src/telemetry.ts CHANGED
@@ -73,6 +73,12 @@ export interface CollectTelemetryOptions {
73
73
  * server treats the event as unattributed/personal.
74
74
  */
75
75
  hqRoot?: string;
76
+ /**
77
+ * Explicit single-company sync scope (`--company <slug-or-uid>`). Used only
78
+ * when an event cwd does not resolve through the manifest. This is intentionally
79
+ * absent for multi-company and personal runs to prevent cross-tenant attribution.
80
+ */
81
+ fallbackCompany?: string;
76
82
  /** Override `~/.claude/projects` for tests. */
77
83
  claudeProjectsRoot?: string;
78
84
  /** Override `~/.codex` for tests. Both live and archived rollouts are scanned. */
@@ -687,6 +693,11 @@ export async function collectAndSendTelemetry(
687
693
  const repoCompanyMap: RepoCompanyMap = opts.hqRoot
688
694
  ? await buildRepoCompanyMap(opts.hqRoot)
689
695
  : { entries: [], bySlug: new Map(), foldsCase: false, ambiguous: new Set<string>() };
696
+ const fallbackCompanyUid = opts.fallbackCompany?.startsWith("cmp_")
697
+ ? opts.fallbackCompany
698
+ : opts.fallbackCompany
699
+ ? repoCompanyMap.bySlug.get(opts.fallbackCompany)
700
+ : undefined;
690
701
 
691
702
  // 1. Opt-in check (server-authoritative, with local fallback + self-heal).
692
703
  let enabled: boolean;
@@ -1012,10 +1023,11 @@ export async function collectAndSendTelemetry(
1012
1023
  sourceRow && typeof sourceRow === "object" && !Array.isArray(sourceRow)
1013
1024
  ? (sourceRow as Record<string, unknown>).cwd
1014
1025
  : undefined;
1015
- const companyUid = resolveCompanyForCwd(
1016
- typeof rowCwd === "string" ? rowCwd : undefined,
1017
- repoCompanyMap,
1018
- );
1026
+ const companyUid =
1027
+ resolveCompanyForCwd(
1028
+ typeof rowCwd === "string" ? rowCwd : undefined,
1029
+ repoCompanyMap,
1030
+ ) ?? fallbackCompanyUid;
1019
1031
  const sanitized = sanitizeRow(sourceRow, companyUid);
1020
1032
  if (!sanitized) {
1021
1033
  batchSources.push(source());