@indigoai-us/hq-cloud 6.14.3 → 6.14.5

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 (50) hide show
  1. package/dist/bin/sync-runner-company.d.ts +1 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +44 -19
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner.d.ts.map +1 -1
  6. package/dist/bin/sync-runner.js +25 -36
  7. package/dist/bin/sync-runner.js.map +1 -1
  8. package/dist/bin/sync-runner.test.js +128 -4
  9. package/dist/bin/sync-runner.test.js.map +1 -1
  10. package/dist/cli/reindex.d.ts.map +1 -1
  11. package/dist/cli/reindex.js +209 -29
  12. package/dist/cli/reindex.js.map +1 -1
  13. package/dist/cli/reindex.test.js +116 -37
  14. package/dist/cli/reindex.test.js.map +1 -1
  15. package/dist/cli/rescue-clone-diagnostics.test.d.ts +2 -0
  16. package/dist/cli/rescue-clone-diagnostics.test.d.ts.map +1 -0
  17. package/dist/cli/rescue-clone-diagnostics.test.js +101 -0
  18. package/dist/cli/rescue-clone-diagnostics.test.js.map +1 -0
  19. package/dist/cli/rescue-core.d.ts.map +1 -1
  20. package/dist/cli/rescue-core.js +28 -6
  21. package/dist/cli/rescue-core.js.map +1 -1
  22. package/dist/cli/share.js +11 -22
  23. package/dist/cli/share.js.map +1 -1
  24. package/dist/cli/share.test.js +40 -0
  25. package/dist/cli/share.test.js.map +1 -1
  26. package/dist/cli/sync.d.ts.map +1 -1
  27. package/dist/cli/sync.js +16 -14
  28. package/dist/cli/sync.js.map +1 -1
  29. package/dist/cli/sync.test.js +73 -0
  30. package/dist/cli/sync.test.js.map +1 -1
  31. package/dist/watcher.d.ts +1 -1
  32. package/dist/watcher.d.ts.map +1 -1
  33. package/dist/watcher.js +5 -6
  34. package/dist/watcher.js.map +1 -1
  35. package/dist/watcher.test.js +54 -0
  36. package/dist/watcher.test.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/bin/sync-runner-company.ts +50 -22
  39. package/src/bin/sync-runner.test.ts +147 -4
  40. package/src/bin/sync-runner.ts +49 -54
  41. package/src/cli/reindex.test.ts +130 -37
  42. package/src/cli/reindex.ts +198 -28
  43. package/src/cli/rescue-clone-diagnostics.test.ts +120 -0
  44. package/src/cli/rescue-core.ts +32 -8
  45. package/src/cli/share.test.ts +47 -0
  46. package/src/cli/share.ts +16 -26
  47. package/src/cli/sync.test.ts +90 -0
  48. package/src/cli/sync.ts +16 -14
  49. package/src/watcher.test.ts +59 -0
  50. package/src/watcher.ts +6 -8
@@ -1604,6 +1604,34 @@ describe("all-complete aggregate", () => {
1604
1604
  expect(deps.stderr.raw()).toContain("runner.telemetry.timeout");
1605
1605
  });
1606
1606
 
