@indigoai-us/hq-cloud 6.13.0 → 6.13.1

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.
@@ -223,6 +223,17 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
223
223
  }
224
224
  try {
225
225
 
226
+ // Overlay/skill-wrapper mirroring is best-effort and, on a normal synced HQ,
227
+ // legitimately no-ops for MANY entries (a real core file already sits where a
228
+ // symlink would go, or a link already points elsewhere). Emitting one stderr
229
+ // line PER such entry floods the runner's Sentry breadcrumb ring (100 entries)
230
+ // and evicts the real per-company sync error, making code-2 syncs impossible to
231
+ // triage (HQ-SYNC-2 / HQ-SYNC-WEB-6). Collapse those per-file lines into a
232
+ // single end-of-run summary; keep per-file detail behind HQ_REINDEX_VERBOSE=1.
233
+ const verbose = process.env.HQ_REINDEX_VERBOSE === "1";
234
+ let skippedNonSymlink = 0;
235
+ let skippedPointsElsewhere = 0;
236
+
226
237
  // Best-effort: each wrapper mkdir below is recursive and re-creates this
227
238
  // parent as needed, so a failure here is non-fatal — warn and carry on so
228
239
  // personal-overlay mirroring and registry regeneration still run.
@@ -341,12 +352,18 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
341
352
  if (lst && lst.isSymbolicLink()) {
342
353
  const current = readlinkOrNull(linkPath);
343
354
  if (current === relativeTarget) continue;
344
- warn(
345
- `reindex: .claude/skills/${wrapperName}/${entry} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
346
- );
355
+ skippedPointsElsewhere++;
356
+ if (verbose) {
357
+ warn(
358
+ `reindex: .claude/skills/${wrapperName}/${entry} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
359
+ );
360
+ }
347
361
  continue;
348
362
  } else if (lst) {
349
- warn(`reindex: ${linkPath} already exists and is not a symlink; skipping`);
363
+ skippedNonSymlink++;
364
+ if (verbose) {
365
+ warn(`reindex: ${linkPath} already exists and is not a symlink; skipping`);
366
+ }
350
367
  continue;
351
368
  }
352
369
 
@@ -440,12 +457,18 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
440
457
  if (lst && lst.isSymbolicLink()) {
441
458
  const current = readlinkOrNull(linkPath);
442
459
  if (current === relativeTarget) continue;
443
- warn(
444
- `reindex: core/${type}/${entry} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
445
- );
460
+ skippedPointsElsewhere++;
461
+ if (verbose) {
462
+ warn(
463
+ `reindex: core/${type}/${entry} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
464
+ );
465
+ }
446
466
  continue;
447
467
  } else if (lst) {
448
- warn(`reindex: core/${type}/${entry} already exists and is not a symlink; skipping`);
468
+ skippedNonSymlink++;
469
+ if (verbose) {
470
+ warn(`reindex: core/${type}/${entry} already exists and is not a symlink; skipping`);
471
+ }
449
472
  continue;
450
473
  }
451
474
 
@@ -463,6 +486,22 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
463
486
  spawnSync("bash", [genScript], { stdio: ["ignore", 2, 2] });
464
487
  }
465
488
 
489
+ // One-line summary in place of the per-file overlay-skip spam (see the comment
490
+ // at the top of the try). Only emitted when something was skipped, so a clean
491
+ // run stays silent and the breadcrumb ring is free for real sync errors.
492
+ if (skippedNonSymlink > 0 || skippedPointsElsewhere > 0) {
493
+ const parts: string[] = [];
494
+ if (skippedNonSymlink > 0) {
495
+ parts.push(`${skippedNonSymlink} pre-existing non-symlink ${skippedNonSymlink === 1 ? "entry" : "entries"}`);
496
+ }
497
+ if (skippedPointsElsewhere > 0) {
498
+ parts.push(`${skippedPointsElsewhere} ${skippedPointsElsewhere === 1 ? "entry" : "entries"} already pointing elsewhere`);
499
+ }
500
+ warn(
501
+ `reindex: left ${parts.join(" + ")} untouched (overlay mirror is best-effort; set HQ_REINDEX_VERBOSE=1 for per-file detail)`,
502
+ );
503
+ }
504
+
466
505
  return { status: 0 };
