@indigoai-us/hq-cloud 6.11.9 → 6.11.11
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.
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +4 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +137 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +16 -1
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +83 -1
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +25 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.js +91 -0
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts +2 -0
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts.map +1 -0
- package/dist/cli/rescue-exec-bit-preserve.test.js +169 -0
- package/dist/cli/rescue-exec-bit-preserve.test.js.map +1 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +10 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +125 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/company-resolver.d.ts +77 -0
- package/dist/company-resolver.d.ts.map +1 -0
- package/dist/company-resolver.js +124 -0
- package/dist/company-resolver.js.map +1 -0
- package/dist/company-resolver.test.d.ts +7 -0
- package/dist/company-resolver.test.d.ts.map +1 -0
- package/dist/company-resolver.test.js +120 -0
- package/dist/company-resolver.test.js.map +1 -0
- package/dist/personal-vault.d.ts +8 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +9 -1
- package/dist/personal-vault.js.map +1 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +22 -3
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +101 -1
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/pull-scope.d.ts +1 -0
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +26 -0
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/telemetry.d.ts +18 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +28 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +93 -1
- package/dist/telemetry.test.js.map +1 -1
- package/dist/vault-client.d.ts +4 -2
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +188 -0
- package/src/bin/sync-runner.ts +4 -0
- package/src/cli/reindex.test.ts +100 -1
- package/src/cli/reindex.ts +17 -1
- package/src/cli/rescue-classify-ordering.test.ts +28 -0
- package/src/cli/rescue-core.ts +87 -0
- package/src/cli/rescue-exec-bit-preserve.test.ts +187 -0
- package/src/cli/sync.test.ts +159 -0
- package/src/cli/sync.ts +12 -1
- package/src/company-resolver.test.ts +136 -0
- package/src/company-resolver.ts +147 -0
- package/src/personal-vault.ts +10 -1
- package/src/skill-telemetry.test.ts +126 -1
- package/src/skill-telemetry.ts +26 -3
- package/src/sync/pull-scope.ts +26 -1
- package/src/telemetry.test.ts +118 -1
- package/src/telemetry.ts +50 -2
- package/src/vault-client.ts +4 -2
|
@@ -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
|
+
}
|
package/src/personal-vault.ts
CHANGED
|
@@ -94,6 +94,15 @@ export interface PersonalVaultOptions {
|
|
|
94
94
|
* hqRoot returns []; callers treat that as "no personal content to push"
|
|
95
95
|
* rather than a hard error.
|
|
96
96
|
*/
|
|
97
|
+
/**
|
|
98
|
+
* S3 key (hq-root-relative, forward-slash) of the companies manifest — the
|
|
99
|
+
* routing source-of-truth — carved into the personal vault even though
|
|
100
|
+
* `companies/` is otherwise excluded. Exported so the PULL plan applies the
|
|
101
|
+
* SAME exemption: skipping it on the pull leaves it unjournaled, which re-fires
|
|
102
|
+
* a transient push-side conflict every sync (no journal baseline).
|
|
103
|
+
*/
|
|
104
|
+
export const PERSONAL_VAULT_MANIFEST_KEY = "companies/manifest.yaml";
|
|
105
|
+
|
|
97
106
|
export function computePersonalVaultPaths(
|
|
98
107
|
hqRoot: string,
|
|
99
108
|
opts: PersonalVaultOptions = {},
|
|
@@ -114,7 +123,7 @@ export function computePersonalVaultPaths(
|
|
|
114
123
|
// because the parent `companies/` is in PERSONAL_VAULT_EXCLUDED_TOP_LEVEL
|
|
115
124
|
// (we never enumerate the whole companies tree wholesale).
|
|
116
125
|
const manifest: string[] = [];
|
|
117
|
-
const manifestPath = path.join(hqRoot,
|
|
126
|
+
const manifestPath = path.join(hqRoot, PERSONAL_VAULT_MANIFEST_KEY);
|
|
118
127
|
try {
|
|
119
128
|
if (fs.statSync(manifestPath).isFile()) {
|
|
120
129
|
manifest.push(manifestPath);
|
|
@@ -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
|
+
});
|
package/src/skill-telemetry.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/sync/pull-scope.ts
CHANGED
|
@@ -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
|
package/src/telemetry.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/telemetry.ts
CHANGED
|
@@ -27,6 +27,11 @@ import { promises as fs } from "node:fs";
|
|
|
27
27
|
import * as os from "node:os";
|
|
28
28
|
import * as path from "node:path";
|
|
29
29
|
|
|
30
|
+
import {
|
|
31
|
+
buildRepoCompanyMap,
|
|
32
|
+
resolveCompanyForCwd,
|
|
33
|
+
type RepoCompanyMap,
|
|
34
|
+
} from "./company-resolver.js";
|
|
30
35
|
import type {
|
|
31
36
|
TelemetryOptInResponse,
|
|
32
37
|
UsageBatch,
|
|
@@ -51,6 +56,15 @@ export interface CollectTelemetryOptions {
|
|
|
51
56
|
machineId: string;
|
|
52
57
|
/** Version of the wrapping caller (menubar app, CLI, etc.). Reaches CloudWatch metrics as the `installerVersion` dimension. */
|
|
53
58
|
installerVersion: string;
|
|
59
|
+
/**
|
|
60
|
+
* HQ root, used to resolve each event's `cwd` → owning repo → owning company
|
|
61
|
+
* via `<hqRoot>/companies/manifest.yaml` and stamp `companyUid` on the event
|
|
62
|
+
* (surface-hq-console-telemetry US-002). The manifest is parsed ONCE per run
|
|
63
|
+
* (see `buildRepoCompanyMap`); the resulting map is reused for every event.
|
|
64
|
+
* When omitted (or when no repo matches), `companyUid` is left UNSET and the
|
|
65
|
+
* server treats the event as unattributed/personal.
|
|
66
|
+
*/
|
|
67
|
+
hqRoot?: string;
|
|
54
68
|
/** Override `~/.claude/projects` for tests. */
|
|
55
69
|
claudeProjectsRoot?: string;
|
|
56
70
|
/** Override `~/.hq/telemetry-cursor.json` for tests. */
|
|
@@ -148,12 +162,23 @@ const KEEP_TOP_LEVEL = [
|
|
|
148
162
|
* camelCase top-level fields. The original `message` object — which
|
|
149
163
|
* carries prompt/response text, thinking, and tool data — is dropped.
|
|
150
164
|
*
|
|
165
|
+
* `companyUid` (US-002): when the caller has resolved the row's `cwd` to an
|
|
166
|
+
* owning company (`resolveCompanyForCwd`), it passes that `cmp_*` uid here and
|
|
167
|
+
* it is stamped on the wire row. It is on the server's KEEP allowlist
|
|
168
|
+
* (`apps/hq-pro/src/vault-service/handlers/usage.ts`). When `companyUid` is
|
|
169
|
+
* undefined (cwd maps to no company repo) the field is OMITTED — the server
|
|
170
|
+
* treats absence as unattributed/personal. The reserved value `unattributed`
|
|
171
|
+
* is never produced (it can only come from a resolved manifest `cmp_*` uid).
|
|
172
|
+
*
|
|
151
173
|
* Returns `null` when the input isn't an object. Empty results (e.g. a row
|
|
152
174
|
* with no recognised fields) are still returned as `{}` and emitted; the
|
|
153
175
|
* server accepts empty rows and they're useful as a "Claude Code was run at
|
|
154
176
|
* this time" heartbeat.
|
|
155
177
|
*/
|
|
156
|
-
export function sanitizeRow(
|
|
178
|
+
export function sanitizeRow(
|
|
179
|
+
row: unknown,
|
|
180
|
+
companyUid?: string,
|
|
181
|
+
): Record<string, unknown> | null {
|
|
157
182
|
if (!row || typeof row !== "object" || Array.isArray(row)) return null;
|
|
158
183
|
const obj = row as Record<string, unknown>;
|
|
159
184
|
const out: Record<string, unknown> = {};
|
|
@@ -164,6 +189,12 @@ export function sanitizeRow(row: unknown): Record<string, unknown> | null {
|
|
|
164
189
|
}
|
|
165
190
|
}
|
|
166
191
|
|
|
192
|
+
// Stamp the resolved company attribution. Omit when unresolved so the server
|
|
193
|
+
// reads it as unattributed/personal. Never the reserved `unattributed`.
|
|
194
|
+
if (companyUid !== undefined) {
|
|
195
|
+
out.companyUid = companyUid;
|
|
196
|
+
}
|
|
197
|
+
|
|
167
198
|
const message = obj.message;
|
|
168
199
|
if (message && typeof message === "object" && !Array.isArray(message)) {
|
|
169
200
|
const m = message as Record<string, unknown>;
|
|
@@ -261,6 +292,13 @@ export async function collectAndSendTelemetry(
|
|
|
261
292
|
const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
|
|
262
293
|
const log = opts.log ?? (() => {});
|
|
263
294
|
|
|
295
|
+
// Company attribution (US-002): parse the manifest ONCE per run and reuse the
|
|
296
|
+
// repo-path→companyUid map for every event below. No per-event manifest read.
|
|
297
|
+
// When `hqRoot` is omitted the map is empty → every event stays unattributed.
|
|
298
|
+
const repoCompanyMap: RepoCompanyMap = opts.hqRoot
|
|
299
|
+
? await buildRepoCompanyMap(opts.hqRoot)
|
|
300
|
+
: { entries: [] };
|
|
301
|
+
|
|
264
302
|
// 1. Opt-in check (server-authoritative, with local fallback).
|
|
265
303
|
let enabled: boolean;
|
|
266
304
|
let optInSource: CollectTelemetryResult["optInSource"];
|
|
@@ -390,7 +428,17 @@ export async function collectAndSendTelemetry(
|
|
|
390
428
|
} catch {
|
|
391
429
|
continue;
|
|
392
430
|
}
|
|
393
|
-
|
|
431
|
+
// Resolve this row's cwd → owning company (cmp_* uid) before sanitizing,
|
|
432
|
+
// using the per-run map. Unresolved → undefined → companyUid omitted.
|
|
433
|
+
const rowCwd =
|
|
434
|
+
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
435
|
+
? (parsed as Record<string, unknown>).cwd
|
|
436
|
+
: undefined;
|
|
437
|
+
const companyUid = resolveCompanyForCwd(
|
|
438
|
+
typeof rowCwd === "string" ? rowCwd : undefined,
|
|
439
|
+
repoCompanyMap,
|
|
440
|
+
);
|
|
441
|
+
const sanitized = sanitizeRow(parsed, companyUid);
|
|
394
442
|
if (!sanitized) continue;
|
|
395
443
|
|
|
396
444
|
// Cost of appending this row to the current batch: the row's JSON
|
package/src/vault-client.ts
CHANGED
|
@@ -393,7 +393,8 @@ export interface UsageBatch {
|
|
|
393
393
|
* Sanitized event rows. Each row is a plain object containing only the
|
|
394
394
|
* fields in the server's KEEP allowlist (sessionId, timestamp, uuid, cwd,
|
|
395
395
|
* gitBranch, userType, model, inputTokens, outputTokens,
|
|
396
|
-
* cacheCreationInputTokens, cacheReadInputTokens
|
|
396
|
+
* cacheCreationInputTokens, cacheReadInputTokens, and the optional
|
|
397
|
+
* companyUid edge-attribution field — US-002). Any extra field is
|
|
397
398
|
* rejected by hq-pro with `unexpected-event-field`, so the sanitizer in
|
|
398
399
|
* `./telemetry.ts` is the only thing allowed to produce these.
|
|
399
400
|
*/
|
|
@@ -416,7 +417,8 @@ export interface SkillInvocationBatch {
|
|
|
416
417
|
/**
|
|
417
418
|
* Skill-invocation event rows. Each row contains only the fields in the
|
|
418
419
|
* server's KEEP allowlist (skill, source, sessionId, timestamp, uuid, cwd,
|
|
419
|
-
* hasArgs
|
|
420
|
+
* hasArgs, and the optional companyUid edge-attribution field — US-002).
|
|
421
|
+
* Raw argument text is never included — see the privacy note in
|
|
420
422
|
* `./skill-telemetry.ts`. Any extra field is rejected by hq-pro with
|
|
421
423
|
* `unexpected-event-field`, so the extractor in `./skill-telemetry.ts` is the
|
|
422
424
|
* only thing allowed to produce these.
|