1607
+ it("silences no-op completion and company-leg telemetry but retains session start", async () => {
1608
+ const posts: TelemetryEventsBatch[] = [];
1609
+ const client: VaultClientSurface = {
1610
+ ...makeVaultStub({
1611
+ memberships: [{ companyUid: "cmp_a" }],
1612
+ entityGet: (uid: string) =>
1613
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1614
+ }),
1615
+ postTelemetryEvents: vi.fn(async (batch) => {
1616
+ posts.push(batch);
1617
+ return { ok: true, written: batch.events.length, skipped: [] };
1618
+ }),
1619
+ };
1620
+ const deps = makeDeps({ createVaultClient: () => client });
1621
+
1622
+ const code = await runRunner(["--companies"], deps);
1623
+ await new Promise((resolve) => setTimeout(resolve, 0));
1624
+
1625
+ expect(code).toBe(0);
1626
+ const eventNames = posts
1627
+ .flatMap((post) => post.events)
1628
+ .map((event) => event.eventName);
1629
+ expect(eventNames).toContain("sync_runner_session_started");
1630
+ expect(eventNames).not.toContain("sync_runner_plan_built");
1631
+ expect(eventNames).not.toContain("company_sync_leg_completed");
1632
+ expect(eventNames).not.toContain("sync_runner_completed");
1633
+ });
1634
+
1607
1635
  it("emits action telemetry for completion and telemetry transport failure does not block completion", async () => {
1608
1636
  const posts: TelemetryEventsBatch[] = [];
1609
1637
  const client: VaultClientSurface = {
@@ -1626,11 +1654,25 @@ describe("all-complete aggregate", () => {
1626
1654
  defaultSyncResult({
1627
1655
  filesDownloaded: 2,
1628
1656
  bytesDownloaded: 300,
1657
+ filesTombstoned: 3,
1658
+ scopeOrphansRemoved: 4,
1659
+ scopeOrphansBlocked: 5,
1660
+ }),
1661
+ ),
1662
+ share: vi
1663
+ .fn<(opts: ShareOptions) => Promise<ShareResult>>()
1664
+ .mockResolvedValue(
1665
+ defaultShareResult({
1666
+ filesUploaded: 6,
1667
+ bytesUploaded: 700,
1668
+ filesDeleted: 7,
1669
+ filesTombstoned: 8,
1670
+ filesRefusedStale: 9,
1629
1671
  }),
1630
1672
  ),
1631
1673
  });
1632
1674
 
1633
- const code = await runRunner(["--companies"], deps);
1675
+ const code = await runRunner(["--companies", "--direction", "both"], deps);
1634
1676
  await new Promise((resolve) => setTimeout(resolve, 0));
1635
1677
 
1636
1678
  expect(code).toBe(0);
@@ -1645,19 +1687,120 @@ describe("all-complete aggregate", () => {
1645
1687
  source: "sync-runner",
1646
1688
  schemaVersion: 1,
1647
1689
  properties: {
1648
- result: "completed",
1690
+ status: "completed",
1649
1691
  attemptedCount: 1,
1650
1692
  companyCount: 1,
1651
1693
  errorCount: 0,
1652
1694
  filesDownloaded: 2,
1653
1695
  bytesDownloaded: 300,
1654
- filesUploaded: 0,
1655
- bytesUploaded: 0,
1696
+ filesUploaded: 6,
1697
+ bytesUploaded: 700,
1698
+ },
1699
+ });
1700
+ const companyLeg = posts
1701
+ .flatMap((post) => post.events)
1702
+ .find((event) => event.eventName === "company_sync_leg_completed");
1703
+ expect(companyLeg).toMatchObject({
1704
+ properties: {
1705
+ status: "complete",
1706
+ deletedCount: 7,
1707
+ tombstonedCount: 11,
1708
+ scopeOrphansRemovedCount: 4,
1709
+ refusedStaleCount: 9,
1710
+ scopeOrphansBlockedCount: 5,
1656
1711
  },
1657
1712
  });
1658
1713
  expect(deps.stderr.raw()).toContain("runner.telemetry_events.nonfatal");
1659
1714
  });
1660
1715
 
