Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.8.2] - 2026-02-25

### Fixed

- **Worktree sessions fragment project identity**: When Claude Code runs in a worktree (`isolation: "worktree"`, introduced in v2.1.47), hook stdin reports the worktree path (e.g., `/tmp/claude-worktree-abc123/`) as `cwd`. Causantic derived project identity from `basename(cwd)`, so worktree sessions were tagged as `claude-worktree-abc123` instead of the real project name. All entry points (hook dispatcher, claudemd-generator, session reader, ingestion, hook-utils) now resolve worktree paths back to the main repository via `git worktree list --porcelain` with a `.git` file parsing fallback. Project identity is consistent across worktree and non-worktree sessions.

### Added

- **`src/utils/project-path.ts`**: New utility — `resolveCanonicalProjectPath(cwd)` detects linked worktrees (`.git` is a file, not a directory), resolves to the main repo path, and caches results. Uses `execFileSync` with 500ms timeout, falls back to parsing the `.git` file. Skips submodules (`.git/modules/` paths). Pattern follows `device-detector.ts`.
- **Claude Code Compatibility section** in `docs/guides/integration.md`: Documents worktree resolution, `CLAUDE_CODE_SIMPLE` mode, enterprise `disableAllHooks`, and future hook events.

### Tests

- 11 new tests in `test/utils/project-path.test.ts`: normal repo, worktree resolution via git command, `.git` file fallback, submodule guard, error cases, caching, and a real git worktree integration test.
- Updated `test/cli/commands/hook.test.ts` and `test/parser/session-reader.test.ts` with worktree-aware assertions.
- 2066 total tests passing.

## [0.8.1] - 2026-02-22

### Changed
Expand Down
20 changes: 20 additions & 0 deletions docs/guides/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

This guide covers integrating Causantic with Claude Code through hooks and MCP.

## Claude Code Compatibility

### Worktree Sessions

Claude Code v2.1.47+ supports worktree isolation (`isolation: "worktree"` in agents, `--worktree` flag). When a session runs in a worktree, Claude Code passes the worktree path (e.g., `/tmp/claude-worktree-abc123/`) as `cwd` in hook stdin.

Causantic automatically resolves worktree paths back to the main repository, so project identity remains consistent across worktree and non-worktree sessions. No configuration is needed.

### CLAUDE_CODE_SIMPLE Mode

Setting `CLAUDE_CODE_SIMPLE=true` disables all Claude Code integrations including MCP servers and hooks. Causantic will not receive any hook events or serve MCP queries when this mode is active.

### Enterprise `disableAllHooks`

The `disableAllHooks` managed setting (enterprise Claude Code deployments) can override Causantic hooks. If hooks are not firing, check whether this setting is active in your organisation's Claude Code configuration.

### Future Hook Events

Claude Code may introduce additional hook events (`ConfigChange`, `WorktreeCreate`, `WorktreeRemove`) that Causantic could leverage for richer context tracking.

## Hook System

Causantic uses Claude Code hooks to capture context at key moments:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "causantic",
"version": "0.8.1",
"version": "0.8.2",
"description": "Long-term memory for Claude Code — local-first, graph-augmented, self-benchmarking",
"type": "module",
"private": false,
Expand Down
16 changes: 10 additions & 6 deletions src/cli/commands/hook.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { basename } from 'node:path';
import type { Command } from '../types.js';
import { resolveCanonicalProjectPath } from '../../utils/project-path.js';

/**
* Claude Code hook stdin input shape.
Expand Down Expand Up @@ -68,8 +69,10 @@ export const hookCommand: Command = {

switch (hookName) {
case 'session-start': {
// session-start needs the project slug (basename of cwd)
const projectSlug = basename(input.cwd ?? args[1] ?? process.cwd());
// session-start needs the project slug (basename of canonical cwd)
const projectSlug = basename(
resolveCanonicalProjectPath(input.cwd ?? args[1] ?? process.cwd()),
);

const { handleSessionStart } = await import('../../hooks/session-start.js');
const result = await handleSessionStart(projectSlug, {});
Expand All @@ -93,7 +96,7 @@ export const hookCommand: Command = {
process.exit(2);
}

const project = basename(input.cwd ?? process.cwd());
const project = basename(resolveCanonicalProjectPath(input.cwd ?? process.cwd()));
const { handlePreCompact } = await import('../../hooks/pre-compact.js');
await handlePreCompact(sessionPath, { project, sessionId: input.session_id });
console.log('Pre-compact hook executed.');
Expand All @@ -108,16 +111,17 @@ export const hookCommand: Command = {
process.exit(2);
}

const project = basename(input.cwd ?? process.cwd());
const project = basename(resolveCanonicalProjectPath(input.cwd ?? process.cwd()));
const { handleSessionEnd } = await import('../../hooks/session-end.js');
await handleSessionEnd(sessionPath, { project, sessionId: input.session_id });
console.log('Session-end hook executed.');
break;
}
case 'claudemd-generator': {
const projectPath = input.cwd ?? args[1] ?? process.cwd();
const rawCwd = input.cwd ?? args[1] ?? process.cwd();
const projectSlug = basename(resolveCanonicalProjectPath(rawCwd));
const { updateClaudeMd } = await import('../../hooks/claudemd-generator.js');
await updateClaudeMd(projectPath, {});
await updateClaudeMd(rawCwd, { projectSlug });
console.log('CLAUDE.md updated.');
break;
}
Expand Down
9 changes: 6 additions & 3 deletions src/hooks/claudemd-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import { readFile, writeFile } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import { basename, join } from 'path';
import { generateMemorySection } from './session-start.js';
import { executeHook, logHook, isTransientError, type HookMetrics } from './hook-utils.js';
import { errorMessage } from '../utils/errors.js';
Expand All @@ -31,6 +31,8 @@ export interface ClaudeMdOptions extends SessionStartOptions {
claudeMdPath?: string;
/** Create file if it doesn't exist. Default: false */
createIfMissing?: boolean;
/** Project slug for memory queries (overrides basename(projectPath)). */
projectSlug?: string;
}

