@indigoai-us/hq-cloud 6.14.2 → 6.14.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.
@@ -2,8 +2,9 @@
2
2
  * hq reindex — surfaces namespaced skills as Claude Code skill wrappers under
3
3
  * .claude/skills/<ns>:<skill>/ (one symlink per source file), mirrors
4
4
  * personal/{knowledge,policies,workers,settings}/<entry> into core/<type>/,
5
- * prunes orphan wrappers + legacy command symlinks, and regenerates the
6
- * workers registry.
5
+ * prunes orphan wrappers + legacy command symlinks, captures this root's Claude
6
+ * Code session logs into workspace/.session-logs, and regenerates the workers
7
+ * registry.
7
8
  *
8
9
  * The logic historically lived in a bundled bash script (scripts/reindex.sh)
9
10
  * that this module exec'd via `bash`. It is now implemented natively in
@@ -16,6 +17,7 @@
16
17
  */
17
18
  import { spawnSync } from "child_process";
18
19
  import * as fs from "fs";
20
+ import * as os from "os";
19
21
  import * as path from "path";
20
22
  import {
21
23
  acquireOperationLock,
@@ -197,6 +199,46 @@ function safeSymlink(target: string, linkPath: string, label: string): boolean {
197
199
  }
198
200
  }
199
201
 
202
+ // Copy every file/folder under Claude Code's session-log directory for this HQ
203
+ // root (`<claude-config>/projects/<encoded-root>`) into
204
+ // `workspace/.session-logs`, so the raw session transcripts are captured inside
205
+ // the HQ tree (where sync/search can see them) on each reindex.
206
+ //
207
+ // Contract (per the feature request):
208
+ // - copies ALL files and folders from the projects dir into the dest,
209
+ // - OVERWRITES existing dest files (force), and
210
+ // - NEVER deletes dest entries that lack a source counterpart — this is a
211
+ // one-way overlay, not a mirror, so anything already parked in
212
+ // workspace/.session-logs survives even after Claude Code rotates or prunes
213
+ // its own projects dir.
214
+ //
215
+ // Best-effort: any failure warns to stderr and is swallowed so it can never
216
+ // fail the reindex. `<claude-config>` honors CLAUDE_CONFIG_DIR, else ~/.claude.
217
+ function copySessionLogs(root: string): void {
218
+ // Claude Code names a project's log dir by replacing every non-alphanumeric
219
+ // character in the project's cwd with '-' (e.g. /home/ec2-user/hq ->
220
+ // -home-ec2-user-hq). Re-derive that slug so we target THIS root's logs.
221
+ const slug = root.replace(/[^a-zA-Z0-9]/g, "-");
222
+ const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
223
+ const src = path.join(claudeConfigDir, "projects", slug);
224
+ if (!isDir(src)) return; // no session logs for this root yet — nothing to do
225
+
226
+ const dest = path.join(root, "workspace", ".session-logs");
227
+ if (!safeMkdir(dest, "workspace/.session-logs directory")) return;
228
+
229
+ try {
230
+ // recursive: copy the whole tree; force: overwrite existing dest files.
231
+ // cpSync copies src INTO dest and never removes dest-only entries — exactly
232
+ // the "overwrite, but don't delete extras" contract we want.
233
+ fs.cpSync(src, dest, { recursive: true, force: true });
234
+ } catch (err) {
235
+ warn(
236
+ `reindex: could not copy session logs from '${src}' to '${dest}' ` +
237
+ `(${(err as NodeJS.ErrnoException).code ?? "error"}: ${(err as Error).message}); skipping.`,
238
+ );
239
+ }
240
+ }
241
+
200
242
  // --- legacy `.claude/commands/<ns>/<skill>.md` symlink matcher --------------
201
243
  // Mirrors the bash `case` patterns; `*` matches any chars (including `/`).
202
244
  function isLegacyCommandTarget(t: string): boolean {
@@ -549,6 +591,11 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
549
591
  }
550
592
  }
551
593
 