1716
+ it("retains terminal and company-leg telemetry for a partial sync", async () => {
1717
+ const posts: TelemetryEventsBatch[] = [];
1718
+ const client: VaultClientSurface = {
1719
+ ...makeVaultStub({
1720
+ memberships: [{ companyUid: "cmp_a" }],
1721
+ entityGet: (uid: string) =>
1722
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1723
+ }),
1724
+ postTelemetryEvents: vi.fn(async (batch) => {
1725
+ posts.push(batch);
1726
+ return { ok: true, written: batch.events.length, skipped: [] };
1727
+ }),
1728
+ };
1729
+ const deps = makeDeps({
1730
+ createVaultClient: () => client,
1731
+ sync: vi
1732
+ .fn<(opts: SyncOptions) => Promise<SyncResult>>()
1733
+ .mockResolvedValue(defaultSyncResult({ aborted: true })),
1734
+ });
1735
+
1736
+ const code = await runRunner(["--companies"], deps);
1737
+ await new Promise((resolve) => setTimeout(resolve, 0));
1738
+
1739
+ expect(code).toBe(0);
1740
+ const events = posts.flatMap((post) => post.events);
1741
+ expect(events.find((event) => event.eventName === "company_sync_leg_completed"))
1742
+ .toMatchObject({ properties: { status: "aborted" } });
1743
+ expect(events.find((event) => event.eventName === "sync_runner_completed"))
1744
+ .toMatchObject({ properties: { status: "partial" } });
1745
+ });
1746
+
1747
+ it("retains terminal and company-leg telemetry when a sync leg throws", async () => {
1748
+ const posts: TelemetryEventsBatch[] = [];
1749
+ const client: VaultClientSurface = {
1750
+ ...makeVaultStub({
1751
+ memberships: [{ companyUid: "cmp_a" }],
1752
+ entityGet: (uid: string) =>
1753
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1754
+ }),
1755
+ postTelemetryEvents: vi.fn(async (batch) => {
1756
+ posts.push(batch);
1757
+ return { ok: true, written: batch.events.length, skipped: [] };
1758
+ }),
1759
+ };
1760
+ const deps = makeDeps({
1761
+ createVaultClient: () => client,
1762
+ sync: vi
1763
+ .fn<(opts: SyncOptions) => Promise<SyncResult>>()
1764
+ .mockRejectedValue(new Error("sync failed")),
1765
+ });
1766
+
1767
+ const code = await runRunner(["--companies"], deps);
1768
+ await new Promise((resolve) => setTimeout(resolve, 0));
1769
+
1770
+ expect(code).toBe(PARTIAL_SYNC_EXIT);
1771
+ const events = posts.flatMap((post) => post.events);
1772
+ expect(events.find((event) => event.eventName === "company_sync_leg_completed"))
1773
+ .toMatchObject({ properties: { status: "errored" } });
1774
+ expect(events.find((event) => event.eventName === "sync_runner_completed"))
1775
+ .toMatchObject({ properties: { status: "partial" } });
1776
+ });
1777
+
1778
+ it("retains terminal failure telemetry when post-client discovery fails", async () => {
1779
+ const posts: TelemetryEventsBatch[] = [];
1780
+ const client: VaultClientSurface = {
1781
+ ...makeVaultStub({
1782
+ memberships: [{ companyUid: "cmp_a" }],
1783
+ }),
1784
+ listMyMemberships: () => Promise.reject(new Error("network down")),
1785
+ postTelemetryEvents: vi.fn(async (batch) => {
1786
+ posts.push(batch);
1787
+ return { ok: true, written: batch.events.length, skipped: [] };
1788
+ }),
1789
+ };
1790
+ const deps = makeDeps({ createVaultClient: () => client });
1791
+
1792
+ const code = await runRunner(["--companies"], deps);
1793
+ await new Promise((resolve) => setTimeout(resolve, 0));
1794
+
1795
+ expect(code).toBe(1);
1796
+ expect(posts.flatMap((post) => post.events)).toContainEqual(
1797
+ expect.objectContaining({
1798
+ eventName: "sync_runner_completed",
1799
+ properties: { status: "failed" },
1800
+ }),
1801
+ );
1802
+ });
1803
+
1661
1804
  it("sums filesDownloaded and bytesDownloaded across all companies", async () => {
1662
1805
  const slugs: Record<string, string> = { cmp_a: "acme", cmp_b: "beta" };
1663
1806
  const deps = makeDeps({
@@ -1166,6 +1166,37 @@ export async function runRunner(
1166
1166
  },
1167
1167
  );
1168
1168
 
1169
+ const emitRunnerTerminalTelemetry = (properties: {
1170
+ status: "completed" | "partial" | "failed";
1171
+ attemptedCount?: number;
1172
+ companyCount?: number;
1173
+ errorCount?: number;
1174
+ filesDownloaded?: number;
1175
+ bytesDownloaded?: number;
1176
+ filesUploaded?: number;
1177
+ bytesUploaded?: number;
1178
+ }): void => {
1179
+ void emitCloudTelemetry(
1180
+ client,
1181
+ {
1182
+ eventName: "sync_runner_completed",
1183
+ source: "sync-runner",
1184
+ sessionId: telemetrySessionId,
1185
+ properties,
1186
+ },
1187
+ {
1188
+ claims,
1189
+ log: (message) =>
1190
+ reportDiagnostic({
1191
+ component: "telemetry-events",
1192
+ event: "runner.telemetry_events.nonfatal",
1193
+ message,
1194
+ context: { source: "sync-runner" },
1195
+ }),
1196
+ },
1197
+ );
1198
+ };
1199
+
1169
1200
  // ---- transport selection (presigned-URL for cmp_, direct-S3/STS for prs_) -
1170
1201
  // Company vaults ALWAYS use the presigned-URL transport now (HQ-59): the
1171
1202
  // STS-direct-S3 path for cmp_ is retired — no env override routes a company
@@ -1222,6 +1253,7 @@ export async function runRunner(
1222
1253
  message: err instanceof Error ? err.message : String(err),
1223
1254
  path: "(discovery)",
1224
1255
  });
1256
+ emitRunnerTerminalTelemetry({ status: "failed" });
1225
1257
  // A transient network failure (offline, DNS blip, vault API briefly
1226
1258
  // unreachable) is NOT a crash — it self-heals on the next poll. Return the
1227
1259
  // retryable exit code so the watch loop stays alive instead of exiting and
@@ -1246,31 +1278,6 @@ export async function runRunner(
1246
1278
  }
1247
1279
  const plan = targetPlan.plan;
1248
1280
 
1249
- void emitCloudTelemetry(
1250
- client,
1251
- {
1252
- eventName: "sync_runner_plan_built",
1253
- source: "sync-runner",
1254
- sessionId: telemetrySessionId,
1255
- properties: {
1256
- direction: parsed.direction,
1257
- targetCount: plan.length,
1258
- companyCount: plan.filter((target) => target.personalMode !== true).length,
1259
- personalCount: plan.filter((target) => target.personalMode === true).length,
1260
- },
1261
- },
1262
- {
1263
- claims,
1264
- log: (message) =>
1265
- reportDiagnostic({
1266
- component: "telemetry-events",
1267
- event: "runner.telemetry_events.nonfatal",
1268
- message,
1269
- context: { source: "sync-runner" },
1270
- }),
1271
- },
1272
- );
1273
-
1274
1281
  emitFanoutPlan(emit, plan);
1275
1282
 
1276
1283
  // One-time seed of the reserved personal-vault journal from the legacy
@@ -1301,7 +1308,7 @@ export async function runRunner(
1301
1308
  telemetryClaims: claims,
1302
1309
  telemetrySessionId,
1303
1310
  });
1304
- const { errors, allConflicts } = fanout;
1311
+ const { errors, allConflicts, meaningfulOutcomeCount } = fanout;
1305
1312
  const rollup = rollupAllComplete(plan, fanout.stateByCompany);
1306
1313
 
1307
1314
  if (rollup.needsReindex) {
@@ -1414,34 +1421,22 @@ export async function runRunner(
1414
1421
  partial: rollup.partial,
1415
1422
  companies: rollup.companies,
1416
1423
  });
1417
- void emitCloudTelemetry(
1418
- client,
1419
- {
1420
- eventName: "sync_runner_completed",
1421
- source: "sync-runner",
1422
- sessionId: telemetrySessionId,
1423
- properties: {
1424
- result: rollup.partial ? "partial" : "completed",
1425
- attemptedCount: plan.length,
1426
- companyCount: rollup.companies.length,
1427
- errorCount: errors.length,
1428
- filesDownloaded: rollup.totalDownloaded,
1429
- bytesDownloaded: rollup.totalDownloadedBytes,
1430
- filesUploaded: rollup.totalUploaded,
1431
- bytesUploaded: rollup.totalUploadedBytes,
1432
- },
1433
- },
1434
- {
1435
- claims,
1436
- log: (message) =>
1437
- reportDiagnostic({
1438
- component: "telemetry-events",
1439
- event: "runner.telemetry_events.nonfatal",
1440
- message,
1441
- context: { source: "sync-runner" },
1442
- }),
1443
- },
1444
- );
1424
+ if (
1425
+ rollup.partial ||
1426
+ errors.length > 0 ||
1427
+ meaningfulOutcomeCount > 0
1428
+ ) {
1429
+ emitRunnerTerminalTelemetry({
1430
+ status: rollup.partial || errors.length > 0 ? "partial" : "completed",
1431
+ attemptedCount: plan.length,
1432
+ companyCount: rollup.companies.length,
1433
+ errorCount: errors.length,
1434
+ filesDownloaded: rollup.totalDownloaded,
1435
+ bytesDownloaded: rollup.totalDownloadedBytes,
1436
+ filesUploaded: rollup.totalUploaded,
1437
+ bytesUploaded: rollup.totalUploadedBytes,
1438
+ });
1439
+ }
1445
1440
 
1446
1441
  // Post-sync qmd reindex — runs AFTER `all-complete` is emitted so the
1447
1442
  // menubar/CLI already shows the sync as done; this is a best-effort tail
@@ -558,70 +558,163 @@ describe("reindex", () => {
558
558
  ).toBe(true);
559
559
  });
