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
This commit is contained in:
Yeachan-Heo
2026-03-20 03:49:58 +00:00
parent 48ffaac2ea
commit a6a0ff61eb
3 changed files with 200 additions and 1 deletions

View File

@@ -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

View File

@@ -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-context>
[PROJECT MEMORY]
${summary}
</project-memory-context>
---
`);
}
} 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)) {

View File

@@ -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');
});
});