Packet: p0-3-4-task-tools — Execution Plan
Task: P0.3.4
Author: T3 Executor (Claude Sonnet 4.6)
Date: 2026-04-17
Status: APPROVED — proceed to Step 4
1. Execution summary
Add registerTaskTools(ctx) to src/domains/tasks/repository.ts, wire it into bootstrap() in src/server.ts, and write ≥15 tests in src/__tests__/domains/tasks/tools.test.ts.
2. File changes
2.1 src/domains/tasks/repository.ts — append at bottom
Add imports at top:
import { z } from 'zod';
import { getDb } from '../../db/index.js';
import { registerColibriTool } from '../../server.js';
import type { ColibriServerContext } from '../../server.js';
import { WritebackRequiredError } from './writeback.js';
Add Zod schemas + registerTaskTools function at bottom of file.
2.2 src/server.ts — add 2 lines in bootstrap()
After registerSkillTools(ctx); add:
// P0.3.4: register β task tools — task_create/get/update/list/next_actions
registerTaskTools(ctx);
Add import at top alongside other domain imports:
import { registerTaskTools } from './domains/tasks/repository.js';
2.3 src/__tests__/domains/tasks/tools.test.ts — new test file
≥15 tests covering all 5 tools, registration guard, writeback integration, and Zod validation.
3. Step-by-step implementation
3.1 Zod schemas (5 schemas, one per tool)
// task_create input
const TaskCreateInputSchema = z.object({
title: z.string().min(1),
status: z.enum(TASK_STATES).optional(),
project_id: z.string().nullable().optional(),
description: z.string().nullable().optional(),
priority: z.string().nullable().optional(),
assignee: z.string().nullable().optional(),
});
// task_get input
const TaskGetInputSchema = z.object({
id: z.string().min(1),
});
// task_update input
const TaskUpdateInputSchema = z.object({
id: z.string().min(1),
patch: z.object({
title: z.string().optional(),
status: z.enum(TASK_STATES).optional(),
project_id: z.string().nullable().optional(),
description: z.string().nullable().optional(),
priority: z.string().nullable().optional(),
assignee: z.string().nullable().optional(),
}),
});
// task_list input
const TaskListInputSchema = z.object({
status: z.enum(TASK_STATES).optional(),
project_id: z.string().nullable().optional(),
limit: z.number().int().positive().max(500).optional(),
offset: z.number().int().min(0).optional(),
include_deleted: z.boolean().optional(),
});
// task_next_actions input
const TaskNextActionsInputSchema = z.object({});
3.2 registerTaskTools function structure
export function registerTaskTools(ctx: ColibriServerContext): void {
registerColibriTool(ctx, 'task_create', { ... }, (input) => {
return createTask(getDb(), input);
});
registerColibriTool(ctx, 'task_get', { ... }, (input) => {
const task = getTask(getDb(), input.id);
if (task === null) {
return { ok: false, error: { code: 'ERR_NOT_FOUND', message: `Task not found: ${input.id}`, taskId: input.id } };
}
return task;
});
registerColibriTool(ctx, 'task_update', { ... }, (input) => {
try {
return updateTask(getDb(), input.id, input.patch);
} catch (e) {
if (e instanceof WritebackRequiredError) {
return { ok: false, error: { code: 'ERR_WRITEBACK_REQUIRED', message: e.message, missing_fields: e.missing_fields, taskId: e.taskId } };
}
if (e instanceof TaskNotFoundError) {
return { ok: false, error: { code: 'ERR_NOT_FOUND', message: e.message, taskId: e.taskId } };
}
throw e;
}
});
registerColibriTool(ctx, 'task_list', { ... }, (input) => {
const tasks = listTasks(getDb(), input);
return { tasks, total_count: tasks.length };
});
registerColibriTool(ctx, 'task_next_actions', { ... }, (_input) => {
const db = getDb();
const rows = db.prepare(
`SELECT * FROM tasks WHERE deleted_at IS NULL AND status NOT IN ('DONE','CANCELLED') ORDER BY rowid ASC`
).all() as Task[];
// Use existing rowToTask from repository (not exported)... actually use listTasks with a known workaround
// OR: since Task shape is returned directly by listTasks per-status, we need a different approach
// Decision: use listTasks multiple times OR direct SQL
// Using direct SQL is cleaner for this case
return { tasks: rows, total_count: rows.length };
});
}
Note on task_next_actions: listTasks filters by a single status. For multi-status filtering, we use direct SQL with NOT IN. The rows returned by db.prepare().all() are raw DB rows — we need rowToTask. Since rowToTask is private to repository.ts, we add task_next_actions INSIDE repository.ts where it has access to rowToTask.
3.3 Test file structure
describe('registerTaskTools') {
- registers 5 tools
- throws on duplicate
}
describe('task_create tool') {
- happy path returns task
- Zod rejects empty title
}
describe('task_get tool') {
- found → returns task
- not found → ERR_NOT_FOUND envelope
- Zod rejects missing id
}
describe('task_update tool') {
- happy path
- not found → ERR_NOT_FOUND
- status→DONE no thought_record → ERR_WRITEBACK_REQUIRED (integration test)
- status→DONE WITH thought_record → success
- Zod rejects invalid status
}
describe('task_list tool') {
- happy path returns {tasks, total_count}
- status filter
}
describe('task_next_actions tool') {
- returns only non-terminal tasks
- excludes DONE and CANCELLED
}
3.4 Test DB helper
Tests need both migrations applied (tasks + thought_records). Follow repository.test.ts pattern:
- Load SQL at module scope via
readFileSync makeTestDb()returns fresh:memory:DB with both migrations- Tool tests drive repo functions directly (not via full MCP stack) to verify behavior
Note: Tool tests for typed error envelopes (task_get not-found, task_update writeback) call the handler function indirectly. The cleanest test approach: call the exported CRUD functions directly + verify error handling logic matches contract, OR create a full ctx + use registerColibriTool pattern like trail tests.
Decision: Follow the trail test pattern — create a server context with InMemoryTransport and call tool handlers by exercising the underlying repo functions directly. For envelope tests (ERR_NOT_FOUND, ERR_WRITEBACK_REQUIRED), test the handler logic at the repo level since the middleware wraps everything. The key integration test is: task_update status→DONE without thought_record throws WritebackRequiredError (verified by calling updateTask directly then mapping to envelope).
4. Risk mitigations
| Risk | Mitigation |
|---|---|
rowToTask is private |
registerTaskTools is inside repository.ts so it has access |
| Double-wrap envelope for error paths | Accepted design per contract §2; documented |
getDb() import circular risk |
getDb is in ../../db/index.js; existing usage in skills + trail confirms no circular dep |
5. Ordering constraint
- Write schemas +
registerTaskToolstorepository.ts - Add import + call to
server.ts - Write test file
- Run
npm test && npm run lint && npm run build - Fix any issues
- Commit all as one Step 4 commit