From a6a0ff61ebd3ce5e0f3a0976c7b3f7af249256c7 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Fri, 20 Mar 2026 03:49:58 +0000 Subject: [PATCH] Restore project memory on real session starts The installed SessionStart hook runs in its own Node process, so the existing project-memory registration path never reached the startup additionalContext consumed by Claude Code. Load and, when needed, refresh persisted project memory directly from the plugin runtime inside the session-start script, then append the formatted summary without relying on process-local collector state. Keep the legacy helper aligned with plugin-root-aware imports. Constraint: The installed SessionStart runtime is `scripts/session-start.mjs`, not the in-process bridge path Constraint: Session-start hooks must remain failure-tolerant and continue on missing dist/runtime artifacts Rejected: Refactor all session-start assembly into shared bridge/script runtime | broader change than needed for the bug Rejected: Preserve collector-only registration across processes | process isolation makes that design ineffective here Confidence: high Scope-risk: narrow Directive: Do not rely on in-memory contextCollector state for subprocess hook injection paths without a persisted or returned handoff Tested: node --check scripts/session-start.mjs scripts/project-memory-session.mjs Tested: Manual A/B subprocess reproduction against pre-fix session-start script vs patched script with persisted project-memory.json Tested: Added focused regression test in src/__tests__/session-start-script-context.test.ts Not-tested: Full Vitest run in this worktree (local shared node_modules does not expose a runnable repo-local vitest binary) Related: issue #1779 --- scripts/project-memory-session.mjs | 6 +- scripts/session-start.mjs | 104 ++++++++++++++++++ .../session-start-script-context.test.ts | 91 +++++++++++++++ 3 files changed, 200 insertions(+), 1 deletion(-) diff --git a/scripts/project-memory-session.mjs b/scripts/project-memory-session.mjs index 1c3332612..cf0bc2de6 100755 --- a/scripts/project-memory-session.mjs +++ b/scripts/project-memory-session.mjs @@ -11,6 +11,10 @@ import { fileURLToPath, pathToFileURL } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +function getRuntimeBaseDir() { + return process.env.CLAUDE_PLUGIN_ROOT || join(__dirname, '..'); +} + // Import timeout-protected stdin reader (prevents hangs on Linux/Windows, see issue #240, #524) let readStdin; try { @@ -34,7 +38,7 @@ try { // Dynamic import of project memory module (prevents crash if dist is missing, see issue #362) let registerProjectMemoryContext; try { - const mod = await import(pathToFileURL(join(__dirname, '..', 'dist', 'hooks', 'project-memory', 'index.js')).href); + const mod = await import(pathToFileURL(join(getRuntimeBaseDir(), 'dist', 'hooks', 'project-memory', 'index.js')).href); registerProjectMemoryContext = mod.registerProjectMemoryContext; } catch { // dist not built or missing - skip project memory detection silently diff --git a/scripts/session-start.mjs b/scripts/session-start.mjs index 663ce8082..81284ce81 100644 --- a/scripts/session-start.mjs +++ b/scripts/session-start.mjs @@ -47,6 +47,89 @@ function readJsonFile(path) { } } +function getRuntimeBaseDir() { + return process.env.CLAUDE_PLUGIN_ROOT || join(__dirname, '..'); +} + +async function loadProjectMemoryModules() { + try { + const runtimeBase = getRuntimeBaseDir(); + const [ + projectMemoryStorage, + projectMemoryDetector, + projectMemoryFormatter, + rulesFinder, + ] = await Promise.all([ + import(pathToFileURL(join(runtimeBase, 'dist', 'hooks', 'project-memory', 'storage.js')).href), + import(pathToFileURL(join(runtimeBase, 'dist', 'hooks', 'project-memory', 'detector.js')).href), + import(pathToFileURL(join(runtimeBase, 'dist', 'hooks', 'project-memory', 'formatter.js')).href), + import(pathToFileURL(join(runtimeBase, 'dist', 'hooks', 'rules-injector', 'finder.js')).href), + ]); + + return { + loadProjectMemory: projectMemoryStorage.loadProjectMemory, + saveProjectMemory: projectMemoryStorage.saveProjectMemory, + shouldRescan: projectMemoryStorage.shouldRescan, + detectProjectEnvironment: projectMemoryDetector.detectProjectEnvironment, + formatContextSummary: projectMemoryFormatter.formatContextSummary, + findProjectRoot: rulesFinder.findProjectRoot, + }; + } catch { + return null; + } +} + +function hasProjectMemoryContent(memory) { + return Boolean( + memory && + ( + memory.userDirectives?.length || + memory.customNotes?.length || + memory.hotPaths?.length || + memory.techStack?.languages?.length || + memory.techStack?.frameworks?.length || + memory.build?.buildCommand || + memory.build?.testCommand + ) + ); +} + +async function resolveProjectMemorySummary(directory, projectMemoryModules) { + const { + detectProjectEnvironment, + findProjectRoot, + formatContextSummary, + loadProjectMemory, + saveProjectMemory, + shouldRescan, + } = projectMemoryModules; + + const projectRoot = findProjectRoot?.(directory); + if (!projectRoot) { + return ''; + } + + let memory = await loadProjectMemory?.(projectRoot); + + if ((!memory || shouldRescan?.(memory)) && detectProjectEnvironment && saveProjectMemory) { + const existing = memory; + memory = await detectProjectEnvironment(projectRoot); + + if (existing) { + memory.customNotes = existing.customNotes; + memory.userDirectives = existing.userDirectives; + } + + await saveProjectMemory(projectRoot, memory); + } + + if (!hasProjectMemoryContent(memory)) { + return ''; + } + + return formatContextSummary(memory)?.trim() || ''; +} + // Semantic version comparison (for cache cleanup sorting) function semverCompare(a, b) { const pa = a.replace(/^v/, '').split('.').map(s => parseInt(s, 10) || 0); @@ -291,6 +374,7 @@ async function main() { const directory = data.cwd || data.directory || process.cwd(); const sessionId = data.session_id || data.sessionId || ''; const messages = []; + const projectMemoryModules = await loadProjectMemoryModules(); // Check for version drift between components const driftInfo = detectVersionDrift(); @@ -421,6 +505,26 @@ Treat this as prior-session context only. Prioritize the user's newest request, `); } + if (projectMemoryModules) { + try { + const summary = await resolveProjectMemorySummary(directory, projectMemoryModules); + if (summary) { + messages.push(` + +[PROJECT MEMORY] + +${summary} + + + +--- +`); + } + } catch { + // Project memory is additive only; never break session start. + } + } + // Check for notepad Priority Context const notepadPath = join(directory, '.omc', 'notepad.md'); if (existsSync(notepadPath)) { diff --git a/src/__tests__/session-start-script-context.test.ts b/src/__tests__/session-start-script-context.test.ts index 1399341db..229d0a7d0 100644 --- a/src/__tests__/session-start-script-context.test.ts +++ b/src/__tests__/session-start-script-context.test.ts @@ -58,4 +58,95 @@ describe('session-start.mjs regression #1386', () => { expect(context).toContain("Prioritize the user's newest request"); expect(context).not.toContain('Continue working in ultrawork mode until all tasks are complete.'); }); + + it('injects persisted project memory into session-start additionalContext', () => { + mkdirSync(join(fakeProject, '.git')); + mkdirSync(join(fakeProject, '.omc'), { recursive: true }); + writeFileSync( + join(fakeProject, '.omc', 'project-memory.json'), + JSON.stringify({ + version: '1.0.0', + lastScanned: Date.now(), + projectRoot: fakeProject, + techStack: { + languages: [ + { + name: 'TypeScript', + version: '5.0.0', + confidence: 'high', + markers: ['tsconfig.json', 'package.json'], + }, + ], + frameworks: [], + packageManager: 'pnpm', + runtime: 'node', + }, + build: { + buildCommand: 'pnpm build', + testCommand: 'pnpm test', + lintCommand: null, + devCommand: null, + scripts: {}, + }, + conventions: { + namingStyle: null, + importStyle: null, + testPattern: null, + fileOrganization: null, + }, + structure: { + isMonorepo: false, + workspaces: [], + mainDirectories: ['src'], + gitBranches: null, + }, + customNotes: [ + { + timestamp: Date.now(), + source: 'manual', + category: 'env', + content: 'Requires LOCAL_API_BASE for smoke tests', + }, + ], + directoryMap: {}, + hotPaths: [], + userDirectives: [ + { + timestamp: Date.now(), + directive: 'Preserve project memory directives at session start', + context: '', + source: 'explicit', + priority: 'high', + }, + ], + }), + ); + + const raw = execFileSync(NODE, [SCRIPT_PATH], { + input: JSON.stringify({ + hook_event_name: 'SessionStart', + session_id: 'session-1779', + cwd: fakeProject, + }), + encoding: 'utf-8', + env: { + ...process.env, + HOME: fakeHome, + USERPROFILE: fakeHome, + }, + timeout: 15000, + }).trim(); + + const output = JSON.parse(raw) as { + continue: boolean; + hookSpecificOutput?: { additionalContext?: string }; + }; + const context = output.hookSpecificOutput?.additionalContext || ''; + + expect(output.continue).toBe(true); + expect(context).toContain('[PROJECT MEMORY]'); + expect(context).toContain('Preserve project memory directives at session start'); + expect(context).toContain('[Project Environment] TypeScript | using pnpm | Build: pnpm build | Test: pnpm test'); + expect(context).toContain('[env] Requires LOCAL_API_BASE for smoke tests'); + }); });