@indigoai-us/hq-cloud 6.3.0 → 6.3.2

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 (36) hide show
  1. package/dist/bin/sync-runner.d.ts +22 -2
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +85 -2
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +201 -0
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/rescue-core.js +14 -2
  8. package/dist/cli/rescue-core.js.map +1 -1
  9. package/dist/cli/rescue-hq-root-guard.test.d.ts +2 -0
  10. package/dist/cli/rescue-hq-root-guard.test.d.ts.map +1 -0
  11. package/dist/cli/rescue-hq-root-guard.test.js +176 -0
  12. package/dist/cli/rescue-hq-root-guard.test.js.map +1 -0
  13. package/dist/skill-telemetry.d.ts +42 -6
  14. package/dist/skill-telemetry.d.ts.map +1 -1
  15. package/dist/skill-telemetry.js +253 -10
  16. package/dist/skill-telemetry.js.map +1 -1
  17. package/dist/skill-telemetry.test.js +287 -1
  18. package/dist/skill-telemetry.test.js.map +1 -1
  19. package/dist/sync/event-sync.d.ts +181 -0
  20. package/dist/sync/event-sync.d.ts.map +1 -0
  21. package/dist/sync/event-sync.js +316 -0
  22. package/dist/sync/event-sync.js.map +1 -0
  23. package/dist/sync/event-sync.test.d.ts +14 -0
  24. package/dist/sync/event-sync.test.d.ts.map +1 -0
  25. package/dist/sync/event-sync.test.js +440 -0
  26. package/dist/sync/event-sync.test.js.map +1 -0
  27. package/package.json +1 -1
  28. package/src/bin/sync-runner.test.ts +246 -0
  29. package/src/bin/sync-runner.ts +117 -3
  30. package/src/cli/rescue-core.ts +15 -2
  31. package/src/cli/rescue-hq-root-guard.test.ts +193 -0
  32. package/src/skill-telemetry.test.ts +433 -0
  33. package/src/skill-telemetry.ts +260 -10
  34. package/src/sync/event-sync.test.ts +533 -0
  35. package/src/sync/event-sync.ts +481 -0
  36. package/test/e2e/sync/cross-tenant-isolation.test.ts +126 -0
@@ -25,12 +25,24 @@
25
25
  *
26
26
  * Codex CLI is captured too, from its own rollout logs at
27
27
  * `~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl`. Codex records cwd +
28
- * sessionId ONCE in a leading `session_meta` line (not on every row), and a
29
- * typed slash command including an HQ skill, e.g. `/indigo:hello-world` — is
30
- * logged verbatim as a later `event_msg` `user_message` (Codex does not expand
31
- * it). That parallels Claude's typed path exactly; Codex has no discrete "skill
32
- * tool_use" event, so only the typed source is captured for it. Both runtimes
33
- * funnel into the same wire shape, scope filter, batcher, and per-file cursor.
28
+ * sessionId ONCE in a leading `session_meta` line (not on every row). Two Codex
29
+ * paths feed the same wire shape, scope filter, batcher, and per-file cursor:
30
+ * - Typed (`source: "typed"`) a slash command, including an HQ skill, e.g.
31
+ * `/indigo:hello-world`, is logged verbatim as a later `event_msg`
32
+ * `user_message` (Codex does not expand it). Parallels Claude's typed path.
33
+ * - Model-driven (`source: "model"`) Codex has no discrete "Skill tool_use"
34
+ * event like Claude. Instead it *runs* a skill by reading its instruction
35
+ * file: the model issues a shell command that reads `…/skills/<name>/
36
+ * SKILL.md`. Codex logs that exec in one of two shapes depending on CLI
37
+ * version — an `event_msg` `exec_command_end` (with `turn_id`, `cwd`, and a
38
+ * `parsed_cmd` it tags `type: "read"`) or a `response_item` `function_call`
39
+ * named `exec_command` (command + `workdir` in its `arguments`). Both are
40
+ * handled. We treat the read as one invocation of `<name>`. A single use
41
+ * re-reads the file several times (line ranges, greps) and a version may log
42
+ * both shapes for one exec, so events are deduped per (sessionId, turn_id,
43
+ * skill) — at most one per Codex turn. Edits to a SKILL.md travel via
44
+ * `apply_patch` (authoring, not using) and never reach this path, so skill
45
+ * development is not miscounted as usage.
34
46
  *
35
47
  * Privacy: raw `<command-args>` / `input.args` content is NEVER sent to the
36
48
  * cloud — only a `hasArgs` boolean. This matches the message-stripping posture
@@ -265,6 +277,18 @@ export function parseCodexSessionMeta(
265
277
  return { sessionId, cwd };
266
278
  }
267
279
 