594
+ // --- Session-log capture --------------------------------------------------
595
+ // Overlay this root's Claude Code session logs into workspace/.session-logs
596
+ // (copy-all, overwrite, never-prune). Best-effort; never fails the reindex.
597
+ copySessionLogs(root);
598
+
552
599
  // --- Workers registry regeneration ----------------------------------------
553
600
  // Source of truth: each worker.yaml. The registry is a derived index — the
554
601
  // generator (when present in the operated-on tree) keeps it in sync.
package/src/index.ts CHANGED
@@ -164,9 +164,11 @@ export {
164
164
  PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
165
165
  PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS,
166
166
  CONTINUITY_POINTER_REL,
167
+ SESSION_LOGS_SYNC_REL,
167
168
  computePersonalVaultPaths,
168
169
  computePersonalCompanySubdirs,
169
170
  computeContinuityPointerPaths,
171
+ computeSessionLogsSyncPaths,
170
172
  } from "./personal-vault.js";
171
173
  export type { PersonalVaultOptions } from "./personal-vault.js";
172
174
 
@@ -25,6 +25,8 @@ import {
25
25
  CONTINUITY_POINTER_REL,
26
26
  computeAgencySyncPaths,
27
27
  AGENCY_SYNC_REL,
28
+ computeSessionLogsSyncPaths,
29
+ SESSION_LOGS_SYNC_REL,
28
30
  PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS,
29
31
  PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
30
32
  } from "./personal-vault.js";
@@ -628,3 +630,58 @@ describe("personal-vault: agency-workspace carve-out", () => {
628
630
  );
629
631
  });
630
632
  });
633
+
634
+ // ─────────────────────────────────────────────────────────────────────────
635
+ // Session-logs carve-out (workspace/.session-logs) — reindex-captured Claude
636
+ // Code transcripts pierce the workspace/ exclusion and round-trip to the vault.
637
+ // ─────────────────────────────────────────────────────────────────────────
638
+ describe("personal-vault: session-logs carve-out", () => {
639
+ let hqRoot: string;
640
+ beforeEach(() => {
641
+ hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sesslog-test-"));
642
+ });
643
+ afterEach(() => {
644
+ fs.rmSync(hqRoot, { recursive: true, force: true });
645
+ });
646
+ const relAll = (abs: string[]): string[] =>
647
+ abs.map((p) => path.relative(hqRoot, p)).sort();
648
+
649
+ it("constant: SESSION_LOGS_SYNC_REL is workspace/.session-logs", () => {
650
+ expect(SESSION_LOGS_SYNC_REL).toBe("workspace/.session-logs");
651
+ });
652
+
653
+ it("includes workspace/.session-logs when it exists", () => {
654
+ fs.mkdirSync(path.join(hqRoot, "workspace", ".session-logs", "tool-results"), {
655
+ recursive: true,
656
+ });
657
+ expect(relAll(computeSessionLogsSyncPaths(hqRoot))).toEqual([
658
+ path.join("workspace", ".session-logs"),
659
+ ]);
660
+ });
661
+
662
+ it("empty when workspace/.session-logs is absent (fail-soft)", () => {
663
+ fs.mkdirSync(path.join(hqRoot, "workspace"), { recursive: true });
664
+ expect(computeSessionLogsSyncPaths(hqRoot)).toEqual([]);
665
+ });
666
+
667
+ it("composes into computePersonalVaultPaths without broadening workspace/", () => {
668
+ fs.mkdirSync(path.join(hqRoot, "workspace", ".session-logs"), { recursive: true });
669
+ fs.writeFileSync(
670
+ path.join(hqRoot, "workspace", ".session-logs", "session.jsonl"),
671
+ "l\n",
672
+ );
673
+ // A sibling under workspace/ that must STAY local.
674
+ fs.mkdirSync(path.join(hqRoot, "workspace", "locks"), { recursive: true });
675
+ fs.writeFileSync(path.join(hqRoot, "workspace", "locks", "x.lock"), "1");
676
+ fs.mkdirSync(path.join(hqRoot, ".claude"), { recursive: true });
677
+
678
+ const out = relAll(computePersonalVaultPaths(hqRoot));
679
+ expect(out).toContain(path.join("workspace", ".session-logs"));
680
+ expect(out).toContain(".claude");
681
+ // The carve-out is exactly the .session-logs subtree — no other workspace/
682
+ // sibling is pulled in.
683
+ expect(out.some((p) => p.startsWith(path.join("workspace", "locks")))).toBe(
684
+ false,
685
+ );
686
+ });
687
+ });
@@ -26,7 +26,12 @@
26
26
  * the routing source-of-truth.)
