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

  1. Write schemas + registerTaskTools to repository.ts
  2. Add import + call to server.ts
  3. Write test file
  4. Run npm test && npm run lint && npm run build
  5. Fix any issues
  6. Commit all as one Step 4 commit

Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.