467
506
  } finally {
468
507
  opLock?.release();
@@ -25,6 +25,7 @@ import {
25
25
  gcTombstones,
26
26
  generatePullId,
27
27
  TOMBSTONE_TTL_MS,
28
+ MAX_PULLS_PER_COMPANY,
28
29
  PERSONAL_VAULT_JOURNAL_SLUG,
29
30
  migratePersonalVaultJournal,
30
31
  listJournals,
@@ -463,6 +464,49 @@ describe("journal", () => {
463
464
  "2026-05-20T00:00:00.000Z",
464
465
  );
465
466
  });
467
+
468
+ it("bounds pulls per company and preserves newest lastPullRecord", () => {
469
+ const j: SyncJournal = {
470
+ version: "2",
471
+ lastSync: "",
472
+ files: {},
473
+ pulls: [],
474
+ };
475
+ const completedAt = (n: number) =>
476
+ `2026-05-20T00:00:${String(n).padStart(2, "0")}.000Z`;
477
+
478
+ for (let i = 0; i < MAX_PULLS_PER_COMPANY + 5; i++) {
479
+ appendPullRecord(
480
+ j,
481
+ pull({
482
+ pullId: `pull-a-${String(i).padStart(2, "0")}`,
483
+ companyUid: "cmp_a",
484
+ completedAt: completedAt(i),
485
+ }),
486
+ );
487
+ }
488
+ for (let i = 0; i < 3; i++) {
489
+ appendPullRecord(
490
+ j,
491
+ pull({
492
+ pullId: `pull-b-${i}`,
493
+ companyUid: "cmp_b",
494
+ completedAt: completedAt(i),
495
+ }),
496
+ );
497
+ }
498
+
499
+ const pulls = j.pulls ?? [];
500
+ const companyA = pulls.filter((p) => p.companyUid === "cmp_a");
501
+ const companyB = pulls.filter((p) => p.companyUid === "cmp_b");
502
+ expect(companyA).toHaveLength(MAX_PULLS_PER_COMPANY);
503
+ expect(companyB).toHaveLength(3);
504
+ expect(companyA[0].pullId).toBe("pull-a-05");
505
+ expect(companyA.at(-1)?.pullId).toBe("pull-a-54");
506
+ expect(companyA.some((p) => p.pullId === "pull-a-00")).toBe(false);
507
+ expect(lastPullRecord(j, "cmp_a")?.pullId).toBe("pull-a-54");
508
+ expect(lastPullRecord(j, "cmp_b")?.pullId).toBe("pull-b-2");
509
+ });
466
510
  });
467
511
 
