Skip to content

Commit d9045ed

Browse files
author
StackMemory Bot (CLI)
committed
feat(hooks): auto-capture THEORY.MD into medium-term storage
- Add getTheoryContent() to claude-sm.ts for session-start injection - Create theory-capture.js PostToolUse hook for edit detection - Register hook in CANONICAL_HOOKS (optional, fires on Edit|Write|MultiEdit) - Add tests for hook behavior and session injection logic
1 parent 9a59c28 commit d9045ed

6 files changed

Lines changed: 380 additions & 14 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
import * as os from 'os';
5+
6+
/**
7+
* Tests for getTheoryContent() logic extracted from claude-sm.ts.
8+
* We test the pure logic without spawning the full ClaudeSM class.
9+
*/
10+
11+
function getTheoryContent(root: string): string | null {
12+
try {
13+
const theoryPath = path.join(root, 'THEORY.MD');
14+
if (fs.existsSync(theoryPath)) {
15+
const content = fs.readFileSync(theoryPath, 'utf8').trim();
16+
if (content.length > 0) {
17+
return content.length > 4000
18+
? content.substring(0, 4000) + '\n\n[...truncated]'
19+
: content;
20+
}
21+
}
22+
} catch {
23+
// Silent
24+
}
25+
return null;
26+
}
27+
28+
describe('getTheoryContent', () => {
29+
let tmpDir: string;
30+
31+
beforeEach(() => {
32+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-sm-theory-'));
33+
});
34+
35+
afterEach(() => {
36+
fs.rmSync(tmpDir, { recursive: true, force: true });
37+
});
38+
39+
it('returns content when THEORY.MD exists', () => {
40+
fs.writeFileSync(
41+
path.join(tmpDir, 'THEORY.MD'),
42+
'# Test Theory\n\nContent here.'
43+
);
44+
const result = getTheoryContent(tmpDir);
45+
expect(result).toBe('# Test Theory\n\nContent here.');
46+
});
47+
48+
it('returns null when THEORY.MD is missing', () => {
49+
const result = getTheoryContent(tmpDir);
50+
expect(result).toBeNull();
51+
});
52+
53+
it('returns null for empty THEORY.MD', () => {
54+
fs.writeFileSync(path.join(tmpDir, 'THEORY.MD'), ' \n \n');
55+
const result = getTheoryContent(tmpDir);
56+
expect(result).toBeNull();
57+
});
58+
59+
it('truncates content at 4000 chars', () => {
60+
const longContent = 'x'.repeat(5000);
61+
fs.writeFileSync(path.join(tmpDir, 'THEORY.MD'), longContent);
62+
const result = getTheoryContent(tmpDir);
63+
expect(result).not.toBeNull();
64+
expect(result!.length).toBeLessThan(5000);
65+
expect(result!.endsWith('[...truncated]')).toBe(true);
66+
// 4000 chars + '\n\n[...truncated]' = 4016
67+
expect(result!.length).toBe(4000 + '\n\n[...truncated]'.length);
68+
});
69+
70+
it('returns full content under 4000 chars without truncation', () => {
71+
const content = 'a'.repeat(3999);
72+
fs.writeFileSync(path.join(tmpDir, 'THEORY.MD'), content);
73+
const result = getTheoryContent(tmpDir);
74+
expect(result).toBe(content);
75+
expect(result!.includes('[...truncated]')).toBe(false);
76+
});
77+
78+
it('trims whitespace from content', () => {
79+
fs.writeFileSync(path.join(tmpDir, 'THEORY.MD'), '\n Hello world \n\n');
80+
const result = getTheoryContent(tmpDir);
81+
expect(result).toBe('Hello world');
82+
});
83+
});

src/cli/claude-sm.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,32 @@ class ClaudeSM {
430430
}
431431
}
432432

