Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions docs/milestones/M3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# M3 — Modes + Hooks + Memory (partial)

> **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).
> **Branch**: `feat/m3-modes-hooks-memory`

## Scope (planned, full M3)

> DEVELOPMENT_PLAN.md §6:
> Task 子代理 + Hooks 9 事件 × 5 handler 类型 + JSON 输出契约 + `if` 字段 + MCP 完整 + compaction + modes 5 档 + auto 分类器 + statusLine(JSON-on-stdin)+ Memory 双系统 + AGENTS.md 互操作 + `/init` 多阶段

## What ships in THIS PR (M3a)

| Module | Lines | Tests |
| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ------ | --- |
| `modes/index.ts` — 5 mode policies, ModeRequest → ModeVerdict | 92 | 14 |
| `hooks/types.ts` — HookContext / HookHandlerOutput / HookResult | 55 | — |
| `hooks/dispatcher.ts` — 9 events × `command` handler + JSON output parsing + `disableAllHooks` + matcher with ` | ` OR + stdin payload + timeout | 178 | 14 |
| `memory/loader.ts` — DEEPCODE.md hierarchical walk + user-level + AGENTS.md + .deepcode/rules/ + @-import (4-hop max, cycle detection) + maxBytes budget | 170 | 14 |
| `index.ts` — re-export new modules | +30 | — |
| **subtotal** | **~525** | **42** |

## What's deferred to M3b (next PR)

- **MCP client** (stdio transport, list-tools, call-tool, mcp\_\_ prefix)
- **Compaction** (context > threshold → summarizer LLM call)
- **statusLine** runner with JSON-on-stdin contract
- **`/init` multi-phase** interactive (subagent explorer + proposal review)
- **`auto` classifier mode** (LLM-judged per tool call; expensive — needs cost cap)
- **Hook handler types** beyond `command`: `http`, `mcp_tool`, `prompt`, `agent`
- **Hook `if` field** for permission-syntax filtering (currently the only matcher is tool name)
- **Mode/permission integration into agent loop** — currently `evaluateMode()` is callable but not yet threaded through `runAgent()`. M3b wires it.

## Verification

```bash
pnpm typecheck → green
pnpm test → 197 passed / 4 skipped / 0 failed
pnpm build → green
```

## Key design decisions

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.

