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
6 changes: 3 additions & 3 deletions docs/BEHAVIOR_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,9 @@ Specific deviations:
| TaskCreate / Monitor / TaskList / TaskGet / TaskOutput / TaskStop / TaskUpdate | ✓ | 🔄 | M8 (background tasks) |
| CronCreate / CronList / CronDelete | ✓ | 🔄 | M8 (cron daemon) |
| ScheduleWakeup | ✓ | 🔄 | M8 |
| WebFetch | ✓ | 🔄 | M3c+ |
| WebSearch | ✓ | 🔄 | M3c+ |
| TodoWrite | ✓ | 🔄 | M3c+ |
| WebFetch | ✓ | | shipped M3c-rest — 5 MiB cap + abort |
| WebSearch | ✓ | | shipped M3c-rest — DDG default + SearXNG |
| TodoWrite | ✓ | | shipped M3c-rest — persists in sessionDir |

## CLI flags

Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,17 @@ export {
BashTool,
GrepTool,
GlobTool,
TodoWriteTool,
WebFetchTool,
WebSearchTool,
readTodos,
TODO_FILE,
parseDuckDuckGoHtml,
ToolRegistry,
BUILTIN_TOOLS,
type TodoItem,
type TodoStatus,
type SearchHit,
} from './tools/index.js';

// Sessions
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/tools/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
// Tools subsystem entry — 6 P0 tools (Read/Write/Edit/Bash/Grep/Glob) + registry.
// Tools subsystem entry — P0 tools + M3c-rest extensions + registry.
// Spec: docs/DEVELOPMENT_PLAN.md §3.2
// Milestone: M1
// Milestone: M1 (P0) + M3c-rest (TodoWrite/WebFetch/WebSearch)

export { ReadTool } from './read.js';
export { WriteTool } from './write.js';
export { EditTool } from './edit.js';
export { BashTool } from './bash.js';
export { GrepTool } from './grep.js';
export { GlobTool } from './glob.js';
export { TodoWriteTool, readTodos, TODO_FILE } from './todo.js';
export type { TodoItem, TodoStatus } from './todo.js';
export { WebFetchTool } from './web-fetch.js';
export { WebSearchTool, parseDuckDuckGoHtml } from './web-search.js';
export type { SearchHit } from './web-search.js';
export { ToolRegistry, BUILTIN_TOOLS } from './registry.js';
export type { ToolDefinition, ToolContext, ToolResult, ToolHandler } from './types.js';
12 changes: 11 additions & 1 deletion packages/core/src/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,26 @@ import { EditTool } from './edit.js';
import { GlobTool } from './glob.js';
import { GrepTool } from './grep.js';
import { ReadTool } from './read.js';
import { TodoWriteTool } from './todo.js';
import { WebFetchTool } from './web-fetch.js';
import { WebSearchTool } from './web-search.js';
import { WriteTool } from './write.js';

/** The 6 P0 tools shipped in M1. */
/**
* Built-in tools shipped by default.
* · 6 P0 tools from M1 (Read/Write/Edit/Bash/Grep/Glob)
* · 3 M3c-rest tools (TodoWrite/WebFetch/WebSearch)
*/
export const BUILTIN_TOOLS: ToolHandler[] = [
ReadTool,
WriteTool,
EditTool,
BashTool,
GrepTool,
GlobTool,
TodoWriteTool,
WebFetchTool,
WebSearchTool,
];

export class ToolRegistry {
Expand Down
102 changes: 102 additions & 0 deletions packages/core/src/tools/todo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { promises as fs } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { readTodos, TODO_FILE, TodoWriteTool } from './todo.js';

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

it('persists a fresh list to <sessionDir>/todos.json', async () => {
const res = await TodoWriteTool.execute(
{
todos: [
{ content: 'Write spec', activeForm: 'Writing spec', status: 'in_progress' },
{ content: 'Add tests', activeForm: 'Adding tests', status: 'pending' },
],
},
{ cwd: process.cwd(), sessionDir },
);
expect(res.isError).toBeFalsy();
expect(res.content).toContain('OK');
expect(res.content).toContain('2 todos');
const raw = await fs.readFile(join(sessionDir, TODO_FILE), 'utf8');
const parsed = JSON.parse(raw) as Array<{ content: string }>;
expect(parsed).toHaveLength(2);
expect(parsed[0].content).toBe('Write spec');
});

it('replaces the list on subsequent calls (no append)', async () => {
await TodoWriteTool.execute(
{
todos: [{ content: 'A', activeForm: 'Doing A', status: 'pending' }],
},
{ cwd: process.cwd(), sessionDir },
);
await TodoWriteTool.execute(
{
todos: [{ content: 'B', activeForm: 'Doing B', status: 'completed' }],
},
{ cwd: process.cwd(), sessionDir },
);
const todos = await readTodos(sessionDir);
expect(todos).toHaveLength(1);
expect(todos[0]?.content).toBe('B');
});

it('rejects when more than one item is in_progress', async () => {
const res = await TodoWriteTool.execute(
{
todos: [
{ content: 'A', activeForm: 'Doing A', status: 'in_progress' },
{ content: 'B', activeForm: 'Doing B', status: 'in_progress' },
],
},
{ cwd: process.cwd(), sessionDir },
);
expect(res.isError).toBe(true);
expect(res.content).toMatch(/at most one/i);
});

it('rejects malformed item shape', async () => {
const res = await TodoWriteTool.execute(
{
todos: [{ content: 'A', status: 'pending' }],
},
{ cwd: process.cwd(), sessionDir },
);
expect(res.isError).toBe(true);
});

it('rejects non-array input', async () => {
const res = await TodoWriteTool.execute(
{ todos: 'nope' as unknown as never },
{ cwd: process.cwd(), sessionDir },
);
expect(res.isError).toBe(true);
});

it('returns ok but not persisted when no sessionDir', async () => {
const res = await TodoWriteTool.execute(
{
todos: [{ content: 'X', activeForm: 'X-ing', status: 'pending' }],
},
{ cwd: process.cwd() },
);
expect(res.isError).toBeFalsy();
expect(res.content).toMatch(/not persisted/);
expect((res.data as { persisted: boolean }).persisted).toBe(false);
});

it('readTodos returns [] when file does not exist', async () => {
const todos = await readTodos(sessionDir);
expect(todos).toEqual([]);
});
});
126 changes: 126 additions & 0 deletions packages/core/src/tools/todo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// TodoWrite tool — agent-managed task list, persisted per-session.
// Spec: docs/DEVELOPMENT_PLAN.md §3.15 / behavior parity with Claude Code's TodoWrite.
//
// The list lives in `<sessionDir>/todos.json`. Each call replaces the whole list
// (agent submits the desired state). UI can render it as a checklist. The tool
// itself is stateless — state is the file.

