@indigoai-us/hq-cloud 6.3.1 → 6.3.3

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.
@@ -5,6 +5,8 @@ import * as path from "node:path";
5
5
  import {
6
6
  extractSkillEvents,
7
7
  extractCodexSkillEvents,
8
+ extractCodexSkillToolEvents,
9
+ codexRowTurnId,
8
10
  parseCodexSessionMeta,
9
11
  collectAndSendSkillTelemetry,
10
12
  } from "./skill-telemetry.js";
@@ -173,6 +175,244 @@ describe("extractCodexSkillEvents (Codex CLI rollouts)", () => {
173
175
  });
174
176
  });
175
177
 
178
+ describe("extractCodexSkillToolEvents (Codex model-driven SKILL.md reads)", () => {
179
+ const ctx = { sessionId: "sess-x", cwd: "/home/ec2-user/hq" };
180
+
181
+ // Codex surfaces a completed exec as `exec_command_end` with `command` as
182
+ // `["/bin/zsh","-lc","<cmd>"]`, a `turn_id`, the run `cwd`, and a `parsed_cmd`
183
+ // it tags `type: "read"` for inspection commands.
184
+ function execEnd(cmd: string, over: Record<string, unknown> = {}) {
185
+ return {
186
+ timestamp: "2026-04-27T07:51:30.395Z",
187
+ type: "event_msg",
188
+ payload: {
189
+ type: "exec_command_end",
190
+ call_id: "call_1",
191
+ turn_id: "turn-A",
192
+ command: ["/bin/zsh", "-lc", cmd],
193
+ cwd: "/home/ec2-user/hq",
194
+ parsed_cmd: [{ type: "read", cmd }],
195
+ ...over,
196
+ },
197
+ };
198
+ }
199
+
200
+ it("extracts a skill load from a sed read of SKILL.md, tagged source:model", () => {
201
+ const ev = extractCodexSkillToolEvents(
202
+ execEnd("sed -n '1,240p' .agents/skills/learn/SKILL.md"),
203
+ ctx,
204
+ );
205
+ expect(ev).toEqual([
206
+ {
207
+ skill: "learn",
208
+ source: "model",
209
+ sessionId: "sess-x",
210
+ timestamp: "2026-04-27T07:51:30.395Z",
211
+ cwd: "/home/ec2-user/hq",
212
+ uuid: "codex:skill:sess-x:turn-A:learn",
213
+ hasArgs: false,
214
+ },
215
+ ]);
216
+ });
217
+
218
+ it("captures the directory above SKILL.md as the skill name, even when nested (bridge layout)", () => {
219
+ const ev = extractCodexSkillToolEvents(
220
+ execEnd("rg -n description /Users/me/.codex/skills/hq/indigo:hello-world/SKILL.md"),
221
+ ctx,
222
+ )[0];
223
+ // skills/hq/<name>/SKILL.md → <name>, not "hq".
224
+ expect(ev.skill).toBe("indigo:hello-world");
225
+ expect(ev.source).toBe("model");
226
+ });
227
+
228
+ it("absolute and relative SKILL.md paths both resolve", () => {
229
+ expect(
230
+ extractCodexSkillToolEvents(execEnd("cat /abs/path/.agents/skills/quiz/SKILL.md"), ctx)[0].skill,
231
+ ).toBe("quiz");
232
+ expect(
233
+ extractCodexSkillToolEvents(execEnd("nl -ba .agents/skills/summary/SKILL.md"), ctx)[0].skill,
234
+ ).toBe("summary");
235
+ });
236
+
237
+ it("dedups a turn's repeated reads of the same skill via the shared seen set", () => {
238
+ const seen = new Set<string>();
239
+ const first = extractCodexSkillToolEvents(
240
+ execEnd("sed -n '1,240p' .agents/skills/learn/SKILL.md"),
241
+ ctx,
242
+ seen,
243
+ );
244
+ const second = extractCodexSkillToolEvents(
245
+ execEnd("rg -n 'frequency' .agents/skills/learn/SKILL.md"),
246
+ ctx,
247
+ seen,
248
+ );
249
+ expect(first).toHaveLength(1);
250
+ expect(second).toEqual([]); // same (session, turn, skill) → collapsed
251
+ });
252
+
253
+ it("does NOT dedup the same skill across different turns", () => {
254
+ const seen = new Set<string>();
255
+ const a = extractCodexSkillToolEvents(
256
+ execEnd("sed -n '1,240p' .agents/skills/learn/SKILL.md", { turn_id: "turn-A" }),
257
+ ctx,
258
+ seen,
259
+ );
260
+ const b = extractCodexSkillToolEvents(
261
+ execEnd("sed -n '1,240p' .agents/skills/learn/SKILL.md", { turn_id: "turn-B" }),
262
+ ctx,
263
+ seen,
264
+ );
265
+ expect(a).toHaveLength(1);
266
+ expect(b).toHaveLength(1);
267
+ expect(b[0].uuid).toBe("codex:skill:sess-x:turn-B:learn");
268
+ });
269
+
270
+ it("excludes edits: parsed_cmd not a read, or a write redirect into SKILL.md", () => {
271
+ // Codex classifies the exec as something other than a read.
272
+ expect(
273
+ extractCodexSkillToolEvents(
274
+ execEnd("apply_patch .agents/skills/learn/SKILL.md", { parsed_cmd: [{ type: "write" }] }),
275
+ ctx,
276
+ ),
277
+ ).toEqual([]);
278
+ // A shell write into the skill file, even with a read verb elsewhere.
279
+ expect(
280
+ extractCodexSkillToolEvents(
281
+ execEnd("cat tmp > .agents/skills/learn/SKILL.md", { parsed_cmd: undefined }),
282
+ ctx,
283
+ ),
284
+ ).toEqual([]);
285
+ });
286
+
287
+ it("prefers the row cwd, falling back to session ctx cwd", () => {
288
+ expect(
289
+ extractCodexSkillToolEvents(
290
+ execEnd("sed -n '1,5p' .agents/skills/learn/SKILL.md", { cwd: "/home/ec2-user/hq/sub" }),
291
+ ctx,
292
+ )[0].cwd,
293
+ ).toBe("/home/ec2-user/hq/sub");
294
+ expect(
295
+ extractCodexSkillToolEvents(
296
+ execEnd("sed -n '1,5p' .agents/skills/learn/SKILL.md", { cwd: undefined }),
297
+ ctx,
298
+ )[0].cwd,
299
+ ).toBe(ctx.cwd);
300
+ });
301
+
302
+ it("uuid falls back to timestamp without a turn_id, and is omitted without a session", () => {
303
+ const noTurn = extractCodexSkillToolEvents(
304
+ execEnd("sed -n '1,5p' .agents/skills/learn/SKILL.md", { turn_id: undefined }),
305
+ ctx,
306
+ )[0];
307
+ expect(noTurn.uuid).toBe("codex:skill:sess-x:2026-04-27T07:51:30.395Z:learn");
308
+ const noSession = extractCodexSkillToolEvents(
309
+ execEnd("sed -n '1,5p' .agents/skills/learn/SKILL.md"),
310
+ {},
311
+ )[0];
312
+ expect(noSession.uuid).toBeUndefined();
313
+ });
314
+
315
+ it("ignores non-exec rows and execs that touch no SKILL.md", () => {
316
+ expect(extractCodexSkillToolEvents(execEnd("sed -n '1,5p' src/index.ts"), ctx)).toEqual([]);
317
+ expect(
318
+ extractCodexSkillToolEvents({ type: "event_msg", payload: { type: "task_started" } }, ctx),
319
+ ).toEqual([]);
320
+ expect(
321
+ extractCodexSkillToolEvents({ type: "event_msg", payload: { type: "user_message", message: "/deploy" } }, ctx),
322
+ ).toEqual([]);
323
+ expect(extractCodexSkillToolEvents(null, ctx)).toEqual([]);
324
+ });
325
+
326
+ // The OTHER exec shape some Codex CLI versions emit (observed on 0.128): a
327
+ // `response_item` `function_call` named `exec_command`, with the command +
328
+ // workdir in `arguments` (a JSON string) and NO turn_id/parsed_cmd on the row.
329
+ function fnCall(cmd: string, args: Record<string, unknown> = {}, over: Record<string, unknown> = {}) {
330
+ return {
331
+ timestamp: "2026-06-10T16:39:41.260Z",
332
+ type: "response_item",
333
+ payload: {
334
+ type: "function_call",
335
+ name: "exec_command",
336
+ call_id: "call_abc",
337
+ arguments: JSON.stringify({ cmd, workdir: "/home/ec2-user/hq", ...args }),
338
+ ...over,
339
+ },
340
+ };
341
+ }
342
+
343
+ it("extracts a skill load from the function_call exec shape, turn from ctx", () => {
344
+ const ev = extractCodexSkillToolEvents(
345
+ fnCall("sed -n '1,80p' .claude/skills/indigo:hello-world/SKILL.md"),
346
+ { ...ctx, turnId: "turn-Z" },
347
+ );
348
+ expect(ev).toEqual([
349
+ {
350
+ skill: "indigo:hello-world",
351
+ source: "model",
352
+ sessionId: "sess-x",
353
+ timestamp: "2026-06-10T16:39:41.260Z",
354
+ cwd: "/home/ec2-user/hq", // from arguments.workdir
355
+ uuid: "codex:skill:sess-x:turn-Z:indigo:hello-world",
356
+ hasArgs: false,
357
+ },
358
+ ]);
359
+ });
360
+
361
+ it("function_call: read-intent decided by command text (no parsed_cmd present)", () => {
362
+ // A read verb → counted.
363
+ expect(
364
+ extractCodexSkillToolEvents(fnCall("wc -l .claude/skills/quiz/SKILL.md"), ctx)[0].skill,
365
+ ).toBe("quiz");
366
+ // A write redirect into the skill file → excluded.
367
+ expect(
368
+ extractCodexSkillToolEvents(fnCall("printf x > .claude/skills/quiz/SKILL.md"), ctx),
369
+ ).toEqual([]);
370
+ });
371
+
372
+ it("function_call: ignores non-exec tool names (e.g. apply_patch edits)", () => {
373
+ expect(
374
+ extractCodexSkillToolEvents(
375
+ fnCall("anything", {}, { name: "apply_patch" }),
376
+ ctx,
377
+ ),
378
+ ).toEqual([]);
379
+ });
380
+
381
+ it("function_call: falls back to ctx.cwd when arguments has no workdir", () => {
382
+ expect(
383
+ extractCodexSkillToolEvents(
384
+ fnCall("sed -n '1,5p' .agents/skills/learn/SKILL.md", { workdir: undefined }),
385
+ ctx,
386
+ )[0].cwd,
387
+ ).toBe(ctx.cwd);
388
+ });
389
+
390
+ it("dedups across the two exec shapes within one turn (no double count)", () => {
391
+ const seen = new Set<string>();
392
+ // exec_command_end form for skill `learn` in turn-A …
393
+ const a = extractCodexSkillToolEvents(
394
+ execEnd("sed -n '1,240p' .agents/skills/learn/SKILL.md", { turn_id: "turn-A" }),
395
+ ctx,
396
+ seen,
397
+ );
398
+ // … then the function_call form for the same skill in the same turn.
399
+ const b = extractCodexSkillToolEvents(
400
+ fnCall("rg -n x .agents/skills/learn/SKILL.md"),
401
+ { ...ctx, turnId: "turn-A" },
402
+ seen,
403
+ );
404
+ expect(a).toHaveLength(1);
405
+ expect(b).toEqual([]); // same (session, turn, skill) → collapsed
406
+ });
407
+
408
+ it("codexRowTurnId reads turn_id from a row's payload, else undefined", () => {
409
+ expect(codexRowTurnId({ type: "turn_context", payload: { turn_id: "turn-9" } })).toBe("turn-9");
410
+ expect(codexRowTurnId({ type: "event_msg", payload: { type: "exec_command_end", turn_id: "turn-7" } })).toBe("turn-7");
411
+ expect(codexRowTurnId({ type: "response_item", payload: { type: "function_call" } })).toBeUndefined();
412
+ expect(codexRowTurnId(null)).toBeUndefined();
413
+ });
414
+ });
415
+
176
416
  describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
