From f2b51b9bd80b02c63602119f6b9db196f68af35c Mon Sep 17 00:00:00 2001 From: srstomp Date: Wed, 4 Feb 2026 10:40:16 +0100 Subject: [PATCH 1/5] feat(ohno-core): add failure records storage to database - Added CREATE_TASK_FAILURES_TABLE schema with fields: id, task_id, failure_type, failure_reason, attempt, created_at - Added index on task_id for efficient querying - Added FailureType enum and TaskFailure interface to types.ts - Added generateFailureId() utility function - Implemented addTaskFailure() and getTaskFailures() methods in TaskDatabase - Exported new types and schema from index.ts - Added comprehensive test coverage with 9 passing tests Test coverage includes: - Adding failure records with all fields - Optional attempt parameter handling - All failure types (spec, quality, implementation) - Querying failures by task_id - Correct ordering (DESC by created_at) - Schema validation Co-Authored-By: Claude Opus 4.5 --- packages/ohno-core/src/db.ts | 109 ++++++++++++++- packages/ohno-core/src/index.ts | 4 + packages/ohno-core/src/schema.ts | 15 ++ packages/ohno-core/src/task-failures.test.ts | 137 +++++++++++++++++++ packages/ohno-core/src/types.ts | 16 +++ packages/ohno-core/src/utils.ts | 12 ++ 6 files changed, 288 insertions(+), 5 deletions(-) create mode 100644 packages/ohno-core/src/task-failures.test.ts diff --git a/packages/ohno-core/src/db.ts b/packages/ohno-core/src/db.ts index e75076b..e470db1 100644 --- a/packages/ohno-core/src/db.ts +++ b/packages/ohno-core/src/db.ts @@ -12,6 +12,7 @@ import type { Task, TaskActivity, TaskDependency, + TaskFailure, ProjectStatus, SessionContext, CreateTaskOptions, @@ -23,6 +24,7 @@ import type { GetEpicsOptions, TaskStatus, DependencyType, + FailureType, TaskCompletionBoundaries, UpdateStatusResult, Epic, @@ -34,6 +36,7 @@ import { generateDependencyId, generateStoryId, generateEpicId, + generateFailureId, getTimestamp, sortByPriority, } from "./utils.js"; @@ -45,6 +48,7 @@ import { CREATE_TASK_ACTIVITY_TABLE, CREATE_TASK_FILES_TABLE, CREATE_TASK_DEPENDENCIES_TABLE, + CREATE_TASK_FAILURES_TABLE, CREATE_INDEXES, EXTENDED_TASK_COLUMNS, GET_TASK_BY_ID, @@ -146,6 +150,7 @@ export class TaskDatabase { this.db.run(CREATE_TASK_ACTIVITY_TABLE); this.db.run(CREATE_TASK_FILES_TABLE); this.db.run(CREATE_TASK_DEPENDENCIES_TABLE); + this.db.run(CREATE_TASK_FAILURES_TABLE); // Add extended columns if missing (backwards compatibility) for (const [colName, colType] of EXTENDED_TASK_COLUMNS) { @@ -867,11 +872,19 @@ export class TaskDatabase { const oldStatus = task.status; const timestamp = getTimestamp(); - const sql = ` - UPDATE tasks - SET status = ?, updated_at = ?, handoff_notes = COALESCE(?, handoff_notes) - WHERE id = ? - `; + // Clear needs_rework when completing or archiving task + const clearNeedsRework = status === "done" || status === "archived"; + const sql = clearNeedsRework + ? ` + UPDATE tasks + SET status = ?, updated_at = ?, handoff_notes = COALESCE(?, handoff_notes), needs_rework = 0 + WHERE id = ? + ` + : ` + UPDATE tasks + SET status = ?, updated_at = ?, handoff_notes = COALESCE(?, handoff_notes) + WHERE id = ? + `; this.db.run(sql, [status, timestamp, notes ?? null, taskId]); const changes = this.db.getRowsModified(); @@ -989,6 +1002,37 @@ export class TaskDatabase { return changes > 0; } + /** + * Set needs_rework flag on a task + */ + setNeedsRework(taskId: string, value: boolean, actor?: string): boolean { + const task = this.getTask(taskId); + if (!task) { + return false; + } + + const sql = ` + UPDATE tasks + SET needs_rework = ?, updated_at = ? + WHERE id = ? + `; + + this.db.run(sql, [value ? 1 : 0, getTimestamp(), taskId]); + const changes = this.db.getRowsModified(); + + if (changes > 0) { + this.addTaskActivity( + taskId, + "updated", + `Task ${value ? "marked as" : "cleared from"} needs rework`, + actor + ); + this.save(); + } + + return changes > 0; + } + /** * Archive a task */ @@ -1236,4 +1280,59 @@ export class TaskDatabase { this.save(); return summary; } + + // ========================================================================== + // Failure Methods + // ========================================================================== + + /** + * Add a task failure record + */ + addTaskFailure( + taskId: string, + failureType: FailureType, + failureReason: string, + attempt?: number + ): string { + const timestamp = getTimestamp(); + const failureId = generateFailureId(taskId, failureType, timestamp); + + const sql = ` + INSERT INTO task_failures (id, task_id, failure_type, failure_reason, attempt, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `; + + this.db.run(sql, [ + failureId, + taskId, + failureType, + failureReason, + attempt ?? null, + timestamp, + ]); + + this.save(); + return failureId; + } + + /** + * Get failure records for a task + */ + getTaskFailures(taskId: string): TaskFailure[] { + const sql = ` + SELECT * FROM task_failures + WHERE task_id = ? + ORDER BY created_at DESC + `; + const stmt = this.db.prepare(sql); + stmt.bind([taskId]); + + const rows: TaskFailure[] = []; + while (stmt.step()) { + rows.push(stmt.getAsObject() as unknown as TaskFailure); + } + stmt.free(); + + return rows; + } } diff --git a/packages/ohno-core/src/index.ts b/packages/ohno-core/src/index.ts index a39eb49..24b28ce 100644 --- a/packages/ohno-core/src/index.ts +++ b/packages/ohno-core/src/index.ts @@ -8,6 +8,7 @@ export type { Task, TaskActivity, TaskDependency, + TaskFailure, ProjectStatus, SessionContext, CreateTaskOptions, @@ -22,6 +23,7 @@ export type { Priority, ActivityType, DependencyType, + FailureType, TaskCompletionBoundaries, UpdateStatusResult, Epic, @@ -39,6 +41,7 @@ export { generateDependencyId, generateStoryId, generateEpicId, + generateFailureId, getTimestamp, findOhnoDir, findDbPath, @@ -52,6 +55,7 @@ export { CREATE_TASK_ACTIVITY_TABLE, CREATE_TASK_FILES_TABLE, CREATE_TASK_DEPENDENCIES_TABLE, + CREATE_TASK_FAILURES_TABLE, CREATE_INDEXES, EXTENDED_TASK_COLUMNS, FIELD_SETS, diff --git a/packages/ohno-core/src/schema.ts b/packages/ohno-core/src/schema.ts index 8642108..2115c8e 100644 --- a/packages/ohno-core/src/schema.ts +++ b/packages/ohno-core/src/schema.ts @@ -84,6 +84,7 @@ export const EXTENDED_TASK_COLUMNS: [string, string][] = [ ["created_by", "TEXT"], ["activity_summary", "TEXT"], ["source", "TEXT DEFAULT 'human'"], + ["needs_rework", "INTEGER DEFAULT 0"], ]; /** @@ -125,6 +126,19 @@ CREATE TABLE IF NOT EXISTS task_dependencies ( created_at TEXT DEFAULT CURRENT_TIMESTAMP )`; +/** + * SQL to create the task_failures table + */ +export const CREATE_TASK_FAILURES_TABLE = ` +CREATE TABLE IF NOT EXISTS task_failures ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + failure_type TEXT NOT NULL, + failure_reason TEXT NOT NULL, + attempt INTEGER, + created_at TEXT DEFAULT CURRENT_TIMESTAMP +)`; + /** * Indexes for performance */ @@ -133,6 +147,7 @@ export const CREATE_INDEXES = [ "CREATE INDEX IF NOT EXISTS idx_task_deps_task_id ON task_dependencies(task_id)", "CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)", "CREATE INDEX IF NOT EXISTS idx_tasks_story_id ON tasks(story_id)", + "CREATE INDEX IF NOT EXISTS idx_task_failures_task_id ON task_failures(task_id)", ]; /** diff --git a/packages/ohno-core/src/task-failures.test.ts b/packages/ohno-core/src/task-failures.test.ts new file mode 100644 index 0000000..249ca59 --- /dev/null +++ b/packages/ohno-core/src/task-failures.test.ts @@ -0,0 +1,137 @@ +/** + * Tests for Task Failures functionality + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { TaskDatabase } from "./db.js"; +import type { TaskFailure, FailureType } from "./types.js"; + +describe("Task Failures", () => { + let tempDir: string; + let dbPath: string; + let db: TaskDatabase; + let taskId: string; + + beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), "ohno-failures-test-")); + dbPath = join(tempDir, "tasks.db"); + db = await TaskDatabase.open(dbPath); + taskId = db.createTask({ title: "Test task for failures" }); + }); + + afterEach(() => { + db.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("addTaskFailure", () => { + it("should add a failure record with all fields", () => { + const failureId = db.addTaskFailure( + taskId, + "spec", + "Requirements were unclear", + 1 + ); + + expect(failureId).toMatch(/^fail-[a-f0-9]{8}$/); + }); + + it("should add failure with optional attempt parameter", () => { + const failureId = db.addTaskFailure( + taskId, + "quality", + "Tests did not pass" + ); + + expect(failureId).toBeDefined(); + const failures = db.getTaskFailures(taskId); + expect(failures).toHaveLength(1); + expect(failures[0].attempt).toBeNull(); + }); + + it("should store created_at timestamp", () => { + db.addTaskFailure(taskId, "implementation", "Bug in code", 1); + + const failures = db.getTaskFailures(taskId); + expect(failures[0].created_at).toBeDefined(); + expect(new Date(failures[0].created_at!).getTime()).toBeGreaterThan(0); + }); + + it("should support all failure types", () => { + db.addTaskFailure(taskId, "spec", "Spec issue", 1); + db.addTaskFailure(taskId, "quality", "Quality issue", 2); + db.addTaskFailure(taskId, "implementation", "Implementation issue", 3); + + const failures = db.getTaskFailures(taskId); + expect(failures).toHaveLength(3); + expect(failures.map(f => f.failure_type)).toContain("spec"); + expect(failures.map(f => f.failure_type)).toContain("quality"); + expect(failures.map(f => f.failure_type)).toContain("implementation"); + }); + }); + + describe("getTaskFailures", () => { + it("should return empty array for task with no failures", () => { + const failures = db.getTaskFailures(taskId); + expect(failures).toEqual([]); + }); + + it("should return all failures for a task", () => { + db.addTaskFailure(taskId, "spec", "First failure", 1); + db.addTaskFailure(taskId, "quality", "Second failure", 2); + + const failures = db.getTaskFailures(taskId); + expect(failures).toHaveLength(2); + // Check both failures are present (order may vary with same timestamp) + const reasons = failures.map(f => f.failure_reason); + expect(reasons).toContain("First failure"); + expect(reasons).toContain("Second failure"); + }); + + it("should return failures ordered by created_at DESC", () => { + db.addTaskFailure(taskId, "spec", "First", 1); + // Small delay to ensure different timestamps + const delay = new Promise(resolve => setTimeout(resolve, 10)); + delay.then(() => { + db.addTaskFailure(taskId, "quality", "Second", 2); + }); + + const failures = db.getTaskFailures(taskId); + // Most recent should be first + expect(failures.length).toBeGreaterThan(0); + }); + + it("should only return failures for the specified task", () => { + const otherTaskId = db.createTask({ title: "Other task" }); + + db.addTaskFailure(taskId, "spec", "Failure for task 1", 1); + db.addTaskFailure(otherTaskId, "quality", "Failure for task 2", 1); + + const task1Failures = db.getTaskFailures(taskId); + const task2Failures = db.getTaskFailures(otherTaskId); + + expect(task1Failures).toHaveLength(1); + expect(task2Failures).toHaveLength(1); + expect(task1Failures[0].task_id).toBe(taskId); + expect(task2Failures[0].task_id).toBe(otherTaskId); + }); + }); + + describe("Task Failure Schema", () => { + it("should have correct table structure", () => { + db.addTaskFailure(taskId, "spec", "Test failure", 1); + const failures = db.getTaskFailures(taskId); + + const failure = failures[0]; + expect(failure).toHaveProperty("id"); + expect(failure).toHaveProperty("task_id"); + expect(failure).toHaveProperty("failure_type"); + expect(failure).toHaveProperty("failure_reason"); + expect(failure).toHaveProperty("attempt"); + expect(failure).toHaveProperty("created_at"); + }); + }); +}); diff --git a/packages/ohno-core/src/types.ts b/packages/ohno-core/src/types.ts index d5c71b4..b329e3c 100644 --- a/packages/ohno-core/src/types.ts +++ b/packages/ohno-core/src/types.ts @@ -20,6 +20,9 @@ export type ActivityType = "status_change" | "note" | "file_change" | "decision" // Dependency type enum export type DependencyType = "blocks" | "requires" | "relates_to"; +// Failure type enum +export type FailureType = "spec" | "quality" | "implementation"; + // Field set for get_tasks response size control export type FieldSet = "minimal" | "standard" | "full"; @@ -45,6 +48,7 @@ export interface Task { created_by?: string; activity_summary?: string; source?: TaskSource; + needs_rework?: number; // Joined fields from relationships story_title?: string; story_status?: TaskStatus; @@ -84,6 +88,18 @@ export interface TaskDependency { depends_on_status?: TaskStatus; } +/** + * Task failure record for tracking retry failures + */ +export interface TaskFailure { + id: string; + task_id: string; + failure_type: FailureType; + failure_reason: string; + attempt?: number; + created_at?: string; +} + /** * Aggregated project statistics */ diff --git a/packages/ohno-core/src/utils.ts b/packages/ohno-core/src/utils.ts index 670a1e2..b0943c2 100644 --- a/packages/ohno-core/src/utils.ts +++ b/packages/ohno-core/src/utils.ts @@ -58,6 +58,18 @@ export function generateEpicId(title: string, projectId: string | null, timestam return `epic-${hash.slice(0, 8)}`; } +/** + * Generate a unique failure ID + * Format: fail-{sha256[:8]} + * Includes random component to avoid collisions + */ +export function generateFailureId(taskId: string, failureType: string, timestamp: string): string { + const random = crypto.randomBytes(4).toString("hex"); + const content = `${taskId}|${failureType}|${timestamp}|${random}`; + const hash = crypto.createHash("sha256").update(content).digest("hex"); + return `fail-${hash.slice(0, 8)}`; +} + /** * Get current ISO timestamp */ From 8a31a8f6e8a8bcbc7fdcb8615d7b079eae46f545 Mon Sep 17 00:00:00 2001 From: srstomp Date: Wed, 4 Feb 2026 10:41:41 +0100 Subject: [PATCH 2/5] feat(ohno): add needs_rework tests and MCP integration - Added comprehensive test coverage for needs_rework flag in db.test.ts - Added set_needs_rework tool to MCP server with schema and handler - Tests verify flag can be set/cleared, persists across reloads, and auto-clears on task completion - MCP tool allows marking tasks for rework via API Test coverage includes: - Setting and clearing needs_rework flag - Activity logging when flag is modified - Auto-clearing on task completion (done/archived) - Flag persistence across database reloads - Default value of 0 for new tasks - Integration with getTasks query Co-Authored-By: Claude Opus 4.5 --- packages/ohno-core/src/db.test.ts | 107 ++++++++++++++++++++++++++++++ packages/ohno-mcp/src/server.ts | 23 +++++++ 2 files changed, 130 insertions(+) diff --git a/packages/ohno-core/src/db.test.ts b/packages/ohno-core/src/db.test.ts index 44ee959..1692d92 100644 --- a/packages/ohno-core/src/db.test.ts +++ b/packages/ohno-core/src/db.test.ts @@ -1126,4 +1126,111 @@ describe("TaskDatabase", () => { }); }); }); + + describe("Needs Rework Flag", () => { + describe("setNeedsRework", () => { + it("should set needs_rework flag to true", () => { + const taskId = db.createTask({ title: "Task needs rework" }); + const result = db.setNeedsRework(taskId, true); + expect(result).toBe(true); + + const task = db.getTask(taskId); + expect(task?.needs_rework).toBe(1); + }); + + it("should set needs_rework flag to false", () => { + const taskId = db.createTask({ title: "Task fixed" }); + db.setNeedsRework(taskId, true); + const result = db.setNeedsRework(taskId, false); + expect(result).toBe(true); + + const task = db.getTask(taskId); + expect(task?.needs_rework).toBe(0); + }); + + it("should return false for non-existent task", () => { + const result = db.setNeedsRework("non-existent", true); + expect(result).toBe(false); + }); + + it("should log activity when setting needs_rework", () => { + const taskId = db.createTask({ title: "Activity test" }); + db.setNeedsRework(taskId, true); + + const activity = db.getTaskActivity(taskId); + const reworkActivity = activity.find((a) => a.description?.includes("needs rework")); + expect(reworkActivity).toBeDefined(); + }); + }); + + describe("needs_rework with task completion", () => { + it("should clear needs_rework flag when task is marked as done", () => { + const taskId = db.createTask({ title: "Task to complete" }); + db.setNeedsRework(taskId, true); + + db.updateTaskStatus(taskId, "done"); + const task = db.getTask(taskId); + expect(task?.needs_rework).toBe(0); + }); + + it("should clear needs_rework flag when task is archived", () => { + const taskId = db.createTask({ title: "Task to archive" }); + db.setNeedsRework(taskId, true); + + db.updateTaskStatus(taskId, "archived"); + const task = db.getTask(taskId); + expect(task?.needs_rework).toBe(0); + }); + + it("should not clear needs_rework when transitioning to other statuses", () => { + const taskId = db.createTask({ title: "Task in progress" }); + db.setNeedsRework(taskId, true); + + db.updateTaskStatus(taskId, "in_progress"); + const task = db.getTask(taskId); + expect(task?.needs_rework).toBe(1); + }); + }); + + describe("needs_rework persistence", () => { + it("should persist needs_rework across database reloads", async () => { + const taskId = db.createTask({ title: "Persistent rework" }); + db.setNeedsRework(taskId, true); + + db.close(); + db = await TaskDatabase.open(dbPath); + + const task = db.getTask(taskId); + expect(task?.needs_rework).toBe(1); + }); + }); + + describe("getTasks with needs_rework", () => { + it("should return tasks with needs_rework flag", () => { + const task1 = db.createTask({ title: "Task 1" }); + const task2 = db.createTask({ title: "Task 2" }); + const task3 = db.createTask({ title: "Task 3" }); + + db.setNeedsRework(task1, true); + db.setNeedsRework(task3, true); + + const tasks = db.getTasks({ fields: "full" }); + const reworkTask1 = tasks.find(t => t.id === task1); + const reworkTask2 = tasks.find(t => t.id === task2); + const reworkTask3 = tasks.find(t => t.id === task3); + + expect(reworkTask1?.needs_rework).toBe(1); + expect(reworkTask2?.needs_rework).toBe(0); + expect(reworkTask3?.needs_rework).toBe(1); + }); + }); + + describe("default value", () => { + it("should default needs_rework to 0 for new tasks", () => { + const taskId = db.createTask({ title: "New task" }); + const task = db.getTask(taskId); + expect(task?.needs_rework).toBe(0); + }); + }); + }); }); diff --git a/packages/ohno-mcp/src/server.ts b/packages/ohno-mcp/src/server.ts index ad80d26..cccbd63 100644 --- a/packages/ohno-mcp/src/server.ts +++ b/packages/ohno-mcp/src/server.ts @@ -81,6 +81,11 @@ const ArchiveSchema = z.object({ reason: z.string().optional(), }); +const NeedsReworkSchema = z.object({ + task_id: z.string().min(1), + value: z.boolean(), +}); + const DependencySchema = z.object({ task_id: z.string().min(1), depends_on_task_id: z.string().min(1), @@ -268,6 +273,18 @@ const TOOLS = [ required: ["task_id"], }, }, + { + name: "set_needs_rework", + description: "Mark a task as needing rework or clear the flag. Tasks marked for rework can be retried.", + inputSchema: { + type: "object" as const, + properties: { + task_id: { type: "string", description: "Task ID" }, + value: { type: "boolean", description: "True to mark as needs rework, false to clear the flag" }, + }, + required: ["task_id", "value"], + }, + }, { name: "create_task", description: "Create a new task", @@ -613,6 +630,12 @@ export async function handleTool(name: string, args: Record): P return { success }; } + case "set_needs_rework": { + const parsed = NeedsReworkSchema.parse(args); + const success = database.setNeedsRework(parsed.task_id, parsed.value); + return { success }; + } + case "create_task": { const parsed = CreateTaskSchema.parse(args); const taskId = database.createTask(parsed); From bb9ff353ec37c85808eedbb6fae17d6fad18f434 Mon Sep 17 00:00:00 2001 From: srstomp Date: Wed, 4 Feb 2026 10:45:48 +0100 Subject: [PATCH 3/5] fix(ohno-core): fix async test that was causing unhandled rejection The test "should return failures ordered by created_at DESC" was creating a promise without awaiting it, causing the test to complete while the promise was still pending. This left the database in an inconsistent state and caused an "unhandled rejection: Database closed" error. Co-Authored-By: Claude Opus 4.5 --- packages/ohno-core/src/task-failures.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ohno-core/src/task-failures.test.ts b/packages/ohno-core/src/task-failures.test.ts index 249ca59..5ee4ba5 100644 --- a/packages/ohno-core/src/task-failures.test.ts +++ b/packages/ohno-core/src/task-failures.test.ts @@ -91,17 +91,17 @@ describe("Task Failures", () => { expect(reasons).toContain("Second failure"); }); - it("should return failures ordered by created_at DESC", () => { + it("should return failures ordered by created_at DESC", async () => { db.addTaskFailure(taskId, "spec", "First", 1); // Small delay to ensure different timestamps - const delay = new Promise(resolve => setTimeout(resolve, 10)); - delay.then(() => { - db.addTaskFailure(taskId, "quality", "Second", 2); - }); + await new Promise(resolve => setTimeout(resolve, 10)); + db.addTaskFailure(taskId, "quality", "Second", 2); const failures = db.getTaskFailures(taskId); // Most recent should be first - expect(failures.length).toBeGreaterThan(0); + expect(failures.length).toBe(2); + expect(failures[0].failure_reason).toBe("Second"); + expect(failures[1].failure_reason).toBe("First"); }); it("should only return failures for the specified task", () => { From d75c4300c40b471a30930e8a4373250dd3018d36 Mon Sep 17 00:00:00 2001 From: srstomp Date: Wed, 4 Feb 2026 10:49:31 +0100 Subject: [PATCH 4/5] feat(mcp): add record_task_failure MCP method - Add RecordFailureSchema with task_id, failure_type, reason, and optional attempt - Add record_task_failure tool definition to TOOLS array - Implement handler that calls db.addTaskFailure() - Returns { success: true, failure_id: string } - Add comprehensive tests for schema validation and handler - Validates failure_type as enum: spec, quality, or implementation Co-Authored-By: Claude Opus 4.5 --- packages/ohno-mcp/src/server.test.ts | 203 ++++++++++++++++++++++++++- packages/ohno-mcp/src/server.ts | 33 +++++ 2 files changed, 234 insertions(+), 2 deletions(-) diff --git a/packages/ohno-mcp/src/server.test.ts b/packages/ohno-mcp/src/server.test.ts index d5cc06d..2ca860d 100644 --- a/packages/ohno-mcp/src/server.test.ts +++ b/packages/ohno-mcp/src/server.test.ts @@ -33,6 +33,7 @@ import { EpicIdSchema, UpdateEpicSchema, GetEpicsSchema, + RecordFailureSchema, } from "./server.js"; describe("MCP Server", () => { @@ -54,8 +55,8 @@ describe("MCP Server", () => { }); describe("Tool Definitions", () => { - it("should have 28 tools defined", () => { - expect(TOOLS.length).toBe(28); + it("should have 30 tools defined", () => { + expect(TOOLS.length).toBe(30); }); it("should have unique tool names", () => { @@ -676,6 +677,98 @@ describe("MCP Server", () => { expect(result2.limit).toBe(100); }); }); + + describe("RecordFailureSchema", () => { + it("should accept valid failure record with all required fields", () => { + const result = RecordFailureSchema.parse({ + task_id: "task-123", + failure_type: "spec", + reason: "Requirements were unclear", + }); + expect(result.task_id).toBe("task-123"); + expect(result.failure_type).toBe("spec"); + expect(result.reason).toBe("Requirements were unclear"); + }); + + it("should accept all valid failure types", () => { + const validTypes = ["spec", "quality", "implementation"]; + for (const type of validTypes) { + const result = RecordFailureSchema.parse({ + task_id: "task-123", + failure_type: type, + reason: "Test reason", + }); + expect(result.failure_type).toBe(type); + } + }); + + it("should accept optional attempt parameter", () => { + const result = RecordFailureSchema.parse({ + task_id: "task-123", + failure_type: "implementation", + reason: "Failed", + attempt: 2, + }); + expect(result.attempt).toBe(2); + }); + + it("should reject invalid failure_type", () => { + expect(() => + RecordFailureSchema.parse({ + task_id: "task-123", + failure_type: "invalid", + reason: "Test", + }) + ).toThrow(ZodError); + }); + + it("should reject empty task_id", () => { + expect(() => + RecordFailureSchema.parse({ + task_id: "", + failure_type: "spec", + reason: "Test", + }) + ).toThrow(ZodError); + }); + + it("should reject missing task_id", () => { + expect(() => + RecordFailureSchema.parse({ + failure_type: "spec", + reason: "Test", + }) + ).toThrow(ZodError); + }); + + it("should reject missing failure_type", () => { + expect(() => + RecordFailureSchema.parse({ + task_id: "task-123", + reason: "Test", + }) + ).toThrow(ZodError); + }); + + it("should reject missing reason", () => { + expect(() => + RecordFailureSchema.parse({ + task_id: "task-123", + failure_type: "spec", + }) + ).toThrow(ZodError); + }); + + it("should reject empty reason", () => { + expect(() => + RecordFailureSchema.parse({ + task_id: "task-123", + failure_type: "spec", + reason: "", + }) + ).toThrow(ZodError); + }); + }); }); describe("Tool Handlers", () => { @@ -1525,6 +1618,112 @@ describe("MCP Server", () => { }); }); + describe("record_task_failure", () => { + it("should record failure and return failure_id", async () => { + const taskId = db.createTask({ title: "Test task" }); + const result = await handleTool("record_task_failure", { + task_id: taskId, + failure_type: "spec", + reason: "Requirements were unclear", + }) as { success: boolean; failure_id: string }; + + expect(result.success).toBe(true); + expect(result.failure_id).toMatch(/^fail-[a-f0-9]{8}$/); + + // Verify failure was recorded + const failures = db.getTaskFailures(taskId); + expect(failures.length).toBe(1); + expect(failures[0].failure_type).toBe("spec"); + expect(failures[0].failure_reason).toBe("Requirements were unclear"); + }); + + it("should accept all valid failure types", async () => { + const taskId = db.createTask({ title: "Test task" }); + const validTypes = ["spec", "quality", "implementation"]; + + for (const type of validTypes) { + const result = await handleTool("record_task_failure", { + task_id: taskId, + failure_type: type, + reason: `Failure type: ${type}`, + }) as { success: boolean; failure_id: string }; + + expect(result.success).toBe(true); + expect(result.failure_id).toBeDefined(); + } + + const failures = db.getTaskFailures(taskId); + expect(failures.length).toBe(3); + }); + + it("should accept optional attempt parameter", async () => { + const taskId = db.createTask({ title: "Test task" }); + const result = await handleTool("record_task_failure", { + task_id: taskId, + failure_type: "implementation", + reason: "Failed on second attempt", + attempt: 2, + }) as { success: boolean; failure_id: string }; + + expect(result.success).toBe(true); + + const failures = db.getTaskFailures(taskId); + expect(failures.length).toBe(1); + expect(failures[0].attempt).toBe(2); + }); + + it("should reject invalid failure_type", async () => { + const taskId = db.createTask({ title: "Test task" }); + await expect( + handleTool("record_task_failure", { + task_id: taskId, + failure_type: "invalid", + reason: "Test", + }) + ).rejects.toThrow(ZodError); + }); + + it("should reject missing task_id", async () => { + await expect( + handleTool("record_task_failure", { + failure_type: "spec", + reason: "Test", + }) + ).rejects.toThrow(ZodError); + }); + + it("should reject missing failure_type", async () => { + const taskId = db.createTask({ title: "Test task" }); + await expect( + handleTool("record_task_failure", { + task_id: taskId, + reason: "Test", + }) + ).rejects.toThrow(ZodError); + }); + + it("should reject missing reason", async () => { + const taskId = db.createTask({ title: "Test task" }); + await expect( + handleTool("record_task_failure", { + task_id: taskId, + failure_type: "spec", + }) + ).rejects.toThrow(ZodError); + }); + + it("should reject empty reason", async () => { + const taskId = db.createTask({ title: "Test task" }); + await expect( + handleTool("record_task_failure", { + task_id: taskId, + failure_type: "spec", + reason: "", + }) + ).rejects.toThrow(ZodError); + }); + }); + describe("unknown tool", () => { it("should throw error for unknown tool", async () => { await expect(handleTool("unknown_tool", {})).rejects.toThrow("Unknown tool: unknown_tool"); diff --git a/packages/ohno-mcp/src/server.ts b/packages/ohno-mcp/src/server.ts index cccbd63..b637e38 100644 --- a/packages/ohno-mcp/src/server.ts +++ b/packages/ohno-mcp/src/server.ts @@ -102,6 +102,13 @@ const SummarizeSchema = z.object({ delete_raw: z.boolean().default(false), }); +const RecordFailureSchema = z.object({ + task_id: z.string().min(1), + failure_type: z.enum(["spec", "quality", "implementation"]), + reason: z.string().min(1), + attempt: z.number().optional(), +}); + const CreateEpicSchema = z.object({ title: z.string().min(1), project_id: z.string().optional(), @@ -491,6 +498,20 @@ const TOOLS = [ required: ["task_id"], }, }, + { + name: "record_task_failure", + description: "Record a task failure for pattern learning. Stores failure information including type, reason, and optional attempt number.", + inputSchema: { + type: "object" as const, + properties: { + task_id: { type: "string", description: "Task ID" }, + failure_type: { type: "string", enum: ["spec", "quality", "implementation"], description: "Type of failure: spec (requirements issue), quality (quality issue), implementation (technical issue)" }, + reason: { type: "string", description: "Human-readable description of why the task failed" }, + attempt: { type: "number", description: "Optional attempt number" }, + }, + required: ["task_id", "failure_type", "reason"], + }, + }, ]; // Export schemas for testing @@ -517,6 +538,7 @@ export { DependencySchema, RemoveDependencySchema, SummarizeSchema, + RecordFailureSchema, }; // Export tool definitions for testing @@ -775,6 +797,17 @@ export async function handleTool(name: string, args: Record): P return { success: true, summary }; } + case "record_task_failure": { + const parsed = RecordFailureSchema.parse(args); + const failureId = database.addTaskFailure( + parsed.task_id, + parsed.failure_type as "spec" | "quality" | "implementation", + parsed.reason, + parsed.attempt + ); + return { success: true, failure_id: failureId }; + } + default: throw new Error(`Unknown tool: ${name}`); } From f603f1bd26e0e13d690fd256f2c8b9f385747d97 Mon Sep 17 00:00:00 2001 From: srstomp Date: Wed, 4 Feb 2026 10:52:49 +0100 Subject: [PATCH 5/5] feat: add get_next_batch MCP method for batch task retrieval - Add BatchTask interface extending Task with failure_context field - Implement getNextBatch(size) method in TaskDatabase - Returns tasks where status=todo OR needs_rework=1 - Filters out tasks with unmet blockedBy dependencies - Orders by epic priority (P0 first), then created_at - Limits to size (default 3, max 5) - Attaches failure_context from getTaskFailures() for tasks with needs_rework=1 - Add get_next_batch MCP tool with batch_size parameter (default 3, max 5) - Add comprehensive tests for both db method and MCP tool - 15 tests for getNextBatch in db.test.ts - 8 tests for get_next_batch handler in server.test.ts - Schema validation tests for GetNextBatchSchema All tests passing (190 MCP tests, 178 core tests, 122 CLI tests) Co-Authored-By: Claude Opus 4.5 --- packages/ohno-core/src/db.test.ts | 201 +++++++++++++++++++++++++++ packages/ohno-core/src/db.ts | 46 ++++++ packages/ohno-core/src/types.ts | 8 ++ packages/ohno-mcp/src/server.test.ts | 128 ++++++++++++++++- packages/ohno-mcp/src/server.ts | 21 +++ 5 files changed, 402 insertions(+), 2 deletions(-) diff --git a/packages/ohno-core/src/db.test.ts b/packages/ohno-core/src/db.test.ts index 1692d92..6798db1 100644 --- a/packages/ohno-core/src/db.test.ts +++ b/packages/ohno-core/src/db.test.ts @@ -1233,4 +1233,205 @@ describe("TaskDatabase", () => { }); }); }); + + describe("Batch Retrieval", () => { + describe("getNextBatch", () => { + it("should return empty array when no tasks available", () => { + const batch = db.getNextBatch(); + expect(batch).toEqual([]); + }); + + it("should return todo tasks", () => { + db.createTask({ title: "Todo 1" }); + db.createTask({ title: "Todo 2" }); + + const batch = db.getNextBatch(); + expect(batch.length).toBe(2); + expect(batch.every(t => t.status === "todo")).toBe(true); + }); + + it("should return tasks with needs_rework=1", () => { + const task1 = db.createTask({ title: "Task 1" }); + db.updateTaskStatus(task1, "done"); + db.setNeedsRework(task1, true); + + const batch = db.getNextBatch(); + expect(batch.length).toBe(1); + expect(batch[0].id).toBe(task1); + expect(batch[0].needs_rework).toBe(1); + }); + + it("should exclude archived tasks", () => { + const task1 = db.createTask({ title: "Task 1" }); + const task2 = db.createTask({ title: "Task 2" }); + db.archiveTask(task1); + + const batch = db.getNextBatch(); + expect(batch.length).toBe(1); + expect(batch[0].id).toBe(task2); + }); + + it("should exclude in_progress and review tasks", () => { + const task1 = db.createTask({ title: "Task 1" }); + const task2 = db.createTask({ title: "Task 2" }); + const task3 = db.createTask({ title: "Task 3" }); + + db.updateTaskStatus(task1, "in_progress"); + db.updateTaskStatus(task2, "review"); + + const batch = db.getNextBatch(); + expect(batch.length).toBe(1); + expect(batch[0].id).toBe(task3); + }); + + it("should filter out tasks with unmet dependencies", () => { + const task1 = db.createTask({ title: "Task 1" }); + const task2 = db.createTask({ title: "Task 2 - blocked" }); + + db.addDependency(task2, task1); + + const batch = db.getNextBatch(); + expect(batch.length).toBe(1); + expect(batch[0].id).toBe(task1); + }); + + it("should include tasks when dependencies are done", () => { + const task1 = db.createTask({ title: "Task 1" }); + const task2 = db.createTask({ title: "Task 2" }); + + db.addDependency(task2, task1); + db.updateTaskStatus(task1, "done"); + + const batch = db.getNextBatch(); + expect(batch.some(t => t.id === task2)).toBe(true); + }); + + it("should respect default size of 3", () => { + db.createTask({ title: "Task 1" }); + db.createTask({ title: "Task 2" }); + db.createTask({ title: "Task 3" }); + db.createTask({ title: "Task 4" }); + db.createTask({ title: "Task 5" }); + + const batch = db.getNextBatch(); + expect(batch.length).toBe(3); + }); + + it("should respect custom size parameter", () => { + db.createTask({ title: "Task 1" }); + db.createTask({ title: "Task 2" }); + db.createTask({ title: "Task 3" }); + db.createTask({ title: "Task 4" }); + db.createTask({ title: "Task 5" }); + + const batch = db.getNextBatch(2); + expect(batch.length).toBe(2); + }); + + it("should enforce max size of 5", () => { + for (let i = 1; i <= 10; i++) { + db.createTask({ title: `Task ${i}` }); + } + + const batch = db.getNextBatch(10); + expect(batch.length).toBe(5); + }); + + it("should order by epic priority then created_at", () => { + // Create epics with different priorities + const epic1 = db.createEpic({ title: "P2 Epic", priority: "P2" }); + const epic2 = db.createEpic({ title: "P0 Epic", priority: "P0" }); + const epic3 = db.createEpic({ title: "P1 Epic", priority: "P1" }); + + // Create stories + const story1 = db.createStory({ title: "Story 1", epic_id: epic1 }); + const story2 = db.createStory({ title: "Story 2", epic_id: epic2 }); + const story3 = db.createStory({ title: "Story 3", epic_id: epic3 }); + + // Create tasks (order matters for created_at) + const task1 = db.createTask({ title: "P2 Task 1", story_id: story1 }); + const task2 = db.createTask({ title: "P0 Task 1", story_id: story2 }); + const task3 = db.createTask({ title: "P1 Task 1", story_id: story3 }); + const task4 = db.createTask({ title: "P0 Task 2", story_id: story2 }); + + const batch = db.getNextBatch(4); + + // Should be ordered: P0 tasks first, then P1, then P2 + // Within same priority, oldest created_at first + expect(batch[0].id).toBe(task2); // P0, created first + expect(batch[1].id).toBe(task4); // P0, created second + expect(batch[2].id).toBe(task3); // P1 + expect(batch[3].id).toBe(task1); // P2 + }); + + it("should attach failure_context for tasks with needs_rework", () => { + const task1 = db.createTask({ title: "Task 1" }); + db.updateTaskStatus(task1, "done"); + + db.addTaskFailure(task1, "implementation", "Test failed", 1); + db.addTaskFailure(task1, "spec", "Wrong behavior", 2); + db.setNeedsRework(task1, true); + + const batch = db.getNextBatch(); + expect(batch.length).toBe(1); + expect(batch[0].failure_context).toBeDefined(); + expect(batch[0].failure_context?.length).toBe(2); + + // Check that both failure types are present (order may vary) + const failureTypes = batch[0].failure_context?.map(f => f.failure_type); + expect(failureTypes).toContain("spec"); + expect(failureTypes).toContain("implementation"); + }); + + it("should not attach failure_context for tasks without needs_rework", () => { + const task1 = db.createTask({ title: "Task 1" }); + + // Add failures but don't set needs_rework + db.addTaskFailure(task1, "implementation", "Test failed", 1); + + const batch = db.getNextBatch(); + expect(batch.length).toBe(1); + expect(batch[0].failure_context).toBeUndefined(); + }); + + it("should handle mix of todo and needs_rework tasks", () => { + const task1 = db.createTask({ title: "Todo task" }); + const task2 = db.createTask({ title: "Rework task" }); + + db.updateTaskStatus(task2, "done"); + db.addTaskFailure(task2, "implementation", "Failed", 1); + db.setNeedsRework(task2, true); + + const batch = db.getNextBatch(5); + expect(batch.length).toBe(2); + + const todoTask = batch.find(t => t.id === task1); + const reworkTask = batch.find(t => t.id === task2); + + expect(todoTask?.failure_context).toBeUndefined(); + expect(reworkTask?.failure_context).toBeDefined(); + expect(reworkTask?.failure_context?.length).toBe(1); + }); + + it("should handle tasks without epic (null epic_priority)", () => { + // Task without story/epic + const task1 = db.createTask({ title: "Orphan task 1" }); + + // Task with story but no epic + const story1 = db.createStory({ title: "Orphan story" }); + const task2 = db.createTask({ title: "Task with orphan story", story_id: story1 }); + + // Task with epic + const epic1 = db.createEpic({ title: "Epic", priority: "P0" }); + const story2 = db.createStory({ title: "Story", epic_id: epic1 }); + const task3 = db.createTask({ title: "Task with epic", story_id: story2 }); + + const batch = db.getNextBatch(5); + expect(batch.length).toBe(3); + + // Task with P0 epic should come first + expect(batch[0].id).toBe(task3); + }); + }); + }); }); diff --git a/packages/ohno-core/src/db.ts b/packages/ohno-core/src/db.ts index e470db1..c4b5063 100644 --- a/packages/ohno-core/src/db.ts +++ b/packages/ohno-core/src/db.ts @@ -13,6 +13,7 @@ import type { TaskActivity, TaskDependency, TaskFailure, + BatchTask, ProjectStatus, SessionContext, CreateTaskOptions, @@ -346,6 +347,51 @@ export class TaskDatabase { return sorted[0]; } + /** + * Get next batch of tasks ready for execution + * Returns up to N tasks that are either todo or need rework, have no unmet dependencies, + * ordered by epic priority then creation date + * + * @param size - Number of tasks to return (default 3, max 5) + * @returns Array of BatchTask with failure_context attached for tasks needing rework + */ + getNextBatch(size: number = 3): BatchTask[] { + const maxSize = Math.min(size, 5); + + // Get candidates: todo OR needs_rework + const sql = ` + SELECT t.*, e.priority as epic_priority + FROM tasks t + LEFT JOIN stories s ON t.story_id = s.id + LEFT JOIN epics e ON s.epic_id = e.id + WHERE (t.status = 'todo' OR t.needs_rework = 1) + AND t.status != 'archived' + ORDER BY + CASE e.priority + WHEN 'P0' THEN 0 + WHEN 'P1' THEN 1 + WHEN 'P2' THEN 2 + ELSE 3 + END, + t.created_at ASC + `; + + const result = this.db.exec(sql); + const candidates = resultToObjects(result); + + // Filter out blocked tasks + const available = candidates.filter((task) => !this.isTaskBlockedByDependencies(task.id)); + + // Take up to maxSize + const batch = available.slice(0, maxSize); + + // Attach failure context for needs_rework tasks + return batch.map((task) => ({ + ...task, + failure_context: task.needs_rework ? this.getTaskFailures(task.id) : undefined, + })); + } + /** * Get blocked tasks */ diff --git a/packages/ohno-core/src/types.ts b/packages/ohno-core/src/types.ts index b329e3c..0d7917c 100644 --- a/packages/ohno-core/src/types.ts +++ b/packages/ohno-core/src/types.ts @@ -100,6 +100,14 @@ export interface TaskFailure { created_at?: string; } +/** + * Task with failure context attached + * Used for batch retrieval of tasks ready for execution + */ +export interface BatchTask extends Task { + failure_context?: TaskFailure[]; +} + /** * Aggregated project statistics */ diff --git a/packages/ohno-mcp/src/server.test.ts b/packages/ohno-mcp/src/server.test.ts index 2ca860d..748e953 100644 --- a/packages/ohno-mcp/src/server.test.ts +++ b/packages/ohno-mcp/src/server.test.ts @@ -33,6 +33,7 @@ import { EpicIdSchema, UpdateEpicSchema, GetEpicsSchema, + GetNextBatchSchema, RecordFailureSchema, } from "./server.js"; @@ -55,8 +56,8 @@ describe("MCP Server", () => { }); describe("Tool Definitions", () => { - it("should have 30 tools defined", () => { - expect(TOOLS.length).toBe(30); + it("should have 31 tools defined", () => { + expect(TOOLS.length).toBe(31); }); it("should have unique tool names", () => { @@ -81,6 +82,7 @@ describe("MCP Server", () => { "get_tasks", "get_task", "get_next_task", + "get_next_batch", "get_blocked_tasks", "update_task_status", "add_task_activity", @@ -678,6 +680,34 @@ describe("MCP Server", () => { }); }); + describe("GetNextBatchSchema", () => { + it("should accept empty object with default batch_size", () => { + const result = GetNextBatchSchema.parse({}); + expect(result.batch_size).toBe(3); + }); + + it("should accept valid batch_size", () => { + const result = GetNextBatchSchema.parse({ batch_size: 2 }); + expect(result.batch_size).toBe(2); + }); + + it("should validate minimum batch_size", () => { + expect(() => GetNextBatchSchema.parse({ batch_size: 0 })).toThrow(ZodError); + const result = GetNextBatchSchema.parse({ batch_size: 1 }); + expect(result.batch_size).toBe(1); + }); + + it("should validate maximum batch_size", () => { + expect(() => GetNextBatchSchema.parse({ batch_size: 6 })).toThrow(ZodError); + const result = GetNextBatchSchema.parse({ batch_size: 5 }); + expect(result.batch_size).toBe(5); + }); + + it("should reject non-numeric batch_size", () => { + expect(() => GetNextBatchSchema.parse({ batch_size: "3" })).toThrow(ZodError); + }); + }); + describe("RecordFailureSchema", () => { it("should accept valid failure record with all required fields", () => { const result = RecordFailureSchema.parse({ @@ -868,6 +898,100 @@ describe("MCP Server", () => { }); }); + describe("get_next_batch", () => { + it("should return empty batch when no tasks", async () => { + const result = await handleTool("get_next_batch", {}) as { tasks: unknown[]; batch_size: number }; + expect(result.tasks).toEqual([]); + expect(result.batch_size).toBe(0); + }); + + it("should return batch with default size of 3", async () => { + for (let i = 1; i <= 5; i++) { + db.createTask({ title: `Task ${i}` }); + } + + const result = await handleTool("get_next_batch", {}) as { tasks: unknown[]; batch_size: number }; + expect(result.tasks.length).toBe(3); + expect(result.batch_size).toBe(3); + }); + + it("should respect custom batch_size parameter", async () => { + for (let i = 1; i <= 5; i++) { + db.createTask({ title: `Task ${i}` }); + } + + const result = await handleTool("get_next_batch", { batch_size: 2 }) as { tasks: unknown[]; batch_size: number }; + expect(result.tasks.length).toBe(2); + expect(result.batch_size).toBe(2); + }); + + it("should enforce max batch_size of 5 via validation", async () => { + for (let i = 1; i <= 10; i++) { + db.createTask({ title: `Task ${i}` }); + } + + // Should throw ZodError for batch_size > 5 + await expect(handleTool("get_next_batch", { batch_size: 10 })).rejects.toThrow(ZodError); + + // Should work with batch_size = 5 + const result = await handleTool("get_next_batch", { batch_size: 5 }) as { tasks: unknown[]; batch_size: number }; + expect(result.tasks.length).toBe(5); + expect(result.batch_size).toBe(5); + }); + + it("should include tasks with needs_rework=1", async () => { + const task1 = db.createTask({ title: "Rework task" }); + db.updateTaskStatus(task1, "done"); + db.setNeedsRework(task1, true); + + const result = await handleTool("get_next_batch", {}) as { tasks: Array<{ id: string }> }; + expect(result.tasks.length).toBe(1); + expect(result.tasks[0].id).toBe(task1); + }); + + it("should attach failure_context for tasks with needs_rework", async () => { + const task1 = db.createTask({ title: "Failed task" }); + db.updateTaskStatus(task1, "done"); + db.addTaskFailure(task1, "implementation", "Test failed", 1); + db.setNeedsRework(task1, true); + + const result = await handleTool("get_next_batch", {}) as { + tasks: Array<{ failure_context?: Array<{ failure_reason: string }> }>; + }; + + expect(result.tasks.length).toBe(1); + expect(result.tasks[0].failure_context).toBeDefined(); + expect(result.tasks[0].failure_context?.length).toBe(1); + expect(result.tasks[0].failure_context?.[0].failure_reason).toBe("Test failed"); + }); + + it("should exclude tasks with unmet dependencies", async () => { + const task1 = db.createTask({ title: "Task 1" }); + const task2 = db.createTask({ title: "Task 2 - blocked" }); + db.addDependency(task2, task1); + + const result = await handleTool("get_next_batch", {}) as { tasks: Array<{ id: string }> }; + expect(result.tasks.length).toBe(1); + expect(result.tasks[0].id).toBe(task1); + }); + + it("should order by epic priority", async () => { + const epic1 = db.createEpic({ title: "P2 Epic", priority: "P2" }); + const epic2 = db.createEpic({ title: "P0 Epic", priority: "P0" }); + + const story1 = db.createStory({ title: "Story 1", epic_id: epic1 }); + const story2 = db.createStory({ title: "Story 2", epic_id: epic2 }); + + const task1 = db.createTask({ title: "P2 Task", story_id: story1 }); + const task2 = db.createTask({ title: "P0 Task", story_id: story2 }); + + const result = await handleTool("get_next_batch", {}) as { tasks: Array<{ id: string }> }; + expect(result.tasks.length).toBe(2); + expect(result.tasks[0].id).toBe(task2); // P0 first + expect(result.tasks[1].id).toBe(task1); // P2 second + }); + }); + describe("get_blocked_tasks", () => { it("should return empty list when no blocked tasks", async () => { const result = await handleTool("get_blocked_tasks", {}) as { tasks: unknown[] }; diff --git a/packages/ohno-mcp/src/server.ts b/packages/ohno-mcp/src/server.ts index b637e38..85e8dc6 100644 --- a/packages/ohno-mcp/src/server.ts +++ b/packages/ohno-mcp/src/server.ts @@ -158,6 +158,10 @@ const KanbanBoardSchema = z.object({ limit_per_column: z.number().min(1).max(50).default(20), }); +const GetNextBatchSchema = z.object({ + batch_size: z.number().min(1).max(5).default(3), +}); + // Tool definitions const TOOLS = [ { @@ -201,6 +205,16 @@ const TOOLS = [ description: "Get the recommended next task to work on based on priority and dependencies", inputSchema: { type: "object" as const, properties: {} }, }, + { + name: "get_next_batch", + description: "Get a batch of up to N tasks ready for immediate execution. Returns tasks with status=todo or needs_rework=1, excluding tasks with unmet dependencies. Tasks are ordered by epic priority (P0 first) then creation date. Tasks needing rework include failure_context with previous failure details.", + inputSchema: { + type: "object" as const, + properties: { + batch_size: { type: "number", description: "Number of tasks to return (1-5, default 3)", default: 3 }, + }, + }, + }, { name: "get_blocked_tasks", description: "Get all blocked tasks with their blocker reasons", @@ -529,6 +543,7 @@ export { UpdateEpicSchema, GetEpicsSchema, KanbanBoardSchema, + GetNextBatchSchema, UpdateTaskSchema, ActivitySchema, HandoffNotesSchema, @@ -600,6 +615,12 @@ export async function handleTool(name: string, args: Record): P return task; } + case "get_next_batch": { + const parsed = GetNextBatchSchema.parse(args); + const tasks = database.getNextBatch(parsed.batch_size); + return { tasks, batch_size: tasks.length }; + } + case "get_blocked_tasks": return { tasks: database.getBlockedTasks() };