@indigoai-us/hq-cloud 6.14.2 → 6.14.4
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.
- package/dist/bin/sync-runner-company.d.ts +1 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +44 -19
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +25 -36
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +128 -4
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +226 -2
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +134 -0
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +16 -14
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +73 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/personal-vault.d.ts +29 -1
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +48 -2
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +44 -1
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/watcher.d.ts +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +20 -7
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +73 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +50 -22
- package/src/bin/sync-runner.test.ts +147 -4
- package/src/bin/sync-runner.ts +49 -54
- package/src/cli/reindex.test.ts +162 -0
- package/src/cli/reindex.ts +219 -2
- package/src/cli/sync.test.ts +90 -0
- package/src/cli/sync.ts +16 -14
- package/src/index.ts +2 -0
- package/src/personal-vault.test.ts +57 -0
- package/src/personal-vault.ts +48 -2
- package/src/watcher.test.ts +89 -0
- package/src/watcher.ts +23 -8
package/src/cli/reindex.test.ts
CHANGED
|
@@ -557,4 +557,166 @@ describe("reindex", () => {
|
|
|
557
557
|
fs.lstatSync(path.join(root, ".claude/skills/core:demo/SKILL.md")).isSymbolicLink(),
|
|
558
558
|
).toBe(true);
|
|
559
559
|
});
|
|
560
|
+
|
|
561
|
+
// --- multi-harness session-log capture into workspace/.session-logs/<harness>/
|
|
562
|
+
describe("session-log capture", () => {
|
|
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");
|
|
572
|
+
|
|
573
|
+
beforeEach(() => {
|
|
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;
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
afterEach(() => {
|
|
583
|
+
for (const d of [claudeDir, codexHome, grokHome]) {
|
|
584
|
+
fs.rmSync(d, { recursive: true, force: true });
|
|
585
|
+
}
|
|
586
|
+
delete process.env.CLAUDE_CONFIG_DIR;
|
|
587
|
+
delete process.env.CODEX_HOME;
|
|
588
|
+
delete process.env.GROK_HOME;
|
|
589
|
+
});
|
|
590
|
+
|
|
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);
|
|
595
|
+
fs.mkdirSync(path.join(projDir, "tool-results"), { recursive: true });
|
|
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;
|
|
620
|
+
}
|
|
621
|
+
|
|
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");
|
|
671
|
+
|
|
672
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
673
|
+
|
|
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);
|
|
679
|
+
});
|
|
680
|
+
|
|
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);
|
|
691
|
+
|
|
692
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
693
|
+
expect(fs.readFileSync(destFile, "utf8")).toBe("SENTINEL\n");
|
|
694
|
+
|
|
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");
|
|
701
|
+
});
|
|
702
|
+
|
|
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");
|
|
708
|
+
|
|
709
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
710
|
+
|
|
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);
|
|
714
|
+
});
|
|
715
|
+
|
|
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.
|
|
718
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
719
|
+
expect(fs.existsSync(path.join(root, "workspace", ".session-logs"))).toBe(false);
|
|
720
|
+
});
|
|
721
|
+
});
|
|
560
722
|
});
|
package/src/cli/reindex.ts
CHANGED
|
@@ -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,
|
|
6
|
-
*
|
|
5
|
+
* prunes orphan wrappers + legacy command symlinks, captures this HQ tree's
|
|
6
|
+
* coding-harness session logs (Claude Code / Codex / Grok) into
|
|
7
|
+
* workspace/.session-logs/<harness>/, and regenerates the workers 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,216 @@ function safeSymlink(target: string, linkPath: string, label: string): boolean {
|
|
|
197
199
|
}
|
|
198
200
|
}
|
|
199
201
|
|
|
202
|
+
// Capture the raw session transcripts of every supported coding harness for
|
|
203
|
+
// this HQ tree into `workspace/.session-logs/<harness>/`, so they live inside
|
|
204
|
+
// the HQ tree (where sync/search can see them) on each reindex.
|
|
205
|
+
//
|
|
206
|
+
// Scope — "this HQ tree" = the HQ root cwd OR any working directory UNDER it
|
|
207
|
+
// (worktrees, repo subdirs). Harnesses key their logs by cwd differently, so
|
|
208
|
+
// each helper re-derives the same scope in its own encoding.
|
|
209
|
+
//
|
|
210
|
+
// Contract (per the feature request), applied by every helper:
|
|
211
|
+
// - copies ALL matching files/folders into `.session-logs/<harness>/…`,
|
|
212
|
+
// - OVERWRITES existing dest files, and
|
|
213
|
+
// - NEVER deletes dest entries that lack a source counterpart — a one-way
|
|
214
|
+
// overlay, not a mirror, so anything already parked in `.session-logs`
|
|
215
|
+
// survives even after a harness rotates or prunes its own store.
|
|
216
|
+
//
|
|
217
|
+
// Best-effort throughout: any failure warns to stderr and is swallowed so it
|
|
218
|
+
// can never fail the reindex. Each harness is independent — one failing must
|
|
219
|
+
// not stop the others. Home dirs honor CLAUDE_CONFIG_DIR / CODEX_HOME /
|
|
220
|
+
// GROK_HOME, else ~/.claude, ~/.codex, ~/.grok.
|
|
221
|
+
function copySessionLogs(root: string): void {
|
|
222
|
+
const destBase = path.join(root, "workspace", ".session-logs");
|
|
223
|
+
for (const capture of [
|
|
224
|
+
copyClaudeSessionLogs,
|
|
225
|
+
copyCodexSessionLogs,
|
|
226
|
+
copyGrokSessionLogs,
|
|
227
|
+
]) {
|
|
228
|
+
try {
|
|
229
|
+
capture(root, destBase);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
warn(`reindex: session-log capture step failed (${(err as Error).message}); skipping.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** `fs.statSync` without throwing (follows symlinks; null when missing). */
|
|
237
|
+
function statOrNull(p: string): fs.Stats | null {
|
|
238
|
+
try {
|
|
239
|
+
return fs.statSync(p);
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Overlay-copy a whole directory tree: mkdir dest, then recursive+force cpSync
|
|
246
|
+
// (overwrite existing, never prune dest-only entries). Best-effort — warns and
|
|
247
|
+
// returns on any failure. Shared by the Claude + Grok per-cwd-folder captures.
|
|
248
|
+
function copyTreeOverlay(src: string, dest: string, label: string): void {
|
|
249
|
+
if (!safeMkdir(dest, `${label} directory`)) return;
|
|
250
|
+
try {
|
|
251
|
+
fs.cpSync(src, dest, { recursive: true, force: true });
|
|
252
|
+
} catch (err) {
|
|
253
|
+
warn(
|
|
254
|
+
`reindex: could not copy ${label} from '${src}' to '${dest}' ` +
|
|
255
|
+
`(${(err as NodeJS.ErrnoException).code ?? "error"}: ${(err as Error).message}); skipping.`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Claude Code: `<claude-config>/projects/<encoded-cwd>/…`, where the cwd is
|
|
261
|
+
// encoded by replacing every non-alphanumeric char with '-'. Because that map
|
|
262
|
+
// is per-character, a descendant cwd's dir name is exactly the root's encoded
|
|
263
|
+
// name + '-' + <encoded-subpath> — so "this HQ tree" is the exact-root dir OR
|
|
264
|
+
// any dir whose name starts with `<slug>-`. Copy each into `.session-logs/claude/`.
|
|
265
|
+
function copyClaudeSessionLogs(root: string, destBase: string): void {
|
|
266
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
267
|
+
const projectsDir = path.join(configDir, "projects");
|
|
268
|
+
if (!isDir(projectsDir)) return;
|
|
269
|
+
|
|
270
|
+
const slug = root.replace(/[^a-zA-Z0-9]/g, "-");
|
|
271
|
+
for (const entry of globEntries(projectsDir)) {
|
|
272
|
+
if (entry !== slug && !entry.startsWith(`${slug}-`)) continue;
|
|
273
|
+
const src = path.join(projectsDir, entry);
|
|
274
|
+
if (!isDir(src)) continue;
|
|
275
|
+
copyTreeOverlay(src, path.join(destBase, "claude", entry), "claude session logs");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Grok: `<grok-home>/sessions/<url-encoded-cwd>/<session-uuid>/…`, where the cwd
|
|
280
|
+
// is URL-encoded (encodeURIComponent: '/' -> '%2F'). A descendant cwd's key is
|
|
281
|
+
// the root's key + '%2F' + <encoded-subpath>, so match the exact-root key OR
|
|
282
|
+
// that prefix. Copy each matching cwd folder into `.session-logs/grok/`.
|
|
283
|
+
function copyGrokSessionLogs(root: string, destBase: string): void {
|
|
284
|
+
const grokHome = process.env.GROK_HOME || path.join(os.homedir(), ".grok");
|
|
285
|
+
const sessionsDir = path.join(grokHome, "sessions");
|
|
286
|
+
if (!isDir(sessionsDir)) return;
|
|
287
|
+
|
|
288
|
+
const key = encodeURIComponent(root);
|
|
289
|
+
for (const entry of globEntries(sessionsDir)) {
|
|
290
|
+
if (entry !== key && !entry.startsWith(`${key}%2F`)) continue;
|
|
291
|
+
const src = path.join(sessionsDir, entry);
|
|
292
|
+
if (!isDir(src)) continue;
|
|
293
|
+
copyTreeOverlay(src, path.join(destBase, "grok", entry), "grok session logs");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Codex: `<codex-home>/sessions/YYYY/MM/DD/rollout-*.jsonl` — a DATE tree, not
|
|
298
|
+
// per-project. The working dir is only recorded inside each rollout's first
|
|
299
|
+
// line (`session_meta`, at `payload.cwd`). Walk the tree and capture a rollout
|
|
300
|
+
// iff its recorded cwd is the HQ root or under it, mirroring the date-relative
|
|
301
|
+
// path under `.session-logs/codex/`. A rollout already copied and unchanged is
|
|
302
|
+
// skipped by mtime, so a steady-state reindex only classifies newly-written
|
|
303
|
+
// rollouts — the full-tree cwd scan is a one-time first-run cost.
|
|
304
|
+
function copyCodexSessionLogs(root: string, destBase: string): void {
|
|
305
|
+
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
306
|
+
const sessionsDir = path.join(codexHome, "sessions");
|
|
307
|
+
if (!isDir(sessionsDir)) return;
|
|
308
|
+
|
|
309
|
+
const destDir = path.join(destBase, "codex");
|
|
310
|
+
const rootPrefix = root.endsWith(path.sep) ? root : root + path.sep;
|
|
311
|
+
|
|
312
|
+
for (const abs of walkFiles(sessionsDir)) {
|
|
313
|
+
if (!abs.endsWith(".jsonl")) continue;
|
|
314
|
+
const srcStat = statOrNull(abs);
|
|
315
|
+
if (!srcStat) continue;
|
|
316
|
+
|
|
317
|
+
const dest = path.join(destDir, path.relative(sessionsDir, abs));
|
|
318
|
+
// Cheap skip: already captured and unchanged since (dest mtime is the copy
|
|
319
|
+
// time, so it stays >= src mtime until the rollout is appended to again).
|
|
320
|
+
const destStat = statOrNull(dest);
|
|
321
|
+
if (destStat && destStat.mtimeMs >= srcStat.mtimeMs) continue;
|
|
322
|
+
|
|
323
|
+
const cwd = readCodexRolloutCwd(abs);
|
|
324
|
+
if (cwd === null) continue;
|
|
325
|
+
if (cwd !== root && !cwd.startsWith(rootPrefix)) continue;
|
|
326
|
+
|
|
327
|
+
if (!safeMkdir(path.dirname(dest), "codex session-log dir")) continue;
|
|
328
|
+
try {
|
|
329
|
+
fs.copyFileSync(abs, dest);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
warn(
|
|
332
|
+
`reindex: could not copy codex session log '${abs}' -> '${dest}' ` +
|
|
333
|
+
`(${(err as NodeJS.ErrnoException).code ?? "error"}: ${(err as Error).message}); skipping.`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Recursively collect every regular file under `dir` (absolute paths). Symlinks
|
|
340
|
+
// are not followed (lstat gate) so a stray link can't escape the tree or loop.
|
|
341
|
+
// Missing/unreadable dirs yield []. Best-effort — mirrors globEntries' tolerance.
|
|
342
|
+
function walkFiles(dir: string): string[] {
|
|
343
|
+
const out: string[] = [];
|
|
344
|
+
let names: string[];
|
|
345
|
+
try {
|
|
346
|
+
names = fs.readdirSync(dir);
|
|
347
|
+
} catch {
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
for (const name of names) {
|
|
351
|
+
const abs = path.join(dir, name);
|
|
352
|
+
const st = lstatOrNull(abs);
|
|
353
|
+
if (!st) continue;
|
|
354
|
+
if (st.isDirectory()) out.push(...walkFiles(abs));
|
|
355
|
+
else if (st.isFile()) out.push(abs);
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Extract `payload.cwd` (Codex ≥ the session_meta format; falls back to a
|
|
361
|
+
// top-level `cwd`) from a rollout's first line, reading only that line. Codex
|
|
362
|
+
// records cwd early, before the large `base_instructions` blob, but the line
|
|
363
|
+
// can still be tens of KB — so read up to a generous cap and stop at the first
|
|
364
|
+
// newline. Returns null on any parse/read failure (the rollout is then skipped).
|
|
365
|
+
function readCodexRolloutCwd(file: string): string | null {
|
|
366
|
+
const line = readFirstLine(file, 4 * 1024 * 1024);
|
|
367
|
+
if (line === null) return null;
|
|
368
|
+
try {
|
|
369
|
+
const obj = JSON.parse(line) as { cwd?: unknown; payload?: { cwd?: unknown } };
|
|
370
|
+
const cwd = obj?.payload?.cwd ?? obj?.cwd;
|
|
371
|
+
return typeof cwd === "string" ? cwd : null;
|
|
372
|
+
} catch {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Read the first line of `file` (up to `capBytes`), returning it WITHOUT the
|
|
378
|
+
// trailing newline, or null on error / empty. Byte-accumulates and decodes once
|
|
379
|
+
// so a multibyte char split across a read boundary can't corrupt the result.
|
|
380
|
+
function readFirstLine(file: string, capBytes: number): string | null {
|
|
381
|
+
let fd: number | null = null;
|
|
382
|
+
try {
|
|
383
|
+
fd = fs.openSync(file, "r");
|
|
384
|
+
const chunks: Buffer[] = [];
|
|
385
|
+
const chunk = Buffer.allocUnsafe(65536);
|
|
386
|
+
let total = 0;
|
|
387
|
+
while (total < capBytes) {
|
|
388
|
+
const n = fs.readSync(fd, chunk, 0, chunk.length, total);
|
|
389
|
+
if (n <= 0) break;
|
|
390
|
+
const nl = chunk.indexOf(0x0a, 0);
|
|
391
|
+
if (nl !== -1 && nl < n) {
|
|
392
|
+
chunks.push(Buffer.from(chunk.subarray(0, nl)));
|
|
393
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
394
|
+
}
|
|
395
|
+
chunks.push(Buffer.from(chunk.subarray(0, n)));
|
|
396
|
+
total += n;
|
|
397
|
+
}
|
|
398
|
+
return chunks.length ? Buffer.concat(chunks).toString("utf8") : null;
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
} finally {
|
|
402
|
+
if (fd !== null) {
|
|
403
|
+
try {
|
|
404
|
+
fs.closeSync(fd);
|
|
405
|
+
} catch {
|
|
406
|
+
/* best-effort */
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
200
412
|
// --- legacy `.claude/commands/<ns>/<skill>.md` symlink matcher --------------
|
|
201
413
|
// Mirrors the bash `case` patterns; `*` matches any chars (including `/`).
|
|
202
414
|
function isLegacyCommandTarget(t: string): boolean {
|
|
@@ -549,6 +761,11 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
549
761
|
}
|
|
550
762
|
}
|
|
551
763
|
|
|
764
|
+
// --- Session-log capture --------------------------------------------------
|
|
765
|
+
// Overlay this root's Claude Code session logs into workspace/.session-logs
|
|
766
|
+
// (copy-all, overwrite, never-prune). Best-effort; never fails the reindex.
|
|
767
|
+
copySessionLogs(root);
|
|
768
|
+
|
|
552
769
|
// --- Workers registry regeneration ----------------------------------------
|
|
553
770
|
// Source of truth: each worker.yaml. The registry is a derived index — the
|
|
554
771
|
// generator (when present in the operated-on tree) keeps it in sync.
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -3518,6 +3518,17 @@ describe("reportNewFilesToNotify chunking (server cap = 1000 files/report)", ()
|
|
|
3518
3518
|
(fetchMock.mock.calls as Array<[string, RequestInit?]>)
|
|
3519
3519
|
.filter(([u]) => String(u).includes("/v1/notify/file-added"))
|
|
3520
3520
|
.map(([, init]) => JSON.parse(String(init!.body)).files.length);
|
|
3521
|
+
const notificationTelemetryEvents = (
|
|
3522
|
+
fetchMock: ReturnType<typeof vi.fn>,
|
|
3523
|
+
): Array<{ eventName: string; properties?: Record<string, unknown> }> =>
|
|
3524
|
+
(fetchMock.mock.calls as Array<[string, RequestInit?]>)
|
|
3525
|
+
.filter(([u]) => String(u).includes("/v1/telemetry/events"))
|
|
3526
|
+
.flatMap(([, init]) =>
|
|
3527
|
+
JSON.parse(String(init!.body)).events as Array<{
|
|
3528
|
+
eventName: string;
|
|
3529
|
+
properties?: Record<string, unknown>;
|
|
3530
|
+
}>,
|
|
3531
|
+
);
|
|
3521
3532
|
|
|
3522
3533
|
afterEach(() => {
|
|
3523
3534
|
vi.unstubAllGlobals();
|
|
@@ -3580,6 +3591,85 @@ describe("reportNewFilesToNotify chunking (server cap = 1000 files/report)", ()
|
|
|
3580
3591
|
expect(notifyBatchSizes(fetchMock)).toEqual([1000, 1000, 1]);
|
|
3581
3592
|
});
|
|
3582
3593
|
|
|
3594
|
+
it("does not emit notification telemetry after a successful report", async () => {
|
|
3595
|
+
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
|
|
3596
|
+
if (url.includes("/v1/notify/file-added")) {
|
|
3597
|
+
return { ok: true, status: 200, text: async () => "" };
|
|
3598
|
+
}
|
|
3599
|
+
return {
|
|
3600
|
+
ok: true,
|
|
3601
|
+
status: 200,
|
|
3602
|
+
text: async () => JSON.stringify({ ok: true, written: 1, skipped: [] }),
|
|
3603
|
+
};
|
|
3604
|
+
});
|
|
3605
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
3606
|
+
|
|
3607
|
+
await reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(1));
|
|
3608
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
3609
|
+
|
|
3610
|
+
expect(notificationTelemetryEvents(fetchMock)).toEqual([]);
|
|
3611
|
+
});
|
|
3612
|
+
|
|
3613
|
+
it("emits failure telemetry for a non-successful notification response", async () => {
|
|
3614
|
+
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
|
|
3615
|
+
if (url.includes("/v1/notify/file-added")) {
|
|
3616
|
+
return { ok: false, status: 503, text: async () => "unavailable" };
|
|
3617
|
+
}
|
|
3618
|
+
return {
|
|
3619
|
+
ok: true,
|
|
3620
|
+
status: 200,
|
|
3621
|
+
text: async () => JSON.stringify({ ok: true, written: 1, skipped: [] }),
|
|
3622
|
+
};
|
|
3623
|
+
});
|
|
3624
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
3625
|
+
|
|
3626
|
+
await reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(1));
|
|
3627
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
3628
|
+
|
|
3629
|
+
expect(notificationTelemetryEvents(fetchMock)).toEqual([
|
|
3630
|
+
expect.objectContaining({
|
|
3631
|
+
eventName: "new_files_notification_reported",
|
|
3632
|
+
properties: {
|
|
3633
|
+
status: "failure",
|
|
3634
|
+
fileCount: 1,
|
|
3635
|
+
batchIndex: 1,
|
|
3636
|
+
batchCount: 1,
|
|
3637
|
+
statusCode: 503,
|
|
3638
|
+
},
|
|
3639
|
+
}),
|
|
3640
|
+
]);
|
|
3641
|
+
});
|
|
3642
|
+
|
|
3643
|
+
it("emits error-class telemetry when notification reporting throws", async () => {
|
|
3644
|
+
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
|
|
3645
|
+
if (url.includes("/v1/notify/file-added")) {
|
|
3646
|
+
throw new TypeError("network down");
|
|
3647
|
+
}
|
|
3648
|
+
return {
|
|
3649
|
+
ok: true,
|
|
3650
|
+
status: 200,
|
|
3651
|
+
text: async () => JSON.stringify({ ok: true, written: 1, skipped: [] }),
|
|
3652
|
+
};
|
|
3653
|
+
});
|
|
3654
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
3655
|
+
|
|
3656
|
+
await reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(1));
|
|
3657
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
3658
|
+
|
|
3659
|
+
expect(notificationTelemetryEvents(fetchMock)).toEqual([
|
|
3660
|
+
expect.objectContaining({
|
|
3661
|
+
eventName: "new_files_notification_reported",
|
|
3662
|
+
properties: {
|
|
3663
|
+
status: "failure",
|
|
3664
|
+
fileCount: 1,
|
|
3665
|
+
batchIndex: 1,
|
|
3666
|
+
batchCount: 1,
|
|
3667
|
+
errorClass: "TypeError",
|
|
3668
|
+
},
|
|
3669
|
+
}),
|
|
3670
|
+
]);
|
|
3671
|
+
});
|
|
3672
|
+
|
|
3583
3673
|
it("no request at all when there are no new files", async () => {
|
|
3584
3674
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => "" });
|
|
3585
3675
|
vi.stubGlobal("fetch", fetchMock);
|
package/src/cli/sync.ts
CHANGED
|
@@ -722,18 +722,20 @@ export async function reportNewFilesToNotify(
|
|
|
722
722
|
}),
|
|
723
723
|
signal: controller.signal,
|
|
724
724
|
});
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
725
|
+
if (!response.ok) {
|
|
726
|
+
void emitCloudTelemetry(telemetryClient, {
|
|
727
|
+
eventName: "new_files_notification_reported",
|
|
728
|
+
source: "hq-sync",
|
|
729
|
+
companyUid,
|
|
730
|
+
properties: {
|
|
731
|
+
status: "failure",
|
|
732
|
+
fileCount: batch.length,
|
|
733
|
+
batchIndex,
|
|
734
|
+
batchCount,
|
|
735
|
+
statusCode: response.status,
|
|
736
|
+
},
|
|
737
|
+
}, { claims: telemetryClaims });
|
|
738
|
+
}
|
|
737
739
|
} catch (err) {
|
|
738
740
|
// Best-effort per chunk: never let notification reporting affect the sync
|
|
739
741
|
// result, and a failed chunk must not abort the remaining chunks.
|
|
@@ -743,11 +745,11 @@ export async function reportNewFilesToNotify(
|
|
|
743
745
|
source: "hq-sync",
|
|
744
746
|
companyUid,
|
|
745
747
|
properties: {
|
|
746
|
-
|
|
748
|
+
status: "failure",
|
|
747
749
|
fileCount: batch.length,
|
|
748
750
|
batchIndex,
|
|
749
751
|
batchCount,
|
|
750
|
-
|
|
752
|
+
errorClass: safeErrorName(err),
|
|
751
753
|
},
|
|
752
754
|
}, { claims: telemetryClaims });
|
|
753
755
|
} finally {
|
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
|
+
});
|