mirror of
https://github.com/Yeachan-Heo/oh-my-claudecode.git
synced 2026-09-03 06:25:33 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user