27
27
  * - `repos/`, `workspace/`: per user directive — heavy local-only
28
28
  * content (cloned remotes, session threads) that has no business in
29
- * the personal vault.
29
+ * the personal vault. Three explicit sub-paths pierce this exclusion and
30
+ * DO round-trip: `workspace/threads/handoff.json` (+ its active thread) via
31
+ * {@link computeContinuityPointerPaths}, `workspace/agency/` via
32
+ * {@link computeAgencySyncPaths}, and `workspace/.session-logs/` (the
33
+ * reindex-captured Claude Code transcripts) via
34
+ * {@link computeSessionLogsSyncPaths}.
30
35
  *
31
36
  * Note: `core/`, `data/`, and `personal/` were previously excluded but are
32
37
  * INCLUDED as of user directive 2026-05-13. `core/` ships the hq-core
@@ -134,7 +139,15 @@ export function computePersonalVaultPaths(
134
139
  );
135
140
  const continuity = computeContinuityPointerPaths(hqRoot);
136
141
  const agency = computeAgencySyncPaths(hqRoot);
137
- return [...topLevel, ...manifest, ...companySubdirs, ...continuity, ...agency];
142
+ const sessionLogs = computeSessionLogsSyncPaths(hqRoot);
143
+ return [
144
+ ...topLevel,
145
+ ...manifest,
146
+ ...companySubdirs,
147
+ ...continuity,
148
+ ...agency,
149
+ ...sessionLogs,
150
+ ];
138
151
  }
139
152
 