468
512
  describe("tombstoneEntry + isTombstone", () => {
package/src/journal.ts CHANGED
@@ -26,6 +26,13 @@ export const TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
26
26
  /** Current journal schema version written by all v2-aware writers. */
27
27
  export const JOURNAL_VERSION_CURRENT = "2" as const;
28
28
 
29
+ /**
30
+ * Retain bounded pull history per company. Scope-shrink logic only needs the
31
+ * newest record, but a small tail keeps diagnostics useful without letting
32
+ * long-running sync loops grow journals forever.
33
+ */
34
+ export const MAX_PULLS_PER_COMPANY = 50;
35
+
29
36
  const JOURNAL_FILE_PREFIX = "sync-journal.";
30
37
  const JOURNAL_FILE_SUFFIX = ".json";
31
38
  const JOURNAL_LAST_GOOD_SUFFIX = ".last-good";
@@ -517,13 +524,41 @@ export function lastPullRecord(
517
524
  return best;
518
525
  }
519
526
 
520
- /** Append a `PullRecord` (mutates `journal.pulls`). */
527
+ function trimPullRecords(journal: SyncJournal): void {
528
+ const pulls = journal.pulls;
529
+ if (!pulls || pulls.length === 0) return;
530
+
531
+ const byCompany = new Map<string, Array<{ record: PullRecord; index: number }>>();
532
+ pulls.forEach((record, index) => {
533
+ const entries = byCompany.get(record.companyUid);
534
+ if (entries) entries.push({ record, index });
535
+ else byCompany.set(record.companyUid, [{ record, index }]);
536
+ });
537
+
538
+ const keep = new Set<number>();
539
+ for (const entries of byCompany.values()) {
540
+ entries
541
+ .sort((a, b) => {
542
+ const byCompletedAt = b.record.completedAt.localeCompare(
543
+ a.record.completedAt,
544
+ );
545
+ return byCompletedAt === 0 ? b.index - a.index : byCompletedAt;
546
+ })
547
+ .slice(0, MAX_PULLS_PER_COMPANY)
548
+ .forEach((entry) => keep.add(entry.index));
549
+ }
550
+
551
+ journal.pulls = pulls.filter((_, index) => keep.has(index));
552
+ }
553
+
554
+ /** Append a `PullRecord` (mutates `journal.pulls`) and cap retained history. */
521
555
  export function appendPullRecord(
522
556
  journal: SyncJournal,
523
557
  record: PullRecord,
524
558
  ): void {
525
559
  migrateToV2(journal);
526
560
  journal.pulls!.push(record);
561
+ trimPullRecords(journal);
527
562
  }
528
563
 
529
564
  /**
@@ -226,6 +226,30 @@ describe("extractCodexSkillToolEvents (Codex model-driven SKILL.md reads)", () =
226
226
  expect(ev.source).toBe("model");
227
227
  });
228
228
 
229
+ it("matches normal Codex skill paths without backtracking ambiguity", () => {
230
+ expect(
231
+ extractCodexSkillToolEvents(
232
+ execEnd("cat /home/u/.claude/skills/hq/land/SKILL.md"),
233
+ ctx,
234
+ )[0].skill,
235
+ ).toBe("land");
236
+ expect(
237
+ extractCodexSkillToolEvents(
238
+ execEnd("cat /home/u/.claude/skills/a/b/c/SKILL.md"),
239
+ ctx,
240
+ )[0].skill,
241
+ ).toBe("c");
242
+ });
243
+
244
+ it("returns quickly for pathological non-matching skills paths", () => {
245
+ const cmd = "/home/u/.claude/skills/" + "a/".repeat(2000) + "z z";
246
+ const started = Date.now();
247
+ const events = extractCodexSkillToolEvents(execEnd(cmd), ctx);
248
+ const elapsedMs = Date.now() - started;
249
+ expect(events).toEqual([]);
250
+ expect(elapsedMs).toBeLessThan(200);
251
+ });
252
+
229
253
  it("absolute and relative SKILL.md paths both resolve", () => {
230
254
  expect(
231
255
  extractCodexSkillToolEvents(execEnd("cat /abs/path/.agents/skills/quiz/SKILL.md"), ctx)[0].skill,
@@ -342,10 +342,11 @@ export function extractCodexSkillEvents(
342
342
  * may be nested arbitrarily deep (`.agents/skills/…`, `.codex/skills/hq/…`),
343
343
  * and `<name>` is always the directory immediately above SKILL.md — captured
344
344
  * as the last segment so the bridge's `skills/hq/<name>/` layout resolves to
345
- * `<name>`, not `hq`. The leading `(?:…/)*` is non-greedy via the segment class
346
- * so it stops at the final directory boundary. */
345
+ * `<name>`, not `hq`. The segment classes deliberately exclude `/` so segment
346
+ * boundaries are unambiguous and non-matching `skills/...` paths cannot
347
+ * catastrophically backtrack. */
347
348
  const CODEX_SKILL_FILE =
348
- /(?:^|\/)skills\/(?:[^\s'"]+\/)*?([^/\s'"]+)\/SKILL\.md\b/;
349
+ /(?:^|\/)skills\/(?:[^/\s'"]+\/)*?([^/\s'"]+)\/SKILL\.md\b/;
349
350
 
350
351
  /** Pull the shell command string out of a Codex `exec_command_end` `command`,
351
352
  * which is `["/bin/zsh", "-lc", "<cmd>"]` (array) on the runtimes we see, but
@@ -488,6 +489,8 @@ export function extractCodexSkillToolEvents(
488
489
  const exec = codexExecParams(obj, payload, ctx);
489
490
  if (!exec) return [];
490
491
  const { cmd } = exec;
492
+ // Real exec commands are tiny; avoid running regexes over pathological rows.
493
+ if (cmd.length > 100_000) return [];
491
494
  const m = CODEX_SKILL_FILE.exec(cmd);
492
495
  if (!m) return [];
493
496
  // Confirm the exec is a read of the skill file, not a write to it. Codex's own