433+
private getTheoryContent(): string | null {
434+
try {
435+
let root: string;
436+
try {
437+
root = execSync('git rev-parse --show-toplevel', {
438+
encoding: 'utf-8',
439+
timeout: 5000,
440+
}).trim();
441+
} catch {
442+
root = process.cwd();
443+
}
444+
const theoryPath = path.join(root, 'THEORY.MD');
445+
if (fs.existsSync(theoryPath)) {
446+
const content = fs.readFileSync(theoryPath, 'utf8').trim();
447+
if (content.length > 0) {
448+
return content.length > 4000
449+
? content.substring(0, 4000) + '\n\n[...truncated]'
450+
: content;
451+
}
452+
}
453+
} catch {
454+
// Silent — theory loading is optional
455+
}
456+
return null;
457+
}
458+
433459
private getHandoffContent(): string | null {
434460
if (!this.config.contextEnabled) return null;
435461

@@ -881,17 +907,24 @@ class ClaudeSM {
881907

882908
// ── Session Injection ─────────────────────────────────────────
883909
let initialInput = '';
884-
const handoffContent = this.getHandoffContent();
885-
if (handoffContent) {
886-
// Only inject if not resuming an existing conversation
887-
const hasResume =
888-
claudeArgs.includes('--continue') ||
889-
claudeArgs.some((a) => a === '--resume');
890-
if (!hasResume) {
891-
// Load into input text area via PTY bracketed paste (not auto-sent)
910+
const hasResume =
911+
claudeArgs.includes('--continue') ||
912+
claudeArgs.some((a) => a === '--resume');
913+
914+
if (!hasResume) {
915+
const handoffContent = this.getHandoffContent();
916+
if (handoffContent) {
892917
initialInput = handoffContent;
893918
console.log(chalk.gray(' Handoff context ready'));
894919
}
920+
921+
const theoryContent = this.getTheoryContent();
922+
if (theoryContent) {
923+
initialInput +=
924+
(initialInput ? '\n\n---\n\n' : '') +
925+
`## Operating Theory (THEORY.MD)\n\n${theoryContent}`;
926+
console.log(chalk.gray(' Theory context loaded'));
927+
}
895928
}
896929

897930
console.log();

src/utils/__tests__/hook-installer.test.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,23 @@ const HOOKS_DIR = path.join(os.homedir(), '.claude', 'hooks');
1818

1919
describe('hook-installer', () => {
2020
describe('CANONICAL_HOOKS', () => {
21-
it('defines all 5 core hooks', () => {
22-
expect(CANONICAL_HOOKS).toHaveLength(5);
21+
it('defines all 6 hooks', () => {
22+
expect(CANONICAL_HOOKS).toHaveLength(6);
2323
const names = CANONICAL_HOOKS.map((h) => h.scriptName);
2424
expect(names).toContain('session-rescue.sh');
2525
expect(names).toContain('stop-checkpoint.js');
2626
expect(names).toContain('chime-on-stop.sh');
2727
expect(names).toContain('auto-checkpoint.js');
2828
expect(names).toContain('cord-trace.js');
29+
expect(names).toContain('theory-capture.js');
2930
});
3031

31-
it('all core hooks are required', () => {
32-
for (const hook of CANONICAL_HOOKS) {
33-
expect(hook.required).toBe(true);
34-
}
32+
it('core hooks are required, optional hooks are not', () => {
33+
const required = CANONICAL_HOOKS.filter((h) => h.required);
34+
const optional = CANONICAL_HOOKS.filter((h) => !h.required);
35+
expect(required).toHaveLength(5);
36+
expect(optional).toHaveLength(1);
37+
expect(optional[0].scriptName).toBe('theory-capture.js');
3538
});
3639

3740
it('js hooks have node commandPrefix', () => {
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
import * as os from 'os';
5+
import { execFileSync } from 'child_process';
6+
7+
describe('theory-capture hook', () => {
8+
let tmpDir: string;
9+
let theoryPath: string;
10+
let hookScript: string;
11+
12+
beforeEach(() => {
13+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'theory-capture-test-'));
14+
theoryPath = path.join(tmpDir, 'THEORY.MD');
15+
// Copy hook script to tmpDir so it runs outside ESM project scope
16+
// (project has "type": "module" which breaks require() in .js files)
17+
const srcScript = path.resolve(
18+
__dirname,
19+
'..',
20+
'..',
21+
'..',
22+
'templates',
23+
'claude-hooks',
24+
'theory-capture.js'
25+
);
26+
hookScript = path.join(tmpDir, 'theory-capture.js');
27+
fs.copyFileSync(srcScript, hookScript);
28+
});
29+
30+
afterEach(() => {
31+
fs.rmSync(tmpDir, { recursive: true, force: true });
32+
});
33+
34+
function runHook(input: Record<string, unknown>): string {
35+
try {
36+
return execFileSync('node', [hookScript], {
37+
input: JSON.stringify(input),
38+
cwd: tmpDir,
39+
encoding: 'utf-8',
40+
timeout: 5000,
41+
env: { ...process.env, PATH: process.env['PATH'] },
42+
});
43+
} catch (e: unknown) {
44+
// Hook should never throw, but capture output if it does
45+
return (e as { stdout?: string }).stdout || '';
46+
}
47+
}
48+
49+
it('writes theory-cache.json on Edit to THEORY.MD', () => {
50+
fs.writeFileSync(theoryPath, '# My Theory\n\nSome content\n');
51+
52+
runHook({
53+
tool_name: 'Edit',
54+
tool_input: { file_path: theoryPath },
55+
});
56+
57+
const cachePath = path.join(tmpDir, '.stackmemory', 'theory-cache.json');
58+
expect(fs.existsSync(cachePath)).toBe(true);
59+
60+
const cache = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
61+
expect(cache.path).toBe(theoryPath);
62+
expect(cache.lineCount).toBe(4);
63+
expect(cache.hash).toBeTruthy();
64+
expect(cache.timestamp).toBeTruthy();
65+
});
66+
67+
it('writes theory-cache.json on Write to THEORY.MD', () => {
68+
fs.writeFileSync(theoryPath, 'line1\nline2\n');
69+
70+
runHook({
71+
tool_name: 'Write',
72+
tool_input: { file_path: theoryPath },
73+
});
74+
75+
const cachePath = path.join(tmpDir, '.stackmemory', 'theory-cache.json');
76+
expect(fs.existsSync(cachePath)).toBe(true);
77+
});
78+
79+
it('ignores Edit to non-THEORY.MD files', () => {
80+
const otherFile = path.join(tmpDir, 'README.md');
81+
fs.writeFileSync(otherFile, 'hello');
82+
83+
runHook({
84+
tool_name: 'Edit',
85+
tool_input: { file_path: otherFile },
86+
});
87+
88+
const cachePath = path.join(tmpDir, '.stackmemory', 'theory-cache.json');
89+
expect(fs.existsSync(cachePath)).toBe(false);
90+
});
91+
92+
it('ignores Read tool even for THEORY.MD', () => {
93+
fs.writeFileSync(theoryPath, 'content');
94+
95+
runHook({
96+
tool_name: 'Read',
97+
tool_input: { file_path: theoryPath },
98+
});
99+
100+
const cachePath = path.join(tmpDir, '.stackmemory', 'theory-cache.json');
101+
expect(fs.existsSync(cachePath)).toBe(false);
102+
});
103+
104+
it('handles case-insensitive THEORY.MD path', () => {
105+
// Create lowercase variant
106+
const lowerPath = path.join(tmpDir, 'theory.md');
107+
fs.writeFileSync(lowerPath, 'content\n');
108+
109+
runHook({
110+
tool_name: 'Edit',
111+
tool_input: { file_path: lowerPath },
112+
});
113+
114+
const cachePath = path.join(tmpDir, '.stackmemory', 'theory-cache.json');
115+
// isTheoryPath checks lowercase so this should match
116+
expect(fs.existsSync(cachePath)).toBe(true);
117+
});
118+
119+
it('handles missing file gracefully', () => {
120+
// Don't create the file — hook should not crash
121+
const missingPath = path.join(tmpDir, 'THEORY.MD');
122+
123+
runHook({
124+
tool_name: 'Edit',
125+
tool_input: { file_path: missingPath },
126+
});
127+
128+
const cachePath = path.join(tmpDir, '.stackmemory', 'theory-cache.json');
129+
expect(fs.existsSync(cachePath)).toBe(false);
130+
});
131+
132+
it('handles missing tool_input gracefully', () => {
133+
// Should not crash
134+
runHook({ tool_name: 'Edit' });
135+
136+
const cachePath = path.join(tmpDir, '.stackmemory', 'theory-cache.json');
137+
expect(fs.existsSync(cachePath)).toBe(false);
138+
});
139+
});

src/utils/hook-installer.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ export const CANONICAL_HOOKS: HookEntry[] = [
6262
commandPrefix: 'node',
6363
required: true,
6464
},
65+
{
66+
scriptName: 'theory-capture.js',
67+
eventType: 'PostToolUse',
68+
matcher: 'Edit|Write|MultiEdit',
69+
timeout: 2,
70+
commandPrefix: 'node',
71+
required: false,
72+
},
6573
];
6674

6775
/** Script names that should be removed from settings (dead/deprecated hooks) */

0 commit comments

Comments
 (0)