2. **`dontAsk` mode upgrades `ask` to `deny`** (no prompt, hard deny). Documented in §3.8 — strict white-list mode.

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`.

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.

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.

6. **AGENTS.md is auto-imported only at project root**, not at parent dirs. Matches Cursor/Aider's convention.

7. **`rules/*.md` are loaded sorted alphabetically** — deterministic ordering for `BEHAVIOR_PARITY.md` testing in M9.

## Tests

```
modes/ 14 tests — invariants across all 6 mode × 4 permission verdicts
hooks/ 14 tests — command exec, JSON parsing, OR matcher, timeout, stdin
memory/ 14 tests — hierarchical walk, @-import, cycle detection, budget
```

Tests run in ~250ms locally.

## Next

M3b: MCP client + compaction + statusLine + agent-loop integration of modes/hooks.
251 changes: 251 additions & 0 deletions packages/core/src/hooks/dispatcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { promises as fs } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { HookDispatcher, runCommand, tryParseJsonOutput } from './dispatcher.js';

describe('HookDispatcher', () => {
let cwd: string;
beforeEach(async () => {
cwd = await mkdtemp(join(tmpdir(), 'dc-hooks-'));
});
afterEach(async () => {
await rm(cwd, { recursive: true, force: true });
});

it('returns empty result for unconfigured event', async () => {
const d = new HookDispatcher({});
const r = await d.dispatch({
event: 'PreToolUse',
cwd,
triggeredAt: '2026-01-01',
payload: { tool: 'Read' },
});
expect(r.stdout).toBe('');
expect(r.anyBlocked).toBe(false);
expect(r.timings).toEqual([]);
});

it('runs command-type handler and captures stdout', async () => {
const d = new HookDispatcher({
hooks: {
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo hello-hook' }] }],
},
});
const r = await d.dispatch({
event: 'PreToolUse',
cwd,
triggeredAt: '2026-01-01',
payload: { tool: 'Bash' },
});
expect(r.stdout).toContain('hello-hook');
expect(r.timings).toHaveLength(1);
expect(r.timings[0]?.exitCode).toBe(0);
});

it('skips handlers whose matcher does not apply', async () => {
const d = new HookDispatcher({
hooks: {
PreToolUse: [
{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo SHOULD_NOT_RUN' }] },
{ matcher: 'Edit', hooks: [{ type: 'command', command: 'echo edit-hook' }] },
],
},
});
const r = await d.dispatch({
event: 'PreToolUse',
cwd,
triggeredAt: '2026-01-01',
payload: { tool: 'Edit' },
});
expect(r.stdout).not.toContain('SHOULD_NOT_RUN');
expect(r.stdout).toContain('edit-hook');
});

it('matcher supports | OR separator', async () => {
const d = new HookDispatcher({
hooks: {
PreToolUse: [
{
matcher: 'Edit|Write',
hooks: [{ type: 'command', command: 'echo edit-or-write' }],
},
],
},
});
const writeResult = await d.dispatch({
event: 'PreToolUse',
cwd,
triggeredAt: '2026-01-01',
payload: { tool: 'Write' },
});
expect(writeResult.stdout).toContain('edit-or-write');
const editResult = await d.dispatch({
event: 'PreToolUse',
cwd,
triggeredAt: '2026-01-01',
payload: { tool: 'Edit' },
});
expect(editResult.stdout).toContain('edit-or-write');
});

it('non-zero exit sets anyBlocked', async () => {
const d = new HookDispatcher({
hooks: {
PreToolUse: [{ hooks: [{ type: 'command', command: 'echo blocked >&2; exit 2' }] }],
},
});
const r = await d.dispatch({
event: 'PreToolUse',
cwd,
triggeredAt: '2026-01-01',
payload: { tool: 'Bash' },
});
expect(r.anyBlocked).toBe(true);
expect(r.timings[0]?.exitCode).toBe(2);
expect(r.stderr).toContain('blocked');
});

it('parses JSON output schema from stdout', async () => {
const d = new HookDispatcher({
hooks: {
PreToolUse: [
{
hooks: [
{
type: 'command',
command:
'echo \'{"decision":"deny","systemMessage":"nope","additionalContext":"context"}\'',
},
],
},
],
},
});
const r = await d.dispatch({
event: 'PreToolUse',
cwd,
triggeredAt: '2026-01-01',
payload: { tool: 'Bash' },
});
expect(r.json?.decision).toBe('deny');
expect(r.json?.systemMessage).toBe('nope');
expect(r.json?.additionalContext).toBe('context');
});

it('disableAllHooks suppresses all execution', async () => {
const d = new HookDispatcher({
disableAllHooks: true,
hooks: {
PreToolUse: [{ hooks: [{ type: 'command', command: 'echo SHOULD_NOT_RUN' }] }],
},
});
const r = await d.dispatch({
event: 'PreToolUse',
cwd,
triggeredAt: '2026-01-01',
payload: { tool: 'Bash' },
});
expect(r.stdout).toBe('');
expect(r.timings).toEqual([]);
});

it('runs multiple events independently', async () => {
const d = new HookDispatcher({
hooks: {
SessionStart: [{ hooks: [{ type: 'command', command: 'echo session-start' }] }],
Stop: [{ hooks: [{ type: 'command', command: 'echo stop' }] }],
},
});
const r1 = await d.dispatch({
event: 'SessionStart',
cwd,
triggeredAt: 't',
payload: {},
});
expect(r1.stdout).toContain('session-start');
const r2 = await d.dispatch({
event: 'Stop',
cwd,
triggeredAt: 't',
payload: {},
});
expect(r2.stdout).toContain('stop');
});

it('reads stdin payload (event + payload as JSON)', async () => {
const stdinReader = join(cwd, 'reader.sh');
await fs.writeFile(stdinReader, '#!/bin/sh\ncat\n', 'utf8');
await fs.chmod(stdinReader, 0o755);
const d = new HookDispatcher({
hooks: {
UserPromptSubmit: [{ hooks: [{ type: 'command', command: stdinReader }] }],
},
});
const r = await d.dispatch({
event: 'UserPromptSubmit',
cwd,
triggeredAt: 't',
payload: { prompt: 'hello there' },
});
expect(r.stdout).toContain('UserPromptSubmit');
expect(r.stdout).toContain('hello there');
});

it('unimplemented handler types return error in stderr but do not block', async () => {
const d = new HookDispatcher({
hooks: {
PreToolUse: [{ hooks: [{ type: 'http', url: 'https://example.com' }] }],
},
});
const r = await d.dispatch({
event: 'PreToolUse',
cwd,
triggeredAt: 't',
payload: { tool: 'Bash' },
});
expect(r.stderr).toMatch(/not implemented/);
expect(r.anyBlocked).toBe(false);
});
});

describe('runCommand', () => {
it('captures stdout and exitCode', async () => {
const r = await runCommand({
command: 'echo hi; exit 0',
cwd: '/tmp',
timeoutMs: 5000,
env: process.env as Record<string, string>,
});
expect(r.stdout).toContain('hi');
expect(r.exitCode).toBe(0);
});

it('kills on timeout', async () => {
const r = await runCommand({
command: 'sleep 5',
cwd: '/tmp',
timeoutMs: 100,
env: process.env as Record<string, string>,
});
expect(r.exitCode).toBe(124);
expect(r.stderr).toMatch(/killed by timeout/);
});
});

describe('tryParseJsonOutput', () => {
it('parses pure JSON', () => {
expect(tryParseJsonOutput('{"decision":"allow"}')?.decision).toBe('allow');
});
it('parses JSON after log lines', () => {
const r = tryParseJsonOutput('log line 1\nlog line 2\n{"decision":"deny"}');
expect(r?.decision).toBe('deny');
});
it('returns null on no JSON', () => {
expect(tryParseJsonOutput('plain text')).toBeNull();
});
it('returns null on empty', () => {
expect(tryParseJsonOutput('')).toBeNull();
});
});
Loading
Loading