@indigoai-us/hq-cloud 5.44.0 → 5.46.0
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 +39 -12
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +63 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +65 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/skill-telemetry.d.ts +107 -0
- package/dist/skill-telemetry.d.ts.map +1 -0
- package/dist/skill-telemetry.js +395 -0
- package/dist/skill-telemetry.js.map +1 -0
- package/dist/skill-telemetry.test.d.ts +2 -0
- package/dist/skill-telemetry.test.d.ts.map +1 -0
- package/dist/skill-telemetry.test.js +219 -0
- package/dist/skill-telemetry.test.js.map +1 -0
- package/dist/vault-client.d.ts +23 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +10 -0
- package/dist/vault-client.js.map +1 -1
- package/package.json +1 -1
- package/scripts/vault-rescue.sh +283 -0
- package/src/bin/sync-runner.ts +39 -13
- package/src/cli/sync.test.ts +81 -0
- package/src/cli/sync.ts +75 -0
- package/src/index.ts +16 -0
- package/src/skill-telemetry.test.ts +279 -0
- package/src/skill-telemetry.ts +499 -0
- package/src/vault-client.ts +34 -0
package/src/cli/sync.ts
CHANGED
|
@@ -382,6 +382,73 @@ export function resolveAutoPruneCap(): number {
|
|
|
382
382
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
+
/** Max time to wait on the best-effort new-files notification POST. */
|
|
386
|
+
const NOTIFY_FILE_ADDED_TIMEOUT_MS = 5000;
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Best-effort report of the files that were new to this drive during the sync,
|
|
390
|
+
* so the HQ Sync app can show a persistent cross-session "new files" history.
|
|
391
|
+
*
|
|
392
|
+
* POSTs to `${apiUrl}/v1/notify/file-added`, which writes per-recipient
|
|
393
|
+
* FILE_EVENT rows for the calling user (the one the files are new for). Fully
|
|
394
|
+
* non-fatal: any error, non-2xx, or timeout is swallowed — the durable signal
|
|
395
|
+
* is the synced file itself; this is only a notification mirror. Bounded by a
|
|
396
|
+
* 5s timeout so a hung endpoint can't stall sync completion. No-op when there
|
|
397
|
+
* are no new files.
|
|
398
|
+
*/
|
|
399
|
+
async function reportNewFilesToNotify(
|
|
400
|
+
vaultConfig: VaultServiceConfig,
|
|
401
|
+
companyUid: string,
|
|
402
|
+
companySlug: string,
|
|
403
|
+
files: Array<{ path: string; bytes: number; addedBy: string | null }>,
|
|
404
|
+
): Promise<void> {
|
|
405
|
+
if (files.length === 0) return;
|
|
406
|
+
try {
|
|
407
|
+
const token =
|
|
408
|
+
typeof vaultConfig.authToken === "function"
|
|
409
|
+
? await vaultConfig.authToken()
|
|
410
|
+
: vaultConfig.authToken;
|
|
411
|
+
const base = vaultConfig.apiUrl.replace(/\/+$/, "");
|
|
412
|
+
const controller = new AbortController();
|
|
413
|
+
const timer = setTimeout(
|
|
414
|
+
() => controller.abort(),
|
|
415
|
+
NOTIFY_FILE_ADDED_TIMEOUT_MS,
|
|
416
|
+
);
|
|
417
|
+
try {
|
|
418
|
+
await fetch(`${base}/v1/notify/file-added`, {
|
|
419
|
+
method: "POST",
|
|
420
|
+
headers: {
|
|
421
|
+
Authorization: `Bearer ${token}`,
|
|
422
|
+
"Content-Type": "application/json",
|
|
423
|
+
},
|
|
424
|
+
body: JSON.stringify({
|
|
425
|
+
companyUid,
|
|
426
|
+
companySlug,
|
|
427
|
+
files: files.map((f) => ({
|
|
428
|
+
path: f.path,
|
|
429
|
+
bytes: f.bytes,
|
|
430
|
+
...(f.addedBy ? { addedBy: f.addedBy } : {}),
|
|
431
|
+
})),
|
|
432
|
+
}),
|
|
433
|
+
signal: controller.signal,
|
|
434
|
+
});
|
|
435
|
+
} finally {
|
|
436
|
+
clearTimeout(timer);
|
|
437
|
+
}
|
|
438
|
+
} catch (err) {
|
|
439
|
+
// Best-effort: never let notification reporting affect the sync result.
|
|
440
|
+
try {
|
|
441
|
+
console.error(
|
|
442
|
+
`[hq-sync] new-files notify report failed (non-fatal): ${
|
|
443
|
+
err instanceof Error ? err.message : String(err)
|
|
444
|
+
}`,
|
|
445
|
+
);
|
|
446
|
+
} catch {
|
|
447
|
+
// swallow — logging must never break sync
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
385
452
|
/**
|
|
386
453
|
* Sync (pull) all allowed files from the entity vault.
|
|
387
454
|
*/
|
|
@@ -932,6 +999,14 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
932
999
|
}
|
|
933
1000
|
emit({ type: "new-files", files: enrichedNewFiles });
|
|
934
1001
|
|
|
1002
|
+
// Report new files to the notification service so they persist as a
|
|
1003
|
+
// cross-session "new files" history in the HQ Sync app (POST
|
|
1004
|
+
// /v1/notify/file-added → per-recipient FILE_EVENT rows for THIS user, who is
|
|
1005
|
+
// the one the files are new for). Best-effort and bounded: a failure or a
|
|
1006
|
+
// hung request must never delay or break the sync — the durable signal is the
|
|
1007
|
+
// synced file itself, this is only a notification mirror.
|
|
1008
|
+
await reportNewFilesToNotify(vaultConfig, ctx.uid, ctx.slug, enrichedNewFiles);
|
|
1009
|
+
|
|
935
1010
|
// Codex P1 (PR #24 follow-up): scope-gate tombstone candidates with
|
|
936
1011
|
// a per-object HEAD before unlinking. `listRemoteFiles` is STS-scoped
|
|
937
1012
|
// (guest sessions with `allowedPrefixes`, role downgrade, custom
|
package/src/index.ts
CHANGED
|
@@ -146,6 +146,8 @@ export type {
|
|
|
146
146
|
TelemetryOptInResponse,
|
|
147
147
|
UsageBatch,
|
|
148
148
|
UsageIngestResult,
|
|
149
|
+
SkillInvocationBatch,
|
|
150
|
+
SkillInvocationIngestResult,
|
|
149
151
|
// US-004 — browse-vs-sync membership sync-mode + ACL surface
|
|
150
152
|
SyncMode,
|
|
151
153
|
MembershipSyncConfig,
|
|
@@ -163,6 +165,20 @@ export type {
|
|
|
163
165
|
TelemetryClientSurface,
|
|
164
166
|
} from "./telemetry.js";
|
|
165
167
|
|
|
168
|
+
// Skill-invocation telemetry collector (`/v1/skill-invocations`). Reads the
|
|
169
|
+
// same Claude Code session logs as the token collector but extracts which
|
|
170
|
+
// skill / slash-command was invoked. Independent cursor; same opt-in gate.
|
|
171
|
+
export {
|
|
172
|
+
collectAndSendSkillTelemetry,
|
|
173
|
+
extractSkillEvents,
|
|
174
|
+
} from "./skill-telemetry.js";
|
|
175
|
+
export type {
|
|
176
|
+
CollectSkillTelemetryOptions,
|
|
177
|
+
CollectSkillTelemetryResult,
|
|
178
|
+
SkillEvent,
|
|
179
|
+
SkillTelemetryClientSurface,
|
|
180
|
+
} from "./skill-telemetry.js";
|
|
181
|
+
|
|
166
182
|
// STS child vending (VLT-8)
|
|
167
183
|
export type {
|
|
168
184
|
TaskAction,
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { extractSkillEvents, collectAndSendSkillTelemetry } from "./skill-telemetry.js";
|
|
6
|
+
import type { SkillInvocationBatch } from "./vault-client.js";
|
|
7
|
+
|
|
8
|
+
describe("extractSkillEvents", () => {
|
|
9
|
+
it("extracts a user-typed slash command with args", () => {
|
|
10
|
+
const row = {
|
|
11
|
+
type: "user",
|
|
12
|
+
sessionId: "sess-1",
|
|
13
|
+
timestamp: "2026-06-03T10:10:07.220Z",
|
|
14
|
+
cwd: "/home/ec2-user/hq",
|
|
15
|
+
uuid: "u-1",
|
|
16
|
+
message: {
|
|
17
|
+
role: "user",
|
|
18
|
+
content:
|
|
19
|
+
"<command-message>personal:worktree</command-message> <command-name>/personal:worktree</command-name> <command-args>pull latest</command-args>",
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const events = extractSkillEvents(row);
|
|
23
|
+
expect(events).toEqual([
|
|
24
|
+
{
|
|
25
|
+
skill: "personal:worktree",
|
|
26
|
+
source: "typed",
|
|
27
|
+
sessionId: "sess-1",
|
|
28
|
+
timestamp: "2026-06-03T10:10:07.220Z",
|
|
29
|
+
cwd: "/home/ec2-user/hq",
|
|
30
|
+
uuid: "u-1",
|
|
31
|
+
hasArgs: true,
|
|
32
|
+
},
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("marks hasArgs false when command-args is absent or empty", () => {
|
|
37
|
+
const row = {
|
|
38
|
+
type: "user",
|
|
39
|
+
message: { role: "user", content: "<command-name>/deploy</command-name>" },
|
|
40
|
+
};
|
|
41
|
+
expect(extractSkillEvents(row)[0]).toMatchObject({ skill: "deploy", source: "typed", hasArgs: false });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("extracts a model-invoked Skill tool_use using the block id for dedup", () => {
|
|
45
|
+
const row = {
|
|
46
|
+
type: "assistant",
|
|
47
|
+
sessionId: "sess-2",
|
|
48
|
+
timestamp: "2026-06-03T14:00:00.000Z",
|
|
49
|
+
cwd: "/home/ec2-user/hq",
|
|
50
|
+
uuid: "row-uuid",
|
|
51
|
+
message: {
|
|
52
|
+
role: "assistant",
|
|
53
|
+
content: [
|
|
54
|
+
{ type: "text", text: "ok" },
|
|
55
|
+
{ type: "tool_use", id: "toolu_abc", name: "Skill", input: { skill: "indigo:hello-world" } },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
expect(extractSkillEvents(row)).toEqual([
|
|
60
|
+
{
|
|
61
|
+
skill: "indigo:hello-world",
|
|
62
|
+
source: "model",
|
|
63
|
+
sessionId: "sess-2",
|
|
64
|
+
timestamp: "2026-06-03T14:00:00.000Z",
|
|
65
|
+
cwd: "/home/ec2-user/hq",
|
|
66
|
+
uuid: "toolu_abc",
|
|
67
|
+
hasArgs: false,
|
|
68
|
+
},
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("sets hasArgs true for a model invocation with args, without capturing the args text", () => {
|
|
73
|
+
const row = {
|
|
74
|
+
type: "assistant",
|
|
75
|
+
message: {
|
|
76
|
+
role: "assistant",
|
|
77
|
+
content: [{ type: "tool_use", name: "Skill", input: { skill: "code-review", args: "high" } }],
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
const ev = extractSkillEvents(row)[0];
|
|
81
|
+
expect(ev).toMatchObject({ skill: "code-review", source: "model", hasArgs: true });
|
|
82
|
+
// Privacy: raw args must never appear on the extracted event.
|
|
83
|
+
expect(JSON.stringify(ev)).not.toContain("high");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("ignores non-Skill tool_use blocks and non-invocation rows", () => {
|
|
87
|
+
expect(
|
|
88
|
+
extractSkillEvents({
|
|
89
|
+
type: "assistant",
|
|
90
|
+
message: { role: "assistant", content: [{ type: "tool_use", name: "Bash", input: { command: "ls" } }] },
|
|
91
|
+
}),
|
|
92
|
+
).toEqual([]);
|
|
93
|
+
expect(extractSkillEvents({ type: "user", message: { content: "just a normal message" } })).toEqual([]);
|
|
94
|
+
expect(extractSkillEvents(null)).toEqual([]);
|
|
95
|
+
expect(extractSkillEvents("nope")).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
|
|
100
|
+
function stubClient(captured: SkillInvocationBatch[]) {
|
|
101
|
+
return {
|
|
102
|
+
async getTelemetryOptIn() {
|
|
103
|
+
return { enabled: true, updatedAt: null };
|
|
104
|
+
},
|
|
105
|
+
async postSkillInvocations(batch: SkillInvocationBatch) {
|
|
106
|
+
captured.push(batch);
|
|
107
|
+
return { ok: true, written: batch.events.length, skipped: [] };
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function row(obj: Record<string, unknown>): string {
|
|
113
|
+
return JSON.stringify(obj);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
it("emits only invocations whose cwd equals hqRoot", async () => {
|
|
117
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-"));
|
|
118
|
+
const projects = path.join(tmp, "projects");
|
|
119
|
+
const hqRoot = "/home/ec2-user/hq";
|
|
120
|
+
|
|
121
|
+
// Project A — sessions run from the HQ root.
|
|
122
|
+
const dirA = path.join(projects, "-home-ec2-user-hq");
|
|
123
|
+
await fs.mkdir(dirA, { recursive: true });
|
|
124
|
+
await fs.writeFile(
|
|
125
|
+
path.join(dirA, "a.jsonl"),
|
|
126
|
+
[
|
|
127
|
+
row({ type: "user", sessionId: "s1", timestamp: "2026-06-03T10:00:00Z", cwd: hqRoot, uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }),
|
|
128
|
+
row({ type: "assistant", sessionId: "s1", timestamp: "2026-06-03T10:01:00Z", cwd: hqRoot, message: { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Skill", input: { skill: "indigo:hello-world" } }] } }),
|
|
129
|
+
].join("\n") + "\n",
|
|
130
|
+
"utf-8",
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// Project B — a different repo on the same machine. Must be excluded.
|
|
134
|
+
const dirB = path.join(projects, "-home-ec2-user-other");
|
|
135
|
+
await fs.mkdir(dirB, { recursive: true });
|
|
136
|
+
await fs.writeFile(
|
|
137
|
+
path.join(dirB, "b.jsonl"),
|
|
138
|
+
row({ type: "user", sessionId: "s2", timestamp: "2026-06-03T10:02:00Z", cwd: "/home/ec2-user/other", uuid: "u2", message: { role: "user", content: "<command-name>/secret-skill</command-name>" } }) + "\n",
|
|
139
|
+
"utf-8",
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const captured: SkillInvocationBatch[] = [];
|
|
143
|
+
const result = await collectAndSendSkillTelemetry({
|
|
144
|
+
client: stubClient(captured),
|
|
145
|
+
machineId: "m-test",
|
|
146
|
+
installerVersion: "test",
|
|
147
|
+
hqRoot,
|
|
148
|
+
claudeProjectsRoot: projects,
|
|
149
|
+
cursorPath: path.join(tmp, "cursor.json"),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const skills = captured.flatMap((b) => b.events.map((e) => e.skill));
|
|
153
|
+
expect(result.eventsSent).toBe(2);
|
|
154
|
+
expect(skills.sort()).toEqual(["deploy", "indigo:hello-world"]);
|
|
155
|
+
// The other-repo invocation never leaves the machine.
|
|
156
|
+
expect(skills).not.toContain("secret-skill");
|
|
157
|
+
|
|
158
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("commits the cursor to EOF on a clean run — a rerun re-sends nothing", async () => {
|
|
162
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-"));
|
|
163
|
+
const projects = path.join(tmp, "projects");
|
|
164
|
+
const dir = path.join(projects, "-p");
|
|
165
|
+
await fs.mkdir(dir, { recursive: true });
|
|
166
|
+
await fs.writeFile(
|
|
167
|
+
path.join(dir, "s.jsonl"),
|
|
168
|
+
row({ type: "user", sessionId: "s1", timestamp: "2026-06-04T09:00:00Z", cwd: "/x", uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }) + "\n",
|
|
169
|
+
"utf-8",
|
|
170
|
+
);
|
|
171
|
+
const cursorPath = path.join(tmp, "cursor.json");
|
|
172
|
+
const captured: SkillInvocationBatch[] = [];
|
|
173
|
+
const client = stubClient(captured);
|
|
174
|
+
|
|
175
|
+
const r1 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
|
|
176
|
+
expect(r1.eventsSent).toBe(1);
|
|
177
|
+
const r2 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
|
|
178
|
+
expect(r2.eventsSent).toBe(0); // cursor at EOF → nothing re-sent
|
|
179
|
+
|
|
180
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("does NOT advance the cursor when the POST fails — re-sends on the next run", async () => {
|
|
184
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-"));
|
|
185
|
+
const projects = path.join(tmp, "projects");
|
|
186
|
+
const dir = path.join(projects, "-p");
|
|
187
|
+
await fs.mkdir(dir, { recursive: true });
|
|
188
|
+
await fs.writeFile(
|
|
189
|
+
path.join(dir, "s.jsonl"),
|
|
190
|
+
row({ type: "user", sessionId: "s1", timestamp: "2026-06-04T09:00:00Z", cwd: "/x", uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }) + "\n",
|
|
191
|
+
"utf-8",
|
|
192
|
+
);
|
|
193
|
+
const cursorPath = path.join(tmp, "cursor.json");
|
|
194
|
+
const captured: SkillInvocationBatch[] = [];
|
|
195
|
+
let failNext = true;
|
|
196
|
+
const client = {
|
|
197
|
+
async getTelemetryOptIn() {
|
|
198
|
+
return { enabled: true, updatedAt: null };
|
|
199
|
+
},
|
|
200
|
+
async postSkillInvocations(batch: SkillInvocationBatch) {
|
|
201
|
+
if (failNext) {
|
|
202
|
+
failNext = false;
|
|
203
|
+
throw new Error("network down");
|
|
204
|
+
}
|
|
205
|
+
captured.push(batch);
|
|
206
|
+
return { ok: true, written: batch.events.length, skipped: [] };
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const r1 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
|
|
211
|
+
expect(r1.eventsSent).toBe(0); // post threw → nothing committed
|
|
212
|
+
expect(captured).toHaveLength(0);
|
|
213
|
+
|
|
214
|
+
const r2 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
|
|
215
|
+
expect(r2.eventsSent).toBe(1); // re-scanned from offset 0 and delivered
|
|
216
|
+
expect(captured.flatMap((b) => b.events.map((e) => e.skill))).toEqual(["deploy"]);
|
|
217
|
+
|
|
218
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("picks up only newly-appended events on a later run (incremental offset)", async () => {
|
|
222
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-"));
|
|
223
|
+
const projects = path.join(tmp, "projects");
|
|
224
|
+
const dir = path.join(projects, "-p");
|
|
225
|
+
await fs.mkdir(dir, { recursive: true });
|
|
226
|
+
const file = path.join(dir, "s.jsonl");
|
|
227
|
+
await fs.writeFile(
|
|
228
|
+
file,
|
|
229
|
+
row({ type: "user", sessionId: "s1", timestamp: "2026-06-04T09:00:00Z", cwd: "/x", uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }) + "\n",
|
|
230
|
+
"utf-8",
|
|
231
|
+
);
|
|
232
|
+
const cursorPath = path.join(tmp, "cursor.json");
|
|
233
|
+
const captured: SkillInvocationBatch[] = [];
|
|
234
|
+
const client = stubClient(captured);
|
|
235
|
+
|
|
236
|
+
await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
|
|
237
|
+
|
|
238
|
+
// New invocation appended after the first clean run.
|
|
239
|
+
await fs.appendFile(
|
|
240
|
+
file,
|
|
241
|
+
row({ type: "assistant", sessionId: "s1", timestamp: "2026-06-04T09:05:00Z", cwd: "/x", message: { role: "assistant", content: [{ type: "tool_use", id: "t9", name: "Skill", input: { skill: "land" } }] } }) + "\n",
|
|
242
|
+
"utf-8",
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const r2 = await collectAndSendSkillTelemetry({ client, machineId: "m", installerVersion: "t", claudeProjectsRoot: projects, cursorPath });
|
|
246
|
+
expect(r2.eventsSent).toBe(1);
|
|
247
|
+
// Only the appended event — not the original — is re-sent.
|
|
248
|
+
const lastBatch = captured[captured.length - 1];
|
|
249
|
+
expect(lastBatch.events.map((e) => e.skill)).toEqual(["land"]);
|
|
250
|
+
|
|
251
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("captures every project when hqRoot is omitted", async () => {
|
|
255
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-"));
|
|
256
|
+
const projects = path.join(tmp, "projects");
|
|
257
|
+
const dir = path.join(projects, "-proj");
|
|
258
|
+
await fs.mkdir(dir, { recursive: true });
|
|
259
|
+
await fs.writeFile(
|
|
260
|
+
path.join(dir, "c.jsonl"),
|
|
261
|
+
row({ type: "user", sessionId: "s3", timestamp: "2026-06-03T10:03:00Z", cwd: "/anywhere", uuid: "u3", message: { role: "user", content: "<command-name>/anywhere-skill</command-name>" } }) + "\n",
|
|
262
|
+
"utf-8",
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const captured: SkillInvocationBatch[] = [];
|
|
266
|
+
const result = await collectAndSendSkillTelemetry({
|
|
267
|
+
client: stubClient(captured),
|
|
268
|
+
machineId: "m-test",
|
|
269
|
+
installerVersion: "test",
|
|
270
|
+
claudeProjectsRoot: projects,
|
|
271
|
+
cursorPath: path.join(tmp, "cursor.json"),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(result.eventsSent).toBe(1);
|
|
275
|
+
expect(captured[0].events[0].skill).toBe("anywhere-skill");
|
|
276
|
+
|
|
277
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
278
|
+
});
|
|
279
|
+
});
|