From 8c854d9237ac79fe7dedc130bdaeb8ca64d25c2d Mon Sep 17 00:00:00 2001 From: Greg Jackson Date: Sat, 14 Mar 2026 21:13:27 +0000 Subject: [PATCH 1/4] feat: add BoardIntent and BoardView types for get_board tool Co-Authored-By: Claude Opus 4.6 (1M context) --- src/types.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/types.ts b/src/types.ts index 6d0ff0c..54dff14 100644 --- a/src/types.ts +++ b/src/types.ts @@ -98,3 +98,18 @@ export interface Overview { recently_completed: Intent[]; blocked_intents: Array; } + +export interface BoardIntent { + id: string; + title: string; + priority: IntentPriority; + team_id: string | null; + claimed_by: string | null; + claim_id: string | null; + blocked_by: string[]; +} + +export interface BoardView { + columns: Partial>; + summary: Partial>; +} From 45daee3f0e609e6ec4022dc51bc755bb20743878 Mon Sep 17 00:00:00 2001 From: Greg Jackson Date: Sat, 14 Mar 2026 21:15:42 +0000 Subject: [PATCH 2/4] test: add failing tests for db.getBoard Add 6 tests in a new 'Board View' describe block covering: - empty board returns zero counts - intents grouped into correct status columns - claimed intents include claimed_by and claim_id - blocked intents include blocked_by - team_id filtering - draft exclusion from all columns Tests expect to fail with "db.getBoard is not a function" until the query is implemented. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/overview.test.ts | 110 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 106 insertions(+), 4 deletions(-) diff --git a/tests/overview.test.ts b/tests/overview.test.ts index 2bfb975..a5ae9d2 100644 --- a/tests/overview.test.ts +++ b/tests/overview.test.ts @@ -12,10 +12,6 @@ describe('Team Status & Overview', () => { await seedTeam(); }); - afterAll(async () => { - await teardownTestDb(); - }); - it('returns team status with intents grouped by status', async () => { const intent = await seedOpenIntent({ title: 'Open task' }); const intent2 = await seedOpenIntent({ title: 'Claimed task' }); @@ -99,3 +95,109 @@ describe('Team Status & Overview', () => { expect(overview.recently_completed[0].title).toBe('Will complete'); }); }); + +describe('Board View', () => { + beforeAll(async () => { + await setupTestDb(); + }); + + beforeEach(async () => { + await cleanTestDb(); + await seedTeam(); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + it('returns empty columns with zero counts when no intents exist', async () => { + const board = await db.getBoard(); + + expect(board.columns.open).toEqual([]); + expect(board.columns.claimed).toEqual([]); + expect(board.columns.blocked).toEqual([]); + expect(board.columns.done).toEqual([]); + expect(board.columns.cancelled).toEqual([]); + expect(board.summary.open).toBe(0); + expect(board.summary.claimed).toBe(0); + expect(board.summary.blocked).toBe(0); + expect(board.summary.done).toBe(0); + expect(board.summary.cancelled).toBe(0); + }); + + it('groups intents into correct status columns', async () => { + const openIntent = await seedOpenIntent({ title: 'Open task' }); + const claimedIntent = await seedOpenIntent({ title: 'Claimed task' }); + await db.claimWork({ intent_id: claimedIntent.id as string, claimed_by: 'alice' }); + + const board = await db.getBoard(); + + expect(board.columns.open).toHaveLength(1); + expect(board.columns.open![0].title).toBe('Open task'); + expect(board.columns.claimed).toHaveLength(1); + expect(board.columns.claimed![0].title).toBe('Claimed task'); + }); + + it('includes claimed_by and claim_id for claimed intents', async () => { + const intent = await seedOpenIntent({ title: 'In progress' }); + const { claim } = await db.claimWork({ + intent_id: intent.id as string, + claimed_by: 'pawel', + }); + + const board = await db.getBoard(); + + const claimedCard = board.columns.claimed![0]; + expect(claimedCard.claimed_by).toBe('pawel'); + expect(claimedCard.claim_id).toBe(claim.id); + }); + + it('includes blocked_by for blocked intents', async () => { + const blocker = await seedOpenIntent({ title: 'Blocker' }); + + await testQuery( + `INSERT INTO intents (title, created_by, team_id, status, priority, acceptance_criteria) + VALUES ('Blocked task', 'alice', 'backend', 'blocked', 'medium', '["Done"]') RETURNING *` + ); + const blockedRes = await testQuery( + `SELECT id FROM intents WHERE title = 'Blocked task'` + ); + const blockedId = blockedRes.rows[0].id; + await testQuery( + 'INSERT INTO intent_dependencies (intent_id, depends_on) VALUES ($1, $2)', + [blockedId, blocker.id] + ); + + const board = await db.getBoard(); + + expect(board.columns.blocked).toHaveLength(1); + expect(board.columns.blocked![0].blocked_by).toContain(blocker.id); + }); + + it('filters by team_id when provided', async () => { + await seedTeam('frontend', 'Frontend Team'); + await seedOpenIntent({ title: 'Backend task', team_id: 'backend' }); + await seedOpenIntent({ title: 'Frontend task', team_id: 'frontend' }); + + const board = await db.getBoard('frontend'); + + expect(board.columns.open).toHaveLength(1); + expect(board.columns.open![0].title).toBe('Frontend task'); + }); + + it('excludes drafts from all columns', async () => { + await testQuery( + `INSERT INTO intents (title, created_by, team_id, status, priority, acceptance_criteria) + VALUES ('Draft task', 'alice', 'backend', 'draft', 'medium', '["Done"]')` + ); + await seedOpenIntent({ title: 'Open task' }); + + const board = await db.getBoard(); + + const allCards = Object.values(board.columns).flat(); + expect(allCards.every(c => c.title !== 'Draft task')).toBe(true); + expect(board.columns.open).toHaveLength(1); + expect(board.columns).not.toHaveProperty('draft'); + expect(board.summary).not.toHaveProperty('draft'); + }); +}); From bff208bc13433837ac014e9c259dd13bdf8f916f Mon Sep 17 00:00:00 2001 From: Greg Jackson Date: Sat, 14 Mar 2026 21:22:21 +0000 Subject: [PATCH 3/4] feat: implement getBoard query function Co-Authored-By: Claude Opus 4.6 (1M context) --- src/db/queries.ts | 75 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/db/queries.ts b/src/db/queries.ts index a1aaa80..398efef 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -3,6 +3,7 @@ import type { Team, Intent, IntentWithRelations, Claim, Signal, ConflictWarning, ContextPackage, TeamStatus, Overview, IntentStatus, IntentPriority, SignalType, + BoardIntent, BoardView, } from '../types.js'; // ─── Teams ────────────────────────────────────────────── @@ -703,3 +704,77 @@ export async function getOverview(): Promise { blocked_intents: blockedRes.rows, }; } + +// ─── Board View ────────────────────────────────────────── + +export async function getBoard(teamId?: string): Promise { + // Main query: all non-draft intents LEFT JOIN active claims + const teamFilter = teamId ? `AND i.team_id = $1` : ''; + const params: unknown[] = teamId ? [teamId] : []; + + const intentsRes = await query<{ + id: string; + title: string; + priority: IntentPriority; + team_id: string | null; + status: IntentStatus; + claimed_by: string | null; + claim_id: string | null; + }>( + `SELECT i.id, i.title, i.priority, i.team_id, i.status, + c.claimed_by, c.id as claim_id + FROM intents i + LEFT JOIN claims c ON c.intent_id = i.id AND c.status = 'active' + WHERE i.status != 'draft' ${teamFilter} + ORDER BY i.priority, i.created_at DESC`, + params + ); + + // Secondary query: blocked dependencies (intent_id -> depends_on where dep is not done) + const blockedDepsRes = await query<{ intent_id: string; depends_on: string }>( + `SELECT d.intent_id, d.depends_on + FROM intent_dependencies d + JOIN intents dep ON dep.id = d.depends_on + JOIN intents i ON i.id = d.intent_id + WHERE dep.status != 'done' + AND i.status = 'blocked'` + ); + + // Build blocked_by lookup: intent_id -> [dependency IDs] + const blockedByMap = new Map(); + for (const row of blockedDepsRes.rows) { + const existing = blockedByMap.get(row.intent_id) ?? []; + existing.push(row.depends_on); + blockedByMap.set(row.intent_id, existing); + } + + // Group into columns + const columns: Record = { + open: [], claimed: [], blocked: [], done: [], cancelled: [], + }; + for (const row of intentsRes.rows) { + const card: BoardIntent = { + id: row.id, + title: row.title, + priority: row.priority, + team_id: row.team_id, + claimed_by: row.claimed_by ?? null, + claim_id: row.claim_id ?? null, + blocked_by: blockedByMap.get(row.id) ?? [], + }; + if (columns[row.status]) { + columns[row.status].push(card); + } + } + + // Build summary counts + const summary: Record = {}; + for (const [status, cards] of Object.entries(columns)) { + summary[status] = cards.length; + } + + return { + columns: columns as BoardView['columns'], + summary: summary as BoardView['summary'], + }; +} From df84e0cd099d55186d902f09e24bffec4f24f1d5 Mon Sep 17 00:00:00 2001 From: Greg Jackson Date: Sat, 14 Mar 2026 22:37:49 +0000 Subject: [PATCH 4/4] feat: register get_board MCP tool and tighten BoardView types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up get_board tool in overview.ts (optional team_id filter). Replace Partial> with Record using Exclude — makes the draft-exclusion contract explicit at the type level and eliminates non-null assertions in consumers. Closes #6 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/db/queries.ts | 24 ++++++++++++------------ src/tools/overview.ts | 12 ++++++++++++ src/types.ts | 6 ++++-- tests/overview.test.ts | 10 +++++----- 4 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/db/queries.ts b/src/db/queries.ts index 398efef..94596e4 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -3,7 +3,7 @@ import type { Team, Intent, IntentWithRelations, Claim, Signal, ConflictWarning, ContextPackage, TeamStatus, Overview, IntentStatus, IntentPriority, SignalType, - BoardIntent, BoardView, + BoardIntent, BoardView, BoardStatus, } from '../types.js'; // ─── Teams ────────────────────────────────────────────── @@ -749,7 +749,7 @@ export async function getBoard(teamId?: string): Promise { } // Group into columns - const columns: Record = { + const columns: Record = { open: [], claimed: [], blocked: [], done: [], cancelled: [], }; for (const row of intentsRes.rows) { @@ -762,19 +762,19 @@ export async function getBoard(teamId?: string): Promise { claim_id: row.claim_id ?? null, blocked_by: blockedByMap.get(row.id) ?? [], }; - if (columns[row.status]) { - columns[row.status].push(card); + if (row.status in columns) { + columns[row.status as BoardStatus].push(card); } } // Build summary counts - const summary: Record = {}; - for (const [status, cards] of Object.entries(columns)) { - summary[status] = cards.length; - } - - return { - columns: columns as BoardView['columns'], - summary: summary as BoardView['summary'], + const summary: Record = { + open: columns.open.length, + claimed: columns.claimed.length, + blocked: columns.blocked.length, + done: columns.done.length, + cancelled: columns.cancelled.length, }; + + return { columns, summary }; } diff --git a/src/tools/overview.ts b/src/tools/overview.ts index 0fb6b09..76b610e 100644 --- a/src/tools/overview.ts +++ b/src/tools/overview.ts @@ -48,4 +48,16 @@ export function registerOverviewTools(server: McpServer): void { return { content: [{ type: 'text', text: JSON.stringify(overview, null, 2) }] }; } ); + + server.tool( + 'get_board', + 'Get kanban board view — all intents grouped by status column with active claims and blocked dependencies. Excludes drafts.', + { + team_id: z.string().optional().describe('Filter to a single team. Omit for cross-team board.'), + }, + async ({ team_id }) => { + const board = await db.getBoard(team_id); + return { content: [{ type: 'text', text: JSON.stringify(board, null, 2) }] }; + } + ); } diff --git a/src/types.ts b/src/types.ts index 54dff14..36ded46 100644 --- a/src/types.ts +++ b/src/types.ts @@ -109,7 +109,9 @@ export interface BoardIntent { blocked_by: string[]; } +export type BoardStatus = Exclude; + export interface BoardView { - columns: Partial>; - summary: Partial>; + columns: Record; + summary: Record; } diff --git a/tests/overview.test.ts b/tests/overview.test.ts index a5ae9d2..b8cf02a 100644 --- a/tests/overview.test.ts +++ b/tests/overview.test.ts @@ -133,9 +133,9 @@ describe('Board View', () => { const board = await db.getBoard(); expect(board.columns.open).toHaveLength(1); - expect(board.columns.open![0].title).toBe('Open task'); + expect(board.columns.open[0].title).toBe('Open task'); expect(board.columns.claimed).toHaveLength(1); - expect(board.columns.claimed![0].title).toBe('Claimed task'); + expect(board.columns.claimed[0].title).toBe('Claimed task'); }); it('includes claimed_by and claim_id for claimed intents', async () => { @@ -147,7 +147,7 @@ describe('Board View', () => { const board = await db.getBoard(); - const claimedCard = board.columns.claimed![0]; + const claimedCard = board.columns.claimed[0]; expect(claimedCard.claimed_by).toBe('pawel'); expect(claimedCard.claim_id).toBe(claim.id); }); @@ -171,7 +171,7 @@ describe('Board View', () => { const board = await db.getBoard(); expect(board.columns.blocked).toHaveLength(1); - expect(board.columns.blocked![0].blocked_by).toContain(blocker.id); + expect(board.columns.blocked[0].blocked_by).toContain(blocker.id); }); it('filters by team_id when provided', async () => { @@ -182,7 +182,7 @@ describe('Board View', () => { const board = await db.getBoard('frontend'); expect(board.columns.open).toHaveLength(1); - expect(board.columns.open![0].title).toBe('Frontend task'); + expect(board.columns.open[0].title).toBe('Frontend task'); }); it('excludes drafts from all columns', async () => {