Skip to content

Commit f3b1301

Browse files
author
StackMemory Bot (CLI)
committed
feat(team): add agent team hooks and CLI commands (STA-474 Phase 2)
Auto-capture shared context via Claude Code lifecycle hooks: - team share/list CLI commands for creating and viewing shared anchors - SubagentStop hook captures last assistant message as shared FACT - TaskCompleted hook shares completion summary as DECISION anchor - TeammateIdle hook logs idle events and shares low-priority FACT - Register 3 new hooks in CANONICAL_HOOKS and installer fallback - 11 tests covering CLI commands and hook script execution
1 parent d459957 commit f3b1301

9 files changed

Lines changed: 917 additions & 4 deletions

File tree

scripts/install-claude-hooks-auto.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,27 @@ try {
8989
commandPrefix: 'node',
9090
required: true,
9191
},
92+
{
93+
scriptName: 'team-subagent-stop.js',
94+
eventType: 'SubagentStop',
95+
timeout: 5,
96+
commandPrefix: 'node',
97+
required: false,
98+
},
99+
{
100+
scriptName: 'team-task-complete.js',
101+
eventType: 'TaskCompleted',
102+
timeout: 5,
103+
commandPrefix: 'node',
104+
required: false,
105+
},
106+
{
107+
scriptName: 'team-teammate-idle.js',
108+
eventType: 'TeammateIdle',
109+
timeout: 3,
110+
commandPrefix: 'node',
111+
required: false,
112+
},
92113
];
93114