140
153
  /**
@@ -261,6 +274,39 @@ export function computeAgencySyncPaths(hqRoot: string): string[] {
261
274
  return [];
262
275
  }
263
276
 
277
+ /**
278
+ * Fixed relative path (forward-slash, hq-root-relative) of the session-logs
279
+ * subtree. See {@link computeSessionLogsSyncPaths}.
280
+ */
281
+ export const SESSION_LOGS_SYNC_REL = "workspace/.session-logs";
282
+
283
+ /**
284
+ * Compute the absolute path of the session-logs carve-out:
285
+ * `workspace/.session-logs/` (the whole subtree).
286
+ *
287
+ * `hq reindex` copies this HQ root's Claude Code session transcripts here (see
288
+ * reindex's copySessionLogs). Like the agency carve-out above, this pierces the
289
+ * `workspace/` top-level exclusion for ONE specific subtree so those raw
290
+ * transcripts round-trip to the personal vault / S3 — the operator's explicit
291
+ * directive is that the session logs travel with the vault. The rest of
292
+ * `workspace/` (session scratch, locks, reports, worktrees) stays machine-local.
293
+ *
294
+ * Returns the directory path when it exists; share()'s per-file walk handles
295
+ * recursion, and the nested personal-vault exclusions still apply to files
296
+ * inside it. Fail-soft: a missing or unreadable `workspace/.session-logs/`
297
+ * returns []. Callers tolerate empty arrays — same contract as the manifest,
298
+ * continuity, and agency special-cases.
299
+ */
300
+ export function computeSessionLogsSyncPaths(hqRoot: string): string[] {
301
+ const sessionLogsDir = path.join(hqRoot, "workspace", ".session-logs");
302
+ try {
303
+ if (fs.statSync(sessionLogsDir).isDirectory()) return [sessionLogsDir];
304
+ } catch {
305
+ // No session logs captured on this machine yet — nothing to carry.
306
+ }
307
+ return [];
308
+ }
309
+
264
310
  /**
265
311
  * Discover `companies/{slug}/` subdirs that should sync to the personal
266
312
  * bucket as a fallback for companies the operator has not designated as
@@ -371,6 +371,36 @@ describe("US-002: createWatchPathFilter — personal-vault exclusions", () => {
371
371
  expect(personal(path.join(ROOT, "workspace/worktrees/x/a.ts"))).toBe(false);
372
372
  });
373
373
 
374
+ // ── workspace/.session-logs subtree carve-out (personal mode) ────────────
375
+ // The personal push uploads workspace/.session-logs/** (reindex-captured
376
+ // Claude Code transcripts, computeSessionLogsSyncPaths), so the personal
377
+ // watcher must emit those changes event-driven. Mirrors the agency carve-out.
378
+ it("DOES emit for workspace/.session-logs files in personalMode (synced subtree)", () => {
379
+ expect(
380
+ personal(path.join(ROOT, "workspace/.session-logs/session.jsonl")),
381
+ ).toBe(true);
382
+ expect(
383
+ personal(path.join(ROOT, "workspace/.session-logs/tool-results/r.txt")),
384
+ ).toBe(true);
385
+ // The subtree root itself, as a file event, also emits.
386
+ expect(personal(path.join(ROOT, "workspace/.session-logs"))).toBe(true);
387
+ });
388
+
389
+ it("allows chokidar to DESCEND to workspace/.session-logs in personalMode (dir probes)", () => {
390
+ expect(personal(path.join(ROOT, "workspace"), true)).toBe(true);
391
+ expect(personal(path.join(ROOT, "workspace/.session-logs"), true)).toBe(true);
392
+ expect(
393
+ personal(path.join(ROOT, "workspace/.session-logs/tool-results"), true),
394
+ ).toBe(true);
395
+ });
396
+
397
+ it("session-logs carve-out is exact-segment (does NOT leak to a similarly-named sibling)", () => {
398
+ expect(
399
+ personal(path.join(ROOT, "workspace/.session-logs-old/foo.txt")),
400
+ ).toBe(false);
401
+ expect(personal(path.join(ROOT, "workspace/.session-logsX.md"))).toBe(false);
402
+ });
403
+
374
404
  it("does not apply the carve-out in non-personal mode", () => {
375
405
  // In non-personal mode the excluded-top-level layer never runs, so the
376
406
  // carve-out is irrelevant; handoff.json passes via the ordinary ignore
package/src/watcher.ts CHANGED
@@ -16,6 +16,7 @@ import { isPersonalVaultExcluded } from "./personal-vault-exclusions.js";
16
16
  import {
17
17
  AGENCY_SYNC_REL,
18
18
  CONTINUITY_POINTER_REL,
19
+ SESSION_LOGS_SYNC_REL,
19
20
  PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
20
21
  } from "./personal-vault.js";
21
22
  import type { PushEvent } from "./sync/push-event.js";
@@ -466,6 +467,22 @@ export function createWatchPathFilter(
466
467
  }
467
468
  if (isDir && AGENCY_SYNC_REL.startsWith(rel + "/")) return true;
468
469
 
470
+ // Session-logs carve-out: `workspace/.session-logs/` (the whole subtree)
471
+ // IS synced via computeSessionLogsSyncPaths (mirrored on the push side),
472
+ // so it must re-include BEFORE the top-level `workspace/` rejection below
473
+ // — identical shape to the agency carve-out just above. Without this, a
474
+ // `--personal --watch` runner never emits reindex-captured session-log
475
+ // changes and they wait for the 60s poll. Paths at/under it emit; the
476
+ // subtree root and its ancestor dir (`workspace`) return true for the dir
477
+ // probe so chokidar descends to reach them instead of pruning `workspace/`.
478
+ if (
479
+ rel === SESSION_LOGS_SYNC_REL ||
480
+ rel.startsWith(SESSION_LOGS_SYNC_REL + "/")
481
+ ) {
482
+ return true;
483
+ }
484
+ if (isDir && SESSION_LOGS_SYNC_REL.startsWith(rel + "/")) return true;
485
+
469
486
  // Layer 3: excluded top-level buckets (.git/companies/repos/workspace).
470
487
  const topLevel = rel.split("/")[0];
471
488
  if (excludedTopLevel.has(topLevel)) return false;