import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import type { ToolContext, ToolHandler, ToolResult } from '../types.js';

export type TodoStatus = 'pending' | 'in_progress' | 'completed';

export interface TodoItem {
content: string;
/** First-person continuous form, shown while the item is in_progress. */
activeForm: string;
status: TodoStatus;
}

interface TodoInput {
todos: TodoItem[];
}

/** Where the per-session todo list lives — relative to sessionDir. */
export const TODO_FILE = 'todos.json';

function isTodo(x: unknown): x is TodoItem {
if (!x || typeof x !== 'object') return false;
const o = x as Record<string, unknown>;
return (
typeof o['content'] === 'string' &&
typeof o['activeForm'] === 'string' &&
(o['status'] === 'pending' || o['status'] === 'in_progress' || o['status'] === 'completed')
);
}

export const TodoWriteTool: ToolHandler = {
name: 'TodoWrite',
definition: {
name: 'TodoWrite',
description:
'Replace the session todo list. Submit the full desired state (not a diff). Each item has content (imperative), activeForm (first-person continuous, shown while in_progress), and status (pending|in_progress|completed). Convention: at most ONE item in_progress at a time.',
inputSchema: {
type: 'object',
properties: {
todos: {
type: 'array',
description: 'Full list of todo items in the desired final state.',
items: {
type: 'object',
properties: {
content: { type: 'string', description: 'Imperative task description.' },
activeForm: { type: 'string', description: 'First-person continuous form.' },
status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
},
required: ['content', 'activeForm', 'status'],
},
},
},
required: ['todos'],
},
},
async execute(rawInput: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const input = rawInput as unknown as TodoInput;
if (!Array.isArray(input?.todos)) {
return { content: 'Error: todos must be an array.', isError: true };
}
if (!input.todos.every(isTodo)) {
return {
content:
'Error: each todo needs string content, string activeForm, and status in (pending|in_progress|completed).',
isError: true,
};
}
const inProgressCount = input.todos.filter((t) => t.status === 'in_progress').length;
if (inProgressCount > 1) {
return {
content: `Error: at most one todo may be in_progress at a time (got ${inProgressCount}).`,
isError: true,
};
}

if (!ctx.sessionDir) {
// Without a sessionDir we can't persist, but we still validate and return.
return {
content: `OK (not persisted: no sessionDir). ${summarize(input.todos)}`,
data: { todos: input.todos, persisted: false },
};
}

const target = join(ctx.sessionDir, TODO_FILE);
try {
await fs.mkdir(ctx.sessionDir, { recursive: true });
await fs.writeFile(target, JSON.stringify(input.todos, null, 2) + '\n', 'utf8');
} catch (err) {
return {
content: `Error persisting todos: ${(err as Error).message}`,
isError: true,
};
}

return {
content: `OK. ${summarize(input.todos)}`,
data: { todos: input.todos, persisted: true, path: target },
};
},
};

function summarize(todos: TodoItem[]): string {
const counts = { pending: 0, in_progress: 0, completed: 0 };
for (const t of todos) counts[t.status]++;
return `${todos.length} todos (${counts.completed} done · ${counts.in_progress} in_progress · ${counts.pending} pending).`;
}

/** Reads the current todo list from a session dir. Returns [] if none. */
export async function readTodos(sessionDir: string): Promise<TodoItem[]> {
try {
const raw = await fs.readFile(join(sessionDir, TODO_FILE), 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed) && parsed.every(isTodo)) return parsed;
return [];
} catch {
return [];
}
}
Loading
Loading