280
+ /** The `turn_id` a Codex rollout row belongs to, when it carries one
281
+ * (`turn_context` and `exec_command_end` do; bare `function_call` execs do
282
+ * not). Used to track the running turn so the function_call exec shape can be
283
+ * attributed to the turn that preceded it. */
284
+ export function codexRowTurnId(row: unknown): string | undefined {
285
+ if (!row || typeof row !== "object" || Array.isArray(row)) return undefined;
286
+ const payload = (row as Record<string, unknown>).payload;
287
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
288
+ const t = (payload as Record<string, unknown>).turn_id;
289
+ return typeof t === "string" ? t : undefined;
290
+ }
291
+
268
292
  /**
269
293
  * Extract a typed skill/slash-command invocation from a Codex `event_msg`
270
294
  * `user_message` row. Session context (cwd, sessionId) lives in the file's
@@ -309,6 +333,206 @@ export function extractCodexSkillEvents(
309
333
  ];
310
334
  }
311
335
 
336
+ /** Match a `…/skills/<name>/SKILL.md` path inside a shell command. `skills/`
337
+ * may be nested arbitrarily deep (`.agents/skills/…`, `.codex/skills/hq/…`),
338
+ * and `<name>` is always the directory immediately above SKILL.md — captured
339
+ * as the last segment so the bridge's `skills/hq/<name>/` layout resolves to
340
+ * `<name>`, not `hq`. The leading `(?:…/)*` is non-greedy via the segment class
341
+ * so it stops at the final directory boundary. */
342
+ const CODEX_SKILL_FILE =
343
+ /(?:^|\/)skills\/(?:[^\s'"]+\/)*?([^/\s'"]+)\/SKILL\.md\b/;
344
+
345
+ /** Pull the shell command string out of a Codex `exec_command_end` `command`,
346
+ * which is `["/bin/zsh", "-lc", "<cmd>"]` (array) on the runtimes we see, but
347
+ * tolerate a bare string too. */
348
+ function codexCommandString(command: unknown): string {
349
+ if (typeof command === "string") return command;
350
+ if (Array.isArray(command)) {
351
+ // The interpreter + flags lead; the actual command is the trailing string.
352
+ for (let i = command.length - 1; i >= 0; i--) {
353
+ if (typeof command[i] === "string") return command[i] as string;
354
+ }
355
+ }
356
+ return "";
357
+ }
358
+
359
+ /** Classify a Codex exec from its own `parsed_cmd`:
360
+ * - `"read"` — every classified sub-command is a read; authoritative.
361
+ * - `"nonread"` — at least one write/apply_patch-style entry; authoritative,
362
+ * the command text is NOT consulted (Codex's call wins).
363
+ * - `"unknown"` — no usable classification (missing/empty); the caller then
364
+ * falls back to a read-verb check on the command text.
365
+ */
366
+ function classifyCodexExec(parsedCmd: unknown): "read" | "nonread" | "unknown" {
367
+ if (!Array.isArray(parsedCmd)) return "unknown";
368
+ let sawRead = false;
369
+ for (const entry of parsedCmd) {
370
+ if (!entry || typeof entry !== "object") continue;
371
+ const t = (entry as Record<string, unknown>).type;
372
+ if (typeof t !== "string") continue;
373
+ if (t !== "read") return "nonread";
374
+ sawRead = true;
375
+ }
376
+ return sawRead ? "read" : "unknown";
377
+ }
378
+
379
+ // A leading read verb in the command — the positive signal that an exec is
380
+ // inspecting SKILL.md rather than rewriting it. Anchored after an optional
381
+ // `cd …;`/env-var prefix so `sed`, `rg`, `cat`, … are recognized at the head of
382
+ // the real command.
383
+ const CODEX_READ_VERB =
384
+ /(?:^|[;&|]\s*|\bcd\s+[^\s;]+\s*;\s*)(sed|cat|head|tail|nl|rg|grep|less|bat|wc|awk|cut|fold|view|print)\b/;
385
+ // Writing into the skill file disqualifies regardless of a read verb elsewhere.
386
+ const CODEX_WRITE_TO_SKILL = /(?:>>?|\btee\b)[^\n]*\/SKILL\.md\b/;
387
+
388
+ /** Codex tool-call names that run a shell command (the `response_item`
389
+ * `function_call` form). Excludes `apply_patch` — that is an edit, not a read. */
390
+ const CODEX_EXEC_TOOLS = new Set([
391
+ "exec_command",
392
+ "shell",
393
+ "local_shell",
394
+ "local_shell_call",
395
+ "bash",
396
+ "container.exec",
397
+ ]);
398
+
399
+ /**
400
+ * Normalize a completed Codex exec from either shape the CLI emits (it varies
401
+ * by version), returning the command string, the turn it belongs to, the run
402
+ * `cwd`, and any `parsed_cmd` classification — or null when the row is neither.
403
+ * - `event_msg` / `exec_command_end`: `command` array, own `turn_id` + `cwd`,
404
+ * and a `parsed_cmd` Codex tags `type: "read"`.
405
+ * - `response_item` / `function_call` (name `exec_command`/`shell`/…): the
406
+ * command lives in `arguments` (a JSON string) as `cmd`/`command`, the run
407
+ * dir as `workdir`. No `turn_id`/`parsed_cmd` on the row, so the turn comes
408
+ * from the scan's running `ctx.turnId` (tracked from `turn_context`) and
409
+ * read-intent is decided by the command text.
410
+ */
411
+ function codexExecParams(
412
+ obj: Record<string, unknown>,
413
+ payload: Record<string, unknown>,
414
+ ctx: { cwd?: string; turnId?: string },
415
+ ): { cmd: string; turnId?: string; cwd?: string; parsedCmd: unknown } | null {
416
+ if (obj.type === "event_msg" && payload.type === "exec_command_end") {
417
+ const cmd = codexCommandString(payload.command);
418
+ if (!cmd) return null;
419
+ return {
420
+ cmd,
421
+ turnId: typeof payload.turn_id === "string" ? payload.turn_id : ctx.turnId,
422
+ cwd: typeof payload.cwd === "string" ? payload.cwd : ctx.cwd,
423
+ parsedCmd: payload.parsed_cmd,
424
+ };
425
+ }
426
+ if (obj.type === "response_item" && payload.type === "function_call") {
427
+ const name = typeof payload.name === "string" ? payload.name : "";
428
+ if (!CODEX_EXEC_TOOLS.has(name)) return null;
429
+ let args: Record<string, unknown> = {};
430
+ const raw = payload.arguments;
431
+ if (typeof raw === "string") {
432
+ try {
433
+ const parsed = JSON.parse(raw);
434
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
435
+ args = parsed as Record<string, unknown>;
436
+ }
437
+ } catch {
438
+ return null;
439
+ }
440
+ } else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
441
+ args = raw as Record<string, unknown>;
442
+ }
443
+ const cmdRaw = args.cmd ?? args.command;
444
+ const cmd = typeof cmdRaw === "string" ? cmdRaw : codexCommandString(cmdRaw);
445
+ if (!cmd) return null;
446
+ return {
447
+ cmd,
448
+ turnId: ctx.turnId,
449
+ cwd: typeof args.workdir === "string" ? args.workdir : ctx.cwd,
450
+ parsedCmd: undefined,
451
+ };
452
+ }
453
+ return null;
454
+ }
455
+
456
+ /**
457
+ * Extract a model-driven skill invocation from a completed Codex exec — the
458
+ * model ran a shell command that *reads* a skill's `SKILL.md`, which is how
459
+ * Codex loads and runs a skill (it has no discrete Skill tool_use). Handles both
460
+ * Codex exec shapes (see `codexExecParams`). Returns 0 or 1 event tagged
461
+ * `source: "model"`.
462
+ *
463
+ * Dedup is per (sessionId, turn_id, skill): a single skill use re-reads the file
464
+ * several times (line ranges, greps) and some Codex versions log both exec
465
+ * shapes for one exec, so the caller threads a `seen` Set to collapse them to
466
+ * one event per Codex turn. When `seen` is omitted (unit tests), no dedup is
467
+ * applied. Session context (sessionId, cwd, and the running turnId) comes via
468
+ * `ctx`; the row's own `cwd` is preferred when present.
469
+ */
470
+ export function extractCodexSkillToolEvents(
471
+ row: unknown,
472
+ ctx: { sessionId?: string; cwd?: string; turnId?: string },
473
+ seen?: Set<string>,
474
+ ): SkillEvent[] {
475
+ if (!row || typeof row !== "object" || Array.isArray(row)) return [];
476
+ const obj = row as Record<string, unknown>;
477
+ const payload =
478
+ obj.payload && typeof obj.payload === "object" && !Array.isArray(obj.payload)
479
+ ? (obj.payload as Record<string, unknown>)
480
+ : undefined;
481
+ if (!payload) return [];
482
+
483
+ const exec = codexExecParams(obj, payload, ctx);
484
+ if (!exec) return [];
485
+ const { cmd } = exec;
486
+ const m = CODEX_SKILL_FILE.exec(cmd);
487
+ if (!m) return [];
488
+ // Confirm the exec is a read of the skill file, not a write to it. Codex's own
489
+ // classification leads; only when it is absent does the command text decide.
490
+ if (CODEX_WRITE_TO_SKILL.test(cmd)) return [];
491
+ const cls = classifyCodexExec(exec.parsedCmd);
492
+ if (cls === "nonread") return [];
493
+ if (cls === "unknown" && !CODEX_READ_VERB.test(cmd)) return [];
494
+
495
+ const skill = m[1].trim();
496
+ if (!skill) return [];
497
+
498
+ const sessionId = ctx.sessionId;
499
+ const turnId = exec.turnId;
500
+ const timestamp = typeof obj.timestamp === "string" ? obj.timestamp : undefined;
501
+ const cwd = exec.cwd;
502
+
503
+ // Dedup key: one invocation per (session, turn, skill). Fall back to the
504
+ // session when no turn_id is present (still collapses a turn's repeat reads
505
+ // for the common single-turn case, since they share a timestamp-free key).
506
+ const dedupKey = `${sessionId ?? ""}:${turnId ?? ""}:${skill}`;
507
+ if (seen) {
508
+ if (seen.has(dedupKey)) return [];
509
+ seen.add(dedupKey);
510
+ }
511
+
512
+ // Synthesize a stable uuid so re-delivery across syncs is idempotent
513
+ // server-side. Prefer (session, turn) — globally unique per invocation; fall
514
+ // back to (session, timestamp) so distinct reads aren't all collapsed when a
515
+ // turn_id is somehow absent.
516
+ const uuid =
517
+ sessionId !== undefined && turnId !== undefined
518
+ ? `codex:skill:${sessionId}:${turnId}:${skill}`
519
+ : sessionId !== undefined && timestamp !== undefined
520
+ ? `codex:skill:${sessionId}:${timestamp}:${skill}`
521
+ : undefined;
522
+
523
+ return [
524
+ {
525
+ skill,
526
+ source: "model",
527
+ sessionId,
528
+ timestamp,
529
+ cwd,
530
+ uuid,
531
+ hasArgs: false,
532
+ },
533
+ ];
534
+ }
535
+
312
536
  /** Shape the event for the wire. Drops raw args unless explicitly enabled. */