177
417
  function stubClient(captured: SkillInvocationBatch[]) {
178
418
  return {
@@ -489,4 +729,197 @@ describe("collectAndSendSkillTelemetry — hqRoot scoping", () => {
489
729
 
490
730
  await fs.rm(tmp, { recursive: true, force: true });
491
731
  });
732
+
733
+ // A Codex rollout that drives a skill the way the model actually does: by
734
+ // reading its SKILL.md via exec_command. `reads` is a list of
735
+ // [skill, turnId, cmdTail] tuples turned into exec_command_end rows.
736
+ function codexModelRollout(
737
+ sessionId: string,
738
+ cwd: string,
739
+ reads: Array<[skill: string, turnId: string, extra?: Record<string, unknown>]>,
740
+ ): string {
741
+ const lines = [
742
+ row({ timestamp: "2026-06-08T10:00:00.000Z", type: "session_meta", payload: { id: sessionId, cwd, originator: "codex_exec" } }),
743
+ ...reads.map(([skill, turnId, extra], i) =>
744
+ row({
745
+ timestamp: `2026-06-08T10:0${i}:00.000Z`,
746
+ type: "event_msg",
747
+ payload: {
748
+ type: "exec_command_end",
749
+ call_id: `call_${i}`,
750
+ turn_id: turnId,
751
+ command: ["/bin/zsh", "-lc", `sed -n '1,240p' ${cwd}/.agents/skills/${skill}/SKILL.md`],
752
+ cwd,
753
+ parsed_cmd: [{ type: "read" }],
754
+ ...(extra ?? {}),
755
+ },
756
+ }),
757
+ ),
758
+ ];
759
+ return lines.join("\n") + "\n";
760
+ }
761
+
762
+ it("captures Codex model-driven skill loads, deduped per turn", async () => {
763
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-codexmodel-"));
764
+ const codex = path.join(tmp, "sessions", "2026", "06", "08");
765
+ await fs.mkdir(codex, { recursive: true });
766
+ const hqRoot = "/home/ec2-user/hq";
767
+
768
+ await fs.writeFile(
769
+ path.join(codex, "rollout-model.jsonl"),
770
+ codexModelRollout("sess-model", hqRoot, [
771
+ ["learn", "turn-A"], // turn-A read #1
772
+ ["learn", "turn-A"], // turn-A read #2 — same turn+skill, must collapse
773
+ ["learn", "turn-B"], // turn-B — distinct turn, counts again
774
+ ["quiz", "turn-B"], // different skill, same turn — counts
775
+ ]),
776
+ "utf-8",
777
+ );
778
+
779
+ const captured: SkillInvocationBatch[] = [];
780
+ const result = await collectAndSendSkillTelemetry({
781
+ client: stubClient(captured),
782
+ machineId: "m",
783
+ installerVersion: "t",
784
+ hqRoot,
785
+ claudeProjectsRoot: path.join(tmp, "no-claude"),
786
+ codexSessionsRoot: path.join(tmp, "sessions"),
787
+ cursorPath: path.join(tmp, "cursor.json"),
788
+ });
789
+
790
+ const events = captured.flatMap((b) => b.events);
791
+ // 4 reads → 3 events: the two turn-A `learn` reads collapse to one.
792
+ expect(result.eventsSent).toBe(3);
793
+ expect(events.every((e) => e.source === "model")).toBe(true);
794
+ expect(events.map((e) => `${e.skill}@${e.uuid}`).sort()).toEqual([
795
+ "learn@codex:skill:sess-model:turn-A:learn",
796
+ "learn@codex:skill:sess-model:turn-B:learn",
797
+ "quiz@codex:skill:sess-model:turn-B:quiz",
798
+ ]);
799
+
800
+ await fs.rm(tmp, { recursive: true, force: true });
801
+ });
802
+
803
+ it("captures the function_call exec shape, attributing the turn from turn_context", async () => {
804
+ // Mirrors a real Codex 0.128 session: SKILL.md reads arrive as
805
+ // `response_item` `function_call` execs (no per-row turn_id), preceded by a
806
+ // `turn_context` that names the turn. The collector must thread that turn id.
807
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-fncall-"));
808
+ const codex = path.join(tmp, "sessions", "2026", "06", "10");
809
+ await fs.mkdir(codex, { recursive: true });
810
+ const hqRoot = "/home/ec2-user/hq";
811
+
812
+ const lines = [
813
+ row({ timestamp: "2026-06-10T16:38:32.000Z", type: "session_meta", payload: { id: "sess-fn", cwd: hqRoot, originator: "codex_exec" } }),
814
+ row({ timestamp: "2026-06-10T16:38:44.000Z", type: "turn_context", payload: { turn_id: "turn-1", cwd: hqRoot } }),
815
+ // Two reads of the same skill in turn-1 — the wc then the sed, as Codex does.
816
+ row({ timestamp: "2026-06-10T16:39:26.000Z", type: "response_item", payload: { type: "function_call", name: "exec_command", call_id: "c1", arguments: JSON.stringify({ cmd: "wc -l .claude/skills/indigo:hello-world/SKILL.md", workdir: hqRoot }) } }),
817
+ row({ timestamp: "2026-06-10T16:39:41.000Z", type: "response_item", payload: { type: "function_call", name: "exec_command", call_id: "c2", arguments: JSON.stringify({ cmd: "sed -n '1,80p' .claude/skills/indigo:hello-world/SKILL.md", workdir: hqRoot }) } }),
818
+ ].join("\n") + "\n";
819
+ await fs.writeFile(path.join(codex, "rollout-fn.jsonl"), lines, "utf-8");
820
+
821
+ const captured: SkillInvocationBatch[] = [];
822
+ const result = await collectAndSendSkillTelemetry({
823
+ client: stubClient(captured),
824
+ machineId: "m",
825
+ installerVersion: "t",
826
+ hqRoot,
827
+ claudeProjectsRoot: path.join(tmp, "no-claude"),
828
+ codexSessionsRoot: path.join(tmp, "sessions"),
829
+ cursorPath: path.join(tmp, "cursor.json"),
830
+ });
831
+
832
+ const events = captured.flatMap((b) => b.events);
833
+ // Two reads, one turn, one skill → exactly one event, attributed to turn-1.
834
+ expect(result.eventsSent).toBe(1);
835
+ expect(events[0]).toMatchObject({
836
+ skill: "indigo:hello-world",
837
+ source: "model",
838
+ cwd: hqRoot,
839
+ uuid: "codex:skill:sess-fn:turn-1:indigo:hello-world",
840
+ });
841
+
842
+ await fs.rm(tmp, { recursive: true, force: true });
843
+ });
844
+
845
+ it("does not count SKILL.md edits (apply_patch / write) as skill usage", async () => {
846
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-edit-"));
847
+ const codex = path.join(tmp, "sessions", "2026", "06", "08");
848
+ await fs.mkdir(codex, { recursive: true });
849
+ const hqRoot = "/home/ec2-user/hq";
850
+
851
+ await fs.writeFile(
852
+ path.join(codex, "rollout-edit.jsonl"),
853
+ codexModelRollout("sess-edit", hqRoot, [
854
+ ["learn", "turn-A", { parsed_cmd: [{ type: "write" }] }], // an edit — excluded
855
+ ]),
856
+ "utf-8",
857
+ );
858
+
859
+ const captured: SkillInvocationBatch[] = [];
860
+ const result = await collectAndSendSkillTelemetry({
861
+ client: stubClient(captured),
862
+ machineId: "m",
863
+ installerVersion: "t",
864
+ hqRoot,
865
+ claudeProjectsRoot: path.join(tmp, "no-claude"),
866
+ codexSessionsRoot: path.join(tmp, "sessions"),
867
+ cursorPath: path.join(tmp, "cursor.json"),
868
+ });
869
+
870
+ expect(result.eventsSent).toBe(0);
871
+ await fs.rm(tmp, { recursive: true, force: true });
872
+ });
873
+
874
+ it("scope now counts skills run from HQ subfolders (worktrees), still excludes siblings", async () => {
875
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-tel-scope-"));
876
+ const projects = path.join(tmp, "projects");
877
+ const codex = path.join(tmp, "sessions", "2026", "06", "08");
878
+ await fs.mkdir(projects, { recursive: true });
879
+ await fs.mkdir(codex, { recursive: true });
880
+ const hqRoot = "/home/ec2-user/hq";
881
+ const worktree = `${hqRoot}/.claude/worktrees/wt1`;
882
+ const sibling = `${hqRoot}-other`; // shares the prefix but is a different repo
883
+
884
+ // Claude skill run from a worktree under the HQ root — newly counted.
885
+ const dirWt = path.join(projects, "wt");
886
+ await fs.mkdir(dirWt, { recursive: true });
887
+ await fs.writeFile(
888
+ path.join(dirWt, "wt.jsonl"),
889
+ row({ type: "assistant", sessionId: "s-wt", timestamp: "2026-06-08T10:00:00Z", cwd: worktree, message: { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Skill", input: { skill: "handoff" } }] } }) + "\n",
890
+ "utf-8",
891
+ );
892
+ // Claude skill run from a sibling repo sharing the prefix — must stay excluded.
893
+ const dirSib = path.join(projects, "sib");
894
+ await fs.mkdir(dirSib, { recursive: true });
895
+ await fs.writeFile(
896
+ path.join(dirSib, "sib.jsonl"),
897
+ row({ type: "assistant", sessionId: "s-sib", timestamp: "2026-06-08T10:01:00Z", cwd: sibling, message: { role: "assistant", content: [{ type: "tool_use", id: "t2", name: "Skill", input: { skill: "sibling-skill" } }] } }) + "\n",
898
+ "utf-8",
899
+ );
900
+ // Codex model load from a worktree under the HQ root — newly counted.
901
+ await fs.writeFile(
902
+ path.join(codex, "rollout-wt.jsonl"),
903
+ codexModelRollout("sess-wt", worktree, [["quiz", "turn-A"]]),
904
+ "utf-8",
905
+ );
906
+
907
+ const captured: SkillInvocationBatch[] = [];
908
+ const result = await collectAndSendSkillTelemetry({
909
+ client: stubClient(captured),
910
+ machineId: "m",
911
+ installerVersion: "t",
912
+ hqRoot,
913
+ claudeProjectsRoot: projects,
914
+ codexSessionsRoot: path.join(tmp, "sessions"),
915
+ cursorPath: path.join(tmp, "cursor.json"),
916
+ });
917
+
918
+ const skills = captured.flatMap((b) => b.events.map((e) => e.skill)).sort();
919
+ expect(result.eventsSent).toBe(2);
920
+ expect(skills).toEqual(["handoff", "quiz"]);
921
+ expect(skills).not.toContain("sibling-skill");
922
+
923
+ await fs.rm(tmp, { recursive: true, force: true });
924
+ });
492
925
  });