94115
const DEAD_HOOKS = ['sms-response-handler.js'];
Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import Database from 'better-sqlite3';
3+
import { mkdtempSync, mkdirSync, copyFileSync } from 'fs';
4+
import { join } from 'path';
5+
import { tmpdir } from 'os';
6+
import { execSync } from 'child_process';
7+
8+
/**
9+
* Create a minimal .stackmemory/context.db that FrameManager can open.
10+
* Does NOT pre-create tables — lets FrameManager.initSchema() do that.
11+
*/
12+
function setupEmptyProject(dir: string): void {
13+
mkdirSync(join(dir, '.git'));
14+
mkdirSync(join(dir, '.stackmemory'));
15+
// Touch an empty database file
16+
const db = new Database(join(dir, '.stackmemory', 'context.db'));
17+
db.close();
18+
}
19+
20+
/**
21+
* Create a project with pre-seeded schema and data for read-only tests
22+
* (team list). Matches the schema FrameManager produces.
23+
*/
24+
function setupProjectWithData(dir: string): Database.Database {
25+
mkdirSync(join(dir, '.git'));
26+
mkdirSync(join(dir, '.stackmemory'));
27+
28+
const db = new Database(join(dir, '.stackmemory', 'context.db'));
29+
db.exec(`
30+
CREATE TABLE IF NOT EXISTS frames (
31+
frame_id TEXT PRIMARY KEY,
32+
run_id TEXT NOT NULL,
33+
project_id TEXT NOT NULL,
34+
parent_frame_id TEXT,
35+
depth INTEGER NOT NULL DEFAULT 0,
36+
type TEXT NOT NULL,
37+
name TEXT NOT NULL,
38+
state TEXT DEFAULT 'active',
39+
inputs TEXT DEFAULT '{}',
40+
outputs TEXT DEFAULT '{}',
41+
digest_text TEXT,
42+
digest_json TEXT DEFAULT '{}',
43+
created_at INTEGER DEFAULT (unixepoch()),
44+
closed_at INTEGER,
45+
retention_policy TEXT DEFAULT 'default',
46+
importance_score REAL DEFAULT 0.5
47+
);
48+
CREATE TABLE IF NOT EXISTS anchors (
49+
anchor_id TEXT PRIMARY KEY,
50+
frame_id TEXT NOT NULL,
51+
project_id TEXT NOT NULL DEFAULT '',
52+
type TEXT NOT NULL,
53+
text TEXT NOT NULL,
54+
priority INTEGER DEFAULT 0,
55+
created_at INTEGER DEFAULT (unixepoch()),
56+
metadata TEXT DEFAULT '{}'
57+
);
58+
CREATE TABLE IF NOT EXISTS events (
59+
event_id TEXT PRIMARY KEY,
60+
frame_id TEXT NOT NULL,
61+
event_type TEXT NOT NULL,
62+
payload TEXT DEFAULT '{}',
63+
ts INTEGER DEFAULT (unixepoch())
64+
);
65+
`);
66+
return db;
67+
}
68+
69+
const cliPath = join(__dirname, '..', '..', 'index.ts');
70+
71+
describe('team CLI commands', () => {
72+
let tmpDir: string;
73+
let originalCwd: string;
74+
75+
beforeEach(() => {
76+
tmpDir = mkdtempSync(join(tmpdir(), 'sm-team-test-'));
77+
originalCwd = process.cwd();
78+
});
79+
80+
afterEach(() => {
81+
process.chdir(originalCwd);
82+
});
83+
84+
describe('team share', () => {
85+
it('should create shared anchor with correct metadata', () => {
86+
setupEmptyProject(tmpDir);
87+
process.chdir(tmpDir);
88+
89+
const result = execSync(
90+
`npx tsx ${cliPath} team share -c "API endpoint is /v2/users" -t DECISION -p 9 --source manual`,
91+
{ cwd: tmpDir, encoding: 'utf-8', timeout: 15000 }
92+
);
93+
94+
expect(result).toContain('[DECISION]');
95+
expect(result).toContain('priority 9');
96+
97+
const checkDb = new Database(join(tmpDir, '.stackmemory', 'context.db'));
98+
const anchors = checkDb
99+
.prepare(`SELECT * FROM anchors WHERE metadata LIKE '%"shared":true%'`)
100+
.all() as Array<{
101+
type: string;
102+
text: string;
103+
priority: number;
104+
metadata: string;
105+
}>;
106+
107+
expect(anchors).toHaveLength(1);
108+
expect(anchors[0].type).toBe('DECISION');
109+
expect(anchors[0].text).toBe('API endpoint is /v2/users');
110+
expect(anchors[0].priority).toBe(9);
111+
112+
const meta = JSON.parse(anchors[0].metadata);
113+
expect(meta.shared).toBe(true);
114+
expect(meta.source).toBe('manual');
115+
expect(meta.sharedBy).toBeDefined();
116+
checkDb.close();
117+
});
118+
119+
it('should default to type=FACT priority=7', () => {
120+
setupEmptyProject(tmpDir);
121+
process.chdir(tmpDir);
122+
123+
execSync(`npx tsx ${cliPath} team share -c "some fact"`, {
124+
cwd: tmpDir,
125+
encoding: 'utf-8',
126+
timeout: 15000,
127+
});
128+
129+
const checkDb = new Database(join(tmpDir, '.stackmemory', 'context.db'));
130+
const anchors = checkDb
131+
.prepare(`SELECT * FROM anchors WHERE metadata LIKE '%"shared":true%'`)
132+
.all() as Array<{ type: string; priority: number }>;
133+
134+
expect(anchors).toHaveLength(1);
135+
expect(anchors[0].type).toBe('FACT');
136+
expect(anchors[0].priority).toBe(7);
137+
checkDb.close();
138+
});
139+
140+
it('should auto-create frame if none active', () => {
141+
setupEmptyProject(tmpDir);
142+
process.chdir(tmpDir);
143+
144+
execSync(`npx tsx ${cliPath} team share -c "auto-frame test"`, {
145+
cwd: tmpDir,
146+
encoding: 'utf-8',
147+
timeout: 15000,
148+
});
149+
150+
const checkDb = new Database(join(tmpDir, '.stackmemory', 'context.db'));
151+
const frames = checkDb.prepare(`SELECT * FROM frames`).all() as Array<{
152+
name: string;
153+
type: string;
154+
}>;
155+
expect(frames.length).toBeGreaterThanOrEqual(1);
156+
expect(frames.some((f) => f.name === 'team_share')).toBe(true);
157+
checkDb.close();
158+
});
159+
160+
it('should store source, agentId, taskId in metadata', () => {
161+
setupEmptyProject(tmpDir);
162+
process.chdir(tmpDir);
163+
164+
execSync(
165+
`npx tsx ${cliPath} team share -c "context with ids" --source subagent --agent-id agent-1 --task-id task-42`,
166+
{ cwd: tmpDir, encoding: 'utf-8', timeout: 15000 }
167+
);
168+
169+
const checkDb = new Database(join(tmpDir, '.stackmemory', 'context.db'));
170+
const anchors = checkDb
171+
.prepare(
172+
`SELECT metadata FROM anchors WHERE metadata LIKE '%"shared":true%'`
173+
)
174+
.all() as Array<{ metadata: string }>;
175+
176+
const meta = JSON.parse(anchors[0].metadata);
177+
expect(meta.source).toBe('subagent');
178+
expect(meta.agentId).toBe('agent-1');
179+
expect(meta.taskId).toBe('task-42');
180+
checkDb.close();
181+
});
182+
183+
it('should truncate content > 2000 chars', () => {
184+
setupEmptyProject(tmpDir);
185+
process.chdir(tmpDir);
186+
187+
const longContent = 'x'.repeat(3000);
188+
execSync(`npx tsx ${cliPath} team share -c "${longContent}"`, {
189+
cwd: tmpDir,
190+
encoding: 'utf-8',
191+
timeout: 15000,
192+
});
193+
194+
const checkDb = new Database(join(tmpDir, '.stackmemory', 'context.db'));
195+
const anchors = checkDb
196+
.prepare(
197+
`SELECT text FROM anchors WHERE metadata LIKE '%"shared":true%'`
198+
)
199+
.all() as Array<{ text: string }>;
200+
201+
expect(anchors[0].text.length).toBe(2000);
202+
checkDb.close();
203+
});
204+
});
205+
206+
describe('team list', () => {
207+
it('should list shared anchors', () => {
208+
const db = setupProjectWithData(tmpDir);
209+
const now = Math.floor(Date.now() / 1000);
210+
db.prepare(
211+
`INSERT INTO frames (frame_id, run_id, project_id, type, name, state, created_at)
212+
VALUES ('f1', 'r1', 'default', 'task', 'test-frame', 'active', ?)`
213+
).run(now - 60);
214+
db.prepare(
215+
`INSERT INTO anchors (anchor_id, frame_id, type, text, priority, created_at, metadata)
216+
VALUES ('a1', 'f1', 'FACT', 'shared finding', 8, ?, '{"shared":true,"source":"manual"}')`
217+
).run(now - 30);
218+
db.close();
219+
220+
process.chdir(tmpDir);
221+
222+
const result = execSync(`npx tsx ${cliPath} team list`, {
223+
cwd: tmpDir,
224+
encoding: 'utf-8',
225+
timeout: 15000,
226+
});
227+
228+
expect(result).toContain('shared finding');
229+
expect(result).toContain('[FACT]');
230+
expect(result).toContain('p8');
231+
});
232+
233+
it('should respect --limit', () => {
234+
const db = setupProjectWithData(tmpDir);
235+
const now = Math.floor(Date.now() / 1000);
236+
237+
db.prepare(
238+
`INSERT INTO frames (frame_id, run_id, project_id, type, name, state, created_at)
239+
VALUES ('f1', 'r1', 'default', 'task', 'test-frame', 'active', ?)`
240+
).run(now);
241+
242+
for (let i = 0; i < 5; i++) {
243+
db.prepare(
244+
`INSERT INTO anchors (anchor_id, frame_id, type, text, priority, created_at, metadata)
245+
VALUES (?, 'f1', 'FACT', ?, 5, ?, '{"shared":true}')`
246+
).run(`a${i}`, `anchor ${i}`, now - i);
247+
}
248+
db.close();
249+
250+
process.chdir(tmpDir);
251+
252+
const result = execSync(`npx tsx ${cliPath} team list --limit 2`, {
253+
cwd: tmpDir,
254+
encoding: 'utf-8',
255+
timeout: 15000,
256+
});
257+
258+
expect(result).toContain('2 anchors');
259+
});
260+
261+
it('should show no results when no shared anchors exist', () => {
262+
const db = setupProjectWithData(tmpDir);
263+
db.close();
264+
process.chdir(tmpDir);
265+
266+
const result = execSync(`npx tsx ${cliPath} team list`, {
267+
cwd: tmpDir,
268+
encoding: 'utf-8',
269+
timeout: 15000,
270+
});
271+
272+
expect(result).toContain('No shared context found');
273+
});
274+
});
275+
276+
describe('hook scripts', () => {
277+
// Hook scripts use require() (CJS). Node resolves package.json from the
278+
// script's directory, so we copy hooks to /tmp to avoid "type":"module".
279+
280+
it('team-subagent-stop.js should exit 0 with valid input', () => {
281+
setupEmptyProject(tmpDir);
282+
const srcHook = join(
283+
__dirname,
284+
'..',
285+
'..',
286+
'..',
287+
'..',
288+
'templates',
289+
'claude-hooks',
290+
'team-subagent-stop.js'
291+
);
292+
const hookCopy = join(tmpDir, 'team-subagent-stop.js');
293+
copyFileSync(srcHook, hookCopy);
294+
295+
const input = JSON.stringify({
296+
agent_id: 'test-agent',
297+
last_assistant_message: 'Found a bug in auth module',
298+
cwd: tmpDir,
299+
});
300+
301+
// Run copied hook (outside project tree, no ESM conflict)
302+
execSync(`echo '${input}' | node ${hookCopy}`, {
303+
cwd: tmpDir,
304+
encoding: 'utf-8',
305+
timeout: 10000,
306+
});
307+
expect(true).toBe(true);
308+
});
309+
310+
it('team-task-complete.js should exit 0 with valid input', () => {
311+
setupEmptyProject(tmpDir);
312+
const srcHook = join(
313+
__dirname,
314+
'..',
315+
'..',
316+
'..',
317+
'..',
318+
'templates',
319+
'claude-hooks',
320+
'team-task-complete.js'
321+
);
322+
const hookCopy = join(tmpDir, 'team-task-complete.js');
323+
copyFileSync(srcHook, hookCopy);
324+
325+
const input = JSON.stringify({
326+
task_id: 't1',
327+
task_subject: 'Fix login bug',
328+
task_description: 'Fixed auth token refresh',
329+
teammate_name: 'worker-1',
330+
cwd: tmpDir,
331+
});
332+
333+
execSync(`echo '${input}' | node ${hookCopy}`, {
334+
cwd: tmpDir,
335+
encoding: 'utf-8',
336+
timeout: 10000,
337+
});
338+
expect(true).toBe(true);
339+
});
340+
341+
it('team-subagent-stop.js should skip gracefully without .stackmemory/', () => {
342+
// tmpDir has .stackmemory but use a separate empty dir
343+
const emptyDir = mkdtempSync(join(tmpdir(), 'sm-no-sm-'));
344+
const srcHook = join(
345+
__dirname,
346+
'..',
347+
'..',
348+
'..',
349+
'..',
350+
'templates',
351+
'claude-hooks',
352+
'team-subagent-stop.js'
353+
);
354+
const hookCopy = join(emptyDir, 'team-subagent-stop.js');
355+
copyFileSync(srcHook, hookCopy);
356+
357+
const input = JSON.stringify({
358+
agent_id: 'test',
359+
last_assistant_message: 'hello',
360+
cwd: emptyDir,
361+
});
362+
363+
execSync(`echo '${input}' | node ${hookCopy}`, {
364+
cwd: emptyDir,
365+
encoding: 'utf-8',
366+
timeout: 10000,
367+
});
368+
expect(true).toBe(true);
369+
});
370+
});
371+
});

0 commit comments

Comments
 (0)