diff --git a/.gitignore b/.gitignore index f248c42..5b21ba3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ *.sqlite .ai-sessions/ .cursor/ +pnpm-lock.yaml # Test artifacts .test-tmp-*/ diff --git a/CLAUDE.md b/CLAUDE.md index a81a006..9728dc2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,7 @@ Always run `bun run check` before committing to ensure typecheck, lint, and test - Claude Code hooks for non-blocking capture (<50ms per hook) - AI summarization via `claude -p` on session end (background process) - Shared knowledge via `ghost/knowledge` orphan branch (no worktree impact) +- Worktree-aware: `mainRepoRoot()` resolves to the main repo via `--git-common-dir`, so sessions/QMD/hooks are always in the main repo root ## Important diff --git a/README.md b/README.md index 319a1af..ef1b89a 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,42 @@ git push origin refs/notes/ai-sessions When QMD is installed, Ghost configures an MCP server so Claude Code can search past sessions directly during a conversation. The agent can ask things like "what did we decide about fee calculation?" and get answers from its own history. +## Git Worktrees + +Ghost is fully worktree-aware. When running inside a git worktree, all session data (`.ai-sessions/`), QMD collections, git hooks, and knowledge files are stored in the main repository root — not the worktree directory. This means: + +- Sessions are never lost when a worktree is cleaned up +- The QMD collection name (`ghost-`) is stable across all worktrees +- MCP search always queries the correct, shared collection +- `ghost enable` from a worktree places config files (`.claude/settings.json`, `.mcp.json`) in the worktree but stores everything else in the main repo + +Ghost resolves the main repo root automatically via `git rev-parse --git-common-dir`. + +### Getting hooks into worktrees + +Ghost's hooks are registered in `.claude/settings.json` and the MCP server in `.mcp.json`. These are local config files — new worktrees won't have them unless you take one of these approaches: + +**Option A: Commit the config files** (recommended for seamless worktree support) + +```bash +git add .claude/settings.json .mcp.json +git commit -m "Add Ghost hook and MCP config" +``` + +Since worktrees share the same git history, any new worktree will have the hooks and MCP server configured automatically. This also means collaborators who clone the repo get Ghost hooks out of the box. + +**Option B: Run `ghost enable` per worktree** + +```bash +git worktree add ../my-feature feature-branch +cd ../my-feature +ghost enable +``` + +This creates `.claude/settings.json` and `.mcp.json` in the worktree. Session data still goes to the main repo. Use this if you prefer to keep config files untracked. + +**Note for Claude Code's automatic worktrees:** When Claude Code creates worktrees via the Agent tool (`isolation: "worktree"`), there's no opportunity to run `ghost enable`. If you use this feature and want sessions captured from worktree agents, commit the config files (Option A). + ## Troubleshooting **Sessions not being captured** diff --git a/src/git.test.ts b/src/git.test.ts new file mode 100644 index 0000000..966377d --- /dev/null +++ b/src/git.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { $ } from "bun"; +import { mainRepoRoot, repoRoot } from "./git.js"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = join(import.meta.dir, `../.test-tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tmpDir, { recursive: true }); + await $`git init ${tmpDir}`.quiet(); + await $`git -C ${tmpDir} commit --allow-empty -m "init"`.quiet(); +}); + +afterEach(() => { + if (existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +describe("repoRoot", () => { + test("returns repo root with cwd parameter", async () => { + const root = await repoRoot(tmpDir); + expect(root).toBe(tmpDir); + }); +}); + +describe("mainRepoRoot", () => { + test("returns same path as repoRoot in a normal repo", async () => { + const main = await mainRepoRoot(tmpDir); + const top = await repoRoot(tmpDir); + expect(main).toBe(top); + }); + + test("returns main repo root when called from a worktree", async () => { + const wtDir = `${tmpDir}-worktree`; + try { + await $`git -C ${tmpDir} worktree add ${wtDir} -b test-wt`.quiet(); + + // repoRoot from worktree should return the worktree dir + const top = await repoRoot(wtDir); + expect(top).toBe(wtDir); + + // mainRepoRoot from worktree should return the main repo dir + const main = await mainRepoRoot(wtDir); + expect(main).toBe(tmpDir); + } finally { + await $`git -C ${tmpDir} worktree remove ${wtDir} --force`.quiet().nothrow(); + if (existsSync(wtDir)) rmSync(wtDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/git.ts b/src/git.ts index c1e7ac0..8ac29d1 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,3 +1,4 @@ +import { dirname } from "node:path"; import { $ } from "bun"; // ============================================================================= @@ -6,12 +7,24 @@ import { $ } from "bun"; const NOTES_REF = "ai-sessions"; -/** Get the git repository root directory */ -export async function repoRoot(): Promise { - const result = await $`git rev-parse --show-toplevel`.quiet(); +/** Get the git repository root directory (worktree-aware — returns worktree path in worktrees) */ +export async function repoRoot(cwd?: string): Promise { + const result = cwd + ? await $`git -C ${cwd} rev-parse --show-toplevel`.quiet() + : await $`git rev-parse --show-toplevel`.quiet(); return result.text().trim(); } +/** Get the main repository root, resolving through worktrees to the original repo. + * In a normal repo, this returns the same as repoRoot(). + * In a worktree, this returns the main repo root (not the worktree directory). */ +export async function mainRepoRoot(cwd?: string): Promise { + const result = cwd + ? await $`git -C ${cwd} rev-parse --path-format=absolute --git-common-dir`.quiet() + : await $`git rev-parse --path-format=absolute --git-common-dir`.quiet(); + return dirname(result.text().trim()); +} + /** Get the current branch name */ export async function currentBranch(): Promise { const result = await $`git branch --show-current`.quiet(); diff --git a/src/hooks.test.ts b/src/hooks.test.ts index 8ede138..034d469 100644 --- a/src/hooks.test.ts +++ b/src/hooks.test.ts @@ -11,6 +11,7 @@ import { handleStop, } from "./hooks.js"; import { ACTIVE_DIR, COMPLETED_DIR, SESSION_DIR } from "./paths.js"; +import { collectionName } from "./qmd.js"; import { getActiveSessionId, getSessionPathForHook, parseFrontmatter, readSessionMap } from "./session.js"; let tmpDir: string; @@ -264,3 +265,72 @@ describe("concurrent sessions via hooks", () => { expect(Object.keys(finalMap).length).toBe(0); }); }); + +describe("worktree support", () => { + let mainDir: string; + let wtDir: string; + + beforeEach(async () => { + mainDir = join(import.meta.dir, `../.test-tmp-main-${Date.now()}-${Math.random().toString(36).slice(2)}`); + wtDir = join(import.meta.dir, `../.test-tmp-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(mainDir, { recursive: true }); + await $`git init ${mainDir}`.quiet(); + await $`git -C ${mainDir} commit --allow-empty -m "init"`.quiet(); + await $`git -C ${mainDir} worktree add ${wtDir} -b test-wt`.quiet(); + }); + + afterEach(async () => { + await $`git -C ${mainDir} worktree remove ${wtDir} --force`.quiet().nothrow(); + if (existsSync(wtDir)) rmSync(wtDir, { recursive: true, force: true }); + if (existsSync(mainDir)) rmSync(mainDir, { recursive: true, force: true }); + }); + + test("session created from worktree is stored in main repo", async () => { + await handleSessionStart({ session_id: "wt-test", cwd: wtDir }); + + // Session should be in mainDir, not wtDir + const id = getActiveSessionId(mainDir); + expect(id).not.toBeNull(); + expect(existsSync(join(mainDir, SESSION_DIR, ACTIVE_DIR, `${id}.md`))).toBe(true); + expect(existsSync(join(wtDir, SESSION_DIR))).toBe(false); + }); + + test("session end from worktree moves to main repo completed dir", async () => { + await handleSessionStart({ session_id: "wt-test", cwd: wtDir }); + const id = getActiveSessionId(mainDir)!; + + await handleSessionEnd({ session_id: "wt-test", cwd: wtDir }); + + expect(existsSync(join(mainDir, SESSION_DIR, COMPLETED_DIR, `${id}.md`))).toBe(true); + expect(existsSync(join(wtDir, SESSION_DIR))).toBe(false); + }); + + test("prompts from worktree append to session in main repo", async () => { + await handleSessionStart({ session_id: "wt-test", cwd: wtDir }); + await handlePrompt({ session_id: "wt-test", cwd: wtDir, prompt: "Fix from worktree" }); + + const id = getActiveSessionId(mainDir)!; + const content = readFileSync(join(mainDir, SESSION_DIR, ACTIVE_DIR, `${id}.md`), "utf8"); + expect(content).toContain("Fix from worktree"); + }); + + test("file writes from worktree append to session in main repo", async () => { + await handleSessionStart({ session_id: "wt-test", cwd: wtDir }); + await handlePostWrite({ + session_id: "wt-test", + cwd: wtDir, + tool_name: "Write", + tool_input: { file_path: "src/worktree-file.ts" }, + }); + + const id = getActiveSessionId(mainDir)!; + const content = readFileSync(join(mainDir, SESSION_DIR, ACTIVE_DIR, `${id}.md`), "utf8"); + expect(content).toContain("- Modified: src/worktree-file.ts"); + }); + + test("collectionName is the same from main repo and worktree", async () => { + const nameFromMain = await collectionName(mainDir); + const nameFromWt = await collectionName(wtDir); + expect(nameFromMain).toBe(nameFromWt); + }); +}); diff --git a/src/hooks.ts b/src/hooks.ts index 54eecc1..6768acc 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,7 +1,7 @@ import { execSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import type { PostToolUseInput, SessionEndInput, SessionStartInput, StopInput, UserPromptInput } from "./env.js"; -import { repoRoot } from "./git.js"; +import { mainRepoRoot } from "./git.js"; import { completedSessionPath } from "./paths.js"; import { appendFileModification, @@ -31,7 +31,8 @@ import { * Returns context string via stdout (Claude sees this). */ export async function handleSessionStart(input: SessionStartInput): Promise { - const root = input.cwd || (await repoRoot()); + const root = await mainRepoRoot(input.cwd); + const cwd = input.cwd || root; // worktree cwd for git diff const _id = await createSession(root, input.session_id); // Clean up orphaned sessions from previous runs that never got a session-end @@ -57,8 +58,8 @@ export async function handleSessionStart(input: SessionStartInput): Promise { - const root = input.cwd || (await repoRoot()); + const root = await mainRepoRoot(input.cwd); const prompt = input.prompt || ""; if (prompt) { appendPrompt(root, input.session_id, prompt); @@ -171,7 +172,7 @@ export async function handlePrompt(input: UserPromptInput): Promise { * PostToolUse(Write|Edit): Record file modification. */ export async function handlePostWrite(input: PostToolUseInput): Promise { - const root = input.cwd || (await repoRoot()); + const root = await mainRepoRoot(input.cwd); const filePath = input.tool_input?.file_path as string | undefined; if (filePath) { appendFileModification(root, input.session_id, filePath); @@ -182,7 +183,7 @@ export async function handlePostWrite(input: PostToolUseInput): Promise { * PostToolUse(Task): Record task completion. */ export async function handlePostTask(input: PostToolUseInput): Promise { - const root = input.cwd || (await repoRoot()); + const root = await mainRepoRoot(input.cwd); const description = (input.tool_input?.description as string) || "subtask completed"; appendTaskNote(root, input.session_id, description); } @@ -191,7 +192,7 @@ export async function handlePostTask(input: PostToolUseInput): Promise { * Stop: Append turn delimiter with timestamp and diff stat. */ export async function handleStop(input: StopInput): Promise { - const root = input.cwd || (await repoRoot()); + const root = await mainRepoRoot(input.cwd); await appendTurnDelimiter(root, input.session_id); } @@ -200,7 +201,7 @@ export async function handleStop(input: StopInput): Promise { * Exits immediately — background process handles summarization, git notes, QMD indexing. */ export async function handleSessionEnd(input: SessionEndInput): Promise { - const root = input.cwd || (await repoRoot()); + const root = await mainRepoRoot(input.cwd); const result = finalizeSession(root, input.session_id); if (!result) return; diff --git a/src/index.ts b/src/index.ts index 82c0a7b..3393986 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import { StopInput, UserPromptInput, } from "./env.js"; -import { repoRoot } from "./git.js"; +import { mainRepoRoot, repoRoot } from "./git.js"; // ============================================================================= // Terminal Colors @@ -281,7 +281,7 @@ if (import.meta.main) { } case "checkpoint": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { checkpoint } = await import("./session.js"); await checkpoint(root); break; @@ -306,14 +306,14 @@ if (import.meta.main) { } case "reset": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { reset } = await import("./setup.js"); await reset(root); break; } case "status": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { status } = await import("./setup.js"); await status(root); break; @@ -365,7 +365,7 @@ if (import.meta.main) { } case "search": { - const _root = await repoRoot(); + const _root = await mainRepoRoot(); const { searchSessions } = await import("./qmd.js"); const result = await searchSessions(cli.query, { tag: cli.values.tag as string | undefined }); if (result) { @@ -377,7 +377,7 @@ if (import.meta.main) { } case "log": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { listCompletedSessions, parseFrontmatter } = await import("./session.js"); const { readFileSync } = await import("node:fs"); const { completedSessionPath } = await import("./paths.js"); @@ -411,7 +411,7 @@ if (import.meta.main) { } // Check if arg is a session ID (e.g. 2025-06-15-a1b2c3d4) if (/^\d{4}-\d{2}-\d{2}-[0-9a-f]{8}$/.test(arg)) { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { existsSync, readFileSync } = await import("node:fs"); const { completedSessionPath, sessionFilePath } = await import("./paths.js"); const completedPath = completedSessionPath(root, arg); @@ -436,7 +436,7 @@ if (import.meta.main) { } case "tag": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { addTags, getMostRecentCompletedId } = await import("./session.js"); let sessionId: string | undefined; let tags: string[]; @@ -457,7 +457,7 @@ if (import.meta.main) { } case "knowledge": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const subcommand = cli.args[0]; const knowledgeSubcmds = ["build", "inject", "show", "diff"]; if (subcommand && knowledgeSubcmds.includes(subcommand)) { @@ -492,7 +492,7 @@ if (import.meta.main) { } case "decision": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const description = cli.args.join(" "); if (!description) { console.error('Usage: ghost decision ""'); @@ -506,7 +506,7 @@ if (import.meta.main) { } case "mistake": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const description = cli.args.join(" "); if (!description) { console.error('Usage: ghost mistake ""'); @@ -520,7 +520,7 @@ if (import.meta.main) { } case "strategy": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const description = cli.args.join(" "); if (!description) { console.error('Usage: ghost strategy ""'); @@ -534,7 +534,7 @@ if (import.meta.main) { } case "decisions": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { listDecisions } = await import("./session.js"); const content = listDecisions(root, cli.values.tag as string | undefined); if (content) { @@ -546,7 +546,7 @@ if (import.meta.main) { } case "resume": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { findRecentSession, generateContinuityBlock } = await import("./session.js"); const sessionId = cli.args[0] || (await findRecentSession(root)); if (!sessionId) { @@ -563,7 +563,7 @@ if (import.meta.main) { } case "brief": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const description = cli.args.join(" "); if (!description) { console.error('Usage: ghost brief ""'); @@ -575,7 +575,7 @@ if (import.meta.main) { } case "heatmap": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { showHeatmap } = await import("./search.js"); await showHeatmap(root, { tag: cli.values.tag as string | undefined, @@ -586,7 +586,7 @@ if (import.meta.main) { } case "stats": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { showStats } = await import("./search.js"); await showStats(root, { json: cli.values.json as boolean | undefined, @@ -597,7 +597,7 @@ if (import.meta.main) { } case "edit": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { sessionDir, knowledgePath, mistakesPath, decisionsPath, strategiesPath } = await import("./paths.js"); const target = cli.args[0]; const paths: Record = { @@ -629,14 +629,14 @@ if (import.meta.main) { } case "absorb": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { absorb: absorbCmd } = await import("./knowledge.js"); await absorbCmd(root, { dryRun: !!cli.values["dry-run"] }); break; } case "genesis": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { genesis, injectKnowledge } = await import("./knowledge.js"); const built = await genesis(root); if (built) { @@ -646,7 +646,7 @@ if (import.meta.main) { } case "cleanup": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { cleanupOrphanedSessions } = await import("./session.js"); const dryRun = !!cli.values["dry-run"]; // Use 0 maxAge for manual cleanup — clean everything not actively in use @@ -669,7 +669,7 @@ if (import.meta.main) { } case "validate": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { validate: validateFiles } = await import("./validate.js"); const issues = validateFiles(root, { fix: !!cli.values.force }); if (issues.length === 0) { @@ -689,7 +689,7 @@ if (import.meta.main) { } case "reindex": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { indexSession } = await import("./qmd.js"); const result = await indexSession(root); if (result.ok) { @@ -702,7 +702,7 @@ if (import.meta.main) { } case "logs": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { existsSync, readFileSync } = await import("node:fs"); const { join } = await import("node:path"); const { SESSION_DIR } = await import("./paths.js"); @@ -726,7 +726,7 @@ if (import.meta.main) { } case "sync": { - const root = await repoRoot(); + const root = await mainRepoRoot(); const { syncKnowledge } = await import("./sync.js"); await syncKnowledge(root); console.log("Shared knowledge synced."); diff --git a/src/qmd.ts b/src/qmd.ts index 8ac03b3..4d8a1f6 100644 --- a/src/qmd.ts +++ b/src/qmd.ts @@ -1,7 +1,7 @@ import { basename } from "node:path"; import { $ } from "bun"; import { checkQmd, resetDepCache } from "./deps.js"; -import { repoRoot } from "./git.js"; +import { mainRepoRoot } from "./git.js"; import { completedDir } from "./paths.js"; // ============================================================================= @@ -19,9 +19,9 @@ export function resetQmdCache(): void { resetDepCache(); } -/** Derive the QMD collection name from the git repo root */ +/** Derive the QMD collection name from the main repo root (stable across worktrees) */ export async function collectionName(root?: string): Promise { - const r = root || (await repoRoot()); + const r = await mainRepoRoot(root); return `ghost-${basename(r)}`; } diff --git a/src/setup.test.ts b/src/setup.test.ts index 8f6eff4..49e5e10 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -187,3 +187,66 @@ describe("disable", () => { await disable(tmpDir); }); }); + +describe("worktree support", () => { + let mainDir: string; + let wtDir: string; + + beforeEach(async () => { + mainDir = join(import.meta.dir, `../.test-tmp-main-${Date.now()}-${Math.random().toString(36).slice(2)}`); + wtDir = join(import.meta.dir, `../.test-tmp-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(mainDir, { recursive: true }); + await $`git init ${mainDir}`.quiet(); + await $`git -C ${mainDir} commit --allow-empty -m "init"`.quiet(); + await $`git -C ${mainDir} worktree add ${wtDir} -b test-wt`.quiet(); + resetQmdCache(); + resetDepCache(); + }); + + afterEach(async () => { + // Clean up QMD collections + try { + const name = await collectionName(mainDir); + await removeCollection(name); + } catch { + // ignore + } + await $`git -C ${mainDir} worktree remove ${wtDir} --force`.quiet().nothrow(); + if (existsSync(wtDir)) rmSync(wtDir, { recursive: true, force: true }); + if (existsSync(mainDir)) rmSync(mainDir, { recursive: true, force: true }); + }); + + test("enable from worktree stores sessions in main repo", async () => { + await enable(wtDir); + + // Config files should be in wtDir (worktree-local) + expect(existsSync(join(wtDir, ".claude", "settings.json"))).toBe(true); + + // Session dirs should be in mainDir (main repo root) + expect(existsSync(join(mainDir, SESSION_DIR, ACTIVE_DIR))).toBe(true); + expect(existsSync(join(mainDir, SESSION_DIR, COMPLETED_DIR))).toBe(true); + + // Session dirs should NOT be in wtDir + expect(existsSync(join(wtDir, SESSION_DIR))).toBe(false); + }); + + test("collectionName is the same from main repo and worktree", async () => { + const nameFromMain = await collectionName(mainDir); + const nameFromWt = await collectionName(wtDir); + expect(nameFromMain).toBe(nameFromWt); + }); + + test("git hooks installed in main repo, not worktree", async () => { + await enable(wtDir); + const mainHookPath = join(mainDir, ".git", "hooks", "post-commit"); + expect(existsSync(mainHookPath)).toBe(true); + const hookContent = readFileSync(mainHookPath, "utf8"); + expect(hookContent).toContain("ghost checkpoint &"); + }); + + test("gitignore added to main repo root", async () => { + await enable(wtDir); + const gitignore = readFileSync(join(mainDir, ".gitignore"), "utf8"); + expect(gitignore).toContain(".ai-sessions/"); + }); +}); diff --git a/src/setup.ts b/src/setup.ts index ea467f8..35d35c9 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -9,7 +9,7 @@ import { printDepsReport, resetDepCache, } from "./deps.js"; -import { configSet } from "./git.js"; +import { configSet, mainRepoRoot } from "./git.js"; import { activeDir, completedDir, SESSION_DIR } from "./paths.js"; import { collectionExists, collectionName, createCollection } from "./qmd.js"; @@ -75,8 +75,13 @@ const GHOST_HOOKS = { // Enable // ============================================================================= -/** Set up ghost in the current git repo */ +/** Set up ghost in the current git repo. + * `root` is the worktree-local directory (for config files like .claude/settings.json, .mcp.json). + * Session storage and QMD use the main repo root (resolved internally via mainRepoRoot). */ export async function enable(root: string, opts?: { install?: boolean; genesis?: boolean }): Promise { + // Resolve main repo root for session storage (stable across worktrees) + const mainRoot = await mainRepoRoot(root); + // 1. Check dependencies console.log(`${c.bold}Checking dependencies...${c.reset}`); let report = await checkAllDeps(); @@ -116,12 +121,12 @@ export async function enable(root: string, opts?: { install?: boolean; genesis?: console.log(` ${c.dim}Install: https://claude.ai/download${c.reset}`); } - // 2. Create session storage directories - mkdirSync(activeDir(root), { recursive: true }); - mkdirSync(completedDir(root), { recursive: true }); + // 2. Create session storage directories (in main repo root) + mkdirSync(activeDir(mainRoot), { recursive: true }); + mkdirSync(completedDir(mainRoot), { recursive: true }); - // 3. Ensure .ai-sessions/ is in the project's .gitignore - ensureGitignored(root); + // 3. Ensure .ai-sessions/ is in the project's .gitignore (main repo root) + ensureGitignored(mainRoot); // 4. Configure git notes display await configSet("notes.displayRef", "refs/notes/ai-sessions"); @@ -136,7 +141,7 @@ export async function enable(root: string, opts?: { install?: boolean; genesis?: writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`); // 6. Add QMD MCP server config to .mcp.json (only if qmd is available) - const name = await collectionName(root); + const name = await collectionName(mainRoot); if (report.qmd.available) { const mcpPath = join(root, ".mcp.json"); const mcpConfig = readSettings(mcpPath); @@ -152,8 +157,8 @@ export async function enable(root: string, opts?: { install?: boolean; genesis?: // 7. Inject Ghost header into CLAUDE.md injectClaudeHeader(root); - // 8. Install git post-commit hook - const hooksDir = join(root, ".git", "hooks"); + // 8. Install git post-commit hook (in main repo .git/hooks — shared across worktrees) + const hooksDir = join(mainRoot, ".git", "hooks"); mkdirSync(hooksDir, { recursive: true }); const postCommitPath = join(hooksDir, "post-commit"); const hookContent = '#!/bin/sh\nexport PATH="$HOME/.bun/bin:$PATH"\nghost checkpoint &\n'; @@ -168,10 +173,10 @@ export async function enable(root: string, opts?: { install?: boolean; genesis?: const { chmod } = await import("node:fs/promises"); await chmod(postCommitPath, 0o755); - // 9. Create initial QMD collection (if available) + // 9. Create initial QMD collection (if available, uses main repo root) let qmdOk = false; if (report.qmd.available) { - qmdOk = await createCollection(root); + qmdOk = await createCollection(mainRoot); } // 10. Report results @@ -193,12 +198,12 @@ export async function enable(root: string, opts?: { install?: boolean; genesis?: console.log(` ${c.bold}Summarize:${c.reset} ${c.yellow}disabled${c.reset} (claude CLI not found)`); } - // 11. Initialize shared knowledge branch and pull team knowledge + // 11. Initialize shared knowledge branch and pull team knowledge (uses main repo root) try { const { initSharedBranch, pullShared } = await import("./sync.js"); - const branchOk = await initSharedBranch(root); + const branchOk = await initSharedBranch(mainRoot); if (branchOk) { - await pullShared(root); + await pullShared(mainRoot); console.log(` ${c.bold}Shared:${c.reset} ghost/knowledge ${c.green}(synced)${c.reset}`); } else { console.log(` ${c.bold}Shared:${c.reset} ghost/knowledge ${c.yellow}(skipped)${c.reset}`); @@ -215,15 +220,15 @@ export async function enable(root: string, opts?: { install?: boolean; genesis?: try { const claudeMdPath = join(root, "CLAUDE.md"); if (existsSync(claudeMdPath) && readFileSync(claudeMdPath, "utf8").trim()) { - await absorb(root); + await absorb(mainRoot); } } catch { // absorb is best-effort during enable } // Then build knowledge from codebase - const built = await genesis(root); + const built = await genesis(mainRoot); if (built) { - await injectKnowledge(root); + await injectKnowledge(mainRoot); } } else if (opts?.genesis && !report.claude.available) { console.log(`\n${c.yellow}Skipping genesis — claude CLI required.${c.reset}`); @@ -300,11 +305,12 @@ export async function disable(root: string): Promise { /** Show ghost status for the current repo */ export async function status(root: string): Promise { + const mainRoot = await mainRepoRoot(root); const { getActiveSessionId, listCompletedSessions } = await import("./session.js"); - const activeId = getActiveSessionId(root); - const completed = listCompletedSessions(root); - const pidFile = join(root, SESSION_DIR, ".background.pid"); + const activeId = getActiveSessionId(mainRoot); + const completed = listCompletedSessions(mainRoot); + const pidFile = join(mainRoot, SESSION_DIR, ".background.pid"); const bgRunning = existsSync(pidFile); console.log(`${c.bold}Active session:${c.reset} ${activeId || "none"}`); @@ -327,10 +333,10 @@ export async function status(root: string): Promise { // Check dependencies const report = await checkAllDeps(); - const name = await collectionName(root); + const name = await collectionName(mainRoot); if (report.qmd.available) { - const hasCollection = await collectionExists(root); + const hasCollection = await collectionExists(mainRoot); console.log( `${c.bold}QMD:${c.reset} ${c.green}installed${c.reset}, collection ${name} ${hasCollection ? `${c.green}exists${c.reset}` : `${c.yellow}missing${c.reset}`}`, ); @@ -345,7 +351,7 @@ export async function status(root: string): Promise { // Check shared branch status try { const { branchExists } = await import("./git.js"); - const exists = await branchExists("ghost/knowledge", root); + const exists = await branchExists("ghost/knowledge", mainRoot); console.log( `${c.bold}Shared branch:${c.reset} ${exists ? `${c.green}ghost/knowledge${c.reset}` : `${c.yellow}not initialized${c.reset}`}`, ); @@ -354,7 +360,7 @@ export async function status(root: string): Promise { } // Show last background log lines - const bgLogFile = join(root, SESSION_DIR, ".background.log"); + const bgLogFile = join(mainRoot, SESSION_DIR, ".background.log"); if (existsSync(bgLogFile)) { try { const logContent = readFileSync(bgLogFile, "utf8").trim(); @@ -378,6 +384,7 @@ export async function status(root: string): Promise { /** Wipe all session data, git notes, and QMD collection. Keeps ghost enabled. */ export async function reset(root: string): Promise { + root = await mainRepoRoot(root); const dir = join(root, SESSION_DIR); if (!existsSync(dir)) { console.log("Nothing to reset — no .ai-sessions/ directory found.");