313
537
  function toWireRow(ev: SkillEvent): Record<string, unknown> {
314
538
  const row: Record<string, unknown> = {
@@ -512,6 +736,14 @@ export async function collectAndSendSkillTelemetry(
512
736
  // `session_meta` line, which we read from the top regardless of the cursor.
513
737
  const codexCtx =
514
738
  kind === "codex" ? await readCodexSessionContext(filePath) : undefined;
739
+ // Per-file dedup of model-driven Codex skill loads: a single skill use
740
+ // re-reads SKILL.md several times within one turn, so collapse them to one
741
+ // event per (session, turn, skill). Scoped per file = per Codex session.
742
+ const codexSeen = kind === "codex" ? new Set<string>() : undefined;
743
+ // Running turn id for Codex: the `function_call` exec shape carries no
744
+ // turn_id of its own, so we track the latest one seen (from `turn_context`,
745
+ // which precedes a turn's execs) and attribute those execs to it.
746
+ let codexTurnId: string | undefined;
515
747
 
516
748
  // Compute the absolute end-byte offset of each line in the read region.
517
749
  const segments = content.split("\n");
@@ -529,14 +761,32 @@ export async function collectAndSendSkillTelemetry(
529
761
  } catch {
530
762
  continue;
531
763
  }
764
+ if (kind === "codex") {
765
+ const t = codexRowTurnId(parsed);
766
+ if (t !== undefined) codexTurnId = t;
767
+ }
532
768
  const events =
533
769
  kind === "codex"
534
- ? extractCodexSkillEvents(parsed, codexCtx ?? {})
770
+ ? [
771
+ ...extractCodexSkillEvents(parsed, codexCtx ?? {}),
772
+ ...extractCodexSkillToolEvents(
773
+ parsed,
774
+ { ...(codexCtx ?? {}), turnId: codexTurnId },
775
+ codexSeen,
776
+ ),
777
+ ]
535
778
  : extractSkillEvents(parsed);
536
779
  for (const ev of events) {
537
- // Scope filter: only emit invocations made from the HQ project.
538
- if (scopeCwd !== undefined && (ev.cwd === undefined || normalizePath(ev.cwd) !== scopeCwd)) {
539
- continue;
780
+ // Scope filter: only emit invocations made from the HQ project — its
781
+ // root or any path beneath it (worktrees, nested apps), so a session run
782
+ // from `<hqRoot>/.claude/worktrees/…` still counts. Sibling repos that
783
+ // merely share a path prefix (`<hqRoot>-other`) are excluded by the
784
+ // trailing-slash boundary.
785
+ if (scopeCwd !== undefined) {
786
+ const c = ev.cwd === undefined ? undefined : normalizePath(ev.cwd);
787
+ if (c === undefined || (c !== scopeCwd && !c.startsWith(`${scopeCwd}/`))) {
788
+ continue;
789
+ }
540
790
  }
541
791
  sourced.push({ row: toWireRow(ev), filePath, endOffset });
542
792
  fileScans[filePath].eventCount++;