From a489c5358f8360ca348be56f5f252fe9f3daaf9e Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Mon, 26 Jan 2026 02:34:54 -0600 Subject: [PATCH] feat: initial project scaffolding - Dev container with Node.js 22 - pnpm workspace monorepo structure - Express + WebSocket server - React + Vite + shadcn/ui frontend - Shared TypeScript types package - Kanban board with drag-and-drop - Task CRUD with file-based persistence - Dark mode styling Sprint 1 - US-101: Project scaffolding with dev container --- .devcontainer/devcontainer.json | 32 ++++ .gitignore | 37 ++++ .prettierrc | 7 + .veritas-kanban/.gitkeep | 0 README.md | 101 +++++++++++ package.json | 28 +++ pnpm-workspace.yaml | 4 + server/package.json | 34 ++++ server/src/index.ts | 50 ++++++ server/src/routes/tasks.ts | 115 ++++++++++++ server/src/services/task-service.ts | 175 +++++++++++++++++++ server/tsconfig.json | 19 ++ shared/package.json | 22 +++ shared/src/index.ts | 3 + shared/src/types.ts | 139 +++++++++++++++ shared/tsconfig.json | 19 ++ tasks/active/.gitkeep | 0 tasks/archive/.gitkeep | 0 web/index.html | 13 ++ web/package.json | 45 +++++ web/postcss.config.js | 6 + web/src/App.tsx | 17 ++ web/src/components/board/KanbanBoard.tsx | 107 ++++++++++++ web/src/components/board/KanbanColumn.tsx | 53 ++++++ web/src/components/layout/Header.tsx | 39 +++++ web/src/components/task/CreateTaskDialog.tsx | 144 +++++++++++++++ web/src/components/task/TaskCard.tsx | 87 +++++++++ web/src/components/ui/button.tsx | 52 ++++++ web/src/components/ui/dialog.tsx | 107 ++++++++++++ web/src/components/ui/input.tsx | 23 +++ web/src/components/ui/label.tsx | 23 +++ web/src/components/ui/select.tsx | 89 ++++++++++ web/src/components/ui/textarea.tsx | 22 +++ web/src/components/ui/toaster.tsx | 4 + web/src/globals.css | 76 ++++++++ web/src/hooks/useTasks.ts | 71 ++++++++ web/src/lib/api.ts | 60 +++++++ web/src/lib/utils.ts | 6 + web/src/main.tsx | 22 +++ web/tailwind.config.js | 49 ++++++ web/tsconfig.json | 25 +++ web/tsconfig.node.json | 11 ++ web/vite.config.ts | 25 +++ 43 files changed, 1961 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 .veritas-kanban/.gitkeep create mode 100644 README.md create mode 100644 package.json create mode 100644 pnpm-workspace.yaml create mode 100644 server/package.json create mode 100644 server/src/index.ts create mode 100644 server/src/routes/tasks.ts create mode 100644 server/src/services/task-service.ts create mode 100644 server/tsconfig.json create mode 100644 shared/package.json create mode 100644 shared/src/index.ts create mode 100644 shared/src/types.ts create mode 100644 shared/tsconfig.json create mode 100644 tasks/active/.gitkeep create mode 100644 tasks/archive/.gitkeep create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/postcss.config.js create mode 100644 web/src/App.tsx create mode 100644 web/src/components/board/KanbanBoard.tsx create mode 100644 web/src/components/board/KanbanColumn.tsx create mode 100644 web/src/components/layout/Header.tsx create mode 100644 web/src/components/task/CreateTaskDialog.tsx create mode 100644 web/src/components/task/TaskCard.tsx create mode 100644 web/src/components/ui/button.tsx create mode 100644 web/src/components/ui/dialog.tsx create mode 100644 web/src/components/ui/input.tsx create mode 100644 web/src/components/ui/label.tsx create mode 100644 web/src/components/ui/select.tsx create mode 100644 web/src/components/ui/textarea.tsx create mode 100644 web/src/components/ui/toaster.tsx create mode 100644 web/src/globals.css create mode 100644 web/src/hooks/useTasks.ts create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/utils.ts create mode 100644 web/src/main.tsx create mode 100644 web/tailwind.config.js create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..1fa3b58b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,32 @@ +{ + "name": "Veritas Kanban", + "image": "mcr.microsoft.com/devcontainers/typescript-node:22", + "features": { + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "bradlc.vscode-tailwindcss", + "formulahendry.auto-rename-tag", + "christian-kohler.path-intellisense", + "ms-vscode.vscode-typescript-next" + ], + "settings": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "typescript.preferences.importModuleSpecifier": "relative", + "typescript.updateImportsOnFileMove.enabled": "always" + } + } + }, + "forwardPorts": [3000, 3001], + "postCreateCommand": "corepack enable && pnpm install", + "remoteUser": "node" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..656b4ff5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +build/ +.next/ + +# IDE +.idea/ +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Env +.env +.env.local +.env.*.local + +# Task data (track structure, not content) +tasks/active/*.md +tasks/archive/*.md +.veritas-kanban/logs/ +.veritas-kanban/config.json + +# Test +coverage/ +.nyc_output/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..1f4c4bbc --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100 +} diff --git a/.veritas-kanban/.gitkeep b/.veritas-kanban/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/README.md b/README.md new file mode 100644 index 00000000..80339595 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# Veritas Kanban + +A local-first task management and AI agent orchestration platform built for Brad Groux and Veritas. + +## Features + +- 📋 **Kanban Board** - Visual task management with drag-and-drop +- 🤖 **AI Agent Orchestration** - Spawn Claude Code, Amp, Copilot, Gemini +- 🌳 **Git Worktrees** - Isolated branches for each coding task +- 📝 **Markdown Storage** - Human-readable task files with frontmatter +- 🔍 **Code Review** - Diff viewing and inline comments +- 🌙 **Dark Mode** - Easy on the eyes + +## Quick Start + +```bash +# Clone the repo +git clone https://github.com/dm-bradgroux/veritas-kanban.git +cd veritas-kanban + +# Install dependencies +pnpm install + +# Start development +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) in your browser. + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| Runtime | Node.js 22+ | +| Language | TypeScript (strict) | +| Server | Express + WebSocket | +| Frontend | React 19 + Vite + shadcn/ui | +| Persistence | Markdown files (gray-matter) | +| Git | simple-git | + +## Project Structure + +``` +veritas-kanban/ +├── .devcontainer/ # Dev container config +├── server/ # Express API + WebSocket +├── web/ # React frontend +├── shared/ # Shared TypeScript types +├── tasks/ # Task storage (markdown files) +│ ├── active/ # Current tasks +│ └── archive/ # Completed tasks +└── .veritas-kanban/ # Config and runtime data +``` + +## Repositories + +- **Work**: https://github.com/dm-bradgroux/veritas-kanban +- **Personal**: https://github.com/BradGroux/veritas-kanban + +## Development + +### Prerequisites + +- Node.js 22+ +- pnpm 9+ + +### Commands + +```bash +pnpm dev # Start dev server (frontend + backend) +pnpm build # Build for production +pnpm typecheck # Run TypeScript checks +pnpm lint # Run ESLint +``` + +### Dev Container + +This project includes a VS Code Dev Container configuration. Open in VS Code and select "Reopen in Container" for a consistent development environment. + +## Task File Format + +Tasks are stored as markdown files with YAML frontmatter: + +```markdown +--- +id: "task_20260126_abc123" +title: "Implement feature X" +type: "code" +status: "in-progress" +priority: "high" +project: "rubicon" +--- + +## Description + +Details about the task... +``` + +## License + +MIT diff --git a/package.json b/package.json new file mode 100644 index 00000000..04c0527a --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "veritas-kanban", + "version": "0.1.0", + "private": true, + "description": "Local-first task management and AI agent orchestration platform", + "author": "Brad Groux ", + "license": "MIT", + "type": "module", + "engines": { + "node": ">=22.0.0", + "pnpm": ">=9.0.0" + }, + "packageManager": "pnpm@9.15.4", + "scripts": { + "dev": "concurrently -n server,web -c blue,green \"pnpm --filter server dev\" \"pnpm --filter web dev\"", + "build": "pnpm --filter server build && pnpm --filter web build", + "lint": "pnpm -r lint", + "typecheck": "pnpm -r typecheck", + "test": "pnpm -r test", + "clean": "pnpm -r clean && rm -rf node_modules" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "concurrently": "^9.1.0", + "prettier": "^3.4.0", + "typescript": "^5.7.0" + } +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..25d38753 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - "server" + - "web" + - "shared" diff --git a/server/package.json b/server/package.json new file mode 100644 index 00000000..490b8a56 --- /dev/null +++ b/server/package.json @@ -0,0 +1,34 @@ +{ + "name": "@veritas-kanban/server", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit", + "lint": "eslint src --ext .ts", + "clean": "rm -rf dist" + }, + "dependencies": { + "@veritas-kanban/shared": "workspace:*", + "cors": "^2.8.5", + "express": "^4.21.0", + "gray-matter": "^4.0.3", + "nanoid": "^5.0.9", + "simple-git": "^3.27.0", + "slugify": "^1.6.6", + "ws": "^8.18.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/ws": "^8.5.13", + "eslint": "^9.17.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 00000000..827a6334 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,50 @@ +import express from 'express'; +import cors from 'cors'; +import { WebSocketServer } from 'ws'; +import { createServer } from 'http'; +import { taskRoutes } from './routes/tasks.js'; + +const app = express(); +const PORT = process.env.PORT || 3001; + +// Middleware +app.use(cors()); +app.use(express.json()); + +// Health check +app.get('/health', (_req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// API Routes +app.use('/api/tasks', taskRoutes); + +// Create HTTP server +const server = createServer(app); + +// WebSocket server for real-time updates +const wss = new WebSocketServer({ server, path: '/ws' }); + +wss.on('connection', (ws) => { + console.log('WebSocket client connected'); + + ws.on('close', () => { + console.log('WebSocket client disconnected'); + }); +}); + +// Export for use in other modules +export { wss }; + +// Start server +server.listen(PORT, () => { + console.log(` +╔═══════════════════════════════════════════════╗ +║ Veritas Kanban Server ║ +╠═══════════════════════════════════════════════╣ +║ API: http://localhost:${PORT} ║ +║ WebSocket: ws://localhost:${PORT}/ws ║ +║ Health: http://localhost:${PORT}/health ║ +╚═══════════════════════════════════════════════╝ + `); +}); diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts new file mode 100644 index 00000000..b74ca498 --- /dev/null +++ b/server/src/routes/tasks.ts @@ -0,0 +1,115 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { TaskService } from '../services/task-service.js'; +import type { CreateTaskInput, UpdateTaskInput } from '@veritas-kanban/shared'; + +const router = Router(); +const taskService = new TaskService(); + +// Validation schemas +const createTaskSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().optional().default(''), + type: z.enum(['code', 'research', 'content', 'automation']).optional().default('code'), + priority: z.enum(['low', 'medium', 'high']).optional().default('medium'), + project: z.string().optional(), + tags: z.array(z.string()).optional(), +}); + +const updateTaskSchema = z.object({ + title: z.string().min(1).max(200).optional(), + description: z.string().optional(), + type: z.enum(['code', 'research', 'content', 'automation']).optional(), + status: z.enum(['todo', 'in-progress', 'review', 'done']).optional(), + priority: z.enum(['low', 'medium', 'high']).optional(), + project: z.string().optional(), + tags: z.array(z.string()).optional(), +}); + +// GET /api/tasks - List all tasks +router.get('/', async (_req, res) => { + try { + const tasks = await taskService.listTasks(); + res.json(tasks); + } catch (error) { + console.error('Error listing tasks:', error); + res.status(500).json({ error: 'Failed to list tasks' }); + } +}); + +// GET /api/tasks/:id - Get single task +router.get('/:id', async (req, res) => { + try { + const task = await taskService.getTask(req.params.id); + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + res.json(task); + } catch (error) { + console.error('Error getting task:', error); + res.status(500).json({ error: 'Failed to get task' }); + } +}); + +// POST /api/tasks - Create task +router.post('/', async (req, res) => { + try { + const input = createTaskSchema.parse(req.body) as CreateTaskInput; + const task = await taskService.createTask(input); + res.status(201).json(task); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ error: 'Validation failed', details: error.errors }); + } + console.error('Error creating task:', error); + res.status(500).json({ error: 'Failed to create task' }); + } +}); + +// PATCH /api/tasks/:id - Update task +router.patch('/:id', async (req, res) => { + try { + const input = updateTaskSchema.parse(req.body) as UpdateTaskInput; + const task = await taskService.updateTask(req.params.id, input); + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + res.json(task); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ error: 'Validation failed', details: error.errors }); + } + console.error('Error updating task:', error); + res.status(500).json({ error: 'Failed to update task' }); + } +}); + +// DELETE /api/tasks/:id - Delete task (move to archive) +router.delete('/:id', async (req, res) => { + try { + const success = await taskService.deleteTask(req.params.id); + if (!success) { + return res.status(404).json({ error: 'Task not found' }); + } + res.status(204).send(); + } catch (error) { + console.error('Error deleting task:', error); + res.status(500).json({ error: 'Failed to delete task' }); + } +}); + +// POST /api/tasks/:id/archive - Archive task +router.post('/:id/archive', async (req, res) => { + try { + const success = await taskService.archiveTask(req.params.id); + if (!success) { + return res.status(404).json({ error: 'Task not found' }); + } + res.json({ archived: true }); + } catch (error) { + console.error('Error archiving task:', error); + res.status(500).json({ error: 'Failed to archive task' }); + } +}); + +export { router as taskRoutes }; diff --git a/server/src/services/task-service.ts b/server/src/services/task-service.ts new file mode 100644 index 00000000..bb63bc20 --- /dev/null +++ b/server/src/services/task-service.ts @@ -0,0 +1,175 @@ +import fs from 'fs/promises'; +import path from 'path'; +import matter from 'gray-matter'; +import { nanoid } from 'nanoid'; +import slugify from 'slugify'; +import type { Task, CreateTaskInput, UpdateTaskInput } from '@veritas-kanban/shared'; + +const TASKS_DIR = path.join(process.cwd(), 'tasks', 'active'); +const ARCHIVE_DIR = path.join(process.cwd(), 'tasks', 'archive'); + +export class TaskService { + constructor() { + this.ensureDirectories(); + } + + private async ensureDirectories(): Promise { + await fs.mkdir(TASKS_DIR, { recursive: true }); + await fs.mkdir(ARCHIVE_DIR, { recursive: true }); + } + + private generateId(): string { + const date = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + return `task_${date}_${nanoid(6)}`; + } + + private taskToFilename(task: Task): string { + const slug = slugify(task.title, { lower: true, strict: true }).slice(0, 50); + return `${task.id}-${slug}.md`; + } + + private taskToMarkdown(task: Task): string { + const { description, reviewComments, ...frontmatter } = task; + + const content = matter.stringify(description || '', frontmatter); + + // Add review comments section if present + if (reviewComments && reviewComments.length > 0) { + const commentsSection = reviewComments + .map(c => `- **${c.file}:${c.line}** - ${c.content}`) + .join('\n'); + return content + '\n\n## Review Comments\n\n' + commentsSection; + } + + return content; + } + + private parseTaskFile(content: string, filename: string): Task { + const { data, content: description } = matter(content); + + // Extract review comments from description if present + let cleanDescription = description; + const reviewComments: Task['reviewComments'] = []; + + const reviewSection = description.indexOf('## Review Comments'); + if (reviewSection !== -1) { + cleanDescription = description.slice(0, reviewSection).trim(); + } + + return { + id: data.id || filename.split('-')[0], + title: data.title || 'Untitled', + description: cleanDescription.trim(), + type: data.type || 'code', + status: data.status || 'todo', + priority: data.priority || 'medium', + project: data.project, + tags: data.tags, + created: data.created || new Date().toISOString(), + updated: data.updated || new Date().toISOString(), + git: data.git, + attempt: data.attempt, + attempts: data.attempts, + reviewComments, + }; + } + + async listTasks(): Promise { + await this.ensureDirectories(); + + const files = await fs.readdir(TASKS_DIR); + const mdFiles = files.filter(f => f.endsWith('.md')); + + const tasks = await Promise.all( + mdFiles.map(async (filename) => { + const filepath = path.join(TASKS_DIR, filename); + const content = await fs.readFile(filepath, 'utf-8'); + return this.parseTaskFile(content, filename); + }) + ); + + // Sort by updated date, newest first + return tasks.sort((a, b) => + new Date(b.updated).getTime() - new Date(a.updated).getTime() + ); + } + + async getTask(id: string): Promise { + const tasks = await this.listTasks(); + return tasks.find(t => t.id === id) || null; + } + + async createTask(input: CreateTaskInput): Promise { + const now = new Date().toISOString(); + + const task: Task = { + id: this.generateId(), + title: input.title, + description: input.description || '', + type: input.type || 'code', + status: 'todo', + priority: input.priority || 'medium', + project: input.project, + tags: input.tags, + created: now, + updated: now, + }; + + const filename = this.taskToFilename(task); + const filepath = path.join(TASKS_DIR, filename); + const content = this.taskToMarkdown(task); + + await fs.writeFile(filepath, content, 'utf-8'); + + return task; + } + + async updateTask(id: string, input: UpdateTaskInput): Promise { + const task = await this.getTask(id); + if (!task) return null; + + const updatedTask: Task = { + ...task, + ...input, + updated: new Date().toISOString(), + }; + + // Remove old file if title changed (filename changes) + const oldFilename = this.taskToFilename(task); + const newFilename = this.taskToFilename(updatedTask); + + if (oldFilename !== newFilename) { + await fs.unlink(path.join(TASKS_DIR, oldFilename)).catch(() => {}); + } + + const filepath = path.join(TASKS_DIR, newFilename); + const content = this.taskToMarkdown(updatedTask); + + await fs.writeFile(filepath, content, 'utf-8'); + + return updatedTask; + } + + async deleteTask(id: string): Promise { + const task = await this.getTask(id); + if (!task) return false; + + const filename = this.taskToFilename(task); + await fs.unlink(path.join(TASKS_DIR, filename)); + + return true; + } + + async archiveTask(id: string): Promise { + const task = await this.getTask(id); + if (!task) return false; + + const filename = this.taskToFilename(task); + const sourcePath = path.join(TASKS_DIR, filename); + const destPath = path.join(ARCHIVE_DIR, filename); + + await fs.rename(sourcePath, destPath); + + return true; + } +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 00000000..aef3e691 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 00000000..4e6b2f68 --- /dev/null +++ b/shared/package.json @@ -0,0 +1,22 @@ +{ + "name": "@veritas-kanban/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "devDependencies": { + "typescript": "^5.7.0" + } +} diff --git a/shared/src/index.ts b/shared/src/index.ts new file mode 100644 index 00000000..6099434e --- /dev/null +++ b/shared/src/index.ts @@ -0,0 +1,3 @@ +// Veritas Kanban - Shared Types + +export * from './types.js'; diff --git a/shared/src/types.ts b/shared/src/types.ts new file mode 100644 index 00000000..57889028 --- /dev/null +++ b/shared/src/types.ts @@ -0,0 +1,139 @@ +// Task Types + +export type TaskType = 'code' | 'research' | 'content' | 'automation'; +export type TaskStatus = 'todo' | 'in-progress' | 'review' | 'done'; +export type TaskPriority = 'low' | 'medium' | 'high'; +export type AgentType = 'claude-code' | 'amp' | 'copilot' | 'gemini'; +export type AttemptStatus = 'pending' | 'running' | 'complete' | 'failed'; + +export interface TaskGit { + repo: string; + branch: string; + baseBranch: string; + worktreePath?: string; +} + +export interface TaskAttempt { + id: string; + agent: AgentType; + status: AttemptStatus; + started?: string; + ended?: string; +} + +export interface Task { + id: string; + title: string; + description: string; + type: TaskType; + status: TaskStatus; + priority: TaskPriority; + project?: string; + tags?: 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[]; +} + +export interface ReviewComment { + id: string; + file: string; + line: number; + content: string; + created: string; +} + +// API Types + +export interface CreateTaskInput { + title: string; + description?: string; + type?: TaskType; + priority?: TaskPriority; + project?: string; + tags?: string[]; +} + +export interface UpdateTaskInput { + title?: string; + description?: string; + type?: TaskType; + status?: TaskStatus; + priority?: TaskPriority; + project?: string; + tags?: string[]; + git?: Partial; +} + +export interface TaskFilters { + status?: TaskStatus | TaskStatus[]; + type?: TaskType | TaskType[]; + project?: string; + search?: string; +} + +// Config Types + +export interface RepoConfig { + name: string; + path: string; + defaultBranch: string; +} + +export interface AgentConfig { + type: AgentType; + name: string; + command: string; + args: string[]; + enabled: boolean; +} + +export interface AppConfig { + repos: RepoConfig[]; + agents: AgentConfig[]; + defaultAgent: AgentType; +} + +// 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; + }; +} diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 00000000..710db5d2 --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/tasks/active/.gitkeep b/tasks/active/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tasks/archive/.gitkeep b/tasks/archive/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/web/index.html b/web/index.html new file mode 100644 index 00000000..172b25a2 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Veritas Kanban + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 00000000..02d4ef72 --- /dev/null +++ b/web/package.json @@ -0,0 +1,45 @@ +{ + "name": "@veritas-kanban/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint": "eslint src --ext .ts,.tsx", + "clean": "rm -rf dist" + }, + "dependencies": { + "@veritas-kanban/shared": "workspace:*", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-select": "^2.1.4", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-toast": "^1.2.4", + "@tanstack/react-query": "^5.62.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.20", + "eslint": "^9.17.0", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 00000000..2aa7205d --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 00000000..75be4615 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,17 @@ +import { KanbanBoard } from './components/board/KanbanBoard'; +import { Header } from './components/layout/Header'; +import { Toaster } from './components/ui/toaster'; + +function App() { + return ( +
+
+
+ +
+ +
+ ); +} + +export default App; diff --git a/web/src/components/board/KanbanBoard.tsx b/web/src/components/board/KanbanBoard.tsx new file mode 100644 index 00000000..aef1367f --- /dev/null +++ b/web/src/components/board/KanbanBoard.tsx @@ -0,0 +1,107 @@ +import { useTasks, useTasksByStatus, useUpdateTask } from '@/hooks/useTasks'; +import { KanbanColumn } from './KanbanColumn'; +import type { TaskStatus } from '@veritas-kanban/shared'; +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + closestCenter, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { useState } from 'react'; +import { TaskCard } from '@/components/task/TaskCard'; +import type { Task } from '@veritas-kanban/shared'; + +const COLUMNS: { id: TaskStatus; title: string }[] = [ + { id: 'todo', title: 'To Do' }, + { id: 'in-progress', title: 'In Progress' }, + { id: 'review', title: 'Review' }, + { id: 'done', title: 'Done' }, +]; + +export function KanbanBoard() { + const { data: tasks, isLoading, error } = useTasks(); + const tasksByStatus = useTasksByStatus(tasks); + const updateTask = useUpdateTask(); + const [activeTask, setActiveTask] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + const handleDragStart = (event: DragStartEvent) => { + const task = tasks?.find(t => t.id === event.active.id); + if (task) { + setActiveTask(task); + } + }; + + const handleDragEnd = (event: DragEndEvent) => { + setActiveTask(null); + + const { active, over } = event; + if (!over) return; + + const taskId = active.id as string; + const newStatus = over.id as TaskStatus; + + const task = tasks?.find(t => t.id === taskId); + if (task && task.status !== newStatus) { + updateTask.mutate({ + id: taskId, + input: { status: newStatus }, + }); + } + }; + + if (isLoading) { + return ( +
+
Loading tasks...
+
+ ); + } + + if (error) { + return ( +
+
+ Error loading tasks: {error.message} +
+
+ ); + } + + return ( + +
+ {COLUMNS.map(column => ( + + ))} +
+ + + {activeTask ? ( + + ) : null} + +
+ ); +} diff --git a/web/src/components/board/KanbanColumn.tsx b/web/src/components/board/KanbanColumn.tsx new file mode 100644 index 00000000..2b63357a --- /dev/null +++ b/web/src/components/board/KanbanColumn.tsx @@ -0,0 +1,53 @@ +import { useDroppable } from '@dnd-kit/core'; +import { cn } from '@/lib/utils'; +import { TaskCard } from '@/components/task/TaskCard'; +import type { Task, TaskStatus } from '@veritas-kanban/shared'; + +interface KanbanColumnProps { + id: TaskStatus; + title: string; + tasks: Task[]; +} + +const columnColors: Record = { + 'todo': 'border-t-slate-500', + 'in-progress': 'border-t-blue-500', + 'review': 'border-t-amber-500', + 'done': 'border-t-green-500', +}; + +export function KanbanColumn({ id, title, tasks }: KanbanColumnProps) { + const { setNodeRef, isOver } = useDroppable({ id }); + + return ( +
+
+

+ {title} +

+ + {tasks.length} + +
+ +
+ {tasks.length === 0 ? ( +
+ No tasks +
+ ) : ( + tasks.map(task => ( + + )) + )} +
+
+ ); +} diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx new file mode 100644 index 00000000..8a23e175 --- /dev/null +++ b/web/src/components/layout/Header.tsx @@ -0,0 +1,39 @@ +import { Plus, Settings } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { CreateTaskDialog } from '@/components/task/CreateTaskDialog'; +import { useState } from 'react'; + +export function Header() { + const [createOpen, setCreateOpen] = useState(false); + + return ( +
+
+
+
+
+ ⚖️ +

Veritas Kanban

+
+
+ +
+ + +
+
+
+ + +
+ ); +} diff --git a/web/src/components/task/CreateTaskDialog.tsx b/web/src/components/task/CreateTaskDialog.tsx new file mode 100644 index 00000000..6a3cce11 --- /dev/null +++ b/web/src/components/task/CreateTaskDialog.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useCreateTask } from '@/hooks/useTasks'; +import type { TaskType, TaskPriority } from '@veritas-kanban/shared'; + +interface CreateTaskDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) { + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [type, setType] = useState('code'); + const [priority, setPriority] = useState('medium'); + const [project, setProject] = useState(''); + + const createTask = useCreateTask(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!title.trim()) return; + + await createTask.mutateAsync({ + title: title.trim(), + description: description.trim(), + type, + priority, + project: project.trim() || undefined, + }); + + // Reset form + setTitle(''); + setDescription(''); + setType('code'); + setPriority('medium'); + setProject(''); + onOpenChange(false); + }; + + return ( + + +
+ + Create New Task + + +
+
+ + setTitle(e.target.value)} + placeholder="Enter task title..." + autoFocus + /> +
+ +
+ +