/**
Expand Down Expand Up @@ -64,8 +66,9 @@ async function internalUpdateClaudeMd(
...sessionOptions
} = options;

// Generate memory section
const memorySection = await generateMemorySection(projectPath, sessionOptions);
// Generate memory section using project slug for DB queries
const memorySlug = options.projectSlug ?? basename(projectPath);
const memorySection = await generateMemorySection(memorySlug, sessionOptions);

if (!memorySection) {
logHook({
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/hook-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ export async function handleIngestionHook(
options: IngestionHookOptions = {},
): Promise<IngestionHookResult> {
const { basename } = await import('node:path');
const { resolveCanonicalProjectPath } = await import('../utils/project-path.js');
const { recordHookStatus } = await import('./hook-status.js');

const { enableRetry = true, maxRetries = 3, gracefulDegradation = true } = options;
Expand All @@ -396,7 +397,7 @@ export async function handleIngestionHook(
degraded: true,
};

const project = options.project ?? basename(process.cwd());
const project = options.project ?? basename(resolveCanonicalProjectPath(process.cwd()));

const { result, metrics } = await executeHook<IngestionHookResult>(
hookName,
Expand Down
3 changes: 2 additions & 1 deletion src/ingest/ingest-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
import type { ChunkInput } from '../storage/types.js';
import type { Chunk, Turn } from '../parser/types.js';
import { createLogger } from '../utils/logger.js';
import { resolveCanonicalProjectPath } from '../utils/project-path.js';

const log = createLogger('ingest-session');

Expand Down Expand Up @@ -166,7 +167,7 @@ export async function ingestSession(
// Get session info
const info = await getSessionInfo(sessionPath);
const projectSlug = deriveProjectSlug(info);
const projectPath = info.cwd || '';
const projectPath = info.cwd ? resolveCanonicalProjectPath(info.cwd) : '';

// Get file stats for mtime check
const fileStats = await stat(sessionPath);
Expand Down
8 changes: 5 additions & 3 deletions src/parser/session-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createInterface } from 'node:readline';
import { basename, dirname, join } from 'node:path';
import type { RawMessage, RawMessageType, SessionInfo } from './types.js';
import { createLogger } from '../utils/logger.js';
import { resolveCanonicalProjectPath } from '../utils/project-path.js';

const log = createLogger('session-reader');

Expand Down Expand Up @@ -257,14 +258,15 @@ export async function hasSubAgents(sessionPath: string): Promise<boolean> {
*/
export function deriveProjectSlug(info: SessionInfo, knownSlugs?: Map<string, string>): string {
if (info.cwd) {
let slug = basename(info.cwd);
const canonicalCwd = resolveCanonicalProjectPath(info.cwd);
let slug = basename(canonicalCwd);

// Check for collision: same basename but different cwd
if (knownSlugs) {
const existingCwd = knownSlugs.get(slug);
if (existingCwd && existingCwd !== info.cwd) {
if (existingCwd && existingCwd !== canonicalCwd) {
// Disambiguate using last two path components
slug = twoComponentSlug(info.cwd);
slug = twoComponentSlug(canonicalCwd);
}
}

Expand Down
148 changes: 148 additions & 0 deletions src/utils/project-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Resolves canonical project paths from git worktree directories.
*
* Claude Code v2.1.47+ supports worktree isolation, passing worktree paths
* (e.g., /tmp/claude-worktree-abc123/) as `cwd`. This utility resolves
* worktree paths back to the main repository path so project identity
* remains consistent across worktree and non-worktree sessions.
*/

import { execFileSync } from 'node:child_process';
import { readFileSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { createLogger } from './logger.js';

const log = createLogger('project-path');

/** Module-level cache: worktree path → canonical path. */
const cache = new Map<string, string>();

/**
* Resolve a working directory to its canonical project path.
*
* For normal repos, returns the input unchanged.
* For linked worktrees, resolves to the main repository path.
* For non-git directories, returns the input unchanged.
*
* @param cwd - The working directory (possibly a worktree path)
* @returns The canonical project path (main repo root)
*/
export function resolveCanonicalProjectPath(cwd: string): string {
if (!cwd) return cwd;

const cached = cache.get(cwd);
if (cached !== undefined) return cached;

const resolved = resolveWorktree(cwd);
cache.set(cwd, resolved);
return resolved;
}

/**
* Clear the path cache. Intended for testing.
*/
export function clearProjectPathCache(): void {
cache.clear();
}

/**
* Internal resolution logic.
*/
function resolveWorktree(cwd: string): string {
// Check if .git exists and what type it is
let gitStat;
try {
gitStat = statSync(join(cwd, '.git'));
} catch {
// No .git — not a git repo (or inaccessible), return as-is
return cwd;
}

// Normal repo: .git is a directory
if (gitStat.isDirectory()) {
return cwd;
}

// Linked worktree: .git is a file containing "gitdir: <path>"
if (gitStat.isFile()) {
return resolveFromGitCommand(cwd) ?? resolveFromGitFile(cwd) ?? cwd;
}

return cwd;
}

/**
* Try resolving via `git worktree list --porcelain`.
* The first line is always the main worktree: "worktree /path/to/main"
*/
function resolveFromGitCommand(cwd: string): string | null {
try {
const output = execFileSync('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], {
timeout: 500,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});

// First line: "worktree /path/to/main/repo"
const firstLine = output.split('\n')[0];
if (firstLine?.startsWith('worktree ')) {
const mainPath = firstLine.slice('worktree '.length).trim();
if (mainPath && mainPath !== cwd) {
log.debug('Resolved worktree via git command', { from: cwd, to: mainPath });
return mainPath;
}
// If mainPath === cwd, this IS the main worktree
return mainPath || null;
}
} catch (error) {
log.debug('git worktree list failed, trying .git file fallback', {
cwd,
error: error instanceof Error ? error.message : String(error),
});
}
return null;
}

/**
* Fallback: parse the .git file to find the main repo.
*
* Worktree .git files contain: "gitdir: /path/to/main/.git/worktrees/<name>"
* Submodule .git files contain: "gitdir: /path/to/main/.git/modules/<name>"
*
* We only resolve worktrees (path contains /worktrees/), not submodules.
*/
function resolveFromGitFile(cwd: string): string | null {
try {
const gitFileContent = readFileSync(join(cwd, '.git'), 'utf-8').trim();

if (!gitFileContent.startsWith('gitdir: ')) {
return null;
}

const gitdir = gitFileContent.slice('gitdir: '.length).trim();

// Only resolve worktrees, not submodules
if (!gitdir.includes('/worktrees/')) {
return null;
}

// Walk up from gitdir to find main repo root:
// gitdir is like /path/to/main/.git/worktrees/<name>
// We need /path/to/main (parent of .git)
const worktreesIdx = gitdir.lastIndexOf('/worktrees/');
const dotGitDir = gitdir.slice(0, worktreesIdx);

// dotGitDir should end with .git (or be the .git directory itself)
const mainPath = dirname(dotGitDir);
if (mainPath) {
log.debug('Resolved worktree via .git file', { from: cwd, to: mainPath });
return mainPath;
}
} catch (error) {
log.debug('Failed to parse .git file', {
cwd,
error: error instanceof Error ? error.message : String(error),
});
}
return null;
}
8 changes: 6 additions & 2 deletions test/cli/commands/hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,9 @@ describe('hookCommand', () => {

await hookCommand.handler(['claudemd-generator', '/projects/my-app']);

expect(mockUpdateClaudeMd).toHaveBeenCalledWith('/projects/my-app', {});
expect(mockUpdateClaudeMd).toHaveBeenCalledWith('/projects/my-app', {
projectSlug: 'my-app',
});
expect(console.log).toHaveBeenCalledWith('CLAUDE.md updated.');
});

Expand All @@ -173,7 +175,9 @@ describe('hookCommand', () => {

await hookCommand.handler(['claudemd-generator']);

expect(mockUpdateClaudeMd).toHaveBeenCalledWith(expect.any(String), {});
expect(mockUpdateClaudeMd).toHaveBeenCalledWith(expect.any(String), {
projectSlug: expect.any(String),
});
});
});

Expand Down
Loading