Skip to content

Commit 3d08bd9

Browse files
oratisclaude
andauthored
feat(core): M3a — modes, hooks framework, memory dual system (#3)
Partial M3 (first of two PRs). Ships the policy/safety/memory primitives; M3b (next PR) wires them into the agent loop and adds MCP + compaction + statusLine + /init multi-phase + auto classifier mode. What ships ---------- Core (packages/core/src/): - modes/index.ts — 5 mode policies (default / acceptEdits / plan / auto / dontAsk / bypassPermissions) → ModeVerdict with the invariants from docs/design/sandbox-plan- worktree.md §3.3 hardcoded: · plan blocks all write tools, regardless of permission · acceptEdits permission-deny still wins · dontAsk upgrades ask → deny (strict allowlist) · bypass skips permissions (sandbox-only safety) - hooks/types.ts — HookContext / HookHandlerOutput / HookResult - hooks/dispatcher.ts — 9 events × command handler, sequential dispatch, matcher tool-name with `|` OR separator, JSON output parsing (last {...} in stdout), per-handler timeout (default 60s), disableAllHooks override, payload streamed to handler stdin as JSON - memory/loader.ts — hierarchical DEEPCODE.md walk (cwd → parents), user-level ~/.deepcode/DEEPCODE.md, AGENTS.md auto-import at project root, .deepcode/rules/*.md sorted load, @-import with 4-hop max + cycle detection + maxBytes budget Tests ----- - modes/index.test.ts (14 tests, all 6 modes × 4 permission verdicts) - hooks/dispatcher.test.ts (14 tests: exec, JSON parse, matcher, timeout, stdin, disableAllHooks, unimplemented-type graceful) - memory/loader.test.ts (14 tests: empty / user / project / parents walk / AGENTS.md / rules dir / @-import / unresolved / cycle / maxBytes / maxDepth + walkUpwards unit) Total: 197 passed / 4 skipped / 0 failed (was 151). Deferred to M3b --------------- - MCP client (stdio transport at minimum) - Compaction (LLM summarizer when context > threshold) - statusLine runner (JSON-on-stdin contract) - /init multi-phase interactive flow - auto classifier mode (LLM-judged per-call) - 4 additional hook handler types (http / mcp_tool / prompt / agent) - hooks `if` field (permission-syntax filtering) - Wiring evaluateMode + HookDispatcher into runAgent (currently callable but not yet enforcing in the agent loop) Verified -------- pnpm typecheck → green pnpm build → green pnpm test → 197 passed / 4 skipped / 0 failed pnpm format:check → conformant Docs ---- - docs/milestones/M3.md — delivery breakdown of M3a vs M3b deferred items Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a592ab4 commit 3d08bd9

11 files changed

Lines changed: 1126 additions & 8 deletions

File tree

docs/milestones/M3.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# M3 — Modes + Hooks + Memory (partial)
2+
3+
> **Status**: ✅ partial — modes, hook framework (command handler), memory dual system shipped. MCP / compaction / statusLine / `/init` multi-phase / `auto` classifier / 5 hook handler types → split into M3b (next PR).
4+
> **Branch**: `feat/m3-modes-hooks-memory`
5+
6+
## Scope (planned, full M3)
7+
8+
> DEVELOPMENT_PLAN.md §6:
9+
> Task 子代理 + Hooks 9 事件 × 5 handler 类型 + JSON 输出契约 + `if` 字段 + MCP 完整 + compaction + modes 5 档 + auto 分类器 + statusLine(JSON-on-stdin)+ Memory 双系统 + AGENTS.md 互操作 + `/init` 多阶段
10+
11+
## What ships in THIS PR (M3a)
12+
13+
| Module | Lines | Tests |
14+
| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ------ | --- |
15+
| `modes/index.ts` — 5 mode policies, ModeRequest → ModeVerdict | 92 | 14 |
16+
| `hooks/types.ts` — HookContext / HookHandlerOutput / HookResult | 55 ||
17+
| `hooks/dispatcher.ts` — 9 events × `command` handler + JSON output parsing + `disableAllHooks` + matcher with ` | ` OR + stdin payload + timeout | 178 | 14 |
18+
| `memory/loader.ts` — DEEPCODE.md hierarchical walk + user-level + AGENTS.md + .deepcode/rules/ + @-import (4-hop max, cycle detection) + maxBytes budget | 170 | 14 |
19+
| `index.ts` — re-export new modules | +30 ||
20+
| **subtotal** | **~525** | **42** |
21+
22+
## What's deferred to M3b (next PR)
23+
24+
- **MCP client** (stdio transport, list-tools, call-tool, mcp\_\_ prefix)
25+
- **Compaction** (context > threshold → summarizer LLM call)
26+
- **statusLine** runner with JSON-on-stdin contract
27+
- **`/init` multi-phase** interactive (subagent explorer + proposal review)
28+
- **`auto` classifier mode** (LLM-judged per tool call; expensive — needs cost cap)
29+
- **Hook handler types** beyond `command`: `http`, `mcp_tool`, `prompt`, `agent`
30+
- **Hook `if` field** for permission-syntax filtering (currently the only matcher is tool name)
31+
- **Mode/permission integration into agent loop** — currently `evaluateMode()` is callable but not yet threaded through `runAgent()`. M3b wires it.
32+
33+
## Verification
34+
35+
```bash
36+
pnpm typecheck → green
37+
pnpm test → 197 passed / 4 skipped / 0 failed
38+
pnpm build → green
39+
```
40+
41+
## Key design decisions
42+
43+
1. **Plan mode enforcement is hardcoded, not configurable.** Write tools (`Write`, `Edit`, `Bash`, `NotebookEdit`) are denied regardless of permission rules — matches `docs/design/sandbox-plan-worktree.md` §3.3 invariant #1.
44+
45+
2. **`dontAsk` mode upgrades `ask` to `deny`** (no prompt, hard deny). Documented in §3.8 — strict white-list mode.
46+
47+
3. **`acceptEdits` permission-deny still wins** — even in `acceptEdits` mode, an explicit `deny` permission blocks the call. Matches matrix row in `docs/design/sandbox-plan-worktree.md`.
48+
49+
4. **Hook handler stdout JSON parsing accepts trailing JSON** — handlers can print log lines before emitting the JSON output object. Last `{...}` in stdout is parsed.
50+
51+
5. **Memory @-import recursion uses cycle detection via visited Set** — both per-file (resolveImportPath) AND globally (visited paths). Tests verify a→b→a cycle terminates.
52+
53+
6. **AGENTS.md is auto-imported only at project root**, not at parent dirs. Matches Cursor/Aider's convention.
54+
55+
7. **`rules/*.md` are loaded sorted alphabetically** — deterministic ordering for `BEHAVIOR_PARITY.md` testing in M9.
56+
57+
## Tests
58+
59+
```
60+
modes/ 14 tests — invariants across all 6 mode × 4 permission verdicts
61+
hooks/ 14 tests — command exec, JSON parsing, OR matcher, timeout, stdin
62+
memory/ 14 tests — hierarchical walk, @-import, cycle detection, budget
63+
```
64+
65+
Tests run in ~250ms locally.
66+
67+
## Next
68+
69+
M3b: MCP client + compaction + statusLine + agent-loop integration of modes/hooks.
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { mkdtemp, rm } from 'node:fs/promises';
2+
import { promises as fs } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6+
import { HookDispatcher, runCommand, tryParseJsonOutput } from './dispatcher.js';
7+
8+
describe('HookDispatcher', () => {
9+
let cwd: string;
10+
beforeEach(async () => {
11+
cwd = await mkdtemp(join(tmpdir(), 'dc-hooks-'));
12+
});
13+
afterEach(async () => {
14+
await rm(cwd, { recursive: true, force: true });
15+
});
16+
17+
it('returns empty result for unconfigured event', async () => {
18+
const d = new HookDispatcher({});
19+
const r = await d.dispatch({
20+
event: 'PreToolUse',
21+
cwd,
22+
triggeredAt: '2026-01-01',
23+
payload: { tool: 'Read' },
24+
});
25+
expect(r.stdout).toBe('');
26+
expect(r.anyBlocked).toBe(false);
27+
expect(r.timings).toEqual([]);
28+
});
29+
30+
it('runs command-type handler and captures stdout', async () => {
31+
const d = new HookDispatcher({
32+
hooks: {
33+
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo hello-hook' }] }],
34+
},
35+
});
36+
const r = await d.dispatch({
37+
event: 'PreToolUse',
38+
cwd,
39+
triggeredAt: '2026-01-01',
40+
payload: { tool: 'Bash' },
41+
});
42+
expect(r.stdout).toContain('hello-hook');
43+
expect(r.timings).toHaveLength(1);
44+
expect(r.timings[0]?.exitCode).toBe(0);
45+
});
46+
47+
it('skips handlers whose matcher does not apply', async () => {
48+
const d = new HookDispatcher({
49+
hooks: {
50+
PreToolUse: [
51+
{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo SHOULD_NOT_RUN' }] },
52+
{ matcher: 'Edit', hooks: [{ type: 'command', command: 'echo edit-hook' }] },
53+
],
54+
},
55+
});
56+
const r = await d.dispatch({
57+
event: 'PreToolUse',
58+
cwd,
59+
triggeredAt: '2026-01-01',
60+
payload: { tool: 'Edit' },
61+
});
62+
expect(r.stdout).not.toContain('SHOULD_NOT_RUN');
63+
expect(r.stdout).toContain('edit-hook');
64+
});
65+
66+
it('matcher supports | OR separator', async () => {
67+
const d = new HookDispatcher({
68+
hooks: {
69+
PreToolUse: [
70+
{
71+
matcher: 'Edit|Write',
72+
hooks: [{ type: 'command', command: 'echo edit-or-write' }],
73+
},
74+
],
75+
},
76+
});
77+
const writeResult = await d.dispatch({
78+
event: 'PreToolUse',
79+
cwd,
80+
triggeredAt: '2026-01-01',
81+
payload: { tool: 'Write' },
82+
});
83+
expect(writeResult.stdout).toContain('edit-or-write');
84+
const editResult = await d.dispatch({
85+
event: 'PreToolUse',
86+
cwd,
87+
triggeredAt: '2026-01-01',
88+
payload: { tool: 'Edit' },
89+
});
90+
expect(editResult.stdout).toContain('edit-or-write');
91+
});
92+
93+
it('non-zero exit sets anyBlocked', async () => {
94+
const d = new HookDispatcher({
95+
hooks: {
96+
PreToolUse: [{ hooks: [{ type: 'command', command: 'echo blocked >&2; exit 2' }] }],
97+
},
98+
});
99+
const r = await d.dispatch({
100+
event: 'PreToolUse',
101+
cwd,
102+
triggeredAt: '2026-01-01',
103+
payload: { tool: 'Bash' },
104+
});
105+
expect(r.anyBlocked).toBe(true);
106+
expect(r.timings[0]?.exitCode).toBe(2);
107+
expect(r.stderr).toContain('blocked');
108+
});
109+
110+
it('parses JSON output schema from stdout', async () => {
111+
const d = new HookDispatcher({
112+
hooks: {
113+
PreToolUse: [
114+
{
115+
hooks: [
116+
{
117+
type: 'command',
118+
command:
119+
'echo \'{"decision":"deny","systemMessage":"nope","additionalContext":"context"}\'',
120+
},
121+
],
122+
},
123+
],
124+
},
125+
});
126+
const r = await d.dispatch({
127+
event: 'PreToolUse',
128+
cwd,
129+
triggeredAt: '2026-01-01',
130+
payload: { tool: 'Bash' },
131+
});
132+
expect(r.json?.decision).toBe('deny');
133+
expect(r.json?.systemMessage).toBe('nope');
134+
expect(r.json?.additionalContext).toBe('context');
135+
});
136+
137+
it('disableAllHooks suppresses all execution', async () => {
138+
const d = new HookDispatcher({
139+
disableAllHooks: true,
140+
hooks: {
141+
PreToolUse: [{ hooks: [{ type: 'command', command: 'echo SHOULD_NOT_RUN' }] }],
142+
},
143+
});
144+
const r = await d.dispatch({
145+
event: 'PreToolUse',
146+
cwd,
147+
triggeredAt: '2026-01-01',
148+
payload: { tool: 'Bash' },
149+
});
150+
expect(r.stdout).toBe('');
151+
expect(r.timings).toEqual([]);
152+
});
153+
154+
it('runs multiple events independently', async () => {
155+
const d = new HookDispatcher({
156+
hooks: {
157+
SessionStart: [{ hooks: [{ type: 'command', command: 'echo session-start' }] }],
158+
Stop: [{ hooks: [{ type: 'command', command: 'echo stop' }] }],
159+
},
160+
});
161+
const r1 = await d.dispatch({
162+
event: 'SessionStart',
163+
cwd,
164+
triggeredAt: 't',
165+
payload: {},
166+
});
167+
expect(r1.stdout).toContain('session-start');
168+
const r2 = await d.dispatch({
169+
event: 'Stop',
170+
cwd,
171+
triggeredAt: 't',
172+
payload: {},
173+
});
174+
expect(r2.stdout).toContain('stop');
175+
});
176+
177+
it('reads stdin payload (event + payload as JSON)', async () => {
178+
const stdinReader = join(cwd, 'reader.sh');
179+
await fs.writeFile(stdinReader, '#!/bin/sh\ncat\n', 'utf8');
180+
await fs.chmod(stdinReader, 0o755);
181+
const d = new HookDispatcher({
182+
hooks: {
183+
UserPromptSubmit: [{ hooks: [{ type: 'command', command: stdinReader }] }],
184+
},
185+
});
186+
const r = await d.dispatch({
187+
event: 'UserPromptSubmit',
188+
cwd,
189+
triggeredAt: 't',
190+
payload: { prompt: 'hello there' },
191+
});
192+
expect(r.stdout).toContain('UserPromptSubmit');
193+
expect(r.stdout).toContain('hello there');
194+
});
195+
196+
it('unimplemented handler types return error in stderr but do not block', async () => {
197+
const d = new HookDispatcher({
198+
hooks: {
199+
PreToolUse: [{ hooks: [{ type: 'http', url: 'https://example.com' }] }],
200+
},
201+
});
202+
const r = await d.dispatch({
203+
event: 'PreToolUse',
204+
cwd,
205+
triggeredAt: 't',
206+
payload: { tool: 'Bash' },
207+
});
208+
expect(r.stderr).toMatch(/not implemented/);
209+
expect(r.anyBlocked).toBe(false);
210+
});
211+
});
212+
213+
describe('runCommand', () => {
214+
it('captures stdout and exitCode', async () => {
215+
const r = await runCommand({
216+
command: 'echo hi; exit 0',
217+
cwd: '/tmp',
218+
timeoutMs: 5000,
219+
env: process.env as Record<string, string>,
220+
});
221+
expect(r.stdout).toContain('hi');
222+
expect(r.exitCode).toBe(0);
223+
});
224+
225+
it('kills on timeout', async () => {
226+
const r = await runCommand({
227+
command: 'sleep 5',
228+
cwd: '/tmp',
229+
timeoutMs: 100,
230+
env: process.env as Record<string, string>,
231+
});
232+
expect(r.exitCode).toBe(124);
233+
expect(r.stderr).toMatch(/killed by timeout/);
234+
});
235+
});
236+
237+
describe('tryParseJsonOutput', () => {
238+
it('parses pure JSON', () => {
239+
expect(tryParseJsonOutput('{"decision":"allow"}')?.decision).toBe('allow');
240+
});
241+
it('parses JSON after log lines', () => {
242+
const r = tryParseJsonOutput('log line 1\nlog line 2\n{"decision":"deny"}');
243+
expect(r?.decision).toBe('deny');
244+
});
245+
it('returns null on no JSON', () => {
246+
expect(tryParseJsonOutput('plain text')).toBeNull();
247+
});
248+
it('returns null on empty', () => {
249+
expect(tryParseJsonOutput('')).toBeNull();
250+
});
251+
});

0 commit comments

Comments
 (0)