560
560
 
561
- // --- session-log capture into workspace/.session-logs ---------------------
561
+ // --- multi-harness session-log capture into workspace/.session-logs/<harness>/
562
562
  describe("session-log capture", () => {
563
- let claudeConfigDir: string;
564
- // Claude Code encodes the project cwd into its projects-dir name by
565
- // replacing every non-alphanumeric char with '-'; mirror that here.
566
- const slugFor = (p: string): string => p.replace(/[^a-zA-Z0-9]/g, "-");
563
+ let claudeDir: string; // CLAUDE_CONFIG_DIR
564
+ let codexHome: string; // CODEX_HOME
565
+ let grokHome: string; // GROK_HOME
566
+
567
+ // Per-harness cwd encodings the harnesses actually use.
568
+ const claudeSlug = (p: string): string => p.replace(/[^a-zA-Z0-9]/g, "-");
569
+ const grokKey = (p: string): string => encodeURIComponent(p);
570
+
571
+ const dest = (): string => path.join(root, "workspace", ".session-logs");
567
572
 
568
573
  beforeEach(() => {
569
- claudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "ms-claude-"));
570
- process.env.CLAUDE_CONFIG_DIR = claudeConfigDir;
574
+ claudeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ms-claude-"));
575
+ codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "ms-codex-"));
576
+ grokHome = fs.mkdtempSync(path.join(os.tmpdir(), "ms-grok-"));
577
+ process.env.CLAUDE_CONFIG_DIR = claudeDir;
578
+ process.env.CODEX_HOME = codexHome;
579
+ process.env.GROK_HOME = grokHome;
571
580
  });
