feat: Sprint US-1200 Refactoring batch — 13 tasks complete

Completed refactors:
- RF-02: Fix dependency vulnerabilities (xlsx → exceljs, Hono updates)
- RF-05: Add React error boundaries (FeatureErrorBoundary wrapper)
- RF-06: Server error handling middleware (AppError classes, asyncHandler)
- RF-10: Split shared types.ts into domain modules (6 files)
- RF-11: Consolidate frontend API layer (hooks now use api.ts)
- RF-13: TaskConfigContext — eliminate prop drilling
- RF-14: Split god components (GitSection, TaskDetailPanel, CreateTaskDialog, DiffViewer)
- RF-16: Frontend accessibility (ARIA labels, sr-only text)
- RF-17: Modularize CLI (899 → commands/ structure)
- RF-18: Modularize MCP (843 → tools/ structure)
- RF-19: Create shared API client library
- RF-21: Server performance (batch loading, memory limits, timeouts, graceful shutdown)
- RF-23: Extract shared utilities (path, format, constants)

Stats: ~59 files changed, significant code reduction through modularization
This commit is contained in:
Brad Groux 2026-01-28 06:08:59 -06:00
parent 01e0e29eb9
commit 39eccf3556
98 changed files with 7591 additions and 7108 deletions

View file

@ -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": ""
},
{

View file

@ -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"
}
]

View file

@ -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"}

@ -1 +0,0 @@
Subproject commit 88bf96a77f887086d7d88a535ceb44deb512a469

View file

@ -12,6 +12,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@veritas-kanban/shared": "workspace:*",
"commander": "^12.1.0",
"chalk": "^5.3.0"
},

189
cli/src/commands/agents.ts Normal file
View file

@ -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 <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 {
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 <id>')
.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 <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);
}
});
}

View file

@ -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<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 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 <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 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);
}
});
}

View file

@ -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 <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);
}
});
}

View file

@ -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);
}
});
}

218
cli/src/commands/tasks.ts Normal file
View file

@ -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);
}
});
}

View file

@ -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();

2
cli/src/utils/api.ts Normal file
View file

@ -0,0 +1,2 @@
// Re-export shared API client
export { api, createApiClient, API_BASE } from '@veritas-kanban/shared';

2
cli/src/utils/find.ts Normal file
View file

@ -0,0 +1,2 @@
// Re-export shared findTask
export { findTask } from '@veritas-kanban/shared';

68
cli/src/utils/format.ts Normal file
View file

@ -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);
}

2
cli/src/utils/types.ts Normal file
View file

@ -0,0 +1,2 @@
// Re-export shared types
export type { Task } from '@veritas-kanban/shared';

View file

@ -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"
}
}

View file

@ -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: [

113
mcp/src/tools/agents.ts Normal file
View file

@ -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}`);
}
}

153
mcp/src/tools/automation.ts Normal file
View file

@ -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}`);
}
}

View file

@ -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}`);
}
}

48
mcp/src/tools/summary.ts Normal file
View file

@ -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}`);
}
}

300
mcp/src/tools/tasks.ts Normal file
View file

@ -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}`);
}
}

2
mcp/src/utils/api.ts Normal file
View file

@ -0,0 +1,2 @@
// Re-export shared API client
export { api, createApiClient, API_BASE } from '@veritas-kanban/shared';

2
mcp/src/utils/find.ts Normal file
View file

@ -0,0 +1,2 @@
// Re-export shared findTask
export { findTask } from '@veritas-kanban/shared';

2
mcp/src/utils/types.ts Normal file
View file

@ -0,0 +1,2 @@
// Re-export shared types
export type { Task } from '@veritas-kanban/shared';

View file

@ -25,5 +25,10 @@
"prettier": "^3.4.0",
"typescript": "^5.7.0",
"vitest": "^4.0.18"
},
"pnpm": {
"overrides": {
"hono": ">=4.11.7"
}
}
}

545
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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');

View file

@ -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(`

View file

@ -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);

View file

@ -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' });
}

View file

@ -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;

View file

@ -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 };

View file

@ -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 };

View file

@ -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;

View file

@ -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 };

View file

@ -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;

File diff suppressed because it is too large Load diff

View file

@ -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');

View file

@ -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, {

View file

@ -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);
}

View file

@ -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({

View file

@ -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);
});

View file

@ -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) {

View file

@ -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);

View file

@ -15,5 +15,5 @@
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/__tests__/**"]
}

View file

@ -1,3 +1,4 @@
// Veritas Kanban - Shared Types
export * from './types.js';
export * from './utils/index.js';

View file

@ -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';

View file

@ -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,
},
};

View file

@ -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';

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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[];
}

View file

@ -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;
};
}

View file

@ -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;
}

View file

@ -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'
};

109
shared/src/utils/format.ts Normal file
View file

@ -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);
}

View file

@ -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';

16
shared/src/utils/path.ts Normal file
View file

@ -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] || '');
}

View file

@ -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>
);

View file

@ -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>

View file

@ -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}

View file

@ -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)}

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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}
/>
)}
</>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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'] });
},

View file

@ -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,
});
}

View file

@ -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] });

View file

@ -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 };
}

View file

@ -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),
});
}

View file

@ -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] });
},

View file

@ -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,
};
}

View file

@ -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);

View file

@ -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 = {
/**

4
web/src/lib/config.ts Normal file
View file

@ -0,0 +1,4 @@
/**
* Shared configuration for the application
*/
export const API_BASE = import.meta.env.VITE_API_URL || '/api';

View file

@ -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>;

9
web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}