diff --git a/.veritas-kanban/projects.json b/.veritas-kanban/projects.json index f22df67b..94a72012 100644 --- a/.veritas-kanban/projects.json +++ b/.veritas-kanban/projects.json @@ -1,11 +1,11 @@ [ { "id": "brain", - "label": "🧠 Knowledge Base", + "label": "🧠 Knowledge", "color": "bg-green-500/20", "order": 1, "created": "2026-01-28T04:30:34.608Z", - "updated": "2026-01-28T04:41:00.155Z" + "updated": "2026-01-28T10:28:09.303Z" }, { "id": "platform", @@ -17,11 +17,11 @@ }, { "id": "veritas-kanban", - "label": "šŸ¤– veritas-kanban", + "label": "šŸ¤– Kanban", "color": "bg-orange-500/20", "order": 3, "created": "2026-01-28T04:30:34.608Z", - "updated": "2026-01-28T04:41:18.261Z", + "updated": "2026-01-28T10:27:55.479Z", "description": "" }, { diff --git a/.veritas-kanban/task-types.json b/.veritas-kanban/task-types.json index ded3f51c..95f10b72 100644 --- a/.veritas-kanban/task-types.json +++ b/.veritas-kanban/task-types.json @@ -74,5 +74,14 @@ "order": 7, "created": "2026-01-28T08:31:13.845Z", "updated": "2026-01-28T08:31:41.818Z" + }, + { + "label": "Refactor", + "icon": "Package", + "color": "border-l-yellow-400", + "id": "refactor-saXoty", + "order": 8, + "created": "2026-01-28T10:27:13.983Z", + "updated": "2026-01-28T10:27:33.257Z" } ] \ No newline at end of file diff --git a/.veritas-kanban/telemetry/events-2026-01-28.ndjson b/.veritas-kanban/telemetry/events-2026-01-28.ndjson index d1273a82..78860660 100644 --- a/.veritas-kanban/telemetry/events-2026-01-28.ndjson +++ b/.veritas-kanban/telemetry/events-2026-01-28.ndjson @@ -535,3 +535,62 @@ {"type":"task.status_changed","taskId":"task_20260128_8HU2XD","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_4rB1Mba649ZE","timestamp":"2026-01-28T10:03:19.726Z"} {"type":"task.status_changed","taskId":"task_20260128_8HU2XD","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_v40bF7Xzb8sF","timestamp":"2026-01-28T10:05:18.411Z"} {"type":"task.status_changed","taskId":"task_20260128_F-XLhL","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_pO7WDomJvI6K","timestamp":"2026-01-28T10:05:29.028Z"} +{"type":"task.status_changed","taskId":"task_20260128_F-XLhL","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_0qPmbpsm4XLb","timestamp":"2026-01-28T10:09:02.221Z"} +{"type":"task.archived","taskId":"task_20260128_F-XLhL","project":"veritas-kanban","status":"done","id":"evt_sAAFqdT13Z3t","timestamp":"2026-01-28T10:29:11.930Z"} +{"type":"task.archived","taskId":"task_20260128_8HU2XD","project":"veritas-kanban","status":"done","id":"evt_hpjGBnt06qoe","timestamp":"2026-01-28T10:29:11.936Z"} +{"type":"task.archived","taskId":"task_20260128_CnogQO","project":"veritas-kanban","status":"done","id":"evt_a6ZN8YTCPAjV","timestamp":"2026-01-28T10:29:11.940Z"} +{"type":"task.archived","taskId":"task_20260128_-KNOy3","project":"veritas-kanban","status":"done","id":"evt_6U9P-_9DvO7B","timestamp":"2026-01-28T10:29:11.944Z"} +{"type":"task.archived","taskId":"task_20260128_6SbOnv","project":"veritas-kanban","status":"done","id":"evt_zZkjizRzYmrA","timestamp":"2026-01-28T10:29:11.949Z"} +{"type":"task.archived","taskId":"task_20260128_NrvUWe","project":"veritas-kanban","status":"done","id":"evt_kFivXp6pBFX2","timestamp":"2026-01-28T10:29:11.953Z"} +{"type":"task.archived","taskId":"task_20260128_SFb0-u","project":"veritas-kanban","status":"done","id":"evt_z1-nQikB754G","timestamp":"2026-01-28T10:29:11.956Z"} +{"type":"task.archived","taskId":"task_20260128_Q78_6y","project":"veritas-kanban","status":"done","id":"evt_QyI4Nn4DmM3T","timestamp":"2026-01-28T10:29:11.958Z"} +{"type":"task.archived","taskId":"task_20260128_XvmMga","project":"brain","status":"done","id":"evt_7FIIo19FoxHM","timestamp":"2026-01-28T10:29:45.336Z"} +{"type":"task.archived","taskId":"task_20260128_8KRThU","project":"veritas-kanban","status":"done","id":"evt_o6SR71L1HpMP","timestamp":"2026-01-28T10:29:45.336Z"} +{"type":"task.archived","taskId":"task_20260128_iG-ISb","project":"platform","status":"done","id":"evt_6AOv5VehaRju","timestamp":"2026-01-28T10:29:45.336Z"} +{"type":"task.created","taskId":"task_20260128_v5QxzF","project":"veritas-kanban","status":"todo","id":"evt_1jTx0Bq7Kmi1","timestamp":"2026-01-28T10:44:54.237Z"} +{"type":"task.created","taskId":"task_20260128_jS7k9o","project":"veritas-kanban","status":"todo","id":"evt_t7ODPkWGDVDL","timestamp":"2026-01-28T10:44:54.252Z"} +{"type":"task.created","taskId":"task_20260128_XIY4cW","project":"veritas-kanban","status":"todo","id":"evt_4tHbT__j5xxI","timestamp":"2026-01-28T10:44:54.256Z"} +{"type":"task.created","taskId":"task_20260128_eib_Vw","project":"veritas-kanban","status":"todo","id":"evt_lNMtAeCY9jBq","timestamp":"2026-01-28T10:44:54.259Z"} +{"type":"task.created","taskId":"task_20260128_HtCf-y","project":"veritas-kanban","status":"todo","id":"evt_u-LROX2DsU0X","timestamp":"2026-01-28T10:44:54.263Z"} +{"type":"task.created","taskId":"task_20260128_80Bww2","project":"veritas-kanban","status":"todo","id":"evt_zi0vWyh98OrI","timestamp":"2026-01-28T10:44:54.266Z"} +{"type":"task.created","taskId":"task_20260128_-8OO8l","project":"veritas-kanban","status":"todo","id":"evt_oofbYVVrTwKl","timestamp":"2026-01-28T10:44:54.270Z"} +{"type":"task.created","taskId":"task_20260128_hfzlfd","project":"veritas-kanban","status":"todo","id":"evt_kmKBp7EE3HH6","timestamp":"2026-01-28T10:44:54.273Z"} +{"type":"task.created","taskId":"task_20260128_wgJpvX","project":"veritas-kanban","status":"todo","id":"evt_JjhVIUair2Ty","timestamp":"2026-01-28T10:44:54.277Z"} +{"type":"task.created","taskId":"task_20260128_IWTxzN","project":"veritas-kanban","status":"todo","id":"evt_4aCh0AEU1S9_","timestamp":"2026-01-28T10:44:54.281Z"} +{"type":"task.created","taskId":"task_20260128_UMDZyh","project":"veritas-kanban","status":"todo","id":"evt_NRz5XBdc7z3J","timestamp":"2026-01-28T10:44:54.284Z"} +{"type":"task.created","taskId":"task_20260128_VnO-L8","project":"veritas-kanban","status":"todo","id":"evt_aY6TNKXKGXCY","timestamp":"2026-01-28T10:44:54.287Z"} +{"type":"task.created","taskId":"task_20260128_fylTjw","project":"veritas-kanban","status":"todo","id":"evt_EzBJX5gmaWpM","timestamp":"2026-01-28T10:44:54.290Z"} +{"type":"task.created","taskId":"task_20260128_bIPwKK","project":"veritas-kanban","status":"todo","id":"evt_uppmWo7g1XR4","timestamp":"2026-01-28T10:44:54.293Z"} +{"type":"task.created","taskId":"task_20260128_1yHkWF","project":"veritas-kanban","status":"todo","id":"evt_xHZsqywicMEu","timestamp":"2026-01-28T10:44:54.295Z"} +{"type":"task.created","taskId":"task_20260128_9ljJmb","project":"veritas-kanban","status":"todo","id":"evt_MwgoSSwo_m92","timestamp":"2026-01-28T10:44:54.296Z"} +{"type":"task.created","taskId":"task_20260128_q-NnH4","project":"veritas-kanban","status":"todo","id":"evt_i4ysKEBkfDQD","timestamp":"2026-01-28T10:44:54.300Z"} +{"type":"task.created","taskId":"task_20260128_OFkc0Z","project":"veritas-kanban","status":"todo","id":"evt_unvqZpKzJX3O","timestamp":"2026-01-28T10:44:54.302Z"} +{"type":"task.created","taskId":"task_20260128_lePtYQ","project":"veritas-kanban","status":"todo","id":"evt_aMpsytSZMOo6","timestamp":"2026-01-28T10:44:54.304Z"} +{"type":"task.created","taskId":"task_20260128_ksQUvs","project":"veritas-kanban","status":"todo","id":"evt_N83fRTOYEBHG","timestamp":"2026-01-28T10:44:54.306Z"} +{"type":"task.created","taskId":"task_20260128_DmkA14","project":"veritas-kanban","status":"todo","id":"evt_OJ8EtGRv9V6-","timestamp":"2026-01-28T10:44:54.309Z"} +{"type":"task.created","taskId":"task_20260128_Qh60Ao","project":"veritas-kanban","status":"todo","id":"evt_V6kWTM2LC0ie","timestamp":"2026-01-28T10:44:54.311Z"} +{"type":"task.created","taskId":"task_20260128_WYNpAQ","project":"veritas-kanban","status":"todo","id":"evt_P9fBuSg3csVc","timestamp":"2026-01-28T10:44:54.314Z"} +{"type":"task.created","taskId":"task_20260128_XAxJXn","project":"veritas-kanban","status":"todo","id":"evt_MikNl2Svcwmt","timestamp":"2026-01-28T10:44:54.317Z"} +{"type":"task.created","taskId":"task_20260128_oeprdJ","project":"veritas-kanban","status":"todo","id":"evt_butwC_KmOMmn","timestamp":"2026-01-28T10:44:54.321Z"} +{"type":"task.status_changed","taskId":"task_20260128_fylTjw","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_WcXGWzWrcU90","timestamp":"2026-01-28T10:51:55.037Z"} +{"type":"task.status_changed","taskId":"task_20260128_IWTxzN","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_rnZ6ccqWwc9r","timestamp":"2026-01-28T10:52:57.896Z"} +{"type":"task.status_changed","taskId":"task_20260128_jS7k9o","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_4go0YTJfit_y","timestamp":"2026-01-28T10:53:54.241Z"} +{"type":"task.status_changed","taskId":"task_20260128_HtCf-y","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_wN4WxdLBFIkK","timestamp":"2026-01-28T10:54:03.660Z"} +{"type":"task.status_changed","taskId":"task_20260128_80Bww2","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_K24bfiTBA_yS","timestamp":"2026-01-28T10:55:22.842Z"} +{"type":"task.status_changed","taskId":"task_20260128_UMDZyh","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_lzdyWjezvrPc","timestamp":"2026-01-28T10:55:23.912Z"} +{"type":"task.status_changed","taskId":"task_20260128_q-NnH4","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_r2wQhOyoD5M8","timestamp":"2026-01-28T10:55:25.591Z"} +{"type":"task.status_changed","taskId":"task_20260128_OFkc0Z","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_w3u7pnJjO9du","timestamp":"2026-01-28T10:55:25.797Z"} +{"type":"task.status_changed","taskId":"task_20260128_9ljJmb","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_o1z5wYacm1EU","timestamp":"2026-01-28T10:55:27.572Z"} +{"type":"task.status_changed","taskId":"task_20260128_OFkc0Z","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_7ClmHA-F1aDL","timestamp":"2026-01-28T10:57:25.934Z"} +{"type":"task.status_changed","taskId":"task_20260128_9ljJmb","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_CbrCzzIFQ7p2","timestamp":"2026-01-28T10:57:27.748Z"} +{"type":"task.status_changed","taskId":"task_20260128_q-NnH4","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_8ndm1pEvWOOn","timestamp":"2026-01-28T10:57:59.076Z"} +{"type":"task.status_changed","taskId":"task_20260128_WYNpAQ","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt__vAuEl4fjCtO","timestamp":"2026-01-28T10:58:54.952Z"} +{"type":"task.status_changed","taskId":"task_20260128_DmkA14","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_K9gpEtMdckR6","timestamp":"2026-01-28T10:58:56.244Z"} +{"type":"task.status_changed","taskId":"task_20260128_lePtYQ","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_5-z0bxNtXB0t","timestamp":"2026-01-28T11:00:22.880Z"} +{"type":"task.status_changed","taskId":"task_20260128_bIPwKK","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_ge6c13Z6uuYt","timestamp":"2026-01-28T11:00:23.288Z"} +{"type":"task.status_changed","taskId":"task_20260128_WYNpAQ","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_sqshClbzr6UD","timestamp":"2026-01-28T11:00:55.417Z"} +{"type":"task.created","taskId":"task_20260128_XP9oDM","project":"brain","status":"todo","id":"evt_syQ4LV-O9a8d","timestamp":"2026-01-28T11:01:28.501Z"} +{"type":"task.status_changed","taskId":"task_20260128_DmkA14","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_6F-GefLq-Npu","timestamp":"2026-01-28T11:01:29.736Z"} +{"type":"task.status_changed","taskId":"task_20260128_lePtYQ","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_3Iyqk4tXewur","timestamp":"2026-01-28T11:03:09.268Z"} +{"type":"task.status_changed","taskId":"task_20260128_80Bww2","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_lhxfDcirN7Nm","timestamp":"2026-01-28T11:03:10.942Z"} +{"type":"task.status_changed","taskId":"task_20260128_bIPwKK","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_qF5oHf8YG7Ha","timestamp":"2026-01-28T11:07:35.701Z"} diff --git a/.veritas-kanban/worktrees/task_20260126_z6KAvr b/.veritas-kanban/worktrees/task_20260126_z6KAvr deleted file mode 160000 index 88bf96a7..00000000 --- a/.veritas-kanban/worktrees/task_20260126_z6KAvr +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 88bf96a77f887086d7d88a535ceb44deb512a469 diff --git a/cli/package.json b/cli/package.json index 7f5de38e..67197763 100644 --- a/cli/package.json +++ b/cli/package.json @@ -12,6 +12,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@veritas-kanban/shared": "workspace:*", "commander": "^12.1.0", "chalk": "^5.3.0" }, diff --git a/cli/src/commands/agents.ts b/cli/src/commands/agents.ts new file mode 100644 index 00000000..7499033e --- /dev/null +++ b/cli/src/commands/agents.ts @@ -0,0 +1,189 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { api } from '../utils/api.js'; +import { findTask } from '../utils/find.js'; +import type { Task } from '../utils/types.js'; + +export function registerAgentCommands(program: Command): void { + // Start agent on task + program + .command('start ') + .description('Start an agent on a task') + .option('-a, --agent ', 'Agent to use (claude-code, amp, copilot, gemini)', 'claude-code') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + try { + const task = await findTask(id); + + if (!task) { + console.error(chalk.red(`Task not found: ${id}`)); + process.exit(1); + } + + if (task.type !== 'code') { + console.error(chalk.red('Can only start agents on code tasks')); + process.exit(1); + } + + if (!task.git?.worktreePath) { + console.error(chalk.red('Task needs a worktree first. Create one via the UI.')); + process.exit(1); + } + + const result = await api<{ attemptId: string }>(`/api/agents/${task.id}/start`, { + method: 'POST', + body: JSON.stringify({ agent: options.agent }), + }); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(chalk.green(`āœ“ Agent started: ${options.agent}`)); + console.log(chalk.dim(`Attempt ID: ${result.attemptId}`)); + console.log(chalk.dim(`Working in: ${task.git.worktreePath}`)); + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // Stop agent + program + .command('stop ') + .description('Stop a running agent') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + try { + const task = await findTask(id); + + if (!task) { + console.error(chalk.red(`Task not found: ${id}`)); + process.exit(1); + } + + await api(`/api/agents/${task.id}/stop`, { method: 'POST' }); + + if (options.json) { + console.log(JSON.stringify({ stopped: true })); + } else { + console.log(chalk.yellow('āœ“ Agent stopped')); + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // Get pending agent requests (for Veritas to process) + program + .command('agents:pending') + .description('List pending agent requests waiting for Clawdbot to process') + .option('--json', 'Output as JSON') + .action(async (options) => { + try { + const pending = await api<{ + taskId: string; + attemptId: string; + prompt: string; + requestedAt: string; + callbackUrl: string; + }[]>('/api/agents/pending'); + + if (options.json) { + console.log(JSON.stringify(pending, null, 2)); + } else if (pending.length === 0) { + console.log(chalk.dim('No pending agent requests')); + } else { + console.log(chalk.bold(`\nšŸ¤– ${pending.length} Pending Agent Request(s)\n`)); + + pending.forEach(req => { + console.log(chalk.cyan(`Task: ${req.taskId}`)); + console.log(chalk.dim(` Attempt: ${req.attemptId}`)); + console.log(chalk.dim(` Requested: ${new Date(req.requestedAt).toLocaleString()}`)); + console.log(chalk.dim(` Callback: ${req.callbackUrl}`)); + console.log(); + + // Print first few lines of prompt + const promptLines = req.prompt.split('\n').slice(0, 10); + console.log(chalk.dim('─'.repeat(50))); + promptLines.forEach(line => console.log(chalk.dim(` ${line}`))); + if (req.prompt.split('\n').length > 10) { + console.log(chalk.dim(' ...')); + } + console.log(chalk.dim('─'.repeat(50))); + console.log(); + }); + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // Complete an agent request (called by Clawdbot after sub-agent finishes) + program + .command('agents:complete ') + .description('Mark an agent request as complete') + .option('-s, --success', 'Mark as successful (default)') + .option('-f, --failed', 'Mark as failed') + .option('-m, --summary ', 'Summary of what was done') + .option('-e, --error ', 'Error message (if failed)') + .action(async (taskId, options) => { + try { + const success = !options.failed; + const body = { + success, + summary: options.summary, + error: options.error, + }; + + await api(`/api/agents/${taskId}/complete`, { + method: 'POST', + body: JSON.stringify(body), + }); + + if (success) { + console.log(chalk.green(`āœ“ Task ${taskId} marked as complete`)); + } else { + console.log(chalk.yellow(`⚠ Task ${taskId} marked as failed`)); + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // Get agent status for a task + program + .command('agents:status ') + .description('Get agent status for a task') + .option('--json', 'Output as JSON') + .action(async (taskId, options) => { + try { + const status = await api<{ + running: boolean; + taskId?: string; + attemptId?: string; + agent?: string; + status?: string; + startedAt?: string; + }>(`/api/agents/${taskId}/status`); + + if (options.json) { + console.log(JSON.stringify(status, null, 2)); + } else if (!status.running) { + console.log(chalk.dim('No agent running for this task')); + } else { + console.log(chalk.yellow(`šŸ¤– Agent Running`)); + console.log(` Task: ${status.taskId}`); + console.log(` Attempt: ${status.attemptId}`); + console.log(` Agent: ${status.agent}`); + console.log(` Started: ${status.startedAt ? new Date(status.startedAt).toLocaleString() : 'unknown'}`); + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); +} diff --git a/cli/src/commands/automation.ts b/cli/src/commands/automation.ts new file mode 100644 index 00000000..e267993c --- /dev/null +++ b/cli/src/commands/automation.ts @@ -0,0 +1,133 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { api } from '../utils/api.js'; +import { findTask } from '../utils/find.js'; +import { formatTask, formatTasksJson } from '../utils/format.js'; +import type { Task } from '../utils/types.js'; + +export function registerAutomationCommands(program: Command): void { + // List pending automation tasks + program + .command('automation:pending') + .alias('ap') + .description('List automation tasks pending execution') + .option('--json', 'Output as JSON') + .action(async (options) => { + try { + const tasks = await api('/api/automation/pending'); + + if (options.json) { + console.log(formatTasksJson(tasks)); + } else if (tasks.length === 0) { + console.log(chalk.dim('No pending automation tasks')); + } else { + console.log(chalk.bold('Pending Automation Tasks:\n')); + tasks.forEach(task => console.log(formatTask(task, true))); + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // List running automation tasks + program + .command('automation:running') + .alias('ar') + .description('List currently running automation tasks') + .option('--json', 'Output as JSON') + .action(async (options) => { + try { + const tasks = await api('/api/automation/running'); + + if (options.json) { + console.log(formatTasksJson(tasks)); + } else if (tasks.length === 0) { + console.log(chalk.dim('No running automation tasks')); + } else { + console.log(chalk.bold('Running Automation Tasks:\n')); + tasks.forEach(task => console.log(formatTask(task, true))); + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // Start automation task (for Veritas to call) + program + .command('automation:start ') + .alias('as') + .description('Start an automation task via Veritas sub-agent') + .option('-s, --session ', 'Clawdbot session key') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + try { + const task = await findTask(id); + + if (!task) { + console.error(chalk.red(`Task not found: ${id}`)); + process.exit(1); + } + + const result = await api<{ taskId: string; attemptId: string; title: string; description: string }>(`/api/automation/${task.id}/start`, { + method: 'POST', + body: JSON.stringify({ sessionKey: options.session }), + }); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(chalk.green('āœ“ Automation started')); + console.log(chalk.dim(`Task: ${result.title}`)); + console.log(chalk.dim(`Attempt: ${result.attemptId}`)); + if (result.description) { + console.log(chalk.dim(`\nDescription:\n${result.description}`)); + } + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // Complete automation task + program + .command('automation:complete ') + .alias('ac') + .description('Mark an automation task as complete') + .option('-r, --result ', 'Result summary') + .option('-f, --failed', 'Mark as failed instead of complete') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + try { + const task = await findTask(id); + + if (!task) { + console.error(chalk.red(`Task not found: ${id}`)); + process.exit(1); + } + + const result = await api<{ taskId: string; status: string }>(`/api/automation/${task.id}/complete`, { + method: 'POST', + body: JSON.stringify({ + result: options.result, + status: options.failed ? 'failed' : 'complete', + }), + }); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + if (result.status === 'complete') { + console.log(chalk.green('āœ“ Automation completed')); + } else { + console.log(chalk.yellow('āœ“ Automation marked as failed')); + } + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); +} diff --git a/cli/src/commands/notifications.ts b/cli/src/commands/notifications.ts new file mode 100644 index 00000000..764e2922 --- /dev/null +++ b/cli/src/commands/notifications.ts @@ -0,0 +1,152 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { api } from '../utils/api.js'; + +export function registerNotificationCommands(program: Command): void { + // Send a notification + program + .command('notify ') + .description('Create a notification') + .option('-t, --type ', 'Notification type (info, error, milestone, high_priority)', 'info') + .option('--title ', 'Notification title') + .option('--task <id>', 'Related task ID') + .option('--json', 'Output as JSON') + .action(async (message, options) => { + try { + const notification = await api<{ id: string; type: string; title: string }>('/api/notifications', { + method: 'POST', + body: JSON.stringify({ + type: options.type, + title: options.title || (options.type === 'error' ? 'Error' : options.type === 'milestone' ? 'Milestone' : 'Notification'), + message, + taskId: options.task, + }), + }); + + if (options.json) { + console.log(JSON.stringify(notification, null, 2)); + } else { + console.log(chalk.green('āœ“ Notification created')); + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // Check for notifications + program + .command('notify:check') + .description('Check for tasks that need notifications') + .option('--json', 'Output as JSON') + .action(async (options) => { + try { + const result = await api<{ checked: number; created: number }>('/api/notifications/check', { + method: 'POST', + }); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(chalk.dim(`Checked ${result.checked} tasks`)); + if (result.created > 0) { + console.log(chalk.yellow(`Created ${result.created} new notifications`)); + } else { + console.log(chalk.dim('No new notifications')); + } + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // Get pending notifications + program + .command('notify:pending') + .description('Get pending notifications formatted for Teams') + .option('--json', 'Output raw JSON') + .option('--mark-sent', 'Mark notifications as sent after output') + .action(async (options) => { + try { + const result = await api<{ count: number; messages: { id: string; type: string; text: string; timestamp: string }[] }>('/api/notifications/pending'); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else if (result.count === 0) { + console.log(chalk.dim('No pending notifications')); + } else { + result.messages.forEach(msg => { + console.log(msg.text); + console.log(chalk.dim('─'.repeat(40))); + }); + } + + if (options.markSent && result.count > 0) { + const ids = result.messages.map(m => m.id); + await api('/api/notifications/mark-sent', { + method: 'POST', + body: JSON.stringify({ ids }), + }); + console.log(chalk.dim(`Marked ${ids.length} notifications as sent`)); + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // List all notifications + program + .command('notify:list') + .description('List all notifications') + .option('-u, --unsent', 'Show only unsent notifications') + .option('--json', 'Output as JSON') + .action(async (options) => { + try { + const url = options.unsent ? '/api/notifications?unsent=true' : '/api/notifications'; + const notifications = await api<{ id: string; type: string; title: string; message: string; sent: boolean; timestamp: string }[]>(url); + + if (options.json) { + console.log(JSON.stringify(notifications, null, 2)); + } else if (notifications.length === 0) { + console.log(chalk.dim('No notifications')); + } else { + const typeIcons: Record<string, string> = { + agent_complete: 'āœ…', + agent_failed: 'āŒ', + needs_review: 'šŸ‘€', + task_done: 'šŸŽ‰', + high_priority: 'šŸ”“', + error: 'āš ļø', + milestone: 'šŸ†', + info: 'ā„¹ļø', + }; + + notifications.forEach(n => { + const icon = typeIcons[n.type] || '•'; + const sent = n.sent ? chalk.dim('[sent]') : chalk.yellow('[pending]'); + console.log(`${icon} ${chalk.bold(n.title)} ${sent}`); + console.log(chalk.dim(` ${n.message.slice(0, 60)}${n.message.length > 60 ? '...' : ''}`)); + }); + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // Clear notifications + program + .command('notify:clear') + .description('Clear all notifications') + .action(async () => { + try { + await api('/api/notifications', { method: 'DELETE' }); + console.log(chalk.green('āœ“ Notifications cleared')); + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); +} diff --git a/cli/src/commands/summary.ts b/cli/src/commands/summary.ts new file mode 100644 index 00000000..6ffffda9 --- /dev/null +++ b/cli/src/commands/summary.ts @@ -0,0 +1,93 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { api, API_BASE } from '../utils/api.js'; + +export function registerSummaryCommands(program: Command): void { + // Show summary + program + .command('summary') + .description('Show task summary') + .option('--json', 'Output as JSON') + .action(async (options) => { + try { + const summary = await api<{ + total: number; + byStatus: Record<string, number>; + byProject: Record<string, { total: number; done: number; inProgress: number }>; + highPriority: { id: string; title: string; status: string; project?: string }[]; + }>('/api/summary'); + + if (options.json) { + console.log(JSON.stringify(summary, null, 2)); + } else { + console.log(chalk.bold('\nšŸ“Š Veritas Kanban Summary\n')); + + console.log(chalk.dim('Status:')); + console.log(` To Do: ${summary.byStatus.todo}`); + console.log(` In Progress: ${summary.byStatus['in-progress']}`); + console.log(` Review: ${summary.byStatus.review}`); + console.log(` Done: ${summary.byStatus.done}`); + + const projects = Object.entries(summary.byProject); + if (projects.length > 0) { + console.log(chalk.dim('\nProjects:')); + projects.forEach(([name, stats]) => { + const percent = stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0; + console.log(` ${name}: ${stats.done}/${stats.total} (${percent}%)`); + }); + } + + if (summary.highPriority.length > 0) { + console.log(chalk.red('\nšŸ”“ High Priority:')); + summary.highPriority.forEach(t => { + console.log(` ${t.title} [${t.status}]${t.project ? ` #${t.project}` : ''}`); + }); + } + + console.log(); + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // Get memory-formatted summary + program + .command('memory') + .description('Get task summary formatted for memory files') + .option('-h, --hours <n>', 'Hours to look back', '24') + .option('-o, --output <file>', 'Append to file instead of stdout') + .option('--json', 'Output recent tasks as JSON instead') + .action(async (options) => { + try { + if (options.json) { + const recent = await api<unknown>(`/api/summary/recent?hours=${options.hours}`); + console.log(JSON.stringify(recent, null, 2)); + } else { + const res = await fetch(`${API_BASE}/api/summary/memory?hours=${options.hours}`); + const markdown = await res.text(); + + if (options.output) { + const fs = await import('fs/promises'); + const path = await import('path'); + + // Expand ~ to home dir + let outputPath = options.output; + if (outputPath.startsWith('~')) { + outputPath = path.join(process.env.HOME || '', outputPath.slice(1)); + } + + // Append to file + await fs.appendFile(outputPath, '\n' + markdown); + console.log(chalk.green(`āœ“ Appended to ${outputPath}`)); + } else { + console.log(markdown); + } + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); +} diff --git a/cli/src/commands/tasks.ts b/cli/src/commands/tasks.ts new file mode 100644 index 00000000..20f9910b --- /dev/null +++ b/cli/src/commands/tasks.ts @@ -0,0 +1,218 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { api } from '../utils/api.js'; +import { findTask } from '../utils/find.js'; +import { formatTask, formatTaskJson, formatTasksJson } from '../utils/format.js'; +import type { Task } from '../utils/types.js'; + +export function registerTaskCommands(program: Command): void { + // List tasks + program + .command('list') + .alias('ls') + .description('List tasks') + .option('-s, --status <status>', 'Filter by status (todo, in-progress, review, done)') + .option('-t, --type <type>', 'Filter by type (code, research, content, automation)') + .option('-p, --project <project>', 'Filter by project') + .option('-v, --verbose', 'Show more details') + .option('--json', 'Output as JSON') + .action(async (options) => { + try { + const tasks = await api<Task[]>('/api/tasks'); + + let filtered = tasks; + if (options.status) { + filtered = filtered.filter(t => t.status === options.status); + } + if (options.type) { + filtered = filtered.filter(t => t.type === options.type); + } + if (options.project) { + filtered = filtered.filter(t => t.project === options.project); + } + + if (options.json) { + console.log(formatTasksJson(filtered)); + } else if (filtered.length === 0) { + console.log(chalk.dim('No tasks found')); + } else { + filtered.forEach(task => console.log(formatTask(task, options.verbose))); + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // Show task details + program + .command('show <id>') + .description('Show task details') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + try { + const task = await findTask(id); + + if (!task) { + console.error(chalk.red(`Task not found: ${id}`)); + process.exit(1); + } + + if (options.json) { + console.log(formatTaskJson(task)); + } else { + console.log(formatTask(task, true)); + console.log(chalk.dim('─'.repeat(60))); + console.log(chalk.dim(`ID: ${task.id}`)); + console.log(chalk.dim(`Created: ${new Date(task.created).toLocaleString()}`)); + console.log(chalk.dim(`Updated: ${new Date(task.updated).toLocaleString()}`)); + // Tags feature not yet implemented in shared types + // if (task.tags?.length) { + // console.log(chalk.dim(`Tags: ${task.tags.join(', ')}`)); + // } + if (task.description) { + console.log('\n' + task.description); + } + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // Create task + program + .command('create <title>') + .description('Create a new task') + .option('-t, --type <type>', 'Task type (code, research, content, automation)', 'code') + .option('-p, --project <project>', 'Project name') + .option('-d, --description <desc>', 'Task description') + .option('--priority <priority>', 'Priority (low, medium, high)', 'medium') + .option('--json', 'Output as JSON') + .action(async (title, options) => { + try { + const task = await api<Task>('/api/tasks', { + method: 'POST', + body: JSON.stringify({ + title, + type: options.type, + project: options.project, + description: options.description || '', + priority: options.priority, + }), + }); + + if (options.json) { + console.log(formatTaskJson(task)); + } else { + console.log(chalk.green('āœ“ Task created')); + console.log(formatTask(task, true)); + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // Update task + program + .command('update <id>') + .description('Update a task') + .option('-s, --status <status>', 'New status') + .option('-t, --type <type>', 'New type') + .option('-p, --project <project>', 'New project') + .option('--priority <priority>', 'New priority') + .option('--title <title>', 'New title') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + try { + const existing = await findTask(id); + + if (!existing) { + console.error(chalk.red(`Task not found: ${id}`)); + process.exit(1); + } + + const updates: Record<string, unknown> = {}; + if (options.status) updates.status = options.status; + if (options.type) updates.type = options.type; + if (options.project) updates.project = options.project; + if (options.priority) updates.priority = options.priority; + if (options.title) updates.title = options.title; + + if (Object.keys(updates).length === 0) { + console.error(chalk.yellow('No updates specified')); + process.exit(1); + } + + const task = await api<Task>(`/api/tasks/${existing.id}`, { + method: 'PATCH', + body: JSON.stringify(updates), + }); + + if (options.json) { + console.log(formatTaskJson(task)); + } else { + console.log(chalk.green('āœ“ Task updated')); + console.log(formatTask(task, true)); + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // Archive task + program + .command('archive <id>') + .description('Archive a completed task') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + try { + const task = await findTask(id); + + if (!task) { + console.error(chalk.red(`Task not found: ${id}`)); + process.exit(1); + } + + await api(`/api/tasks/${task.id}/archive`, { method: 'POST' }); + + if (options.json) { + console.log(JSON.stringify({ archived: true })); + } else { + console.log(chalk.green('āœ“ Task archived')); + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // Delete task + program + .command('delete <id>') + .description('Delete a task') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + try { + const task = await findTask(id); + + if (!task) { + console.error(chalk.red(`Task not found: ${id}`)); + process.exit(1); + } + + await api(`/api/tasks/${task.id}`, { method: 'DELETE' }); + + if (options.json) { + console.log(JSON.stringify({ deleted: true })); + } else { + console.log(chalk.green('āœ“ Task deleted')); + } + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 13874cb0..0d8587d5 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,123 +1,10 @@ #!/usr/bin/env node import { Command } from 'commander'; -import chalk from 'chalk'; - -const API_BASE = process.env.VK_API_URL || 'http://localhost:3001'; - -interface Task { - id: string; - title: string; - description: string; - type: 'code' | 'research' | 'content' | 'automation'; - status: 'todo' | 'in-progress' | 'review' | 'done'; - priority: 'low' | 'medium' | 'high'; - project?: string; - tags?: string[]; - created: string; - updated: string; - git?: { - repo: string; - branch: string; - baseBranch: string; - worktreePath?: string; - }; - attempt?: { - id: string; - agent: string; - status: string; - started?: string; - ended?: string; - }; - review?: { - decision?: 'approved' | 'changes-requested' | 'rejected'; - decidedAt?: string; - summary?: string; - }; -} - -async function api<T>(path: string, options?: RequestInit): Promise<T> { - const res = await fetch(`${API_BASE}${path}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers, - }, - }); - - if (!res.ok) { - const error = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(error.error || `API error: ${res.status}`); - } - - if (res.status === 204) return undefined as T; - return res.json(); -} - -function formatTask(task: Task, verbose = false): string { - const statusColors: Record<string, (s: string) => string> = { - 'todo': chalk.gray, - 'in-progress': chalk.yellow, - 'review': chalk.blue, - 'done': chalk.green, - }; - - const priorityColors: Record<string, (s: string) => string> = { - 'low': chalk.dim, - 'medium': chalk.white, - 'high': chalk.red, - }; - - const typeIcons: Record<string, string> = { - 'code': 'šŸ’»', - 'research': 'šŸ”', - 'content': 'šŸ“', - 'automation': '⚔', - }; - - const statusColor = statusColors[task.status] || chalk.white; - const priorityColor = priorityColors[task.priority] || chalk.white; - - let line = `${typeIcons[task.type] || '•'} ${chalk.cyan(task.id.slice(-8))} `; - line += statusColor(`[${task.status}]`) + ' '; - line += priorityColor(`(${task.priority})`) + ' '; - line += chalk.bold(task.title); - - if (task.project) { - line += chalk.dim(` #${task.project}`); - } - - if (verbose) { - line += '\n'; - if (task.description) { - line += chalk.dim(` ${task.description.slice(0, 80)}${task.description.length > 80 ? '...' : ''}\n`); - } - if (task.git?.branch) { - line += chalk.dim(` 🌿 ${task.git.branch}\n`); - } - if (task.attempt?.status === 'running') { - line += chalk.yellow(` šŸ¤– Agent running (${task.attempt.agent})\n`); - } - if (task.review?.decision) { - const decisionColors: Record<string, (s: string) => string> = { - 'approved': chalk.green, - 'changes-requested': chalk.yellow, - 'rejected': chalk.red, - }; - const color = decisionColors[task.review.decision] || chalk.white; - line += color(` āœ“ ${task.review.decision}\n`); - } - } - - return line; -} - -function formatTaskJson(task: Task): string { - return JSON.stringify(task, null, 2); -} - -function formatTasksJson(tasks: Task[]): string { - return JSON.stringify(tasks, null, 2); -} +import { registerTaskCommands } from './commands/tasks.js'; +import { registerAgentCommands } from './commands/agents.js'; +import { registerAutomationCommands } from './commands/automation.js'; +import { registerNotificationCommands } from './commands/notifications.js'; +import { registerSummaryCommands } from './commands/summary.js'; const program = new Command(); @@ -126,774 +13,11 @@ program .description('Veritas Kanban CLI - Task management for AI agents') .version('0.1.0'); -// List tasks -program - .command('list') - .alias('ls') - .description('List tasks') - .option('-s, --status <status>', 'Filter by status (todo, in-progress, review, done)') - .option('-t, --type <type>', 'Filter by type (code, research, content, automation)') - .option('-p, --project <project>', 'Filter by project') - .option('-v, --verbose', 'Show more details') - .option('--json', 'Output as JSON') - .action(async (options) => { - try { - const tasks = await api<Task[]>('/api/tasks'); - - let filtered = tasks; - if (options.status) { - filtered = filtered.filter(t => t.status === options.status); - } - if (options.type) { - filtered = filtered.filter(t => t.type === options.type); - } - if (options.project) { - filtered = filtered.filter(t => t.project === options.project); - } - - if (options.json) { - console.log(formatTasksJson(filtered)); - } else if (filtered.length === 0) { - console.log(chalk.dim('No tasks found')); - } else { - filtered.forEach(task => console.log(formatTask(task, options.verbose))); - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// Show task details -program - .command('show <id>') - .description('Show task details') - .option('--json', 'Output as JSON') - .action(async (id, options) => { - try { - // Support partial ID matching - const tasks = await api<Task[]>('/api/tasks'); - const task = tasks.find(t => t.id === id || t.id.endsWith(id)); - - if (!task) { - console.error(chalk.red(`Task not found: ${id}`)); - process.exit(1); - } - - if (options.json) { - console.log(formatTaskJson(task)); - } else { - console.log(formatTask(task, true)); - console.log(chalk.dim('─'.repeat(60))); - console.log(chalk.dim(`ID: ${task.id}`)); - console.log(chalk.dim(`Created: ${new Date(task.created).toLocaleString()}`)); - console.log(chalk.dim(`Updated: ${new Date(task.updated).toLocaleString()}`)); - if (task.tags?.length) { - console.log(chalk.dim(`Tags: ${task.tags.join(', ')}`)); - } - if (task.description) { - console.log('\n' + task.description); - } - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// Create task -program - .command('create <title>') - .description('Create a new task') - .option('-t, --type <type>', 'Task type (code, research, content, automation)', 'code') - .option('-p, --project <project>', 'Project name') - .option('-d, --description <desc>', 'Task description') - .option('--priority <priority>', 'Priority (low, medium, high)', 'medium') - .option('--json', 'Output as JSON') - .action(async (title, options) => { - try { - const task = await api<Task>('/api/tasks', { - method: 'POST', - body: JSON.stringify({ - title, - type: options.type, - project: options.project, - description: options.description || '', - priority: options.priority, - }), - }); - - if (options.json) { - console.log(formatTaskJson(task)); - } else { - console.log(chalk.green('āœ“ Task created')); - console.log(formatTask(task, true)); - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// Update task -program - .command('update <id>') - .description('Update a task') - .option('-s, --status <status>', 'New status') - .option('-t, --type <type>', 'New type') - .option('-p, --project <project>', 'New project') - .option('--priority <priority>', 'New priority') - .option('--title <title>', 'New title') - .option('--json', 'Output as JSON') - .action(async (id, options) => { - try { - // Support partial ID matching - const tasks = await api<Task[]>('/api/tasks'); - const existing = tasks.find(t => t.id === id || t.id.endsWith(id)); - - if (!existing) { - console.error(chalk.red(`Task not found: ${id}`)); - process.exit(1); - } - - const updates: Record<string, unknown> = {}; - if (options.status) updates.status = options.status; - if (options.type) updates.type = options.type; - if (options.project) updates.project = options.project; - if (options.priority) updates.priority = options.priority; - if (options.title) updates.title = options.title; - - if (Object.keys(updates).length === 0) { - console.error(chalk.yellow('No updates specified')); - process.exit(1); - } - - const task = await api<Task>(`/api/tasks/${existing.id}`, { - method: 'PATCH', - body: JSON.stringify(updates), - }); - - if (options.json) { - console.log(formatTaskJson(task)); - } else { - console.log(chalk.green('āœ“ Task updated')); - console.log(formatTask(task, true)); - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// Start agent on task -program - .command('start <id>') - .description('Start an agent on a task') - .option('-a, --agent <agent>', 'Agent to use (claude-code, amp, copilot, gemini)', 'claude-code') - .option('--json', 'Output as JSON') - .action(async (id, options) => { - try { - // Support partial ID matching - const tasks = await api<Task[]>('/api/tasks'); - const task = tasks.find(t => t.id === id || t.id.endsWith(id)); - - if (!task) { - console.error(chalk.red(`Task not found: ${id}`)); - process.exit(1); - } - - if (task.type !== 'code') { - console.error(chalk.red('Can only start agents on code tasks')); - process.exit(1); - } - - if (!task.git?.worktreePath) { - console.error(chalk.red('Task needs a worktree first. Create one via the UI.')); - process.exit(1); - } - - const result = await api<{ attemptId: string }>(`/api/agents/${task.id}/start`, { - method: 'POST', - body: JSON.stringify({ agent: options.agent }), - }); - - if (options.json) { - console.log(JSON.stringify(result, null, 2)); - } else { - console.log(chalk.green(`āœ“ Agent started: ${options.agent}`)); - console.log(chalk.dim(`Attempt ID: ${result.attemptId}`)); - console.log(chalk.dim(`Working in: ${task.git.worktreePath}`)); - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// Stop agent -program - .command('stop <id>') - .description('Stop a running agent') - .option('--json', 'Output as JSON') - .action(async (id, options) => { - try { - const tasks = await api<Task[]>('/api/tasks'); - const task = tasks.find(t => t.id === id || t.id.endsWith(id)); - - if (!task) { - console.error(chalk.red(`Task not found: ${id}`)); - process.exit(1); - } - - await api(`/api/agents/${task.id}/stop`, { method: 'POST' }); - - if (options.json) { - console.log(JSON.stringify({ stopped: true })); - } else { - console.log(chalk.yellow('āœ“ Agent stopped')); - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// Archive task -program - .command('archive <id>') - .description('Archive a completed task') - .option('--json', 'Output as JSON') - .action(async (id, options) => { - try { - const tasks = await api<Task[]>('/api/tasks'); - const task = tasks.find(t => t.id === id || t.id.endsWith(id)); - - if (!task) { - console.error(chalk.red(`Task not found: ${id}`)); - process.exit(1); - } - - await api(`/api/tasks/${task.id}/archive`, { method: 'POST' }); - - if (options.json) { - console.log(JSON.stringify({ archived: true })); - } else { - console.log(chalk.green('āœ“ Task archived')); - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// Delete task -program - .command('delete <id>') - .description('Delete a task') - .option('--json', 'Output as JSON') - .action(async (id, options) => { - try { - const tasks = await api<Task[]>('/api/tasks'); - const task = tasks.find(t => t.id === id || t.id.endsWith(id)); - - if (!task) { - console.error(chalk.red(`Task not found: ${id}`)); - process.exit(1); - } - - await api(`/api/tasks/${task.id}`, { method: 'DELETE' }); - - if (options.json) { - console.log(JSON.stringify({ deleted: true })); - } else { - console.log(chalk.green('āœ“ Task deleted')); - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// === Automation Commands === - -// List pending automation tasks -program - .command('automation:pending') - .alias('ap') - .description('List automation tasks pending execution') - .option('--json', 'Output as JSON') - .action(async (options) => { - try { - const tasks = await api<Task[]>('/api/automation/pending'); - - if (options.json) { - console.log(formatTasksJson(tasks)); - } else if (tasks.length === 0) { - console.log(chalk.dim('No pending automation tasks')); - } else { - console.log(chalk.bold('Pending Automation Tasks:\n')); - tasks.forEach(task => console.log(formatTask(task, true))); - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// List running automation tasks -program - .command('automation:running') - .alias('ar') - .description('List currently running automation tasks') - .option('--json', 'Output as JSON') - .action(async (options) => { - try { - const tasks = await api<Task[]>('/api/automation/running'); - - if (options.json) { - console.log(formatTasksJson(tasks)); - } else if (tasks.length === 0) { - console.log(chalk.dim('No running automation tasks')); - } else { - console.log(chalk.bold('Running Automation Tasks:\n')); - tasks.forEach(task => console.log(formatTask(task, true))); - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// Start automation task (for Veritas to call) -program - .command('automation:start <id>') - .alias('as') - .description('Start an automation task via Veritas sub-agent') - .option('-s, --session <key>', 'Clawdbot session key') - .option('--json', 'Output as JSON') - .action(async (id, options) => { - try { - const tasks = await api<Task[]>('/api/tasks'); - const task = tasks.find(t => t.id === id || t.id.endsWith(id)); - - if (!task) { - console.error(chalk.red(`Task not found: ${id}`)); - process.exit(1); - } - - const result = await api<{ taskId: string; attemptId: string; title: string; description: string }>(`/api/automation/${task.id}/start`, { - method: 'POST', - body: JSON.stringify({ sessionKey: options.session }), - }); - - if (options.json) { - console.log(JSON.stringify(result, null, 2)); - } else { - console.log(chalk.green('āœ“ Automation started')); - console.log(chalk.dim(`Task: ${result.title}`)); - console.log(chalk.dim(`Attempt: ${result.attemptId}`)); - if (result.description) { - console.log(chalk.dim(`\nDescription:\n${result.description}`)); - } - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// Complete automation task -program - .command('automation:complete <id>') - .alias('ac') - .description('Mark an automation task as complete') - .option('-r, --result <text>', 'Result summary') - .option('-f, --failed', 'Mark as failed instead of complete') - .option('--json', 'Output as JSON') - .action(async (id, options) => { - try { - const tasks = await api<Task[]>('/api/tasks'); - const task = tasks.find(t => t.id === id || t.id.endsWith(id)); - - if (!task) { - console.error(chalk.red(`Task not found: ${id}`)); - process.exit(1); - } - - const result = await api<{ taskId: string; status: string }>(`/api/automation/${task.id}/complete`, { - method: 'POST', - body: JSON.stringify({ - result: options.result, - status: options.failed ? 'failed' : 'complete', - }), - }); - - if (options.json) { - console.log(JSON.stringify(result, null, 2)); - } else { - if (result.status === 'complete') { - console.log(chalk.green('āœ“ Automation completed')); - } else { - console.log(chalk.yellow('āœ“ Automation marked as failed')); - } - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// === Notification Commands === - -// Send a notification -program - .command('notify <message>') - .description('Create a notification') - .option('-t, --type <type>', 'Notification type (info, error, milestone, high_priority)', 'info') - .option('--title <title>', 'Notification title') - .option('--task <id>', 'Related task ID') - .option('--json', 'Output as JSON') - .action(async (message, options) => { - try { - const notification = await api<{ id: string; type: string; title: string }>('/api/notifications', { - method: 'POST', - body: JSON.stringify({ - type: options.type, - title: options.title || (options.type === 'error' ? 'Error' : options.type === 'milestone' ? 'Milestone' : 'Notification'), - message, - taskId: options.task, - }), - }); - - if (options.json) { - console.log(JSON.stringify(notification, null, 2)); - } else { - console.log(chalk.green('āœ“ Notification created')); - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// Check for notifications -program - .command('notify:check') - .description('Check for tasks that need notifications') - .option('--json', 'Output as JSON') - .action(async (options) => { - try { - const result = await api<{ checked: number; created: number }>('/api/notifications/check', { - method: 'POST', - }); - - if (options.json) { - console.log(JSON.stringify(result, null, 2)); - } else { - console.log(chalk.dim(`Checked ${result.checked} tasks`)); - if (result.created > 0) { - console.log(chalk.yellow(`Created ${result.created} new notifications`)); - } else { - console.log(chalk.dim('No new notifications')); - } - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// Get pending notifications -program - .command('notify:pending') - .description('Get pending notifications formatted for Teams') - .option('--json', 'Output raw JSON') - .option('--mark-sent', 'Mark notifications as sent after output') - .action(async (options) => { - try { - const result = await api<{ count: number; messages: { id: string; type: string; text: string; timestamp: string }[] }>('/api/notifications/pending'); - - if (options.json) { - console.log(JSON.stringify(result, null, 2)); - } else if (result.count === 0) { - console.log(chalk.dim('No pending notifications')); - } else { - result.messages.forEach(msg => { - console.log(msg.text); - console.log(chalk.dim('─'.repeat(40))); - }); - } - - if (options.markSent && result.count > 0) { - const ids = result.messages.map(m => m.id); - await api('/api/notifications/mark-sent', { - method: 'POST', - body: JSON.stringify({ ids }), - }); - console.log(chalk.dim(`Marked ${ids.length} notifications as sent`)); - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// List all notifications -program - .command('notify:list') - .description('List all notifications') - .option('-u, --unsent', 'Show only unsent notifications') - .option('--json', 'Output as JSON') - .action(async (options) => { - try { - const url = options.unsent ? '/api/notifications?unsent=true' : '/api/notifications'; - const notifications = await api<{ id: string; type: string; title: string; message: string; sent: boolean; timestamp: string }[]>(url); - - if (options.json) { - console.log(JSON.stringify(notifications, null, 2)); - } else if (notifications.length === 0) { - console.log(chalk.dim('No notifications')); - } else { - const typeIcons: Record<string, string> = { - agent_complete: 'āœ…', - agent_failed: 'āŒ', - needs_review: 'šŸ‘€', - task_done: 'šŸŽ‰', - high_priority: 'šŸ”“', - error: 'āš ļø', - milestone: 'šŸ†', - info: 'ā„¹ļø', - }; - - notifications.forEach(n => { - const icon = typeIcons[n.type] || '•'; - const sent = n.sent ? chalk.dim('[sent]') : chalk.yellow('[pending]'); - console.log(`${icon} ${chalk.bold(n.title)} ${sent}`); - console.log(chalk.dim(` ${n.message.slice(0, 60)}${n.message.length > 60 ? '...' : ''}`)); - }); - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// Clear notifications -program - .command('notify:clear') - .description('Clear all notifications') - .action(async () => { - try { - await api('/api/notifications', { method: 'DELETE' }); - console.log(chalk.green('āœ“ Notifications cleared')); - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// === Memory Sync Commands === - -// Show summary -program - .command('summary') - .description('Show task summary') - .option('--json', 'Output as JSON') - .action(async (options) => { - try { - const summary = await api<{ - total: number; - byStatus: Record<string, number>; - byProject: Record<string, { total: number; done: number; inProgress: number }>; - highPriority: { id: string; title: string; status: string; project?: string }[]; - }>('/api/summary'); - - if (options.json) { - console.log(JSON.stringify(summary, null, 2)); - } else { - console.log(chalk.bold('\nšŸ“Š Veritas Kanban Summary\n')); - - console.log(chalk.dim('Status:')); - console.log(` To Do: ${summary.byStatus.todo}`); - console.log(` In Progress: ${summary.byStatus['in-progress']}`); - console.log(` Review: ${summary.byStatus.review}`); - console.log(` Done: ${summary.byStatus.done}`); - - const projects = Object.entries(summary.byProject); - if (projects.length > 0) { - console.log(chalk.dim('\nProjects:')); - projects.forEach(([name, stats]) => { - const percent = stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0; - console.log(` ${name}: ${stats.done}/${stats.total} (${percent}%)`); - }); - } - - if (summary.highPriority.length > 0) { - console.log(chalk.red('\nšŸ”“ High Priority:')); - summary.highPriority.forEach(t => { - console.log(` ${t.title} [${t.status}]${t.project ? ` #${t.project}` : ''}`); - }); - } - - console.log(); - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// Get memory-formatted summary -program - .command('memory') - .description('Get task summary formatted for memory files') - .option('-h, --hours <n>', 'Hours to look back', '24') - .option('-o, --output <file>', 'Append to file instead of stdout') - .option('--json', 'Output recent tasks as JSON instead') - .action(async (options) => { - try { - if (options.json) { - const recent = await api<unknown>(`/api/summary/recent?hours=${options.hours}`); - console.log(JSON.stringify(recent, null, 2)); - } else { - const res = await fetch(`${API_BASE}/api/summary/memory?hours=${options.hours}`); - const markdown = await res.text(); - - if (options.output) { - const fs = await import('fs/promises'); - const path = await import('path'); - - // Expand ~ to home dir - let outputPath = options.output; - if (outputPath.startsWith('~')) { - outputPath = path.join(process.env.HOME || '', outputPath.slice(1)); - } - - // Append to file - await fs.appendFile(outputPath, '\n' + markdown); - console.log(chalk.green(`āœ“ Appended to ${outputPath}`)); - } else { - console.log(markdown); - } - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// === Agent Commands (Clawdbot Integration) === - -// Get pending agent requests (for Veritas to process) -program - .command('agents:pending') - .description('List pending agent requests waiting for Clawdbot to process') - .option('--json', 'Output as JSON') - .action(async (options) => { - try { - const pending = await api<{ - taskId: string; - attemptId: string; - prompt: string; - requestedAt: string; - callbackUrl: string; - }[]>('/api/agents/pending'); - - if (options.json) { - console.log(JSON.stringify(pending, null, 2)); - } else if (pending.length === 0) { - console.log(chalk.dim('No pending agent requests')); - } else { - console.log(chalk.bold(`\nšŸ¤– ${pending.length} Pending Agent Request(s)\n`)); - - pending.forEach(req => { - console.log(chalk.cyan(`Task: ${req.taskId}`)); - console.log(chalk.dim(` Attempt: ${req.attemptId}`)); - console.log(chalk.dim(` Requested: ${new Date(req.requestedAt).toLocaleString()}`)); - console.log(chalk.dim(` Callback: ${req.callbackUrl}`)); - console.log(); - - // Print first few lines of prompt - const promptLines = req.prompt.split('\n').slice(0, 10); - console.log(chalk.dim('─'.repeat(50))); - promptLines.forEach(line => console.log(chalk.dim(` ${line}`))); - if (req.prompt.split('\n').length > 10) { - console.log(chalk.dim(' ...')); - } - console.log(chalk.dim('─'.repeat(50))); - console.log(); - }); - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// Complete an agent request (called by Clawdbot after sub-agent finishes) -program - .command('agents:complete <taskId>') - .description('Mark an agent request as complete') - .option('-s, --success', 'Mark as successful (default)') - .option('-f, --failed', 'Mark as failed') - .option('-m, --summary <text>', 'Summary of what was done') - .option('-e, --error <text>', 'Error message (if failed)') - .action(async (taskId, options) => { - try { - const success = !options.failed; - const body = { - success, - summary: options.summary, - error: options.error, - }; - - await api(`/api/agents/${taskId}/complete`, { - method: 'POST', - body: JSON.stringify(body), - }); - - if (success) { - console.log(chalk.green(`āœ“ Task ${taskId} marked as complete`)); - } else { - console.log(chalk.yellow(`⚠ Task ${taskId} marked as failed`)); - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); - -// Get agent status for a task -program - .command('agents:status <taskId>') - .description('Get agent status for a task') - .option('--json', 'Output as JSON') - .action(async (taskId, options) => { - try { - const status = await api<{ - running: boolean; - taskId?: string; - attemptId?: string; - agent?: string; - status?: string; - startedAt?: string; - }>(`/api/agents/${taskId}/status`); - - if (options.json) { - console.log(JSON.stringify(status, null, 2)); - } else if (!status.running) { - console.log(chalk.dim('No agent running for this task')); - } else { - console.log(chalk.yellow(`šŸ¤– Agent Running`)); - console.log(` Task: ${status.taskId}`); - console.log(` Attempt: ${status.attemptId}`); - console.log(` Agent: ${status.agent}`); - console.log(` Started: ${status.startedAt ? new Date(status.startedAt).toLocaleString() : 'unknown'}`); - } - } catch (err) { - console.error(chalk.red(`Error: ${(err as Error).message}`)); - process.exit(1); - } - }); +// Register all command groups +registerTaskCommands(program); +registerAgentCommands(program); +registerAutomationCommands(program); +registerNotificationCommands(program); +registerSummaryCommands(program); program.parse(); diff --git a/cli/src/utils/api.ts b/cli/src/utils/api.ts new file mode 100644 index 00000000..3e7476d4 --- /dev/null +++ b/cli/src/utils/api.ts @@ -0,0 +1,2 @@ +// Re-export shared API client +export { api, createApiClient, API_BASE } from '@veritas-kanban/shared'; diff --git a/cli/src/utils/find.ts b/cli/src/utils/find.ts new file mode 100644 index 00000000..cba20f69 --- /dev/null +++ b/cli/src/utils/find.ts @@ -0,0 +1,2 @@ +// Re-export shared findTask +export { findTask } from '@veritas-kanban/shared'; diff --git a/cli/src/utils/format.ts b/cli/src/utils/format.ts new file mode 100644 index 00000000..675477de --- /dev/null +++ b/cli/src/utils/format.ts @@ -0,0 +1,68 @@ +import chalk from 'chalk'; +import type { Task } from './types.js'; + +export function formatTask(task: Task, verbose = false): string { + const statusColors: Record<string, (s: string) => string> = { + 'todo': chalk.gray, + 'in-progress': chalk.yellow, + 'review': chalk.blue, + 'done': chalk.green, + }; + + const priorityColors: Record<string, (s: string) => string> = { + 'low': chalk.dim, + 'medium': chalk.white, + 'high': chalk.red, + }; + + const typeIcons: Record<string, string> = { + 'code': 'šŸ’»', + 'research': 'šŸ”', + 'content': 'šŸ“', + 'automation': '⚔', + }; + + const statusColor = statusColors[task.status] || chalk.white; + const priorityColor = priorityColors[task.priority] || chalk.white; + + let line = `${typeIcons[task.type] || '•'} ${chalk.cyan(task.id.slice(-8))} `; + line += statusColor(`[${task.status}]`) + ' '; + line += priorityColor(`(${task.priority})`) + ' '; + line += chalk.bold(task.title); + + if (task.project) { + line += chalk.dim(` #${task.project}`); + } + + if (verbose) { + line += '\n'; + if (task.description) { + line += chalk.dim(` ${task.description.slice(0, 80)}${task.description.length > 80 ? '...' : ''}\n`); + } + if (task.git?.branch) { + line += chalk.dim(` 🌿 ${task.git.branch}\n`); + } + if (task.attempt?.status === 'running') { + line += chalk.yellow(` šŸ¤– Agent running (${task.attempt.agent})\n`); + } + if (task.review?.decision) { + const decisionColors: Record<string, (s: string) => string> = { + 'approved': chalk.green, + 'changes-requested': chalk.yellow, + 'rejected': chalk.red, + }; + const color = decisionColors[task.review.decision] || chalk.white; + line += color(` āœ“ ${task.review.decision}\n`); + } + } + + return line; +} + +export function formatTaskJson(task: Task): string { + return JSON.stringify(task, null, 2); +} + +export function formatTasksJson(tasks: Task[]): string { + return JSON.stringify(tasks, null, 2); +} diff --git a/cli/src/utils/types.ts b/cli/src/utils/types.ts new file mode 100644 index 00000000..5cc0e2c7 --- /dev/null +++ b/cli/src/utils/types.ts @@ -0,0 +1,2 @@ +// Re-export shared types +export type { Task } from '@veritas-kanban/shared'; diff --git a/mcp/package.json b/mcp/package.json index a4d32a4d..fcdcea4e 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -13,12 +13,14 @@ "start": "node dist/index.js" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", + "@veritas-kanban/shared": "workspace:*", + "@modelcontextprotocol/sdk": "^1.25.3", + "hono": "^4.11.7", "zod": "^3.24.0" }, "devDependencies": { "@types/node": "^22.10.0", - "typescript": "^5.7.0", - "tsx": "^4.19.0" + "tsx": "^4.19.0", + "typescript": "^5.7.0" } } diff --git a/mcp/src/index.ts b/mcp/src/index.ts index 8dec3d4a..b28f8bd6 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -7,105 +7,18 @@ import { ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; -import { z } from 'zod'; -const API_BASE = process.env.VK_API_URL || 'http://localhost:3001'; +// Import utilities +import { api } from './utils/api.js'; +import { findTask } from './utils/find.js'; +import { Task } from './utils/types.js'; -// Task type definition -interface Task { - id: string; - title: string; - description: string; - type: 'code' | 'research' | 'content' | 'automation'; - status: 'todo' | 'in-progress' | 'review' | 'done'; - priority: 'low' | 'medium' | 'high'; - project?: string; - tags?: string[]; - created: string; - updated: string; - git?: { - repo: string; - branch: string; - baseBranch: string; - worktreePath?: string; - }; - attempt?: { - id: string; - agent: string; - status: string; - started?: string; - ended?: string; - }; - review?: { - decision?: string; - decidedAt?: string; - summary?: string; - }; -} - -// API helper -async function api<T>(path: string, options?: RequestInit): Promise<T> { - const res = await fetch(`${API_BASE}${path}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers, - }, - }); - - if (!res.ok) { - const error = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(error.error || `API error: ${res.status}`); - } - - if (res.status === 204) return undefined as T; - return res.json(); -} - -// Tool input schemas -const ListTasksSchema = z.object({ - status: z.enum(['todo', 'in-progress', 'review', 'done']).optional(), - type: z.enum(['code', 'research', 'content', 'automation']).optional(), - project: z.string().optional(), -}); - -const CreateTaskSchema = z.object({ - title: z.string().min(1), - description: z.string().optional(), - type: z.enum(['code', 'research', 'content', 'automation']).default('code'), - priority: z.enum(['low', 'medium', 'high']).default('medium'), - project: z.string().optional(), -}); - -const UpdateTaskSchema = z.object({ - id: z.string().min(1), - title: z.string().optional(), - description: z.string().optional(), - status: z.enum(['todo', 'in-progress', 'review', 'done']).optional(), - type: z.enum(['code', 'research', 'content', 'automation']).optional(), - priority: z.enum(['low', 'medium', 'high']).optional(), - project: z.string().optional(), -}); - -const TaskIdSchema = z.object({ - id: z.string().min(1), -}); - -const StartAgentSchema = z.object({ - id: z.string().min(1), - agent: z.enum(['claude-code', 'amp', 'copilot', 'gemini', 'veritas']).default('claude-code'), -}); - -const StartAutomationSchema = z.object({ - id: z.string().min(1), - sessionKey: z.string().optional(), -}); - -const CompleteAutomationSchema = z.object({ - id: z.string().min(1), - result: z.string().optional(), - failed: z.boolean().default(false), -}); +// Import tool modules +import { taskTools, handleTaskTool } from './tools/tasks.js'; +import { agentTools, handleAgentTool } from './tools/agents.js'; +import { automationTools, handleAutomationTool } from './tools/automation.js'; +import { notificationTools, handleNotificationTool } from './tools/notifications.js'; +import { summaryTools, handleSummaryTool } from './tools/summary.js'; // Create MCP server const server = new Server( @@ -121,308 +34,19 @@ const server = new Server( } ); -// Find task by ID (supports partial matching) -async function findTask(id: string): Promise<Task | null> { - const tasks = await api<Task[]>('/api/tasks'); - return tasks.find(t => t.id === id || t.id.endsWith(id)) || null; -} +// Combine all tools +const allTools = [ + ...taskTools, + ...agentTools, + ...automationTools, + ...notificationTools, + ...summaryTools, +]; // List available tools server.setRequestHandler(ListToolsRequestSchema, async () => { return { - tools: [ - { - name: 'list_tasks', - description: 'List all tasks in Veritas Kanban. Can filter by status, type, or project.', - inputSchema: { - type: 'object', - properties: { - status: { - type: 'string', - enum: ['todo', 'in-progress', 'review', 'done'], - description: 'Filter by task status', - }, - type: { - type: 'string', - enum: ['code', 'research', 'content', 'automation'], - description: 'Filter by task type', - }, - project: { - type: 'string', - description: 'Filter by project name', - }, - }, - }, - }, - { - name: 'get_task', - description: 'Get details of a specific task by ID (supports partial ID matching)', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'Task ID or partial ID (last 6+ characters)', - }, - }, - required: ['id'], - }, - }, - { - name: 'create_task', - description: 'Create a new task in Veritas Kanban', - inputSchema: { - type: 'object', - properties: { - title: { - type: 'string', - description: 'Task title', - }, - description: { - type: 'string', - description: 'Task description', - }, - type: { - type: 'string', - enum: ['code', 'research', 'content', 'automation'], - description: 'Task type (default: code)', - }, - priority: { - type: 'string', - enum: ['low', 'medium', 'high'], - description: 'Task priority (default: medium)', - }, - project: { - type: 'string', - description: 'Project name', - }, - }, - required: ['title'], - }, - }, - { - name: 'update_task', - description: 'Update an existing task', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'Task ID or partial ID', - }, - title: { - type: 'string', - description: 'New title', - }, - description: { - type: 'string', - description: 'New description', - }, - status: { - type: 'string', - enum: ['todo', 'in-progress', 'review', 'done'], - description: 'New status', - }, - type: { - type: 'string', - enum: ['code', 'research', 'content', 'automation'], - description: 'New type', - }, - priority: { - type: 'string', - enum: ['low', 'medium', 'high'], - description: 'New priority', - }, - project: { - type: 'string', - description: 'New project', - }, - }, - required: ['id'], - }, - }, - { - name: 'start_agent', - description: 'Start an AI coding agent on a code task (requires worktree)', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'Task ID or partial ID', - }, - agent: { - type: 'string', - enum: ['claude-code', 'amp', 'copilot', 'gemini'], - description: 'Agent to use (default: claude-code)', - }, - }, - required: ['id'], - }, - }, - { - name: 'stop_agent', - description: 'Stop a running agent on a task', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'Task ID or partial ID', - }, - }, - required: ['id'], - }, - }, - { - name: 'archive_task', - description: 'Archive a completed task', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'Task ID or partial ID', - }, - }, - required: ['id'], - }, - }, - { - name: 'delete_task', - description: 'Delete a task permanently', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'Task ID or partial ID', - }, - }, - required: ['id'], - }, - }, - { - name: 'list_pending_automation', - description: 'List automation tasks waiting to be executed by Veritas', - inputSchema: { - type: 'object', - properties: {}, - }, - }, - { - name: 'list_running_automation', - description: 'List automation tasks currently being executed', - inputSchema: { - type: 'object', - properties: {}, - }, - }, - { - name: 'start_automation', - description: 'Start an automation task via Veritas sub-agent', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'Task ID or partial ID', - }, - sessionKey: { - type: 'string', - description: 'Clawdbot session key (optional)', - }, - }, - required: ['id'], - }, - }, - { - name: 'complete_automation', - description: 'Mark an automation task as complete or failed', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'Task ID or partial ID', - }, - result: { - type: 'string', - description: 'Result summary', - }, - failed: { - type: 'boolean', - description: 'Mark as failed instead of complete', - }, - }, - required: ['id'], - }, - }, - { - name: 'get_summary', - description: 'Get overall kanban summary (status counts, projects, high-priority)', - inputSchema: { - type: 'object', - properties: {}, - }, - }, - { - name: 'get_memory_summary', - description: 'Get task summary formatted for memory files (completed tasks, active high-priority, project progress)', - inputSchema: { - type: 'object', - properties: { - hours: { - type: 'number', - description: 'Hours to look back (default: 24)', - }, - }, - }, - }, - { - name: 'create_notification', - description: 'Create a notification (for Teams delivery)', - inputSchema: { - type: 'object', - properties: { - type: { - type: 'string', - enum: ['info', 'error', 'milestone', 'high_priority', 'agent_complete', 'agent_failed', 'needs_review', 'task_done'], - description: 'Notification type', - }, - title: { - type: 'string', - description: 'Notification title', - }, - message: { - type: 'string', - description: 'Notification message', - }, - taskId: { - type: 'string', - description: 'Related task ID (optional)', - }, - }, - required: ['type', 'title', 'message'], - }, - }, - { - name: 'get_pending_notifications', - description: 'Get pending notifications formatted for Teams', - inputSchema: { - type: 'object', - properties: {}, - }, - }, - { - name: 'check_notifications', - description: 'Check for tasks that need notifications (review ready, agent failed, etc.)', - inputSchema: { - type: 'object', - properties: {}, - }, - }, - ], + tools: allTools, }; }); @@ -431,314 +55,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { - switch (name) { - case 'list_tasks': { - const params = ListTasksSchema.parse(args || {}); - let tasks = await api<Task[]>('/api/tasks'); - - if (params.status) { - tasks = tasks.filter(t => t.status === params.status); - } - if (params.type) { - tasks = tasks.filter(t => t.type === params.type); - } - if (params.project) { - tasks = tasks.filter(t => t.project === params.project); - } - - return { - content: [ - { - type: 'text', - text: JSON.stringify(tasks, null, 2), - }, - ], - }; - } - - case 'get_task': { - const { id } = TaskIdSchema.parse(args); - const task = await findTask(id); - - if (!task) { - return { - content: [{ type: 'text', text: `Task not found: ${id}` }], - isError: true, - }; - } - - return { - content: [{ type: 'text', text: JSON.stringify(task, null, 2) }], - }; - } - - case 'create_task': { - const params = CreateTaskSchema.parse(args); - const task = await api<Task>('/api/tasks', { - method: 'POST', - body: JSON.stringify(params), - }); - - return { - content: [ - { - type: 'text', - text: `Task created: ${task.id}\n${JSON.stringify(task, null, 2)}`, - }, - ], - }; - } - - case 'update_task': { - const { id, ...updates } = UpdateTaskSchema.parse(args); - const task = await findTask(id); - - if (!task) { - return { - content: [{ type: 'text', text: `Task not found: ${id}` }], - isError: true, - }; - } - - const updated = await api<Task>(`/api/tasks/${task.id}`, { - method: 'PATCH', - body: JSON.stringify(updates), - }); - - return { - content: [ - { - type: 'text', - text: `Task updated: ${updated.id}\n${JSON.stringify(updated, null, 2)}`, - }, - ], - }; - } - - case 'start_agent': { - const { id, agent } = StartAgentSchema.parse(args); - const task = await findTask(id); - - if (!task) { - return { - content: [{ type: 'text', text: `Task not found: ${id}` }], - isError: true, - }; - } - - if (task.type !== 'code') { - return { - content: [{ type: 'text', text: 'Can only start agents on code tasks' }], - isError: true, - }; - } - - if (!task.git?.worktreePath) { - return { - content: [{ type: 'text', text: 'Task needs a worktree first' }], - isError: true, - }; - } - - const result = await api<{ attemptId: string }>(`/api/agents/${task.id}/start`, { - method: 'POST', - body: JSON.stringify({ agent }), - }); - - return { - content: [ - { - type: 'text', - text: `Agent started: ${agent}\nAttempt ID: ${result.attemptId}\nWorking in: ${task.git.worktreePath}`, - }, - ], - }; - } - - case 'stop_agent': { - const { id } = TaskIdSchema.parse(args); - const task = await findTask(id); - - if (!task) { - return { - content: [{ type: 'text', text: `Task not found: ${id}` }], - isError: true, - }; - } - - await api(`/api/agents/${task.id}/stop`, { method: 'POST' }); - - return { - content: [{ type: 'text', text: 'Agent stopped' }], - }; - } - - case 'archive_task': { - const { id } = TaskIdSchema.parse(args); - const task = await findTask(id); - - if (!task) { - return { - content: [{ type: 'text', text: `Task not found: ${id}` }], - isError: true, - }; - } - - await api(`/api/tasks/${task.id}/archive`, { method: 'POST' }); - - return { - content: [{ type: 'text', text: `Task archived: ${task.id}` }], - }; - } - - case 'delete_task': { - const { id } = TaskIdSchema.parse(args); - const task = await findTask(id); - - if (!task) { - return { - content: [{ type: 'text', text: `Task not found: ${id}` }], - isError: true, - }; - } - - await api(`/api/tasks/${task.id}`, { method: 'DELETE' }); - - return { - content: [{ type: 'text', text: `Task deleted: ${task.id}` }], - }; - } - - case 'list_pending_automation': { - const tasks = await api<Task[]>('/api/automation/pending'); - return { - content: [{ type: 'text', text: JSON.stringify(tasks, null, 2) }], - }; - } - - case 'list_running_automation': { - const tasks = await api<Task[]>('/api/automation/running'); - return { - content: [{ type: 'text', text: JSON.stringify(tasks, null, 2) }], - }; - } - - case 'start_automation': { - const { id, sessionKey } = StartAutomationSchema.parse(args); - const task = await findTask(id); - - if (!task) { - return { - content: [{ type: 'text', text: `Task not found: ${id}` }], - isError: true, - }; - } - - const result = await api<{ taskId: string; attemptId: string; title: string; description: string }>( - `/api/automation/${task.id}/start`, - { - method: 'POST', - body: JSON.stringify({ sessionKey }), - } - ); - - return { - content: [ - { - type: 'text', - text: `Automation started\nTask: ${result.title}\nAttempt: ${result.attemptId}\n\nDescription:\n${result.description}`, - }, - ], - }; - } - - case 'complete_automation': { - const { id, result, failed } = CompleteAutomationSchema.parse(args); - const task = await findTask(id); - - if (!task) { - return { - content: [{ type: 'text', text: `Task not found: ${id}` }], - isError: true, - }; - } - - const response = await api<{ taskId: string; status: string }>( - `/api/automation/${task.id}/complete`, - { - method: 'POST', - body: JSON.stringify({ result, status: failed ? 'failed' : 'complete' }), - } - ); - - return { - content: [ - { - type: 'text', - text: `Automation ${response.status === 'complete' ? 'completed' : 'marked as failed'}: ${task.id}`, - }, - ], - }; - } - - case 'get_summary': { - const summary = await api<unknown>('/api/summary'); - return { - content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }], - }; - } - - case 'get_memory_summary': { - const hours = (args as { hours?: number })?.hours || 24; - const res = await fetch(`${API_BASE}/api/summary/memory?hours=${hours}`); - const markdown = await res.text(); - return { - content: [{ type: 'text', text: markdown }], - }; - } - - case 'create_notification': { - const { type, title, message, taskId } = args as { type: string; title: string; message: string; taskId?: string }; - const notification = await api<{ id: string }>('/api/notifications', { - method: 'POST', - body: JSON.stringify({ type, title, message, taskId }), - }); - return { - content: [{ type: 'text', text: `Notification created: ${notification.id}` }], - }; - } - - case 'get_pending_notifications': { - const result = await api<{ count: number; messages: { text: string }[] }>('/api/notifications/pending'); - if (result.count === 0) { - return { - content: [{ type: 'text', text: 'No pending notifications' }], - }; - } - return { - content: [{ type: 'text', text: result.messages.map(m => m.text).join('\n\n---\n\n') }], - }; - } - - case 'check_notifications': { - const result = await api<{ checked: number; created: number; notifications: { title: string }[] }>('/api/notifications/check', { - method: 'POST', - }); - let text = `Checked ${result.checked} tasks. Created ${result.created} new notifications.`; - if (result.notifications.length > 0) { - text += '\n\nNew notifications:\n' + result.notifications.map(n => `- ${n.title}`).join('\n'); - } - return { - content: [{ type: 'text', text }], - }; - } - - default: - return { - content: [{ type: 'text', text: `Unknown tool: ${name}` }], - isError: true, - }; + // Route to appropriate handler + if (taskTools.some(t => t.name === name)) { + return await handleTaskTool(name, args); } + if (agentTools.some(t => t.name === name)) { + return await handleAgentTool(name, args); + } + if (automationTools.some(t => t.name === name)) { + return await handleAutomationTool(name, args); + } + if (notificationTools.some(t => t.name === name)) { + return await handleNotificationTool(name, args); + } + if (summaryTools.some(t => t.name === name)) { + return await handleSummaryTool(name, args); + } + + return { + content: [{ type: 'text', text: `Unknown tool: ${name}` }], + isError: true, + }; } catch (error) { return { content: [ diff --git a/mcp/src/tools/agents.ts b/mcp/src/tools/agents.ts new file mode 100644 index 00000000..cebaf8ca --- /dev/null +++ b/mcp/src/tools/agents.ts @@ -0,0 +1,113 @@ +import { z } from 'zod'; +import { api } from '../utils/api.js'; +import { findTask } from '../utils/find.js'; + +const StartAgentSchema = z.object({ + id: z.string().min(1), + agent: z.enum(['claude-code', 'amp', 'copilot', 'gemini', 'veritas']).default('claude-code'), +}); + +const TaskIdSchema = z.object({ + id: z.string().min(1), +}); + +export const agentTools = [ + { + name: 'start_agent', + description: 'Start an AI coding agent on a code task (requires worktree)', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Task ID or partial ID', + }, + agent: { + type: 'string', + enum: ['claude-code', 'amp', 'copilot', 'gemini'], + description: 'Agent to use (default: claude-code)', + }, + }, + required: ['id'], + }, + }, + { + name: 'stop_agent', + description: 'Stop a running agent on a task', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Task ID or partial ID', + }, + }, + required: ['id'], + }, + }, +]; + +export async function handleAgentTool(name: string, args: any): Promise<any> { + switch (name) { + case 'start_agent': { + const { id, agent } = StartAgentSchema.parse(args); + const task = await findTask(id); + + if (!task) { + return { + content: [{ type: 'text', text: `Task not found: ${id}` }], + isError: true, + }; + } + + if (task.type !== 'code') { + return { + content: [{ type: 'text', text: 'Can only start agents on code tasks' }], + isError: true, + }; + } + + if (!task.git?.worktreePath) { + return { + content: [{ type: 'text', text: 'Task needs a worktree first' }], + isError: true, + }; + } + + const result = await api<{ attemptId: string }>(`/api/agents/${task.id}/start`, { + method: 'POST', + body: JSON.stringify({ agent }), + }); + + return { + content: [ + { + type: 'text', + text: `Agent started: ${agent}\nAttempt ID: ${result.attemptId}\nWorking in: ${task.git.worktreePath}`, + }, + ], + }; + } + + case 'stop_agent': { + const { id } = TaskIdSchema.parse(args); + const task = await findTask(id); + + if (!task) { + return { + content: [{ type: 'text', text: `Task not found: ${id}` }], + isError: true, + }; + } + + await api(`/api/agents/${task.id}/stop`, { method: 'POST' }); + + return { + content: [{ type: 'text', text: 'Agent stopped' }], + }; + } + + default: + throw new Error(`Unknown agent tool: ${name}`); + } +} diff --git a/mcp/src/tools/automation.ts b/mcp/src/tools/automation.ts new file mode 100644 index 00000000..96d96a34 --- /dev/null +++ b/mcp/src/tools/automation.ts @@ -0,0 +1,153 @@ +import { z } from 'zod'; +import { api } from '../utils/api.js'; +import { findTask } from '../utils/find.js'; +import { Task } from '../utils/types.js'; + +const StartAutomationSchema = z.object({ + id: z.string().min(1), + sessionKey: z.string().optional(), +}); + +const CompleteAutomationSchema = z.object({ + id: z.string().min(1), + result: z.string().optional(), + failed: z.boolean().default(false), +}); + +export const automationTools = [ + { + name: 'list_pending_automation', + description: 'List automation tasks waiting to be executed by Veritas', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'list_running_automation', + description: 'List automation tasks currently being executed', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'start_automation', + description: 'Start an automation task via Veritas sub-agent', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Task ID or partial ID', + }, + sessionKey: { + type: 'string', + description: 'Clawdbot session key (optional)', + }, + }, + required: ['id'], + }, + }, + { + name: 'complete_automation', + description: 'Mark an automation task as complete or failed', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Task ID or partial ID', + }, + result: { + type: 'string', + description: 'Result summary', + }, + failed: { + type: 'boolean', + description: 'Mark as failed instead of complete', + }, + }, + required: ['id'], + }, + }, +]; + +export async function handleAutomationTool(name: string, args: any): Promise<any> { + switch (name) { + case 'list_pending_automation': { + const tasks = await api<Task[]>('/api/automation/pending'); + return { + content: [{ type: 'text', text: JSON.stringify(tasks, null, 2) }], + }; + } + + case 'list_running_automation': { + const tasks = await api<Task[]>('/api/automation/running'); + return { + content: [{ type: 'text', text: JSON.stringify(tasks, null, 2) }], + }; + } + + case 'start_automation': { + const { id, sessionKey } = StartAutomationSchema.parse(args); + const task = await findTask(id); + + if (!task) { + return { + content: [{ type: 'text', text: `Task not found: ${id}` }], + isError: true, + }; + } + + const result = await api<{ taskId: string; attemptId: string; title: string; description: string }>( + `/api/automation/${task.id}/start`, + { + method: 'POST', + body: JSON.stringify({ sessionKey }), + } + ); + + return { + content: [ + { + type: 'text', + text: `Automation started\nTask: ${result.title}\nAttempt: ${result.attemptId}\n\nDescription:\n${result.description}`, + }, + ], + }; + } + + case 'complete_automation': { + const { id, result, failed } = CompleteAutomationSchema.parse(args); + const task = await findTask(id); + + if (!task) { + return { + content: [{ type: 'text', text: `Task not found: ${id}` }], + isError: true, + }; + } + + const response = await api<{ taskId: string; status: string }>( + `/api/automation/${task.id}/complete`, + { + method: 'POST', + body: JSON.stringify({ result, status: failed ? 'failed' : 'complete' }), + } + ); + + return { + content: [ + { + type: 'text', + text: `Automation ${response.status === 'complete' ? 'completed' : 'marked as failed'}: ${task.id}`, + }, + ], + }; + } + + default: + throw new Error(`Unknown automation tool: ${name}`); + } +} diff --git a/mcp/src/tools/notifications.ts b/mcp/src/tools/notifications.ts new file mode 100644 index 00000000..ce6c97e0 --- /dev/null +++ b/mcp/src/tools/notifications.ts @@ -0,0 +1,90 @@ +import { api } from '../utils/api.js'; + +export const notificationTools = [ + { + name: 'create_notification', + description: 'Create a notification (for Teams delivery)', + inputSchema: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['info', 'error', 'milestone', 'high_priority', 'agent_complete', 'agent_failed', 'needs_review', 'task_done'], + description: 'Notification type', + }, + title: { + type: 'string', + description: 'Notification title', + }, + message: { + type: 'string', + description: 'Notification message', + }, + taskId: { + type: 'string', + description: 'Related task ID (optional)', + }, + }, + required: ['type', 'title', 'message'], + }, + }, + { + name: 'get_pending_notifications', + description: 'Get pending notifications formatted for Teams', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'check_notifications', + description: 'Check for tasks that need notifications (review ready, agent failed, etc.)', + inputSchema: { + type: 'object', + properties: {}, + }, + }, +]; + +export async function handleNotificationTool(name: string, args: any): Promise<any> { + switch (name) { + case 'create_notification': { + const { type, title, message, taskId } = args as { type: string; title: string; message: string; taskId?: string }; + const notification = await api<{ id: string }>('/api/notifications', { + method: 'POST', + body: JSON.stringify({ type, title, message, taskId }), + }); + return { + content: [{ type: 'text', text: `Notification created: ${notification.id}` }], + }; + } + + case 'get_pending_notifications': { + const result = await api<{ count: number; messages: { text: string }[] }>('/api/notifications/pending'); + if (result.count === 0) { + return { + content: [{ type: 'text', text: 'No pending notifications' }], + }; + } + return { + content: [{ type: 'text', text: result.messages.map(m => m.text).join('\n\n---\n\n') }], + }; + } + + case 'check_notifications': { + const result = await api<{ checked: number; created: number; notifications: { title: string }[] }>('/api/notifications/check', { + method: 'POST', + }); + let text = `Checked ${result.checked} tasks. Created ${result.created} new notifications.`; + if (result.notifications.length > 0) { + text += '\n\nNew notifications:\n' + result.notifications.map(n => `- ${n.title}`).join('\n'); + } + return { + content: [{ type: 'text', text }], + }; + } + + default: + throw new Error(`Unknown notification tool: ${name}`); + } +} diff --git a/mcp/src/tools/summary.ts b/mcp/src/tools/summary.ts new file mode 100644 index 00000000..c91bc4c9 --- /dev/null +++ b/mcp/src/tools/summary.ts @@ -0,0 +1,48 @@ +import { api, API_BASE } from '../utils/api.js'; + +export const summaryTools = [ + { + name: 'get_summary', + description: 'Get overall kanban summary (status counts, projects, high-priority)', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'get_memory_summary', + description: 'Get task summary formatted for memory files (completed tasks, active high-priority, project progress)', + inputSchema: { + type: 'object', + properties: { + hours: { + type: 'number', + description: 'Hours to look back (default: 24)', + }, + }, + }, + }, +]; + +export async function handleSummaryTool(name: string, args: any): Promise<any> { + switch (name) { + case 'get_summary': { + const summary = await api<unknown>('/api/summary'); + return { + content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }], + }; + } + + case 'get_memory_summary': { + const hours = (args as { hours?: number })?.hours || 24; + const res = await fetch(`${API_BASE}/api/summary/memory?hours=${hours}`); + const markdown = await res.text(); + return { + content: [{ type: 'text', text: markdown }], + }; + } + + default: + throw new Error(`Unknown summary tool: ${name}`); + } +} diff --git a/mcp/src/tools/tasks.ts b/mcp/src/tools/tasks.ts new file mode 100644 index 00000000..2deb9681 --- /dev/null +++ b/mcp/src/tools/tasks.ts @@ -0,0 +1,300 @@ +import { z } from 'zod'; +import { api } from '../utils/api.js'; +import { findTask } from '../utils/find.js'; +import { Task } from '../utils/types.js'; + +// Tool input schemas +const ListTasksSchema = z.object({ + status: z.enum(['todo', 'in-progress', 'review', 'done']).optional(), + type: z.enum(['code', 'research', 'content', 'automation']).optional(), + project: z.string().optional(), +}); + +const CreateTaskSchema = z.object({ + title: z.string().min(1), + description: z.string().optional(), + type: z.enum(['code', 'research', 'content', 'automation']).default('code'), + priority: z.enum(['low', 'medium', 'high']).default('medium'), + project: z.string().optional(), +}); + +const UpdateTaskSchema = z.object({ + id: z.string().min(1), + title: z.string().optional(), + description: z.string().optional(), + status: z.enum(['todo', 'in-progress', 'review', 'done']).optional(), + type: z.enum(['code', 'research', 'content', 'automation']).optional(), + priority: z.enum(['low', 'medium', 'high']).optional(), + project: z.string().optional(), +}); + +const TaskIdSchema = z.object({ + id: z.string().min(1), +}); + +export const taskTools = [ + { + name: 'list_tasks', + description: 'List all tasks in Veritas Kanban. Can filter by status, type, or project.', + inputSchema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['todo', 'in-progress', 'review', 'done'], + description: 'Filter by task status', + }, + type: { + type: 'string', + enum: ['code', 'research', 'content', 'automation'], + description: 'Filter by task type', + }, + project: { + type: 'string', + description: 'Filter by project name', + }, + }, + }, + }, + { + name: 'get_task', + description: 'Get details of a specific task by ID (supports partial ID matching)', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Task ID or partial ID (last 6+ characters)', + }, + }, + required: ['id'], + }, + }, + { + name: 'create_task', + description: 'Create a new task in Veritas Kanban', + inputSchema: { + type: 'object', + properties: { + title: { + type: 'string', + description: 'Task title', + }, + description: { + type: 'string', + description: 'Task description', + }, + type: { + type: 'string', + enum: ['code', 'research', 'content', 'automation'], + description: 'Task type (default: code)', + }, + priority: { + type: 'string', + enum: ['low', 'medium', 'high'], + description: 'Task priority (default: medium)', + }, + project: { + type: 'string', + description: 'Project name', + }, + }, + required: ['title'], + }, + }, + { + name: 'update_task', + description: 'Update an existing task', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Task ID or partial ID', + }, + title: { + type: 'string', + description: 'New title', + }, + description: { + type: 'string', + description: 'New description', + }, + status: { + type: 'string', + enum: ['todo', 'in-progress', 'review', 'done'], + description: 'New status', + }, + type: { + type: 'string', + enum: ['code', 'research', 'content', 'automation'], + description: 'New type', + }, + priority: { + type: 'string', + enum: ['low', 'medium', 'high'], + description: 'New priority', + }, + project: { + type: 'string', + description: 'New project', + }, + }, + required: ['id'], + }, + }, + { + name: 'archive_task', + description: 'Archive a completed task', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Task ID or partial ID', + }, + }, + required: ['id'], + }, + }, + { + name: 'delete_task', + description: 'Delete a task permanently', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Task ID or partial ID', + }, + }, + required: ['id'], + }, + }, +]; + +export async function handleTaskTool(name: string, args: any): Promise<any> { + switch (name) { + case 'list_tasks': { + const params = ListTasksSchema.parse(args || {}); + let tasks = await api<Task[]>('/api/tasks'); + + if (params.status) { + tasks = tasks.filter(t => t.status === params.status); + } + if (params.type) { + tasks = tasks.filter(t => t.type === params.type); + } + if (params.project) { + tasks = tasks.filter(t => t.project === params.project); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(tasks, null, 2), + }, + ], + }; + } + + case 'get_task': { + const { id } = TaskIdSchema.parse(args); + const task = await findTask(id); + + if (!task) { + return { + content: [{ type: 'text', text: `Task not found: ${id}` }], + isError: true, + }; + } + + return { + content: [{ type: 'text', text: JSON.stringify(task, null, 2) }], + }; + } + + case 'create_task': { + const params = CreateTaskSchema.parse(args); + const task = await api<Task>('/api/tasks', { + method: 'POST', + body: JSON.stringify(params), + }); + + return { + content: [ + { + type: 'text', + text: `Task created: ${task.id}\n${JSON.stringify(task, null, 2)}`, + }, + ], + }; + } + + case 'update_task': { + const { id, ...updates } = UpdateTaskSchema.parse(args); + const task = await findTask(id); + + if (!task) { + return { + content: [{ type: 'text', text: `Task not found: ${id}` }], + isError: true, + }; + } + + const updated = await api<Task>(`/api/tasks/${task.id}`, { + method: 'PATCH', + body: JSON.stringify(updates), + }); + + return { + content: [ + { + type: 'text', + text: `Task updated: ${updated.id}\n${JSON.stringify(updated, null, 2)}`, + }, + ], + }; + } + + case 'archive_task': { + const { id } = TaskIdSchema.parse(args); + const task = await findTask(id); + + if (!task) { + return { + content: [{ type: 'text', text: `Task not found: ${id}` }], + isError: true, + }; + } + + await api(`/api/tasks/${task.id}/archive`, { method: 'POST' }); + + return { + content: [{ type: 'text', text: `Task archived: ${task.id}` }], + }; + } + + case 'delete_task': { + const { id } = TaskIdSchema.parse(args); + const task = await findTask(id); + + if (!task) { + return { + content: [{ type: 'text', text: `Task not found: ${id}` }], + isError: true, + }; + } + + await api(`/api/tasks/${task.id}`, { method: 'DELETE' }); + + return { + content: [{ type: 'text', text: `Task deleted: ${task.id}` }], + }; + } + + default: + throw new Error(`Unknown task tool: ${name}`); + } +} diff --git a/mcp/src/utils/api.ts b/mcp/src/utils/api.ts new file mode 100644 index 00000000..3e7476d4 --- /dev/null +++ b/mcp/src/utils/api.ts @@ -0,0 +1,2 @@ +// Re-export shared API client +export { api, createApiClient, API_BASE } from '@veritas-kanban/shared'; diff --git a/mcp/src/utils/find.ts b/mcp/src/utils/find.ts new file mode 100644 index 00000000..cba20f69 --- /dev/null +++ b/mcp/src/utils/find.ts @@ -0,0 +1,2 @@ +// Re-export shared findTask +export { findTask } from '@veritas-kanban/shared'; diff --git a/mcp/src/utils/types.ts b/mcp/src/utils/types.ts new file mode 100644 index 00000000..5cc0e2c7 --- /dev/null +++ b/mcp/src/utils/types.ts @@ -0,0 +1,2 @@ +// Re-export shared types +export type { Task } from '@veritas-kanban/shared'; diff --git a/package.json b/package.json index 9211bf60..98019395 100644 --- a/package.json +++ b/package.json @@ -25,5 +25,10 @@ "prettier": "^3.4.0", "typescript": "^5.7.0", "vitest": "^4.0.18" + }, + "pnpm": { + "overrides": { + "hono": ">=4.11.7" + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 805277d7..c1f0a94f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + hono: '>=4.11.7' + importers: .: @@ -26,6 +29,9 @@ importers: cli: dependencies: + '@veritas-kanban/shared': + specifier: workspace:* + version: link:../shared chalk: specifier: ^5.3.0 version: 5.6.2 @@ -46,8 +52,14 @@ importers: mcp: dependencies: '@modelcontextprotocol/sdk': - specifier: ^1.0.0 - version: 1.25.3(hono@4.11.5)(zod@3.25.76) + specifier: ^1.25.3 + version: 1.25.3(hono@4.11.7)(zod@3.25.76) + '@veritas-kanban/shared': + specifier: workspace:* + version: link:../shared + hono: + specifier: '>=4.11.7' + version: 4.11.7 zod: specifier: ^3.24.0 version: 3.25.76 @@ -70,6 +82,9 @@ importers: cors: specifier: ^2.8.5 version: 2.8.6 + exceljs: + specifier: ^4.4.0 + version: 4.4.0 express: specifier: ^4.21.0 version: 4.22.1 @@ -97,9 +112,6 @@ importers: ws: specifier: ^8.18.0 version: 8.19.0 - xlsx: - specifier: ^0.18.5 - version: 0.18.5 zod: specifier: ^3.24.0 version: 3.25.76 @@ -107,6 +119,9 @@ importers: '@types/cors': specifier: ^2.8.17 version: 2.8.19 + '@types/exceljs': + specifier: ^1.3.2 + version: 1.3.2 '@types/express': specifier: ^5.0.0 version: 5.0.6 @@ -708,6 +723,12 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fast-csv/format@4.3.5': + resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==} + + '@fast-csv/parse@4.3.6': + resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==} + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -727,7 +748,7 @@ packages: resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4 + hono: '>=4.11.7' '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -1414,6 +1435,10 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/exceljs@1.3.2': + resolution: {integrity: sha512-etq4LpmL5vZKzf29U5u7cWjoCWGc67hTc2WrvwmcBUyRiHydUPg5ZXGFwBHcEkTLinyT6fRmJiVSRwf4RdnjUA==} + deprecated: This is a stub types definition. exceljs provides its own type definitions, so you do not need this installed. + '@types/express-serve-static-core@5.1.1': resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} @@ -1432,6 +1457,9 @@ packages: '@types/multer@2.0.0': resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==} + '@types/node@14.18.63': + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + '@types/node@22.19.7': resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==} @@ -1515,10 +1543,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - adler-32@1.3.1: - resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} - engines: {node: '>=0.8'} - ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -1551,6 +1575,18 @@ packages: append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -1571,6 +1607,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + autoprefixer@10.4.23: resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==} engines: {node: ^10 || ^12 || >=14} @@ -1588,10 +1627,20 @@ packages: resolution: {integrity: sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==} hasBin: true + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + binary@0.3.0: + resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bluebird@3.4.7: resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} @@ -1606,6 +1655,9 @@ packages: brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1615,9 +1667,23 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-indexof-polyfill@1.0.2: + resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} + engines: {node: '>=0.10'} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffers@0.1.1: + resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} + engines: {node: '>=0.2.0'} + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -1645,14 +1711,13 @@ packages: caniuse-lite@1.0.30001766: resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} - cfb@1.2.2: - resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} - engines: {node: '>=0.8'} - chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chainsaw@0.1.0: + resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1676,10 +1741,6 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - codepage@1.15.0: - resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} - engines: {node: '>=0.8'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1695,6 +1756,10 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1745,6 +1810,10 @@ packages: engines: {node: '>=0.8'} hasBin: true + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1757,6 +1826,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -1804,6 +1876,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -1817,6 +1892,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1915,6 +1993,10 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + exceljs@4.4.0: + resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} + engines: {node: '>=8.3.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1937,6 +2019,10 @@ packages: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} + fast-csv@4.3.6: + resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==} + engines: {node: '>=10.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1996,10 +2082,6 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - frac@1.1.2: - resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} - engines: {node: '>=0.8'} - fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -2011,11 +2093,22 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fstream@1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + deprecated: This package is no longer supported. + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -2050,6 +2143,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -2058,6 +2155,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gray-matter@4.0.3: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} @@ -2074,8 +2174,8 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - hono@4.11.5: - resolution: {integrity: sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g==} + hono@4.11.7: + resolution: {integrity: sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==} engines: {node: '>=16.9.0'} http-errors@2.0.1: @@ -2090,6 +2190,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2105,6 +2208,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2202,6 +2309,10 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2216,13 +2327,56 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + listenercount@1.0.1: + resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + lop@0.4.2: resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==} @@ -2297,6 +2451,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -2579,6 +2737,9 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -2607,6 +2768,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + rollup@4.56.0: resolution: {integrity: sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2631,6 +2797,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@5.0.1: + resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} + engines: {node: '>=10'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2705,10 +2875,6 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - ssf@0.11.2: - resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} - engines: {node: '>=0.8'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2775,6 +2941,10 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -2797,6 +2967,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tmp@0.2.5: + resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} + engines: {node: '>=14.14'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2805,6 +2979,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + traverse@0.3.9: + resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -2858,6 +3035,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unzipper@0.10.14: + resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -2894,6 +3074,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -2982,18 +3166,10 @@ packages: engines: {node: '>=8'} hasBin: true - wmf@1.0.2: - resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} - engines: {node: '>=0.8'} - word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - word@0.3.0: - resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} - engines: {node: '>=0.8'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3013,15 +3189,13 @@ packages: utf-8-validate: optional: true - xlsx@0.18.5: - resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} - engines: {node: '>=0.8'} - hasBin: true - xmlbuilder@10.1.1: resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==} engines: {node: '>=4.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -3045,6 +3219,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -3396,6 +3574,25 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fast-csv/format@4.3.5': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isequal: 4.5.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@4.3.6': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -3413,9 +3610,9 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@hono/node-server@1.19.9(hono@4.11.5)': + '@hono/node-server@1.19.9(hono@4.11.7)': dependencies: - hono: 4.11.5 + hono: 4.11.7 '@humanfs/core@0.19.1': {} @@ -3455,9 +3652,9 @@ snapshots: '@kwsites/promise-deferred@1.1.1': {} - '@modelcontextprotocol/sdk@1.25.3(hono@4.11.5)(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.9(hono@4.11.5) + '@hono/node-server': 1.19.9(hono@4.11.7) ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) content-type: 1.0.5 @@ -4092,6 +4289,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/exceljs@1.3.2': + dependencies: + exceljs: 4.4.0 + '@types/express-serve-static-core@5.1.1': dependencies: '@types/node': 22.19.7 @@ -4115,6 +4316,8 @@ snapshots: dependencies: '@types/express': 5.0.6 + '@types/node@14.18.63': {} + '@types/node@22.19.7': dependencies: undici-types: 6.21.0 @@ -4213,8 +4416,6 @@ snapshots: acorn@8.15.0: {} - adler-32@1.3.1: {} - ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -4248,6 +4449,42 @@ snapshots: append-field@1.0.0: {} + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + arg@5.0.2: {} argparse@1.0.10: @@ -4264,6 +4501,8 @@ snapshots: assertion-error@2.0.1: {} + async@3.2.6: {} + autoprefixer@10.4.23(postcss@8.5.6): dependencies: browserslist: 4.28.1 @@ -4279,8 +4518,21 @@ snapshots: baseline-browser-mapping@2.9.18: {} + big-integer@1.6.52: {} + binary-extensions@2.3.0: {} + binary@0.3.0: + dependencies: + buffers: 0.1.1 + chainsaw: 0.1.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + bluebird@3.4.7: {} body-parser@1.20.4: @@ -4319,6 +4571,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -4331,8 +4587,19 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer-crc32@0.2.13: {} + buffer-from@1.1.2: {} + buffer-indexof-polyfill@1.0.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffers@0.1.1: {} + busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -4355,13 +4622,12 @@ snapshots: caniuse-lite@1.0.30001766: {} - cfb@1.2.2: - dependencies: - adler-32: 1.3.1 - crc-32: 1.2.2 - chai@6.2.2: {} + chainsaw@0.1.0: + dependencies: + traverse: 0.3.9 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -4393,8 +4659,6 @@ snapshots: clsx@2.1.1: {} - codepage@1.15.0: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4405,6 +4669,13 @@ snapshots: commander@4.1.1: {} + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + concat-map@0.0.1: {} concat-stream@2.0.0: @@ -4448,6 +4719,11 @@ snapshots: crc-32@1.2.2: {} + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4458,6 +4734,8 @@ snapshots: csstype@3.2.3: {} + dayjs@1.11.19: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -4490,6 +4768,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + ee-first@1.1.1: {} electron-to-chromium@1.5.278: {} @@ -4498,6 +4780,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -4654,6 +4940,18 @@ snapshots: dependencies: eventsource-parser: 3.0.6 + exceljs@4.4.0: + dependencies: + archiver: 5.3.2 + dayjs: 1.11.19 + fast-csv: 4.3.6 + jszip: 3.10.1 + readable-stream: 3.6.2 + saxes: 5.0.1 + tmp: 0.2.5 + unzipper: 0.10.14 + uuid: 8.3.2 + expect-type@1.3.0: {} express-rate-limit@7.5.1(express@5.2.1): @@ -4733,6 +5031,11 @@ snapshots: dependencies: is-extendable: 0.1.1 + fast-csv@4.3.6: + dependencies: + '@fast-csv/format': 4.3.5 + '@fast-csv/parse': 4.3.6 + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -4802,17 +5105,26 @@ snapshots: forwarded@0.2.0: {} - frac@1.1.2: {} - fraction.js@5.3.4: {} fresh@0.5.2: {} fresh@2.0.0: {} + fs-constants@1.0.0: {} + + fs.realpath@1.0.0: {} + fsevents@2.3.3: optional: true + fstream@1.0.12: + dependencies: + graceful-fs: 4.2.11 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.7.1 + function-bind@1.1.2: {} gensync@1.0.0-beta.2: {} @@ -4851,10 +5163,21 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + globals@14.0.0: {} gopd@1.2.0: {} + graceful-fs@4.2.11: {} + gray-matter@4.0.3: dependencies: js-yaml: 3.14.2 @@ -4870,7 +5193,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.11.5: {} + hono@4.11.7: {} http-errors@2.0.1: dependencies: @@ -4888,6 +5211,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} immediate@3.0.6: {} @@ -4899,6 +5224,11 @@ snapshots: imurmurhash@0.1.4: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + inherits@2.0.4: {} ipaddr.js@1.9.1: {} @@ -4971,6 +5301,10 @@ snapshots: kind-of@6.0.3: {} + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -4984,12 +5318,40 @@ snapshots: lines-and-columns@1.2.4: {} + listenercount@1.0.1: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + + lodash.escaperegexp@4.1.2: {} + + lodash.flatten@4.4.0: {} + + lodash.groupby@4.6.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isequal@4.5.0: {} + + lodash.isfunction@3.0.9: {} + + lodash.isnil@4.0.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isundefined@3.0.1: {} + lodash.merge@4.6.2: {} + lodash.union@4.6.0: {} + + lodash.uniq@4.5.0: {} + lop@0.4.2: dependencies: duck: 0.1.12 @@ -5058,6 +5420,10 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.2 + minimist@1.2.8: {} mkdirp@0.5.6: @@ -5296,6 +5662,10 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.6 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -5316,6 +5686,10 @@ snapshots: reusify@1.1.0: {} + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + rollup@4.56.0: dependencies: '@types/estree': 1.0.8 @@ -5371,6 +5745,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@5.0.1: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} section-matter@1.0.0: @@ -5486,10 +5864,6 @@ snapshots: sprintf-js@1.0.3: {} - ssf@0.11.2: - dependencies: - frac: 1.1.2 - stackback@0.0.2: {} statuses@2.0.2: {} @@ -5574,6 +5948,14 @@ snapshots: - tsx - yaml + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -5593,12 +5975,16 @@ snapshots: tinyrainbow@3.0.3: {} + tmp@0.2.5: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toidentifier@1.0.1: {} + traverse@0.3.9: {} + tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} @@ -5639,6 +6025,19 @@ snapshots: unpipe@1.0.0: {} + unzipper@0.10.14: + dependencies: + big-integer: 1.6.52 + binary: 0.3.0 + bluebird: 3.4.7 + buffer-indexof-polyfill: 1.0.2 + duplexer2: 0.1.4 + fstream: 1.0.12 + graceful-fs: 4.2.11 + listenercount: 1.0.1 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -5668,6 +6067,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@8.3.2: {} + vary@1.1.2: {} vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0): @@ -5730,12 +6131,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - wmf@1.0.2: {} - word-wrap@1.2.5: {} - word@0.3.0: {} - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -5746,18 +6143,10 @@ snapshots: ws@8.19.0: {} - xlsx@0.18.5: - dependencies: - adler-32: 1.3.1 - cfb: 1.2.2 - codepage: 1.15.0 - crc-32: 1.2.2 - ssf: 0.11.2 - wmf: 1.0.2 - word: 0.3.0 - xmlbuilder@10.1.1: {} + xmlchars@2.2.0: {} + xtend@4.0.2: {} y18n@5.0.8: {} @@ -5778,6 +6167,12 @@ snapshots: yocto-queue@0.1.0: {} + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json index c60fbf95..f0f5f6b8 100644 --- a/server/.veritas-kanban/activity.json +++ b/server/.veritas-kanban/activity.json @@ -1,926 +1,1006 @@ [ { - "id": "activity_1769594729028_e3u00fq38", - "type": "status_changed", - "taskId": "task_20260128_F-XLhL", - "taskTitle": "US-1156: Settings accessibility (WCAG 2.1 AA)", - "details": { - "from": "todo", - "status": "in-progress" - }, - "timestamp": "2026-01-28T10:05:29.028Z" - }, - { - "id": "activity_1769594718437_r5xkcmuti", + "id": "activity_1769598455726_9m6dfmawo", "type": "comment_added", - "taskId": "task_20260128_8HU2XD", - "taskTitle": "US-1158: Replace alert() calls with toast notifications", + "taskId": "task_20260128_bIPwKK", + "taskTitle": "RF-14: Split God Components — Task Layer", "details": { "author": "Veritas", - "preview": "Replaced all 5 alert() calls with toast notificati..." + "preview": "Split 4 god components:\n\n• GitSection (645→78) — e..." }, - "timestamp": "2026-01-28T10:05:18.437Z" + "timestamp": "2026-01-28T11:07:35.726Z" }, { - "id": "activity_1769594718411_t76i5yv6c", + "id": "activity_1769598455703_wizi2w72n", "type": "status_changed", - "taskId": "task_20260128_8HU2XD", - "taskTitle": "US-1158: Replace alert() calls with toast notifications", + "taskId": "task_20260128_bIPwKK", + "taskTitle": "RF-14: Split God Components — Task Layer", "details": { "from": "in-progress", "status": "done" }, - "timestamp": "2026-01-28T10:05:18.411Z" + "timestamp": "2026-01-28T11:07:35.703Z" }, { - "id": "activity_1769594599726_5lp9h98h5", + "id": "activity_1769598190964_uestvqhwo", + "type": "comment_added", + "taskId": "task_20260128_80Bww2", + "taskTitle": "RF-06: Server Error Handling Middleware", + "details": { + "author": "Veritas", + "preview": "Created error handling middleware with AppError cl..." + }, + "timestamp": "2026-01-28T11:03:10.964Z" + }, + { + "id": "activity_1769598190943_2wedc751a", "type": "status_changed", - "taskId": "task_20260128_8HU2XD", - "taskTitle": "US-1158: Replace alert() calls with toast notifications", + "taskId": "task_20260128_80Bww2", + "taskTitle": "RF-06: Server Error Handling Middleware", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-28T11:03:10.943Z" + }, + { + "id": "activity_1769598189293_mfiwr82z1", + "type": "comment_added", + "taskId": "task_20260128_lePtYQ", + "taskTitle": "RF-19: Create Shared API Client Library", + "details": { + "author": "Veritas", + "preview": "Created shared API client (api-client.ts) with cre..." + }, + "timestamp": "2026-01-28T11:03:09.293Z" + }, + { + "id": "activity_1769598189269_ei9e6h2qm", + "type": "status_changed", + "taskId": "task_20260128_lePtYQ", + "taskTitle": "RF-19: Create Shared API Client Library", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-28T11:03:09.269Z" + }, + { + "id": "activity_1769598089757_7fgiag2kr", + "type": "comment_added", + "taskId": "task_20260128_DmkA14", + "taskTitle": "RF-21: Server Performance & Reliability", + "details": { + "author": "Veritas", + "preview": "Fixed N+1 dependency loading with batch resolution..." + }, + "timestamp": "2026-01-28T11:01:29.757Z" + }, + { + "id": "activity_1769598089736_dgcoou8fp", + "type": "status_changed", + "taskId": "task_20260128_DmkA14", + "taskTitle": "RF-21: Server Performance & Reliability", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-28T11:01:29.736Z" + }, + { + "id": "activity_1769598088502_zzb9tazwa", + "type": "task_created", + "taskId": "task_20260128_XP9oDM", + "taskTitle": "Document Claude Skills List", + "details": { + "type": "research", + "priority": "low", + "project": "brain" + }, + "timestamp": "2026-01-28T11:01:28.502Z" + }, + { + "id": "activity_1769598055446_btmwkbslc", + "type": "comment_added", + "taskId": "task_20260128_WYNpAQ", + "taskTitle": "RF-23: Extract Shared Utilities", + "details": { + "author": "Veritas", + "preview": "Created shared utils (path, format, constants). Re..." + }, + "timestamp": "2026-01-28T11:00:55.446Z" + }, + { + "id": "activity_1769598055418_pk29g6cuc", + "type": "status_changed", + "taskId": "task_20260128_WYNpAQ", + "taskTitle": "RF-23: Extract Shared Utilities", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-28T11:00:55.418Z" + }, + { + "id": "activity_1769598023288_jwheqtogc", + "type": "status_changed", + "taskId": "task_20260128_bIPwKK", + "taskTitle": "RF-14: Split God Components — Task Layer", "details": { "from": "todo", "status": "in-progress" }, - "timestamp": "2026-01-28T10:03:19.726Z" + "timestamp": "2026-01-28T11:00:23.288Z" }, { - "id": "activity_1769592539931_twcrqcpt5", - "type": "sprint_archived", - "taskId": "US-1100", - "taskTitle": "US-1100", + "id": "activity_1769598022881_ro0xdrhdm", + "type": "status_changed", + "taskId": "task_20260128_lePtYQ", + "taskTitle": "RF-19: Create Shared API Client Library", "details": { - "taskCount": 10 + "from": "todo", + "status": "in-progress" }, - "timestamp": "2026-01-28T09:28:59.931Z" + "timestamp": "2026-01-28T11:00:22.881Z" }, { - "id": "activity_1769592485058_6vdret7hw", + "id": "activity_1769597936244_662eea9k9", + "type": "status_changed", + "taskId": "task_20260128_DmkA14", + "taskTitle": "RF-21: Server Performance & Reliability", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-28T10:58:56.244Z" + }, + { + "id": "activity_1769597934952_9aixjwxma", + "type": "status_changed", + "taskId": "task_20260128_WYNpAQ", + "taskTitle": "RF-23: Extract Shared Utilities", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-28T10:58:54.952Z" + }, + { + "id": "activity_1769597879101_wwbdk687z", + "type": "comment_added", + "taskId": "task_20260128_q-NnH4", + "taskTitle": "RF-17: Modularize CLI (899 lines)", + "details": { + "author": "Veritas", + "preview": "Split 899-line CLI into modular command files unde..." + }, + "timestamp": "2026-01-28T10:57:59.101Z" + }, + { + "id": "activity_1769597879077_vpduqp956", + "type": "status_changed", + "taskId": "task_20260128_q-NnH4", + "taskTitle": "RF-17: Modularize CLI (899 lines)", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-28T10:57:59.077Z" + }, + { + "id": "activity_1769597847802_kzqfu8t7m", + "type": "comment_added", + "taskId": "task_20260128_9ljJmb", + "taskTitle": "RF-16: Frontend Accessibility Improvements", + "details": { + "author": "Veritas", + "preview": "Added ARIA labels, roles, sr-only text for color-o..." + }, + "timestamp": "2026-01-28T10:57:27.802Z" + }, + { + "id": "activity_1769597847749_rsjossoih", + "type": "status_changed", + "taskId": "task_20260128_9ljJmb", + "taskTitle": "RF-16: Frontend Accessibility Improvements", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-28T10:57:27.749Z" + }, + { + "id": "activity_1769597845960_ygox535pm", + "type": "comment_added", + "taskId": "task_20260128_OFkc0Z", + "taskTitle": "RF-18: Modularize MCP Server (843 lines)", + "details": { + "author": "Veritas", + "preview": "Split 843-line MCP server into modular tool handle..." + }, + "timestamp": "2026-01-28T10:57:25.960Z" + }, + { + "id": "activity_1769597845935_gt1tbplbv", + "type": "status_changed", + "taskId": "task_20260128_OFkc0Z", + "taskTitle": "RF-18: Modularize MCP Server (843 lines)", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-28T10:57:25.935Z" + }, + { + "id": "activity_1769597727572_50ynh5hr2", + "type": "status_changed", + "taskId": "task_20260128_9ljJmb", + "taskTitle": "RF-16: Frontend Accessibility Improvements", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-28T10:55:27.572Z" + }, + { + "id": "activity_1769597725797_u7m2ogoae", + "type": "status_changed", + "taskId": "task_20260128_OFkc0Z", + "taskTitle": "RF-18: Modularize MCP Server (843 lines)", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-28T10:55:25.797Z" + }, + { + "id": "activity_1769597725591_pfu0udd2b", + "type": "status_changed", + "taskId": "task_20260128_q-NnH4", + "taskTitle": "RF-17: Modularize CLI (899 lines)", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-28T10:55:25.591Z" + }, + { + "id": "activity_1769597723912_3smyv41mw", + "type": "status_changed", + "taskId": "task_20260128_UMDZyh", + "taskTitle": "RF-11: Consolidate Frontend API Layer", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-28T10:55:23.912Z" + }, + { + "id": "activity_1769597722842_uwutz6is6", + "type": "status_changed", + "taskId": "task_20260128_80Bww2", + "taskTitle": "RF-06: Server Error Handling Middleware", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-28T10:55:22.842Z" + }, + { + "id": "activity_1769597722772_q12wvry30", "type": "task_updated", - "taskId": "task_20260128_8KRThU", - "taskTitle": "FEATURE REQUEST: Change Review Column to Blocked", - "timestamp": "2026-01-28T09:28:05.058Z" + "taskId": "task_20260128_v5QxzF", + "taskTitle": "RF-01: Server Authentication & Authorization", + "timestamp": "2026-01-28T10:55:22.772Z" }, { - "id": "activity_1769592479575_p0swcv4xx", - "type": "status_changed", - "taskId": "task_20260128_8KRThU", - "taskTitle": "FEATURE REQUEST: Change Review Column to Blocked", - "details": { - "from": "in-progress", - "status": "done" - }, - "timestamp": "2026-01-28T09:27:59.575Z" - }, - { - "id": "activity_1769592048834_lnlgvfmr9", - "type": "status_changed", - "taskId": "task_20260128_8KRThU", - "taskTitle": "FEATURE REQUEST: Change Review Column to Blocked", - "details": { - "from": "todo", - "status": "in-progress" - }, - "timestamp": "2026-01-28T09:20:48.834Z" - }, - { - "id": "activity_1769592048818_rv4n02og5", - "type": "comment_added", - "taskId": "task_20260128_8KRThU", - "taskTitle": "FEATURE REQUEST: Change Review Column to Blocked", - "details": { - "author": "Veritas", - "preview": "Sprint US-1500 created with 7 stories (US-1501–US-..." - }, - "timestamp": "2026-01-28T09:20:48.818Z" - }, - { - "id": "activity_1769592037043_5b23m5plu", - "type": "task_created", - "taskId": "task_20260128_g86sZV", - "taskTitle": "US-1507: Add blocked reason tracking", - "details": { - "type": "code", - "priority": "medium", - "project": "veritas-kanban" - }, - "timestamp": "2026-01-28T09:20:37.043Z" - }, - { - "id": "activity_1769592022111_s9qvt78ma", - "type": "task_created", - "taskId": "task_20260128_KTgZMx", - "taskTitle": "US-1506: Update test suite — review → blocked references", - "details": { - "type": "code", - "priority": "medium", - "project": "veritas-kanban" - }, - "timestamp": "2026-01-28T09:20:22.111Z" - }, - { - "id": "activity_1769592012261_e2g8dvk3m", - "type": "task_created", - "taskId": "task_20260128_0hOHmd", - "taskTitle": "US-1505: Data migration — convert existing review tasks to blocked", - "details": { - "type": "code", - "priority": "high", - "project": "veritas-kanban" - }, - "timestamp": "2026-01-28T09:20:12.261Z" - }, - { - "id": "activity_1769592001252_wkek4tpfs", - "type": "task_created", - "taskId": "task_20260128_62_v6g", - "taskTitle": "US-1504: Propagate review → blocked across all backend routes and services", - "details": { - "type": "code", - "priority": "high", - "project": "veritas-kanban" - }, - "timestamp": "2026-01-28T09:20:01.252Z" - }, - { - "id": "activity_1769591988668_y93p97rtf", - "type": "task_created", - "taskId": "task_20260128_hQpqum", - "taskTitle": "US-1503: Propagate review → blocked across all frontend components", - "details": { - "type": "code", - "priority": "high", - "project": "veritas-kanban" - }, - "timestamp": "2026-01-28T09:19:48.668Z" - }, - { - "id": "activity_1769591973480_ed1rh84tf", - "type": "task_created", - "taskId": "task_20260128_iFFyez", - "taskTitle": "US-1502: Update Kanban column display — Blocked title + red color", - "details": { - "type": "code", - "priority": "high", - "project": "veritas-kanban" - }, - "timestamp": "2026-01-28T09:19:33.480Z" - }, - { - "id": "activity_1769591960496_c9xm2ql3x", - "type": "task_created", - "taskId": "task_20260128_-WSaw3", - "taskTitle": "US-1501: Rename TaskStatus review → blocked in shared types", - "details": { - "type": "code", - "priority": "high", - "project": "veritas-kanban" - }, - "timestamp": "2026-01-28T09:19:20.496Z" - }, - { - "id": "activity_1769591477776_dv9jz0ymr", + "id": "activity_1769597722759_k2ahcgkxi", "type": "task_updated", - "taskId": "task_20260128_d47DtQ", - "taskTitle": "FEATURE REQUEST: Host Kimi K2.5 Model In Azure", - "timestamp": "2026-01-28T09:11:17.776Z" + "taskId": "task_20260128_eib_Vw", + "taskTitle": "RF-04: Add Comprehensive Test Suite", + "timestamp": "2026-01-28T10:55:22.759Z" }, { - "id": "activity_1769591429316_9kwem5ftu", - "type": "status_changed", - "taskId": "task_20260128_-KNOy3", - "taskTitle": "US-1155: Settings performance — memoization & lazy loading", - "details": { - "from": "in-progress", - "status": "done" - }, - "timestamp": "2026-01-28T09:10:29.316Z" - }, - { - "id": "activity_1769591394306_t4h0ljnmv", - "type": "status_changed", - "taskId": "task_20260128_CnogQO", - "taskTitle": "US-1157: Sanitize settings import against prototype pollution", - "details": { - "from": "in-progress", - "status": "done" - }, - "timestamp": "2026-01-28T09:09:54.306Z" - }, - { - "id": "activity_1769591389898_ue1r7w1pp", - "type": "comment_added", - "taskId": "task_20260128_CnogQO", - "taskTitle": "US-1157: Sanitize settings import against prototype pollution", - "details": { - "author": "Veritas", - "preview": "Already implemented in commit 3402051. Added Zod s..." - }, - "timestamp": "2026-01-28T09:09:49.898Z" - }, - { - "id": "activity_1769591349039_t0t1gp5dq", - "type": "comment_added", - "taskId": "task_20260128_-KNOy3", - "taskTitle": "US-1155: Settings performance — memoization & lazy loading", - "details": { - "author": "Veritas", - "preview": "Added React.memo to shared row components, lazy-lo..." - }, - "timestamp": "2026-01-28T09:09:09.039Z" - }, - { - "id": "activity_1769591243934_a59soco89", - "type": "status_changed", - "taskId": "task_20260128_CnogQO", - "taskTitle": "US-1157: Sanitize settings import against prototype pollution", - "details": { - "from": "todo", - "status": "in-progress" - }, - "timestamp": "2026-01-28T09:07:23.934Z" - }, - { - "id": "activity_1769591188250_6ff1szybq", - "type": "status_changed", - "taskId": "task_20260128_-KNOy3", - "taskTitle": "US-1155: Settings performance — memoization & lazy loading", - "details": { - "from": "todo", - "status": "in-progress" - }, - "timestamp": "2026-01-28T09:06:28.250Z" - }, - { - "id": "activity_1769591169459_ugp657565", - "type": "status_changed", - "taskId": "task_20260128_6SbOnv", - "taskTitle": "US-1154: Add test coverage for settings features", - "details": { - "from": "in-progress", - "status": "done" - }, - "timestamp": "2026-01-28T09:06:09.459Z" - }, - { - "id": "activity_1769591162356_cdvv56zb6", - "type": "comment_added", - "taskId": "task_20260128_6SbOnv", - "taskTitle": "US-1154: Add test coverage for settings features", - "details": { - "author": "Veritas", - "preview": "Fixed test discovery (worktrees excluded), fixed e..." - }, - "timestamp": "2026-01-28T09:06:02.356Z" - }, - { - "id": "activity_1769590946523_ekw6m4yic", - "type": "task_created", - "taskId": "task_20260128_d47DtQ", - "taskTitle": "FEATURE REQUEST: Host Kimi K2.5 Model In Azure", - "details": { - "type": "model-EhHjEh", - "priority": "medium", - "project": "platform" - }, - "timestamp": "2026-01-28T09:02:26.523Z" - }, - { - "id": "activity_1769590855496_etxhp8xkm", - "type": "status_changed", - "taskId": "task_20260128_6SbOnv", - "taskTitle": "US-1154: Add test coverage for settings features", - "details": { - "from": "todo", - "status": "in-progress" - }, - "timestamp": "2026-01-28T09:00:55.496Z" - }, - { - "id": "activity_1769590817364_r5rm7bm00", - "type": "status_changed", - "taskId": "task_20260128_NrvUWe", - "taskTitle": "US-1153: Harden template import validation & security", - "details": { - "from": "in-progress", - "status": "done" - }, - "timestamp": "2026-01-28T09:00:17.364Z" - }, - { - "id": "activity_1769590809751_ltmapn9s7", - "type": "comment_added", - "taskId": "task_20260128_NrvUWe", - "taskTitle": "US-1153: Harden template import validation & security", - "details": { - "author": "Veritas", - "preview": "Template import now validated via Zod schema with ..." - }, - "timestamp": "2026-01-28T09:00:09.751Z" - }, - { - "id": "activity_1769590750284_v0gs7kh7q", - "type": "status_changed", - "taskId": "task_20260128_SFb0-u", - "taskTitle": "US-1152: Add error boundaries to Settings dialog", - "details": { - "from": "in-progress", - "status": "done" - }, - "timestamp": "2026-01-28T08:59:10.284Z" - }, - { - "id": "activity_1769590750269_ivtnnmt4z", - "type": "comment_added", - "taskId": "task_20260128_SFb0-u", - "taskTitle": "US-1152: Add error boundaries to Settings dialog", - "details": { - "author": "Veritas", - "preview": "Error boundaries added to all 7 settings tabs. Eac..." - }, - "timestamp": "2026-01-28T08:59:10.269Z" - }, - { - "id": "activity_1769590744864_d8axnsesd", - "type": "comment_added", - "taskId": "task_20260128_SFb0-u", - "taskTitle": "US-1152: Add error boundaries to Settings dialog", - "details": { - "author": "Veritas", - "preview": "Error boundaries added to all 7 settings tabs. Eac..." - }, - "timestamp": "2026-01-28T08:59:04.864Z" - }, - { - "id": "activity_1769590643978_93tt215h7", - "type": "status_changed", - "taskId": "task_20260128_NrvUWe", - "taskTitle": "US-1153: Harden template import validation & security", - "details": { - "from": "todo", - "status": "in-progress" - }, - "timestamp": "2026-01-28T08:57:23.978Z" - }, - { - "id": "activity_1769590643942_0khfec0mn", - "type": "status_changed", - "taskId": "task_20260128_SFb0-u", - "taskTitle": "US-1152: Add error boundaries to Settings dialog", - "details": { - "from": "todo", - "status": "in-progress" - }, - "timestamp": "2026-01-28T08:57:23.942Z" - }, - { - "id": "activity_1769590629182_1ij2bbqck", - "type": "status_changed", - "taskId": "task_20260128_Q78_6y", - "taskTitle": "US-1151: Extract Settings tab components from monolith", - "details": { - "from": "in-progress", - "status": "done" - }, - "timestamp": "2026-01-28T08:57:09.182Z" - }, - { - "id": "activity_1769590629157_abpevivbd", - "type": "comment_added", - "taskId": "task_20260128_Q78_6y", - "taskTitle": "US-1151: Extract Settings tab components from monolith", - "details": { - "author": "Veritas", - "preview": "Extracted monolithic SettingsDialog.tsx (1,481→279..." - }, - "timestamp": "2026-01-28T08:57:09.157Z" - }, - { - "id": "activity_1769590226849_pj4gxcgf9", + "id": "activity_1769597722736_noefnouuk", "type": "task_updated", - "taskId": "task_20260128_Q78_6y", - "taskTitle": "US-1151: Extract Settings tab components from monolith", - "timestamp": "2026-01-28T08:50:26.849Z" + "taskId": "task_20260128_80Bww2", + "taskTitle": "RF-06: Server Error Handling Middleware", + "timestamp": "2026-01-28T10:55:22.736Z" }, { - "id": "activity_1769590166795_2kk4ua4tk", + "id": "activity_1769597722706_wa5u0h76j", + "type": "task_updated", + "taskId": "task_20260128_9ljJmb", + "taskTitle": "RF-16: Frontend Accessibility Improvements", + "timestamp": "2026-01-28T10:55:22.706Z" + }, + { + "id": "activity_1769597722691_5oj73ps8w", + "type": "task_updated", + "taskId": "task_20260128_DmkA14", + "taskTitle": "RF-21: Server Performance & Reliability", + "timestamp": "2026-01-28T10:55:22.691Z" + }, + { + "id": "activity_1769597722677_xtm7rtri8", + "type": "task_updated", + "taskId": "task_20260128_oeprdJ", + "taskTitle": "RF-25: Code Cleanup & Organization", + "timestamp": "2026-01-28T10:55:22.677Z" + }, + { + "id": "activity_1769597722644_hv02hwkg3", + "type": "task_updated", + "taskId": "task_20260128_XIY4cW", + "taskTitle": "RF-03: Server Security Hardening", + "timestamp": "2026-01-28T10:55:22.644Z" + }, + { + "id": "activity_1769597722606_77lv25nid", + "type": "task_updated", + "taskId": "task_20260128_-8OO8l", + "taskTitle": "RF-07: Server Input Validation Layer", + "timestamp": "2026-01-28T10:55:22.606Z" + }, + { + "id": "activity_1769597722589_a66z45tfq", + "type": "task_updated", + "taskId": "task_20260128_hfzlfd", + "taskTitle": "RF-08: Split tasks.ts Route (777 lines)", + "timestamp": "2026-01-28T10:55:22.589Z" + }, + { + "id": "activity_1769597722572_l99a5fcp0", + "type": "task_updated", + "taskId": "task_20260128_wgJpvX", + "taskTitle": "RF-09: Move Business Logic from Routes to Services", + "timestamp": "2026-01-28T10:55:22.572Z" + }, + { + "id": "activity_1769597722541_xetg84v9c", + "type": "task_updated", + "taskId": "task_20260128_UMDZyh", + "taskTitle": "RF-11: Consolidate Frontend API Layer", + "timestamp": "2026-01-28T10:55:22.541Z" + }, + { + "id": "activity_1769597722456_pytwtp2kf", + "type": "task_updated", + "taskId": "task_20260128_VnO-L8", + "taskTitle": "RF-12: Extract Shared WebSocket Hook", + "timestamp": "2026-01-28T10:55:22.456Z" + }, + { + "id": "activity_1769597722439_hkn54ujkp", + "type": "task_updated", + "taskId": "task_20260128_bIPwKK", + "taskTitle": "RF-14: Split God Components — Task Layer", + "timestamp": "2026-01-28T10:55:22.439Z" + }, + { + "id": "activity_1769597722424_0jmkycp16", + "type": "task_updated", + "taskId": "task_20260128_1yHkWF", + "taskTitle": "RF-15: Split God Components — Board & Settings", + "timestamp": "2026-01-28T10:55:22.424Z" + }, + { + "id": "activity_1769597722408_l7oy4kag1", + "type": "task_updated", + "taskId": "task_20260128_q-NnH4", + "taskTitle": "RF-17: Modularize CLI (899 lines)", + "timestamp": "2026-01-28T10:55:22.408Z" + }, + { + "id": "activity_1769597722375_s2y1gr116", + "type": "task_updated", + "taskId": "task_20260128_OFkc0Z", + "taskTitle": "RF-18: Modularize MCP Server (843 lines)", + "timestamp": "2026-01-28T10:55:22.375Z" + }, + { + "id": "activity_1769597722329_9bpgzxpvv", + "type": "task_updated", + "taskId": "task_20260128_lePtYQ", + "taskTitle": "RF-19: Create Shared API Client Library", + "timestamp": "2026-01-28T10:55:22.329Z" + }, + { + "id": "activity_1769597722269_5wxqiz7ff", + "type": "task_updated", + "taskId": "task_20260128_ksQUvs", + "taskTitle": "RF-20: Frontend Performance Optimization", + "timestamp": "2026-01-28T10:55:22.269Z" + }, + { + "id": "activity_1769597722252_0ndhdok2q", + "type": "task_updated", + "taskId": "task_20260128_Qh60Ao", + "taskTitle": "RF-22: TypeScript Strictness & Linting", + "timestamp": "2026-01-28T10:55:22.253Z" + }, + { + "id": "activity_1769597722235_gcvtvroxk", + "type": "task_updated", + "taskId": "task_20260128_WYNpAQ", + "taskTitle": "RF-23: Extract Shared Utilities", + "timestamp": "2026-01-28T10:55:22.235Z" + }, + { + "id": "activity_1769597722217_dlodb9szd", + "type": "task_updated", + "taskId": "task_20260128_XAxJXn", + "taskTitle": "RF-24: Frontend State Management Improvements", + "timestamp": "2026-01-28T10:55:22.217Z" + }, + { + "id": "activity_1769597722203_fbgxnu4s7", + "type": "task_updated", + "taskId": "task_20260128_fylTjw", + "taskTitle": "RF-13: Create TaskConfigContext — Fix Prop Drilling", + "timestamp": "2026-01-28T10:55:22.203Z" + }, + { + "id": "activity_1769597722189_qmtf8ypqj", + "type": "task_updated", + "taskId": "task_20260128_IWTxzN", + "taskTitle": "RF-10: Split Shared types.ts (591 lines)", + "timestamp": "2026-01-28T10:55:22.189Z" + }, + { + "id": "activity_1769597722174_48cuqhna8", + "type": "task_updated", + "taskId": "task_20260128_jS7k9o", + "taskTitle": "RF-02: Fix Dependency Security Vulnerabilities", + "timestamp": "2026-01-28T10:55:22.174Z" + }, + { + "id": "activity_1769597722149_7femugrhu", + "type": "task_updated", + "taskId": "task_20260128_HtCf-y", + "taskTitle": "RF-05: Add React Error Boundaries", + "timestamp": "2026-01-28T10:55:22.149Z" + }, + { + "id": "activity_1769597700935_1v1n64b8n", + "type": "task_deleted", + "taskId": "task_20260128_2sIFHb", + "taskTitle": "US-1206: Responsive design audit for tablets/phones", + "timestamp": "2026-01-28T10:55:00.935Z" + }, + { + "id": "activity_1769597700924_w0ttc9tkh", + "type": "task_deleted", + "taskId": "task_20260128_SAHMz6", + "taskTitle": "US-1205: Auto-detect and display LAN URL", + "timestamp": "2026-01-28T10:55:00.924Z" + }, + { + "id": "activity_1769597700913_7zn5cevnz", + "type": "task_deleted", + "taskId": "task_20260128_Q8rmjd", + "taskTitle": "US-1204: macOS firewall configuration guide", + "timestamp": "2026-01-28T10:55:00.913Z" + }, + { + "id": "activity_1769597700901_rk6zsstoy", + "type": "task_deleted", + "taskId": "task_20260128_2jWpyv", + "taskTitle": "US-1203: Configure CORS for LAN origins", + "timestamp": "2026-01-28T10:55:00.901Z" + }, + { + "id": "activity_1769597700891_jhmca6kez", + "type": "task_deleted", + "taskId": "task_20260128_aTOv4t", + "taskTitle": "US-1202: Bind Express API server to LAN", + "timestamp": "2026-01-28T10:55:00.891Z" + }, + { + "id": "activity_1769597700880_4nutnn222", + "type": "task_deleted", + "taskId": "task_20260128_Z1vcc9", + "taskTitle": "US-1201: Bind Vite dev server to LAN", + "timestamp": "2026-01-28T10:55:00.880Z" + }, + { + "id": "activity_1769597700862_6lvyv18vm", + "type": "task_deleted", + "taskId": "task_20260128_eUf_U7", + "taskTitle": "US-1201: Bind Vite dev server to LAN", + "timestamp": "2026-01-28T10:55:00.862Z" + }, + { + "id": "activity_1769597643679_gema54xkr", + "type": "comment_added", + "taskId": "task_20260128_HtCf-y", + "taskTitle": "RF-05: Add React Error Boundaries", + "details": { + "author": "Veritas", + "preview": "Created FeatureErrorBoundary component and wrapped..." + }, + "timestamp": "2026-01-28T10:54:03.679Z" + }, + { + "id": "activity_1769597643660_l1j6m6e6m", "type": "status_changed", - "taskId": "task_20260128_Q78_6y", - "taskTitle": "US-1151: Extract Settings tab components from monolith", + "taskId": "task_20260128_HtCf-y", + "taskTitle": "RF-05: Add React Error Boundaries", "details": { "from": "todo", - "status": "in-progress" + "status": "done" }, - "timestamp": "2026-01-28T08:49:26.795Z" + "timestamp": "2026-01-28T10:54:03.660Z" }, { - "id": "activity_1769589885558_p754qy6ot", - "type": "task_created", - "taskId": "task_20260128_8HU2XD", - "taskTitle": "US-1158: Replace alert() calls with toast notifications", + "id": "activity_1769597634283_7n16j9357", + "type": "comment_added", + "taskId": "task_20260128_jS7k9o", + "taskTitle": "RF-02: Fix Dependency Security Vulnerabilities", "details": { - "type": "code", + "author": "Veritas", + "preview": "Replaced xlsx with exceljs (fixes CVE-2023-30533/3..." + }, + "timestamp": "2026-01-28T10:53:54.283Z" + }, + { + "id": "activity_1769597634242_k9z2egqtr", + "type": "status_changed", + "taskId": "task_20260128_jS7k9o", + "taskTitle": "RF-02: Fix Dependency Security Vulnerabilities", + "details": { + "from": "todo", + "status": "done" + }, + "timestamp": "2026-01-28T10:53:54.242Z" + }, + { + "id": "activity_1769597577923_86mu4bzf8", + "type": "comment_added", + "taskId": "task_20260128_IWTxzN", + "taskTitle": "RF-10: Split Shared types.ts (591 lines)", + "details": { + "author": "Veritas", + "preview": "Split types.ts into domain-specific files under ty..." + }, + "timestamp": "2026-01-28T10:52:57.923Z" + }, + { + "id": "activity_1769597577897_ohfr8r1ya", + "type": "status_changed", + "taskId": "task_20260128_IWTxzN", + "taskTitle": "RF-10: Split Shared types.ts (591 lines)", + "details": { + "from": "todo", + "status": "done" + }, + "timestamp": "2026-01-28T10:52:57.897Z" + }, + { + "id": "activity_1769597515063_a9dyrsz71", + "type": "comment_added", + "taskId": "task_20260128_fylTjw", + "taskTitle": "RF-13: Create TaskConfigContext — Fix Prop Drilling", + "details": { + "author": "Veritas", + "preview": "Created TaskConfigContext/Provider/useTaskConfig h..." + }, + "timestamp": "2026-01-28T10:51:55.063Z" + }, + { + "id": "activity_1769597515038_obucllbc0", + "type": "status_changed", + "taskId": "task_20260128_fylTjw", + "taskTitle": "RF-13: Create TaskConfigContext — Fix Prop Drilling", + "details": { + "from": "todo", + "status": "done" + }, + "timestamp": "2026-01-28T10:51:55.038Z" + }, + { + "id": "activity_1769597094577_qsxc455op", + "type": "task_updated", + "taskId": "task_20260128_XAxJXn", + "taskTitle": "RF-24: Frontend State Management Improvements", + "timestamp": "2026-01-28T10:44:54.577Z" + }, + { + "id": "activity_1769597094557_cagrqs0xy", + "type": "task_updated", + "taskId": "task_20260128_WYNpAQ", + "taskTitle": "RF-23: Extract Shared Utilities", + "timestamp": "2026-01-28T10:44:54.557Z" + }, + { + "id": "activity_1769597094533_34drwh772", + "type": "task_updated", + "taskId": "task_20260128_Qh60Ao", + "taskTitle": "RF-22: TypeScript Strictness & Linting", + "timestamp": "2026-01-28T10:44:54.533Z" + }, + { + "id": "activity_1769597094507_n40rdj3ju", + "type": "task_updated", + "taskId": "task_20260128_ksQUvs", + "taskTitle": "RF-20: Frontend Performance Optimization", + "timestamp": "2026-01-28T10:44:54.507Z" + }, + { + "id": "activity_1769597094487_l91g4belc", + "type": "task_updated", + "taskId": "task_20260128_lePtYQ", + "taskTitle": "RF-19: Create Shared API Client Library", + "timestamp": "2026-01-28T10:44:54.487Z" + }, + { + "id": "activity_1769597094461_uy0gubgtv", + "type": "task_updated", + "taskId": "task_20260128_OFkc0Z", + "taskTitle": "RF-18: Modularize MCP Server (843 lines)", + "timestamp": "2026-01-28T10:44:54.461Z" + }, + { + "id": "activity_1769597094439_ha1il6l9j", + "type": "task_updated", + "taskId": "task_20260128_q-NnH4", + "taskTitle": "RF-17: Modularize CLI (899 lines)", + "timestamp": "2026-01-28T10:44:54.439Z" + }, + { + "id": "activity_1769597094412_xn6j4owg1", + "type": "task_updated", + "taskId": "task_20260128_1yHkWF", + "taskTitle": "RF-15: Split God Components — Board & Settings", + "timestamp": "2026-01-28T10:44:54.412Z" + }, + { + "id": "activity_1769597094397_8mfca4th3", + "type": "task_updated", + "taskId": "task_20260128_bIPwKK", + "taskTitle": "RF-14: Split God Components — Task Layer", + "timestamp": "2026-01-28T10:44:54.397Z" + }, + { + "id": "activity_1769597094366_d2xoqpwa9", + "type": "task_updated", + "taskId": "task_20260128_VnO-L8", + "taskTitle": "RF-12: Extract Shared WebSocket Hook", + "timestamp": "2026-01-28T10:44:54.366Z" + }, + { + "id": "activity_1769597094359_zna4zchgs", + "type": "task_updated", + "taskId": "task_20260128_UMDZyh", + "taskTitle": "RF-11: Consolidate Frontend API Layer", + "timestamp": "2026-01-28T10:44:54.359Z" + }, + { + "id": "activity_1769597094354_fmii3vyp2", + "type": "task_updated", + "taskId": "task_20260128_wgJpvX", + "taskTitle": "RF-09: Move Business Logic from Routes to Services", + "timestamp": "2026-01-28T10:44:54.354Z" + }, + { + "id": "activity_1769597094350_b5xvgesiw", + "type": "task_updated", + "taskId": "task_20260128_hfzlfd", + "taskTitle": "RF-08: Split tasks.ts Route (777 lines)", + "timestamp": "2026-01-28T10:44:54.350Z" + }, + { + "id": "activity_1769597094346_gakzl1var", + "type": "task_updated", + "taskId": "task_20260128_-8OO8l", + "taskTitle": "RF-07: Server Input Validation Layer", + "timestamp": "2026-01-28T10:44:54.346Z" + }, + { + "id": "activity_1769597094341_mgggbjq87", + "type": "task_updated", + "taskId": "task_20260128_XIY4cW", + "taskTitle": "RF-03: Server Security Hardening", + "timestamp": "2026-01-28T10:44:54.341Z" + }, + { + "id": "activity_1769597094322_4ijtqw395", + "type": "task_created", + "taskId": "task_20260128_oeprdJ", + "taskTitle": "RF-25: Code Cleanup & Organization", + "details": { + "type": "refactor-saXoty", "priority": "low", "project": "veritas-kanban" }, - "timestamp": "2026-01-28T08:44:45.558Z" + "timestamp": "2026-01-28T10:44:54.322Z" }, { - "id": "activity_1769589878257_3ffqbousc", + "id": "activity_1769597094318_dc3gn8fup", "type": "task_created", - "taskId": "task_20260128_CnogQO", - "taskTitle": "US-1157: Sanitize settings import against prototype pollution", + "taskId": "task_20260128_XAxJXn", + "taskTitle": "RF-24: Frontend State Management Improvements", "details": { - "type": "code", + "type": "refactor-saXoty", "priority": "medium", "project": "veritas-kanban" }, - "timestamp": "2026-01-28T08:44:38.257Z" + "timestamp": "2026-01-28T10:44:54.318Z" }, { - "id": "activity_1769589871074_en1h4id50", + "id": "activity_1769597094314_sm29llt3n", "type": "task_created", - "taskId": "task_20260128_F-XLhL", - "taskTitle": "US-1156: Settings accessibility (WCAG 2.1 AA)", + "taskId": "task_20260128_WYNpAQ", + "taskTitle": "RF-23: Extract Shared Utilities", "details": { - "type": "code", + "type": "refactor-saXoty", "priority": "medium", "project": "veritas-kanban" }, - "timestamp": "2026-01-28T08:44:31.074Z" + "timestamp": "2026-01-28T10:44:54.314Z" }, { - "id": "activity_1769589861607_14bhx2k5g", + "id": "activity_1769597094311_yj5w5drfp", "type": "task_created", - "taskId": "task_20260128_-KNOy3", - "taskTitle": "US-1155: Settings performance — memoization & lazy loading", + "taskId": "task_20260128_Qh60Ao", + "taskTitle": "RF-22: TypeScript Strictness & Linting", "details": { - "type": "code", + "type": "refactor-saXoty", "priority": "medium", "project": "veritas-kanban" }, - "timestamp": "2026-01-28T08:44:21.607Z" + "timestamp": "2026-01-28T10:44:54.311Z" }, { - "id": "activity_1769589851638_rj39j8yaz", + "id": "activity_1769597094309_m481jvdxm", "type": "task_created", - "taskId": "task_20260128_6SbOnv", - "taskTitle": "US-1154: Add test coverage for settings features", + "taskId": "task_20260128_DmkA14", + "taskTitle": "RF-21: Server Performance & Reliability", "details": { - "type": "code", - "priority": "high", - "project": "veritas-kanban" - }, - "timestamp": "2026-01-28T08:44:11.638Z" - }, - { - "id": "activity_1769589842836_6guypgz4i", - "type": "task_created", - "taskId": "task_20260128_NrvUWe", - "taskTitle": "US-1153: Harden template import validation & security", - "details": { - "type": "code", - "priority": "high", - "project": "veritas-kanban" - }, - "timestamp": "2026-01-28T08:44:02.836Z" - }, - { - "id": "activity_1769589834742_skz4qpgwq", - "type": "task_created", - "taskId": "task_20260128_SFb0-u", - "taskTitle": "US-1152: Add error boundaries to Settings dialog", - "details": { - "type": "code", - "priority": "high", - "project": "veritas-kanban" - }, - "timestamp": "2026-01-28T08:43:54.742Z" - }, - { - "id": "activity_1769589827001_9gfz0vpqx", - "type": "task_created", - "taskId": "task_20260128_Q78_6y", - "taskTitle": "US-1151: Extract Settings tab components from monolith", - "details": { - "type": "code", - "priority": "high", - "project": "veritas-kanban" - }, - "timestamp": "2026-01-28T08:43:47.001Z" - }, - { - "id": "activity_1769589376876_l09szmsnu", - "type": "comment_added", - "taskId": "task_20260128_njhuR8", - "taskTitle": "US-1110: Settings import/export & reset", - "details": { - "author": "Veritas", - "preview": "Settings import/export & reset complete: export to..." - }, - "timestamp": "2026-01-28T08:36:16.876Z" - }, - { - "id": "activity_1769589376846_5f3t59f1k", - "type": "status_changed", - "taskId": "task_20260128_njhuR8", - "taskTitle": "US-1110: Settings import/export & reset", - "details": { - "from": "in-progress", - "status": "done" - }, - "timestamp": "2026-01-28T08:36:16.846Z" - }, - { - "id": "activity_1769589334140_qw71wwjrf", - "type": "status_changed", - "taskId": "task_20260128_njhuR8", - "taskTitle": "US-1110: Settings import/export & reset", - "details": { - "from": "todo", - "status": "in-progress" - }, - "timestamp": "2026-01-28T08:35:34.140Z" - }, - { - "id": "activity_1769589320546_cy8qlf98x", - "type": "comment_added", - "taskId": "task_20260128_Kbjd3t", - "taskTitle": "US-1108: Auto-archive settings", - "details": { - "author": "Veritas", - "preview": "Auto-archive settings complete: auto-archive toggl..." - }, - "timestamp": "2026-01-28T08:35:20.546Z" - }, - { - "id": "activity_1769589320537_qxwtl297n", - "type": "comment_added", - "taskId": "task_20260128_itHuxN", - "taskTitle": "US-1107: Notification settings", - "details": { - "author": "Veritas", - "preview": "Notification settings tab complete: master toggle,..." - }, - "timestamp": "2026-01-28T08:35:20.537Z" - }, - { - "id": "activity_1769589320524_bmt32f2uc", - "type": "comment_added", - "taskId": "task_20260128_ndyIRl", - "taskTitle": "US-1106: Telemetry & activity settings", - "details": { - "author": "Veritas", - "preview": "Telemetry & activity settings wired server-side: e..." - }, - "timestamp": "2026-01-28T08:35:20.524Z" - }, - { - "id": "activity_1769589320510_zs326om90", - "type": "comment_added", - "taskId": "task_20260128_HaNM-g", - "taskTitle": "US-1105: Agent & git settings", - "details": { - "author": "Veritas", - "preview": "Agent & git settings tab complete: timeout, auto-c..." - }, - "timestamp": "2026-01-28T08:35:20.510Z" - }, - { - "id": "activity_1769589312604_8nhvt8qs7", - "type": "status_changed", - "taskId": "task_20260128_Kbjd3t", - "taskTitle": "US-1108: Auto-archive settings", - "details": { - "from": "in-progress", - "status": "done" - }, - "timestamp": "2026-01-28T08:35:12.604Z" - }, - { - "id": "activity_1769589312589_i4j1dr456", - "type": "status_changed", - "taskId": "task_20260128_itHuxN", - "taskTitle": "US-1107: Notification settings", - "details": { - "from": "in-progress", - "status": "done" - }, - "timestamp": "2026-01-28T08:35:12.589Z" - }, - { - "id": "activity_1769589312571_sp30si9h8", - "type": "status_changed", - "taskId": "task_20260128_ndyIRl", - "taskTitle": "US-1106: Telemetry & activity settings", - "details": { - "from": "in-progress", - "status": "done" - }, - "timestamp": "2026-01-28T08:35:12.571Z" - }, - { - "id": "activity_1769589312540_ii0ufqkn4", - "type": "status_changed", - "taskId": "task_20260128_HaNM-g", - "taskTitle": "US-1105: Agent & git settings", - "details": { - "from": "in-progress", - "status": "done" - }, - "timestamp": "2026-01-28T08:35:12.540Z" - }, - { - "id": "activity_1769589172887_jp4y9jj7c", - "type": "comment_added", - "taskId": "task_20260128_vYzfwx", - "taskTitle": "US-1104: Task behavior settings", - "details": { - "author": "Veritas", - "preview": "Task behavior settings wired in TaskDetailPanel: d..." - }, - "timestamp": "2026-01-28T08:32:52.887Z" - }, - { - "id": "activity_1769589172877_npxiqczo8", - "type": "comment_added", - "taskId": "task_20260128_DUyAbh", - "taskTitle": "US-1103: Board & display settings", - "details": { - "author": "Veritas", - "preview": "Board & display settings complete: sprint badges o..." - }, - "timestamp": "2026-01-28T08:32:52.877Z" - }, - { - "id": "activity_1769589167537_tukrzq1ly", - "type": "status_changed", - "taskId": "task_20260128_vYzfwx", - "taskTitle": "US-1104: Task behavior settings", - "details": { - "from": "in-progress", - "status": "done" - }, - "timestamp": "2026-01-28T08:32:47.537Z" - }, - { - "id": "activity_1769589167510_sgfq2w68c", - "type": "status_changed", - "taskId": "task_20260128_DUyAbh", - "taskTitle": "US-1103: Board & display settings", - "details": { - "from": "in-progress", - "status": "done" - }, - "timestamp": "2026-01-28T08:32:47.510Z" - }, - { - "id": "activity_1769588643293_d36yhzq9l", - "type": "status_changed", - "taskId": "task_20260128_iG-ISb", - "taskTitle": "Review the ByteRover Skill", - "details": { - "from": "in-progress", - "status": "done" - }, - "timestamp": "2026-01-28T08:24:03.293Z" - }, - { - "id": "activity_1769588641597_qrbvyuw82", - "type": "comment_added", - "taskId": "task_20260128_iG-ISb", - "taskTitle": "Review the ByteRover Skill", - "details": { - "author": "Veritas", - "preview": "Passing on ByteRover. Too much overlap with our ex..." - }, - "timestamp": "2026-01-28T08:24:01.597Z" - }, - { - "id": "activity_1769588405585_vcuxykrps", - "type": "status_changed", - "taskId": "task_20260128_Kbjd3t", - "taskTitle": "US-1108: Auto-archive settings", - "details": { - "from": "todo", - "status": "in-progress" - }, - "timestamp": "2026-01-28T08:20:05.585Z" - }, - { - "id": "activity_1769588405575_9q54r5fma", - "type": "status_changed", - "taskId": "task_20260128_itHuxN", - "taskTitle": "US-1107: Notification settings", - "details": { - "from": "todo", - "status": "in-progress" - }, - "timestamp": "2026-01-28T08:20:05.575Z" - }, - { - "id": "activity_1769588405563_rn41vcwvx", - "type": "status_changed", - "taskId": "task_20260128_ndyIRl", - "taskTitle": "US-1106: Telemetry & activity settings", - "details": { - "from": "todo", - "status": "in-progress" - }, - "timestamp": "2026-01-28T08:20:05.563Z" - }, - { - "id": "activity_1769588405544_7kws5xknl", - "type": "status_changed", - "taskId": "task_20260128_HaNM-g", - "taskTitle": "US-1105: Agent & git settings", - "details": { - "from": "todo", - "status": "in-progress" - }, - "timestamp": "2026-01-28T08:20:05.544Z" - }, - { - "id": "activity_1769588405528_m9t3fqms0", - "type": "status_changed", - "taskId": "task_20260128_vYzfwx", - "taskTitle": "US-1104: Task behavior settings", - "details": { - "from": "todo", - "status": "in-progress" - }, - "timestamp": "2026-01-28T08:20:05.528Z" - }, - { - "id": "activity_1769588405497_lz2cy9e52", - "type": "status_changed", - "taskId": "task_20260128_DUyAbh", - "taskTitle": "US-1103: Board & display settings", - "details": { - "from": "todo", - "status": "in-progress" - }, - "timestamp": "2026-01-28T08:20:05.497Z" - }, - { - "id": "activity_1769588391537_1sdwjfyig", - "type": "comment_added", - "taskId": "task_20260128_KbDn0E", - "taskTitle": "US-1109: Settings UI — Tabbed panel redesign", - "details": { - "author": "Veritas", - "preview": "Complete tabbed settings panel with 7 tabs, toggle..." - }, - "timestamp": "2026-01-28T08:19:51.537Z" - }, - { - "id": "activity_1769588391508_njfkv8ncv", - "type": "status_changed", - "taskId": "task_20260128_KbDn0E", - "taskTitle": "US-1109: Settings UI — Tabbed panel redesign", - "details": { - "from": "in-progress", - "status": "done" - }, - "timestamp": "2026-01-28T08:19:51.508Z" - }, - { - "id": "activity_1769588199653_whhfki2dz", - "type": "task_created", - "taskId": "task_20260128_8KRThU", - "taskTitle": "FEATURE REQUEST: Change Review Column to Blocked", - "details": { - "type": "feature-request-n30mhV", + "type": "refactor-saXoty", "priority": "medium", "project": "veritas-kanban" }, - "timestamp": "2026-01-28T08:16:39.653Z" + "timestamp": "2026-01-28T10:44:54.309Z" }, { - "id": "activity_1769588192436_i9tzea6mk", - "type": "status_changed", - "taskId": "task_20260128_KbDn0E", - "taskTitle": "US-1109: Settings UI — Tabbed panel redesign", + "id": "activity_1769597094307_i222jnzay", + "type": "task_created", + "taskId": "task_20260128_ksQUvs", + "taskTitle": "RF-20: Frontend Performance Optimization", "details": { - "from": "todo", - "status": "in-progress" + "type": "refactor-saXoty", + "priority": "medium", + "project": "veritas-kanban" }, - "timestamp": "2026-01-28T08:16:32.436Z" + "timestamp": "2026-01-28T10:44:54.307Z" }, { - "id": "activity_1769588192387_wjoormah7", - "type": "comment_added", - "taskId": "task_20260128_gUHeIV", - "taskTitle": "US-1102: Feature toggle React context & hooks", + "id": "activity_1769597094304_fp0p28nrr", + "type": "task_created", + "taskId": "task_20260128_lePtYQ", + "taskTitle": "RF-19: Create Shared API Client Library", "details": { - "author": "Veritas", - "preview": "useFeatureSettings, useFeatureSetting, useUpdateFe..." + "type": "refactor-saXoty", + "priority": "high", + "project": "veritas-kanban" }, - "timestamp": "2026-01-28T08:16:32.387Z" + "timestamp": "2026-01-28T10:44:54.304Z" }, { - "id": "activity_1769588192375_z068olrt4", - "type": "status_changed", - "taskId": "task_20260128_gUHeIV", - "taskTitle": "US-1102: Feature toggle React context & hooks", + "id": "activity_1769597094302_da0avgdrb", + "type": "task_created", + "taskId": "task_20260128_OFkc0Z", + "taskTitle": "RF-18: Modularize MCP Server (843 lines)", "details": { - "from": "todo", - "status": "done" + "type": "refactor-saXoty", + "priority": "high", + "project": "veritas-kanban" }, - "timestamp": "2026-01-28T08:16:32.375Z" + "timestamp": "2026-01-28T10:44:54.302Z" }, { - "id": "activity_1769588192354_kmcif9395", - "type": "comment_added", - "taskId": "task_20260128_X6nifI", - "taskTitle": "US-1101: Settings infrastructure & config schema expansion", + "id": "activity_1769597094300_yxqyiipse", + "type": "task_created", + "taskId": "task_20260128_q-NnH4", + "taskTitle": "RF-17: Modularize CLI (899 lines)", "details": { - "author": "Veritas", - "preview": "FeatureSettings types + defaults, deep merge in Co..." + "type": "refactor-saXoty", + "priority": "high", + "project": "veritas-kanban" }, - "timestamp": "2026-01-28T08:16:32.354Z" + "timestamp": "2026-01-28T10:44:54.300Z" }, { - "id": "activity_1769588192286_615p4w08r", - "type": "status_changed", - "taskId": "task_20260128_X6nifI", - "taskTitle": "US-1101: Settings infrastructure & config schema expansion", + "id": "activity_1769597094297_dm9cpivlf", + "type": "task_created", + "taskId": "task_20260128_9ljJmb", + "taskTitle": "RF-16: Frontend Accessibility Improvements", "details": { - "from": "in-progress", - "status": "done" + "type": "refactor-saXoty", + "priority": "high", + "project": "veritas-kanban" }, - "timestamp": "2026-01-28T08:16:32.286Z" + "timestamp": "2026-01-28T10:44:54.297Z" }, { - "id": "activity_1769588154981_unig8oz3f", - "type": "comment_added", - "taskId": "task_20260128_XvmMga", - "taskTitle": "Research Kimi K2.5", + "id": "activity_1769597094295_q836y1gtk", + "type": "task_created", + "taskId": "task_20260128_1yHkWF", + "taskTitle": "RF-15: Split God Components — Board & Settings", "details": { - "author": "Veritas", - "preview": "Research complete. Files saved to research/kimi-k2..." + "type": "refactor-saXoty", + "priority": "medium", + "project": "veritas-kanban" }, - "timestamp": "2026-01-28T08:15:54.981Z" + "timestamp": "2026-01-28T10:44:54.295Z" }, { - "id": "activity_1769588154947_mp9tdjkky", - "type": "status_changed", - "taskId": "task_20260128_XvmMga", - "taskTitle": "Research Kimi K2.5", + "id": "activity_1769597094293_pa6hc9jq9", + "type": "task_created", + "taskId": "task_20260128_bIPwKK", + "taskTitle": "RF-14: Split God Components — Task Layer", "details": { - "from": "in-progress", - "status": "done" + "type": "refactor-saXoty", + "priority": "high", + "project": "veritas-kanban" }, - "timestamp": "2026-01-28T08:15:54.947Z" + "timestamp": "2026-01-28T10:44:54.293Z" }, { - "id": "activity_1769588034705_calq30nad", - "type": "status_changed", - "taskId": "task_20260128_X6nifI", - "taskTitle": "US-1101: Settings infrastructure & config schema expansion", + "id": "activity_1769597094290_ow86dbqzy", + "type": "task_created", + "taskId": "task_20260128_fylTjw", + "taskTitle": "RF-13: Create TaskConfigContext — Fix Prop Drilling", "details": { - "from": "todo", - "status": "in-progress" + "type": "refactor-saXoty", + "priority": "high", + "project": "veritas-kanban" }, - "timestamp": "2026-01-28T08:13:54.705Z" + "timestamp": "2026-01-28T10:44:54.290Z" }, { - "id": "activity_1769588024542_4bcp6khaz", - "type": "comment_added", - "taskId": "task_20260128_iG-ISb", - "taskTitle": "Review the ByteRover Skill", + "id": "activity_1769597094287_j0p1vcl2p", + "type": "task_created", + "taskId": "task_20260128_VnO-L8", + "taskTitle": "RF-12: Extract Shared WebSocket Hook", "details": { - "author": "Veritas", - "preview": "Reviewed ByteRover skill and sent analysis to Brad..." + "type": "refactor-saXoty", + "priority": "medium", + "project": "veritas-kanban" }, - "timestamp": "2026-01-28T08:13:44.542Z" + "timestamp": "2026-01-28T10:44:54.287Z" }, { - "id": "activity_1769588020507_qw0jiamvq", - "type": "status_changed", - "taskId": "task_20260128_iG-ISb", - "taskTitle": "Review the ByteRover Skill", + "id": "activity_1769597094285_kt7owyz1x", + "type": "task_created", + "taskId": "task_20260128_UMDZyh", + "taskTitle": "RF-11: Consolidate Frontend API Layer", "details": { - "from": "todo", - "status": "in-progress" + "type": "refactor-saXoty", + "priority": "high", + "project": "veritas-kanban" }, - "timestamp": "2026-01-28T08:13:40.508Z" + "timestamp": "2026-01-28T10:44:54.285Z" }, { - "id": "activity_1769587980296_eqnkbfpqk", - "type": "status_changed", - "taskId": "task_20260128_XvmMga", - "taskTitle": "Research Kimi K2.5", + "id": "activity_1769597094281_8ki7ujdil", + "type": "task_created", + "taskId": "task_20260128_IWTxzN", + "taskTitle": "RF-10: Split Shared types.ts (591 lines)", "details": { - "from": "todo", - "status": "in-progress" + "type": "refactor-saXoty", + "priority": "high", + "project": "veritas-kanban" }, - "timestamp": "2026-01-28T08:13:00.296Z" + "timestamp": "2026-01-28T10:44:54.281Z" }, { - "id": "activity_1769587926555_hnhvh3cvw", - "type": "task_archived", - "taskId": "task_20260128_eyx3Mw", - "taskTitle": "FEATURE REQUEST: Working indicator", - "timestamp": "2026-01-28T08:12:06.555Z" + "id": "activity_1769597094277_grthpt3kk", + "type": "task_created", + "taskId": "task_20260128_wgJpvX", + "taskTitle": "RF-09: Move Business Logic from Routes to Services", + "details": { + "type": "refactor-saXoty", + "priority": "high", + "project": "veritas-kanban" + }, + "timestamp": "2026-01-28T10:44:54.277Z" + }, + { + "id": "activity_1769597094273_6u3ggb0ye", + "type": "task_created", + "taskId": "task_20260128_hfzlfd", + "taskTitle": "RF-08: Split tasks.ts Route (777 lines)", + "details": { + "type": "refactor-saXoty", + "priority": "high", + "project": "veritas-kanban" + }, + "timestamp": "2026-01-28T10:44:54.273Z" + }, + { + "id": "activity_1769597094270_8eg8ptglt", + "type": "task_created", + "taskId": "task_20260128_-8OO8l", + "taskTitle": "RF-07: Server Input Validation Layer", + "details": { + "type": "refactor-saXoty", + "priority": "high", + "project": "veritas-kanban" + }, + "timestamp": "2026-01-28T10:44:54.270Z" + }, + { + "id": "activity_1769597094267_nngq85s49", + "type": "task_created", + "taskId": "task_20260128_80Bww2", + "taskTitle": "RF-06: Server Error Handling Middleware", + "details": { + "type": "refactor-saXoty", + "priority": "high", + "project": "veritas-kanban" + }, + "timestamp": "2026-01-28T10:44:54.267Z" + }, + { + "id": "activity_1769597094263_j2bunhxkj", + "type": "task_created", + "taskId": "task_20260128_HtCf-y", + "taskTitle": "RF-05: Add React Error Boundaries", + "details": { + "type": "refactor-saXoty", + "priority": "high", + "project": "veritas-kanban" + }, + "timestamp": "2026-01-28T10:44:54.263Z" + }, + { + "id": "activity_1769597094260_88pk1fi27", + "type": "task_created", + "taskId": "task_20260128_eib_Vw", + "taskTitle": "RF-04: Add Comprehensive Test Suite", + "details": { + "type": "refactor-saXoty", + "priority": "high", + "project": "veritas-kanban" + }, + "timestamp": "2026-01-28T10:44:54.260Z" + }, + { + "id": "activity_1769597094257_ly84nzryw", + "type": "task_created", + "taskId": "task_20260128_XIY4cW", + "taskTitle": "RF-03: Server Security Hardening", + "details": { + "type": "refactor-saXoty", + "priority": "high", + "project": "veritas-kanban" + }, + "timestamp": "2026-01-28T10:44:54.257Z" + }, + { + "id": "activity_1769597094252_02b6pxwjq", + "type": "task_created", + "taskId": "task_20260128_jS7k9o", + "taskTitle": "RF-02: Fix Dependency Security Vulnerabilities", + "details": { + "type": "refactor-saXoty", + "priority": "high", + "project": "veritas-kanban" + }, + "timestamp": "2026-01-28T10:44:54.252Z" + }, + { + "id": "activity_1769597094237_9y2vlux8u", + "type": "task_created", + "taskId": "task_20260128_v5QxzF", + "taskTitle": "RF-01: Server Authentication & Authorization", + "details": { + "type": "refactor-saXoty", + "priority": "high", + "project": "veritas-kanban" + }, + "timestamp": "2026-01-28T10:44:54.237Z" } ] \ No newline at end of file diff --git a/server/package.json b/server/package.json index 753f7c27..244d110b 100644 --- a/server/package.json +++ b/server/package.json @@ -17,6 +17,7 @@ "dependencies": { "@veritas-kanban/shared": "workspace:*", "cors": "^2.8.5", + "exceljs": "^4.4.0", "express": "^4.21.0", "gray-matter": "^4.0.3", "mammoth": "^1.11.0", @@ -26,11 +27,11 @@ "simple-git": "^3.27.0", "unpdf": "^1.4.0", "ws": "^8.18.0", - "xlsx": "^0.18.5", "zod": "^3.24.0" }, "devDependencies": { "@types/cors": "^2.8.17", + "@types/exceljs": "^1.3.2", "@types/express": "^5.0.0", "@types/mime-types": "^3.0.1", "@types/multer": "^2.0.0", diff --git a/server/src/__tests__/text-extraction-service.test.ts b/server/src/__tests__/text-extraction-service.test.ts index d178fd4a..a71b3b9e 100644 --- a/server/src/__tests__/text-extraction-service.test.ts +++ b/server/src/__tests__/text-extraction-service.test.ts @@ -3,7 +3,7 @@ import fs from 'fs/promises'; import path from 'path'; import os from 'os'; import { TextExtractionService } from '../services/text-extraction-service.js'; -import * as XLSX from 'xlsx'; +import ExcelJS from 'exceljs'; describe('TextExtractionService', () => { let service: TextExtractionService; @@ -77,18 +77,17 @@ describe('TextExtractionService', () => { it('should extract text from XLSX as CSV', async () => { const filepath = path.join(testRoot, 'test.xlsx'); - // Create a real XLSX file using the xlsx library - const workbook = XLSX.utils.book_new(); - const data = [ - ['Name', 'Age', 'City'], - ['Alice', 30, 'New York'], - ['Bob', 25, 'London'], - ]; - const worksheet = XLSX.utils.aoa_to_sheet(data); - XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1'); + // Create a real XLSX file using exceljs + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('Sheet1'); + + // Add data + worksheet.addRow(['Name', 'Age', 'City']); + worksheet.addRow(['Alice', 30, 'New York']); + worksheet.addRow(['Bob', 25, 'London']); // Write to file - await fs.writeFile(filepath, XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' })); + await workbook.xlsx.writeFile(filepath); const extracted = await service.extractText(filepath, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); @@ -101,19 +100,19 @@ describe('TextExtractionService', () => { it('should handle multiple sheets in XLSX', async () => { const filepath = path.join(testRoot, 'multi-sheet.xlsx'); - const workbook = XLSX.utils.book_new(); + const workbook = new ExcelJS.Workbook(); // Sheet 1 - const data1 = [['Column1'], ['Value1']]; - const worksheet1 = XLSX.utils.aoa_to_sheet(data1); - XLSX.utils.book_append_sheet(workbook, worksheet1, 'Sheet1'); + const worksheet1 = workbook.addWorksheet('Sheet1'); + worksheet1.addRow(['Column1']); + worksheet1.addRow(['Value1']); // Sheet 2 - const data2 = [['Column2'], ['Value2']]; - const worksheet2 = XLSX.utils.aoa_to_sheet(data2); - XLSX.utils.book_append_sheet(workbook, worksheet2, 'Sheet2'); + const worksheet2 = workbook.addWorksheet('Sheet2'); + worksheet2.addRow(['Column2']); + worksheet2.addRow(['Value2']); - await fs.writeFile(filepath, XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' })); + await workbook.xlsx.writeFile(filepath); const extracted = await service.extractText(filepath, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); diff --git a/server/src/index.ts b/server/src/index.ts index b9086769..2c96b4f0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -25,6 +25,7 @@ import { settingsRoutes, syncSettingsToServices } from './routes/settings.js'; import { getTelemetryService } from './services/telemetry-service.js'; import { ConfigService } from './services/config-service.js'; import { initBroadcast } from './services/broadcast-service.js'; +import { errorHandler } from './middleware/error-handler.js'; import type { AgentOutput } from './services/agent-service.js'; const app = express(); @@ -61,6 +62,9 @@ app.use('/api/metrics', metricsRoutes); app.use('/api/traces', tracesRoutes); app.use('/api/settings', settingsRoutes); +// Error handling middleware (must be last) +app.use(errorHandler); + // Initialize telemetry service and sync with feature settings (async () => { try { @@ -195,6 +199,33 @@ wss.on('connection', (ws) => { // Export for use in other modules export { wss }; +// Graceful shutdown handler +function gracefulShutdown(signal: string) { + console.log(`\n${signal} received. Shutting down gracefully...`); + + // Close WebSocket connections + console.log('Closing WebSocket connections...'); + wss.clients.forEach((client) => { + client.close(1000, 'Server shutting down'); + }); + + // Close HTTP server + server.close(() => { + console.log('HTTP server closed.'); + process.exit(0); + }); + + // Force exit after 10 seconds + setTimeout(() => { + console.error('Forced shutdown after timeout.'); + process.exit(1); + }, 10000); +} + +// Register shutdown handlers +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); + // Start server server.listen(PORT, () => { console.log(` diff --git a/server/src/middleware/async-handler.ts b/server/src/middleware/async-handler.ts new file mode 100644 index 00000000..fccb036f --- /dev/null +++ b/server/src/middleware/async-handler.ts @@ -0,0 +1,7 @@ +import { Request, Response, NextFunction, RequestHandler } from 'express'; + +export const asyncHandler = ( + fn: (req: Request, res: Response, next: NextFunction) => Promise<any> +): RequestHandler => + (req, res, next) => + Promise.resolve(fn(req, res, next)).catch(next); diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts new file mode 100644 index 00000000..27ef0413 --- /dev/null +++ b/server/src/middleware/error-handler.ts @@ -0,0 +1,54 @@ +import { Request, Response, NextFunction } from 'express'; + +// Custom error classes +export class AppError extends Error { + constructor( + public statusCode: number, + message: string, + public code?: string, + public details?: unknown + ) { + super(message); + this.name = 'AppError'; + } +} + +export class NotFoundError extends AppError { + constructor(message = 'Not found') { + super(404, message, 'NOT_FOUND'); + } +} + +export class ValidationError extends AppError { + constructor(message: string, details?: unknown) { + super(400, message, 'VALIDATION_ERROR', details); + } +} + +export class ConflictError extends AppError { + constructor(message: string) { + super(409, message, 'CONFLICT'); + } +} + +// Express error handling middleware (4 args) +export function errorHandler( + err: Error, + req: Request, + res: Response, + next: NextFunction +) { + if (err instanceof AppError) { + const response: { error: string; code?: string; details?: unknown } = { + error: err.message, + code: err.code, + }; + if (err.details) { + response.details = err.details; + } + return res.status(err.statusCode).json(response); + } + + console.error('Unhandled error:', err); + res.status(500).json({ error: 'Internal server error' }); +} diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index be85dbf3..c8c333e6 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -1,29 +1,20 @@ import { Router, type Router as RouterType } from 'express'; import { activityService } from '../services/activity-service.js'; +import { asyncHandler } from '../middleware/async-handler.js'; const router: RouterType = Router(); // GET /api/activity - Get recent activities -router.get('/', async (req, res) => { - try { - const limit = parseInt(req.query.limit as string) || 50; - const activities = await activityService.getActivities(limit); - res.json(activities); - } catch (error) { - console.error('Error getting activities:', error); - res.status(500).json({ error: 'Failed to get activities' }); - } -}); +router.get('/', asyncHandler(async (req, res) => { + const limit = parseInt(req.query.limit as string) || 50; + const activities = await activityService.getActivities(limit); + res.json(activities); +})); // DELETE /api/activity - Clear all activities -router.delete('/', async (_req, res) => { - try { - await activityService.clearActivities(); - res.status(204).send(); - } catch (error) { - console.error('Error clearing activities:', error); - res.status(500).json({ error: 'Failed to clear activities' }); - } -}); +router.delete('/', asyncHandler(async (_req, res) => { + await activityService.clearActivities(); + res.status(204).send(); +})); export default router; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 42123b60..54789825 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2,152 +2,114 @@ import { Router, type Router as RouterType } from 'express'; import { ClawdbotAgentService, clawdbotAgentService } from '../services/clawdbot-agent-service.js'; import { getTelemetryService } from '../services/telemetry-service.js'; import type { AgentType, TokenTelemetryEvent } from '@veritas-kanban/shared'; +import { asyncHandler } from '../middleware/async-handler.js'; +import { NotFoundError, ValidationError } from '../middleware/error-handler.js'; const router: RouterType = Router(); // POST /api/agents/:taskId/start - Start agent on task (delegates to Clawdbot) -router.post('/:taskId/start', async (req, res) => { - try { - const { agent } = req.body as { agent?: AgentType }; - const status = await clawdbotAgentService.startAgent(req.params.taskId, agent); - res.status(201).json(status); - } catch (error: any) { - console.error('Error starting agent:', error); - res.status(400).json({ error: error.message || 'Failed to start agent' }); - } -}); +router.post('/:taskId/start', asyncHandler(async (req, res) => { + const { agent } = req.body as { agent?: AgentType }; + const status = await clawdbotAgentService.startAgent((req.params.taskId as string), agent); + res.status(201).json(status); +})); // POST /api/agents/:taskId/complete - Callback from Clawdbot when agent finishes -router.post('/:taskId/complete', async (req, res) => { - try { - const { success, summary, error } = req.body as { - success: boolean; - summary?: string; - error?: string; - }; - - await clawdbotAgentService.completeAgent(req.params.taskId, { success, summary, error }); - res.json({ received: true }); - } catch (error: any) { - console.error('Error completing agent:', error); - res.status(400).json({ error: error.message || 'Failed to complete agent' }); - } -}); +router.post('/:taskId/complete', asyncHandler(async (req, res) => { + const { success, summary, error } = req.body as { + success: boolean; + summary?: string; + error?: string; + }; + + await clawdbotAgentService.completeAgent((req.params.taskId as string), { success, summary, error }); + res.json({ received: true }); +})); // POST /api/agents/:taskId/stop - Stop running agent -router.post('/:taskId/stop', async (req, res) => { - try { - await clawdbotAgentService.stopAgent(req.params.taskId); - res.json({ stopped: true }); - } catch (error: any) { - console.error('Error stopping agent:', error); - res.status(400).json({ error: error.message || 'Failed to stop agent' }); - } -}); +router.post('/:taskId/stop', asyncHandler(async (req, res) => { + await clawdbotAgentService.stopAgent(req.params.taskId as string); + res.json({ stopped: true }); +})); // GET /api/agents/:taskId/status - Get agent status -router.get('/:taskId/status', async (req, res) => { - try { - const status = clawdbotAgentService.getAgentStatus(req.params.taskId); - if (!status) { - return res.json({ running: false }); - } - res.json({ running: true, ...status }); - } catch (error: any) { - console.error('Error getting status:', error); - res.status(500).json({ error: error.message || 'Failed to get status' }); +router.get('/:taskId/status', asyncHandler(async (req, res) => { + const status = clawdbotAgentService.getAgentStatus(req.params.taskId as string); + if (!status) { + return res.json({ running: false }); } -}); + res.json({ running: true, ...status }); +})); // GET /api/agents/pending - List pending agent requests (for Veritas to poll) -router.get('/pending', async (_req, res) => { - try { - const requests = await clawdbotAgentService.listPendingRequests(); - res.json(requests); - } catch (error: any) { - console.error('Error listing pending:', error); - res.status(500).json({ error: error.message || 'Failed to list pending requests' }); - } -}); +router.get('/pending', asyncHandler(async (_req, res) => { + const requests = await clawdbotAgentService.listPendingRequests(); + res.json(requests); +})); // GET /api/agents/:taskId/attempts - List attempts for task -router.get('/:taskId/attempts', async (req, res) => { - try { - const attempts = await clawdbotAgentService.listAttempts(req.params.taskId); - res.json(attempts); - } catch (error: any) { - console.error('Error listing attempts:', error); - res.status(500).json({ error: error.message || 'Failed to list attempts' }); - } -}); +router.get('/:taskId/attempts', asyncHandler(async (req, res) => { + const attempts = await clawdbotAgentService.listAttempts(req.params.taskId as string); + res.json(attempts); +})); // GET /api/agents/:taskId/attempts/:attemptId/log - Get attempt log -router.get('/:taskId/attempts/:attemptId/log', async (req, res) => { - try { - const log = await clawdbotAgentService.getAttemptLog(req.params.taskId, req.params.attemptId); - res.type('text/markdown').send(log); - } catch (error: any) { - console.error('Error getting log:', error); - res.status(404).json({ error: error.message || 'Log not found' }); - } -}); +router.get('/:taskId/attempts/:attemptId/log', asyncHandler(async (req, res) => { + const log = await clawdbotAgentService.getAttemptLog((req.params.taskId as string), (req.params.attemptId as string)); + res.type('text/markdown').send(log); +})); // POST /api/agents/:taskId/tokens - Report token usage for a run -router.post('/:taskId/tokens', async (req, res) => { - try { - const { attemptId, inputTokens, outputTokens, totalTokens, model, agent } = req.body as { - attemptId?: string; - inputTokens: number; - outputTokens: number; - totalTokens?: number; - model?: string; - agent?: AgentType; - }; +router.post('/:taskId/tokens', asyncHandler(async (req, res) => { + const { attemptId, inputTokens, outputTokens, totalTokens, model, agent } = req.body as { + attemptId?: string; + inputTokens: number; + outputTokens: number; + totalTokens?: number; + model?: string; + agent?: AgentType; + }; - // Validate required fields - if (typeof inputTokens !== 'number' || typeof outputTokens !== 'number') { - return res.status(400).json({ error: 'inputTokens and outputTokens are required numbers' }); - } - - const taskId = req.params.taskId; - - // Get task to find project and current attempt - const { TaskService } = await import('../services/task-service.js'); - const taskService = new TaskService(); - const task = await taskService.getTask(taskId); - - if (!task) { - return res.status(404).json({ error: 'Task not found' }); - } - - // Use provided attemptId or current attempt - const resolvedAttemptId = attemptId || task.attempt?.id || 'unknown'; - const resolvedAgent = agent || task.attempt?.agent || 'claude-code'; - - // Emit telemetry event - const telemetry = getTelemetryService(); - const event = await telemetry.emit<TokenTelemetryEvent>({ - type: 'run.tokens', - taskId, - attemptId: resolvedAttemptId, - agent: resolvedAgent, - project: task.project, - inputTokens, - outputTokens, - totalTokens: totalTokens ?? (inputTokens + outputTokens), - model, - }); - - res.status(201).json({ - recorded: true, - eventId: event.id, - totalTokens: event.totalTokens, - }); - } catch (error: any) { - console.error('Error recording tokens:', error); - res.status(500).json({ error: error.message || 'Failed to record token usage' }); + // Validate required fields + if (typeof inputTokens !== 'number' || typeof outputTokens !== 'number') { + throw new ValidationError('inputTokens and outputTokens are required numbers'); } -}); + + const taskId = req.params.taskId as string; + + // Get task to find project and current attempt + const { TaskService } = await import('../services/task-service.js'); + const taskService = new TaskService(); + const task = await taskService.getTask(taskId); + + if (!task) { + throw new NotFoundError('Task not found'); + } + + // Use provided attemptId or current attempt + const resolvedAttemptId = attemptId || task.attempt?.id || 'unknown'; + const resolvedAgent = agent || task.attempt?.agent || 'claude-code'; + + // Emit telemetry event + const telemetry = getTelemetryService(); + const event = await telemetry.emit<TokenTelemetryEvent>({ + type: 'run.tokens', + taskId, + attemptId: resolvedAttemptId, + agent: resolvedAgent, + project: task.project, + inputTokens, + outputTokens, + totalTokens: totalTokens ?? (inputTokens + outputTokens), + model, + }); + + res.status(201).json({ + recorded: true, + eventId: event.id, + totalTokens: event.totalTokens, + }); +})); // Export service for WebSocket use export { router as agentRoutes, clawdbotAgentService as agentService }; diff --git a/server/src/routes/diff.ts b/server/src/routes/diff.ts index 8aeceed6..24f7d5ad 100644 --- a/server/src/routes/diff.ts +++ b/server/src/routes/diff.ts @@ -1,44 +1,31 @@ import { Router, type Router as RouterType } from 'express'; import { DiffService } from '../services/diff-service.js'; +import { asyncHandler } from '../middleware/async-handler.js'; +import { ValidationError } from '../middleware/error-handler.js'; const router: RouterType = Router(); const diffService = new DiffService(); // GET /api/diff/:taskId - Get diff summary for task -router.get('/:taskId', async (req, res) => { - try { - const summary = await diffService.getDiffSummary(req.params.taskId); - res.json(summary); - } catch (error: any) { - console.error('Error getting diff summary:', error); - res.status(400).json({ error: error.message || 'Failed to get diff summary' }); - } -}); +router.get('/:taskId', asyncHandler(async (req, res) => { + const summary = await diffService.getDiffSummary(req.params.taskId as string); + res.json(summary); +})); // GET /api/diff/:taskId/file - Get diff for specific file -router.get('/:taskId/file', async (req, res) => { - try { - const { path } = req.query; - if (!path || typeof path !== 'string') { - return res.status(400).json({ error: 'File path is required' }); - } - const diff = await diffService.getFileDiff(req.params.taskId, path); - res.json(diff); - } catch (error: any) { - console.error('Error getting file diff:', error); - res.status(400).json({ error: error.message || 'Failed to get file diff' }); +router.get('/:taskId/file', asyncHandler(async (req, res) => { + const path = req.query.path as string; + if (!path || typeof path !== 'string') { + throw new ValidationError('File path is required'); } -}); + const diff = await diffService.getFileDiff((req.params.taskId as string), path); + res.json(diff); +})); // GET /api/diff/:taskId/full - Get full diff for all files -router.get('/:taskId/full', async (req, res) => { - try { - const diffs = await diffService.getFullDiff(req.params.taskId); - res.json(diffs); - } catch (error: any) { - console.error('Error getting full diff:', error); - res.status(400).json({ error: error.message || 'Failed to get full diff' }); - } -}); +router.get('/:taskId/full', asyncHandler(async (req, res) => { + const diffs = await diffService.getFullDiff(req.params.taskId as string); + res.json(diffs); +})); export { router as diffRoutes }; diff --git a/server/src/routes/github.ts b/server/src/routes/github.ts index fec78660..cb25b67d 100644 --- a/server/src/routes/github.ts +++ b/server/src/routes/github.ts @@ -1,6 +1,8 @@ import { Router, type Router as RouterType } from 'express'; import { z } from 'zod'; import { GitHubService } from '../services/github-service.js'; +import { asyncHandler } from '../middleware/async-handler.js'; +import { ValidationError } from '../middleware/error-handler.js'; const router: RouterType = Router(); const githubService = new GitHubService(); @@ -15,40 +17,30 @@ const createPRSchema = z.object({ }); // GET /api/github/status - Check gh CLI status -router.get('/status', async (_req, res) => { - try { - const status = await githubService.checkGhCli(); - res.json(status); - } catch (error) { - console.error('Error checking GitHub CLI status:', error); - res.status(500).json({ error: 'Failed to check GitHub CLI status' }); - } -}); +router.get('/status', asyncHandler(async (_req, res) => { + const status = await githubService.checkGhCli(); + res.json(status); +})); // POST /api/github/pr - Create a PR for a task -router.post('/pr', async (req, res) => { +router.post('/pr', asyncHandler(async (req, res) => { + let input; try { - const input = createPRSchema.parse(req.body); - const pr = await githubService.createPR(input); - res.status(201).json(pr); - } catch (error: any) { + input = createPRSchema.parse(req.body); + } catch (error) { if (error instanceof z.ZodError) { - return res.status(400).json({ error: 'Validation failed', details: error.errors }); + throw new ValidationError('Validation failed', error.errors); } - console.error('Error creating PR:', error); - res.status(500).json({ error: error.message || 'Failed to create PR' }); + throw error; } -}); + const pr = await githubService.createPR(input); + res.status(201).json(pr); +})); // POST /api/github/pr/:taskId/open - Open PR in browser -router.post('/pr/:taskId/open', async (req, res) => { - try { - await githubService.openPRInBrowser(req.params.taskId); - res.json({ success: true }); - } catch (error: any) { - console.error('Error opening PR:', error); - res.status(500).json({ error: error.message || 'Failed to open PR' }); - } -}); +router.post('/pr/:taskId/open', asyncHandler(async (req, res) => { + await githubService.openPRInBrowser(req.params.taskId as string); + res.json({ success: true }); +})); export default router; diff --git a/server/src/routes/notifications.ts b/server/src/routes/notifications.ts index 7422c8d4..3e92ebee 100644 --- a/server/src/routes/notifications.ts +++ b/server/src/routes/notifications.ts @@ -3,6 +3,8 @@ import { z } from 'zod'; import { TaskService } from '../services/task-service.js'; import fs from 'fs/promises'; import path from 'path'; +import { asyncHandler } from '../middleware/async-handler.js'; +import { ValidationError } from '../middleware/error-handler.js'; const router: RouterType = Router(); const taskService = new TaskService(); @@ -75,203 +77,184 @@ const createSchema = z.object({ taskId: z.string().optional(), }); -router.post('/', async (req, res) => { +router.post('/', asyncHandler(async (req, res) => { + let input; try { - const input = createSchema.parse(req.body); - - let taskTitle: string | undefined; - let project: string | undefined; - - if (input.taskId) { - const task = await taskService.getTask(input.taskId); - if (task) { - taskTitle = task.title; - project = task.project; - } - } - - const notification = await addNotification({ - type: input.type, - title: input.title, - message: input.message, - taskId: input.taskId, - taskTitle, - project, - }); - - res.status(201).json(notification); + input = createSchema.parse(req.body); } catch (error) { if (error instanceof z.ZodError) { - return res.status(400).json({ error: 'Validation failed', details: error.errors }); + throw new ValidationError('Validation failed', error.errors); } - console.error('Error creating notification:', error); - res.status(500).json({ error: 'Failed to create notification' }); + throw error; } -}); + + let taskTitle: string | undefined; + let project: string | undefined; + + if (input.taskId) { + const task = await taskService.getTask(input.taskId); + if (task) { + taskTitle = task.title; + project = task.project; + } + } + + const notification = await addNotification({ + type: input.type, + title: input.title, + message: input.message, + taskId: input.taskId, + taskTitle, + project, + }); + + res.status(201).json(notification); +})); // GET /api/notifications - List notifications -router.get('/', async (req, res) => { - try { - const unsent = req.query.unsent === 'true'; - let notifications = await loadNotifications(); - - if (unsent) { - notifications = notifications.filter(n => !n.sent); - } - - // Most recent first - notifications.reverse(); - - res.json(notifications); - } catch (error) { - console.error('Error listing notifications:', error); - res.status(500).json({ error: 'Failed to list notifications' }); +router.get('/', asyncHandler(async (req, res) => { + const unsent = req.query.unsent === 'true'; + let notifications = await loadNotifications(); + + if (unsent) { + notifications = notifications.filter(n => !n.sent); } -}); + + // Most recent first + notifications.reverse(); + + res.json(notifications); +})); // GET /api/notifications/pending - Get unsent notifications formatted for Teams -router.get('/pending', async (_req, res) => { - try { - const notifications = await loadNotifications(); - const unsent = notifications.filter(n => !n.sent); +router.get('/pending', asyncHandler(async (_req, res) => { + const notifications = await loadNotifications(); + const unsent = notifications.filter(n => !n.sent); + + if (unsent.length === 0) { + return res.json({ count: 0, messages: [] }); + } + + // Format for Teams + const messages = unsent.map(n => { + const icon = typeIcons[n.type]; + let text = `${icon} **${n.title}**\n${n.message}`; - if (unsent.length === 0) { - return res.json({ count: 0, messages: [] }); + if (n.taskTitle) { + text += `\n\nšŸ“‹ Task: ${n.taskTitle}`; + if (n.project) text += ` (#${n.project})`; + text += `\nšŸ”— \`vk show ${n.taskId?.slice(-8)}\``; } - // Format for Teams - const messages = unsent.map(n => { - const icon = typeIcons[n.type]; - let text = `${icon} **${n.title}**\n${n.message}`; - - if (n.taskTitle) { - text += `\n\nšŸ“‹ Task: ${n.taskTitle}`; - if (n.project) text += ` (#${n.project})`; - text += `\nšŸ”— \`vk show ${n.taskId?.slice(-8)}\``; - } - - return { - id: n.id, - type: n.type, - text, - timestamp: n.timestamp, - }; - }); - - res.json({ count: unsent.length, messages }); - } catch (error) { - console.error('Error getting pending notifications:', error); - res.status(500).json({ error: 'Failed to get pending notifications' }); - } -}); + return { + id: n.id, + type: n.type, + text, + timestamp: n.timestamp, + }; + }); + + res.json({ count: unsent.length, messages }); +})); // POST /api/notifications/mark-sent - Mark notifications as sent const markSentSchema = z.object({ ids: z.array(z.string()), }); -router.post('/mark-sent', async (req, res) => { +router.post('/mark-sent', asyncHandler(async (req, res) => { + let ids; try { - const { ids } = markSentSchema.parse(req.body); - const notifications = await loadNotifications(); - - let marked = 0; - notifications.forEach(n => { - if (ids.includes(n.id)) { - n.sent = true; - marked++; - } - }); - - await saveNotifications(notifications); - res.json({ marked }); + ({ ids } = markSentSchema.parse(req.body)); } catch (error) { if (error instanceof z.ZodError) { - return res.status(400).json({ error: 'Validation failed', details: error.errors }); + throw new ValidationError('Validation failed', error.errors); } - console.error('Error marking notifications sent:', error); - res.status(500).json({ error: 'Failed to mark notifications sent' }); + throw error; } -}); + + const notifications = await loadNotifications(); + + let marked = 0; + notifications.forEach(n => { + if (ids.includes(n.id)) { + n.sent = true; + marked++; + } + }); + + await saveNotifications(notifications); + res.json({ marked }); +})); // POST /api/notifications/check - Check for tasks that need notifications -router.post('/check', async (_req, res) => { - try { - const tasks = await taskService.listTasks(); - const created: Notification[] = []; - - // Check for tasks in review (needs review notification) - const inReview = tasks.filter(t => - t.status === 'review' && - t.attempt?.status === 'complete' && - t.attempt?.agent !== 'veritas' +router.post('/check', asyncHandler(async (_req, res) => { + const tasks = await taskService.listTasks(); + const created: Notification[] = []; + + // Check for tasks in review (needs review notification) + const inReview = tasks.filter(t => + t.status === 'review' && + t.attempt?.status === 'complete' && + t.attempt?.agent !== 'veritas' + ); + + for (const task of inReview) { + // Check if we already notified about this + const existing = await loadNotifications(); + const alreadyNotified = existing.some(n => + n.taskId === task.id && + n.type === 'needs_review' && + new Date(n.timestamp) > new Date(Date.now() - 24 * 60 * 60 * 1000) // Within 24h ); - for (const task of inReview) { - // Check if we already notified about this - const existing = await loadNotifications(); - const alreadyNotified = existing.some(n => - n.taskId === task.id && - n.type === 'needs_review' && - new Date(n.timestamp) > new Date(Date.now() - 24 * 60 * 60 * 1000) // Within 24h - ); - - if (!alreadyNotified) { - const notification = await addNotification({ - type: 'needs_review', - title: 'Code Ready for Review', - message: `Agent completed work on "${task.title}". Please review the changes.`, - taskId: task.id, - taskTitle: task.title, - project: task.project, - }); - created.push(notification); - } + if (!alreadyNotified) { + const notification = await addNotification({ + type: 'needs_review', + title: 'Code Ready for Review', + message: `Agent completed work on "${task.title}". Please review the changes.`, + taskId: task.id, + taskTitle: task.title, + project: task.project, + }); + created.push(notification); } - - // Check for failed agent attempts - const failed = tasks.filter(t => - t.attempt?.status === 'failed' && - t.status !== 'done' - ); - - for (const task of failed) { - const existing = await loadNotifications(); - const alreadyNotified = existing.some(n => - n.taskId === task.id && - n.type === 'agent_failed' && - new Date(n.timestamp) > new Date(Date.now() - 24 * 60 * 60 * 1000) - ); - - if (!alreadyNotified) { - const notification = await addNotification({ - type: 'agent_failed', - title: 'Agent Failed', - message: `${task.attempt?.agent} failed on "${task.title}". May need manual intervention.`, - taskId: task.id, - taskTitle: task.title, - project: task.project, - }); - created.push(notification); - } - } - - res.json({ checked: tasks.length, created: created.length, notifications: created }); - } catch (error) { - console.error('Error checking for notifications:', error); - res.status(500).json({ error: 'Failed to check for notifications' }); } -}); + + // Check for failed agent attempts + const failed = tasks.filter(t => + t.attempt?.status === 'failed' && + t.status !== 'done' + ); + + for (const task of failed) { + const existing = await loadNotifications(); + const alreadyNotified = existing.some(n => + n.taskId === task.id && + n.type === 'agent_failed' && + new Date(n.timestamp) > new Date(Date.now() - 24 * 60 * 60 * 1000) + ); + + if (!alreadyNotified) { + const notification = await addNotification({ + type: 'agent_failed', + title: 'Agent Failed', + message: `${task.attempt?.agent} failed on "${task.title}". May need manual intervention.`, + taskId: task.id, + taskTitle: task.title, + project: task.project, + }); + created.push(notification); + } + } + + res.json({ checked: tasks.length, created: created.length, notifications: created }); +})); // DELETE /api/notifications - Clear all notifications -router.delete('/', async (_req, res) => { - try { - await saveNotifications([]); - res.json({ cleared: true }); - } catch (error) { - console.error('Error clearing notifications:', error); - res.status(500).json({ error: 'Failed to clear notifications' }); - } -}); +router.delete('/', asyncHandler(async (_req, res) => { + await saveNotifications([]); + res.json({ cleared: true }); +})); export { router as notificationRoutes }; diff --git a/server/src/routes/preview.ts b/server/src/routes/preview.ts index 916d315c..a1dbf389 100644 --- a/server/src/routes/preview.ts +++ b/server/src/routes/preview.ts @@ -1,66 +1,42 @@ import { Router, type Router as RouterType } from 'express'; import { PreviewService } from '../services/preview-service.js'; +import { asyncHandler } from '../middleware/async-handler.js'; const router: RouterType = Router(); const previewService = new PreviewService(); // GET /api/preview - List all running previews -router.get('/', async (_req, res) => { - try { - const previews = previewService.getAllPreviews(); - res.json(previews); - } catch (error) { - console.error('Error listing previews:', error); - res.status(500).json({ error: 'Failed to list previews' }); - } -}); +router.get('/', asyncHandler(async (_req, res) => { + const previews = previewService.getAllPreviews(); + res.json(previews); +})); // GET /api/preview/:taskId - Get preview status for a task -router.get('/:taskId', async (req, res) => { - try { - const status = previewService.getPreviewStatus(req.params.taskId); - if (!status) { - return res.json({ status: 'stopped' }); - } - res.json(status); - } catch (error) { - console.error('Error getting preview status:', error); - res.status(500).json({ error: 'Failed to get preview status' }); +router.get('/:taskId', asyncHandler(async (req, res) => { + const status = previewService.getPreviewStatus(req.params.taskId as string); + if (!status) { + return res.json({ status: 'stopped' }); } -}); + res.json(status); +})); // GET /api/preview/:taskId/output - Get preview server output -router.get('/:taskId/output', async (req, res) => { - try { - const lines = parseInt(req.query.lines as string) || 50; - const output = previewService.getPreviewOutput(req.params.taskId, lines); - res.json({ output }); - } catch (error) { - console.error('Error getting preview output:', error); - res.status(500).json({ error: 'Failed to get preview output' }); - } -}); +router.get('/:taskId/output', asyncHandler(async (req, res) => { + const lines = parseInt(req.query.lines as string) || 50; + const output = previewService.getPreviewOutput((req.params.taskId as string), lines); + res.json({ output }); +})); // POST /api/preview/:taskId/start - Start preview for a task -router.post('/:taskId/start', async (req, res) => { - try { - const preview = await previewService.startPreview(req.params.taskId); - res.status(201).json(preview); - } catch (error: any) { - console.error('Error starting preview:', error); - res.status(500).json({ error: error.message || 'Failed to start preview' }); - } -}); +router.post('/:taskId/start', asyncHandler(async (req, res) => { + const preview = await previewService.startPreview(req.params.taskId as string); + res.status(201).json(preview); +})); // POST /api/preview/:taskId/stop - Stop preview for a task -router.post('/:taskId/stop', async (req, res) => { - try { - await previewService.stopPreview(req.params.taskId); - res.json({ success: true }); - } catch (error: any) { - console.error('Error stopping preview:', error); - res.status(500).json({ error: error.message || 'Failed to stop preview' }); - } -}); +router.post('/:taskId/stop', asyncHandler(async (req, res) => { + await previewService.stopPreview(req.params.taskId as string); + res.json({ success: true }); +})); export default router; diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index a713fed2..724bf2ca 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -5,6 +5,8 @@ import { WorktreeService } from '../services/worktree-service.js'; import { activityService } from '../services/activity-service.js'; import type { CreateTaskInput, UpdateTaskInput } from '@veritas-kanban/shared'; import { broadcastTaskChange } from '../services/broadcast-service.js'; +import { asyncHandler } from '../middleware/async-handler.js'; +import { NotFoundError, ValidationError } from '../middleware/error-handler.js'; const router: RouterType = Router(); const taskService = new TaskService(); @@ -83,292 +85,231 @@ const updateTaskSchema = z.object({ }); // GET /api/tasks - List all tasks -router.get('/', async (_req, res) => { - try { - const tasks = await taskService.listTasks(); - res.json(tasks); - } catch (error) { - console.error('Error listing tasks:', error); - res.status(500).json({ error: 'Failed to list tasks' }); - } -}); +router.get('/', asyncHandler(async (_req, res) => { + const tasks = await taskService.listTasks(); + res.json(tasks); +})); // GET /api/tasks/archived - List archived tasks -router.get('/archived', async (_req, res) => { - try { - const tasks = await taskService.listArchivedTasks(); - res.json(tasks); - } catch (error) { - console.error('Error listing archived tasks:', error); - res.status(500).json({ error: 'Failed to list archived tasks' }); - } -}); +router.get('/archived', asyncHandler(async (_req, res) => { + const tasks = await taskService.listArchivedTasks(); + res.json(tasks); +})); // GET /api/tasks/archive/suggestions - Get sprints ready to archive -router.get('/archive/suggestions', async (_req, res) => { - try { - const suggestions = await taskService.getArchiveSuggestions(); - res.json(suggestions); - } catch (error) { - console.error('Error getting archive suggestions:', error); - res.status(500).json({ error: 'Failed to get archive suggestions' }); - } -}); +router.get('/archive/suggestions', asyncHandler(async (_req, res) => { + const suggestions = await taskService.getArchiveSuggestions(); + res.json(suggestions); +})); // POST /api/tasks/archive/sprint/:sprint - Archive all tasks in a sprint -router.post('/archive/sprint/:sprint', async (req, res) => { - try { - const result = await taskService.archiveSprint(req.params.sprint); - - // Log activity - await activityService.logActivity('sprint_archived', req.params.sprint, req.params.sprint, { - taskCount: result.archived, - }); - - res.json(result); - } catch (error: any) { - console.error('Error archiving sprint:', error); - res.status(400).json({ error: error.message || 'Failed to archive sprint' }); - } -}); +router.post('/archive/sprint/:sprint', asyncHandler(async (req, res) => { + const result = await taskService.archiveSprint(req.params.sprint as string); + + // Log activity + await activityService.logActivity('sprint_archived', (req.params.sprint as string), (req.params.sprint as string), { + taskCount: result.archived, + }); + + res.json(result); +})); // POST /api/tasks/reorder - Reorder tasks within a column -router.post('/reorder', async (req, res) => { - try { - const { orderedIds } = req.body as { orderedIds: string[] }; - if (!Array.isArray(orderedIds) || orderedIds.length === 0) { - return res.status(400).json({ error: 'orderedIds must be a non-empty array of task IDs' }); - } - const updated = await taskService.reorderTasks(orderedIds); - broadcastTaskChange('reordered'); - res.json({ updated: updated.length }); - } catch (error) { - console.error('Error reordering tasks:', error); - res.status(500).json({ error: 'Failed to reorder tasks' }); +router.post('/reorder', asyncHandler(async (req, res) => { + const { orderedIds } = req.body as { orderedIds: string[] }; + if (!Array.isArray(orderedIds) || orderedIds.length === 0) { + throw new ValidationError('orderedIds must be a non-empty array of task IDs'); } -}); + const updated = await taskService.reorderTasks(orderedIds); + broadcastTaskChange('reordered'); + res.json({ updated: updated.length }); +})); // GET /api/tasks/time/summary - Get time summary by project (must be before /:id) -router.get('/time/summary', async (_req, res) => { - try { - const summary = await taskService.getTimeSummary(); - res.json(summary); - } catch (error) { - console.error('Error getting time summary:', error); - res.status(500).json({ error: 'Failed to get time summary' }); - } -}); +router.get('/time/summary', asyncHandler(async (_req, res) => { + const summary = await taskService.getTimeSummary(); + res.json(summary); +})); // GET /api/tasks/:id - Get single task -router.get('/:id', async (req, res) => { - try { - const task = await taskService.getTask(req.params.id); - if (!task) { - return res.status(404).json({ error: 'Task not found' }); - } - res.json(task); - } catch (error) { - console.error('Error getting task:', error); - res.status(500).json({ error: 'Failed to get task' }); +router.get('/:id', asyncHandler(async (req, res) => { + const task = await taskService.getTask(req.params.id as string); + if (!task) { + throw new NotFoundError('Task not found'); } -}); + res.json(task); +})); // GET /api/tasks/:id/blocking-status - Get task blocking status -router.get('/:id/blocking-status', async (req, res) => { - try { - const task = await taskService.getTask(req.params.id); - if (!task) { - return res.status(404).json({ error: 'Task not found' }); - } - - if (!task.blockedBy?.length) { - return res.json({ isBlocked: false, blockers: [] }); - } - - const allTasks = await taskService.listTasks(); - const blockingTasks = allTasks.filter(t => task.blockedBy?.includes(t.id)); - const incompleteBlockers = blockingTasks.filter(t => t.status !== 'done'); - - res.json({ - isBlocked: incompleteBlockers.length > 0, - blockers: incompleteBlockers.map(t => ({ id: t.id, title: t.title, status: t.status })), - completedBlockers: blockingTasks.filter(t => t.status === 'done').map(t => ({ id: t.id, title: t.title })), - }); - } catch (error) { - console.error('Error getting blocking status:', error); - res.status(500).json({ error: 'Failed to get blocking status' }); +router.get('/:id/blocking-status', asyncHandler(async (req, res) => { + const task = await taskService.getTask(req.params.id as string); + if (!task) { + throw new NotFoundError('Task not found'); } -}); + + if (!task.blockedBy?.length) { + return res.json({ isBlocked: false, blockers: [] }); + } + + const allTasks = await taskService.listTasks(); + const blockingTasks = allTasks.filter(t => task.blockedBy?.includes(t.id)); + const incompleteBlockers = blockingTasks.filter(t => t.status !== 'done'); + + res.json({ + isBlocked: incompleteBlockers.length > 0, + blockers: incompleteBlockers.map(t => ({ id: t.id, title: t.title, status: t.status })), + completedBlockers: blockingTasks.filter(t => t.status === 'done').map(t => ({ id: t.id, title: t.title })), + }); +})); // POST /api/tasks - Create task -router.post('/', async (req, res) => { +router.post('/', asyncHandler(async (req, res) => { try { - const input = createTaskSchema.parse(req.body) as CreateTaskInput; - const task = await taskService.createTask(input); - broadcastTaskChange('created', task.id); - - // Log activity - await activityService.logActivity('task_created', task.id, task.title, { - type: task.type, - priority: task.priority, - project: task.project, - }); - - res.status(201).json(task); + var input = createTaskSchema.parse(req.body) as CreateTaskInput; } catch (error) { if (error instanceof z.ZodError) { - return res.status(400).json({ error: 'Validation failed', details: error.errors }); + throw new ValidationError('Validation failed', error.errors); } - console.error('Error creating task:', error); - res.status(500).json({ error: 'Failed to create task' }); + throw error; } -}); + const task = await taskService.createTask(input); + broadcastTaskChange('created', task.id); + + // Log activity + await activityService.logActivity('task_created', task.id, task.title, { + type: task.type, + priority: task.priority, + project: task.project, + }); + + res.status(201).json(task); +})); // PATCH /api/tasks/:id - Update task -router.patch('/:id', async (req, res) => { +router.patch('/:id', asyncHandler(async (req, res) => { + let input: UpdateTaskInput; try { - const input = updateTaskSchema.parse(req.body) as UpdateTaskInput; - const oldTask = await taskService.getTask(req.params.id); - if (!oldTask) { - return res.status(404).json({ error: 'Task not found' }); - } - - // Check if trying to move blocked task to in-progress - if (input.status === 'in-progress' && oldTask.status === 'todo' && oldTask.blockedBy?.length) { - const allTasks = await taskService.listTasks(); - const blockingTasks = allTasks.filter(t => oldTask.blockedBy?.includes(t.id)); - const incompleteBlockers = blockingTasks.filter(t => t.status !== 'done'); - - if (incompleteBlockers.length > 0) { - return res.status(400).json({ - error: 'Task is blocked', - blockedBy: incompleteBlockers.map(t => ({ id: t.id, title: t.title })), - }); - } - } - - const task = await taskService.updateTask(req.params.id, input); - if (!task) { - return res.status(404).json({ error: 'Task not found' }); - } - broadcastTaskChange('updated', task.id); - - // Log activity for status changes - if (input.status && oldTask.status !== input.status) { - await activityService.logActivity('status_changed', task.id, task.title, { - from: oldTask.status, - status: input.status, - }); - } else { - await activityService.logActivity('task_updated', task.id, task.title); - } - - res.json(task); + input = updateTaskSchema.parse(req.body) as UpdateTaskInput; } catch (error) { if (error instanceof z.ZodError) { - return res.status(400).json({ error: 'Validation failed', details: error.errors }); + throw new ValidationError('Validation failed', error.errors); } - console.error('Error updating task:', error); - res.status(500).json({ error: 'Failed to update task' }); + throw error; } -}); + + const oldTask = await taskService.getTask(req.params.id as string); + if (!oldTask) { + throw new NotFoundError('Task not found'); + } + + // Check if trying to move blocked task to in-progress + if (input.status === 'in-progress' && oldTask.status === 'todo' && oldTask.blockedBy?.length) { + const allTasks = await taskService.listTasks(); + const blockingTasks = allTasks.filter(t => oldTask.blockedBy?.includes(t.id)); + const incompleteBlockers = blockingTasks.filter(t => t.status !== 'done'); + + if (incompleteBlockers.length > 0) { + throw new ValidationError('Task is blocked', { + blockedBy: incompleteBlockers.map(t => ({ id: t.id, title: t.title })), + }); + } + } + + const task = await taskService.updateTask((req.params.id as string), input); + if (!task) { + throw new NotFoundError('Task not found'); + } + broadcastTaskChange('updated', task.id); + + // Log activity for status changes + if (input.status && oldTask.status !== input.status) { + await activityService.logActivity('status_changed', task.id, task.title, { + from: oldTask.status, + status: input.status, + }); + } else { + await activityService.logActivity('task_updated', task.id, task.title); + } + + res.json(task); +})); // DELETE /api/tasks/:id - Delete task (move to archive) -router.delete('/:id', async (req, res) => { - try { - const task = await taskService.getTask(req.params.id); - const success = await taskService.deleteTask(req.params.id); - if (!success) { - return res.status(404).json({ error: 'Task not found' }); - } - broadcastTaskChange('deleted', req.params.id); - - // Log activity - if (task) { - await activityService.logActivity('task_deleted', task.id, task.title); - } - - res.status(204).send(); - } catch (error) { - console.error('Error deleting task:', error); - res.status(500).json({ error: 'Failed to delete task' }); +router.delete('/:id', asyncHandler(async (req, res) => { + const task = await taskService.getTask(req.params.id as string); + const success = await taskService.deleteTask(req.params.id as string); + if (!success) { + throw new NotFoundError('Task not found'); } -}); + broadcastTaskChange('deleted', (req.params.id as string)); + + // Log activity + if (task) { + await activityService.logActivity('task_deleted', task.id, task.title); + } + + res.status(204).send(); +})); // POST /api/tasks/:id/archive - Archive task -router.post('/:id/archive', async (req, res) => { - try { - const task = await taskService.getTask(req.params.id); - const success = await taskService.archiveTask(req.params.id); - if (!success) { - return res.status(404).json({ error: 'Task not found' }); - } - broadcastTaskChange('archived', req.params.id); - - // Log activity - if (task) { - await activityService.logActivity('task_archived', task.id, task.title); - } - - res.json({ archived: true }); - } catch (error) { - console.error('Error archiving task:', error); - res.status(500).json({ error: 'Failed to archive task' }); +router.post('/:id/archive', asyncHandler(async (req, res) => { + const task = await taskService.getTask(req.params.id as string); + const success = await taskService.archiveTask(req.params.id as string); + if (!success) { + throw new NotFoundError('Task not found'); } -}); + broadcastTaskChange('archived', (req.params.id as string)); + + // Log activity + if (task) { + await activityService.logActivity('task_archived', task.id, task.title); + } + + res.json({ archived: true }); +})); // POST /api/tasks/bulk-archive - Archive multiple tasks by sprint -router.post('/bulk-archive', async (req, res) => { - try { - const { sprint } = req.body as { sprint: string }; - if (!sprint) { - return res.status(400).json({ error: 'Sprint is required' }); - } - - const tasks = await taskService.listTasks(); - const sprintTasks = tasks.filter(t => t.sprint === sprint && t.status === 'done'); - - if (sprintTasks.length === 0) { - return res.status(400).json({ error: 'No completed tasks found for this sprint' }); - } - - const archived: string[] = []; - for (const task of sprintTasks) { - const success = await taskService.archiveTask(task.id); - if (success) { - archived.push(task.id); - await activityService.logActivity('task_archived', task.id, task.title); - } - } - - res.json({ archived, count: archived.length }); - } catch (error) { - console.error('Error bulk archiving tasks:', error); - res.status(500).json({ error: 'Failed to archive tasks' }); +router.post('/bulk-archive', asyncHandler(async (req, res) => { + const { sprint } = req.body as { sprint: string }; + if (!sprint) { + throw new ValidationError('Sprint is required'); } -}); + + const tasks = await taskService.listTasks(); + const sprintTasks = tasks.filter(t => t.sprint === sprint && t.status === 'done'); + + if (sprintTasks.length === 0) { + throw new ValidationError('No completed tasks found for this sprint'); + } + + const archived: string[] = []; + for (const task of sprintTasks) { + const success = await taskService.archiveTask(task.id); + if (success) { + archived.push(task.id); + await activityService.logActivity('task_archived', task.id, task.title); + } + } + + res.json({ archived, count: archived.length }); +})); // POST /api/tasks/:id/restore - Restore task from archive -router.post('/:id/restore', async (req, res) => { - try { - const task = await taskService.restoreTask(req.params.id); - if (!task) { - return res.status(404).json({ error: 'Archived task not found' }); - } - broadcastTaskChange('restored', task.id); - - // Log activity - await activityService.logActivity('status_changed', task.id, task.title, { - from: 'archived', - status: 'done', - }); - - res.json(task); - } catch (error) { - console.error('Error restoring task:', error); - res.status(500).json({ error: 'Failed to restore task' }); +router.post('/:id/restore', asyncHandler(async (req, res) => { + const task = await taskService.restoreTask(req.params.id as string); + if (!task) { + throw new NotFoundError('Archived task not found'); } -}); + broadcastTaskChange('restored', task.id); + + // Log activity + await activityService.logActivity('status_changed', task.id, task.title, { + from: 'archived', + status: 'done', + }); + + res.json(task); +})); // === Subtask Routes === @@ -387,391 +328,325 @@ const addCommentSchema = z.object({ }); // POST /api/tasks/:id/subtasks - Add subtask -router.post('/:id/subtasks', async (req, res) => { +router.post('/:id/subtasks', asyncHandler(async (req, res) => { + let title: string; try { - const { title } = addSubtaskSchema.parse(req.body); - const task = await taskService.getTask(req.params.id); - if (!task) { - return res.status(404).json({ error: 'Task not found' }); - } - - const subtask = { - id: `subtask_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, - title, - completed: false, - created: new Date().toISOString(), - }; - - const subtasks = [...(task.subtasks || []), subtask]; - const updatedTask = await taskService.updateTask(req.params.id, { subtasks }); - - res.status(201).json(updatedTask); + ({ title } = addSubtaskSchema.parse(req.body)); } catch (error) { if (error instanceof z.ZodError) { - return res.status(400).json({ error: 'Validation failed', details: error.errors }); + throw new ValidationError('Validation failed', error.errors); } - console.error('Error adding subtask:', error); - res.status(500).json({ error: 'Failed to add subtask' }); + throw error; } -}); + + const task = await taskService.getTask(req.params.id as string); + if (!task) { + throw new NotFoundError('Task not found'); + } + + const subtask = { + id: `subtask_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + title, + completed: false, + created: new Date().toISOString(), + }; + + const subtasks = [...(task.subtasks || []), subtask]; + const updatedTask = await taskService.updateTask((req.params.id as string), { subtasks }); + + res.status(201).json(updatedTask); +})); // PATCH /api/tasks/:id/subtasks/:subtaskId - Update subtask -router.patch('/:id/subtasks/:subtaskId', async (req, res) => { +router.patch('/:id/subtasks/:subtaskId', asyncHandler(async (req, res) => { + let updates; try { - const updates = updateSubtaskSchema.parse(req.body); - const task = await taskService.getTask(req.params.id); - if (!task) { - return res.status(404).json({ error: 'Task not found' }); - } - - const subtasks = task.subtasks || []; - const subtaskIndex = subtasks.findIndex(s => s.id === req.params.subtaskId); - if (subtaskIndex === -1) { - return res.status(404).json({ error: 'Subtask not found' }); - } - - subtasks[subtaskIndex] = { ...subtasks[subtaskIndex], ...updates }; - - // Check if we should auto-complete the parent task - let taskUpdates: any = { subtasks }; - if (task.autoCompleteOnSubtasks && subtasks.every(s => s.completed)) { - taskUpdates.status = 'done'; - } - - const updatedTask = await taskService.updateTask(req.params.id, taskUpdates); - - res.json(updatedTask); + updates = updateSubtaskSchema.parse(req.body); } catch (error) { if (error instanceof z.ZodError) { - return res.status(400).json({ error: 'Validation failed', details: error.errors }); + throw new ValidationError('Validation failed', error.errors); } - console.error('Error updating subtask:', error); - res.status(500).json({ error: 'Failed to update subtask' }); + throw error; } -}); + + const task = await taskService.getTask(req.params.id as string); + if (!task) { + throw new NotFoundError('Task not found'); + } + + const subtasks = task.subtasks || []; + const subtaskIndex = subtasks.findIndex(s => s.id === (req.params.subtaskId as string)); + if (subtaskIndex === -1) { + throw new NotFoundError('Subtask not found'); + } + + subtasks[subtaskIndex] = { ...subtasks[subtaskIndex], ...updates }; + + // Check if we should auto-complete the parent task + let taskUpdates: any = { subtasks }; + if (task.autoCompleteOnSubtasks && subtasks.every(s => s.completed)) { + taskUpdates.status = 'done'; + } + + const updatedTask = await taskService.updateTask((req.params.id as string), taskUpdates); + + res.json(updatedTask); +})); // DELETE /api/tasks/:id/subtasks/:subtaskId - Delete subtask -router.delete('/:id/subtasks/:subtaskId', async (req, res) => { - try { - const task = await taskService.getTask(req.params.id); - if (!task) { - return res.status(404).json({ error: 'Task not found' }); - } - - const subtasks = (task.subtasks || []).filter(s => s.id !== req.params.subtaskId); - const updatedTask = await taskService.updateTask(req.params.id, { subtasks }); - - res.json(updatedTask); - } catch (error) { - console.error('Error deleting subtask:', error); - res.status(500).json({ error: 'Failed to delete subtask' }); +router.delete('/:id/subtasks/:subtaskId', asyncHandler(async (req, res) => { + const task = await taskService.getTask(req.params.id as string); + if (!task) { + throw new NotFoundError('Task not found'); } -}); + + const subtasks = (task.subtasks || []).filter(s => s.id !== (req.params.subtaskId as string)); + const updatedTask = await taskService.updateTask((req.params.id as string), { subtasks }); + + res.json(updatedTask); +})); // === Comment Routes === // POST /api/tasks/:id/comments - Add comment -router.post('/:id/comments', async (req, res) => { +router.post('/:id/comments', asyncHandler(async (req, res) => { + let author: string, text: string; try { - const { author, text } = addCommentSchema.parse(req.body); - const task = await taskService.getTask(req.params.id); - if (!task) { - return res.status(404).json({ error: 'Task not found' }); - } - - const comment = { - id: `comment_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, - author, - text, - timestamp: new Date().toISOString(), - }; - - const comments = [...(task.comments || []), comment]; - const updatedTask = await taskService.updateTask(req.params.id, { comments }); - - // Log activity - await activityService.logActivity('comment_added', task.id, task.title, { - author, - preview: text.slice(0, 50) + (text.length > 50 ? '...' : ''), - }); - - res.status(201).json(updatedTask); + ({ author, text } = addCommentSchema.parse(req.body)); } catch (error) { if (error instanceof z.ZodError) { - return res.status(400).json({ error: 'Validation failed', details: error.errors }); + throw new ValidationError('Validation failed', error.errors); } - console.error('Error adding comment:', error); - res.status(500).json({ error: 'Failed to add comment' }); + throw error; } -}); + + const task = await taskService.getTask(req.params.id as string); + if (!task) { + throw new NotFoundError('Task not found'); + } + + const comment = { + id: `comment_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + author, + text, + timestamp: new Date().toISOString(), + }; + + const comments = [...(task.comments || []), comment]; + const updatedTask = await taskService.updateTask((req.params.id as string), { comments }); + + // Log activity + await activityService.logActivity('comment_added', task.id, task.title, { + author, + preview: text.slice(0, 50) + (text.length > 50 ? '...' : ''), + }); + + res.status(201).json(updatedTask); +})); // PATCH /api/tasks/:id/comments/:commentId - Edit comment -router.patch('/:id/comments/:commentId', async (req, res) => { +router.patch('/:id/comments/:commentId', asyncHandler(async (req, res) => { + let text: string; try { - const { text } = z.object({ text: z.string().min(1) }).parse(req.body); - const task = await taskService.getTask(req.params.id); - if (!task) { - return res.status(404).json({ error: 'Task not found' }); - } - - const comments = task.comments || []; - const commentIndex = comments.findIndex(c => c.id === req.params.commentId); - if (commentIndex === -1) { - return res.status(404).json({ error: 'Comment not found' }); - } - - comments[commentIndex] = { - ...comments[commentIndex], - text, - timestamp: comments[commentIndex].timestamp, // preserve original timestamp - }; - - const updatedTask = await taskService.updateTask(req.params.id, { comments }); - res.json(updatedTask); + ({ text } = z.object({ text: z.string().min(1) }).parse(req.body)); } catch (error) { if (error instanceof z.ZodError) { - return res.status(400).json({ error: 'Validation failed', details: error.errors }); + throw new ValidationError('Validation failed', error.errors); } - console.error('Error editing comment:', error); - res.status(500).json({ error: 'Failed to edit comment' }); + throw error; } -}); + + const task = await taskService.getTask(req.params.id as string); + if (!task) { + throw new NotFoundError('Task not found'); + } + + const comments = task.comments || []; + const commentIndex = comments.findIndex(c => c.id === (req.params.commentId as string)); + if (commentIndex === -1) { + throw new NotFoundError('Comment not found'); + } + + comments[commentIndex] = { + ...comments[commentIndex], + text, + timestamp: comments[commentIndex].timestamp, // preserve original timestamp + }; + + const updatedTask = await taskService.updateTask((req.params.id as string), { comments }); + res.json(updatedTask); +})); // DELETE /api/tasks/:id/comments/:commentId - Delete comment -router.delete('/:id/comments/:commentId', async (req, res) => { - try { - const task = await taskService.getTask(req.params.id); - if (!task) { - return res.status(404).json({ error: 'Task not found' }); - } - - const comments = task.comments || []; - const filtered = comments.filter(c => c.id !== req.params.commentId); - if (filtered.length === comments.length) { - return res.status(404).json({ error: 'Comment not found' }); - } - - const updatedTask = await taskService.updateTask(req.params.id, { comments: filtered }); - - await activityService.logActivity('comment_deleted', task.id, task.title, { - commentId: req.params.commentId, - }); - - res.json(updatedTask); - } catch (error) { - console.error('Error deleting comment:', error); - res.status(500).json({ error: 'Failed to delete comment' }); +router.delete('/:id/comments/:commentId', asyncHandler(async (req, res) => { + const task = await taskService.getTask(req.params.id as string); + if (!task) { + throw new NotFoundError('Task not found'); } -}); + + const comments = task.comments || []; + const filtered = comments.filter(c => c.id !== (req.params.commentId as string)); + if (filtered.length === comments.length) { + throw new NotFoundError('Comment not found'); + } + + const updatedTask = await taskService.updateTask((req.params.id as string), { comments: filtered }); + + await activityService.logActivity('comment_deleted', task.id, task.title, { + commentId: (req.params.commentId as string), + }); + + res.json(updatedTask); +})); // === Worktree Routes === // POST /api/tasks/:id/worktree - Create worktree -router.post('/:id/worktree', async (req, res) => { - try { - const worktree = await worktreeService.createWorktree(req.params.id); - res.status(201).json(worktree); - } catch (error: any) { - console.error('Error creating worktree:', error); - res.status(400).json({ error: error.message || 'Failed to create worktree' }); - } -}); +router.post('/:id/worktree', asyncHandler(async (req, res) => { + const worktree = await worktreeService.createWorktree(req.params.id as string); + res.status(201).json(worktree); +})); // GET /api/tasks/:id/worktree - Get worktree status -router.get('/:id/worktree', async (req, res) => { - try { - const status = await worktreeService.getWorktreeStatus(req.params.id); - res.json(status); - } catch (error: any) { - console.error('Error getting worktree status:', error); - res.status(400).json({ error: error.message || 'Failed to get worktree status' }); - } -}); +router.get('/:id/worktree', asyncHandler(async (req, res) => { + const status = await worktreeService.getWorktreeStatus(req.params.id as string); + res.json(status); +})); // DELETE /api/tasks/:id/worktree - Delete worktree -router.delete('/:id/worktree', async (req, res) => { - try { - const force = req.query.force === 'true'; - await worktreeService.deleteWorktree(req.params.id, force); - res.status(204).send(); - } catch (error: any) { - console.error('Error deleting worktree:', error); - res.status(400).json({ error: error.message || 'Failed to delete worktree' }); - } -}); +router.delete('/:id/worktree', asyncHandler(async (req, res) => { + const force = req.query.force === 'true'; + await worktreeService.deleteWorktree((req.params.id as string), force); + res.status(204).send(); +})); // POST /api/tasks/:id/worktree/rebase - Rebase worktree -router.post('/:id/worktree/rebase', async (req, res) => { - try { - const status = await worktreeService.rebaseWorktree(req.params.id); - res.json(status); - } catch (error: any) { - console.error('Error rebasing worktree:', error); - res.status(400).json({ error: error.message || 'Failed to rebase worktree' }); - } -}); +router.post('/:id/worktree/rebase', asyncHandler(async (req, res) => { + const status = await worktreeService.rebaseWorktree(req.params.id as string); + res.json(status); +})); // POST /api/tasks/:id/worktree/merge - Merge worktree to base branch -router.post('/:id/worktree/merge', async (req, res) => { - try { - await worktreeService.mergeWorktree(req.params.id); - res.json({ merged: true }); - } catch (error: any) { - console.error('Error merging worktree:', error); - res.status(400).json({ error: error.message || 'Failed to merge worktree' }); - } -}); +router.post('/:id/worktree/merge', asyncHandler(async (req, res) => { + await worktreeService.mergeWorktree(req.params.id as string); + res.json({ merged: true }); +})); // GET /api/tasks/:id/worktree/open - Get VS Code open command -router.get('/:id/worktree/open', async (req, res) => { - try { - const command = await worktreeService.openInVSCode(req.params.id); - res.json({ command }); - } catch (error: any) { - console.error('Error getting open command:', error); - res.status(400).json({ error: error.message || 'Failed to get open command' }); - } -}); +router.get('/:id/worktree/open', asyncHandler(async (req, res) => { + const command = await worktreeService.openInVSCode(req.params.id as string); + res.json({ command }); +})); // === Template Application Routes === // POST /api/tasks/:id/apply-template - Apply template to existing task -router.post('/:id/apply-template', async (req, res) => { - try { - const { templateId, templateName, fieldsChanged } = req.body; - - if (!templateId) { - return res.status(400).json({ error: 'Template ID is required' }); - } - - const task = await taskService.getTask(req.params.id); - if (!task) { - return res.status(404).json({ error: 'Task not found' }); - } - - // Log activity for template application - await activityService.logActivity('template_applied', task.id, task.title, { - templateId, - templateName: templateName || 'Unknown', - fieldsChanged: fieldsChanged || [], - }); - - res.json({ success: true }); - } catch (error: any) { - console.error('Error logging template application:', error); - res.status(500).json({ error: error.message || 'Failed to log template application' }); +router.post('/:id/apply-template', asyncHandler(async (req, res) => { + const { templateId, templateName, fieldsChanged } = req.body; + + if (!templateId) { + throw new ValidationError('Template ID is required'); } -}); + + const task = await taskService.getTask(req.params.id as string); + if (!task) { + throw new NotFoundError('Task not found'); + } + + // Log activity for template application + await activityService.logActivity('template_applied', task.id, task.title, { + templateId, + templateName: templateName || 'Unknown', + fieldsChanged: fieldsChanged || [], + }); + + res.json({ success: true }); +})); // === Time Tracking Routes === // POST /api/tasks/:id/time/start - Start timer for a task -router.post('/:id/time/start', async (req, res) => { - try { - const task = await taskService.startTimer(req.params.id); - res.json(task); - } catch (error: any) { - console.error('Error starting timer:', error); - res.status(400).json({ error: error.message || 'Failed to start timer' }); - } -}); +router.post('/:id/time/start', asyncHandler(async (req, res) => { + const task = await taskService.startTimer(req.params.id as string); + res.json(task); +})); // POST /api/tasks/:id/time/stop - Stop timer for a task -router.post('/:id/time/stop', async (req, res) => { - try { - const task = await taskService.stopTimer(req.params.id); - res.json(task); - } catch (error: any) { - console.error('Error stopping timer:', error); - res.status(400).json({ error: error.message || 'Failed to stop timer' }); - } -}); +router.post('/:id/time/stop', asyncHandler(async (req, res) => { + const task = await taskService.stopTimer(req.params.id as string); + res.json(task); +})); // POST /api/tasks/:id/time/entry - Add manual time entry -router.post('/:id/time/entry', async (req, res) => { - try { - const { duration, description } = req.body; - if (typeof duration !== 'number' || duration <= 0) { - return res.status(400).json({ error: 'Duration must be a positive number (in seconds)' }); - } - const task = await taskService.addTimeEntry(req.params.id, duration, description); - res.json(task); - } catch (error: any) { - console.error('Error adding time entry:', error); - res.status(400).json({ error: error.message || 'Failed to add time entry' }); +router.post('/:id/time/entry', asyncHandler(async (req, res) => { + const { duration, description } = req.body; + if (typeof duration !== 'number' || duration <= 0) { + throw new ValidationError('Duration must be a positive number (in seconds)'); } -}); + const task = await taskService.addTimeEntry((req.params.id as string), duration, description); + res.json(task); +})); // DELETE /api/tasks/:id/time/entry/:entryId - Delete a time entry -router.delete('/:id/time/entry/:entryId', async (req, res) => { - try { - const task = await taskService.deleteTimeEntry(req.params.id, req.params.entryId); - res.json(task); - } catch (error: any) { - console.error('Error deleting time entry:', error); - res.status(400).json({ error: error.message || 'Failed to delete time entry' }); - } -}); +router.delete('/:id/time/entry/:entryId', asyncHandler(async (req, res) => { + const task = await taskService.deleteTimeEntry((req.params.id as string), (req.params.entryId as string)); + res.json(task); +})); // === Attachment Context Route === // GET /api/tasks/:id/context - Get full task context for agent consumption -router.get('/:id/context', async (req, res) => { - try { - const { getAttachmentService } = await import('../services/attachment-service.js'); - const attachmentService = getAttachmentService(); - - const task = await taskService.getTask(req.params.id); - if (!task) { - return res.status(404).json({ error: 'Task not found' }); - } - - // Collect all extracted text and image paths - const attachments = task.attachments || []; - const extractedTexts: { filename: string; text: string }[] = []; - const imagePaths: string[] = []; - - for (const attachment of attachments) { - // Get extracted text if available - const text = await attachmentService.getExtractedText(task.id, attachment.id); - if (text) { - extractedTexts.push({ - filename: attachment.originalName, - text, - }); - } - - // Collect image paths - if (attachment.mimeType.startsWith('image/')) { - const filepath = attachmentService.getAttachmentPath(task.id, attachment.filename); - imagePaths.push(filepath); - } - } - - // Build context object - const context = { - taskId: task.id, - title: task.title, - description: task.description, - type: task.type, - status: task.status, - priority: task.priority, - project: task.project, - sprint: task.sprint, - attachments: { - count: attachments.length, - documents: extractedTexts, - images: imagePaths, - }, - created: task.created, - updated: task.updated, - }; - - res.json(context); - } catch (error: any) { - console.error('Error getting task context:', error); - res.status(500).json({ error: error.message || 'Failed to get task context' }); +router.get('/:id/context', asyncHandler(async (req, res) => { + const { getAttachmentService } = await import('../services/attachment-service.js'); + const attachmentService = getAttachmentService(); + + const task = await taskService.getTask(req.params.id as string); + if (!task) { + throw new NotFoundError('Task not found'); } -}); + + // Collect all extracted text and image paths + const attachments = task.attachments || []; + const extractedTexts: { filename: string; text: string }[] = []; + const imagePaths: string[] = []; + + for (const attachment of attachments) { + // Get extracted text if available + const text = await attachmentService.getExtractedText(task.id, attachment.id); + if (text) { + extractedTexts.push({ + filename: attachment.originalName, + text, + }); + } + + // Collect image paths + if (attachment.mimeType.startsWith('image/')) { + const filepath = attachmentService.getAttachmentPath(task.id, attachment.filename); + imagePaths.push(filepath); + } + } + + // Build context object + const context = { + taskId: task.id, + title: task.title, + description: task.description, + type: task.type, + status: task.status, + priority: task.priority, + project: task.project, + sprint: task.sprint, + attachments: { + count: attachments.length, + documents: extractedTexts, + images: imagePaths, + }, + created: task.created, + updated: task.updated, + }; + + res.json(context); +})); export { router as taskRoutes }; diff --git a/server/src/services/activity-service.ts b/server/src/services/activity-service.ts index 61a10edf..c98d9e62 100644 --- a/server/src/services/activity-service.ts +++ b/server/src/services/activity-service.ts @@ -30,7 +30,7 @@ export interface Activity { export class ActivityService { private activityFile: string; - private maxActivities: number = 100; + private readonly MAX_ACTIVITIES = 1000; // Limit to prevent unbounded memory growth constructor() { this.activityFile = join(process.cwd(), '.veritas-kanban', 'activity.json'); @@ -88,8 +88,12 @@ export class ActivityService { } } - // Prepend new activity and limit to maxActivities - activities = [activity, ...activities].slice(0, this.maxActivities); + // Prepend new activity and limit to MAX_ACTIVITIES + activities = [activity, ...activities].slice(0, this.MAX_ACTIVITIES); + + if (activities.length >= this.MAX_ACTIVITIES) { + console.warn(`[Activity] Activity limit reached (${this.MAX_ACTIVITIES}), trimming oldest entries`); + } await writeFile(this.activityFile, JSON.stringify(activities, null, 2), 'utf-8'); diff --git a/server/src/services/agent-service.ts b/server/src/services/agent-service.ts index c4333c9f..54407e92 100644 --- a/server/src/services/agent-service.ts +++ b/server/src/services/agent-service.ts @@ -8,6 +8,7 @@ import { TaskService } from './task-service.js'; import { getTelemetryService, type TelemetryService } from './telemetry-service.js'; import { getTraceService, type TraceService } from './trace-service.js'; import type { Task, AgentType, TaskAttempt, AttemptStatus, RunTelemetryEvent } from '@veritas-kanban/shared'; +import { expandPath } from '@veritas-kanban/shared'; const PROJECT_ROOT = path.resolve(process.cwd(), '..'); const LOGS_DIR = path.join(PROJECT_ROOT, '.veritas-kanban', 'logs'); @@ -60,10 +61,6 @@ export class AgentService { await fs.mkdir(this.logsDir, { recursive: true }); } - private expandPath(p: string): string { - return p.replace(/^~/, process.env.HOME || ''); - } - async startAgent(taskId: string, agentType?: AgentType): Promise<AgentStatus> { // Get task const task = await this.taskService.getTask(taskId); @@ -113,7 +110,7 @@ export class AgentService { const emitter = new EventEmitter(); // Spawn agent process - const worktreePath = this.expandPath(task.git.worktreePath); + const worktreePath = expandPath(task.git.worktreePath); const args = [...agentConfig.args]; const childProcess = spawn(agentConfig.command, args, { diff --git a/server/src/services/preview-service.ts b/server/src/services/preview-service.ts index b2b2bbc7..5980db23 100644 --- a/server/src/services/preview-service.ts +++ b/server/src/services/preview-service.ts @@ -2,6 +2,7 @@ import { spawn, ChildProcess } from 'child_process'; import { ConfigService } from './config-service.js'; import { TaskService } from './task-service.js'; import type { Task } from '@veritas-kanban/shared'; +import { expandPath } from '@veritas-kanban/shared'; export interface PreviewServer { taskId: string; @@ -24,16 +25,13 @@ const runningServers = new Map<string, { export class PreviewService { private configService: ConfigService; private taskService: TaskService; + private readonly MAX_OUTPUT_SIZE = 1024 * 1024; // 1MB limit for output constructor() { this.configService = new ConfigService(); this.taskService = new TaskService(); } - private expandPath(p: string): string { - return p.replace(/^~/, process.env.HOME || ''); - } - /** * Extract port from output using common patterns */ @@ -110,7 +108,7 @@ export class PreviewService { } // Determine working directory (worktree or main repo) - const workDir = task.git.worktreePath || this.expandPath(repoConfig.path); + const workDir = task.git.worktreePath || expandPath(repoConfig.path); // Parse command const [cmd, ...args] = repoConfig.devServer.command.split(' '); @@ -141,7 +139,20 @@ export class PreviewService { const text = data.toString(); info.output.push(text); - // Keep only last 100 lines + // Calculate total output size + const totalSize = info.output.reduce((sum, line) => sum + line.length, 0); + + // Enforce size limit + if (totalSize > this.MAX_OUTPUT_SIZE) { + // Truncate from the beginning, keep recent output + while (info.output.length > 0 && + info.output.reduce((sum, line) => sum + line.length, 0) > this.MAX_OUTPUT_SIZE * 0.8) { + info.output.shift(); + } + info.output.unshift('...[output truncated due to size limit]...\n'); + } + + // Keep only last 100 lines as secondary limit if (info.output.length > 100) { info.output = info.output.slice(-100); } diff --git a/server/src/services/task-service.ts b/server/src/services/task-service.ts index fc98e921..e1b0c149 100644 --- a/server/src/services/task-service.ts +++ b/server/src/services/task-service.ts @@ -150,6 +150,35 @@ export class TaskService { ); } + /** + * Batch-resolve task dependencies to avoid N+1 queries + * Loads all tasks once, then resolves dependencies from memory + */ + async getTasksWithDependencies(taskIds?: string[]): Promise<Task[]> { + const allTasks = await this.listTasks(); + const taskMap = new Map(allTasks.map(t => [t.id, t])); + + const tasksToResolve = taskIds + ? allTasks.filter(t => taskIds.includes(t.id)) + : allTasks; + + return tasksToResolve.map(task => { + if (!task.blockedBy || task.blockedBy.length === 0) { + return task; + } + + // Resolve dependencies from the in-memory map + const resolvedDependencies = task.blockedBy + .map(depId => taskMap.get(depId)) + .filter((t): t is Task => t !== undefined); + + return { + ...task, + _dependencies: resolvedDependencies, // Add resolved deps without modifying schema + }; + }); + } + async getTask(id: string): Promise<Task | null> { const tasks = await this.listTasks(); return tasks.find(t => t.id === id) || null; @@ -358,7 +387,7 @@ export class TaskService { // Find sprints where ALL tasks are done const suggestions: { sprint: string; taskCount: number; tasks: Task[] }[] = []; - for (const [sprint, sprintTaskList] of sprintTasks) { + for (const [sprint, sprintTaskList] of Array.from(sprintTasks.entries())) { const allDone = sprintTaskList.every(t => t.status === 'done'); if (allDone && sprintTaskList.length > 0) { suggestions.push({ diff --git a/server/src/services/telemetry-service.ts b/server/src/services/telemetry-service.ts index 044a0060..58b56873 100644 --- a/server/src/services/telemetry-service.ts +++ b/server/src/services/telemetry-service.ts @@ -35,6 +35,8 @@ export class TelemetryService { private config: TelemetryConfig; private initialized: boolean = false; private writeQueue: Promise<void> = Promise.resolve(); + private pendingWrites: Array<TelemetryEvent> = []; + private readonly MAX_QUEUE_SIZE = 10000; constructor(options: TelemetryServiceOptions = {}) { this.telemetryDir = options.telemetryDir || TELEMETRY_DIR; @@ -99,9 +101,21 @@ export class TelemetryService { timestamp: new Date().toISOString(), } as T; + // Add to queue with size limit - drop oldest if exceeded + this.pendingWrites.push(fullEvent); + if (this.pendingWrites.length > this.MAX_QUEUE_SIZE) { + const dropped = this.pendingWrites.shift(); + console.warn(`[Telemetry] Queue size exceeded (${this.MAX_QUEUE_SIZE}), dropped event:`, dropped?.type); + } + // Queue the write to prevent concurrent file access issues const writePromise = this.writeQueue - .then(() => this.writeEvent(fullEvent)) + .then(() => { + const eventToWrite = this.pendingWrites.shift(); + if (eventToWrite) { + return this.writeEvent(eventToWrite); + } + }) .catch((err) => { console.error('[Telemetry] Failed to write event:', err); }); diff --git a/server/src/services/text-extraction-service.ts b/server/src/services/text-extraction-service.ts index ae09e95e..8fa0ea9e 100644 --- a/server/src/services/text-extraction-service.ts +++ b/server/src/services/text-extraction-service.ts @@ -2,7 +2,7 @@ import fs from 'fs/promises'; import path from 'path'; import { extractText as unpdfExtract } from 'unpdf'; import mammoth from 'mammoth'; -import * as XLSX from 'xlsx'; +import ExcelJS from 'exceljs'; export interface TextExtractionResult { text: string | null; @@ -115,23 +115,49 @@ export class TextExtractionService { /** * Extract text from Excel files (convert to CSV-like format) + * Note: Only supports .xlsx format (Excel 2007+). Legacy .xls format is not supported. */ private async extractXLSX(filepath: string): Promise<string | null> { try { - const buffer = await fs.readFile(filepath); - const workbook = XLSX.read(buffer, { type: 'buffer' }); + const workbook = new ExcelJS.Workbook(); + + // Read the workbook directly from file + await workbook.xlsx.readFile(filepath); // Extract all sheets const sheets: string[] = []; - for (const sheetName of workbook.SheetNames) { - const worksheet = workbook.Sheets[sheetName]; - const csv = XLSX.utils.sheet_to_csv(worksheet); + workbook.eachSheet((worksheet, sheetId) => { + const rows: string[] = []; - if (csv.trim()) { - sheets.push(`=== Sheet: ${sheetName} ===\n${csv}`); + worksheet.eachRow((row, rowNumber) => { + const values: string[] = []; + + row.eachCell({ includeEmpty: true }, (cell, colNumber) => { + // Get cell value as string + const value = cell.value; + + // Handle different value types + if (value === null || value === undefined) { + values.push(''); + } else if (typeof value === 'object' && 'text' in value) { + // Rich text + values.push(value.text || ''); + } else if (typeof value === 'object' && 'formula' in value) { + // Formula - use result if available + values.push(value.result?.toString() || ''); + } else { + values.push(value.toString()); + } + }); + + rows.push(values.join(',')); + }); + + if (rows.length > 0) { + sheets.push(`=== Sheet: ${worksheet.name} ===\n${rows.join('\n')}`); } - } + }); return sheets.length > 0 ? sheets.join('\n\n') : null; } catch (error) { diff --git a/server/src/services/worktree-service.ts b/server/src/services/worktree-service.ts index c6669b4d..0883927b 100644 --- a/server/src/services/worktree-service.ts +++ b/server/src/services/worktree-service.ts @@ -4,6 +4,8 @@ import { simpleGit, SimpleGit } from 'simple-git'; import { ConfigService } from './config-service.js'; import { TaskService } from './task-service.js'; import type { Task } from '@veritas-kanban/shared'; +import { spawn } from 'child_process'; +import { promisify } from 'util'; // Default paths const PROJECT_ROOT = path.resolve(process.cwd(), '..'); @@ -29,6 +31,7 @@ export class WorktreeService { private worktreesDir: string; private configService: ConfigService; private taskService: TaskService; + private readonly GIT_TIMEOUT = 30000; // 30 seconds timeout for git operations constructor(options: WorktreeServiceOptions = {}) { this.worktreesDir = options.worktreesDir || WORKTREES_DIR; @@ -36,6 +39,56 @@ export class WorktreeService { this.taskService = new TaskService(); } + /** + * Execute a git command with timeout + */ + private async execGitWithTimeout( + repoPath: string, + args: string[] + ): Promise<string> { + return new Promise((resolve, reject) => { + const proc = spawn('git', args, { + cwd: repoPath, + timeout: this.GIT_TIMEOUT, + }); + + let stdout = ''; + let stderr = ''; + + proc.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('error', (error) => { + reject(new Error(`Git command failed: ${error.message}`)); + }); + + proc.on('close', (code) => { + if (code === 0) { + resolve(stdout); + } else if (code === null) { + reject(new Error(`Git command timed out after ${this.GIT_TIMEOUT}ms`)); + } else { + reject(new Error(`Git command failed with code ${code}: ${stderr}`)); + } + }); + + // Set timeout manually as backup + const timeoutId = setTimeout(() => { + proc.kill('SIGTERM'); + reject(new Error(`Git operation timed out after ${this.GIT_TIMEOUT}ms`)); + }, this.GIT_TIMEOUT); + + proc.on('exit', () => { + clearTimeout(timeoutId); + }); + }); + } + private async ensureWorktreesDir(): Promise<void> { await fs.mkdir(this.worktreesDir, { recursive: true }); } @@ -84,12 +137,12 @@ export class WorktreeService { throw new Error('Worktree already exists for this task'); } - // Fetch latest from remote + // Fetch latest from remote with timeout try { - await git.fetch(); - } catch (e) { + await this.execGitWithTimeout(repoPath, ['fetch']); + } catch (e: any) { // Ignore fetch errors (might be offline) - console.warn('Could not fetch from remote:', e); + console.warn('Could not fetch from remote:', e.message); } // Check if branch already exists @@ -97,11 +150,13 @@ export class WorktreeService { const branchExists = branches.all.includes(task.git.branch); if (branchExists) { - // Use existing branch - await git.raw(['worktree', 'add', worktreePath, task.git.branch]); + // Use existing branch with timeout + await this.execGitWithTimeout(repoPath, ['worktree', 'add', worktreePath, task.git.branch]); } else { - // Create new branch from base - await git.raw(['worktree', 'add', '-b', task.git.branch, worktreePath, task.git.baseBranch]); + // Create new branch from base with timeout + await this.execGitWithTimeout(repoPath, [ + 'worktree', 'add', '-b', task.git.branch, worktreePath, task.git.baseBranch + ]); } // Update task with worktree path @@ -131,13 +186,13 @@ export class WorktreeService { // Get ahead/behind info let aheadBehind = { ahead: 0, behind: 0 }; try { - const { git: repoGit } = await this.getRepoGit(task.git.repo); + const { git: repoGit, repoPath: mainRepoPath } = await this.getRepoGit(task.git.repo); - // Fetch to get latest - await repoGit.fetch().catch(() => {}); + // Fetch to get latest with timeout + await this.execGitWithTimeout(mainRepoPath, ['fetch']).catch(() => {}); - // Compare with base branch - const log = await worktreeGit.raw([ + // Compare with base branch with timeout + const log = await this.execGitWithTimeout(worktreePath, [ 'rev-list', '--left-right', '--count', @@ -145,8 +200,8 @@ export class WorktreeService { ]); const [behind, ahead] = log.trim().split('\t').map(Number); aheadBehind = { ahead: ahead || 0, behind: behind || 0 }; - } catch (e) { - console.warn('Could not get ahead/behind info:', e); + } catch (e: any) { + console.warn('Could not get ahead/behind info:', e.message); } return { @@ -178,10 +233,12 @@ export class WorktreeService { } // Get main repo git - const { git: repoGit } = await this.getRepoGit(task.git.repo); + const { git: repoGit, repoPath } = await this.getRepoGit(task.git.repo); - // Remove worktree - await repoGit.raw(['worktree', 'remove', worktreePath, force ? '--force' : ''].filter(Boolean)); + // Remove worktree with timeout + const args = ['worktree', 'remove', worktreePath]; + if (force) args.push('--force'); + await this.execGitWithTimeout(repoPath, args); // Update task to remove worktree path await this.taskService.updateTask(taskId, { @@ -198,13 +255,13 @@ export class WorktreeService { throw new Error('Task does not have an active worktree'); } - const worktreeGit = simpleGit(task.git.worktreePath); + const worktreePath = task.git.worktreePath; - // Fetch latest - await worktreeGit.fetch(); + // Fetch latest with timeout + await this.execGitWithTimeout(worktreePath, ['fetch']); - // Rebase onto base branch - await worktreeGit.rebase([`origin/${task.git.baseBranch}`]); + // Rebase onto base branch with timeout + await this.execGitWithTimeout(worktreePath, ['rebase', `origin/${task.git.baseBranch}`]); return this.getWorktreeStatus(taskId); } @@ -217,17 +274,17 @@ export class WorktreeService { const { git: repoGit, repoPath } = await this.getRepoGit(task.git.repo); - // Checkout base branch in main repo - await repoGit.checkout(task.git.baseBranch); + // Checkout base branch in main repo with timeout + await this.execGitWithTimeout(repoPath, ['checkout', task.git.baseBranch]); - // Pull latest - await repoGit.pull(); + // Pull latest with timeout + await this.execGitWithTimeout(repoPath, ['pull']); - // Merge feature branch - await repoGit.merge([task.git.branch]); + // Merge feature branch with timeout + await this.execGitWithTimeout(repoPath, ['merge', task.git.branch]); - // Push - await repoGit.push(); + // Push with timeout + await this.execGitWithTimeout(repoPath, ['push']); // Delete worktree await this.deleteWorktree(taskId, true); diff --git a/server/tsconfig.json b/server/tsconfig.json index aef3e691..dc31fc7d 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -15,5 +15,5 @@ "resolveJsonModule": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/__tests__/**"] } diff --git a/shared/src/index.ts b/shared/src/index.ts index 6099434e..de3fecb0 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,3 +1,4 @@ // Veritas Kanban - Shared Types export * from './types.js'; +export * from './utils/index.js'; diff --git a/shared/src/types.ts b/shared/src/types.ts index a6a55a9d..318e0006 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -1,591 +1,2 @@ -// Task Types - -export type TaskType = string; -export type TaskStatus = 'todo' | 'in-progress' | 'review' | 'done'; -export type TaskPriority = 'low' | 'medium' | 'high'; -export type AgentType = 'claude-code' | 'amp' | 'copilot' | 'gemini' | 'veritas'; -export type AttemptStatus = 'pending' | 'running' | 'complete' | 'failed'; - -export interface TaskGit { - repo: string; - branch: string; - baseBranch: string; - worktreePath?: string; - prUrl?: string; - prNumber?: number; -} - -export interface TaskAttempt { - id: string; - agent: AgentType; - status: AttemptStatus; - started?: string; - ended?: string; -} - -export interface Subtask { - id: string; - title: string; - completed: boolean; - created: string; -} - -export interface TimeEntry { - id: string; - startTime: string; - endTime?: string; // Undefined if timer is running - duration?: number; // Duration in seconds (calculated when stopped) - description?: string; // Optional note for the entry - manual?: boolean; // True if manually entered -} - -export interface TimeTracking { - entries: TimeEntry[]; - totalSeconds: number; // Total tracked time in seconds - isRunning: boolean; // Is timer currently running - activeEntryId?: string; // ID of the currently running entry -} - -export interface Comment { - id: string; - author: string; - text: string; - timestamp: string; -} - -export interface Attachment { - id: string; - filename: string; // Sanitized filename stored on disk - originalName: string; // Original filename from upload - mimeType: string; - size: number; // File size in bytes - uploaded: string; // ISO timestamp -} - -export interface AttachmentLimits { - maxFileSize: number; // Max size per file in bytes - maxFilesPerTask: number; // Max number of attachments per task - maxTotalSize: number; // Max total size for all attachments per task -} - -// Default attachment limits -export const DEFAULT_ATTACHMENT_LIMITS: AttachmentLimits = { - maxFileSize: 10 * 1024 * 1024, // 10MB per file - maxFilesPerTask: 20, // 20 files per task - maxTotalSize: 50 * 1024 * 1024, // 50MB total per task -}; - -// Allowed MIME types for attachments -export const ALLOWED_MIME_TYPES = [ - // Documents - 'application/pdf', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.ms-excel', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'text/plain', - 'text/markdown', - 'text/html', - 'text/csv', - - // Images - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/webp', - 'image/svg+xml', - - // Code & Config - 'application/json', - 'application/xml', - 'text/xml', - 'application/yaml', - 'text/yaml', -]; - -export interface Task { - id: string; - title: string; - description: string; - type: TaskType; - status: TaskStatus; - priority: TaskPriority; - project?: string; - sprint?: string; - created: string; - updated: string; - - // Code task specific - git?: TaskGit; - - // Current attempt - attempt?: TaskAttempt; - - // Attempt history - attempts?: TaskAttempt[]; - - // Review comments (for code tasks) - reviewComments?: ReviewComment[]; - - // Review state - review?: ReviewState; - - // Subtasks - subtasks?: Subtask[]; - autoCompleteOnSubtasks?: boolean; // Auto-complete parent when all subtasks done - - // Dependencies - blockedBy?: string[]; // Array of task IDs that block this task - - // Automation task specific (for veritas sub-agent) - automation?: { - sessionKey?: string; // Clawdbot session key - spawnedAt?: string; // When sub-agent was spawned - completedAt?: string; // When sub-agent finished - result?: string; // Result summary from sub-agent - }; - - // Time tracking - timeTracking?: TimeTracking; - - // Comments - comments?: Comment[]; - - // Attachments - attachments?: Attachment[]; - - // Position within column (for drag-and-drop ordering) - position?: number; -} - -export interface ReviewComment { - id: string; - file: string; - line: number; - content: string; - created: string; -} - -export type ReviewDecision = 'approved' | 'changes-requested' | 'rejected'; - -export interface ReviewState { - decision?: ReviewDecision; - decidedAt?: string; - summary?: string; -} - -// API Types - -export interface CreateTaskInput { - title: string; - description?: string; - type?: TaskType; - priority?: TaskPriority; - project?: string; - sprint?: string; - subtasks?: Subtask[]; // Can be provided when creating from a template - blockedBy?: string[]; // Can be provided when creating from a blueprint -} - -export interface UpdateTaskInput { - title?: string; - description?: string; - type?: TaskType; - status?: TaskStatus; - priority?: TaskPriority; - project?: string; - sprint?: string; - git?: Partial<TaskGit>; - attempt?: TaskAttempt; - reviewComments?: ReviewComment[]; - review?: ReviewState; - subtasks?: Subtask[]; - autoCompleteOnSubtasks?: boolean; - blockedBy?: string[]; - automation?: { - sessionKey?: string; - spawnedAt?: string; - completedAt?: string; - result?: string; - }; - timeTracking?: TimeTracking; - comments?: Comment[]; - attachments?: Attachment[]; - position?: number; -} - -export interface TaskFilters { - status?: TaskStatus | TaskStatus[]; - type?: TaskType | TaskType[]; - project?: string; - search?: string; -} - -// Config Types - -export interface DevServerConfig { - command: string; // e.g., "pnpm dev" or "npm run dev" - port?: number; // Expected port (auto-detected if not specified) - readyPattern?: string; // Regex pattern to detect when server is ready -} - -export interface RepoConfig { - name: string; - path: string; - defaultBranch: string; - devServer?: DevServerConfig; -} - -export interface AgentConfig { - type: AgentType; - name: string; - command: string; - args: string[]; - enabled: boolean; -} - -export interface AppConfig { - repos: RepoConfig[]; - agents: AgentConfig[]; - defaultAgent: AgentType; - telemetry?: TelemetryConfig; - features?: FeatureSettings; -} - -// WebSocket Message Types - -export type WSMessageType = - | 'agent:output' - | 'agent:status' - | 'agent:complete' - | 'task:updated' - | 'error'; - -export interface WSMessage { - type: WSMessageType; - taskId?: string; - attemptId?: string; - data: unknown; - timestamp: string; -} - -export interface AgentOutputMessage extends WSMessage { - type: 'agent:output'; - data: { - stream: 'stdout' | 'stderr'; - content: string; - }; -} - -export interface AgentStatusMessage extends WSMessage { - type: 'agent:status'; - data: { - status: AttemptStatus; - exitCode?: number; - }; -} - -// ============ Telemetry Types ============ - -export type TelemetryEventType = - | 'task.created' - | 'task.status_changed' - | 'task.archived' - | 'task.restored' - | 'run.started' - | 'run.completed' - | 'run.error' - | 'run.tokens'; - -/** Base telemetry event - all events extend this */ -export interface TelemetryEvent { - id: string; - type: TelemetryEventType; - timestamp: string; - taskId?: string; - project?: string; -} - -/** Task lifecycle events */ -export interface TaskTelemetryEvent extends TelemetryEvent { - type: 'task.created' | 'task.status_changed' | 'task.archived' | 'task.restored'; - taskId: string; - status?: TaskStatus; - previousStatus?: TaskStatus; -} - -/** Agent run events */ -export interface RunTelemetryEvent extends TelemetryEvent { - type: 'run.started' | 'run.completed' | 'run.error'; - taskId: string; - attemptId: string; - agent: AgentType; - durationMs?: number; - exitCode?: number; - success?: boolean; - error?: string; -} - -/** Token usage events */ -export interface TokenTelemetryEvent extends TelemetryEvent { - type: 'run.tokens'; - taskId: string; - attemptId: string; - agent: AgentType; - inputTokens: number; - outputTokens: number; - totalTokens: number; - model?: string; -} - -/** Union type for all telemetry events */ -export type AnyTelemetryEvent = TaskTelemetryEvent | RunTelemetryEvent | TokenTelemetryEvent; - -/** Telemetry configuration */ -export interface TelemetryConfig { - enabled: boolean; - retention: number; // Days to retain events - traces?: boolean; // Optional trace collection (future) -} - -// ============ Feature Settings Types ============ - -/** Board display settings */ -export interface BoardSettings { - showDashboard: boolean; - showArchiveSuggestions: boolean; - cardDensity: 'normal' | 'compact'; - showPriorityIndicators: boolean; - showProjectBadges: boolean; - showSprintBadges: boolean; - enableDragAndDrop: boolean; -} - -/** Task behavior settings */ -export interface TaskBehaviorSettings { - enableTimeTracking: boolean; - enableSubtaskAutoComplete: boolean; - enableDependencies: boolean; - enableAttachments: boolean; - attachmentMaxFileSize: number; // bytes - attachmentMaxPerTask: number; - attachmentMaxTotalSize: number; // bytes - enableComments: boolean; - defaultPriority: TaskPriority; -} - -/** Agent & git settings */ -export interface AgentBehaviorSettings { - timeoutMinutes: number; // 5-480 - autoCommitOnComplete: boolean; - autoCleanupWorktrees: boolean; - enablePreview: boolean; -} - -/** Telemetry & activity settings */ -export interface TelemetryFeatureSettings { - enabled: boolean; - retentionDays: number; // 7-365 - enableTraces: boolean; - enableActivityTracking: boolean; -} - -/** Notification settings */ -export interface NotificationSettings { - enabled: boolean; - onTaskComplete: boolean; - onAgentFailure: boolean; - onReviewNeeded: boolean; - channel: string; // Teams channel ID -} - -/** Archive settings */ -export interface ArchiveSettings { - autoArchiveEnabled: boolean; - autoArchiveAfterDays: number; -} - -/** All feature settings combined */ -export interface FeatureSettings { - board: BoardSettings; - tasks: TaskBehaviorSettings; - agents: AgentBehaviorSettings; - telemetry: TelemetryFeatureSettings; - notifications: NotificationSettings; - archive: ArchiveSettings; -} - -/** Default feature settings — matches current app behavior */ -export const DEFAULT_FEATURE_SETTINGS: FeatureSettings = { - board: { - showDashboard: true, - showArchiveSuggestions: true, - cardDensity: 'normal', - showPriorityIndicators: true, - showProjectBadges: true, - showSprintBadges: true, - enableDragAndDrop: true, - }, - tasks: { - enableTimeTracking: true, - enableSubtaskAutoComplete: true, - enableDependencies: true, - enableAttachments: true, - attachmentMaxFileSize: 10 * 1024 * 1024, // 10MB - attachmentMaxPerTask: 20, - attachmentMaxTotalSize: 50 * 1024 * 1024, // 50MB - enableComments: true, - defaultPriority: 'medium', - }, - agents: { - timeoutMinutes: 30, - autoCommitOnComplete: false, - autoCleanupWorktrees: false, - enablePreview: true, - }, - telemetry: { - enabled: true, - retentionDays: 90, - enableTraces: false, - enableActivityTracking: true, - }, - notifications: { - enabled: false, - onTaskComplete: true, - onAgentFailure: true, - onReviewNeeded: true, - channel: '', - }, - archive: { - autoArchiveEnabled: false, - autoArchiveAfterDays: 30, - }, -}; - -/** Query options for fetching events */ -export interface TelemetryQueryOptions { - type?: TelemetryEventType | TelemetryEventType[]; - since?: string; // ISO timestamp - until?: string; // ISO timestamp - taskId?: string; - project?: string; - limit?: number; -} - -// ============ Managed List Types ============ - -/** Base interface for managed list items */ -export interface ManagedListItem { - id: string; - label: string; - order: number; - isDefault?: boolean; - isHidden?: boolean; - created: string; - updated: string; -} - -/** Configuration options for ManagedListService */ -export interface ManagedListServiceOptions { - filename: string; - configDir: string; - defaults: ManagedListItem[]; -} - -/** Task type configuration with icon and color */ -export interface TaskTypeConfig extends ManagedListItem { - icon: string; // Lucide icon name (e.g., "Code", "Search") - color?: string; // Tailwind border color class (e.g., "border-l-violet-500") -} - -/** Project configuration with description and badge color */ -export interface ProjectConfig extends ManagedListItem { - description?: string; - color?: string; // Tailwind bg color class for badges (e.g., "bg-blue-500/20") -} - -/** Sprint configuration */ -export interface SprintConfig extends ManagedListItem { - description?: string; -} - -// ============ Template Types ============ - -/** Subtask template for pre-defined subtask lists */ -export interface SubtaskTemplate { - title: string; // Supports variables: "Review {{project}} PR" - order: number; -} - -/** Blueprint task for multi-task template creation */ -export interface BlueprintTask { - refId: string; // Local reference for dependency wiring - title: string; // Supports variables - taskDefaults: { - type?: TaskType; - priority?: TaskPriority; - project?: string; - descriptionTemplate?: string; - agent?: AgentType; - }; - subtaskTemplates?: SubtaskTemplate[]; - blockedByRefs?: string[]; // References to other BlueprintTask.refIds -} - -/** Task template with enhanced features */ -export interface TaskTemplate { - id: string; - name: string; - description?: string; - category?: string; // Template category: "sprint", "bug", "feature", etc. - version: number; // Schema version for migration (0 = legacy, 1 = enhanced) - - taskDefaults: { - type?: TaskType; - priority?: TaskPriority; - project?: string; - descriptionTemplate?: string; - agent?: AgentType; // NEW in v1: preferred agent - }; - - // NEW in v1: Pre-defined subtasks - subtaskTemplates?: SubtaskTemplate[]; - - // NEW in v1: For multi-task blueprints - blueprint?: BlueprintTask[]; - - created: string; - updated: string; -} - -/** Input for creating a new template */ -export interface CreateTemplateInput { - name: string; - description?: string; - category?: string; - taskDefaults: { - type?: TaskType; - priority?: TaskPriority; - project?: string; - descriptionTemplate?: string; - agent?: AgentType; - }; - subtaskTemplates?: SubtaskTemplate[]; - blueprint?: BlueprintTask[]; -} - -/** Input for updating an existing template */ -export interface UpdateTemplateInput { - name?: string; - description?: string; - category?: string; - taskDefaults?: { - type?: TaskType; - priority?: TaskPriority; - project?: string; - descriptionTemplate?: string; - agent?: AgentType; - }; - subtaskTemplates?: SubtaskTemplate[]; - blueprint?: BlueprintTask[]; -} +// Backward compatibility — all types now in types/ directory +export * from './types/index.js'; diff --git a/shared/src/types/config.types.ts b/shared/src/types/config.types.ts new file mode 100644 index 00000000..861a8da8 --- /dev/null +++ b/shared/src/types/config.types.ts @@ -0,0 +1,147 @@ +// Config Types + +import type { AgentType, TaskPriority } from './task.types.js'; +import type { TelemetryConfig } from './telemetry.types.js'; + +export interface DevServerConfig { + command: string; // e.g., "pnpm dev" or "npm run dev" + port?: number; // Expected port (auto-detected if not specified) + readyPattern?: string; // Regex pattern to detect when server is ready +} + +export interface RepoConfig { + name: string; + path: string; + defaultBranch: string; + devServer?: DevServerConfig; +} + +export interface AgentConfig { + type: AgentType; + name: string; + command: string; + args: string[]; + enabled: boolean; +} + +export interface AppConfig { + repos: RepoConfig[]; + agents: AgentConfig[]; + defaultAgent: AgentType; + telemetry?: TelemetryConfig; + features?: FeatureSettings; +} + +// ============ Feature Settings Types ============ + +/** Board display settings */ +export interface BoardSettings { + showDashboard: boolean; + showArchiveSuggestions: boolean; + cardDensity: 'normal' | 'compact'; + showPriorityIndicators: boolean; + showProjectBadges: boolean; + showSprintBadges: boolean; + enableDragAndDrop: boolean; +} + +/** Task behavior settings */ +export interface TaskBehaviorSettings { + enableTimeTracking: boolean; + enableSubtaskAutoComplete: boolean; + enableDependencies: boolean; + enableAttachments: boolean; + attachmentMaxFileSize: number; // bytes + attachmentMaxPerTask: number; + attachmentMaxTotalSize: number; // bytes + enableComments: boolean; + defaultPriority: TaskPriority; +} + +/** Agent & git settings */ +export interface AgentBehaviorSettings { + timeoutMinutes: number; // 5-480 + autoCommitOnComplete: boolean; + autoCleanupWorktrees: boolean; + enablePreview: boolean; +} + +/** Telemetry & activity settings */ +export interface TelemetryFeatureSettings { + enabled: boolean; + retentionDays: number; // 7-365 + enableTraces: boolean; + enableActivityTracking: boolean; +} + +/** Notification settings */ +export interface NotificationSettings { + enabled: boolean; + onTaskComplete: boolean; + onAgentFailure: boolean; + onReviewNeeded: boolean; + channel: string; // Teams channel ID +} + +/** Archive settings */ +export interface ArchiveSettings { + autoArchiveEnabled: boolean; + autoArchiveAfterDays: number; +} + +/** All feature settings combined */ +export interface FeatureSettings { + board: BoardSettings; + tasks: TaskBehaviorSettings; + agents: AgentBehaviorSettings; + telemetry: TelemetryFeatureSettings; + notifications: NotificationSettings; + archive: ArchiveSettings; +} + +/** Default feature settings — matches current app behavior */ +export const DEFAULT_FEATURE_SETTINGS: FeatureSettings = { + board: { + showDashboard: true, + showArchiveSuggestions: true, + cardDensity: 'normal', + showPriorityIndicators: true, + showProjectBadges: true, + showSprintBadges: true, + enableDragAndDrop: true, + }, + tasks: { + enableTimeTracking: true, + enableSubtaskAutoComplete: true, + enableDependencies: true, + enableAttachments: true, + attachmentMaxFileSize: 10 * 1024 * 1024, // 10MB + attachmentMaxPerTask: 20, + attachmentMaxTotalSize: 50 * 1024 * 1024, // 50MB + enableComments: true, + defaultPriority: 'medium', + }, + agents: { + timeoutMinutes: 30, + autoCommitOnComplete: false, + autoCleanupWorktrees: false, + enablePreview: true, + }, + telemetry: { + enabled: true, + retentionDays: 90, + enableTraces: false, + enableActivityTracking: true, + }, + notifications: { + enabled: false, + onTaskComplete: true, + onAgentFailure: true, + onReviewNeeded: true, + channel: '', + }, + archive: { + autoArchiveEnabled: false, + autoArchiveAfterDays: 30, + }, +}; diff --git a/shared/src/types/index.ts b/shared/src/types/index.ts new file mode 100644 index 00000000..4b9ae523 --- /dev/null +++ b/shared/src/types/index.ts @@ -0,0 +1,8 @@ +// Barrel file - re-exports all types from domain-specific files + +export * from './task.types.js'; +export * from './config.types.js'; +export * from './telemetry.types.js'; +export * from './template.types.js'; +export * from './websocket.types.js'; +export * from './managed-list.types.js'; diff --git a/shared/src/types/managed-list.types.ts b/shared/src/types/managed-list.types.ts new file mode 100644 index 00000000..4a0af2f6 --- /dev/null +++ b/shared/src/types/managed-list.types.ts @@ -0,0 +1,36 @@ +// Managed List Types + +/** Base interface for managed list items */ +export interface ManagedListItem { + id: string; + label: string; + order: number; + isDefault?: boolean; + isHidden?: boolean; + created: string; + updated: string; +} + +/** Configuration options for ManagedListService */ +export interface ManagedListServiceOptions { + filename: string; + configDir: string; + defaults: ManagedListItem[]; +} + +/** Task type configuration with icon and color */ +export interface TaskTypeConfig extends ManagedListItem { + icon: string; // Lucide icon name (e.g., "Code", "Search") + color?: string; // Tailwind border color class (e.g., "border-l-violet-500") +} + +/** Project configuration with description and badge color */ +export interface ProjectConfig extends ManagedListItem { + description?: string; + color?: string; // Tailwind bg color class for badges (e.g., "bg-blue-500/20") +} + +/** Sprint configuration */ +export interface SprintConfig extends ManagedListItem { + description?: string; +} diff --git a/shared/src/types/task.types.ts b/shared/src/types/task.types.ts new file mode 100644 index 00000000..c578babe --- /dev/null +++ b/shared/src/types/task.types.ts @@ -0,0 +1,222 @@ +// Task Types + +export type TaskType = string; +export type TaskStatus = 'todo' | 'in-progress' | 'review' | 'done'; +export type TaskPriority = 'low' | 'medium' | 'high'; +export type AgentType = 'claude-code' | 'amp' | 'copilot' | 'gemini' | 'veritas'; +export type AttemptStatus = 'pending' | 'running' | 'complete' | 'failed'; + +export interface TaskGit { + repo: string; + branch: string; + baseBranch: string; + worktreePath?: string; + prUrl?: string; + prNumber?: number; +} + +export interface TaskAttempt { + id: string; + agent: AgentType; + status: AttemptStatus; + started?: string; + ended?: string; +} + +export interface Subtask { + id: string; + title: string; + completed: boolean; + created: string; +} + +export interface TimeEntry { + id: string; + startTime: string; + endTime?: string; // Undefined if timer is running + duration?: number; // Duration in seconds (calculated when stopped) + description?: string; // Optional note for the entry + manual?: boolean; // True if manually entered +} + +export interface TimeTracking { + entries: TimeEntry[]; + totalSeconds: number; // Total tracked time in seconds + isRunning: boolean; // Is timer currently running + activeEntryId?: string; // ID of the currently running entry +} + +export interface Comment { + id: string; + author: string; + text: string; + timestamp: string; +} + +export interface Attachment { + id: string; + filename: string; // Sanitized filename stored on disk + originalName: string; // Original filename from upload + mimeType: string; + size: number; // File size in bytes + uploaded: string; // ISO timestamp +} + +export interface AttachmentLimits { + maxFileSize: number; // Max size per file in bytes + maxFilesPerTask: number; // Max number of attachments per task + maxTotalSize: number; // Max total size for all attachments per task +} + +// Default attachment limits +export const DEFAULT_ATTACHMENT_LIMITS: AttachmentLimits = { + maxFileSize: 10 * 1024 * 1024, // 10MB per file + maxFilesPerTask: 20, // 20 files per task + maxTotalSize: 50 * 1024 * 1024, // 50MB total per task +}; + +// Allowed MIME types for attachments +export const ALLOWED_MIME_TYPES = [ + // Documents + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/plain', + 'text/markdown', + 'text/html', + 'text/csv', + + // Images + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/svg+xml', + + // Code & Config + 'application/json', + 'application/xml', + 'text/xml', + 'application/yaml', + 'text/yaml', +]; + +export interface Task { + id: string; + title: string; + description: string; + type: TaskType; + status: TaskStatus; + priority: TaskPriority; + project?: string; + sprint?: string; + created: string; + updated: string; + + // Code task specific + git?: TaskGit; + + // Current attempt + attempt?: TaskAttempt; + + // Attempt history + attempts?: TaskAttempt[]; + + // Review comments (for code tasks) + reviewComments?: ReviewComment[]; + + // Review state + review?: ReviewState; + + // Subtasks + subtasks?: Subtask[]; + autoCompleteOnSubtasks?: boolean; // Auto-complete parent when all subtasks done + + // Dependencies + blockedBy?: string[]; // Array of task IDs that block this task + + // Automation task specific (for veritas sub-agent) + automation?: { + sessionKey?: string; // Clawdbot session key + spawnedAt?: string; // When sub-agent was spawned + completedAt?: string; // When sub-agent finished + result?: string; // Result summary from sub-agent + }; + + // Time tracking + timeTracking?: TimeTracking; + + // Comments + comments?: Comment[]; + + // Attachments + attachments?: Attachment[]; + + // Position within column (for drag-and-drop ordering) + position?: number; +} + +export interface ReviewComment { + id: string; + file: string; + line: number; + content: string; + created: string; +} + +export type ReviewDecision = 'approved' | 'changes-requested' | 'rejected'; + +export interface ReviewState { + decision?: ReviewDecision; + decidedAt?: string; + summary?: string; +} + +// API Types + +export interface CreateTaskInput { + title: string; + description?: string; + type?: TaskType; + priority?: TaskPriority; + project?: string; + sprint?: string; + subtasks?: Subtask[]; // Can be provided when creating from a template + blockedBy?: string[]; // Can be provided when creating from a blueprint +} + +export interface UpdateTaskInput { + title?: string; + description?: string; + type?: TaskType; + status?: TaskStatus; + priority?: TaskPriority; + project?: string; + sprint?: string; + git?: Partial<TaskGit>; + attempt?: TaskAttempt; + reviewComments?: ReviewComment[]; + review?: ReviewState; + subtasks?: Subtask[]; + autoCompleteOnSubtasks?: boolean; + blockedBy?: string[]; + automation?: { + sessionKey?: string; + spawnedAt?: string; + completedAt?: string; + result?: string; + }; + timeTracking?: TimeTracking; + comments?: Comment[]; + attachments?: Attachment[]; + position?: number; +} + +export interface TaskFilters { + status?: TaskStatus | TaskStatus[]; + type?: TaskType | TaskType[]; + project?: string; + search?: string; +} diff --git a/shared/src/types/telemetry.types.ts b/shared/src/types/telemetry.types.ts new file mode 100644 index 00000000..14743614 --- /dev/null +++ b/shared/src/types/telemetry.types.ts @@ -0,0 +1,74 @@ +// Telemetry Types + +import type { TaskStatus, AgentType } from './task.types.js'; + +export type TelemetryEventType = + | 'task.created' + | 'task.status_changed' + | 'task.archived' + | 'task.restored' + | 'run.started' + | 'run.completed' + | 'run.error' + | 'run.tokens'; + +/** Base telemetry event - all events extend this */ +export interface TelemetryEvent { + id: string; + type: TelemetryEventType; + timestamp: string; + taskId?: string; + project?: string; +} + +/** Task lifecycle events */ +export interface TaskTelemetryEvent extends TelemetryEvent { + type: 'task.created' | 'task.status_changed' | 'task.archived' | 'task.restored'; + taskId: string; + status?: TaskStatus; + previousStatus?: TaskStatus; +} + +/** Agent run events */ +export interface RunTelemetryEvent extends TelemetryEvent { + type: 'run.started' | 'run.completed' | 'run.error'; + taskId: string; + attemptId: string; + agent: AgentType; + durationMs?: number; + exitCode?: number; + success?: boolean; + error?: string; +} + +/** Token usage events */ +export interface TokenTelemetryEvent extends TelemetryEvent { + type: 'run.tokens'; + taskId: string; + attemptId: string; + agent: AgentType; + inputTokens: number; + outputTokens: number; + totalTokens: number; + model?: string; +} + +/** Union type for all telemetry events */ +export type AnyTelemetryEvent = TaskTelemetryEvent | RunTelemetryEvent | TokenTelemetryEvent; + +/** Telemetry configuration */ +export interface TelemetryConfig { + enabled: boolean; + retention: number; // Days to retain events + traces?: boolean; // Optional trace collection (future) +} + +/** Query options for fetching events */ +export interface TelemetryQueryOptions { + type?: TelemetryEventType | TelemetryEventType[]; + since?: string; // ISO timestamp + until?: string; // ISO timestamp + taskId?: string; + project?: string; + limit?: number; +} diff --git a/shared/src/types/template.types.ts b/shared/src/types/template.types.ts new file mode 100644 index 00000000..43750faa --- /dev/null +++ b/shared/src/types/template.types.ts @@ -0,0 +1,82 @@ +// Template Types + +import type { TaskType, TaskPriority, AgentType } from './task.types.js'; + +/** Subtask template for pre-defined subtask lists */ +export interface SubtaskTemplate { + title: string; // Supports variables: "Review {{project}} PR" + order: number; +} + +/** Blueprint task for multi-task template creation */ +export interface BlueprintTask { + refId: string; // Local reference for dependency wiring + title: string; // Supports variables + taskDefaults: { + type?: TaskType; + priority?: TaskPriority; + project?: string; + descriptionTemplate?: string; + agent?: AgentType; + }; + subtaskTemplates?: SubtaskTemplate[]; + blockedByRefs?: string[]; // References to other BlueprintTask.refIds +} + +/** Task template with enhanced features */ +export interface TaskTemplate { + id: string; + name: string; + description?: string; + category?: string; // Template category: "sprint", "bug", "feature", etc. + version: number; // Schema version for migration (0 = legacy, 1 = enhanced) + + taskDefaults: { + type?: TaskType; + priority?: TaskPriority; + project?: string; + descriptionTemplate?: string; + agent?: AgentType; // NEW in v1: preferred agent + }; + + // NEW in v1: Pre-defined subtasks + subtaskTemplates?: SubtaskTemplate[]; + + // NEW in v1: For multi-task blueprints + blueprint?: BlueprintTask[]; + + created: string; + updated: string; +} + +/** Input for creating a new template */ +export interface CreateTemplateInput { + name: string; + description?: string; + category?: string; + taskDefaults: { + type?: TaskType; + priority?: TaskPriority; + project?: string; + descriptionTemplate?: string; + agent?: AgentType; + }; + subtaskTemplates?: SubtaskTemplate[]; + blueprint?: BlueprintTask[]; +} + +/** Input for updating an existing template */ +export interface UpdateTemplateInput { + name?: string; + description?: string; + category?: string; + taskDefaults?: { + type?: TaskType; + priority?: TaskPriority; + project?: string; + descriptionTemplate?: string; + agent?: AgentType; + }; + subtaskTemplates?: SubtaskTemplate[]; + blueprint?: BlueprintTask[]; +} diff --git a/shared/src/types/websocket.types.ts b/shared/src/types/websocket.types.ts new file mode 100644 index 00000000..043b0bd1 --- /dev/null +++ b/shared/src/types/websocket.types.ts @@ -0,0 +1,34 @@ +// WebSocket Message Types + +import type { AttemptStatus } from './task.types.js'; + +export type WSMessageType = + | 'agent:output' + | 'agent:status' + | 'agent:complete' + | 'task:updated' + | 'error'; + +export interface WSMessage { + type: WSMessageType; + taskId?: string; + attemptId?: string; + data: unknown; + timestamp: string; +} + +export interface AgentOutputMessage extends WSMessage { + type: 'agent:output'; + data: { + stream: 'stdout' | 'stderr'; + content: string; + }; +} + +export interface AgentStatusMessage extends WSMessage { + type: 'agent:status'; + data: { + status: AttemptStatus; + exitCode?: number; + }; +} diff --git a/shared/src/utils/api-client.ts b/shared/src/utils/api-client.ts new file mode 100644 index 00000000..d501428b --- /dev/null +++ b/shared/src/utils/api-client.ts @@ -0,0 +1,51 @@ +/** + * Shared API client for CLI and MCP + */ + +import type { Task } from '../types/task.types.js'; + +const DEFAULT_BASE = 'http://localhost:3001'; + +/** + * Create an API client instance + * @param baseUrl - Base URL for the API (default: http://localhost:3001) + * @returns API client function + */ +export function createApiClient(baseUrl = DEFAULT_BASE) { + return async function api<T>(path: string, options?: RequestInit): Promise<T> { + const res = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }); + + if (!res.ok) { + const error = await res.json().catch(() => ({ error: res.statusText })) as { error?: string }; + throw new Error(error.error || `API error: ${res.status}`); + } + + if (res.status === 204) { + return undefined as T; + } + return res.json() as Promise<T>; + }; +} + +/** + * Default API client using environment variable or localhost + */ +export const API_BASE = process.env.VK_API_URL || DEFAULT_BASE; +export const api = createApiClient(API_BASE); + +/** + * Find task by ID (supports partial matching on ID suffix) + * @param id - Full or partial task ID + * @param apiClient - Optional custom API client (defaults to shared api client) + * @returns Task if found, null otherwise + */ +export async function findTask(id: string, apiClient = api): Promise<Task | null> { + const tasks = await apiClient<Task[]>('/api/tasks'); + return tasks.find(t => t.id === id || t.id.endsWith(id)) || null; +} diff --git a/shared/src/utils/constants.ts b/shared/src/utils/constants.ts new file mode 100644 index 00000000..eb281db7 --- /dev/null +++ b/shared/src/utils/constants.ts @@ -0,0 +1,37 @@ +/** + * Shared constants used across the application + */ + +/** + * Priority level labels + */ +export const PRIORITY_LABELS: Record<string, string> = { + low: 'Low', + medium: 'Medium', + high: 'High', + critical: 'Critical' +}; + +/** + * Task status labels + */ +export const STATUS_LABELS: Record<string, string> = { + 'todo': 'To Do', + 'in-progress': 'In Progress', + 'review': 'Review', + 'done': 'Done', + 'blocked': 'Blocked', + 'cancelled': 'Cancelled' +}; + +/** + * Task type labels + */ +export const TYPE_LABELS: Record<string, string> = { + feature: 'Feature', + bug: 'Bug', + refactor: 'Refactor', + docs: 'Documentation', + test: 'Test', + chore: 'Chore' +}; diff --git a/shared/src/utils/format.ts b/shared/src/utils/format.ts new file mode 100644 index 00000000..4fd194d2 --- /dev/null +++ b/shared/src/utils/format.ts @@ -0,0 +1,109 @@ +/** + * Formatting utilities for durations, dates, and other display values + */ + +/** + * Format a duration in seconds to a human-readable string + * @param seconds - Duration in seconds + * @returns Formatted duration (e.g., "2h 15m", "45s") + */ +export function formatDuration(seconds: number): string { + if (seconds < 60) { + return `${seconds}s`; + } + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + + if (minutes > 0 && secs > 0) { + return `${minutes}m ${secs}s`; + } + + return `${minutes}m`; +} + +/** + * Format a duration in milliseconds to a human-readable string + * @param ms - Duration in milliseconds + * @returns Formatted duration (e.g., "2h 15m", "45s", "250ms") + */ +export function formatDurationMs(ms: number): string { + if (ms < 1000) { + return `${ms}ms`; + } + const seconds = Math.floor(ms / 1000); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) { + return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; +} + +/** + * Format a date to a localized string + * @param date - Date string or Date object + * @param options - Intl.DateTimeFormatOptions + * @returns Formatted date string + */ +export function formatDate(date: string | Date, options?: Intl.DateTimeFormatOptions): string { + const d = typeof date === 'string' ? new Date(date) : date; + const defaultOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'short', + day: 'numeric' + }; + return d.toLocaleDateString('en-US', options || defaultOptions); +} + +/** + * Format a date and time to a localized string + * @param date - Date string or Date object + * @param options - Intl.DateTimeFormatOptions + * @returns Formatted date/time string + */ +export function formatDateTime(date: string | Date, options?: Intl.DateTimeFormatOptions): string { + const d = typeof date === 'string' ? new Date(date) : date; + return d.toLocaleString('en-US', options); +} + +/** + * Format a date to a relative time string (e.g., "2 hours ago") + * @param date - Date string or Date object + * @returns Relative time string + */ +export function formatRelativeTime(date: string | Date): string { + const d = typeof date === 'string' ? new Date(date) : date; + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSecs < 60) { + return 'just now'; + } + if (diffMins < 60) { + return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`; + } + if (diffHours < 24) { + return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; + } + if (diffDays < 30) { + return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; + } + + // Fall back to formatted date for older items + return formatDate(d); +} diff --git a/shared/src/utils/index.ts b/shared/src/utils/index.ts new file mode 100644 index 00000000..67bf5341 --- /dev/null +++ b/shared/src/utils/index.ts @@ -0,0 +1,8 @@ +/** + * Shared utilities barrel export + */ + +export * from './path.js'; +export * from './format.js'; +export * from './constants.js'; +export * from './api-client.js'; diff --git a/shared/src/utils/path.ts b/shared/src/utils/path.ts new file mode 100644 index 00000000..f35dbf70 --- /dev/null +++ b/shared/src/utils/path.ts @@ -0,0 +1,16 @@ +/** + * Path expansion utilities + */ + +/** + * Expand ~ and environment variables in a path + * @param p - Path to expand + * @returns Expanded path + */ +export function expandPath(p: string): string { + // Replace ~ with home directory + // Replace environment variables like $VAR + return p + .replace(/^~/, process.env.HOME || '') + .replace(/\$(\w+)/g, (_, name) => process.env[name] || ''); +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 386f8add..5f279d7f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { KeyboardProvider } from './hooks/useKeyboard'; import { KeyboardShortcutsDialog } from './components/layout/KeyboardShortcutsDialog'; import { BulkActionsProvider } from './hooks/useBulkActions'; import { useTaskSync } from './hooks/useTaskSync'; +import { TaskConfigProvider } from './contexts/TaskConfigContext'; function App() { // Connect to WebSocket for real-time task updates @@ -13,14 +14,16 @@ function App() { return ( <KeyboardProvider> <BulkActionsProvider> - <div className="min-h-screen bg-background"> - <Header /> - <main className="container mx-auto px-4 py-6"> - <KanbanBoard /> - </main> - <Toaster /> - <KeyboardShortcutsDialog /> - </div> + <TaskConfigProvider> + <div className="min-h-screen bg-background"> + <Header /> + <main className="container mx-auto px-4 py-6"> + <KanbanBoard /> + </main> + <Toaster /> + <KeyboardShortcutsDialog /> + </div> + </TaskConfigProvider> </BulkActionsProvider> </KeyboardProvider> ); diff --git a/web/src/components/board/BulkActionsBar.tsx b/web/src/components/board/BulkActionsBar.tsx index f014aede..d73fe5fc 100644 --- a/web/src/components/board/BulkActionsBar.tsx +++ b/web/src/components/board/BulkActionsBar.tsx @@ -109,7 +109,7 @@ export function BulkActionsBar({ allTaskIds }: BulkActionsBarProps) { return ( <> - <div className="flex items-center justify-between gap-4 mb-4 p-3 rounded-lg bg-muted/50 border"> + <div className="flex items-center justify-between gap-4 mb-4 p-3 rounded-lg bg-muted/50 border" role="toolbar" aria-label="Bulk actions"> <div className="flex items-center gap-3"> <Button variant="ghost" @@ -123,6 +123,7 @@ export function BulkActionsBar({ allTaskIds }: BulkActionsBarProps) { variant="outline" size="sm" onClick={handleSelectAll} + aria-label={allSelected ? 'Deselect all tasks' : 'Select all tasks'} > {allSelected ? 'Deselect All' : 'Select All'} </Button> diff --git a/web/src/components/board/KanbanBoard.tsx b/web/src/components/board/KanbanBoard.tsx index ab230273..38291d4b 100644 --- a/web/src/components/board/KanbanBoard.tsx +++ b/web/src/components/board/KanbanBoard.tsx @@ -3,9 +3,6 @@ import { KanbanColumn } from './KanbanColumn'; import { TaskDetailPanel } from '@/components/task/TaskDetailPanel'; import { Skeleton } from '@/components/ui/skeleton'; import type { TaskStatus, Task } from '@veritas-kanban/shared'; -import { useTaskTypes } from '@/hooks/useTaskTypes'; -import { useProjects } from '@/hooks/useProjects'; -import { useSprints } from '@/hooks/useSprints'; import { useFeatureSettings } from '@/hooks/useFeatureSettings'; import { DndContext, @@ -32,6 +29,7 @@ import { import { BulkActionsBar } from './BulkActionsBar'; import { ArchiveSuggestionBanner } from './ArchiveSuggestionBanner'; import { DashboardSection } from '@/components/dashboard/DashboardSection'; +import FeatureErrorBoundary from '@/components/shared/FeatureErrorBoundary'; const COLUMNS: { id: TaskStatus; title: string }[] = [ { id: 'todo', title: 'To Do' }, @@ -77,9 +75,6 @@ function LoadingSkeleton() { export function KanbanBoard() { const { data: tasks, isLoading, error } = useTasks(); - const { data: taskTypes = [] } = useTaskTypes(); - const { data: projects = [] } = useProjects(); - const { data: sprints = [] } = useSprints(); const { settings: featureSettings } = useFeatureSettings(); const [activeTask, setActiveTask] = useState<Task | null>(null); const [selectedTask, setSelectedTask] = useState<Task | null>(null); @@ -276,14 +271,36 @@ export function KanbanBoard() { {featureSettings.board.showArchiveSuggestions && <ArchiveSuggestionBanner />} - {featureSettings.board.enableDragAndDrop ? ( - <DndContext - sensors={sensors} - collisionDetection={closestCorners} - onDragStart={handleDragStart} - onDragOver={handleDragOver} - onDragEnd={handleDragEnd} - > + <FeatureErrorBoundary fallbackTitle="Board failed to render"> + {featureSettings.board.enableDragAndDrop ? ( + <DndContext + sensors={sensors} + collisionDetection={closestCorners} + onDragStart={handleDragStart} + onDragOver={handleDragOver} + onDragEnd={handleDragEnd} + > + <div className="grid grid-cols-4 gap-4"> + {COLUMNS.map(column => ( + <KanbanColumn + key={column.id} + id={column.id} + title={column.title} + tasks={tasksByStatus[column.id]} + allTasks={filteredTasks} + onTaskClick={handleTaskClick} + selectedTaskId={selectedTaskId} + /> + ))} + </div> + + <DragOverlay> + {activeTask ? ( + <TaskCard task={activeTask} isDragging /> + ) : null} + </DragOverlay> + </DndContext> + ) : ( <div className="grid grid-cols-4 gap-4"> {COLUMNS.map(column => ( <KanbanColumn @@ -294,39 +311,13 @@ export function KanbanBoard() { allTasks={filteredTasks} onTaskClick={handleTaskClick} selectedTaskId={selectedTaskId} - taskTypes={taskTypes} - projects={projects} - sprints={sprints} /> ))} </div> - - <DragOverlay> - {activeTask ? ( - <TaskCard task={activeTask} isDragging taskTypes={taskTypes} projects={projects} sprints={sprints} /> - ) : null} - </DragOverlay> - </DndContext> - ) : ( - <div className="grid grid-cols-4 gap-4"> - {COLUMNS.map(column => ( - <KanbanColumn - key={column.id} - id={column.id} - title={column.title} - tasks={tasksByStatus[column.id]} - allTasks={filteredTasks} - onTaskClick={handleTaskClick} - selectedTaskId={selectedTaskId} - taskTypes={taskTypes} - projects={projects} - sprints={sprints} - /> - ))} - </div> - )} + )} - {featureSettings.board.showDashboard && <DashboardSection />} + {featureSettings.board.showDashboard && <DashboardSection />} + </FeatureErrorBoundary> <TaskDetailPanel task={currentSelectedTask} diff --git a/web/src/components/board/KanbanColumn.tsx b/web/src/components/board/KanbanColumn.tsx index aa048484..0bf3ceb5 100644 --- a/web/src/components/board/KanbanColumn.tsx +++ b/web/src/components/board/KanbanColumn.tsx @@ -3,7 +3,7 @@ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' import { cn } from '@/lib/utils'; import { TaskCard } from '@/components/task/TaskCard'; import { isTaskBlocked, getTaskBlockers } from '@/hooks/useTasks'; -import type { Task, TaskStatus, TaskTypeConfig, ProjectConfig, SprintConfig } from '@veritas-kanban/shared'; +import type { Task, TaskStatus } from '@veritas-kanban/shared'; interface KanbanColumnProps { id: TaskStatus; @@ -12,9 +12,6 @@ interface KanbanColumnProps { allTasks: Task[]; onTaskClick?: (task: Task) => void; selectedTaskId?: string | null; - taskTypes?: TaskTypeConfig[]; - projects?: ProjectConfig[]; - sprints?: SprintConfig[]; } const columnColors: Record<TaskStatus, string> = { @@ -24,12 +21,14 @@ const columnColors: Record<TaskStatus, string> = { 'done': 'border-t-green-500', }; -export function KanbanColumn({ id, title, tasks, allTasks, onTaskClick, selectedTaskId, taskTypes = [], projects = [], sprints = [] }: KanbanColumnProps) { +export function KanbanColumn({ id, title, tasks, allTasks, onTaskClick, selectedTaskId }: KanbanColumnProps) { const { setNodeRef, isOver } = useDroppable({ id }); return ( <div ref={setNodeRef} + role="region" + aria-label={`${title} column, ${tasks.length} tasks`} className={cn( 'flex flex-col rounded-lg bg-muted/50 border-t-2 transition-all', columnColors[id], @@ -40,7 +39,7 @@ export function KanbanColumn({ id, title, tasks, allTasks, onTaskClick, selected <h2 className="text-sm font-medium text-muted-foreground"> {title} </h2> - <span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full"> + <span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full" aria-live="polite"> {tasks.length} </span> </div> @@ -63,9 +62,6 @@ export function KanbanColumn({ id, title, tasks, allTasks, onTaskClick, selected key={task.id} task={task} onClick={() => onTaskClick?.(task)} - taskTypes={taskTypes} - projects={projects} - sprints={sprints} isSelected={task.id === selectedTaskId} isBlocked={blocked} blockerTitles={blockers.map(b => b.title)} diff --git a/web/src/components/shared/FeatureErrorBoundary.tsx b/web/src/components/shared/FeatureErrorBoundary.tsx new file mode 100644 index 00000000..771471ca --- /dev/null +++ b/web/src/components/shared/FeatureErrorBoundary.tsx @@ -0,0 +1,120 @@ +import React, { Component, ReactNode } from 'react'; +import { AlertCircle, ChevronDown, ChevronUp, RotateCcw } from 'lucide-react'; + +interface FeatureErrorBoundaryProps { + children: ReactNode; + fallbackTitle?: string; + onReset?: () => void; +} + +interface FeatureErrorBoundaryState { + hasError: boolean; + error: Error | null; + errorExpanded: boolean; +} + +export class FeatureErrorBoundary extends Component< + FeatureErrorBoundaryProps, + FeatureErrorBoundaryState +> { + constructor(props: FeatureErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null, + errorExpanded: false, + }; + } + + static getDerivedStateFromError(error: Error): Partial<FeatureErrorBoundaryState> { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('FeatureErrorBoundary caught error:', error, errorInfo); + } + + handleReset = () => { + this.setState({ + hasError: false, + error: null, + errorExpanded: false, + }); + + // Call optional reset callback + if (this.props.onReset) { + this.props.onReset(); + } + }; + + toggleErrorDetails = () => { + this.setState((prev) => ({ errorExpanded: !prev.errorExpanded })); + }; + + render() { + if (this.state.hasError) { + const title = this.props.fallbackTitle || 'Something went wrong'; + + return ( + <div className="flex items-center justify-center min-h-[200px] p-6"> + <div className="max-w-md w-full rounded-lg border border-zinc-700 bg-zinc-900 p-6 space-y-4"> + <div className="flex items-start gap-3"> + <AlertCircle className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" /> + <div className="flex-1 min-w-0 space-y-2"> + <h3 className="text-base font-semibold text-zinc-100"> + {title} + </h3> + <p className="text-sm text-zinc-400"> + An unexpected error occurred. Try refreshing or contact support if the issue persists. + </p> + + {this.state.error && ( + <div className="pt-2"> + <button + onClick={this.toggleErrorDetails} + className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors" + > + {this.state.errorExpanded ? ( + <ChevronUp className="h-3 w-3" /> + ) : ( + <ChevronDown className="h-3 w-3" /> + )} + {this.state.errorExpanded ? 'Hide' : 'Show'} error details + </button> + + {this.state.errorExpanded && ( + <div className="mt-2 p-3 rounded bg-zinc-950 border border-zinc-800"> + <code className="text-xs text-red-400 break-all whitespace-pre-wrap"> + {this.state.error.message} + {this.state.error.stack && ( + <div className="mt-2 text-zinc-500 text-[10px] leading-relaxed"> + {this.state.error.stack} + </div> + )} + </code> + </div> + )} + </div> + )} + </div> + </div> + + <div className="flex justify-end pt-2"> + <button + onClick={this.handleReset} + className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-zinc-100 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-md transition-colors" + > + <RotateCcw className="h-3.5 w-3.5" /> + Try Again + </button> + </div> + </div> + </div> + ); + } + + return this.props.children; + } +} + +export default FeatureErrorBoundary; diff --git a/web/src/components/task/AgentPanel.tsx b/web/src/components/task/AgentPanel.tsx index dab2405b..4ee1af19 100644 --- a/web/src/components/task/AgentPanel.tsx +++ b/web/src/components/task/AgentPanel.tsx @@ -49,6 +49,7 @@ import { } from 'lucide-react'; import type { Task, AgentType, AttemptStatus } from '@veritas-kanban/shared'; import { cn } from '@/lib/utils'; +import FeatureErrorBoundary from '@/components/shared/FeatureErrorBoundary'; interface AgentPanelProps { task: Task; @@ -151,12 +152,13 @@ export function AgentPanel({ task }: AgentPanelProps) { } return ( - <div className="space-y-3"> - <div className="flex items-center justify-between"> - <Label className="text-muted-foreground flex items-center gap-2"> - <Bot className="h-4 w-4" /> - AI Agent - </Label> + <FeatureErrorBoundary fallbackTitle="Agent panel failed to load"> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <Label className="text-muted-foreground flex items-center gap-2"> + <Bot className="h-4 w-4" /> + AI Agent + </Label> <div className="flex items-center gap-2"> {isConnected ? ( <Wifi className="h-3 w-3 text-green-500" /> @@ -391,6 +393,7 @@ export function AgentPanel({ task }: AgentPanelProps) { )} </div> )} - </div> + </div> + </FeatureErrorBoundary> ); } diff --git a/web/src/components/task/CreateTaskDialog.tsx b/web/src/components/task/CreateTaskDialog.tsx index fe98c9bf..8ae1c4b7 100644 --- a/web/src/components/task/CreateTaskDialog.tsx +++ b/web/src/components/task/CreateTaskDialog.tsx @@ -18,19 +18,14 @@ import { SelectValue, } from '@/components/ui/select'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { useCreateTask } from '@/hooks/useTasks'; -import { useTemplates, type TaskTemplate } from '@/hooks/useTemplates'; -import type { TaskPriority, Subtask } from '@veritas-kanban/shared'; -import { FileText, X, Check, AlertCircle, HelpCircle, Info } from 'lucide-react'; import { useTaskTypes, getTypeIcon } from '@/hooks/useTaskTypes'; import { useProjects } from '@/hooks/useProjects'; import { useSprints } from '@/hooks/useSprints'; -import { nanoid } from 'nanoid'; -import { - interpolateVariables, - extractCustomVariables, - type VariableContext -} from '@/lib/template-variables'; +import { useTemplateForm } from '@/hooks/useTemplateForm'; +import { BlueprintPreview } from './create/BlueprintPreview'; +import { TemplateVariableInputs } from './create/TemplateVariableInputs'; +import type { TaskPriority } from '@veritas-kanban/shared'; +import { FileText, X, Check, HelpCircle, Info } from 'lucide-react'; import { getCategoryIcon } from '@/lib/template-categories'; interface CreateTaskDialogProps { @@ -45,20 +40,28 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) const [priority, setPriority] = useState<TaskPriority>('medium'); const [project, setProject] = useState(''); const [sprint, setSprint] = useState(''); - const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null); - const [subtasks, setSubtasks] = useState<Subtask[]>([]); - const [customVars, setCustomVars] = useState<Record<string, string>>({}); - const [requiredCustomVars, setRequiredCustomVars] = useState<string[]>([]); const [categoryFilter, setCategoryFilter] = useState<string>('all'); const [showHelp, setShowHelp] = useState(false); const [showNewProject, setShowNewProject] = useState(false); const [newProjectName, setNewProjectName] = useState(''); - const createTask = useCreateTask(); - const { data: templates } = useTemplates(); const { data: taskTypes = [] } = useTaskTypes(); const { data: projects = [] } = useProjects(); const { data: sprints = [] } = useSprints(); + + const { + selectedTemplate, + templates, + subtasks, + customVars, + requiredCustomVars, + applyTemplate, + clearTemplate, + removeSubtask, + setCustomVars, + createTasks, + isCreating, + } = useTemplateForm(); // Filter templates by selected category const filteredTemplates = useMemo(() => { @@ -67,123 +70,33 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) return templates.filter(t => (t.category || 'custom') === categoryFilter); }, [templates, categoryFilter]); - const applyTemplate = (template: TaskTemplate) => { - setSelectedTemplate(template.id); - - // If this is a blueprint template, extract variables from all blueprint tasks - if (template.blueprint && template.blueprint.length > 0) { - const allBlueprintText = template.blueprint.flatMap(bt => [ - bt.title, - bt.taskDefaults.descriptionTemplate || '', - ...(bt.subtaskTemplates?.map(st => st.title) || []) - ]).join(' '); - - const customVarNames = extractCustomVariables(allBlueprintText); - setRequiredCustomVars(customVarNames); - - const initialCustomVars: Record<string, string> = {}; - customVarNames.forEach(name => { - initialCustomVars[name] = ''; - }); - setCustomVars(initialCustomVars); - - // Don't populate the form for blueprints + const handleTemplateSelect = (templateId: string) => { + if (templateId === 'none') { + clearTemplate(); return; } - // Single-task template - if (template.taskDefaults.type) setType(template.taskDefaults.type); - if (template.taskDefaults.priority) setPriority(template.taskDefaults.priority); - if (template.taskDefaults.project) setProject(template.taskDefaults.project); + const template = templates?.find(t => t.id === templateId); + if (!template) return; - // Extract custom variables from description template and subtasks - const allTemplateText = [ - template.taskDefaults.descriptionTemplate || '', - ...(template.subtaskTemplates?.map(st => st.title) || []) - ].join(' '); - - const customVarNames = extractCustomVariables(allTemplateText); - setRequiredCustomVars(customVarNames); - - // Initialize custom vars - const initialCustomVars: Record<string, string> = {}; - customVarNames.forEach(name => { - initialCustomVars[name] = ''; - }); - setCustomVars(initialCustomVars); - - // Store raw template (will be interpolated when custom vars are filled) - if (template.taskDefaults.descriptionTemplate) { - setDescription(template.taskDefaults.descriptionTemplate); - } - - // Convert subtask templates to actual subtasks (will be interpolated on submit) - if (template.subtaskTemplates && template.subtaskTemplates.length > 0) { - const now = new Date().toISOString(); - const templateSubtasks: Subtask[] = template.subtaskTemplates - .sort((a, b) => a.order - b.order) - .map(st => ({ - id: nanoid(), - title: st.title, // Will be interpolated on submit - completed: false, - created: now, - })); - setSubtasks(templateSubtasks); - } else { - setSubtasks([]); - } - }; - - const clearTemplate = () => { - setSelectedTemplate(null); - setSubtasks([]); - setCustomVars({}); - setRequiredCustomVars([]); - }; - - const removeSubtask = (id: string) => { - setSubtasks(prev => prev.filter(st => st.id !== id)); + const defaults = applyTemplate(template); + if (defaults.type) setType(defaults.type); + if (defaults.priority) setPriority(defaults.priority); + if (defaults.project) setProject(defaults.project); + if (defaults.description) setDescription(defaults.description); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - // Build variable context - const context: VariableContext = { - project: project.trim() || undefined, - author: 'Brad', // TODO: Get from user config - customVars, - }; - - // Check if this is a blueprint template const template = selectedTemplate ? templates?.find(t => t.id === selectedTemplate) : null; - if (template?.blueprint && template.blueprint.length > 0) { - // Blueprint: create multiple tasks - await handleBlueprintCreation(template, context); - } else { - // Single task creation - if (!title.trim()) return; - - // Interpolate variables in description - const interpolatedDescription = interpolateVariables(description, context); - - // Interpolate variables in subtask titles - const interpolatedSubtasks = subtasks.map(st => ({ - ...st, - title: interpolateVariables(st.title, context), - })); - - await createTask.mutateAsync({ - title: title.trim(), - description: interpolatedDescription.trim(), - type, - priority, - project: project.trim() || undefined, - sprint: sprint.trim() || undefined, - subtasks: interpolatedSubtasks.length > 0 ? interpolatedSubtasks : undefined, - }); + // For blueprints, we don't need a title + if (!(template?.blueprint && template.blueprint.length > 0) && !title.trim()) { + return; } + + await createTasks(title, description, project, sprint, type, priority); // Reset form setTitle(''); @@ -192,61 +105,14 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) setPriority('medium'); setProject(''); setSprint(''); - setSelectedTemplate(null); - setSubtasks([]); - setCustomVars({}); - setRequiredCustomVars([]); + clearTemplate(); setShowNewProject(false); setNewProjectName(''); onOpenChange(false); }; - const handleBlueprintCreation = async (template: TaskTemplate, context: VariableContext) => { - if (!template.blueprint) return; - - // Map to store refId -> actual task ID - const refIdToTaskId: Record<string, string> = {}; - - // Create tasks in order, resolving dependencies - for (const blueprintTask of template.blueprint) { - // Interpolate title - const taskTitle = interpolateVariables(blueprintTask.title, context); - - // Interpolate description - const taskDescription = interpolateVariables( - blueprintTask.taskDefaults.descriptionTemplate || '', - context - ); - - // Create subtasks - const taskSubtasks = blueprintTask.subtaskTemplates?.map(st => { - const now = new Date().toISOString(); - return { - id: nanoid(), - title: interpolateVariables(st.title, context), - completed: false, - created: now, - }; - }); - - // Resolve dependencies - const blockedBy = blueprintTask.blockedByRefs?.map(refId => refIdToTaskId[refId]).filter(Boolean); - - // Create the task - const createdTask = await createTask.mutateAsync({ - title: taskTitle, - description: taskDescription, - type: blueprintTask.taskDefaults.type, - priority: blueprintTask.taskDefaults.priority, - project: blueprintTask.taskDefaults.project || project.trim() || undefined, - subtasks: taskSubtasks, - blockedBy, - }); - - // Store the mapping - refIdToTaskId[blueprintTask.refId] = createdTask.id; - } - }; + const currentTemplate = selectedTemplate ? templates?.find(t => t.id === selectedTemplate) : null; + const isBlueprint = currentTemplate?.blueprint && currentTemplate.blueprint.length > 0; return ( <Dialog open={open} onOpenChange={onOpenChange}> @@ -270,6 +136,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) size="sm" className="h-6 w-6 p-0" onClick={() => setShowHelp(!showHelp)} + aria-label={showHelp ? 'Hide template help' : 'Show template help'} > <HelpCircle className="h-4 w-4 text-muted-foreground" /> </Button> @@ -292,6 +159,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) </div> </div> )} + <Tabs value={categoryFilter} onValueChange={setCategoryFilter}> <TabsList className="grid w-full grid-cols-4"> <TabsTrigger value="all" className="text-xs">All</TabsTrigger> @@ -300,16 +168,10 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) <TabsTrigger value="sprint" className="text-xs">šŸ”„</TabsTrigger> </TabsList> </Tabs> + <Select value={selectedTemplate || 'none'} - onValueChange={(value) => { - if (value === 'none') { - clearTemplate(); - } else { - const template = templates.find(t => t.id === value); - if (template) applyTemplate(template); - } - }} + onValueChange={handleTemplateSelect} > <SelectTrigger className="mt-2"> <SelectValue placeholder="Select template..." /> @@ -333,268 +195,205 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) )} {/* Blueprint preview or regular form */} - {selectedTemplate && templates?.find(t => t.id === selectedTemplate)?.blueprint ? ( - <div className="grid gap-4 py-4"> - <div className="border rounded-md p-3 bg-muted/30"> - <div className="flex items-center gap-2 mb-2"> - <AlertCircle className="h-4 w-4 text-blue-500" /> - <Label className="text-sm font-medium">Blueprint: Multiple Tasks</Label> - </div> - <p className="text-xs text-muted-foreground mb-3"> - This template will create {templates.find(t => t.id === selectedTemplate)?.blueprint?.length} linked tasks. - </p> - <div className="space-y-2"> - {templates.find(t => t.id === selectedTemplate)?.blueprint?.map((bt, idx) => ( - <div key={bt.refId} className="text-sm border-l-2 border-primary/50 pl-3 py-1"> - <div className="font-medium"> - {idx + 1}. {bt.title} - </div> - {bt.blockedByRefs && bt.blockedByRefs.length > 0 && ( - <div className="text-xs text-muted-foreground"> - Blocked by: {bt.blockedByRefs.join(', ')} - </div> - )} - {bt.subtaskTemplates && bt.subtaskTemplates.length > 0 && ( - <div className="text-xs text-muted-foreground"> - {bt.subtaskTemplates.length} subtasks - </div> - )} - </div> - ))} - </div> - </div> - - {/* Custom variable inputs for blueprint */} - {requiredCustomVars.length > 0 && ( - <div className="grid gap-3 border rounded-md p-3 bg-muted/30"> - <div className="flex items-center gap-2"> - <AlertCircle className="h-4 w-4 text-blue-500" /> - <Label className="text-sm font-medium">Template Variables</Label> - </div> - {requiredCustomVars.map((varName) => ( - <div key={varName} className="grid gap-1.5"> - <Label htmlFor={`var-${varName}`} className="text-xs"> - {varName} - </Label> - <Input - id={`var-${varName}`} - value={customVars[varName] || ''} - onChange={(e) => setCustomVars(prev => ({ ...prev, [varName]: e.target.value }))} - placeholder={`Enter ${varName}...`} - className="h-8" - /> - </div> - ))} - </div> - )} - </div> - ) : ( - <div className="grid gap-4 py-4"> - <div className="grid gap-2"> - <Label htmlFor="title">Title</Label> - <Input - id="title" - value={title} - onChange={(e) => setTitle(e.target.value)} - placeholder="Enter task title..." - autoFocus + <div className="grid gap-4 py-4"> + {isBlueprint ? ( + <> + <BlueprintPreview template={currentTemplate!} /> + <TemplateVariableInputs + variables={requiredCustomVars} + values={customVars} + onChange={(name, value) => setCustomVars(prev => ({ ...prev, [name]: value }))} /> - </div> - - <div className="grid gap-2"> - <Label htmlFor="description">Description</Label> - <Textarea - id="description" - value={description} - onChange={(e) => setDescription(e.target.value)} - placeholder="Describe the task..." - rows={3} - /> - </div> - - <div className="grid grid-cols-2 gap-4"> - <div className="grid gap-2"> - <Label htmlFor="type">Type</Label> - <Select value={type} onValueChange={setType}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {taskTypes.map((taskType) => { - const IconComponent = getTypeIcon(taskType.icon); - return ( - <SelectItem key={taskType.id} value={taskType.id}> - <div className="flex items-center gap-2"> - {IconComponent && <IconComponent className="h-4 w-4" />} - {taskType.label} - </div> - </SelectItem> - ); - })} - </SelectContent> - </Select> - </div> - - <div className="grid gap-2"> - <Label htmlFor="priority">Priority</Label> - <Select value={priority} onValueChange={(v) => setPriority(v as TaskPriority)}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="low">Low</SelectItem> - <SelectItem value="medium">Medium</SelectItem> - <SelectItem value="high">High</SelectItem> - </SelectContent> - </Select> - </div> - </div> - - <div className="grid gap-2"> - <Label htmlFor="project">Project (optional)</Label> - {!showNewProject ? ( - <Select - value={project} - onValueChange={(value) => { - if (value === '__new__') { - setShowNewProject(true); - setNewProjectName(''); - } else { - setProject(value); - } - }} - > - <SelectTrigger> - <SelectValue placeholder="Select project..." /> - </SelectTrigger> - <SelectContent> - <SelectItem value="__none__">No project</SelectItem> - {projects.map((proj) => ( - <SelectItem key={proj.id} value={proj.id}> - {proj.label} - </SelectItem> - ))} - <SelectItem value="__new__" className="text-primary"> - + New Project - </SelectItem> - </SelectContent> - </Select> - ) : ( - <div className="flex gap-2"> + </> + ) : ( + <> + <div className="grid gap-2"> + <Label htmlFor="title">Title</Label> <Input - value={newProjectName} - onChange={(e) => setNewProjectName(e.target.value)} - placeholder="Enter project name..." + id="title" + value={title} + onChange={(e) => setTitle(e.target.value)} + placeholder="Enter task title..." autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter' && newProjectName.trim()) { - e.preventDefault(); - setProject(newProjectName.trim()); - setShowNewProject(false); - } - if (e.key === 'Escape') { - setShowNewProject(false); - setNewProjectName(''); - } - }} /> - <Button - type="button" - size="sm" - onClick={() => { - if (newProjectName.trim()) { - setProject(newProjectName.trim()); - setShowNewProject(false); - } - }} - > - Add - </Button> - <Button - type="button" - size="sm" - variant="outline" - onClick={() => { - setShowNewProject(false); - setNewProjectName(''); - }} - > - Cancel - </Button> </div> - )} - </div> - - {/* Sprint */} - <div className="grid gap-2"> - <Label>Sprint (optional)</Label> - <Select value={sprint || '__none__'} onValueChange={(v) => setSprint(v === '__none__' ? '' : v)}> - <SelectTrigger> - <SelectValue placeholder="No sprint" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="__none__">No Sprint</SelectItem> - {sprints.map(s => ( - <SelectItem key={s.id} value={s.id}>{s.label}</SelectItem> - ))} - </SelectContent> - </Select> - </div> - - {/* Custom variable inputs */} - {requiredCustomVars.length > 0 && ( - <div className="grid gap-3 border rounded-md p-3 bg-muted/30"> - <div className="flex items-center gap-2"> - <AlertCircle className="h-4 w-4 text-blue-500" /> - <Label className="text-sm font-medium">Template Variables</Label> + + <div className="grid gap-2"> + <Label htmlFor="description">Description</Label> + <Textarea + id="description" + value={description} + onChange={(e) => setDescription(e.target.value)} + placeholder="Describe the task..." + rows={3} + /> </div> - {requiredCustomVars.map((varName) => ( - <div key={varName} className="grid gap-1.5"> - <Label htmlFor={`var-${varName}`} className="text-xs"> - {varName} - </Label> - <Input - id={`var-${varName}`} - value={customVars[varName] || ''} - onChange={(e) => setCustomVars(prev => ({ ...prev, [varName]: e.target.value }))} - placeholder={`Enter ${varName}...`} - className="h-8" - /> + + <div className="grid grid-cols-2 gap-4"> + <div className="grid gap-2"> + <Label htmlFor="type">Type</Label> + <Select value={type} onValueChange={setType}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {taskTypes.map((taskType) => { + const IconComponent = getTypeIcon(taskType.icon); + return ( + <SelectItem key={taskType.id} value={taskType.id}> + <div className="flex items-center gap-2"> + {IconComponent && <IconComponent className="h-4 w-4" />} + {taskType.label} + </div> + </SelectItem> + ); + })} + </SelectContent> + </Select> </div> - ))} - </div> - )} - - {/* Subtasks from template */} - {subtasks.length > 0 && ( - <div className="grid gap-2"> - <Label>Subtasks ({subtasks.length})</Label> - <div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2"> - {subtasks.map((subtask) => ( - <div - key={subtask.id} - className="flex items-center justify-between py-1 px-2 rounded hover:bg-muted/50" + + <div className="grid gap-2"> + <Label htmlFor="priority">Priority</Label> + <Select value={priority} onValueChange={(v) => setPriority(v as TaskPriority)}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="low">Low</SelectItem> + <SelectItem value="medium">Medium</SelectItem> + <SelectItem value="high">High</SelectItem> + </SelectContent> + </Select> + </div> + </div> + + <div className="grid gap-2"> + <Label htmlFor="project">Project (optional)</Label> + {!showNewProject ? ( + <Select + value={project} + onValueChange={(value) => { + if (value === '__new__') { + setShowNewProject(true); + setNewProjectName(''); + } else { + setProject(value); + } + }} > - <div className="flex items-center gap-2 flex-1"> - <Check className="h-3 w-3 text-muted-foreground" /> - <span className="text-sm">{subtask.title}</span> - </div> + <SelectTrigger> + <SelectValue placeholder="Select project..." /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__none__">No project</SelectItem> + {projects.map((proj) => ( + <SelectItem key={proj.id} value={proj.id}> + {proj.label} + </SelectItem> + ))} + <SelectItem value="__new__" className="text-primary"> + + New Project + </SelectItem> + </SelectContent> + </Select> + ) : ( + <div className="flex gap-2"> + <Input + value={newProjectName} + onChange={(e) => setNewProjectName(e.target.value)} + placeholder="Enter project name..." + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter' && newProjectName.trim()) { + e.preventDefault(); + setProject(newProjectName.trim()); + setShowNewProject(false); + } + if (e.key === 'Escape') { + setShowNewProject(false); + setNewProjectName(''); + } + }} + /> <Button type="button" - variant="ghost" size="sm" - className="h-6 w-6 p-0" - onClick={() => removeSubtask(subtask.id)} + onClick={() => { + if (newProjectName.trim()) { + setProject(newProjectName.trim()); + setShowNewProject(false); + } + }} > - <X className="h-3 w-3" /> + Add + </Button> + <Button + type="button" + size="sm" + variant="outline" + onClick={() => { + setShowNewProject(false); + setNewProjectName(''); + }} + > + Cancel </Button> </div> - ))} + )} </div> - </div> + + <div className="grid gap-2"> + <Label>Sprint (optional)</Label> + <Select value={sprint || '__none__'} onValueChange={(v) => setSprint(v === '__none__' ? '' : v)}> + <SelectTrigger> + <SelectValue placeholder="No sprint" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__none__">No Sprint</SelectItem> + {sprints.map(s => ( + <SelectItem key={s.id} value={s.id}>{s.label}</SelectItem> + ))} + </SelectContent> + </Select> + </div> + + <TemplateVariableInputs + variables={requiredCustomVars} + values={customVars} + onChange={(name, value) => setCustomVars(prev => ({ ...prev, [name]: value }))} + /> + + {subtasks.length > 0 && ( + <div className="grid gap-2"> + <Label>Subtasks ({subtasks.length})</Label> + <div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2"> + {subtasks.map((subtask) => ( + <div + key={subtask.id} + className="flex items-center justify-between py-1 px-2 rounded hover:bg-muted/50" + > + <div className="flex items-center gap-2 flex-1"> + <Check className="h-3 w-3 text-muted-foreground" /> + <span className="text-sm">{subtask.title}</span> + </div> + <Button + type="button" + variant="ghost" + size="sm" + className="h-6 w-6 p-0" + onClick={() => removeSubtask(subtask.id)} + aria-label={`Remove subtask: ${subtask.title}`} + > + <X className="h-3 w-3" /> + </Button> + </div> + ))} + </div> + </div> + )} + </> )} - </div> - )} + </div> <DialogFooter> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> @@ -602,16 +401,9 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) </Button> <Button type="submit" - disabled={ - (selectedTemplate && templates?.find(t => t.id === selectedTemplate)?.blueprint - ? false - : !title.trim()) || createTask.isPending - } + disabled={(isBlueprint ? false : !title.trim()) || isCreating} > - {createTask.isPending ? 'Creating...' : - selectedTemplate && templates?.find(t => t.id === selectedTemplate)?.blueprint - ? 'Create Tasks' - : 'Create Task'} + {isCreating ? 'Creating...' : isBlueprint ? 'Create Tasks' : 'Create Task'} </Button> </DialogFooter> </form> diff --git a/web/src/components/task/DiffViewer.tsx b/web/src/components/task/DiffViewer.tsx index 8fa95f75..f6ac0b4f 100644 --- a/web/src/components/task/DiffViewer.tsx +++ b/web/src/components/task/DiffViewer.tsx @@ -1,23 +1,10 @@ import { useState } from 'react'; -import { useDiffSummary, useFileDiff } from '@/hooks/useDiff'; -import { Button } from '@/components/ui/button'; -import { Textarea } from '@/components/ui/textarea'; -import { - FileCode, - FilePlus, - FileMinus, - FileEdit, - ChevronRight, - Loader2, - AlertCircle, - Plus, - MessageSquare, - X, -} from 'lucide-react'; +import { useDiffSummary } from '@/hooks/useDiff'; +import { FileTree } from './diff/FileTree'; +import { FileDiffView } from './diff/FileDiffView'; +import { Loader2, AlertCircle, ChevronRight } from 'lucide-react'; import type { Task, ReviewComment } from '@veritas-kanban/shared'; -import type { FileChange, DiffLine, DiffHunk } from '@/lib/api'; -import { cn } from '@/lib/utils'; -import { nanoid } from 'nanoid'; +import FeatureErrorBoundary from '@/components/shared/FeatureErrorBoundary'; interface DiffViewerProps { task: Task; @@ -25,353 +12,6 @@ interface DiffViewerProps { onRemoveComment: (commentId: string) => void; } -const statusIcons: Record<FileChange['status'], React.ReactNode> = { - added: <FilePlus className="h-4 w-4 text-green-500" />, - modified: <FileEdit className="h-4 w-4 text-amber-500" />, - deleted: <FileMinus className="h-4 w-4 text-red-500" />, - renamed: <FileCode className="h-4 w-4 text-blue-500" />, -}; - -function FileTree({ - files, - selectedFile, - onSelectFile, - comments, -}: { - files: FileChange[]; - selectedFile: string | null; - onSelectFile: (path: string) => void; - comments: ReviewComment[]; -}) { - const commentsByFile = comments.reduce((acc, c) => { - acc[c.file] = (acc[c.file] || 0) + 1; - return acc; - }, {} as Record<string, number>); - - return ( - <div className="space-y-1"> - {files.map((file) => ( - <button - key={file.path} - onClick={() => onSelectFile(file.path)} - className={cn( - 'w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md text-left', - 'hover:bg-muted transition-colors', - selectedFile === file.path && 'bg-muted' - )} - > - {statusIcons[file.status]} - <span className="truncate flex-1 font-mono text-xs">{file.path}</span> - <span className="flex items-center gap-1 text-xs"> - {commentsByFile[file.path] && ( - <span className="flex items-center gap-0.5 text-amber-500"> - <MessageSquare className="h-3 w-3" /> - {commentsByFile[file.path]} - </span> - )} - {file.additions > 0 && ( - <span className="text-green-500">+{file.additions}</span> - )} - {file.deletions > 0 && ( - <span className="text-red-500">-{file.deletions}</span> - )} - </span> - </button> - ))} - </div> - ); -} - -function CommentInput({ - onSubmit, - onCancel, -}: { - onSubmit: (content: string) => void; - onCancel: () => void; -}) { - const [content, setContent] = useState(''); - - const handleSubmit = () => { - if (content.trim()) { - onSubmit(content.trim()); - setContent(''); - } - }; - - return ( - <div className="p-2 bg-amber-500/10 border-l-2 border-amber-500 space-y-2"> - <Textarea - value={content} - onChange={(e) => setContent(e.target.value)} - placeholder="Add review comment..." - rows={2} - className="text-xs" - autoFocus - /> - <div className="flex gap-2"> - <Button size="sm" onClick={handleSubmit} disabled={!content.trim()}> - Add Comment - </Button> - <Button size="sm" variant="ghost" onClick={onCancel}> - Cancel - </Button> - </div> - </div> - ); -} - -function CommentDisplay({ - comment, - onRemove, -}: { - comment: ReviewComment; - onRemove: () => void; -}) { - return ( - <div className="p-2 bg-amber-500/10 border-l-2 border-amber-500 group"> - <div className="flex items-start justify-between"> - <p className="text-xs whitespace-pre-wrap">{comment.content}</p> - <button - onClick={onRemove} - className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive" - > - <X className="h-3 w-3" /> - </button> - </div> - <p className="text-[10px] text-muted-foreground mt-1"> - {new Date(comment.created).toLocaleString()} - </p> - </div> - ); -} - -function DiffLineView({ - line, - comments, - addingCommentAtLine, - onStartAddComment, - onSubmitComment, - onCancelComment, - onRemoveComment, -}: { - line: DiffLine; - comments: ReviewComment[]; - addingCommentAtLine: number | null; - onStartAddComment: (line: number) => void; - onSubmitComment: (content: string) => void; - onCancelComment: () => void; - onRemoveComment: (commentId: string) => void; -}) { - const lineNumber = line.newNumber || line.oldNumber; - const lineComments = comments.filter(c => c.line === lineNumber); - const isAddingHere = addingCommentAtLine === lineNumber; - - return ( - <> - <div - className={cn( - 'group flex hover:bg-muted/30', - line.type === 'add' && 'bg-green-500/10', - line.type === 'delete' && 'bg-red-500/10', - lineComments.length > 0 && 'bg-amber-500/5' - )} - > - {/* Line numbers */} - <div className="flex-shrink-0 w-20 flex text-muted-foreground select-none border-r border-border"> - <span className="w-10 px-2 text-right border-r border-border text-[10px]"> - {line.oldNumber || ''} - </span> - <span className="w-10 px-2 text-right text-[10px]"> - {line.newNumber || ''} - </span> - </div> - - {/* Add comment button */} - {lineNumber && ( - <button - onClick={() => onStartAddComment(lineNumber)} - className="w-6 flex-shrink-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-amber-500" - > - <Plus className="h-3 w-3" /> - </button> - )} - - {/* Change indicator */} - <div className="w-6 flex-shrink-0 flex items-center justify-center"> - {line.type === 'add' && <span className="text-green-500">+</span>} - {line.type === 'delete' && <span className="text-red-500">-</span>} - </div> - - {/* Content */} - <pre className="flex-1 px-2 overflow-x-auto whitespace-pre text-xs"> - {line.content || ' '} - </pre> - - {/* Comment indicator */} - {lineComments.length > 0 && ( - <div className="flex-shrink-0 px-2 flex items-center"> - <MessageSquare className="h-3 w-3 text-amber-500" /> - </div> - )} - </div> - - {/* Inline comments */} - {lineComments.map(comment => ( - <CommentDisplay - key={comment.id} - comment={comment} - onRemove={() => onRemoveComment(comment.id)} - /> - ))} - - {/* Comment input */} - {isAddingHere && ( - <CommentInput - onSubmit={onSubmitComment} - onCancel={onCancelComment} - /> - )} - </> - ); -} - -function DiffHunkView({ - hunk, - comments, - addingCommentAtLine, - onStartAddComment, - onSubmitComment, - onCancelComment, - onRemoveComment, -}: { - hunk: DiffHunk; - comments: ReviewComment[]; - addingCommentAtLine: number | null; - onStartAddComment: (line: number) => void; - onSubmitComment: (content: string) => void; - onCancelComment: () => void; - onRemoveComment: (commentId: string) => void; -}) { - return ( - <div className="border-b border-border last:border-b-0"> - <div className="bg-muted/50 px-4 py-1 text-xs text-muted-foreground font-mono"> - @@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@ - </div> - - <div className="font-mono text-xs"> - {hunk.lines.map((line, idx) => ( - <DiffLineView - key={idx} - line={line} - comments={comments} - addingCommentAtLine={addingCommentAtLine} - onStartAddComment={onStartAddComment} - onSubmitComment={onSubmitComment} - onCancelComment={onCancelComment} - onRemoveComment={onRemoveComment} - /> - ))} - </div> - </div> - ); -} - -function FileDiffView({ - taskId, - filePath, - comments, - onAddComment, - onRemoveComment, -}: { - taskId: string; - filePath: string; - comments: ReviewComment[]; - onAddComment: (comment: ReviewComment) => void; - onRemoveComment: (commentId: string) => void; -}) { - const { data: diff, isLoading, error } = useFileDiff(taskId, filePath); - const [addingCommentAtLine, setAddingCommentAtLine] = useState<number | null>(null); - - const fileComments = comments.filter(c => c.file === filePath); - - const handleSubmitComment = (content: string) => { - if (addingCommentAtLine === null) return; - - const comment: ReviewComment = { - id: `comment_${nanoid(8)}`, - file: filePath, - line: addingCommentAtLine, - content, - created: new Date().toISOString(), - }; - - onAddComment(comment); - setAddingCommentAtLine(null); - }; - - if (isLoading) { - return ( - <div className="flex items-center justify-center h-64 text-muted-foreground"> - <Loader2 className="h-5 w-5 animate-spin mr-2" /> - Loading diff... - </div> - ); - } - - if (error || !diff) { - return ( - <div className="flex items-center justify-center h-64 text-muted-foreground"> - <AlertCircle className="h-5 w-5 mr-2" /> - {(error as Error)?.message || 'Failed to load diff'} - </div> - ); - } - - if (diff.hunks.length === 0) { - return ( - <div className="flex items-center justify-center h-64 text-muted-foreground"> - No changes in this file - </div> - ); - } - - return ( - <div className="border rounded-md overflow-hidden bg-card"> - <div className="flex items-center justify-between px-4 py-2 bg-muted/50 border-b"> - <div className="flex items-center gap-2"> - {statusIcons[diff.status]} - <span className="font-mono text-sm">{diff.path}</span> - </div> - <div className="flex items-center gap-2 text-xs"> - {fileComments.length > 0 && ( - <span className="flex items-center gap-1 text-amber-500"> - <MessageSquare className="h-3 w-3" /> - {fileComments.length} - </span> - )} - <span className="text-green-500">+{diff.additions}</span> - <span className="text-red-500">-{diff.deletions}</span> - </div> - </div> - - <div className="overflow-x-auto"> - {diff.hunks.map((hunk, idx) => ( - <DiffHunkView - key={idx} - hunk={hunk} - comments={fileComments} - addingCommentAtLine={addingCommentAtLine} - onStartAddComment={setAddingCommentAtLine} - onSubmitComment={handleSubmitComment} - onCancelComment={() => setAddingCommentAtLine(null)} - onRemoveComment={onRemoveComment} - /> - ))} - </div> - </div> - ); -} - export function DiffViewer({ task, onAddComment, onRemoveComment }: DiffViewerProps) { const hasWorktree = !!task.git?.worktreePath; const { data: summary, isLoading, error } = useDiffSummary(task.id, hasWorktree); @@ -415,52 +55,54 @@ export function DiffViewer({ task, onAddComment, onRemoveComment }: DiffViewerPr } return ( - <div className="flex gap-4 h-[500px]"> - {/* File tree */} - <div className="w-64 flex-shrink-0 border rounded-md overflow-hidden bg-card"> - <div className="px-3 py-2 border-b bg-muted/50"> - <div className="text-sm font-medium"> - Changed Files ({summary.totalFiles}) + <FeatureErrorBoundary fallbackTitle="Diff viewer failed to render"> + <div className="flex gap-4 h-[500px]"> + {/* File tree */} + <div className="w-64 flex-shrink-0 border rounded-md overflow-hidden bg-card"> + <div className="px-3 py-2 border-b bg-muted/50"> + <div className="text-sm font-medium"> + Changed Files ({summary.totalFiles}) + </div> + <div className="text-xs text-muted-foreground"> + <span className="text-green-500">+{summary.totalAdditions}</span> + {' / '} + <span className="text-red-500">-{summary.totalDeletions}</span> + {comments.length > 0 && ( + <> + {' / '} + <span className="text-amber-500">{comments.length} comments</span> + </> + )} + </div> </div> - <div className="text-xs text-muted-foreground"> - <span className="text-green-500">+{summary.totalAdditions}</span> - {' / '} - <span className="text-red-500">-{summary.totalDeletions}</span> - {comments.length > 0 && ( - <> - {' / '} - <span className="text-amber-500">{comments.length} comments</span> - </> - )} + <div className="p-2 overflow-y-auto h-[calc(100%-60px)]"> + <FileTree + files={summary.files} + selectedFile={selectedFile} + onSelectFile={setSelectedFile} + comments={comments} + /> </div> </div> - <div className="p-2 overflow-y-auto h-[calc(100%-60px)]"> - <FileTree - files={summary.files} - selectedFile={selectedFile} - onSelectFile={setSelectedFile} - comments={comments} - /> - </div> - </div> - {/* Diff view */} - <div className="flex-1 overflow-y-auto"> - {selectedFile ? ( - <FileDiffView - taskId={task.id} - filePath={selectedFile} - comments={comments} - onAddComment={onAddComment} - onRemoveComment={onRemoveComment} - /> - ) : ( - <div className="flex items-center justify-center h-full text-muted-foreground border rounded-md"> - <ChevronRight className="h-5 w-5 mr-2" /> - Select a file to view changes - </div> - )} + {/* Diff view */} + <div className="flex-1 overflow-y-auto"> + {selectedFile ? ( + <FileDiffView + taskId={task.id} + filePath={selectedFile} + comments={comments} + onAddComment={onAddComment} + onRemoveComment={onRemoveComment} + /> + ) : ( + <div className="flex items-center justify-center h-full text-muted-foreground border rounded-md"> + <ChevronRight className="h-5 w-5 mr-2" /> + Select a file to view changes + </div> + )} + </div> </div> - </div> + </FeatureErrorBoundary> ); } diff --git a/web/src/components/task/GitSection.tsx b/web/src/components/task/GitSection.tsx index d8e6c4a0..83b9c9b8 100644 --- a/web/src/components/task/GitSection.tsx +++ b/web/src/components/task/GitSection.tsx @@ -1,518 +1,26 @@ -import { useState, useEffect, useMemo } from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Textarea } from '@/components/ui/textarea'; -import { Checkbox } from '@/components/ui/checkbox'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from '@/components/ui/alert-dialog'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; -import { useConfig, useRepoBranches } from '@/hooks/useConfig'; -import { - useWorktreeStatus, - useCreateWorktree, - useDeleteWorktree, - useRebaseWorktree, - useMergeWorktree -} from '@/hooks/useWorktree'; -import { useCreatePR, useGitHubStatus } from '@/hooks/useGitHub'; -import { useConflictStatus } from '@/hooks/useConflicts'; -import { ConflictResolver } from './ConflictResolver'; -import { - GitBranch, - FolderGit2, - Loader2, - AlertCircle, - Play, - ExternalLink, - RefreshCw, - GitMerge, - Trash2, - FileCode, - ArrowUp, - ArrowDown, - GitPullRequest, - AlertTriangle, -} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useConfig } from '@/hooks/useConfig'; +import { GitBranch, Loader2, AlertCircle } from 'lucide-react'; import type { Task, TaskGit } from '@veritas-kanban/shared'; -import { cn } from '@/lib/utils'; +import { GitSelectionForm } from './git/GitSelectionForm'; +import { WorktreeStatus } from './git/WorktreeStatus'; interface GitSectionProps { task: Task; onGitChange: (git: Partial<TaskGit> | undefined) => void; } -function slugify(text: string): string { - return text - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 40); -} - -function WorktreeStatus({ task }: { task: Task }) { - const hasWorktree = !!task.git?.worktreePath; - const hasPR = !!task.git?.prUrl; - const { data: status, isLoading, error } = useWorktreeStatus(task.id, hasWorktree); - const { data: ghStatus } = useGitHubStatus(); - const { data: conflictStatus } = useConflictStatus(hasWorktree ? task.id : undefined); - - const createWorktree = useCreateWorktree(); - const deleteWorktree = useDeleteWorktree(); - const rebaseWorktree = useRebaseWorktree(); - const mergeWorktree = useMergeWorktree(); - const createPR = useCreatePR(); - - // PR dialog state - const [prDialogOpen, setPrDialogOpen] = useState(false); - const [prTitle, setPrTitle] = useState(task.title); - const [prBody, setPrBody] = useState(task.description || ''); - const [prDraft, setPrDraft] = useState(false); - - // Conflict resolver state - const [conflictResolverOpen, setConflictResolverOpen] = useState(false); - - const handleOpenInVSCode = () => { - if (task.git?.worktreePath) { - // Use vscode:// protocol to open - window.open(`vscode://file/${task.git.worktreePath}`, '_blank'); - } - }; - - const handleOpenPR = () => { - if (task.git?.prUrl) { - window.open(task.git.prUrl, '_blank'); - } - }; - - const handleCreatePR = async () => { - try { - const result = await createPR.mutateAsync({ - taskId: task.id, - title: prTitle, - body: prBody, - draft: prDraft, - }); - setPrDialogOpen(false); - // Open the new PR in browser - window.open(result.url, '_blank'); - } catch (error) { - // Error is handled by mutation - } - }; - - if (!task.git?.repo || !task.git?.branch) { - return null; - } - - // No worktree yet - show create button - if (!hasWorktree) { - return ( - <div className="mt-3 pt-3 border-t"> - <Button - variant="outline" - className="w-full" - onClick={() => createWorktree.mutate(task.id)} - disabled={createWorktree.isPending} - > - {createWorktree.isPending ? ( - <Loader2 className="h-4 w-4 mr-2 animate-spin" /> - ) : ( - <Play className="h-4 w-4 mr-2" /> - )} - Create Worktree - </Button> - {createWorktree.error && ( - <p className="text-xs text-red-500 mt-2"> - {(createWorktree.error as Error).message} - </p> - )} - </div> - ); - } - - // Loading worktree status - if (isLoading) { - return ( - <div className="mt-3 pt-3 border-t"> - <div className="flex items-center gap-2 text-sm text-muted-foreground"> - <Loader2 className="h-4 w-4 animate-spin" /> - Loading worktree status... - </div> - </div> - ); - } - - // Error loading status - if (error) { - return ( - <div className="mt-3 pt-3 border-t"> - <div className="flex items-center gap-2 text-sm text-red-500"> - <AlertCircle className="h-4 w-4" /> - {(error as Error).message} - </div> - </div> - ); - } - - // Show worktree status - return ( - <div className="mt-3 pt-3 border-t space-y-3"> - {/* Conflict warning banner */} - {conflictStatus?.hasConflicts && ( - <div className="flex items-center justify-between gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20"> - <div className="flex items-center gap-2"> - <AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0" /> - <div> - <p className="text-sm font-medium text-amber-700 dark:text-amber-400"> - {conflictStatus.conflictingFiles.length} conflict{conflictStatus.conflictingFiles.length !== 1 ? 's' : ''} detected - </p> - <p className="text-xs text-amber-600 dark:text-amber-500"> - {conflictStatus.rebaseInProgress ? 'Rebase' : 'Merge'} requires manual resolution - </p> - </div> - </div> - <Button - size="sm" - variant="outline" - onClick={() => setConflictResolverOpen(true)} - className="border-amber-500/30 hover:bg-amber-500/10" - > - <AlertTriangle className="h-4 w-4 mr-1" /> - Resolve Conflicts - </Button> - </div> - )} - - {/* Status indicators */} - <div className="flex items-center justify-between text-sm"> - <div className="flex items-center gap-2"> - <span className={cn( - "h-2 w-2 rounded-full", - conflictStatus?.hasConflicts ? "bg-amber-500" : "bg-green-500" - )} /> - <span className="text-muted-foreground"> - {conflictStatus?.hasConflicts ? 'Conflicts detected' : 'Worktree active'} - </span> - </div> - <div className="flex items-center gap-3 text-xs text-muted-foreground"> - {status && ( - <> - {status.aheadBehind.ahead > 0 && ( - <span className="flex items-center gap-1"> - <ArrowUp className="h-3 w-3" /> - {status.aheadBehind.ahead} ahead - </span> - )} - {status.aheadBehind.behind > 0 && ( - <span className="flex items-center gap-1 text-amber-500"> - <ArrowDown className="h-3 w-3" /> - {status.aheadBehind.behind} behind - </span> - )} - {status.hasChanges && ( - <span className="flex items-center gap-1"> - <FileCode className="h-3 w-3" /> - {status.changedFiles} changed - </span> - )} - </> - )} - </div> - </div> - - {/* Action buttons */} - <div className="flex flex-wrap gap-2"> - <Button - variant="outline" - size="sm" - onClick={handleOpenInVSCode} - > - <ExternalLink className="h-3 w-3 mr-1" /> - Open in VS Code - </Button> - - {/* PR Button - show View PR if exists, Create PR if not */} - {hasPR ? ( - <Button - variant="outline" - size="sm" - onClick={handleOpenPR} - > - <GitPullRequest className="h-3 w-3 mr-1" /> - View PR #{task.git?.prNumber} - </Button> - ) : status && status.aheadBehind.ahead > 0 && ghStatus?.authenticated && ( - <Dialog open={prDialogOpen} onOpenChange={setPrDialogOpen}> - <DialogTrigger asChild> - <Button variant="outline" size="sm"> - <GitPullRequest className="h-3 w-3 mr-1" /> - Create PR - </Button> - </DialogTrigger> - <DialogContent className="sm:max-w-[500px]"> - <DialogHeader> - <DialogTitle>Create Pull Request</DialogTitle> - <DialogDescription> - Create a PR from {task.git?.branch} to {task.git?.baseBranch} - </DialogDescription> - </DialogHeader> - <div className="grid gap-4 py-4"> - <div className="grid gap-2"> - <Label htmlFor="pr-title">Title</Label> - <Input - id="pr-title" - value={prTitle} - onChange={(e) => setPrTitle(e.target.value)} - placeholder="PR title" - /> - </div> - <div className="grid gap-2"> - <Label htmlFor="pr-body">Description</Label> - <Textarea - id="pr-body" - value={prBody} - onChange={(e) => setPrBody(e.target.value)} - placeholder="Describe your changes..." - rows={5} - /> - </div> - <div className="flex items-center space-x-2"> - <Checkbox - id="pr-draft" - checked={prDraft} - onCheckedChange={(checked) => setPrDraft(checked === true)} - /> - <Label htmlFor="pr-draft" className="text-sm font-normal"> - Create as draft PR - </Label> - </div> - {createPR.error && ( - <p className="text-sm text-red-500"> - {(createPR.error as Error).message} - </p> - )} - </div> - <DialogFooter> - <Button variant="outline" onClick={() => setPrDialogOpen(false)}> - Cancel - </Button> - <Button onClick={handleCreatePR} disabled={createPR.isPending || !prTitle}> - {createPR.isPending ? ( - <> - <Loader2 className="h-4 w-4 mr-2 animate-spin" /> - Creating... - </> - ) : ( - <> - <GitPullRequest className="h-4 w-4 mr-2" /> - Create PR - </> - )} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - )} - - {status && status.aheadBehind.behind > 0 && ( - <Button - variant="outline" - size="sm" - onClick={() => rebaseWorktree.mutate(task.id)} - disabled={rebaseWorktree.isPending} - > - {rebaseWorktree.isPending ? ( - <Loader2 className="h-3 w-3 mr-1 animate-spin" /> - ) : ( - <RefreshCw className="h-3 w-3 mr-1" /> - )} - Rebase - </Button> - )} - - {status && status.aheadBehind.ahead > 0 && !status.hasChanges && ( - <AlertDialog> - <AlertDialogTrigger asChild> - <Button variant="default" size="sm"> - <GitMerge className="h-3 w-3 mr-1" /> - Merge - </Button> - </AlertDialogTrigger> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>Merge to {task.git?.baseBranch}?</AlertDialogTitle> - <AlertDialogDescription> - This will merge {task.git?.branch} into {task.git?.baseBranch}, push to remote, - delete the worktree, and mark the task as Done. - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel>Cancel</AlertDialogCancel> - <AlertDialogAction - onClick={() => mergeWorktree.mutate(task.id)} - disabled={mergeWorktree.isPending} - > - {mergeWorktree.isPending ? 'Merging...' : 'Merge & Complete'} - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - )} - - <AlertDialog> - <AlertDialogTrigger asChild> - <Button variant="ghost" size="sm" className="text-muted-foreground"> - <Trash2 className="h-3 w-3 mr-1" /> - Delete Worktree - </Button> - </AlertDialogTrigger> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>Delete worktree?</AlertDialogTitle> - <AlertDialogDescription> - {status?.hasChanges - ? 'Warning: This worktree has uncommitted changes that will be lost.' - : 'This will remove the worktree but keep the branch.'} - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel>Cancel</AlertDialogCancel> - <AlertDialogAction - onClick={() => deleteWorktree.mutate({ - taskId: task.id, - force: status?.hasChanges - })} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - Delete - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - </div> - - {/* Worktree path */} - <div className="text-xs text-muted-foreground font-mono truncate"> - {task.git.worktreePath} - </div> - - {/* Conflict Resolver */} - <ConflictResolver - task={task} - open={conflictResolverOpen} - onOpenChange={setConflictResolverOpen} - /> - </div> - ); -} - export function GitSection({ task, onGitChange }: GitSectionProps) { const { data: config, isLoading: configLoading } = useConfig(); - const [selectedRepo, setSelectedRepo] = useState<string>(task.git?.repo || ''); - const [baseBranch, setBaseBranch] = useState<string>(task.git?.baseBranch || ''); - const [featureBranch, setFeatureBranch] = useState<string>(task.git?.branch || ''); - const [autoGenerateBranch, setAutoGenerateBranch] = useState(!task.git?.branch); - - const { data: branches, isLoading: branchesLoading } = useRepoBranches(selectedRepo || undefined); - - // Get the repo config for the selected repo - const repoConfig = useMemo(() => { - return config?.repos.find(r => r.name === selectedRepo); - }, [config, selectedRepo]); - - // Auto-generate feature branch name from task title - useEffect(() => { - if (autoGenerateBranch && task.title) { - const slug = slugify(task.title); - setFeatureBranch(`feature/${slug}`); - } - }, [task.title, autoGenerateBranch]); - - // Set default base branch when repo changes - useEffect(() => { - if (repoConfig && !baseBranch) { - setBaseBranch(repoConfig.defaultBranch); - } - }, [repoConfig, baseBranch]); - - // Sync from task.git when it changes - useEffect(() => { - if (task.git) { - setSelectedRepo(task.git.repo || ''); - setBaseBranch(task.git.baseBranch || ''); - setFeatureBranch(task.git.branch || ''); - setAutoGenerateBranch(!task.git.branch); - } - }, [task.id]); // Only re-sync when task changes - - // Update parent when values change - const handleRepoChange = (repo: string) => { - setSelectedRepo(repo); - const newRepoConfig = config?.repos.find(r => r.name === repo); - const newBaseBranch = newRepoConfig?.defaultBranch || 'main'; - setBaseBranch(newBaseBranch); - - onGitChange({ - repo, - baseBranch: newBaseBranch, - branch: featureBranch, - }); - }; - - const handleBaseBranchChange = (branch: string) => { - setBaseBranch(branch); - onGitChange({ - repo: selectedRepo, - baseBranch: branch, - branch: featureBranch, - }); - }; - - const handleFeatureBranchChange = (branch: string) => { - setFeatureBranch(branch); - setAutoGenerateBranch(false); - onGitChange({ - repo: selectedRepo, - baseBranch, - branch, - }); - }; const handleClearGit = () => { - setSelectedRepo(''); - setBaseBranch(''); - setFeatureBranch(''); - setAutoGenerateBranch(true); onGitChange(undefined); }; // Don't allow editing if worktree exists const isLocked = !!task.git?.worktreePath; + const selectedRepo = task.git?.repo; if (configLoading) { return ( @@ -563,83 +71,8 @@ export function GitSection({ task, onGitChange }: GitSectionProps) { )} </div> - <div className="grid gap-3 p-3 rounded-md border bg-muted/30"> - {/* Repository Selection */} - <div className="grid gap-1.5"> - <Label className="text-xs">Repository</Label> - <Select - value={selectedRepo} - onValueChange={handleRepoChange} - disabled={isLocked} - > - <SelectTrigger className={cn(isLocked && 'opacity-60')}> - <SelectValue placeholder="Select repository..." /> - </SelectTrigger> - <SelectContent> - {config.repos.map((repo) => ( - <SelectItem key={repo.name} value={repo.name}> - <div className="flex items-center gap-2"> - <FolderGit2 className="h-3 w-3" /> - {repo.name} - </div> - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - - {selectedRepo && ( - <> - {/* Base Branch */} - <div className="grid gap-1.5"> - <Label className="text-xs">Base Branch</Label> - {branchesLoading ? ( - <div className="flex items-center gap-2 text-sm text-muted-foreground h-9 px-3"> - <Loader2 className="h-3 w-3 animate-spin" /> - Loading branches... - </div> - ) : ( - <Select - value={baseBranch} - onValueChange={handleBaseBranchChange} - disabled={isLocked} - > - <SelectTrigger className={cn(isLocked && 'opacity-60')}> - <SelectValue placeholder="Select base branch..." /> - </SelectTrigger> - <SelectContent> - {branches?.map((branch) => ( - <SelectItem key={branch} value={branch}> - {branch} - </SelectItem> - ))} - </SelectContent> - </Select> - )} - </div> - - {/* Feature Branch */} - <div className="grid gap-1.5"> - <Label className="text-xs">Feature Branch</Label> - <Input - value={featureBranch} - onChange={(e) => handleFeatureBranchChange(e.target.value)} - placeholder="feature/my-feature" - disabled={isLocked} - className={cn(isLocked && 'opacity-60')} - /> - {autoGenerateBranch && featureBranch && !isLocked && ( - <p className="text-xs text-muted-foreground"> - Auto-generated from task title - </p> - )} - </div> - - {/* Worktree Status */} - <WorktreeStatus task={task} /> - </> - )} - </div> + <GitSelectionForm task={task} onGitChange={onGitChange} /> + <WorktreeStatus task={task} /> </div> ); } diff --git a/web/src/components/task/TaskCard.tsx b/web/src/components/task/TaskCard.tsx index d60c39ca..9f09a9c9 100644 --- a/web/src/components/task/TaskCard.tsx +++ b/web/src/components/task/TaskCard.tsx @@ -7,7 +7,7 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; -import type { Task, TaskPriority, TaskTypeConfig, ProjectConfig, SprintConfig } from '@veritas-kanban/shared'; +import type { Task, TaskPriority } from '@veritas-kanban/shared'; import { Check, Ban, Clock, Timer, Loader2, Paperclip, ListChecks, Zap } from 'lucide-react'; import { useBulkActions } from '@/hooks/useBulkActions'; import { formatDuration } from '@/hooks/useTimeTracking'; @@ -15,6 +15,7 @@ import { getTypeIcon, getTypeColor } from '@/hooks/useTaskTypes'; import { getProjectColor, getProjectLabel } from '@/hooks/useProjects'; import { getSprintLabel } from '@/hooks/useSprints'; import { useFeatureSettings } from '@/hooks/useFeatureSettings'; +import { useTaskConfig } from '@/contexts/TaskConfigContext'; const agentNames: Record<string, string> = { 'claude-code': 'Claude', @@ -31,9 +32,6 @@ interface TaskCardProps { isSelected?: boolean; isBlocked?: boolean; blockerTitles?: string[]; - taskTypes?: TaskTypeConfig[]; - projects?: ProjectConfig[]; - sprints?: SprintConfig[]; } const priorityColors: Record<TaskPriority, string> = { @@ -42,7 +40,8 @@ const priorityColors: Record<TaskPriority, string> = { low: 'bg-slate-500/20 text-slate-400', }; -export function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blockerTitles, taskTypes = [], projects = [], sprints = [] }: TaskCardProps) { +export function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blockerTitles }: TaskCardProps) { + const { taskTypes, projects, sprints } = useTaskConfig(); const { attributes, listeners, @@ -107,6 +106,8 @@ export function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blo {...listeners} {...attributes} onClick={handleClick} + role="article" + aria-label={`Task: ${task.title}, Status: ${task.status}, Priority: ${task.priority}`} className={cn( 'group bg-card border border-border rounded-md cursor-grab active:cursor-grabbing', isCompact ? 'p-2' : 'p-3', @@ -119,10 +120,12 @@ export function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blo isAgentRunning && 'ring-2 ring-blue-500/50 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.3)]' )} > + <span className="sr-only">Status: {task.status}</span> <div className="flex items-start gap-2"> {isSelecting && ( <button onClick={handleCheckboxClick} + aria-label={isChecked ? 'Deselect task' : 'Select task'} className={cn( 'h-4 w-4 rounded border-2 flex items-center justify-center flex-shrink-0 mt-0.5 transition-colors', isChecked @@ -154,6 +157,7 @@ export function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blo <Tooltip> <TooltipTrigger asChild> <span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 flex items-center gap-1 animate-pulse"> + <span className="sr-only">Agent {agentNames[task.attempt?.agent || ''] || task.attempt?.agent} is actively running on this task</span> <Loader2 className="h-3 w-3 animate-spin" /> {agentNames[task.attempt?.agent || ''] || 'Agent'} running </span> diff --git a/web/src/components/task/TaskDetailPanel.tsx b/web/src/components/task/TaskDetailPanel.tsx index d32b8b4d..c8f8758a 100644 --- a/web/src/components/task/TaskDetailPanel.tsx +++ b/web/src/components/task/TaskDetailPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState } from 'react'; import { Sheet, SheetContent, @@ -11,47 +11,23 @@ import { TabsList, TabsTrigger, } from '@/components/ui/tabs'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { useUpdateTask, useDeleteTask } from '@/hooks/useTasks'; -import { Trash2, Calendar, Clock, GitBranch, Bot, FileDiff, ClipboardCheck, Monitor, FileCode, Paperclip, Archive, RotateCcw } from 'lucide-react'; -import type { Task, TaskStatus, TaskPriority, ReviewComment, ReviewState } from '@veritas-kanban/shared'; import { useTaskTypes, getTypeIcon } from '@/hooks/useTaskTypes'; -import { useProjects } from '@/hooks/useProjects'; -import { useSprints } from '@/hooks/useSprints'; import { useFeatureSettings } from '@/hooks/useFeatureSettings'; +import { useDebouncedSave } from '@/hooks/useDebouncedSave'; +import { TaskDetailsTab } from './detail/TaskDetailsTab'; import { GitSection } from './GitSection'; import { AgentPanel } from './AgentPanel'; import { DiffViewer } from './DiffViewer'; import { ReviewPanel } from './ReviewPanel'; -import { SubtasksSection } from './SubtasksSection'; -import { DependenciesSection } from './DependenciesSection'; import { PreviewPanel } from './PreviewPanel'; -import { TimeTrackingSection } from './TimeTrackingSection'; -import { CommentsSection } from './CommentsSection'; import { AttachmentsSection } from './AttachmentsSection'; import { ApplyTemplateDialog } from './ApplyTemplateDialog'; +import FeatureErrorBoundary from '@/components/shared/FeatureErrorBoundary'; +import { GitBranch, Bot, FileDiff, ClipboardCheck, Monitor, FileCode, Paperclip, Archive } from 'lucide-react'; +import type { Task, ReviewComment, ReviewState } from '@veritas-kanban/shared'; interface TaskDetailPanelProps { task: Task | null; @@ -61,75 +37,15 @@ interface TaskDetailPanelProps { onRestore?: (taskId: string) => void; } -const statusLabels: Record<TaskStatus, string> = { - 'todo': 'To Do', - 'in-progress': 'In Progress', - 'review': 'Review', - 'done': 'Done', -}; - -const priorityLabels: Record<TaskPriority, string> = { - low: 'Low', - medium: 'Medium', - high: 'High', -}; - -function useDebouncedSave(task: Task | null, updateTask: ReturnType<typeof useUpdateTask>) { - const [localTask, setLocalTask] = useState<Task | null>(task); - const [changedFields, setChangedFields] = useState<Set<keyof Task>>(new Set()); - - // Sync from server when task prop changes (e.g., refetch) - useEffect(() => { - setLocalTask(task); - setChangedFields(new Set()); - }, [task]); - - // Debounced save - only send fields that were actually changed - useEffect(() => { - if (changedFields.size === 0 || !localTask) return; - - const timeout = setTimeout(() => { - // Build input with only changed fields - const input: Record<string, unknown> = {}; - changedFields.forEach(field => { - input[field] = localTask[field]; - }); - - updateTask.mutate({ - id: localTask.id, - input, - }); - setChangedFields(new Set()); - }, 500); - - return () => clearTimeout(timeout); - }, [localTask, changedFields, updateTask]); - - const updateField = useCallback(<K extends keyof Task>(field: K, value: Task[K]) => { - setLocalTask(prev => prev ? { ...prev, [field]: value } : null); - setChangedFields(prev => new Set(prev).add(field)); - }, []); - - const isDirty = changedFields.size > 0; - - return { localTask, updateField, isDirty }; -} - export function TaskDetailPanel({ task, open, onOpenChange, readOnly = false, onRestore }: TaskDetailPanelProps) { - const updateTask = useUpdateTask(); - const deleteTask = useDeleteTask(); const { data: taskTypes = [] } = useTaskTypes(); - const { data: projects = [] } = useProjects(); - const { data: sprints = [] } = useSprints(); const { settings: featureSettings } = useFeatureSettings(); const taskSettings = featureSettings.tasks; const agentSettings = featureSettings.agents; - const { localTask, updateField, isDirty } = useDebouncedSave(task, updateTask); + const { localTask, updateField, isDirty } = useDebouncedSave(task); const [activeTab, setActiveTab] = useState('details'); const [previewOpen, setPreviewOpen] = useState(false); const [applyTemplateOpen, setApplyTemplateOpen] = useState(false); - const [showNewProject, setShowNewProject] = useState(false); - const [newProjectName, setNewProjectName] = useState(''); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -141,24 +57,8 @@ export function TaskDetailPanel({ task, open, onOpenChange, readOnly = false, on return () => document.removeEventListener('keydown', handleKeyDown); }, [open, onOpenChange]); - const handleDelete = async () => { - if (!task) return; - await deleteTask.mutateAsync(task.id); - onOpenChange(false); - }; - if (!localTask) return null; - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: '2-digit', - }); - }; - const isCodeTask = localTask.type === 'code'; const hasWorktree = !!localTask.git?.worktreePath; @@ -255,346 +155,77 @@ export function TaskDetailPanel({ task, open, onOpenChange, readOnly = false, on <div className="flex-1 overflow-y-auto mt-4"> {/* Details Tab */} - <TabsContent value="details" className="mt-0 space-y-6"> - <div className="space-y-2"> - <Label className="text-muted-foreground">Description</Label> - {readOnly ? ( - <div className="text-sm whitespace-pre-wrap text-foreground/80 bg-muted/30 rounded-md p-3 min-h-[60px]"> - {localTask.description || 'No description'} - </div> - ) : ( - <Textarea - value={localTask.description} - onChange={(e) => updateField('description', e.target.value)} - placeholder="Add a description..." - rows={4} - className="resize-none" - /> - )} - </div> - - <div className="grid grid-cols-3 gap-4"> - <div className="space-y-2"> - <Label className="text-muted-foreground">Status</Label> - {readOnly ? ( - <div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md"> - {statusLabels[localTask.status]} - </div> - ) : ( - <Select - value={localTask.status} - onValueChange={(v) => updateField('status', v as TaskStatus)} - > - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {Object.entries(statusLabels).map(([value, label]) => ( - <SelectItem key={value} value={value}>{label}</SelectItem> - ))} - </SelectContent> - </Select> - )} - </div> - - <div className="space-y-2"> - <Label className="text-muted-foreground">Type</Label> - {readOnly ? ( - <div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md"> - {typeLabel} - </div> - ) : ( - <Select - value={localTask.type} - onValueChange={(v) => updateField('type', v)} - > - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {taskTypes.map((type) => { - const IconComponent = getTypeIcon(type.icon); - return ( - <SelectItem key={type.id} value={type.id}> - <div className="flex items-center gap-2"> - {IconComponent && <IconComponent className="h-4 w-4" />} - {type.label} - </div> - </SelectItem> - ); - })} - </SelectContent> - </Select> - )} - </div> - - <div className="space-y-2"> - <Label className="text-muted-foreground">Priority</Label> - {readOnly ? ( - <div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md capitalize"> - {localTask.priority} - </div> - ) : ( - <Select - value={localTask.priority} - onValueChange={(v) => updateField('priority', v as TaskPriority)} - > - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {Object.entries(priorityLabels).map(([value, label]) => ( - <SelectItem key={value} value={value}>{label}</SelectItem> - ))} - </SelectContent> - </Select> - )} - </div> - </div> - - <div className="space-y-2"> - <Label className="text-muted-foreground">Project</Label> - {readOnly ? ( - <div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md"> - {(projects.find(p => p.id === localTask.project)?.label) || localTask.project || 'No project'} - </div> - ) : !showNewProject ? ( - <Select - value={localTask.project || '__none__'} - onValueChange={(value) => { - if (value === '__new__') { - setShowNewProject(true); - setNewProjectName(''); - } else if (value === '__none__') { - updateField('project', undefined); - } else { - updateField('project', value); - } - }} - > - <SelectTrigger> - <SelectValue placeholder="Select project..." /> - </SelectTrigger> - <SelectContent> - <SelectItem value="__none__">No project</SelectItem> - {projects.map((proj) => ( - <SelectItem key={proj.id} value={proj.id}> - {proj.label} - </SelectItem> - ))} - <SelectItem value="__new__" className="text-primary"> - + New Project - </SelectItem> - </SelectContent> - </Select> - ) : ( - <div className="flex gap-2"> - <Input - value={newProjectName} - onChange={(e) => setNewProjectName(e.target.value)} - placeholder="Enter project name..." - autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter' && newProjectName.trim()) { - e.preventDefault(); - updateField('project', newProjectName.trim()); - setShowNewProject(false); - } - if (e.key === 'Escape') { - setShowNewProject(false); - setNewProjectName(''); - } - }} - /> - <Button - type="button" - size="sm" - onClick={() => { - if (newProjectName.trim()) { - updateField('project', newProjectName.trim()); - setShowNewProject(false); - } - }} - > - Add - </Button> - <Button - type="button" - size="sm" - variant="outline" - onClick={() => { - setShowNewProject(false); - setNewProjectName(''); - }} - > - Cancel - </Button> - </div> - )} - </div> - - <div className="space-y-2"> - <Label className="text-muted-foreground">Sprint</Label> - {readOnly ? ( - <div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md"> - {(sprints.find(s => s.id === localTask.sprint)?.label) || localTask.sprint || 'No sprint'} - </div> - ) : ( - <Select - value={localTask.sprint || '__none__'} - onValueChange={(value) => updateField('sprint', value === '__none__' ? undefined : value)} - > - <SelectTrigger> - <SelectValue placeholder="No sprint" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="__none__">No Sprint</SelectItem> - {sprints.map(s => ( - <SelectItem key={s.id} value={s.id}>{s.label}</SelectItem> - ))} - </SelectContent> - </Select> - )} - </div> - - {/* Subtasks */} - <div className="border-t pt-4"> - <SubtasksSection - task={localTask} - onAutoCompleteChange={(value) => updateField('autoCompleteOnSubtasks', value || undefined)} - /> - </div> - - {/* Dependencies */} - {taskSettings.enableDependencies && ( - <div className="border-t pt-4"> - <DependenciesSection - task={localTask} - onBlockedByChange={(blockedBy) => updateField('blockedBy', blockedBy)} - /> - </div> - )} - - {/* Time Tracking */} - {taskSettings.enableTimeTracking && ( - <div className="border-t pt-4"> - <TimeTrackingSection task={localTask} /> - </div> - )} - - {/* Comments */} - {taskSettings.enableComments && ( - <div className="border-t pt-4"> - <CommentsSection task={localTask} /> - </div> - )} - - <div className="border-t pt-4 space-y-2 text-sm text-muted-foreground"> - <div className="flex items-center gap-2"> - <Calendar className="h-4 w-4" /> - <span>Created: {formatDate(localTask.created)}</span> - </div> - <div className="flex items-center gap-2"> - <Clock className="h-4 w-4" /> - <span>Updated: {formatDate(localTask.updated)}</span> - </div> - <div className="text-xs font-mono opacity-50">ID: {localTask.id}</div> - </div> - - <div className="border-t pt-4"> - {readOnly && onRestore ? ( - <Button - variant="default" - className="w-full" - onClick={() => onRestore(localTask.id)} - > - <RotateCcw className="h-4 w-4 mr-2" /> - Restore to Board - </Button> - ) : !readOnly && ( - <AlertDialog> - <AlertDialogTrigger asChild> - <Button variant="destructive" className="w-full"> - <Trash2 className="h-4 w-4 mr-2" /> - Delete Task - </Button> - </AlertDialogTrigger> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>Delete this task?</AlertDialogTitle> - <AlertDialogDescription> - This will permanently delete "{localTask.title}". - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel>Cancel</AlertDialogCancel> - <AlertDialogAction - onClick={handleDelete} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - Delete - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - )} - </div> + <TabsContent value="details" className="mt-0"> + <TaskDetailsTab + task={localTask} + onUpdate={updateField} + onClose={() => onOpenChange(false)} + readOnly={readOnly} + onRestore={onRestore} + /> </TabsContent> {/* Attachments Tab */} {taskSettings.enableAttachments && ( <TabsContent value="attachments" className="mt-0"> - <AttachmentsSection task={localTask} /> + <FeatureErrorBoundary fallbackTitle="Attachments section failed to load"> + <AttachmentsSection task={localTask} /> + </FeatureErrorBoundary> </TabsContent> )} {/* Git Tab */} {isCodeTask && ( <TabsContent value="git" className="mt-0"> - <GitSection - task={localTask} - onGitChange={(git) => updateField('git', git as Task['git'])} - /> + <FeatureErrorBoundary fallbackTitle="Git section failed to load"> + <GitSection + task={localTask} + onGitChange={(git) => updateField('git', git as Task['git'])} + /> + </FeatureErrorBoundary> </TabsContent> )} {/* Agent Tab */} {isCodeTask && ( <TabsContent value="agent" className="mt-0"> - <AgentPanel task={localTask} /> + <FeatureErrorBoundary fallbackTitle="Agent panel failed to load"> + <AgentPanel task={localTask} /> + </FeatureErrorBoundary> </TabsContent> )} {/* Changes Tab */} {isCodeTask && hasWorktree && ( <TabsContent value="changes" className="mt-0"> - <DiffViewer - task={localTask} - onAddComment={(comment: ReviewComment) => { - const newComments = [...(localTask.reviewComments || []), comment]; - updateField('reviewComments', newComments); - }} - onRemoveComment={(commentId: string) => { - const newComments = (localTask.reviewComments || []).filter(c => c.id !== commentId); - updateField('reviewComments', newComments.length > 0 ? newComments : undefined); - }} - /> + <FeatureErrorBoundary fallbackTitle="Changes viewer failed to load"> + <DiffViewer + task={localTask} + onAddComment={(comment: ReviewComment) => { + const newComments = [...(localTask.reviewComments || []), comment]; + updateField('reviewComments', newComments); + }} + onRemoveComment={(commentId: string) => { + const newComments = (localTask.reviewComments || []).filter(c => c.id !== commentId); + updateField('reviewComments', newComments.length > 0 ? newComments : undefined); + }} + /> + </FeatureErrorBoundary> </TabsContent> )} {/* Review Tab */} {isCodeTask && ( <TabsContent value="review" className="mt-0"> - <ReviewPanel - task={localTask} - onReview={(review: ReviewState) => { - updateField('review', Object.keys(review).length > 0 ? review : undefined); - }} - onMergeComplete={() => { - // Close the panel after successful merge - onOpenChange(false); - }} - /> + <FeatureErrorBoundary fallbackTitle="Review panel failed to load"> + <ReviewPanel + task={localTask} + onReview={(review: ReviewState) => { + updateField('review', Object.keys(review).length > 0 ? review : undefined); + }} + onMergeComplete={() => onOpenChange(false)} + /> + </FeatureErrorBoundary> </TabsContent> )} </div> diff --git a/web/src/components/task/create/BlueprintPreview.tsx b/web/src/components/task/create/BlueprintPreview.tsx new file mode 100644 index 00000000..cb2b2718 --- /dev/null +++ b/web/src/components/task/create/BlueprintPreview.tsx @@ -0,0 +1,44 @@ +import { Label } from '@/components/ui/label'; +import { AlertCircle } from 'lucide-react'; +import type { TaskTemplate } from '@/hooks/useTemplates'; + +interface BlueprintPreviewProps { + template: TaskTemplate; +} + +export function BlueprintPreview({ template }: BlueprintPreviewProps) { + if (!template.blueprint || template.blueprint.length === 0) { + return null; + } + + return ( + <div className="border rounded-md p-3 bg-muted/30"> + <div className="flex items-center gap-2 mb-2"> + <AlertCircle className="h-4 w-4 text-blue-500" /> + <Label className="text-sm font-medium">Blueprint: Multiple Tasks</Label> + </div> + <p className="text-xs text-muted-foreground mb-3"> + This template will create {template.blueprint.length} linked tasks. + </p> + <div className="space-y-2"> + {template.blueprint.map((bt, idx) => ( + <div key={bt.refId} className="text-sm border-l-2 border-primary/50 pl-3 py-1"> + <div className="font-medium"> + {idx + 1}. {bt.title} + </div> + {bt.blockedByRefs && bt.blockedByRefs.length > 0 && ( + <div className="text-xs text-muted-foreground"> + Blocked by: {bt.blockedByRefs.join(', ')} + </div> + )} + {bt.subtaskTemplates && bt.subtaskTemplates.length > 0 && ( + <div className="text-xs text-muted-foreground"> + {bt.subtaskTemplates.length} subtasks + </div> + )} + </div> + ))} + </div> + </div> + ); +} diff --git a/web/src/components/task/create/TemplateVariableInputs.tsx b/web/src/components/task/create/TemplateVariableInputs.tsx new file mode 100644 index 00000000..ce810666 --- /dev/null +++ b/web/src/components/task/create/TemplateVariableInputs.tsx @@ -0,0 +1,38 @@ +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { AlertCircle } from 'lucide-react'; + +interface TemplateVariableInputsProps { + variables: string[]; + values: Record<string, string>; + onChange: (name: string, value: string) => void; +} + +export function TemplateVariableInputs({ variables, values, onChange }: TemplateVariableInputsProps) { + if (variables.length === 0) { + return null; + } + + return ( + <div className="grid gap-3 border rounded-md p-3 bg-muted/30"> + <div className="flex items-center gap-2"> + <AlertCircle className="h-4 w-4 text-blue-500" /> + <Label className="text-sm font-medium">Template Variables</Label> + </div> + {variables.map((varName) => ( + <div key={varName} className="grid gap-1.5"> + <Label htmlFor={`var-${varName}`} className="text-xs"> + {varName} + </Label> + <Input + id={`var-${varName}`} + value={values[varName] || ''} + onChange={(e) => onChange(varName, e.target.value)} + placeholder={`Enter ${varName}...`} + className="h-8" + /> + </div> + ))} + </div> + ); +} diff --git a/web/src/components/task/detail/TaskDetailsTab.tsx b/web/src/components/task/detail/TaskDetailsTab.tsx new file mode 100644 index 00000000..0dcbac27 --- /dev/null +++ b/web/src/components/task/detail/TaskDetailsTab.tsx @@ -0,0 +1,172 @@ +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Button } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { TaskMetadataSection } from './TaskMetadataSection'; +import { SubtasksSection } from '../SubtasksSection'; +import { DependenciesSection } from '../DependenciesSection'; +import { TimeTrackingSection } from '../TimeTrackingSection'; +import { CommentsSection } from '../CommentsSection'; +import { useDeleteTask } from '@/hooks/useTasks'; +import { useFeatureSettings } from '@/hooks/useFeatureSettings'; +import { Trash2, Calendar, Clock, RotateCcw } from 'lucide-react'; +import type { Task } from '@veritas-kanban/shared'; + +interface TaskDetailsTabProps { + task: Task; + onUpdate: <K extends keyof Task>(field: K, value: Task[K]) => void; + onClose: () => void; + readOnly?: boolean; + onRestore?: (taskId: string) => void; +} + +export function TaskDetailsTab({ + task, + onUpdate, + onClose, + readOnly = false, + onRestore, +}: TaskDetailsTabProps) { + const deleteTask = useDeleteTask(); + const { settings: featureSettings } = useFeatureSettings(); + const taskSettings = featureSettings.tasks; + + const handleDelete = async () => { + await deleteTask.mutateAsync(task.id); + onClose(); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + }; + + return ( + <div className="space-y-6"> + {/* Description */} + <div className="space-y-2"> + <Label className="text-muted-foreground">Description</Label> + {readOnly ? ( + <div className="text-sm whitespace-pre-wrap text-foreground/80 bg-muted/30 rounded-md p-3 min-h-[60px]"> + {task.description || 'No description'} + </div> + ) : ( + <Textarea + value={task.description} + onChange={(e) => onUpdate('description', e.target.value)} + placeholder="Add a description..." + rows={4} + className="resize-none" + /> + )} + </div> + + {/* Metadata Section */} + <TaskMetadataSection + task={task} + onUpdate={onUpdate} + readOnly={readOnly} + /> + + {/* Subtasks */} + <div className="border-t pt-4"> + <SubtasksSection + task={task} + onAutoCompleteChange={(value) => onUpdate('autoCompleteOnSubtasks', value || undefined)} + /> + </div> + + {/* Dependencies */} + {taskSettings.enableDependencies && ( + <div className="border-t pt-4"> + <DependenciesSection + task={task} + onBlockedByChange={(blockedBy) => onUpdate('blockedBy', blockedBy)} + /> + </div> + )} + + {/* Time Tracking */} + {taskSettings.enableTimeTracking && ( + <div className="border-t pt-4"> + <TimeTrackingSection task={task} /> + </div> + )} + + {/* Comments */} + {taskSettings.enableComments && ( + <div className="border-t pt-4"> + <CommentsSection task={task} /> + </div> + )} + + {/* Metadata Footer */} + <div className="border-t pt-4 space-y-2 text-sm text-muted-foreground"> + <div className="flex items-center gap-2"> + <Calendar className="h-4 w-4" /> + <span>Created: {formatDate(task.created)}</span> + </div> + <div className="flex items-center gap-2"> + <Clock className="h-4 w-4" /> + <span>Updated: {formatDate(task.updated)}</span> + </div> + <div className="text-xs font-mono opacity-50">ID: {task.id}</div> + </div> + + {/* Delete/Restore Button */} + <div className="border-t pt-4"> + {readOnly && onRestore ? ( + <Button + variant="default" + className="w-full" + onClick={() => onRestore(task.id)} + > + <RotateCcw className="h-4 w-4 mr-2" /> + Restore to Board + </Button> + ) : !readOnly && ( + <AlertDialog> + <AlertDialogTrigger asChild> + <Button variant="destructive" className="w-full"> + <Trash2 className="h-4 w-4 mr-2" /> + Delete Task + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Delete this task?</AlertDialogTitle> + <AlertDialogDescription> + This will permanently delete "{task.title}". + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction + onClick={handleDelete} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + )} + </div> + </div> + ); +} diff --git a/web/src/components/task/detail/TaskMetadataSection.tsx b/web/src/components/task/detail/TaskMetadataSection.tsx new file mode 100644 index 00000000..c9b96fef --- /dev/null +++ b/web/src/components/task/detail/TaskMetadataSection.tsx @@ -0,0 +1,237 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useTaskTypes, getTypeIcon } from '@/hooks/useTaskTypes'; +import { useProjects } from '@/hooks/useProjects'; +import { useSprints } from '@/hooks/useSprints'; +import type { Task, TaskStatus, TaskPriority } from '@veritas-kanban/shared'; + +interface TaskMetadataSectionProps { + task: Task; + onUpdate: <K extends keyof Task>(field: K, value: Task[K]) => void; + readOnly?: boolean; +} + +const statusLabels: Record<TaskStatus, string> = { + 'todo': 'To Do', + 'in-progress': 'In Progress', + 'review': 'Review', + 'done': 'Done', +}; + +const priorityLabels: Record<TaskPriority, string> = { + low: 'Low', + medium: 'Medium', + high: 'High', +}; + +export function TaskMetadataSection({ task, onUpdate, readOnly = false }: TaskMetadataSectionProps) { + const { data: taskTypes = [] } = useTaskTypes(); + const { data: projects = [] } = useProjects(); + const { data: sprints = [] } = useSprints(); + const [showNewProject, setShowNewProject] = useState(false); + const [newProjectName, setNewProjectName] = useState(''); + + // Get current type info + const currentType = taskTypes.find(t => t.id === task.type); + const typeLabel = currentType ? currentType.label : task.type; + + return ( + <div className="space-y-4"> + {/* Status, Type, Priority */} + <div className="grid grid-cols-3 gap-4"> + <div className="space-y-2"> + <Label className="text-muted-foreground">Status</Label> + {readOnly ? ( + <div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md"> + {statusLabels[task.status]} + </div> + ) : ( + <Select + value={task.status} + onValueChange={(v) => onUpdate('status', v as TaskStatus)} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {Object.entries(statusLabels).map(([value, label]) => ( + <SelectItem key={value} value={value}>{label}</SelectItem> + ))} + </SelectContent> + </Select> + )} + </div> + + <div className="space-y-2"> + <Label className="text-muted-foreground">Type</Label> + {readOnly ? ( + <div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md"> + {typeLabel} + </div> + ) : ( + <Select + value={task.type} + onValueChange={(v) => onUpdate('type', v)} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {taskTypes.map((type) => { + const IconComponent = getTypeIcon(type.icon); + return ( + <SelectItem key={type.id} value={type.id}> + <div className="flex items-center gap-2"> + {IconComponent && <IconComponent className="h-4 w-4" />} + {type.label} + </div> + </SelectItem> + ); + })} + </SelectContent> + </Select> + )} + </div> + + <div className="space-y-2"> + <Label className="text-muted-foreground">Priority</Label> + {readOnly ? ( + <div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md capitalize"> + {task.priority} + </div> + ) : ( + <Select + value={task.priority} + onValueChange={(v) => onUpdate('priority', v as TaskPriority)} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {Object.entries(priorityLabels).map(([value, label]) => ( + <SelectItem key={value} value={value}>{label}</SelectItem> + ))} + </SelectContent> + </Select> + )} + </div> + </div> + + {/* Project */} + <div className="space-y-2"> + <Label className="text-muted-foreground">Project</Label> + {readOnly ? ( + <div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md"> + {(projects.find(p => p.id === task.project)?.label) || task.project || 'No project'} + </div> + ) : !showNewProject ? ( + <Select + value={task.project || '__none__'} + onValueChange={(value) => { + if (value === '__new__') { + setShowNewProject(true); + setNewProjectName(''); + } else if (value === '__none__') { + onUpdate('project', undefined); + } else { + onUpdate('project', value); + } + }} + > + <SelectTrigger> + <SelectValue placeholder="Select project..." /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__none__">No project</SelectItem> + {projects.map((proj) => ( + <SelectItem key={proj.id} value={proj.id}> + {proj.label} + </SelectItem> + ))} + <SelectItem value="__new__" className="text-primary"> + + New Project + </SelectItem> + </SelectContent> + </Select> + ) : ( + <div className="flex gap-2"> + <Input + value={newProjectName} + onChange={(e) => setNewProjectName(e.target.value)} + placeholder="Enter project name..." + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter' && newProjectName.trim()) { + e.preventDefault(); + onUpdate('project', newProjectName.trim()); + setShowNewProject(false); + } + if (e.key === 'Escape') { + setShowNewProject(false); + setNewProjectName(''); + } + }} + /> + <Button + type="button" + size="sm" + onClick={() => { + if (newProjectName.trim()) { + onUpdate('project', newProjectName.trim()); + setShowNewProject(false); + } + }} + > + Add + </Button> + <Button + type="button" + size="sm" + variant="outline" + onClick={() => { + setShowNewProject(false); + setNewProjectName(''); + }} + > + Cancel + </Button> + </div> + )} + </div> + + {/* Sprint */} + <div className="space-y-2"> + <Label className="text-muted-foreground">Sprint</Label> + {readOnly ? ( + <div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md"> + {(sprints.find(s => s.id === task.sprint)?.label) || task.sprint || 'No sprint'} + </div> + ) : ( + <Select + value={task.sprint || '__none__'} + onValueChange={(value) => onUpdate('sprint', value === '__none__' ? undefined : value)} + > + <SelectTrigger> + <SelectValue placeholder="No sprint" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__none__">No Sprint</SelectItem> + {sprints.map(s => ( + <SelectItem key={s.id} value={s.id}>{s.label}</SelectItem> + ))} + </SelectContent> + </Select> + )} + </div> + </div> + ); +} diff --git a/web/src/components/task/diff/DiffHunk.tsx b/web/src/components/task/diff/DiffHunk.tsx new file mode 100644 index 00000000..8b0ef052 --- /dev/null +++ b/web/src/components/task/diff/DiffHunk.tsx @@ -0,0 +1,46 @@ +import { DiffLineView } from './DiffLine'; +import type { DiffHunk } from '@/lib/api'; +import type { ReviewComment } from '@veritas-kanban/shared'; + +interface DiffHunkProps { + hunk: DiffHunk; + comments: ReviewComment[]; + addingCommentAtLine: number | null; + onStartAddComment: (line: number) => void; + onSubmitComment: (content: string) => void; + onCancelComment: () => void; + onRemoveComment: (commentId: string) => void; +} + +export function DiffHunkView({ + hunk, + comments, + addingCommentAtLine, + onStartAddComment, + onSubmitComment, + onCancelComment, + onRemoveComment, +}: DiffHunkProps) { + return ( + <div className="border-b border-border last:border-b-0"> + <div className="bg-muted/50 px-4 py-1 text-xs text-muted-foreground font-mono"> + @@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@ + </div> + + <div className="font-mono text-xs"> + {hunk.lines.map((line, idx) => ( + <DiffLineView + key={idx} + line={line} + comments={comments} + addingCommentAtLine={addingCommentAtLine} + onStartAddComment={onStartAddComment} + onSubmitComment={onSubmitComment} + onCancelComment={onCancelComment} + onRemoveComment={onRemoveComment} + /> + ))} + </div> + </div> + ); +} diff --git a/web/src/components/task/diff/DiffLine.tsx b/web/src/components/task/diff/DiffLine.tsx new file mode 100644 index 00000000..8b46e464 --- /dev/null +++ b/web/src/components/task/diff/DiffLine.tsx @@ -0,0 +1,97 @@ +import { Plus, MessageSquare } from 'lucide-react'; +import { CommentInput, CommentDisplay } from './ReviewComment'; +import type { DiffLine } from '@/lib/api'; +import type { ReviewComment } from '@veritas-kanban/shared'; +import { cn } from '@/lib/utils'; + +interface DiffLineProps { + line: DiffLine; + comments: ReviewComment[]; + addingCommentAtLine: number | null; + onStartAddComment: (line: number) => void; + onSubmitComment: (content: string) => void; + onCancelComment: () => void; + onRemoveComment: (commentId: string) => void; +} + +export function DiffLineView({ + line, + comments, + addingCommentAtLine, + onStartAddComment, + onSubmitComment, + onCancelComment, + onRemoveComment, +}: DiffLineProps) { + const lineNumber = line.newNumber || line.oldNumber; + const lineComments = comments.filter(c => c.line === lineNumber); + const isAddingHere = addingCommentAtLine === lineNumber; + + return ( + <> + <div + className={cn( + 'group flex hover:bg-muted/30', + line.type === 'add' && 'bg-green-500/10', + line.type === 'delete' && 'bg-red-500/10', + lineComments.length > 0 && 'bg-amber-500/5' + )} + > + {/* Line numbers */} + <div className="flex-shrink-0 w-20 flex text-muted-foreground select-none border-r border-border"> + <span className="w-10 px-2 text-right border-r border-border text-[10px]"> + {line.oldNumber || ''} + </span> + <span className="w-10 px-2 text-right text-[10px]"> + {line.newNumber || ''} + </span> + </div> + + {/* Add comment button */} + {lineNumber && ( + <button + onClick={() => onStartAddComment(lineNumber)} + className="w-6 flex-shrink-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-amber-500" + > + <Plus className="h-3 w-3" /> + </button> + )} + + {/* Change indicator */} + <div className="w-6 flex-shrink-0 flex items-center justify-center"> + {line.type === 'add' && <span className="text-green-500">+</span>} + {line.type === 'delete' && <span className="text-red-500">-</span>} + </div> + + {/* Content */} + <pre className="flex-1 px-2 overflow-x-auto whitespace-pre text-xs"> + {line.content || ' '} + </pre> + + {/* Comment indicator */} + {lineComments.length > 0 && ( + <div className="flex-shrink-0 px-2 flex items-center"> + <MessageSquare className="h-3 w-3 text-amber-500" /> + </div> + )} + </div> + + {/* Inline comments */} + {lineComments.map(comment => ( + <CommentDisplay + key={comment.id} + comment={comment} + onRemove={() => onRemoveComment(comment.id)} + /> + ))} + + {/* Comment input */} + {isAddingHere && ( + <CommentInput + onSubmit={onSubmitComment} + onCancel={onCancelComment} + /> + )} + </> + ); +} diff --git a/web/src/components/task/diff/FileDiffView.tsx b/web/src/components/task/diff/FileDiffView.tsx new file mode 100644 index 00000000..e9cde728 --- /dev/null +++ b/web/src/components/task/diff/FileDiffView.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; +import { useFileDiff } from '@/hooks/useDiff'; +import { DiffHunkView } from './DiffHunk'; +import { + FileCode, + FilePlus, + FileMinus, + FileEdit, + Loader2, + AlertCircle, + MessageSquare, +} from 'lucide-react'; +import type { ReviewComment } from '@veritas-kanban/shared'; +import type { FileChange } from '@/lib/api'; +import { nanoid } from 'nanoid'; + +const statusIcons: Record<FileChange['status'], React.ReactNode> = { + added: <FilePlus className="h-4 w-4 text-green-500" />, + modified: <FileEdit className="h-4 w-4 text-amber-500" />, + deleted: <FileMinus className="h-4 w-4 text-red-500" />, + renamed: <FileCode className="h-4 w-4 text-blue-500" />, +}; + +interface FileDiffViewProps { + taskId: string; + filePath: string; + comments: ReviewComment[]; + onAddComment: (comment: ReviewComment) => void; + onRemoveComment: (commentId: string) => void; +} + +export function FileDiffView({ + taskId, + filePath, + comments, + onAddComment, + onRemoveComment, +}: FileDiffViewProps) { + const { data: diff, isLoading, error } = useFileDiff(taskId, filePath); + const [addingCommentAtLine, setAddingCommentAtLine] = useState<number | null>(null); + + const fileComments = comments.filter(c => c.file === filePath); + + const handleSubmitComment = (content: string) => { + if (addingCommentAtLine === null) return; + + const comment: ReviewComment = { + id: `comment_${nanoid(8)}`, + file: filePath, + line: addingCommentAtLine, + content, + created: new Date().toISOString(), + }; + + onAddComment(comment); + setAddingCommentAtLine(null); + }; + + if (isLoading) { + return ( + <div className="flex items-center justify-center h-64 text-muted-foreground"> + <Loader2 className="h-5 w-5 animate-spin mr-2" /> + Loading diff... + </div> + ); + } + + if (error || !diff) { + return ( + <div className="flex items-center justify-center h-64 text-muted-foreground"> + <AlertCircle className="h-5 w-5 mr-2" /> + {(error as Error)?.message || 'Failed to load diff'} + </div> + ); + } + + if (diff.hunks.length === 0) { + return ( + <div className="flex items-center justify-center h-64 text-muted-foreground"> + No changes in this file + </div> + ); + } + + return ( + <div className="border rounded-md overflow-hidden bg-card"> + <div className="flex items-center justify-between px-4 py-2 bg-muted/50 border-b"> + <div className="flex items-center gap-2"> + {statusIcons[diff.status]} + <span className="font-mono text-sm">{diff.path}</span> + </div> + <div className="flex items-center gap-2 text-xs"> + {fileComments.length > 0 && ( + <span className="flex items-center gap-1 text-amber-500"> + <MessageSquare className="h-3 w-3" /> + {fileComments.length} + </span> + )} + <span className="text-green-500">+{diff.additions}</span> + <span className="text-red-500">-{diff.deletions}</span> + </div> + </div> + + <div className="overflow-x-auto"> + {diff.hunks.map((hunk, idx) => ( + <DiffHunkView + key={idx} + hunk={hunk} + comments={fileComments} + addingCommentAtLine={addingCommentAtLine} + onStartAddComment={setAddingCommentAtLine} + onSubmitComment={handleSubmitComment} + onCancelComment={() => setAddingCommentAtLine(null)} + onRemoveComment={onRemoveComment} + /> + ))} + </div> + </div> + ); +} diff --git a/web/src/components/task/diff/FileTree.tsx b/web/src/components/task/diff/FileTree.tsx new file mode 100644 index 00000000..027cd56d --- /dev/null +++ b/web/src/components/task/diff/FileTree.tsx @@ -0,0 +1,64 @@ +import { + FileCode, + FilePlus, + FileMinus, + FileEdit, + MessageSquare, +} from 'lucide-react'; +import type { FileChange } from '@/lib/api'; +import type { ReviewComment } from '@veritas-kanban/shared'; +import { cn } from '@/lib/utils'; + +const statusIcons: Record<FileChange['status'], React.ReactNode> = { + added: <FilePlus className="h-4 w-4 text-green-500" />, + modified: <FileEdit className="h-4 w-4 text-amber-500" />, + deleted: <FileMinus className="h-4 w-4 text-red-500" />, + renamed: <FileCode className="h-4 w-4 text-blue-500" />, +}; + +interface FileTreeProps { + files: FileChange[]; + selectedFile: string | null; + onSelectFile: (path: string) => void; + comments: ReviewComment[]; +} + +export function FileTree({ files, selectedFile, onSelectFile, comments }: FileTreeProps) { + const commentsByFile = comments.reduce((acc, c) => { + acc[c.file] = (acc[c.file] || 0) + 1; + return acc; + }, {} as Record<string, number>); + + return ( + <div className="space-y-1"> + {files.map((file) => ( + <button + key={file.path} + onClick={() => onSelectFile(file.path)} + className={cn( + 'w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md text-left', + 'hover:bg-muted transition-colors', + selectedFile === file.path && 'bg-muted' + )} + > + {statusIcons[file.status]} + <span className="truncate flex-1 font-mono text-xs">{file.path}</span> + <span className="flex items-center gap-1 text-xs"> + {commentsByFile[file.path] && ( + <span className="flex items-center gap-0.5 text-amber-500"> + <MessageSquare className="h-3 w-3" /> + {commentsByFile[file.path]} + </span> + )} + {file.additions > 0 && ( + <span className="text-green-500">+{file.additions}</span> + )} + {file.deletions > 0 && ( + <span className="text-red-500">-{file.deletions}</span> + )} + </span> + </button> + ))} + </div> + ); +} diff --git a/web/src/components/task/diff/ReviewComment.tsx b/web/src/components/task/diff/ReviewComment.tsx new file mode 100644 index 00000000..8dcc2e48 --- /dev/null +++ b/web/src/components/task/diff/ReviewComment.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { X } from 'lucide-react'; +import type { ReviewComment } from '@veritas-kanban/shared'; + +interface CommentInputProps { + onSubmit: (content: string) => void; + onCancel: () => void; +} + +export function CommentInput({ onSubmit, onCancel }: CommentInputProps) { + const [content, setContent] = useState(''); + + const handleSubmit = () => { + if (content.trim()) { + onSubmit(content.trim()); + setContent(''); + } + }; + + return ( + <div className="p-2 bg-amber-500/10 border-l-2 border-amber-500 space-y-2"> + <Textarea + value={content} + onChange={(e) => setContent(e.target.value)} + placeholder="Add review comment..." + rows={2} + className="text-xs" + autoFocus + /> + <div className="flex gap-2"> + <Button size="sm" onClick={handleSubmit} disabled={!content.trim()}> + Add Comment + </Button> + <Button size="sm" variant="ghost" onClick={onCancel}> + Cancel + </Button> + </div> + </div> + ); +} + +interface CommentDisplayProps { + comment: ReviewComment; + onRemove: () => void; +} + +export function CommentDisplay({ comment, onRemove }: CommentDisplayProps) { + return ( + <div className="p-2 bg-amber-500/10 border-l-2 border-amber-500 group"> + <div className="flex items-start justify-between"> + <p className="text-xs whitespace-pre-wrap">{comment.content}</p> + <button + onClick={onRemove} + className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive" + > + <X className="h-3 w-3" /> + </button> + </div> + <p className="text-[10px] text-muted-foreground mt-1"> + {new Date(comment.created).toLocaleString()} + </p> + </div> + ); +} diff --git a/web/src/components/task/git/GitSelectionForm.tsx b/web/src/components/task/git/GitSelectionForm.tsx new file mode 100644 index 00000000..e7f66790 --- /dev/null +++ b/web/src/components/task/git/GitSelectionForm.tsx @@ -0,0 +1,180 @@ +import { useState, useEffect, useMemo } from 'react'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useConfig, useRepoBranches } from '@/hooks/useConfig'; +import { Loader2, FolderGit2 } from 'lucide-react'; +import type { Task, TaskGit } from '@veritas-kanban/shared'; +import { cn } from '@/lib/utils'; + +interface GitSelectionFormProps { + task: Task; + onGitChange: (git: Partial<TaskGit> | undefined) => void; +} + +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); +} + +export function GitSelectionForm({ task, onGitChange }: GitSelectionFormProps) { + const { data: config } = useConfig(); + const [selectedRepo, setSelectedRepo] = useState<string>(task.git?.repo || ''); + const [baseBranch, setBaseBranch] = useState<string>(task.git?.baseBranch || ''); + const [featureBranch, setFeatureBranch] = useState<string>(task.git?.branch || ''); + const [autoGenerateBranch, setAutoGenerateBranch] = useState(!task.git?.branch); + + const { data: branches, isLoading: branchesLoading } = useRepoBranches(selectedRepo || undefined); + + // Get the repo config for the selected repo + const repoConfig = useMemo(() => { + return config?.repos.find(r => r.name === selectedRepo); + }, [config, selectedRepo]); + + // Auto-generate feature branch name from task title + useEffect(() => { + if (autoGenerateBranch && task.title) { + const slug = slugify(task.title); + setFeatureBranch(`feature/${slug}`); + } + }, [task.title, autoGenerateBranch]); + + // Set default base branch when repo changes + useEffect(() => { + if (repoConfig && !baseBranch) { + setBaseBranch(repoConfig.defaultBranch); + } + }, [repoConfig, baseBranch]); + + // Sync from task.git when it changes + useEffect(() => { + if (task.git) { + setSelectedRepo(task.git.repo || ''); + setBaseBranch(task.git.baseBranch || ''); + setFeatureBranch(task.git.branch || ''); + setAutoGenerateBranch(!task.git.branch); + } + }, [task.id]); // Only re-sync when task changes + + // Update parent when values change + const handleRepoChange = (repo: string) => { + setSelectedRepo(repo); + const newRepoConfig = config?.repos.find(r => r.name === repo); + const newBaseBranch = newRepoConfig?.defaultBranch || 'main'; + setBaseBranch(newBaseBranch); + + onGitChange({ + repo, + baseBranch: newBaseBranch, + branch: featureBranch, + }); + }; + + const handleBaseBranchChange = (branch: string) => { + setBaseBranch(branch); + onGitChange({ + repo: selectedRepo, + baseBranch: branch, + branch: featureBranch, + }); + }; + + const handleFeatureBranchChange = (branch: string) => { + setFeatureBranch(branch); + setAutoGenerateBranch(false); + onGitChange({ + repo: selectedRepo, + baseBranch, + branch, + }); + }; + + // Don't allow editing if worktree exists + const isLocked = !!task.git?.worktreePath; + + return ( + <div className="grid gap-3 p-3 rounded-md border bg-muted/30"> + {/* Repository Selection */} + <div className="grid gap-1.5"> + <Label className="text-xs">Repository</Label> + <Select + value={selectedRepo} + onValueChange={handleRepoChange} + disabled={isLocked} + > + <SelectTrigger className={cn(isLocked && 'opacity-60')}> + <SelectValue placeholder="Select repository..." /> + </SelectTrigger> + <SelectContent> + {config?.repos.map((repo) => ( + <SelectItem key={repo.name} value={repo.name}> + <div className="flex items-center gap-2"> + <FolderGit2 className="h-3 w-3" /> + {repo.name} + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + {selectedRepo && ( + <> + {/* Base Branch */} + <div className="grid gap-1.5"> + <Label className="text-xs">Base Branch</Label> + {branchesLoading ? ( + <div className="flex items-center gap-2 text-sm text-muted-foreground h-9 px-3"> + <Loader2 className="h-3 w-3 animate-spin" /> + Loading branches... + </div> + ) : ( + <Select + value={baseBranch} + onValueChange={handleBaseBranchChange} + disabled={isLocked} + > + <SelectTrigger className={cn(isLocked && 'opacity-60')}> + <SelectValue placeholder="Select base branch..." /> + </SelectTrigger> + <SelectContent> + {branches?.map((branch) => ( + <SelectItem key={branch} value={branch}> + {branch} + </SelectItem> + ))} + </SelectContent> + </Select> + )} + </div> + + {/* Feature Branch */} + <div className="grid gap-1.5"> + <Label className="text-xs">Feature Branch</Label> + <Input + value={featureBranch} + onChange={(e) => handleFeatureBranchChange(e.target.value)} + placeholder="feature/my-feature" + disabled={isLocked} + className={cn(isLocked && 'opacity-60')} + /> + {autoGenerateBranch && featureBranch && !isLocked && ( + <p className="text-xs text-muted-foreground"> + Auto-generated from task title + </p> + )} + </div> + </> + )} + </div> + ); +} diff --git a/web/src/components/task/git/PRDialog.tsx b/web/src/components/task/git/PRDialog.tsx new file mode 100644 index 00000000..6e0e5be6 --- /dev/null +++ b/web/src/components/task/git/PRDialog.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useCreatePR } from '@/hooks/useGitHub'; +import { Loader2, GitPullRequest } from 'lucide-react'; +import type { Task } from '@veritas-kanban/shared'; + +interface PRDialogProps { + task: Task; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function PRDialog({ task, open, onOpenChange }: PRDialogProps) { + const [prTitle, setPrTitle] = useState(task.title); + const [prBody, setPrBody] = useState(task.description || ''); + const [prDraft, setPrDraft] = useState(false); + + const createPR = useCreatePR(); + + const handleCreatePR = async () => { + try { + const result = await createPR.mutateAsync({ + taskId: task.id, + title: prTitle, + body: prBody, + draft: prDraft, + }); + onOpenChange(false); + // Open the new PR in browser + window.open(result.url, '_blank'); + } catch (error) { + // Error is handled by mutation + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>Create Pull Request</DialogTitle> + <DialogDescription> + Create a PR from {task.git?.branch} to {task.git?.baseBranch} + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + <div className="grid gap-2"> + <Label htmlFor="pr-title">Title</Label> + <Input + id="pr-title" + value={prTitle} + onChange={(e) => setPrTitle(e.target.value)} + placeholder="PR title" + /> + </div> + <div className="grid gap-2"> + <Label htmlFor="pr-body">Description</Label> + <Textarea + id="pr-body" + value={prBody} + onChange={(e) => setPrBody(e.target.value)} + placeholder="Describe your changes..." + rows={5} + /> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="pr-draft" + checked={prDraft} + onCheckedChange={(checked) => setPrDraft(checked === true)} + /> + <Label htmlFor="pr-draft" className="text-sm font-normal"> + Create as draft PR + </Label> + </div> + {createPR.error && ( + <p className="text-sm text-red-500"> + {(createPR.error as Error).message} + </p> + )} + </div> + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + Cancel + </Button> + <Button onClick={handleCreatePR} disabled={createPR.isPending || !prTitle}> + {createPR.isPending ? ( + <> + <Loader2 className="h-4 w-4 mr-2 animate-spin" /> + Creating... + </> + ) : ( + <> + <GitPullRequest className="h-4 w-4 mr-2" /> + Create PR + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/web/src/components/task/git/WorktreeStatus.tsx b/web/src/components/task/git/WorktreeStatus.tsx new file mode 100644 index 00000000..f3f76057 --- /dev/null +++ b/web/src/components/task/git/WorktreeStatus.tsx @@ -0,0 +1,323 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { + useWorktreeStatus, + useCreateWorktree, + useDeleteWorktree, + useRebaseWorktree, + useMergeWorktree +} from '@/hooks/useWorktree'; +import { useGitHubStatus } from '@/hooks/useGitHub'; +import { useConflictStatus } from '@/hooks/useConflicts'; +import { ConflictResolver } from '../ConflictResolver'; +import { PRDialog } from './PRDialog'; +import { + Loader2, + AlertCircle, + Play, + ExternalLink, + RefreshCw, + GitMerge, + Trash2, + FileCode, + ArrowUp, + ArrowDown, + GitPullRequest, + AlertTriangle, +} from 'lucide-react'; +import type { Task } from '@veritas-kanban/shared'; +import { cn } from '@/lib/utils'; + +interface WorktreeStatusProps { + task: Task; +} + +export function WorktreeStatus({ task }: WorktreeStatusProps) { + const hasWorktree = !!task.git?.worktreePath; + const hasPR = !!task.git?.prUrl; + const { data: status, isLoading, error } = useWorktreeStatus(task.id, hasWorktree); + const { data: ghStatus } = useGitHubStatus(); + const { data: conflictStatus } = useConflictStatus(hasWorktree ? task.id : undefined); + + const createWorktree = useCreateWorktree(); + const deleteWorktree = useDeleteWorktree(); + const rebaseWorktree = useRebaseWorktree(); + const mergeWorktree = useMergeWorktree(); + + // Conflict resolver state + const [conflictResolverOpen, setConflictResolverOpen] = useState(false); + const [prDialogOpen, setPrDialogOpen] = useState(false); + + const handleOpenInVSCode = () => { + if (task.git?.worktreePath) { + window.open(`vscode://file/${task.git.worktreePath}`, '_blank'); + } + }; + + const handleOpenPR = () => { + if (task.git?.prUrl) { + window.open(task.git.prUrl, '_blank'); + } + }; + + if (!task.git?.repo || !task.git?.branch) { + return null; + } + + // No worktree yet - show create button + if (!hasWorktree) { + return ( + <div className="mt-3 pt-3 border-t"> + <Button + variant="outline" + className="w-full" + onClick={() => createWorktree.mutate(task.id)} + disabled={createWorktree.isPending} + > + {createWorktree.isPending ? ( + <Loader2 className="h-4 w-4 mr-2 animate-spin" /> + ) : ( + <Play className="h-4 w-4 mr-2" /> + )} + Create Worktree + </Button> + {createWorktree.error && ( + <p className="text-xs text-red-500 mt-2"> + {(createWorktree.error as Error).message} + </p> + )} + </div> + ); + } + + // Loading worktree status + if (isLoading) { + return ( + <div className="mt-3 pt-3 border-t"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader2 className="h-4 w-4 animate-spin" /> + Loading worktree status... + </div> + </div> + ); + } + + // Error loading status + if (error) { + return ( + <div className="mt-3 pt-3 border-t"> + <div className="flex items-center gap-2 text-sm text-red-500"> + <AlertCircle className="h-4 w-4" /> + {(error as Error).message} + </div> + </div> + ); + } + + // Show worktree status + return ( + <div className="mt-3 pt-3 border-t space-y-3"> + {/* Conflict warning banner */} + {conflictStatus?.hasConflicts && ( + <div className="flex items-center justify-between gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20"> + <div className="flex items-center gap-2"> + <AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0" /> + <div> + <p className="text-sm font-medium text-amber-700 dark:text-amber-400"> + {conflictStatus.conflictingFiles.length} conflict{conflictStatus.conflictingFiles.length !== 1 ? 's' : ''} detected + </p> + <p className="text-xs text-amber-600 dark:text-amber-500"> + {conflictStatus.rebaseInProgress ? 'Rebase' : 'Merge'} requires manual resolution + </p> + </div> + </div> + <Button + size="sm" + variant="outline" + onClick={() => setConflictResolverOpen(true)} + className="border-amber-500/30 hover:bg-amber-500/10" + > + <AlertTriangle className="h-4 w-4 mr-1" /> + Resolve Conflicts + </Button> + </div> + )} + + {/* Status indicators */} + <div className="flex items-center justify-between text-sm"> + <div className="flex items-center gap-2"> + <span className={cn( + "h-2 w-2 rounded-full", + conflictStatus?.hasConflicts ? "bg-amber-500" : "bg-green-500" + )}> + <span className="sr-only"> + {conflictStatus?.hasConflicts ? 'Warning: conflicts detected' : 'Status: active and healthy'} + </span> + </span> + <span className="text-muted-foreground"> + {conflictStatus?.hasConflicts ? 'Conflicts detected' : 'Worktree active'} + </span> + </div> + <div className="flex items-center gap-3 text-xs text-muted-foreground"> + {status && ( + <> + {status.aheadBehind.ahead > 0 && ( + <span className="flex items-center gap-1"> + <ArrowUp className="h-3 w-3" /> + {status.aheadBehind.ahead} ahead + </span> + )} + {status.aheadBehind.behind > 0 && ( + <span className="flex items-center gap-1 text-amber-500"> + <ArrowDown className="h-3 w-3" /> + {status.aheadBehind.behind} behind + </span> + )} + {status.hasChanges && ( + <span className="flex items-center gap-1"> + <FileCode className="h-3 w-3" /> + {status.changedFiles} changed + </span> + )} + </> + )} + </div> + </div> + + {/* Action buttons */} + <div className="flex flex-wrap gap-2"> + <Button + variant="outline" + size="sm" + onClick={handleOpenInVSCode} + > + <ExternalLink className="h-3 w-3 mr-1" /> + Open in VS Code + </Button> + + {/* PR Button - show View PR if exists, Create PR if not */} + {hasPR ? ( + <Button + variant="outline" + size="sm" + onClick={handleOpenPR} + > + <GitPullRequest className="h-3 w-3 mr-1" /> + View PR #{task.git?.prNumber} + </Button> + ) : status && status.aheadBehind.ahead > 0 && ghStatus?.authenticated && ( + <Button variant="outline" size="sm" onClick={() => setPrDialogOpen(true)}> + <GitPullRequest className="h-3 w-3 mr-1" /> + Create PR + </Button> + )} + + {status && status.aheadBehind.behind > 0 && ( + <Button + variant="outline" + size="sm" + onClick={() => rebaseWorktree.mutate(task.id)} + disabled={rebaseWorktree.isPending} + > + {rebaseWorktree.isPending ? ( + <Loader2 className="h-3 w-3 mr-1 animate-spin" /> + ) : ( + <RefreshCw className="h-3 w-3 mr-1" /> + )} + Rebase + </Button> + )} + + {status && status.aheadBehind.ahead > 0 && !status.hasChanges && ( + <AlertDialog> + <AlertDialogTrigger asChild> + <Button variant="default" size="sm"> + <GitMerge className="h-3 w-3 mr-1" /> + Merge + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Merge to {task.git?.baseBranch}?</AlertDialogTitle> + <AlertDialogDescription> + This will merge {task.git?.branch} into {task.git?.baseBranch}, push to remote, + delete the worktree, and mark the task as Done. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction + onClick={() => mergeWorktree.mutate(task.id)} + disabled={mergeWorktree.isPending} + > + {mergeWorktree.isPending ? 'Merging...' : 'Merge & Complete'} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + )} + + <AlertDialog> + <AlertDialogTrigger asChild> + <Button variant="ghost" size="sm" className="text-muted-foreground"> + <Trash2 className="h-3 w-3 mr-1" /> + Delete Worktree + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Delete worktree?</AlertDialogTitle> + <AlertDialogDescription> + {status?.hasChanges + ? 'Warning: This worktree has uncommitted changes that will be lost.' + : 'This will remove the worktree but keep the branch.'} + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction + onClick={() => deleteWorktree.mutate({ + taskId: task.id, + force: status?.hasChanges + })} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + + {/* Worktree path */} + <div className="text-xs text-muted-foreground font-mono truncate"> + {task.git.worktreePath} + </div> + + {/* Conflict Resolver */} + <ConflictResolver + task={task} + open={conflictResolverOpen} + onOpenChange={setConflictResolverOpen} + /> + + {/* PR Dialog */} + <PRDialog + task={task} + open={prDialogOpen} + onOpenChange={setPrDialogOpen} + /> + </div> + ); +} diff --git a/web/src/contexts/TaskConfigContext.tsx b/web/src/contexts/TaskConfigContext.tsx new file mode 100644 index 00000000..14f5cc09 --- /dev/null +++ b/web/src/contexts/TaskConfigContext.tsx @@ -0,0 +1,36 @@ +import { createContext, useContext, ReactNode } from 'react'; +import { useTaskTypes } from '@/hooks/useTaskTypes'; +import { useProjects } from '@/hooks/useProjects'; +import { useSprints } from '@/hooks/useSprints'; +import type { TaskTypeConfig, ProjectConfig, SprintConfig } from '@veritas-kanban/shared'; + +interface TaskConfigContextValue { + taskTypes: TaskTypeConfig[]; + projects: ProjectConfig[]; + sprints: SprintConfig[]; + isLoading: boolean; +} + +const TaskConfigContext = createContext<TaskConfigContextValue | null>(null); + +export function TaskConfigProvider({ children }: { children: ReactNode }) { + const { data: taskTypes = [], isLoading: taskTypesLoading } = useTaskTypes(); + const { data: projects = [], isLoading: projectsLoading } = useProjects(); + const { data: sprints = [], isLoading: sprintsLoading } = useSprints(); + + const isLoading = taskTypesLoading || projectsLoading || sprintsLoading; + + return ( + <TaskConfigContext.Provider value={{ taskTypes, projects, sprints, isLoading }}> + {children} + </TaskConfigContext.Provider> + ); +} + +export function useTaskConfig() { + const context = useContext(TaskConfigContext); + if (!context) { + throw new Error('useTaskConfig must be used within TaskConfigProvider'); + } + return context; +} diff --git a/web/src/hooks/useActivity.ts b/web/src/hooks/useActivity.ts index 675e1344..68ca0819 100644 --- a/web/src/hooks/useActivity.ts +++ b/web/src/hooks/useActivity.ts @@ -1,43 +1,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api, type Activity, type ActivityType } from '../lib/api'; -const API_BASE = '/api'; - -export type ActivityType = - | 'task_created' - | 'task_updated' - | 'status_changed' - | 'agent_started' - | 'agent_stopped' - | 'agent_completed' - | 'task_archived' - | 'task_deleted' - | 'worktree_created' - | 'worktree_merged'; - -export interface Activity { - id: string; - type: ActivityType; - taskId: string; - taskTitle: string; - details?: Record<string, unknown>; - timestamp: string; -} - -async function fetchActivities(limit: number = 50): Promise<Activity[]> { - const response = await fetch(`${API_BASE}/activity?limit=${limit}`); - if (!response.ok) throw new Error('Failed to fetch activities'); - return response.json(); -} - -async function clearActivities(): Promise<void> { - const response = await fetch(`${API_BASE}/activity`, { method: 'DELETE' }); - if (!response.ok) throw new Error('Failed to clear activities'); -} +export type { Activity, ActivityType }; export function useActivities(limit: number = 50) { return useQuery({ queryKey: ['activities', limit], - queryFn: () => fetchActivities(limit), + queryFn: () => api.activity.list(limit), refetchInterval: 30000, // Refresh every 30s }); } @@ -45,7 +14,7 @@ export function useActivities(limit: number = 50) { export function useClearActivities() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: clearActivities, + mutationFn: () => api.activity.clear(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['activities'] }); }, diff --git a/web/src/hooks/useAttachments.ts b/web/src/hooks/useAttachments.ts index 11bc8e04..b84f3273 100644 --- a/web/src/hooks/useAttachments.ts +++ b/web/src/hooks/useAttachments.ts @@ -1,31 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import type { Attachment } from '@veritas-kanban/shared'; - -const API_BASE = 'http://localhost:3001/api'; - -interface UploadResponse { - success: boolean; - attachments: Attachment[]; - task: unknown; -} - -interface TaskContext { - taskId: string; - title: string; - description: string; - type: string; - status: string; - priority: string; - project?: string; - tags?: string[]; - attachments: { - count: number; - documents: { filename: string; text: string }[]; - images: string[]; - }; - created: string; - updated: string; -} +import { api, type TaskContext } from '../lib/api'; /** * Fetch attachments for a task @@ -33,13 +8,7 @@ interface TaskContext { export function useAttachments(taskId: string) { return useQuery<Attachment[]>({ queryKey: ['attachments', taskId], - queryFn: async () => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments`); - if (!response.ok) { - throw new Error('Failed to fetch attachments'); - } - return response.json(); - }, + queryFn: () => api.attachments.list(taskId), enabled: !!taskId, }); } @@ -52,17 +21,7 @@ export function useUploadAttachment() { return useMutation({ mutationFn: async ({ taskId, formData }: { taskId: string; formData: FormData }) => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments`, { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to upload attachment'); - } - - return response.json() as Promise<UploadResponse>; + return api.attachments.upload(taskId, formData); }, onSuccess: (_, { taskId }) => { // Invalidate both attachments and task queries @@ -81,16 +40,7 @@ export function useDeleteAttachment() { return useMutation({ mutationFn: async ({ taskId, attachmentId }: { taskId: string; attachmentId: string }) => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments/${attachmentId}`, { - method: 'DELETE', - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to delete attachment'); - } - - return response.json(); + return api.attachments.delete(taskId, attachmentId); }, onSuccess: (_, { taskId }) => { // Invalidate both attachments and task queries @@ -107,13 +57,7 @@ export function useDeleteAttachment() { export function useTaskContext(taskId: string) { return useQuery<TaskContext>({ queryKey: ['task-context', taskId], - queryFn: async () => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/context`); - if (!response.ok) { - throw new Error('Failed to fetch task context'); - } - return response.json(); - }, + queryFn: () => api.attachments.getTaskContext(taskId), enabled: !!taskId, }); } diff --git a/web/src/hooks/useConflicts.ts b/web/src/hooks/useConflicts.ts index 0c0f9c8e..f3978a82 100644 --- a/web/src/hooks/useConflicts.ts +++ b/web/src/hooks/useConflicts.ts @@ -1,35 +1,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + api, + type ConflictStatus, + type ConflictFile, + type ResolveResult, + type ConflictMarker +} from '../lib/api'; -const API_BASE = '/api'; - -export interface ConflictStatus { - hasConflicts: boolean; - conflictingFiles: string[]; - rebaseInProgress: boolean; - mergeInProgress: boolean; -} - -export interface ConflictMarker { - startLine: number; - separatorLine: number; - endLine: number; - oursLines: string[]; - theirsLines: string[]; -} - -export interface ConflictFile { - path: string; - content: string; - oursContent: string; - theirsContent: string; - baseContent: string; - markers: ConflictMarker[]; -} - -export interface ResolveResult { - success: boolean; - remainingConflicts: string[]; -} +export type { ConflictStatus, ConflictFile, ResolveResult, ConflictMarker }; /** * Get conflict status for a task @@ -39,11 +17,7 @@ export function useConflictStatus(taskId: string | undefined) { queryKey: ['conflicts', taskId], queryFn: async () => { if (!taskId) return { hasConflicts: false, conflictingFiles: [], rebaseInProgress: false, mergeInProgress: false }; - const response = await fetch(`${API_BASE}/conflicts/${taskId}`); - if (!response.ok) { - throw new Error('Failed to get conflict status'); - } - return response.json(); + return api.conflicts.getStatus(taskId); }, enabled: !!taskId, refetchInterval: (query) => { @@ -64,11 +38,7 @@ export function useFileConflict(taskId: string | undefined, filePath: string | u queryKey: ['conflicts', taskId, 'file', filePath], queryFn: async () => { if (!taskId || !filePath) throw new Error('Task ID and file path required'); - const response = await fetch(`${API_BASE}/conflicts/${taskId}/file?path=${encodeURIComponent(filePath)}`); - if (!response.ok) { - throw new Error('Failed to get file conflict'); - } - return response.json(); + return api.conflicts.getFile(taskId, filePath); }, enabled: !!taskId && !!filePath, }); @@ -87,18 +57,7 @@ export function useResolveConflict() { manualContent?: string; }>({ mutationFn: async ({ taskId, filePath, resolution, manualContent }) => { - const response = await fetch(`${API_BASE}/conflicts/${taskId}/resolve?path=${encodeURIComponent(filePath)}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ resolution, manualContent }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to resolve conflict'); - } - - return response.json(); + return api.conflicts.resolve(taskId, filePath, resolution, manualContent); }, onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ['conflicts', variables.taskId] }); @@ -114,18 +73,7 @@ export function useAbortConflict() { const queryClient = useQueryClient(); return useMutation<{ success: boolean }, Error, string>({ - mutationFn: async (taskId) => { - const response = await fetch(`${API_BASE}/conflicts/${taskId}/abort`, { - method: 'POST', - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to abort'); - } - - return response.json(); - }, + mutationFn: (taskId) => api.conflicts.abort(taskId), onSuccess: (_data, taskId) => { queryClient.invalidateQueries({ queryKey: ['conflicts', taskId] }); queryClient.invalidateQueries({ queryKey: ['worktree', taskId] }); @@ -140,20 +88,7 @@ export function useContinueConflict() { const queryClient = useQueryClient(); return useMutation<{ success: boolean; error?: string }, Error, { taskId: string; message?: string }>({ - mutationFn: async ({ taskId, message }) => { - const response = await fetch(`${API_BASE}/conflicts/${taskId}/continue`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to continue'); - } - - return response.json(); - }, + mutationFn: ({ taskId, message }) => api.conflicts.continue(taskId, message), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ['conflicts', variables.taskId] }); queryClient.invalidateQueries({ queryKey: ['worktree', variables.taskId] }); diff --git a/web/src/hooks/useDebouncedSave.ts b/web/src/hooks/useDebouncedSave.ts new file mode 100644 index 00000000..c2be37b3 --- /dev/null +++ b/web/src/hooks/useDebouncedSave.ts @@ -0,0 +1,45 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useUpdateTask } from './useTasks'; +import type { Task } from '@veritas-kanban/shared'; + +export function useDebouncedSave(task: Task | null) { + const updateTask = useUpdateTask(); + const [localTask, setLocalTask] = useState<Task | null>(task); + const [changedFields, setChangedFields] = useState<Set<keyof Task>>(new Set()); + + // Sync from server when task prop changes (e.g., refetch) + useEffect(() => { + setLocalTask(task); + setChangedFields(new Set()); + }, [task]); + + // Debounced save - only send fields that were actually changed + useEffect(() => { + if (changedFields.size === 0 || !localTask) return; + + const timeout = setTimeout(() => { + // Build input with only changed fields + const input: Record<string, unknown> = {}; + changedFields.forEach(field => { + input[field] = localTask[field]; + }); + + updateTask.mutate({ + id: localTask.id, + input, + }); + setChangedFields(new Set()); + }, 500); + + return () => clearTimeout(timeout); + }, [localTask, changedFields, updateTask]); + + const updateField = useCallback(<K extends keyof Task>(field: K, value: Task[K]) => { + setLocalTask(prev => prev ? { ...prev, [field]: value } : null); + setChangedFields(prev => new Set(prev).add(field)); + }, []); + + const isDirty = changedFields.size > 0; + + return { localTask, updateField, isDirty }; +} diff --git a/web/src/hooks/useGitHub.ts b/web/src/hooks/useGitHub.ts index 9afca977..7279c12d 100644 --- a/web/src/hooks/useGitHub.ts +++ b/web/src/hooks/useGitHub.ts @@ -1,30 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { api, type GitHubStatus, type PRInfo, type CreatePRInput } from '../lib/api'; -const API_BASE = '/api'; - -export interface GitHubStatus { - installed: boolean; - authenticated: boolean; - user?: string; -} - -export interface PRInfo { - url: string; - number: number; - title: string; - state: string; - draft: boolean; - headBranch: string; - baseBranch: string; -} - -export interface CreatePRInput { - taskId: string; - title?: string; - body?: string; - targetBranch?: string; - draft?: boolean; -} +export type { GitHubStatus, PRInfo, CreatePRInput }; /** * Check GitHub CLI status @@ -32,13 +9,7 @@ export interface CreatePRInput { export function useGitHubStatus() { return useQuery<GitHubStatus>({ queryKey: ['github', 'status'], - queryFn: async () => { - const response = await fetch(`${API_BASE}/github/status`); - if (!response.ok) { - throw new Error('Failed to check GitHub status'); - } - return response.json(); - }, + queryFn: () => api.github.getStatus(), staleTime: 60 * 1000, // 1 minute }); } @@ -50,20 +21,7 @@ export function useCreatePR() { const queryClient = useQueryClient(); return useMutation<PRInfo, Error, CreatePRInput>({ - mutationFn: async (input) => { - const response = await fetch(`${API_BASE}/github/pr`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to create PR'); - } - - return response.json(); - }, + mutationFn: (input) => api.github.createPR(input), onSuccess: (_data, variables) => { // Invalidate the task to refresh its PR info queryClient.invalidateQueries({ queryKey: ['tasks'] }); @@ -77,15 +35,6 @@ export function useCreatePR() { */ export function useOpenPR() { return useMutation<void, Error, string>({ - mutationFn: async (taskId) => { - const response = await fetch(`${API_BASE}/github/pr/${taskId}/open`, { - method: 'POST', - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to open PR'); - } - }, + mutationFn: (taskId) => api.github.openPR(taskId), }); } diff --git a/web/src/hooks/usePreview.ts b/web/src/hooks/usePreview.ts index 93af85e7..1b902fa8 100644 --- a/web/src/hooks/usePreview.ts +++ b/web/src/hooks/usePreview.ts @@ -1,18 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { api, type PreviewServer } from '../lib/api'; -const API_BASE = '/api'; - -export interface PreviewServer { - taskId: string; - repoName: string; - pid: number; - port: number; - url: string; - status: 'starting' | 'running' | 'stopped' | 'error'; - startedAt: string; - output: string[]; - error?: string; -} +export type { PreviewServer }; /** * Get preview status for a task @@ -22,11 +11,7 @@ export function usePreviewStatus(taskId: string | undefined) { queryKey: ['preview', taskId], queryFn: async () => { if (!taskId) return { status: 'stopped' as const }; - const response = await fetch(`${API_BASE}/preview/${taskId}`); - if (!response.ok) { - throw new Error('Failed to get preview status'); - } - return response.json(); + return api.preview.getStatus(taskId); }, enabled: !!taskId, refetchInterval: (data) => { @@ -51,11 +36,7 @@ export function usePreviewOutput(taskId: string | undefined, lines: number = 50) queryKey: ['preview', taskId, 'output', lines], queryFn: async () => { if (!taskId) return { output: [] }; - const response = await fetch(`${API_BASE}/preview/${taskId}/output?lines=${lines}`); - if (!response.ok) { - throw new Error('Failed to get preview output'); - } - return response.json(); + return api.preview.getOutput(taskId, lines); }, enabled: !!taskId, refetchInterval: 2000, // Poll every 2s for output @@ -69,18 +50,7 @@ export function useStartPreview() { const queryClient = useQueryClient(); return useMutation<PreviewServer, Error, string>({ - mutationFn: async (taskId) => { - const response = await fetch(`${API_BASE}/preview/${taskId}/start`, { - method: 'POST', - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to start preview'); - } - - return response.json(); - }, + mutationFn: (taskId) => api.preview.start(taskId), onSuccess: (_data, taskId) => { queryClient.invalidateQueries({ queryKey: ['preview', taskId] }); }, @@ -94,16 +64,7 @@ export function useStopPreview() { const queryClient = useQueryClient(); return useMutation<void, Error, string>({ - mutationFn: async (taskId) => { - const response = await fetch(`${API_BASE}/preview/${taskId}/stop`, { - method: 'POST', - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to stop preview'); - } - }, + mutationFn: (taskId) => api.preview.stop(taskId), onSuccess: (_data, taskId) => { queryClient.invalidateQueries({ queryKey: ['preview', taskId] }); }, diff --git a/web/src/hooks/useTemplateForm.ts b/web/src/hooks/useTemplateForm.ts new file mode 100644 index 00000000..3d85911f --- /dev/null +++ b/web/src/hooks/useTemplateForm.ts @@ -0,0 +1,209 @@ +import { useState } from 'react'; +import { useTemplates, type TaskTemplate } from './useTemplates'; +import { useCreateTask } from './useTasks'; +import { + interpolateVariables, + extractCustomVariables, + type VariableContext +} from '@/lib/template-variables'; +import { nanoid } from 'nanoid'; +import type { Subtask } from '@veritas-kanban/shared'; + +export function useTemplateForm() { + const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null); + const [subtasks, setSubtasks] = useState<Subtask[]>([]); + const [customVars, setCustomVars] = useState<Record<string, string>>({}); + const [requiredCustomVars, setRequiredCustomVars] = useState<string[]>([]); + + const { data: templates } = useTemplates(); + const createTask = useCreateTask(); + + const applyTemplate = (template: TaskTemplate) => { + setSelectedTemplate(template.id); + + // If this is a blueprint template, extract variables from all blueprint tasks + if (template.blueprint && template.blueprint.length > 0) { + const allBlueprintText = template.blueprint.flatMap(bt => [ + bt.title, + bt.taskDefaults.descriptionTemplate || '', + ...(bt.subtaskTemplates?.map(st => st.title) || []) + ]).join(' '); + + const customVarNames = extractCustomVariables(allBlueprintText); + setRequiredCustomVars(customVarNames); + + const initialCustomVars: Record<string, string> = {}; + customVarNames.forEach(name => { + initialCustomVars[name] = ''; + }); + setCustomVars(initialCustomVars); + + return { + type: template.taskDefaults.type, + priority: template.taskDefaults.priority, + project: template.taskDefaults.project, + description: '', + }; + } + + // Single-task template + // Extract custom variables from description template and subtasks + const allTemplateText = [ + template.taskDefaults.descriptionTemplate || '', + ...(template.subtaskTemplates?.map(st => st.title) || []) + ].join(' '); + + const customVarNames = extractCustomVariables(allTemplateText); + setRequiredCustomVars(customVarNames); + + // Initialize custom vars + const initialCustomVars: Record<string, string> = {}; + customVarNames.forEach(name => { + initialCustomVars[name] = ''; + }); + setCustomVars(initialCustomVars); + + // Convert subtask templates to actual subtasks + if (template.subtaskTemplates && template.subtaskTemplates.length > 0) { + const now = new Date().toISOString(); + const templateSubtasks: Subtask[] = template.subtaskTemplates + .sort((a, b) => a.order - b.order) + .map(st => ({ + id: nanoid(), + title: st.title, + completed: false, + created: now, + })); + setSubtasks(templateSubtasks); + } else { + setSubtasks([]); + } + + return { + type: template.taskDefaults.type, + priority: template.taskDefaults.priority, + project: template.taskDefaults.project, + description: template.taskDefaults.descriptionTemplate || '', + }; + }; + + const clearTemplate = () => { + setSelectedTemplate(null); + setSubtasks([]); + setCustomVars({}); + setRequiredCustomVars([]); + }; + + const removeSubtask = (id: string) => { + setSubtasks(prev => prev.filter(st => st.id !== id)); + }; + + const createTasks = async ( + title: string, + description: string, + project: string, + sprint: string, + type: string, + priority: string, + ) => { + // Build variable context + const context: VariableContext = { + project: project.trim() || undefined, + author: 'Brad', // TODO: Get from user config + customVars, + }; + + // Check if this is a blueprint template + const template = selectedTemplate ? templates?.find(t => t.id === selectedTemplate) : null; + + if (template?.blueprint && template.blueprint.length > 0) { + // Blueprint: create multiple tasks + await handleBlueprintCreation(template, context, project); + } else { + // Single task creation + // Interpolate variables in description + const interpolatedDescription = interpolateVariables(description, context); + + // Interpolate variables in subtask titles + const interpolatedSubtasks = subtasks.map(st => ({ + ...st, + title: interpolateVariables(st.title, context), + })); + + await createTask.mutateAsync({ + title: title.trim(), + description: interpolatedDescription.trim(), + type, + priority: priority as any, + project: project.trim() || undefined, + sprint: sprint.trim() || undefined, + subtasks: interpolatedSubtasks.length > 0 ? interpolatedSubtasks : undefined, + }); + } + }; + + const handleBlueprintCreation = async ( + template: TaskTemplate, + context: VariableContext, + project: string, + ) => { + if (!template.blueprint) return; + + // Map to store refId -> actual task ID + const refIdToTaskId: Record<string, string> = {}; + + // Create tasks in order, resolving dependencies + for (const blueprintTask of template.blueprint) { + // Interpolate title + const taskTitle = interpolateVariables(blueprintTask.title, context); + + // Interpolate description + const taskDescription = interpolateVariables( + blueprintTask.taskDefaults.descriptionTemplate || '', + context + ); + + // Create subtasks + const taskSubtasks = blueprintTask.subtaskTemplates?.map(st => { + const now = new Date().toISOString(); + return { + id: nanoid(), + title: interpolateVariables(st.title, context), + completed: false, + created: now, + }; + }); + + // Resolve dependencies + const blockedBy = blueprintTask.blockedByRefs?.map(refId => refIdToTaskId[refId]).filter(Boolean); + + // Create the task + const createdTask = await createTask.mutateAsync({ + title: taskTitle, + description: taskDescription, + type: blueprintTask.taskDefaults.type, + priority: blueprintTask.taskDefaults.priority, + project: blueprintTask.taskDefaults.project || project.trim() || undefined, + subtasks: taskSubtasks, + blockedBy, + }); + + // Store the mapping + refIdToTaskId[blueprintTask.refId] = createdTask.id; + } + }; + + return { + selectedTemplate, + templates, + subtasks, + customVars, + requiredCustomVars, + applyTemplate, + clearTemplate, + removeSubtask, + setCustomVars, + createTasks, + isCreating: createTask.isPending, + }; +} diff --git a/web/src/hooks/useTimeTracking.ts b/web/src/hooks/useTimeTracking.ts index edfc14c6..81ac61b3 100644 --- a/web/src/hooks/useTimeTracking.ts +++ b/web/src/hooks/useTimeTracking.ts @@ -1,12 +1,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { Task } from '@veritas-kanban/shared'; +import { api, type TimeSummary } from '../lib/api'; -const API_BASE = '/api'; - -export interface TimeSummary { - byProject: { project: string; totalSeconds: number; taskCount: number }[]; - total: number; -} +export type { TimeSummary }; /** * Get time summary by project @@ -14,13 +10,7 @@ export interface TimeSummary { export function useTimeSummary() { return useQuery<TimeSummary>({ queryKey: ['time', 'summary'], - queryFn: async () => { - const response = await fetch(`${API_BASE}/tasks/time/summary`); - if (!response.ok) { - throw new Error('Failed to get time summary'); - } - return response.json(); - }, + queryFn: () => api.time.getSummary(), }); } @@ -31,18 +21,7 @@ export function useStartTimer() { const queryClient = useQueryClient(); return useMutation<Task, Error, string>({ - mutationFn: async (taskId) => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/time/start`, { - method: 'POST', - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to start timer'); - } - - return response.json(); - }, + mutationFn: (taskId) => api.time.start(taskId), onSuccess: (task) => { queryClient.invalidateQueries({ queryKey: ['tasks'] }); queryClient.setQueryData(['tasks', task.id], task); @@ -58,18 +37,7 @@ export function useStopTimer() { const queryClient = useQueryClient(); return useMutation<Task, Error, string>({ - mutationFn: async (taskId) => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/time/stop`, { - method: 'POST', - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to stop timer'); - } - - return response.json(); - }, + mutationFn: (taskId) => api.time.stop(taskId), onSuccess: (task) => { queryClient.invalidateQueries({ queryKey: ['tasks'] }); queryClient.setQueryData(['tasks', task.id], task); @@ -85,20 +53,7 @@ export function useAddTimeEntry() { const queryClient = useQueryClient(); return useMutation<Task, Error, { taskId: string; duration: number; description?: string }>({ - mutationFn: async ({ taskId, duration, description }) => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/time/entry`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ duration, description }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to add time entry'); - } - - return response.json(); - }, + mutationFn: ({ taskId, duration, description }) => api.time.addEntry(taskId, duration, description), onSuccess: (task) => { queryClient.invalidateQueries({ queryKey: ['tasks'] }); queryClient.setQueryData(['tasks', task.id], task); @@ -114,18 +69,7 @@ export function useDeleteTimeEntry() { const queryClient = useQueryClient(); return useMutation<Task, Error, { taskId: string; entryId: string }>({ - mutationFn: async ({ taskId, entryId }) => { - const response = await fetch(`${API_BASE}/tasks/${taskId}/time/entry/${entryId}`, { - method: 'DELETE', - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to delete time entry'); - } - - return response.json(); - }, + mutationFn: ({ taskId, entryId }) => api.time.deleteEntry(taskId, entryId), onSuccess: (task) => { queryClient.invalidateQueries({ queryKey: ['tasks'] }); queryClient.setQueryData(['tasks', task.id], task); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index cf59721f..8f078615 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -12,9 +12,9 @@ import type { TaskTypeConfig, SprintConfig, FeatureSettings, + Attachment, } from '@veritas-kanban/shared'; - -const API_BASE = '/api'; +import { API_BASE } from './config'; async function handleResponse<T>(response: Response): Promise<T> { if (!response.ok) { @@ -517,6 +517,175 @@ export const api = { return handleResponse<SprintConfig[]>(response); }, }, + + activity: { + list: async (limit: number = 50): Promise<Activity[]> => { + const response = await fetch(`${API_BASE}/activity?limit=${limit}`); + return handleResponse<Activity[]>(response); + }, + + clear: async (): Promise<void> => { + const response = await fetch(`${API_BASE}/activity`, { + method: 'DELETE', + }); + return handleResponse<void>(response); + }, + }, + + attachments: { + list: async (taskId: string): Promise<Attachment[]> => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments`); + return handleResponse<Attachment[]>(response); + }, + + upload: async (taskId: string, formData: FormData): Promise<AttachmentUploadResponse> => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments`, { + method: 'POST', + body: formData, + }); + return handleResponse<AttachmentUploadResponse>(response); + }, + + delete: async (taskId: string, attachmentId: string): Promise<void> => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments/${attachmentId}`, { + method: 'DELETE', + }); + return handleResponse<void>(response); + }, + + getTaskContext: async (taskId: string): Promise<TaskContext> => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/context`); + return handleResponse<TaskContext>(response); + }, + }, + + conflicts: { + getStatus: async (taskId: string): Promise<ConflictStatus> => { + const response = await fetch(`${API_BASE}/conflicts/${taskId}`); + return handleResponse<ConflictStatus>(response); + }, + + getFile: async (taskId: string, filePath: string): Promise<ConflictFile> => { + const response = await fetch(`${API_BASE}/conflicts/${taskId}/file?path=${encodeURIComponent(filePath)}`); + return handleResponse<ConflictFile>(response); + }, + + resolve: async ( + taskId: string, + filePath: string, + resolution: 'ours' | 'theirs' | 'manual', + manualContent?: string + ): Promise<ResolveResult> => { + const response = await fetch(`${API_BASE}/conflicts/${taskId}/resolve?path=${encodeURIComponent(filePath)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ resolution, manualContent }), + }); + return handleResponse<ResolveResult>(response); + }, + + abort: async (taskId: string): Promise<{ success: boolean }> => { + const response = await fetch(`${API_BASE}/conflicts/${taskId}/abort`, { + method: 'POST', + }); + return handleResponse<{ success: boolean }>(response); + }, + + continue: async (taskId: string, message?: string): Promise<{ success: boolean; error?: string }> => { + const response = await fetch(`${API_BASE}/conflicts/${taskId}/continue`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message }), + }); + return handleResponse<{ success: boolean; error?: string }>(response); + }, + }, + + github: { + getStatus: async (): Promise<GitHubStatus> => { + const response = await fetch(`${API_BASE}/github/status`); + return handleResponse<GitHubStatus>(response); + }, + + createPR: async (input: CreatePRInput): Promise<PRInfo> => { + const response = await fetch(`${API_BASE}/github/pr`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + return handleResponse<PRInfo>(response); + }, + + openPR: async (taskId: string): Promise<void> => { + const response = await fetch(`${API_BASE}/github/pr/${taskId}/open`, { + method: 'POST', + }); + return handleResponse<void>(response); + }, + }, + + preview: { + getStatus: async (taskId: string): Promise<PreviewServer | { status: 'stopped' }> => { + const response = await fetch(`${API_BASE}/preview/${taskId}`); + return handleResponse<PreviewServer | { status: 'stopped' }>(response); + }, + + getOutput: async (taskId: string, lines: number = 50): Promise<{ output: string[] }> => { + const response = await fetch(`${API_BASE}/preview/${taskId}/output?lines=${lines}`); + return handleResponse<{ output: string[] }>(response); + }, + + start: async (taskId: string): Promise<PreviewServer> => { + const response = await fetch(`${API_BASE}/preview/${taskId}/start`, { + method: 'POST', + }); + return handleResponse<PreviewServer>(response); + }, + + stop: async (taskId: string): Promise<void> => { + const response = await fetch(`${API_BASE}/preview/${taskId}/stop`, { + method: 'POST', + }); + return handleResponse<void>(response); + }, + }, + + time: { + getSummary: async (): Promise<TimeSummary> => { + const response = await fetch(`${API_BASE}/tasks/time/summary`); + return handleResponse<TimeSummary>(response); + }, + + start: async (taskId: string): Promise<Task> => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/time/start`, { + method: 'POST', + }); + return handleResponse<Task>(response); + }, + + stop: async (taskId: string): Promise<Task> => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/time/stop`, { + method: 'POST', + }); + return handleResponse<Task>(response); + }, + + addEntry: async (taskId: string, duration: number, description?: string): Promise<Task> => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/time/entry`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ duration, description }), + }); + return handleResponse<Task>(response); + }, + + deleteEntry: async (taskId: string, entryId: string): Promise<Task> => { + const response = await fetch(`${API_BASE}/tasks/${taskId}/time/entry/${entryId}`, { + method: 'DELETE', + }); + return handleResponse<Task>(response); + }, + }, }; // Types for archive suggestions @@ -604,6 +773,127 @@ export interface WorktreeInfo { changedFiles: number; } +// Types for activity +export type ActivityType = + | 'task_created' + | 'task_updated' + | 'status_changed' + | 'agent_started' + | 'agent_stopped' + | 'agent_completed' + | 'task_archived' + | 'task_deleted' + | 'worktree_created' + | 'worktree_merged'; + +export interface Activity { + id: string; + type: ActivityType; + taskId: string; + taskTitle: string; + details?: Record<string, unknown>; + timestamp: string; +} + +// Types for attachments +export interface AttachmentUploadResponse { + success: boolean; + attachments: Attachment[]; + task: unknown; +} + +export interface TaskContext { + taskId: string; + title: string; + description: string; + type: string; + status: string; + priority: string; + project?: string; + tags?: string[]; + attachments: { + count: number; + documents: { filename: string; text: string }[]; + images: string[]; + }; + created: string; + updated: string; +} + +// Types for conflicts +export interface ConflictStatus { + hasConflicts: boolean; + conflictingFiles: string[]; + rebaseInProgress: boolean; + mergeInProgress: boolean; +} + +export interface ConflictMarker { + startLine: number; + separatorLine: number; + endLine: number; + oursLines: string[]; + theirsLines: string[]; +} + +export interface ConflictFile { + path: string; + content: string; + oursContent: string; + theirsContent: string; + baseContent: string; + markers: ConflictMarker[]; +} + +export interface ResolveResult { + success: boolean; + remainingConflicts: string[]; +} + +// Types for GitHub +export interface GitHubStatus { + installed: boolean; + authenticated: boolean; + user?: string; +} + +export interface PRInfo { + url: string; + number: number; + title: string; + state: string; + draft: boolean; + headBranch: string; + baseBranch: string; +} + +export interface CreatePRInput { + taskId: string; + title?: string; + body?: string; + targetBranch?: string; + draft?: boolean; +} + +// Types for preview +export interface PreviewServer { + taskId: string; + repoName: string; + pid: number; + port: number; + url: string; + status: 'starting' | 'running' | 'stopped' | 'error'; + startedAt: string; + output: string[]; + error?: string; +} + +// Types for time tracking +export interface TimeSummary { + byProject: { project: string; totalSeconds: number; taskCount: number }[]; + total: number; +} + // Managed List API helpers export const managedList = { /** diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts new file mode 100644 index 00000000..05d259ed --- /dev/null +++ b/web/src/lib/config.ts @@ -0,0 +1,4 @@ +/** + * Shared configuration for the application + */ +export const API_BASE = import.meta.env.VITE_API_URL || '/api'; diff --git a/web/src/lib/template-schema.d.ts b/web/src/lib/template-schema.d.ts deleted file mode 100644 index dc705ce1..00000000 --- a/web/src/lib/template-schema.d.ts +++ /dev/null @@ -1,741 +0,0 @@ -/** - * Zod validation schema for task templates - * Enforces strict validation, size limits, and security checks - */ -import { z } from 'zod'; -/** - * Task template schema with strict validation - */ -export declare const TaskTemplateSchema: z.ZodEffects<z.ZodObject<{ - id: z.ZodOptional<z.ZodString>; - name: z.ZodEffects<z.ZodString, string, string>; - description: z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, string | undefined>; - category: z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, string | undefined>; - version: z.ZodOptional<z.ZodNumber>; - taskDefaults: z.ZodObject<{ - type: z.ZodOptional<z.ZodString>; - priority: z.ZodOptional<z.ZodString>; - project: z.ZodOptional<z.ZodString>; - descriptionTemplate: z.ZodOptional<z.ZodString>; - agent: z.ZodOptional<z.ZodString>; - }, "strict", z.ZodTypeAny, { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }, { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }>; - subtaskTemplates: z.ZodOptional<z.ZodArray<z.ZodObject<{ - title: z.ZodEffects<z.ZodString, string, string>; - order: z.ZodOptional<z.ZodNumber>; - }, "strict", z.ZodTypeAny, { - title: string; - order?: number | undefined; - }, { - title: string; - order?: number | undefined; - }>, "many">>; - blueprint: z.ZodOptional<z.ZodArray<z.ZodObject<{ - refId: z.ZodEffects<z.ZodString, string, string>; - title: z.ZodEffects<z.ZodString, string, string>; - taskDefaults: z.ZodOptional<z.ZodObject<{ - type: z.ZodOptional<z.ZodString>; - priority: z.ZodOptional<z.ZodString>; - project: z.ZodOptional<z.ZodString>; - descriptionTemplate: z.ZodOptional<z.ZodString>; - agent: z.ZodOptional<z.ZodString>; - }, "strict", z.ZodTypeAny, { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }, { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }>>; - subtaskTemplates: z.ZodOptional<z.ZodArray<z.ZodObject<{ - title: z.ZodEffects<z.ZodString, string, string>; - order: z.ZodOptional<z.ZodNumber>; - }, "strict", z.ZodTypeAny, { - title: string; - order?: number | undefined; - }, { - title: string; - order?: number | undefined; - }>, "many">>; - blockedByRefs: z.ZodOptional<z.ZodArray<z.ZodString, "many">>; - }, "strict", z.ZodTypeAny, { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }, { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }>, "many">>; - created: z.ZodOptional<z.ZodString>; - updated: z.ZodOptional<z.ZodString>; -}, "strict", z.ZodTypeAny, { - name: string; - taskDefaults: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }; - id?: string | undefined; - description?: string | undefined; - created?: string | undefined; - updated?: string | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - category?: string | undefined; - blueprint?: { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }[] | undefined; - version?: number | undefined; -}, { - name: string; - taskDefaults: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }; - id?: string | undefined; - description?: string | undefined; - created?: string | undefined; - updated?: string | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - category?: string | undefined; - blueprint?: { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }[] | undefined; - version?: number | undefined; -}>, { - name: string; - taskDefaults: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }; - id?: string | undefined; - description?: string | undefined; - created?: string | undefined; - updated?: string | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - category?: string | undefined; - blueprint?: { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }[] | undefined; - version?: number | undefined; -}, { - name: string; - taskDefaults: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }; - id?: string | undefined; - description?: string | undefined; - created?: string | undefined; - updated?: string | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - category?: string | undefined; - blueprint?: { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }[] | undefined; - version?: number | undefined; -}>; -/** - * Schema for importing templates (single or array) - */ -export declare const TemplateImportSchema: z.ZodUnion<[z.ZodEffects<z.ZodObject<{ - id: z.ZodOptional<z.ZodString>; - name: z.ZodEffects<z.ZodString, string, string>; - description: z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, string | undefined>; - category: z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, string | undefined>; - version: z.ZodOptional<z.ZodNumber>; - taskDefaults: z.ZodObject<{ - type: z.ZodOptional<z.ZodString>; - priority: z.ZodOptional<z.ZodString>; - project: z.ZodOptional<z.ZodString>; - descriptionTemplate: z.ZodOptional<z.ZodString>; - agent: z.ZodOptional<z.ZodString>; - }, "strict", z.ZodTypeAny, { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }, { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }>; - subtaskTemplates: z.ZodOptional<z.ZodArray<z.ZodObject<{ - title: z.ZodEffects<z.ZodString, string, string>; - order: z.ZodOptional<z.ZodNumber>; - }, "strict", z.ZodTypeAny, { - title: string; - order?: number | undefined; - }, { - title: string; - order?: number | undefined; - }>, "many">>; - blueprint: z.ZodOptional<z.ZodArray<z.ZodObject<{ - refId: z.ZodEffects<z.ZodString, string, string>; - title: z.ZodEffects<z.ZodString, string, string>; - taskDefaults: z.ZodOptional<z.ZodObject<{ - type: z.ZodOptional<z.ZodString>; - priority: z.ZodOptional<z.ZodString>; - project: z.ZodOptional<z.ZodString>; - descriptionTemplate: z.ZodOptional<z.ZodString>; - agent: z.ZodOptional<z.ZodString>; - }, "strict", z.ZodTypeAny, { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }, { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }>>; - subtaskTemplates: z.ZodOptional<z.ZodArray<z.ZodObject<{ - title: z.ZodEffects<z.ZodString, string, string>; - order: z.ZodOptional<z.ZodNumber>; - }, "strict", z.ZodTypeAny, { - title: string; - order?: number | undefined; - }, { - title: string; - order?: number | undefined; - }>, "many">>; - blockedByRefs: z.ZodOptional<z.ZodArray<z.ZodString, "many">>; - }, "strict", z.ZodTypeAny, { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }, { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }>, "many">>; - created: z.ZodOptional<z.ZodString>; - updated: z.ZodOptional<z.ZodString>; -}, "strict", z.ZodTypeAny, { - name: string; - taskDefaults: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }; - id?: string | undefined; - description?: string | undefined; - created?: string | undefined; - updated?: string | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - category?: string | undefined; - blueprint?: { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }[] | undefined; - version?: number | undefined; -}, { - name: string; - taskDefaults: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }; - id?: string | undefined; - description?: string | undefined; - created?: string | undefined; - updated?: string | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - category?: string | undefined; - blueprint?: { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }[] | undefined; - version?: number | undefined; -}>, { - name: string; - taskDefaults: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }; - id?: string | undefined; - description?: string | undefined; - created?: string | undefined; - updated?: string | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - category?: string | undefined; - blueprint?: { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }[] | undefined; - version?: number | undefined; -}, { - name: string; - taskDefaults: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }; - id?: string | undefined; - description?: string | undefined; - created?: string | undefined; - updated?: string | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - category?: string | undefined; - blueprint?: { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }[] | undefined; - version?: number | undefined; -}>, z.ZodArray<z.ZodEffects<z.ZodObject<{ - id: z.ZodOptional<z.ZodString>; - name: z.ZodEffects<z.ZodString, string, string>; - description: z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, string | undefined>; - category: z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, string | undefined>; - version: z.ZodOptional<z.ZodNumber>; - taskDefaults: z.ZodObject<{ - type: z.ZodOptional<z.ZodString>; - priority: z.ZodOptional<z.ZodString>; - project: z.ZodOptional<z.ZodString>; - descriptionTemplate: z.ZodOptional<z.ZodString>; - agent: z.ZodOptional<z.ZodString>; - }, "strict", z.ZodTypeAny, { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }, { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }>; - subtaskTemplates: z.ZodOptional<z.ZodArray<z.ZodObject<{ - title: z.ZodEffects<z.ZodString, string, string>; - order: z.ZodOptional<z.ZodNumber>; - }, "strict", z.ZodTypeAny, { - title: string; - order?: number | undefined; - }, { - title: string; - order?: number | undefined; - }>, "many">>; - blueprint: z.ZodOptional<z.ZodArray<z.ZodObject<{ - refId: z.ZodEffects<z.ZodString, string, string>; - title: z.ZodEffects<z.ZodString, string, string>; - taskDefaults: z.ZodOptional<z.ZodObject<{ - type: z.ZodOptional<z.ZodString>; - priority: z.ZodOptional<z.ZodString>; - project: z.ZodOptional<z.ZodString>; - descriptionTemplate: z.ZodOptional<z.ZodString>; - agent: z.ZodOptional<z.ZodString>; - }, "strict", z.ZodTypeAny, { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }, { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }>>; - subtaskTemplates: z.ZodOptional<z.ZodArray<z.ZodObject<{ - title: z.ZodEffects<z.ZodString, string, string>; - order: z.ZodOptional<z.ZodNumber>; - }, "strict", z.ZodTypeAny, { - title: string; - order?: number | undefined; - }, { - title: string; - order?: number | undefined; - }>, "many">>; - blockedByRefs: z.ZodOptional<z.ZodArray<z.ZodString, "many">>; - }, "strict", z.ZodTypeAny, { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }, { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }>, "many">>; - created: z.ZodOptional<z.ZodString>; - updated: z.ZodOptional<z.ZodString>; -}, "strict", z.ZodTypeAny, { - name: string; - taskDefaults: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }; - id?: string | undefined; - description?: string | undefined; - created?: string | undefined; - updated?: string | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - category?: string | undefined; - blueprint?: { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }[] | undefined; - version?: number | undefined; -}, { - name: string; - taskDefaults: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }; - id?: string | undefined; - description?: string | undefined; - created?: string | undefined; - updated?: string | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - category?: string | undefined; - blueprint?: { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }[] | undefined; - version?: number | undefined; -}>, { - name: string; - taskDefaults: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }; - id?: string | undefined; - description?: string | undefined; - created?: string | undefined; - updated?: string | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - category?: string | undefined; - blueprint?: { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }[] | undefined; - version?: number | undefined; -}, { - name: string; - taskDefaults: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - }; - id?: string | undefined; - description?: string | undefined; - created?: string | undefined; - updated?: string | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - category?: string | undefined; - blueprint?: { - title: string; - refId: string; - taskDefaults?: { - type?: string | undefined; - project?: string | undefined; - priority?: string | undefined; - agent?: string | undefined; - descriptionTemplate?: string | undefined; - } | undefined; - subtaskTemplates?: { - title: string; - order?: number | undefined; - }[] | undefined; - blockedByRefs?: string[] | undefined; - }[] | undefined; - version?: number | undefined; -}>, "many">]>; -/** - * Type exports - */ -export type ValidatedTemplate = z.infer<typeof TaskTemplateSchema>; -export type TemplateImport = z.infer<typeof TemplateImportSchema>; diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 00000000..c57d674b --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// <reference types="vite/client" /> + +interface ImportMetaEnv { + readonly VITE_API_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +}