@indigoai-us/hq-cloud 6.11.9 → 6.11.10

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 (48) hide show
  1. package/dist/bin/sync-runner.d.ts.map +1 -1
  2. package/dist/bin/sync-runner.js +4 -0
  3. package/dist/bin/sync-runner.js.map +1 -1
  4. package/dist/bin/sync-runner.test.js +72 -0
  5. package/dist/bin/sync-runner.test.js.map +1 -1
  6. package/dist/cli/reindex.test.js +44 -0
  7. package/dist/cli/reindex.test.js.map +1 -1
  8. package/dist/cli/rescue-classify-ordering.test.js +25 -0
  9. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  10. package/dist/company-resolver.d.ts +77 -0
  11. package/dist/company-resolver.d.ts.map +1 -0
  12. package/dist/company-resolver.js +124 -0
  13. package/dist/company-resolver.js.map +1 -0
  14. package/dist/company-resolver.test.d.ts +7 -0
  15. package/dist/company-resolver.test.d.ts.map +1 -0
  16. package/dist/company-resolver.test.js +120 -0
  17. package/dist/company-resolver.test.js.map +1 -0
  18. package/dist/skill-telemetry.d.ts.map +1 -1
  19. package/dist/skill-telemetry.js +22 -3
  20. package/dist/skill-telemetry.js.map +1 -1
  21. package/dist/skill-telemetry.test.js +101 -1
  22. package/dist/skill-telemetry.test.js.map +1 -1
  23. package/dist/sync/pull-scope.d.ts +1 -0
  24. package/dist/sync/pull-scope.d.ts.map +1 -1
  25. package/dist/sync/pull-scope.js +26 -0
  26. package/dist/sync/pull-scope.js.map +1 -1
  27. package/dist/telemetry.d.ts +18 -1
  28. package/dist/telemetry.d.ts.map +1 -1
  29. package/dist/telemetry.js +28 -2
  30. package/dist/telemetry.js.map +1 -1
  31. package/dist/telemetry.test.js +93 -1
  32. package/dist/telemetry.test.js.map +1 -1
  33. package/dist/vault-client.d.ts +4 -2
  34. package/dist/vault-client.d.ts.map +1 -1
  35. package/dist/vault-client.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/bin/sync-runner.test.ts +90 -0
  38. package/src/bin/sync-runner.ts +4 -0
  39. package/src/cli/reindex.test.ts +53 -0
  40. package/src/cli/rescue-classify-ordering.test.ts +28 -0
  41. package/src/company-resolver.test.ts +136 -0
  42. package/src/company-resolver.ts +147 -0
  43. package/src/skill-telemetry.test.ts +126 -1
  44. package/src/skill-telemetry.ts +26 -3
  45. package/src/sync/pull-scope.ts +26 -1
  46. package/src/telemetry.test.ts +118 -1
  47. package/src/telemetry.ts +50 -2
  48. package/src/vault-client.ts +4 -2
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Tests for the cwd → owning-company resolver (surface-hq-console-telemetry
3
+ * US-002). Covers manifest parsing, cmp_* extraction, longest-prefix matching,
4
+ * the trailing-slash sibling boundary, and the unattributed (no-match) path.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
8
+ import * as fs from "fs";
9
+ import * as os from "os";
10
+ import * as path from "path";
11
+ import {
12
+ buildRepoCompanyMap,
13
+ resolveCompanyForCwd,
14
+ } from "./company-resolver.js";
15
+
16
+ const INDIGO = "cmp_01INDIGO";
17
+ const HPO = "cmp_01HPO";
18
+
19
+ const MANIFEST = `companies:
20
+ personal:
21
+ name: Personal
22
+ repos: []
23
+ indigo:
24
+ name: Indigo
25
+ repos:
26
+ - repos/private/hq-cloud
27
+ - repos/public/hq-core
28
+ cloud_uid: ${INDIGO}
29
+ hpo:
30
+ name: HPO
31
+ repos:
32
+ - repos/private/hpo-jobs
33
+ cloud_uid: ${HPO}
34
+ liverecover:
35
+ name: LiveRecover
36
+ repos:
37
+ - repos/private/liverecover-site
38
+ `;
39
+
40
+ function setupHqRoot(manifest: string = MANIFEST): string {
41
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-resolver-test-"));
42
+ fs.mkdirSync(path.join(root, "companies"), { recursive: true });
43
+ fs.writeFileSync(path.join(root, "companies", "manifest.yaml"), manifest);
44
+ return root;
45
+ }
46
+
47
+ describe("buildRepoCompanyMap + resolveCompanyForCwd", () => {
48
+ let hqRoot: string;
49
+ afterEach(() => {
50
+ if (hqRoot) fs.rmSync(hqRoot, { recursive: true, force: true });
51
+ });
52
+
53
+ it("resolves a cwd inside a company repo to that company's cmp_ uid", async () => {
54
+ hqRoot = setupHqRoot();
55
+ const map = await buildRepoCompanyMap(hqRoot);
56
+ const cwd = path.join(hqRoot, "repos/private/hq-cloud/src");
57
+ expect(resolveCompanyForCwd(cwd, map)).toBe(INDIGO);
58
+ });
59
+
60
+ it("resolves the repo root itself (exact match, no trailing slash)", async () => {
61
+ hqRoot = setupHqRoot();
62
+ const map = await buildRepoCompanyMap(hqRoot);
63
+ const cwd = path.join(hqRoot, "repos/public/hq-core");
64
+ expect(resolveCompanyForCwd(cwd, map)).toBe(INDIGO);
65
+ });
66
+
67
+ it("maps each repo to its own company", async () => {
68
+ hqRoot = setupHqRoot();
69
+ const map = await buildRepoCompanyMap(hqRoot);
70
+ expect(resolveCompanyForCwd(path.join(hqRoot, "repos/private/hpo-jobs/x"), map)).toBe(HPO);
71
+ });
72
+
73
+ it("returns undefined for a cwd outside any company repo (unattributed)", async () => {
74
+ hqRoot = setupHqRoot();
75
+ const map = await buildRepoCompanyMap(hqRoot);
76
+ expect(resolveCompanyForCwd("/Users/x/random-repo", map)).toBeUndefined();
77
+ expect(resolveCompanyForCwd(path.join(hqRoot, "companies/indigo"), map)).toBeUndefined();
78
+ });
79
+
80
+ it("does NOT attribute a company that lacks a cloud_uid (not cloud-backed)", async () => {
81
+ hqRoot = setupHqRoot();
82
+ const map = await buildRepoCompanyMap(hqRoot);
83
+ // liverecover-site has no cloud_uid → unattributed.
84
+ expect(resolveCompanyForCwd(path.join(hqRoot, "repos/private/liverecover-site"), map)).toBeUndefined();
85
+ });
86
+
87
+ it("does not match a sibling that merely shares a path prefix", async () => {
88
+ hqRoot = setupHqRoot();
89
+ const map = await buildRepoCompanyMap(hqRoot);
90
+ // `<repo>-other` shares the string prefix but must NOT match (slash boundary).
91
+ expect(resolveCompanyForCwd(path.join(hqRoot, "repos/private/hq-cloud-other"), map)).toBeUndefined();
92
+ });
93
+
94
+ it("handles a trailing slash on the cwd", async () => {
95
+ hqRoot = setupHqRoot();
96
+ const map = await buildRepoCompanyMap(hqRoot);
97
+ expect(resolveCompanyForCwd(path.join(hqRoot, "repos/private/hq-cloud") + "/", map)).toBe(INDIGO);
98
+ });
99
+
100
+ it("returns undefined for an undefined/empty cwd", async () => {
101
+ hqRoot = setupHqRoot();
102
+ const map = await buildRepoCompanyMap(hqRoot);
103
+ expect(resolveCompanyForCwd(undefined, map)).toBeUndefined();
104
+ expect(resolveCompanyForCwd("", map)).toBeUndefined();
105
+ });
106
+
107
+ it("never returns the reserved 'unattributed' sentinel", async () => {
108
+ hqRoot = setupHqRoot(`companies:
109
+ bad:
110
+ repos:
111
+ - repos/x
112
+ cloud_uid: unattributed
113
+ `);
114
+ const map = await buildRepoCompanyMap(hqRoot);
115
+ // `unattributed` is not a cmp_* uid → not added to the map → no match.
116
+ expect(map.entries).toHaveLength(0);
117
+ expect(resolveCompanyForCwd(path.join(hqRoot, "repos/x"), map)).toBeUndefined();
118
+ });
119
+
120
+ it("yields an empty map for a missing manifest", async () => {
121
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-resolver-empty-"));
122
+ try {
123
+ const map = await buildRepoCompanyMap(root);
124
+ expect(map.entries).toHaveLength(0);
125
+ expect(resolveCompanyForCwd(path.join(root, "repos/private/hq-cloud"), map)).toBeUndefined();
126
+ } finally {
127
+ fs.rmSync(root, { recursive: true, force: true });
128
+ }
129
+ });
130
+
131
+ it("yields an empty map for an unparseable manifest", async () => {
132
+ hqRoot = setupHqRoot(":\n not: [valid: yaml");
133
+ const map = await buildRepoCompanyMap(hqRoot);
134
+ expect(map.entries).toHaveLength(0);
135
+ });
136
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * cwd → owning-company resolution for telemetry edge attribution
3
+ * (surface-hq-console-telemetry US-002).
4
+ *
5
+ * Both telemetry collectors (`./telemetry.ts`, `./skill-telemetry.ts`) stamp an
6
+ * optional `companyUid` on each event so the server can attribute usage to the
7
+ * company that owns the repo the event was produced in. The mapping comes from
8
+ * `<hqRoot>/companies/manifest.yaml`, whose shape is:
9
+ *
10
+ * companies:
11
+ * indigo:
12
+ * repos:
13
+ * - repos/private/hq-cloud
14
+ * - repos/public/hq-core
15
+ * cloud_uid: cmp_01KQWRSQTCTES1SEFKXKDZAAK1
16
+ * liverecover:
17
+ * repos:
18
+ * - repos/private/liverecover-site
19
+ * # (no cloud_uid — not cloud-backed)
20
+ *
21
+ * companyUid source: the value stamped is the manifest's `cloud_uid`. That is a
22
+ * `cmp_*` entity uid — the SAME value the server membership-keys on
23
+ * (`membershipKey = '<personUid>#<companyUid>'`, where companyUid is the cmp_*
24
+ * uid; see hq-pro `vault-service/handlers/usage.ts` + `skill-invocations.ts`).
25
+ * So no slug→uid round-trip is needed: the manifest already carries the uid the
26
+ * server expects. A company entry WITHOUT a `cloud_uid` (not cloud-backed, e.g.
27
+ * `liverecover`/`temple`) contributes no mapping — its repos resolve to no
28
+ * companyUid and the event is sent unattributed (field omitted).
29
+ *
30
+ * The reserved sentinel `unattributed` is NEVER produced here — "no match"
31
+ * means "return undefined / omit the field", which the server treats as
32
+ * unattributed/personal.
33
+ *
34
+ * Cost: `buildRepoCompanyMap()` parses the manifest ONCE per sync run; the
35
+ * resulting map is reused for every event via `resolveCompanyForCwd()`. No
36
+ * per-event manifest reads.
37
+ *
38
+ * Privacy: this module only ever returns a `cmp_*` uid (or undefined). It never
39
+ * adds a raw path to any payload — the collectors add exactly one new field,
40
+ * `companyUid`.
41
+ */
42
+
43
+ import { promises as fs } from "node:fs";
44
+ import * as path from "node:path";
45
+
46
+ import yaml from "js-yaml";
47
+
48
+ /**
49
+ * A repo-path → companyUid lookup, built once per run. Keys are ABSOLUTE,
50
+ * normalized repo roots (`<hqRoot>/repos/private/hq-cloud`); values are the
51
+ * owning company's `cmp_*` uid. An event `cwd` is attributed to the entry whose
52
+ * repo root is the longest prefix of (or equal to) the cwd.
53
+ */
54
+ export interface RepoCompanyMap {
55
+ /** Absolute, normalized repo root → owning company `cmp_*` uid. */
56
+ entries: Array<{ repoRoot: string; companyUid: string }>;
57
+ }
58
+
59
+ interface ManifestCompany {
60
+ repos?: unknown;
61
+ cloud_uid?: unknown;
62
+ }
63
+
64
+ interface ManifestShape {
65
+ companies?: Record<string, ManifestCompany>;
66
+ }
67
+
68
+ /** Drop a trailing slash (keeping a bare "/"). Mirrors the scope-path
69
+ * normalization in `./skill-telemetry.ts` so prefix matching is consistent. */
70
+ function normalizePath(p: string): string {
71
+ return p.length > 1 ? p.replace(/\/+$/, "") : p;
72
+ }
73
+
74
+ /**
75
+ * Parse `<hqRoot>/companies/manifest.yaml` ONCE and build the repo-path →
76
+ * companyUid lookup. Best-effort: a missing/unparseable manifest yields an
77
+ * empty map (every event then resolves to no company → unattributed), so a
78
+ * telemetry run never fails on a manifest problem.
79
+ *
80
+ * Only companies that carry a `cloud_uid` (`cmp_*`) contribute entries — a
81
+ * company without one is not cloud-backed and its repos stay unattributed.
82
+ */
83
+ export async function buildRepoCompanyMap(hqRoot: string): Promise<RepoCompanyMap> {
84
+ const manifestPath = path.join(hqRoot, "companies", "manifest.yaml");
85
+ let doc: ManifestShape;
86
+ try {
87
+ const raw = await fs.readFile(manifestPath, "utf-8");
88
+ const parsed = yaml.load(raw);
89
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
90
+ return { entries: [] };
91
+ }
92
+ doc = parsed as ManifestShape;
93
+ } catch {
94
+ // Missing / unreadable / unparseable — no attribution this run.
95
+ return { entries: [] };
96
+ }
97
+
98
+ const companies = doc.companies;
99
+ if (!companies || typeof companies !== "object") return { entries: [] };
100
+
101
+ const entries: Array<{ repoRoot: string; companyUid: string }> = [];
102
+ for (const company of Object.values(companies)) {
103
+ if (!company || typeof company !== "object") continue;
104
+ const cloudUid = company.cloud_uid;
105
+ // Only cloud-backed companies (with a cmp_* uid) attribute events.
106
+ if (typeof cloudUid !== "string" || !cloudUid.startsWith("cmp_")) continue;
107
+ const repos = company.repos;
108
+ if (!Array.isArray(repos)) continue;
109
+ for (const repo of repos) {
110
+ if (typeof repo !== "string" || repo.trim().length === 0) continue;
111
+ // Manifest repo paths are relative to the HQ root (e.g.
112
+ // `repos/private/hq-cloud`). Resolve to an absolute, normalized root.
113
+ const repoRoot = normalizePath(path.resolve(hqRoot, repo));
114
+ entries.push({ repoRoot, companyUid: cloudUid });
115
+ }
116
+ }
117
+
118
+ // Longest repoRoot first so the first containment match is the most specific
119
+ // (handles nested repo layouts where one repo path prefixes another).
120
+ entries.sort((a, b) => b.repoRoot.length - a.repoRoot.length);
121
+ return { entries };
122
+ }
123
+
124
+ /**
125
+ * Resolve an event's `cwd` to the owning company's `cmp_*` uid, or `undefined`
126
+ * when the cwd is inside no cloud-backed company repo (→ omit `companyUid`;
127
+ * the server treats absence as unattributed/personal).
128
+ *
129
+ * Matching is by path containment: the cwd must equal a repo root or sit
130
+ * beneath it (`<repoRoot>/...`). A trailing-slash boundary prevents a sibling
131
+ * that merely shares a string prefix (`<repoRoot>-other`) from matching.
132
+ * Entries are pre-sorted longest-first, so the first match is the most
133
+ * specific.
134
+ */
135
+ export function resolveCompanyForCwd(
136
+ cwd: string | undefined,
137
+ map: RepoCompanyMap,
138
+ ): string | undefined {
139
+ if (typeof cwd !== "string" || cwd.length === 0) return undefined;
140
+ const c = normalizePath(cwd);
141
+ for (const { repoRoot, companyUid } of map.entries) {
142
+ if (c === repoRoot || c.startsWith(`${repoRoot}/`)) {
143
+ return companyUid;
144
+ }
145
+ }
146
+ return undefined;
147
+ }
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi } from "vitest";
2
2
  import { promises as fs } from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
@@ -923,3 +923,128 @@ describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
923
923
  await fs.rm(tmp, { recursive: true, force: true });
924
924
  });
925
925
  });
926
+
927
+ // ── companyUid edge attribution (US-002) ────────────────────────────────────────
928
+
929
+ describe("collectAndSendSkillTelemetry — companyUid attribution", () => {
930
+ const COMPANY_UID = "cmp_01INDIGOSKILL";
931
+
932
+ function stubClient(captured: SkillInvocationBatch[]) {
933
+ return {
934
+ async getTelemetryOptIn() {
935
+ return { enabled: true, updatedAt: null };
936
+ },
937
+ async postSkillInvocations(batch: SkillInvocationBatch) {
938
+ captured.push(batch);
939
+ return { ok: true, written: batch.events.length, skipped: [] };
940
+ },
941
+ };
942
+ }
943
+
944
+ function row(obj: Record<string, unknown>): string {
945
+ return JSON.stringify(obj);
946
+ }
947
+
948
+ /** Write a manifest mapping `repos/private/hq-cloud` → COMPANY_UID under hqRoot. */
949
+ async function writeManifest(hqRoot: string): Promise<void> {
950
+ await fs.mkdir(path.join(hqRoot, "companies"), { recursive: true });
951
+ await fs.writeFile(
952
+ path.join(hqRoot, "companies", "manifest.yaml"),
953
+ `companies:\n indigo:\n repos:\n - repos/private/hq-cloud\n cloud_uid: ${COMPANY_UID}\n`,
954
+ "utf-8",
955
+ );
956
+ }
957
+
958
+ it("stamps companyUid when cwd is inside a company repo, omits it otherwise", async () => {
959
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-co-"));
960
+ const hqRoot = tmp; // real dir so the manifest's repo path resolves under it
961
+ await writeManifest(hqRoot);
962
+ const projects = path.join(tmp, "projects");
963
+ const dir = path.join(projects, "-p");
964
+ await fs.mkdir(dir, { recursive: true });
965
+
966
+ const inRepo = path.join(hqRoot, "repos/private/hq-cloud");
967
+ const atRoot = hqRoot; // inside scope, but not inside any mapped repo
968
+ await fs.writeFile(
969
+ path.join(dir, "s.jsonl"),
970
+ [
971
+ // cwd inside the company repo → attributed.
972
+ row({ type: "user", sessionId: "s1", timestamp: "2026-06-10T10:00:00Z", cwd: inRepo, uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }),
973
+ // cwd at hqRoot (passes scope) but maps to no repo → unattributed.
974
+ row({ type: "assistant", sessionId: "s1", timestamp: "2026-06-10T10:01:00Z", cwd: atRoot, message: { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Skill", input: { skill: "indigo:hello-world" } }] } }),
975
+ ].join("\n") + "\n",
976
+ "utf-8",
977
+ );
978
+
979
+ const captured: SkillInvocationBatch[] = [];
980
+ const result = await collectAndSendSkillTelemetry({
981
+ client: stubClient(captured),
982
+ machineId: "m",
983
+ installerVersion: "t",
984
+ hqRoot,
985
+ claudeProjectsRoot: projects,
986
+ codexSessionsRoot: path.join(tmp, "codex"),
987
+ cursorPath: path.join(tmp, "cursor.json"),
988
+ });
989
+
990
+ expect(result.eventsSent).toBe(2);
991
+ const events = captured.flatMap((b) => b.events);
992
+ const bySkill = new Map(events.map((e) => [e.skill, e]));
993
+ expect(bySkill.get("deploy")!.companyUid).toBe(COMPANY_UID);
994
+ expect("companyUid" in bySkill.get("indigo:hello-world")!).toBe(false);
995
+ // Never the reserved sentinel.
996
+ for (const e of events) expect(e.companyUid).not.toBe("unattributed");
997
+
998
+ await fs.rm(tmp, { recursive: true, force: true });
999
+ });
1000
+
1001
+ it("parses the manifest once per run (cache), not per event", async () => {
1002
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-cache-"));
1003
+ const hqRoot = tmp;
1004
+ await writeManifest(hqRoot);
1005
+ const projects = path.join(tmp, "projects");
1006
+ const dir = path.join(projects, "-p");
1007
+ await fs.mkdir(dir, { recursive: true });
1008
+ const inRepo = path.join(hqRoot, "repos/private/hq-cloud");
1009
+ await fs.writeFile(
1010
+ path.join(dir, "s.jsonl"),
1011
+ Array.from({ length: 4 }, (_, i) =>
1012
+ row({ type: "user", sessionId: "s1", timestamp: `2026-06-10T10:0${i}:00Z`, cwd: inRepo, uuid: `u${i}`, message: { role: "user", content: "<command-name>/deploy</command-name>" } }),
1013
+ ).join("\n") + "\n",
1014
+ "utf-8",
1015
+ );
1016
+
1017
+ const manifestPath = path.join(hqRoot, "companies", "manifest.yaml");
1018
+ const realReadFile = fs.readFile;
1019
+ let manifestReads = 0;
1020
+ const spy = vi
1021
+ .spyOn(fs, "readFile")
1022
+ .mockImplementation((async (p: Parameters<typeof realReadFile>[0], ...rest: unknown[]) => {
1023
+ if (typeof p === "string" && p === manifestPath) manifestReads++;
1024
+ // @ts-expect-error pass-through to the real implementation
1025
+ return realReadFile(p, ...rest);
1026
+ }) as typeof realReadFile);
1027
+
1028
+ const captured: SkillInvocationBatch[] = [];
1029
+ try {
1030
+ await collectAndSendSkillTelemetry({
1031
+ client: stubClient(captured),
1032
+ machineId: "m",
1033
+ installerVersion: "t",
1034
+ hqRoot,
1035
+ claudeProjectsRoot: projects,
1036
+ codexSessionsRoot: path.join(tmp, "codex"),
1037
+ cursorPath: path.join(tmp, "cursor.json"),
1038
+ });
1039
+ } finally {
1040
+ spy.mockRestore();
1041
+ }
1042
+
1043
+ expect(manifestReads).toBe(1);
1044
+ const events = captured.flatMap((b) => b.events);
1045
+ expect(events).toHaveLength(4);
1046
+ for (const e of events) expect(e.companyUid).toBe(COMPANY_UID);
1047
+
1048
+ await fs.rm(tmp, { recursive: true, force: true });
1049
+ });
1050
+ });
@@ -59,6 +59,11 @@ import { promises as fs } from "node:fs";
59
59
  import * as os from "node:os";
60
60
  import * as path from "node:path";
61
61
 
62
+ import {
63
+ buildRepoCompanyMap,
64
+ resolveCompanyForCwd,
65
+ type RepoCompanyMap,
66
+ } from "./company-resolver.js";
62
67
  import type {
63
68
  SkillInvocationBatch,
64
69
  SkillInvocationIngestResult,
@@ -533,8 +538,15 @@ export function extractCodexSkillToolEvents(
533
538
  ];
534
539
  }
535
540
 
536
- /** Shape the event for the wire. Drops raw args unless explicitly enabled. */
537
- function toWireRow(ev: SkillEvent): Record<string, unknown> {
541
+ /** Shape the event for the wire. Drops raw args unless explicitly enabled.
542
+ *
543
+ * `companyUid` (US-002): the caller resolves the event's `cwd` → owning company
544
+ * (`resolveCompanyForCwd`) and passes that `cmp_*` uid here. It is on the
545
+ * server's KEEP allowlist (`apps/hq-pro/src/vault-service/handlers/
546
+ * skill-invocations.ts`). When undefined (cwd maps to no company repo) the
547
+ * field is OMITTED — the server treats absence as unattributed/personal. The
548
+ * reserved value `unattributed` is never produced. */
549
+ function toWireRow(ev: SkillEvent, companyUid?: string): Record<string, unknown> {
538
550
  const row: Record<string, unknown> = {
539
551
  skill: ev.skill,
540
552
  source: ev.source,
@@ -544,6 +556,7 @@ function toWireRow(ev: SkillEvent): Record<string, unknown> {
544
556
  if (ev.timestamp !== undefined) row.timestamp = ev.timestamp;
545
557
  if (ev.uuid !== undefined) row.uuid = ev.uuid;
546
558
  if (ev.cwd !== undefined) row.cwd = ev.cwd;
559
+ if (companyUid !== undefined) row.companyUid = companyUid;
547
560
  // INCLUDE_ARGS_PREVIEW is intentionally a compile-time constant `false`;
548
561
  // the guarded branch documents the (currently disabled) egress path.
549
562
  if (INCLUDE_ARGS_PREVIEW) {
@@ -643,6 +656,13 @@ export async function collectAndSendSkillTelemetry(
643
656
  const normalizePath = (p: string): string => (p.length > 1 ? p.replace(/\/+$/, "") : p);
644
657
  const scopeCwd = opts.hqRoot !== undefined ? normalizePath(opts.hqRoot) : undefined;
645
658
 
659
+ // Company attribution (US-002): parse the manifest ONCE per run and reuse the
660
+ // repo-path→companyUid map for every event below. No per-event manifest read.
661
+ // When `hqRoot` is omitted the map is empty → every event stays unattributed.
662
+ const repoCompanyMap: RepoCompanyMap = opts.hqRoot
663
+ ? await buildRepoCompanyMap(opts.hqRoot)
664
+ : { entries: [] };
665
+
646
666
  // 1. Opt-in check — reuse the same gate as token telemetry.
647
667
  let enabled: boolean;
648
668
  let optInSource: CollectSkillTelemetryResult["optInSource"];
@@ -788,7 +808,10 @@ export async function collectAndSendSkillTelemetry(
788
808
  continue;
789
809
  }
790
810
  }
791
- sourced.push({ row: toWireRow(ev), filePath, endOffset });
811
+ // Resolve cwd → owning company (cmp_* uid) from the per-run map.
812
+ // Unresolved → undefined → companyUid omitted (unattributed/personal).
813
+ const companyUid = resolveCompanyForCwd(ev.cwd, repoCompanyMap);
814
+ sourced.push({ row: toWireRow(ev, companyUid), filePath, endOffset });
792
815
  fileScans[filePath].eventCount++;
793
816
  }
794
817
  }
@@ -35,7 +35,7 @@ import type {
35
35
  */
36
36
  export interface PullScopeClient {
37
37
  listMyMemberships(): Promise<
38
- Array<{ companyUid: string; membershipKey: string }>
38
+ Array<{ companyUid: string; membershipKey: string; role?: string }>
39
39
  >;
40
40
  getMembershipSyncConfig?: (
41
41
  membershipId: string,
@@ -82,6 +82,31 @@ export async function resolvePullScope(
82
82
  const memberships = await client.listMyMemberships();
83
83
  const m = memberships.find((x) => x.companyUid === companyUid);
84
84
  if (!m) return { syncMode: "all" };
85
+ // OWNER role-bypass (feedback_67bdb8a4 / feedback_a46c3b37). An owner's
86
+ // effective vault access is the WHOLE company by role — the data plane
87
+ // resolves owner to `admin` on every key (`resolveEffectivePermission`'s
88
+ // role bypass; presign's `viewOthersFiles` is unconditional for owners).
89
+ // But the scope resolution below derives `shared` scope from
90
+ // `listMyExplicitGrants`, and that endpoint DELIBERATELY EXCLUDES the
91
+ // owner/admin role bypass — it answers "what is explicitly shared with
92
+ // me", not "what can I touch by role" (see hq-pro files-grants handler).
93
+ // An owner who owns by role (not by explicit grant) therefore resolves to
94
+ // an empty/sparse prefixSet, so the push uploads NOTHING (every path is
95
+ // filtered out by `wrapFilterWithScope`) and any in-role write — including
96
+ // a brand-new top-level project prefix the owner just created — is rejected
97
+ // by the server as `403 SCOPE_EXCEEDS_PARENT`. An owner has no meaningful
98
+ // "shared subset": they own everything, so their effective sync scope IS
99
+ // the full vault. Resolve them to `all` directly — restoring the pre-
100
+ // pull-scope-refactor behavior (owners always journaled the whole company).
101
+ // This is checked BEFORE the sync-config fetch so it holds even if a stray
102
+ // `shared` row exists for the owner. A `custom` choice would also be
103
+ // overridden here, which is intentional: an owner cannot be scoped below
104
+ // their role on the sync path without the server rejecting their own
105
+ // writes. Non-owner roles fall through to the grant-based resolution
106
+ // below — a member/guest's explicit grants ARE their full footprint, and a
107
+ // (conditionally bypassed) admin stays grant-scoped so a company that set
108
+ // `admin.viewOthersFiles=false` keeps its admins narrowed.
109
+ if (m.role === "owner") return { syncMode: "all" };
85
110
  // Agent memberships (`agt_…#cmp_…`) belong to an agent identity that owns NO
86
111
  // person entity. hq-pro's per-membership sync-config endpoint sits behind an
87
112
  // up-front person-gate, so an agent caller is rejected with
@@ -8,8 +8,9 @@
8
8
  * boundary is `TelemetryClientSurface`, not the HTTP layer.
9
9
  */
10
10
 
11
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
11
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
12
12
  import * as fs from "fs";
13
+ import { promises as fsp } from "fs";
13
14
  import * as os from "os";
14
15
  import * as path from "path";
15
16
  import {
@@ -186,11 +187,20 @@ describe("sanitizeRow", () => {
186
187
  "sessionId", "timestamp", "uuid", "cwd", "gitBranch", "userType",
187
188
  "model", "inputTokens", "outputTokens",
188
189
  "cacheCreationInputTokens", "cacheReadInputTokens",
190
+ "companyUid",
189
191
  ]);
190
192
  for (const key of Object.keys(out)) {
191
193
  expect(allowed.has(key), `field "${key}" must be in server allowlist`).toBe(true);
192
194
  }
193
195
  });
196
+
197
+ it("(US-002) stamps companyUid when provided, omits it when not", () => {
198
+ const withUid = sanitizeRow(JSON.parse(USER_ROW), "cmp_01INDIGO")!;
199
+ expect(withUid.companyUid).toBe("cmp_01INDIGO");
200
+
201
+ const withoutUid = sanitizeRow(JSON.parse(USER_ROW))!;
202
+ expect("companyUid" in withoutUid).toBe(false);
203
+ });
194
204
  });
195
205
 
196
206
  // ── collectAndSendTelemetry ───────────────────────────────────────────────────
@@ -392,3 +402,110 @@ describe("collectAndSendTelemetry", () => {
392
402
  expect(client.posts).toHaveLength(1); // no second POST
393
403
  });
394
404
  });
405
+
406
+ // ── companyUid edge attribution (US-002) ────────────────────────────────────────
407
+
408
+ describe("collectAndSendTelemetry — companyUid attribution", () => {
409
+ const COMPANY_UID = "cmp_01INDIGOTEST";
410
+ let env: TestEnv;
411
+ let hqRoot: string;
412
+
413
+ // The manifest lives at <hqRoot>/companies/manifest.yaml; the repo it maps is
414
+ // a real subdir of hqRoot so the resolver's absolute-path math lines up.
415
+ function writeManifest(): void {
416
+ fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
417
+ fs.writeFileSync(
418
+ path.join(hqRoot, "companies", "manifest.yaml"),
419
+ `companies:\n indigo:\n repos:\n - repos/private/hq-cloud\n cloud_uid: ${COMPANY_UID}\n`,
420
+ );
421
+ }
422
+
423
+ function rowWithCwd(uuid: string, cwd: string): string {
424
+ return JSON.stringify({
425
+ type: "user",
426
+ timestamp: "2026-06-10T10:00:00Z",
427
+ sessionId: "s1",
428
+ uuid,
429
+ userType: "human",
430
+ cwd,
431
+ message: { role: "user", content: [{ type: "text", text: "hi" }] },
432
+ });
433
+ }
434
+
435
+ beforeEach(() => {
436
+ env = setupEnv();
437
+ // Use the same tmp root as hqRoot so the repo path resolves under it.
438
+ hqRoot = env.root;
439
+ writeManifest();
440
+ });
441
+ afterEach(() => { teardownEnv(env); });
442
+
443
+ it("stamps companyUid on events whose cwd is inside a company repo", async () => {
444
+ const client = makeClient();
445
+ const inRepo = path.join(hqRoot, "repos/private/hq-cloud/src");
446
+ writeJsonl(env, "proj", "s.jsonl", [rowWithCwd("u1", inRepo)]);
447
+
448
+ await collectAndSendTelemetry({ ...makeOpts(env, client), hqRoot });
449
+
450
+ expect(client.posts).toHaveLength(1);
451
+ expect(client.posts[0].events[0].companyUid).toBe(COMPANY_UID);
452
+ });
453
+
454
+ it("omits companyUid when the cwd maps to no company repo", async () => {
455
+ const client = makeClient();
456
+ writeJsonl(env, "proj", "s.jsonl", [rowWithCwd("u1", "/Users/x/some-other-repo")]);
457
+
458
+ await collectAndSendTelemetry({ ...makeOpts(env, client), hqRoot });
459
+
460
+ expect(client.posts).toHaveLength(1);
461
+ expect("companyUid" in client.posts[0].events[0]).toBe(false);
462
+ });
463
+
464
+ it("never sends the reserved 'unattributed' value", async () => {
465
+ const client = makeClient();
466
+ writeJsonl(env, "proj", "s.jsonl", [
467
+ rowWithCwd("u1", path.join(hqRoot, "repos/private/hq-cloud")),
468
+ rowWithCwd("u2", "/elsewhere"),
469
+ ]);
470
+
471
+ await collectAndSendTelemetry({ ...makeOpts(env, client), hqRoot });
472
+
473
+ for (const ev of client.posts.flatMap((p) => p.events)) {
474
+ expect(ev.companyUid).not.toBe("unattributed");
475
+ }
476
+ });
477
+
478
+ it("parses the manifest once per run (cache), not per event", async () => {
479
+ const client = makeClient();
480
+ const inRepo = path.join(hqRoot, "repos/private/hq-cloud");
481
+ // 5 events in one run; assert the manifest file is read exactly once.
482
+ writeJsonl(
483
+ env,
484
+ "proj",
485
+ "s.jsonl",
486
+ Array.from({ length: 5 }, (_, i) => rowWithCwd(`u${i}`, inRepo)),
487
+ );
488
+
489
+ const manifestPath = path.join(hqRoot, "companies", "manifest.yaml");
490
+ const realReadFile = fsp.readFile;
491
+ let manifestReads = 0;
492
+ const spy = vi
493
+ .spyOn(fsp, "readFile")
494
+ .mockImplementation((async (p: Parameters<typeof realReadFile>[0], ...rest: unknown[]) => {
495
+ if (typeof p === "string" && p === manifestPath) manifestReads++;
496
+ // @ts-expect-error pass-through to the real implementation
497
+ return realReadFile(p, ...rest);
498
+ }) as typeof realReadFile);
499
+
500
+ try {
501
+ await collectAndSendTelemetry({ ...makeOpts(env, client), hqRoot });
502
+ } finally {
503
+ spy.mockRestore();
504
+ }
505
+
506
+ expect(manifestReads).toBe(1); // one parse for all 5 events
507
+ const events = client.posts.flatMap((p) => p.events);
508
+ expect(events).toHaveLength(5);
509
+ for (const ev of events) expect(ev.companyUid).toBe(COMPANY_UID);
510
+ });
511
+ });