572
581
 
573
582
  afterEach(() => {
574
- fs.rmSync(claudeConfigDir, { recursive: true, force: true });
583
+ for (const d of [claudeDir, codexHome, grokHome]) {
584
+ fs.rmSync(d, { recursive: true, force: true });
585
+ }
575
586
  delete process.env.CLAUDE_CONFIG_DIR;
587
+ delete process.env.CODEX_HOME;
588
+ delete process.env.GROK_HOME;
576
589
  });
577
590
 
578
- function seedProjectLogs(): string {
579
- const projDir = path.join(claudeConfigDir, "projects", slugFor(root));
591
+ // Seed a Claude project folder for cwd `cwdPath`, return its dest dir name.
592
+ function seedClaude(cwdPath: string, body = "claude\n"): string {
593
+ const name = claudeSlug(cwdPath);
594
+ const projDir = path.join(claudeDir, "projects", name);
580
595
  fs.mkdirSync(path.join(projDir, "tool-results"), { recursive: true });
581
- fs.writeFileSync(path.join(projDir, "session.jsonl"), "line-1\n");
582
- fs.writeFileSync(path.join(projDir, "tool-results", "r.txt"), "result\n");
583
- return projDir;
596
+ fs.writeFileSync(path.join(projDir, "session.jsonl"), body);
597
+ fs.writeFileSync(path.join(projDir, "tool-results", "r.txt"), "res\n");
598
+ return name;
599
+ }
600
+
601
+ // Seed a Grok session folder for cwd `cwdPath`, return its dest dir name.
602
+ function seedGrok(cwdPath: string, body = "grok\n"): string {
603
+ const name = grokKey(cwdPath);
604
+ const sess = path.join(grokHome, "sessions", name, "uuid-1");
605
+ fs.mkdirSync(sess, { recursive: true });
606
+ fs.writeFileSync(path.join(sess, "chat_history.jsonl"), body);
607
+ return name;
608
+ }
609
+
610
+ // Seed a Codex rollout at YYYY/MM/DD/<file> whose session_meta records `cwd`.
611
+ function seedCodex(cwd: string, date: [string, string, string], file: string): string {
612
+ const [y, m, d] = date;
613
+ const dir = path.join(codexHome, "sessions", y, m, d);
614
+ fs.mkdirSync(dir, { recursive: true });
615
+ const meta = JSON.stringify({ type: "session_meta", payload: { cwd } });
616
+ const event = JSON.stringify({ type: "event_msg", payload: { text: "hi" } });
617
+ const abs = path.join(dir, file);
618
+ fs.writeFileSync(abs, `${meta}\n${event}\n`);
619
+ return abs;
584
620
  }
585
621
 
586
- it("copies all files and folders from the projects dir into workspace/.session-logs", () => {
587
- seedProjectLogs();
622
+ // ── Claude ──────────────────────────────────────────────────────────────
623
+ it("claude: captures the HQ-root project AND descendant projects under claude/", () => {
624
+ const rootName = seedClaude(root);
625
+ const subName = seedClaude(path.join(root, "workspace", "worktrees", "x"));
626
+ // An unrelated project (different root) must NOT be captured.
627
+ seedClaude("/some/other/place");
628
+
629
+ expect(reindex({ repoRoot: root }).status).toBe(0);
630
+
631
+ const c = path.join(dest(), "claude");
632
+ expect(fs.readFileSync(path.join(c, rootName, "session.jsonl"), "utf8")).toBe("claude\n");
633
+ expect(fs.existsSync(path.join(c, subName, "session.jsonl"))).toBe(true);
634
+ expect(fs.existsSync(path.join(c, claudeSlug("/some/other/place")))).toBe(false);
635
+ // Namespaced: claude logs are NOT dumped at the .session-logs root anymore.
636
+ expect(fs.existsSync(path.join(dest(), "session.jsonl"))).toBe(false);
637
+ });
638
+
639
+ it("claude: overwrites an existing dest file but never prunes dest-only entries", () => {
640
+ const rootName = seedClaude(root, "fresh\n");
641
+ const c = path.join(dest(), "claude");
642
+ fs.mkdirSync(path.join(c, rootName), { recursive: true });
643
+ fs.writeFileSync(path.join(c, rootName, "session.jsonl"), "STALE\n");
644
+ fs.writeFileSync(path.join(dest(), "orphan.txt"), "keep\n");
645
+
646
+ expect(reindex({ repoRoot: root }).status).toBe(0);
647
+
648
+ expect(fs.readFileSync(path.join(c, rootName, "session.jsonl"), "utf8")).toBe("fresh\n");
649
+ expect(fs.readFileSync(path.join(dest(), "orphan.txt"), "utf8")).toBe("keep\n");
650
+ });
651
+
652
+ // ── Grok ────────────────────────────────────────────────────────────────
653
+ it("grok: captures the HQ-root and descendant cwd folders under grok/, excludes others", () => {
654
+ const rootName = seedGrok(root);
655
+ const subName = seedGrok(path.join(root, "repos", "private", "x"));
656
+ seedGrok("/tmp/benchmark"); // unrelated cwd → excluded
657
+
658
+ expect(reindex({ repoRoot: root }).status).toBe(0);
659
+
660
+ const g = path.join(dest(), "grok");
661
+ expect(fs.readFileSync(path.join(g, rootName, "uuid-1", "chat_history.jsonl"), "utf8")).toBe("grok\n");
662
+ expect(fs.existsSync(path.join(g, subName, "uuid-1", "chat_history.jsonl"))).toBe(true);
663
+ expect(fs.existsSync(path.join(g, grokKey("/tmp/benchmark")))).toBe(false);
664
+ });
665
+
666
+ // ── Codex ───────────────────────────────────────────────────────────────
667
+ it("codex: captures rollouts whose recorded cwd is the HQ tree, mirroring the date path", () => {
668
+ seedCodex(root, ["2026", "07", "13"], "rollout-root.jsonl");
669
+ seedCodex(path.join(root, "workspace", "worktrees", "y"), ["2026", "07", "13"], "rollout-sub.jsonl");
670
+ seedCodex("/elsewhere/repo", ["2026", "07", "13"], "rollout-other.jsonl");
588
671
 
589
672
  expect(reindex({ repoRoot: root }).status).toBe(0);
590
673
 
591
- const dest = path.join(root, "workspace", ".session-logs");
592
- expect(fs.readFileSync(path.join(dest, "session.jsonl"), "utf8")).toBe("line-1\n");
593
- expect(fs.readFileSync(path.join(dest, "tool-results", "r.txt"), "utf8")).toBe("result\n");
674
+ const cx = path.join(dest(), "codex", "2026", "07", "13");
675
+ expect(fs.existsSync(path.join(cx, "rollout-root.jsonl"))).toBe(true);
676
+ expect(fs.existsSync(path.join(cx, "rollout-sub.jsonl"))).toBe(true);
677
+ // A rollout run from outside the HQ tree is never captured (tenant scope).
678
+ expect(fs.existsSync(path.join(cx, "rollout-other.jsonl"))).toBe(false);
594
679
  });
595
680
 
596
- it("overwrites an existing dest file with the newer source copy", () => {
597
- seedProjectLogs();
598
- const dest = path.join(root, "workspace", ".session-logs");
599
- fs.mkdirSync(dest, { recursive: true });
600
- fs.writeFileSync(path.join(dest, "session.jsonl"), "STALE\n");
681
+ it("codex: skips a rollout already captured and unchanged (mtime guard)", () => {
682
+ const src = seedCodex(root, ["2026", "07", "13"], "rollout-a.jsonl");
683
+ expect(reindex({ repoRoot: root }).status).toBe(0);
684
+
685
+ // Tamper the dest copy and push its mtime into the future; an unchanged
686
+ // src must be skipped, leaving the sentinel in place.
687
+ const destFile = path.join(dest(), "codex", "2026", "07", "13", "rollout-a.jsonl");
688
+ fs.writeFileSync(destFile, "SENTINEL\n");
689
+ const future = Date.now() / 1000 + 3600;
690
+ fs.utimesSync(destFile, future, future);
601
691
 
602
692
  expect(reindex({ repoRoot: root }).status).toBe(0);
693
+ expect(fs.readFileSync(destFile, "utf8")).toBe("SENTINEL\n");
603
694
 
604
- expect(fs.readFileSync(path.join(dest, "session.jsonl"), "utf8")).toBe("line-1\n");
695
+ // But when the src is newer, it IS re-copied (overwriting the sentinel).
696
+ fs.writeFileSync(src, `${JSON.stringify({ type: "session_meta", payload: { cwd: root } })}\nNEW\n`);
697
+ const soon = Date.now() / 1000 + 7200;
698
+ fs.utimesSync(src, soon, soon);
699
+ expect(reindex({ repoRoot: root }).status).toBe(0);
700
+ expect(fs.readFileSync(destFile, "utf8")).toContain("NEW");
605
701
  });
606
702
 
607
- it("never deletes dest entries that have no source counterpart", () => {
608
- seedProjectLogs();
609
- const dest = path.join(root, "workspace", ".session-logs");
610
- fs.mkdirSync(path.join(dest, "old-run"), { recursive: true });
611
- fs.writeFileSync(path.join(dest, "old-run", "keep.jsonl"), "keep\n");
612
- fs.writeFileSync(path.join(dest, "orphan.txt"), "orphan\n");
703
+ // ── Cross-harness ─────────────────────────────────────────────────────────
704
+ it("all three harnesses land side-by-side under their own subfolders", () => {
705
+ seedClaude(root);
706
+ seedGrok(root);
707
+ seedCodex(root, ["2026", "07", "14"], "rollout-z.jsonl");
613
708
 
614
709
  expect(reindex({ repoRoot: root }).status).toBe(0);
615
710
 
616
- // Source content is present...
617
- expect(fs.existsSync(path.join(dest, "session.jsonl"))).toBe(true);
618
- // ...and the dest-only entries survived (no prune).
619
- expect(fs.readFileSync(path.join(dest, "old-run", "keep.jsonl"), "utf8")).toBe("keep\n");
620
- expect(fs.readFileSync(path.join(dest, "orphan.txt"), "utf8")).toBe("orphan\n");
711
+ expect(fs.existsSync(path.join(dest(), "claude", claudeSlug(root), "session.jsonl"))).toBe(true);
712
+ expect(fs.existsSync(path.join(dest(), "grok", grokKey(root), "uuid-1", "chat_history.jsonl"))).toBe(true);
713
+ expect(fs.existsSync(path.join(dest(), "codex", "2026", "07", "14", "rollout-z.jsonl"))).toBe(true);
621
714
  });
622
715
 
623
- it("no-ops (still exit 0) when there is no projects dir for this root", () => {
624
- // No seedProjectLogs()the source dir doesn't exist.
716
+ it("no-ops (still exit 0) when no harness has logs for this root", () => {
717
+ // Home dirs exist but are empty nothing to capture, no dest created.
625
718
  expect(reindex({ repoRoot: root }).status).toBe(0);
626
719
  expect(fs.existsSync(path.join(root, "workspace", ".session-logs"))).toBe(false);
627
720
  });