Skip to content

Commit 9a59c28

Browse files
author
StackMemory Bot (CLI)
committed
feat(skills): add theory skill for living THEORY.MD
Integrates Theorist pattern (@blader, MIT) as native StackMemory skill. Maintains a narrative operating theory at repo root with show/init/update/status commands, content validation, anti-pattern warnings, and frame event recording.
1 parent a7eba4e commit 9a59c28

5 files changed

Lines changed: 631 additions & 1 deletion

File tree

src/cli/commands/skills.ts

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,109 @@ export function createSkillsCommand(): Command {
10311031
}
10321032
});
10331033

1034+
// Theory skill commands
1035+
const theoryCmd = skillsCmd
1036+
.command('theory')
1037+
.description('Maintain a living THEORY.MD at repo root');
1038+
1039+
theoryCmd
1040+
.command('show')
1041+
.description('Display current THEORY.MD content')
1042+
.action(async () => {
1043+
try {
1044+
const { context } = await initializeSkillContext();
1045+
const { TheorySkill } = await import('../../skills/theory-skill.js');
1046+
const skill = new TheorySkill(context);
1047+
const result = skill.show();
1048+
if (result.success) {
1049+
console.log(result.message);
1050+
} else {
1051+
console.log(chalk.red('✗'), result.message);
1052+
}
1053+
await context.database.disconnect();
1054+
} catch (error: unknown) {
1055+
console.error(chalk.red('Error:'), (error as Error).message);
1056+
process.exit(1);
1057+
}
1058+
});
1059+
1060+
theoryCmd
1061+
.command('init <problem>')
1062+
.description('Create THEORY.MD with scaffold sections')
1063+
.action(async (problem) => {
1064+
try {
1065+
const { context } = await initializeSkillContext();
1066+
const { TheorySkill } = await import('../../skills/theory-skill.js');
1067+
const skill = new TheorySkill(context);
1068+
const result = skill.init(problem);
1069+
if (result.success) {
1070+
console.log(chalk.green('✓'), result.message);
1071+
} else {
1072+
console.log(chalk.red('✗'), result.message);
1073+
}
1074+
await context.database.disconnect();
1075+
} catch (error: unknown) {
1076+
console.error(chalk.red('Error:'), (error as Error).message);
1077+
process.exit(1);
1078+
}
1079+
});
1080+
1081+
theoryCmd
1082+
.command('update <content>')
1083+
.description('Overwrite THEORY.MD with new content')
1084+
.action(async (content) => {
1085+
try {
1086+
const { context } = await initializeSkillContext();
1087+
const { TheorySkill } = await import('../../skills/theory-skill.js');
1088+
const skill = new TheorySkill(context);
1089+
const result = skill.update(content);
1090+
if (result.success) {
1091+
console.log(chalk.green('✓'), result.message);
1092+
if (result.data?.warnings?.length > 0) {
1093+
result.data.warnings.forEach((w: string) => {
1094+
console.log(chalk.yellow(` ⚠ ${w}`));
1095+
});
1096+
}
1097+
} else {
1098+
console.log(chalk.red('✗'), result.message);
1099+
}
1100+
await context.database.disconnect();
1101+
} catch (error: unknown) {
1102+
console.error(chalk.red('Error:'), (error as Error).message);
1103+
process.exit(1);
1104+
}
1105+
});
1106+
1107+
theoryCmd
1108+
.command('status')
1109+
.description('Show THEORY.MD metadata')
1110+
.action(async () => {
1111+
try {
1112+
const { context } = await initializeSkillContext();
1113+
const { TheorySkill } = await import('../../skills/theory-skill.js');
1114+
const skill = new TheorySkill(context);
1115+
const result = skill.status();
1116+
if (result.success) {
1117+
console.log(chalk.cyan('THEORY.MD Status:'));
1118+
if (result.data?.exists) {
1119+
console.log(` Lines: ${result.data.lineCount}`);
1120+
console.log(
1121+
` Sections: ${result.data.sections?.length}/${result.data.totalSections}`
1122+
);
1123+
console.log(` Last modified: ${result.data.lastModified}`);
1124+
} else {
1125+
console.log(chalk.gray(' Not found'));
1126+
}
1127+
} else {
1128+
console.log(chalk.red('✗'), result.message);
1129+
}
1130+
await context.database.disconnect();
1131+
} catch (error: unknown) {
1132+
console.error(chalk.red('Error:'), (error as Error).message);
1133+
process.exit(1);
1134+
}
1135+
});
1136+
10341137
// Help command for skills
10351138
skillsCmd
10361139
.command('help [skill]')
@@ -1087,6 +1190,27 @@ Examples:
10871190
10881191
Apply patches: git apply .stackmemory/patches/<file>.patch
10891192
Review specs: cd /tmp/sm-spec-* && git log --oneline
1193+
`);
1194+
break;
1195+
case 'theory':
1196+
console.log(`
1197+
theory — Living Operating Theory Document
1198+
1199+
Maintains a THEORY.MD at repo root: a narrative capturing your problem
1200+
thesis, mental model, strategy, discoveries, and open questions.
1201+
1202+
Subcommands:
1203+
show Display current THEORY.MD
1204+
init <problem> Create THEORY.MD with scaffold sections
1205+
update <content> Overwrite THEORY.MD (validates, warns on anti-patterns)
1206+
status Show metadata (lines, sections, last modified)
1207+
1208+
Examples:
1209+
stackmemory skills theory init "Build context-aware memory for AI agents"
1210+
stackmemory skills theory show
1211+
stackmemory skills theory status
1212+
1213+
Based on Theorist by @blader (MIT).
10901214
`);
10911215
break;
10921216
default:
@@ -1116,7 +1240,10 @@ Review specs: cd /tmp/sm-spec-* && git log --oneline
11161240
);
11171241
console.log(' linear-run - Execute Linear tasks via RLM orchestrator');
11181242
console.log(
1119-
' agent - Spawn parallel agents (research, maintain, spec-run)\n'
1243+
' agent - Spawn parallel agents (research, maintain, spec-run)'
1244+
);
1245+
console.log(
1246+
' theory - Maintain a living THEORY.MD (show, init, update, status)\n'
11201247
);
11211248
console.log(
11221249
chalk.yellow(

src/skills/__tests__/claude-skills.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,7 @@ describe('Claude Skills', () => {
512512
'api',
513513
'spec',
514514
'agent',
515+
'theory',
515516
]);
516517
});
517518

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
/**
2+
* Tests for TheorySkill
3+
*/
4+
5+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
6+
import * as fs from 'fs';
7+
import * as path from 'path';
8+
import { execSync } from 'child_process';
9+
import type { SkillContext } from '../claude-skills.js';
10+
11+
// We test against a real temp directory with git init
12+
let tmpDir: string;
13+
let originalCwd: string;
14+
15+
// Mock logger
16+
vi.mock('../../core/monitoring/logger.js', () => ({
17+
logger: {
18+
info: vi.fn(),
19+
debug: vi.fn(),
20+
warn: vi.fn(),
21+
error: vi.fn(),
22+
},
23+
}));
24+
25+
function makeContext(overrides?: Partial<SkillContext>): SkillContext {
26+
return {
27+
projectId: 'test-project',
28+
userId: 'test-user',
29+
dualStackManager: {} as any,
30+
handoffManager: {} as any,
31+
contextRetriever: {} as any,
32+
database: {} as any,
33+
...overrides,
34+
};
35+
}
36+
37+
describe('TheorySkill', () => {
38+
beforeEach(() => {
39+
tmpDir = fs.mkdtempSync(path.join('/tmp', 'theory-test-'));
40+
originalCwd = process.cwd();
41+
// Initialize a git repo so getGitRoot() works
42+
execSync('git init', { cwd: tmpDir, timeout: 5000 });
43+
process.chdir(tmpDir);
44+
});
45+
46+
afterEach(() => {
47+
process.chdir(originalCwd);
48+
fs.rmSync(tmpDir, { recursive: true, force: true });
49+
});
50+
51+
// Dynamic import to pick up mocks correctly
52+
async function getTheorySkill() {
53+
// Clear module cache to pick up fresh cwd
54+
const mod = await import('../theory-skill.js');
55+
return mod.TheorySkill;
56+
}
57+
58+
it('init creates THEORY.MD with sections', async () => {
59+
const TheorySkill = await getTheorySkill();
60+
const skill = new TheorySkill(makeContext());
61+
62+
const result = skill.init('Build context-aware memory for AI agents');
63+
64+
expect(result.success).toBe(true);
65+
expect(result.message).toContain('Created THEORY.MD');
66+
expect(result.data?.sections).toHaveLength(5);
67+
68+
// File should exist
69+
const content = fs.readFileSync(path.join(tmpDir, 'THEORY.MD'), 'utf-8');
70+
expect(content).toContain('## Problem');
71+
expect(content).toContain('Build context-aware memory for AI agents');
72+
expect(content).toContain('## Operating Theory');
73+
expect(content).toContain('## Strategy');
74+
expect(content).toContain('## Key Discoveries');
75+
expect(content).toContain('## Open Questions');
76+
});
77+
78+
it('init rejects empty problem statement', async () => {
79+
const TheorySkill = await getTheorySkill();
80+
const skill = new TheorySkill(makeContext());
81+
82+
const result = skill.init('');
83+
expect(result.success).toBe(false);
84+
expect(result.message).toContain('problem statement is required');
85+
});
86+
87+
it('init refuses if THEORY.MD already exists', async () => {
88+
const TheorySkill = await getTheorySkill();
89+
const skill = new TheorySkill(makeContext());
90+
91+
fs.writeFileSync(path.join(tmpDir, 'THEORY.MD'), '# existing');
92+
93+
const result = skill.init('New problem');
94+
expect(result.success).toBe(false);
95+
expect(result.message).toContain('already exists');
96+
});
97+
98+
it('show returns content when THEORY.MD exists', async () => {
99+
const TheorySkill = await getTheorySkill();
100+
const skill = new TheorySkill(makeContext());
101+
102+
const testContent = '# THEORY.MD\n\nSome theory content here.';
103+
fs.writeFileSync(path.join(tmpDir, 'THEORY.MD'), testContent);
104+
105+
const result = skill.show();
106+
expect(result.success).toBe(true);
107+
expect(result.message).toBe(testContent);
108+
expect(result.data?.length).toBe(testContent.length);
109+
});
110+
111+
it('show returns error when no THEORY.MD', async () => {
112+
const TheorySkill = await getTheorySkill();
113+
const skill = new TheorySkill(makeContext());
114+
115+
const result = skill.show();
116+
expect(result.success).toBe(false);
117+
expect(result.message).toContain('No THEORY.MD found');
118+
});
119+
120+
it('update validates and writes content', async () => {
121+
const TheorySkill = await getTheorySkill();
122+
const skill = new TheorySkill(makeContext());
123+
124+
// Content must be > 100 chars
125+
const content =
126+
'# THEORY.MD\n\n## Problem\n\nWe need to build a context-aware memory system that preserves agent state across sessions and enables efficient retrieval.';
127+
128+
const result = skill.update(content);
129+
expect(result.success).toBe(true);
130+
expect(result.message).toContain('Updated THEORY.MD');
131+
132+
const written = fs.readFileSync(path.join(tmpDir, 'THEORY.MD'), 'utf-8');
133+
expect(written).toBe(content);
134+
});
135+
136+
it('update rejects too-short content', async () => {
137+
const TheorySkill = await getTheorySkill();
138+
const skill = new TheorySkill(makeContext());
139+
140+
const result = skill.update('too short');
141+
expect(result.success).toBe(false);
142+
expect(result.message).toContain('too short');
143+
});
144+
145+
it('update warns on checkboxes', async () => {
146+
const TheorySkill = await getTheorySkill();
147+
const skill = new TheorySkill(makeContext());
148+
149+
const content =
150+
'# THEORY.MD\n\n## Problem\n\nBuild a thing.\n\n- [x] Done\n- [ ] Not done\n\nLots more text here to get past the minimum length requirement which is one hundred characters.';
151+
152+
const result = skill.update(content);
153+
expect(result.success).toBe(true);
154+
expect(result.data?.warnings).toContainEqual(
155+
expect.stringContaining('checkboxes')
156+
);
157+
});
158+
159+
it('update warns on dates', async () => {
160+
const TheorySkill = await getTheorySkill();
161+
const skill = new TheorySkill(makeContext());
162+
163+
const content =
164+
'# THEORY.MD\n\n## Problem\n\nBuild a thing.\n\n2024-01-15: Added feature X\n\nLots more text here to get past the minimum length requirement which is one hundred characters exactly.';
165+
166+
const result = skill.update(content);
167+
expect(result.success).toBe(true);
168+
expect(result.data?.warnings).toContainEqual(
169+
expect.stringContaining('dates')
170+
);
171+
});
172+
173+
it('update records frame event when frameManager available', async () => {
174+
const TheorySkill = await getTheorySkill();
175+
const mockFrameManager = {
176+
createFrame: vi.fn().mockReturnValue('frame-123'),
177+
addEvent: vi.fn().mockReturnValue('event-123'),
178+
closeFrame: vi.fn(),
179+
};
180+
181+
const skill = new TheorySkill(
182+
makeContext({ frameManager: mockFrameManager as any })
183+
);
184+
185+
const content =
186+
'# THEORY.MD\n\n## Problem\n\nWe need context-aware memory for AI agents that persists across sessions and supports efficient semantic retrieval.';
187+
188+
skill.update(content);
189+
190+
expect(mockFrameManager.createFrame).toHaveBeenCalledWith(
191+
'write',
192+
'theory-update',
193+
expect.objectContaining({ source: 'THEORY.MD' })
194+
);
195+
expect(mockFrameManager.addEvent).toHaveBeenCalledWith(
196+
'artifact',
197+
expect.objectContaining({ type: 'theory-update' }),
198+
'frame-123'
199+
);
200+
expect(mockFrameManager.closeFrame).toHaveBeenCalledWith(
201+
'frame-123',
202+
expect.objectContaining({ theory_updated: true })
203+
);
204+
});
205+
206+
it('status reports metadata when THEORY.MD exists', async () => {
207+
const TheorySkill = await getTheorySkill();
208+
const skill = new TheorySkill(makeContext());
209+
210+
// Create via init
211+
skill.init('Test problem for status check');
212+
213+
const result = skill.status();
214+
expect(result.success).toBe(true);
215+
expect(result.data?.exists).toBe(true);
216+
expect(result.data?.lineCount).toBeGreaterThan(0);
217+
expect(result.data?.sections).toContain('## Problem');
218+
expect(result.data?.totalSections).toBe(5);
219+
expect(result.data?.lastModified).toBeDefined();
220+
});
221+
222+
it('status reports not found when no THEORY.MD', async () => {
223+
const TheorySkill = await getTheorySkill();
224+
const skill = new TheorySkill(makeContext());
225+
226+
const result = skill.status();
227+
expect(result.success).toBe(true);
228+
expect(result.data?.exists).toBe(false);
229+
});
230+
231+
it('git root detection works', async () => {
232+
const TheorySkill = await getTheorySkill();
233+
const skill = new TheorySkill(makeContext());
234+
235+
// The skill should find the git root (our tmpDir)
236+
skill.init('Git root test');
237+
238+
expect(fs.existsSync(path.join(tmpDir, 'THEORY.MD'))).toBe(true);
239+
});
240+
});

0 commit comments

Comments
 (0)