Audit: p0-3-4-task-tools — β Task MCP Tool Surface
Task: P0.3.4
Auditor: T3 Executor (Claude Sonnet 4.6)
Date: 2026-04-17
Branch: feature/p0-3-4-task-tools
Base commit: d18f1272 (P0.3.3 writeback enforcement merged)
1. Scope
This audit inventories the surfaces that P0.3.4 must expose via MCP tools and the conventions established by prior tasks that constrain the implementation.
2. β CRUD surface (repository.ts exports)
File: src/domains/tasks/repository.ts
Exported functions
| Function | Signature | Throws |
|---|---|---|
createTask(db, input) |
(Database, CreateTaskInput) → Task |
better-sqlite3 errors (disk full, CHECK violation) |
getTask(db, id) |
(Database, string) → Task \| null |
never (returns null on miss) |
updateTask(db, id, patch) |
(Database, string, UpdateTaskPatch) → Task |
TaskNotFoundError, WritebackRequiredError (when status→DONE without thought_record), CHECK violation |
deleteTask(db, id) |
(Database, string) → Task |
TaskNotFoundError |
listTasks(db, filter?) |
(Database, ListTasksFilter?) → Task[] |
never |
Exported error classes
| Class | Fields | When thrown |
|---|---|---|
TaskNotFoundError |
taskId: string, operation: 'update' \| 'delete' |
No live row with given id |
WritebackRequiredError (from writeback.ts) |
taskId: string, missing_fields: string[] |
status→DONE without thought_record |
Key types
CreateTaskInput:{ title: string, status?: TaskState, project_id?, description?, priority?, assignee? }UpdateTaskPatch: all optional version of CreateTaskInput fieldsListTasksFilter:{ status?, project_id?, limit?, offset?, include_deleted? }Task: readonly snapshot with all 10 columns plusdeleted_at
3. FSM states (state-machine.ts)
8 canonical states: INIT, GATHER, ANALYZE, PLAN, APPLY, VERIFY, DONE, CANCELLED
Entry state: INIT (default for createTask)
Terminal states: DONE, CANCELLED
For task_next_actions — “actionable” means tasks not yet in a terminal state. The FSM contract defines TERMINAL_STATES = { DONE, CANCELLED }. Unblocked tasks are those with status NOT IN ('DONE', 'CANCELLED') — i.e. any of INIT, GATHER, ANALYZE, PLAN, APPLY, VERIFY.
4. Writeback enforcement (P0.3.3)
updateTask calls enforceWriteback(db, id) BEFORE the SQL UPDATE when patch.status === 'DONE'. enforceWriteback counts thought_records for taskId — if count is 0, throws WritebackRequiredError { taskId, missing_fields: ['thought_record'] }.
The MCP tool layer must catch WritebackRequiredError and return:
{ "ok": false, "error": { "code": "ERR_WRITEBACK_REQUIRED", "message": "...", "missing_fields": ["thought_record"], "taskId": "..." } }
5. Tool conventions (Wave A/B/C/D locks)
From skills/repository.ts (P0.6.2 reference)
- Pattern:
registerSkillTools(ctx: ColibriServerContext): void - Uses
registerColibriTool(ctx, name, config, handler) - Handler lazy-resolves
getDb()at call-time (NOT registration time) - Tools return
datapayload only — middleware wraps in{ ok: true, data: ... } - On error: handler throws → middleware catches →
{ ok: false, error: { code: 'HANDLER_ERROR', ... } }
From trail/repository.ts (P0.7.2 reference)
- Same pattern as above
- Schema objects at file top:
const XInputSchema = z.object({...}) - Handlers are inline lambdas calling repo functions with
getDb()
Key conventions
- Lazy getDb(): handlers call
getDb()inside the closure body, NOT atregisterTaskTools()call time - Envelopes: handlers return data directly —
registerColibriToolwraps in{ok:true,data:...} - Error handling: For typed errors (TaskNotFoundError, WritebackRequiredError), handlers must NOT throw — return
{ok:false,error:{...}}manually to override the genericHANDLER_ERRORcode - Zod schemas: one
z.object({})per tool input, at top of function/file - ORDER BY rowid: for deterministic ordering per Wave C P0.7.2 lesson
- Tool names: snake_case:
task_create,task_get,task_update,task_list,task_next_actions - File location: inside
src/domains/tasks/repository.ts(not a separate tools.ts)
6. server.ts bootstrap() wiring
Current bootstrap() in src/server.ts (line ~553-560):
registerThoughtTools(ctx);
registerSkillTools(ctx);
await start(ctx);
P0.3.4 must add registerTaskTools(ctx) after registerSkillTools(ctx) (or alongside it — ordering matters only if tools share names, which they don’t).
7. Test patterns (from repository.test.ts and trail/repository.test.ts)
- In-memory SQLite:
new Database(':memory:') - Both migrations needed:
002_tasks.sql+003_thought_records.sql insertThoughtRecord(db, taskId)helper for satisfying writeback prerequisite- Tool registration test: use
InMemoryTransport.createLinkedPair()+createServer({...}) - Check
ctx._registeredToolNames.has('tool_name')to verify registration - Test duplicate registration throws
'tool already registered'
8. What P0.3.4 must NOT do
- No FSM enforcement in the MCP tools (P0.3.1 FSM exists for logic; repository.ts is deliberately dumb-CRUD per its header; FSM policing in tools is noted as a concern but the task-breakdown says only
task_create/get/update/list/next_actions— notask_transitiontool) - No new migration (tasks table already exists from P0.3.2 002_tasks.sql)
- No changes to repository.ts CRUD functions
- No changes to writeback.ts
9. Identified risks
-
Error envelope shape:
registerColibriToolwraps handler returns in{ok:true,data:...}. For custom error envelopes, the handler must return{ok:false,error:{...}}and the middleware will wrap it again as{ok:true,data:{ok:false,error:{...}}}. To avoid this double-wrapping, we need to examine the middleware more carefully.Resolution: Looking at server.ts lines 350-369 — the middleware catches thrown errors and wraps them as
{ok:false,error:{code:'HANDLER_ERROR',...}}. For custom error envelopes, the handler must throw a typed error OR return the envelope directly. Since the middleware wraps ALL successful returns in{ok:true,data:...}, handlers that want custom error codes must throw — the middleware will produce{ok:false,error:{code:'HANDLER_ERROR',...}}. To getERR_WRITEBACK_REQUIRED, we need to throw with a distinguishable error OR we return the envelope from the handler asdata(accepting double-wrap).Re-reading: The trail tools return
ThoughtRecorddirectly — middleware wraps that as{ok:true,data:ThoughtRecord}. The skill tool returns{skills:[...],total_count:N}— wrapped as{ok:true,data:{skills:[...],total_count:N}}. Neither handles typed errors with custom codes.Decision: For task tools, to provide meaningful
ERR_WRITEBACK_REQUIREDandERR_NOT_FOUNDcodes, the handler should catch typed errors and return a custom envelope{ok:false,error:{code:'ERR_X',...}}. BUT the middleware wraps this in{ok:true,data:{ok:false,...}}— a double-wrap. We need to look at whether the middleware passes throughok:falseshapes.Final resolution: The middleware wraps all successful returns. There’s no bypass. The cleanest solution per s17 §6 is: throw a typed error from the handler → middleware produces
{ok:false,error:{code:'HANDLER_ERROR',message:...}}. To get custom codes, we can throw an error with the code embedded in the message, OR we must accept thatERR_WRITEBACK_REQUIREDbecomesHANDLER_ERRORat the middleware level with the structuredmessage. The task spec says “return envelope” so we should return{ok:false,...}from the handler, which gets double-wrapped.HOWEVER: re-reading the task spec carefully: “s17 §6 envelope: tools return
{ ok: true, data: ... }on success or{ ok: false, error: { code: 'ERR_X', message: '...' } }on failure — DO NOT throw from handlers, return the envelope”. The task brief explicitly says DO NOT throw — return envelopes. The trail tools do the opposite (they throw via direct repo calls), so the trail tools produce{ok:true,data:...}on success and{ok:false,error:{code:'HANDLER_ERROR',...}}on any exception. For P0.3.4, the mission brief says explicitly return envelopes and do not throw — meaning the task tools deviate from the trail pattern and return{ok:false,error:{code:'ERR_X',...}}as the handler return value, which gets wrapped as{ok:true,data:{ok:false,...}}.This is a design clarification needed in the contract step.
- getDb import: must import
getDbfrom../../db/index.js - WritebackRequiredError import: must import from
./writeback.js