summaryrefslogtreecommitdiff
path: root/admin-panel/src
diff options
context:
space:
mode:
Diffstat (limited to 'admin-panel/src')
-rw-r--r--admin-panel/src/client/components/agent-log.ts2
-rw-r--r--admin-panel/src/client/components/board.ts123
-rw-r--r--admin-panel/src/client/components/diff-viewer.ts2
-rw-r--r--admin-panel/src/client/components/pipeline-controls.ts2
-rw-r--r--admin-panel/src/client/components/project-form.ts2
-rw-r--r--admin-panel/src/client/components/task-card.ts40
-rw-r--r--admin-panel/src/client/components/task-modal.ts271
-rw-r--r--admin-panel/src/client/index.html22
-rw-r--r--admin-panel/src/client/lib/api.ts155
-rw-r--r--admin-panel/src/client/lib/markdown.ts39
-rw-r--r--admin-panel/src/client/lib/ws.ts79
-rw-r--r--admin-panel/src/client/main.ts63
-rw-r--r--admin-panel/src/client/pages/dashboard.ts150
-rw-r--r--admin-panel/src/client/pages/project.ts167
-rw-r--r--admin-panel/src/client/pages/settings.ts59
-rw-r--r--admin-panel/src/client/styles/main.css724
-rw-r--r--admin-panel/src/server/agents/base-agent.ts83
-rw-r--r--admin-panel/src/server/agents/builder.ts64
-rw-r--r--admin-panel/src/server/agents/critic.ts55
-rw-r--r--admin-panel/src/server/agents/inspector.ts54
-rw-r--r--admin-panel/src/server/agents/planner.ts45
-rw-r--r--admin-panel/src/server/agents/ranger.ts54
-rw-r--r--admin-panel/src/server/agents/shield.ts62
-rw-r--r--admin-panel/src/server/db.ts82
-rw-r--r--admin-panel/src/server/index.ts46
-rw-r--r--admin-panel/src/server/routes/dashboard.ts138
-rw-r--r--admin-panel/src/server/routes/gerrit.ts68
-rw-r--r--admin-panel/src/server/routes/pipeline.ts82
-rw-r--r--admin-panel/src/server/routes/projects.ts138
-rw-r--r--admin-panel/src/server/routes/settings.ts35
-rw-r--r--admin-panel/src/server/routes/tasks.ts248
-rw-r--r--admin-panel/src/server/schema.sql73
-rw-r--r--admin-panel/src/server/services/claude.service.ts47
-rw-r--r--admin-panel/src/server/services/gerrit.service.ts81
-rw-r--r--admin-panel/src/server/services/git.service.ts142
-rw-r--r--admin-panel/src/server/services/pipeline.service.ts304
-rw-r--r--admin-panel/src/server/templates/builder.md47
-rw-r--r--admin-panel/src/server/templates/critic.md47
-rw-r--r--admin-panel/src/server/templates/inspector.md61
-rw-r--r--admin-panel/src/server/templates/planner.md48
-rw-r--r--admin-panel/src/server/templates/ranger.md50
-rw-r--r--admin-panel/src/server/templates/shield.md40
-rw-r--r--admin-panel/src/server/ws.ts56
43 files changed, 4150 insertions, 0 deletions
diff --git a/admin-panel/src/client/components/agent-log.ts b/admin-panel/src/client/components/agent-log.ts
new file mode 100644
index 0000000..5938426
--- /dev/null
+++ b/admin-panel/src/client/components/agent-log.ts
@@ -0,0 +1,2 @@
+// Agent log is inlined in task-modal.ts — this file exists for Phase 3 expansion
+export {};
diff --git a/admin-panel/src/client/components/board.ts b/admin-panel/src/client/components/board.ts
new file mode 100644
index 0000000..b611133
--- /dev/null
+++ b/admin-panel/src/client/components/board.ts
@@ -0,0 +1,123 @@
+import { api, type Task } from '../lib/api.js';
+import { renderTaskCard } from './task-card.js';
+import { showTaskModal } from './task-modal.js';
+
+const COLUMNS = [
+ { id: 'todo', label: 'Req' },
+ { id: 'plan', label: 'Plan' },
+ { id: 'plan_review', label: 'Plan Review' },
+ { id: 'impl', label: 'Impl' },
+ { id: 'impl_review', label: 'Impl Review' },
+ { id: 'test', label: 'Test' },
+ { id: 'done', label: 'Done' },
+];
+
+export function renderBoard(container: HTMLElement, projectName: string, tasks: Task[]): void {
+ const tasksByStatus = new Map<string, Task[]>();
+ for (const col of COLUMNS) {
+ tasksByStatus.set(col.id, []);
+ }
+ for (const task of tasks) {
+ const list = tasksByStatus.get(task.status);
+ if (list) list.push(task);
+ else tasksByStatus.get('todo')!.push(task); // fallback
+ }
+
+ container.innerHTML = `<div class="board-container">${COLUMNS.map(col => {
+ const colTasks = tasksByStatus.get(col.id)!;
+ return `
+ <div class="board-column" data-status="${col.id}">
+ <div class="column-header">
+ <h3>${col.label}</h3>
+ <span class="column-count">${colTasks.length}</span>
+ </div>
+ <div class="column-body" data-status="${col.id}">
+ ${colTasks.map(task => renderTaskCard(task)).join('')}
+ </div>
+ </div>
+ `;
+ }).join('')}</div>`;
+
+ // Setup drag-drop
+ setupDragDrop(container, projectName);
+
+ // Setup card click
+ container.querySelectorAll('.task-card').forEach(card => {
+ card.addEventListener('click', (e) => {
+ // Don't open modal if dragging
+ if ((card as HTMLElement).classList.contains('dragging')) return;
+ const taskId = parseInt((card as HTMLElement).dataset.taskId!);
+ const task = tasks.find(t => t.id === taskId);
+ if (task) showTaskModal(projectName, task);
+ });
+ });
+}
+
+function setupDragDrop(container: HTMLElement, projectName: string): void {
+ const cards = container.querySelectorAll('.task-card');
+ const columnBodies = container.querySelectorAll('.column-body');
+
+ let draggedCard: HTMLElement | null = null;
+
+ cards.forEach(card => {
+ const el = card as HTMLElement;
+ el.draggable = true;
+
+ el.addEventListener('dragstart', (e) => {
+ draggedCard = el;
+ el.classList.add('dragging');
+ (e as DragEvent).dataTransfer!.effectAllowed = 'move';
+ (e as DragEvent).dataTransfer!.setData('text/plain', el.dataset.taskId!);
+ });
+
+ el.addEventListener('dragend', () => {
+ el.classList.remove('dragging');
+ draggedCard = null;
+ columnBodies.forEach(cb => cb.classList.remove('drag-over'));
+ });
+ });
+
+ columnBodies.forEach(body => {
+ body.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ (e as DragEvent).dataTransfer!.dropEffect = 'move';
+ body.classList.add('drag-over');
+ });
+
+ body.addEventListener('dragleave', () => {
+ body.classList.remove('drag-over');
+ });
+
+ body.addEventListener('drop', async (e) => {
+ e.preventDefault();
+ body.classList.remove('drag-over');
+
+ if (!draggedCard) return;
+
+ const taskId = parseInt(draggedCard.dataset.taskId!);
+ const newStatus = (body as HTMLElement).dataset.status!;
+
+ // Calculate rank based on drop position
+ const existingCards = Array.from(body.querySelectorAll('.task-card:not(.dragging)'));
+ let rank: number;
+
+ if (existingCards.length === 0) {
+ rank = 1;
+ } else {
+ // Drop at end by default
+ const lastCard = existingCards[existingCards.length - 1] as HTMLElement;
+ const lastRank = parseFloat(lastCard.dataset.rank || '0');
+ rank = lastRank + 1;
+ }
+
+ // Optimistically move card
+ body.appendChild(draggedCard);
+
+ try {
+ await api.reorderTask(projectName, taskId, rank, newStatus);
+ } catch (err) {
+ console.error('Reorder failed:', err);
+ }
+ });
+ });
+}
diff --git a/admin-panel/src/client/components/diff-viewer.ts b/admin-panel/src/client/components/diff-viewer.ts
new file mode 100644
index 0000000..b746b75
--- /dev/null
+++ b/admin-panel/src/client/components/diff-viewer.ts
@@ -0,0 +1,2 @@
+// Diff viewer is inlined in task-modal.ts — this file exists for Phase 4 expansion
+export {};
diff --git a/admin-panel/src/client/components/pipeline-controls.ts b/admin-panel/src/client/components/pipeline-controls.ts
new file mode 100644
index 0000000..2b3e9a5
--- /dev/null
+++ b/admin-panel/src/client/components/pipeline-controls.ts
@@ -0,0 +1,2 @@
+// Pipeline controls are inlined in task-modal.ts — this file exists for Phase 3 expansion
+export {};
diff --git a/admin-panel/src/client/components/project-form.ts b/admin-panel/src/client/components/project-form.ts
new file mode 100644
index 0000000..d69f194
--- /dev/null
+++ b/admin-panel/src/client/components/project-form.ts
@@ -0,0 +1,2 @@
+// Project form is inlined in dashboard.ts — this file exists for future extraction if needed
+export {};
diff --git a/admin-panel/src/client/components/task-card.ts b/admin-panel/src/client/components/task-card.ts
new file mode 100644
index 0000000..a63e507
--- /dev/null
+++ b/admin-panel/src/client/components/task-card.ts
@@ -0,0 +1,40 @@
+import type { Task } from '../lib/api.js';
+
+export function renderTaskCard(task: Task): string {
+ const tags = parseTags(task.tags);
+ const priorityClass = `badge-${task.priority}`;
+ const isActive = task.current_agent !== null;
+
+ return `
+ <div class="task-card ${isActive ? 'pipeline-active' : ''}"
+ data-task-id="${task.id}"
+ data-rank="${task.rank}"
+ draggable="true">
+ <div class="task-card-header">
+ <span class="task-id">#${task.id}</span>
+ ${task.current_agent ? `<span class="badge badge-agent">${escapeHtml(task.current_agent)}</span>` : ''}
+ </div>
+ <div class="task-title">${escapeHtml(task.title)}</div>
+ <div class="task-meta">
+ <span class="badge ${priorityClass}">${task.priority}</span>
+ <span class="badge badge-level">L${task.level}</span>
+ ${tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}
+ </div>
+ </div>
+ `;
+}
+
+function parseTags(tagsStr: string): string[] {
+ try {
+ const parsed = JSON.parse(tagsStr);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+function escapeHtml(text: string): string {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
diff --git a/admin-panel/src/client/components/task-modal.ts b/admin-panel/src/client/components/task-modal.ts
new file mode 100644
index 0000000..725d921
--- /dev/null
+++ b/admin-panel/src/client/components/task-modal.ts
@@ -0,0 +1,271 @@
+import { api, type Task } from '../lib/api.js';
+import { renderMarkdown } from '../lib/markdown.js';
+
+const STATUS_ORDER = ['todo', 'plan', 'plan_review', 'impl', 'impl_review', 'test', 'done'];
+const STATUS_LABELS: Record<string, string> = {
+ todo: 'Req', plan: 'Plan', plan_review: 'Plan Review',
+ impl: 'Impl', impl_review: 'Impl Review', test: 'Test', done: 'Done'
+};
+
+const TABS = ['Requirements', 'Plan', 'Implementation', 'Reviews', 'Tests', 'Agent Log'];
+
+export function showTaskModal(projectName: string, task: Task): void {
+ document.querySelector('.modal-overlay')?.remove();
+
+ const overlay = document.createElement('div');
+ overlay.className = 'modal-overlay';
+ overlay.innerHTML = `
+ <div class="modal task-modal">
+ <div class="modal-header">
+ <div class="top-row">
+ <span class="task-id" style="font-size:14px">#${task.id}</span>
+ <button class="btn-icon close-btn" style="font-size:20px">&times;</button>
+ </div>
+ <h2 style="width:100%">${escapeHtml(task.title)}</h2>
+ <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
+ <span class="badge badge-${task.priority}">${task.priority}</span>
+ <span class="badge badge-level">L${task.level}</span>
+ <span class="badge" style="background:var(--border);color:var(--text-secondary)">
+ ${STATUS_LABELS[task.status] || task.status}
+ </span>
+ ${task.current_agent ? `<span class="badge badge-agent">${escapeHtml(task.current_agent)}</span>` : ''}
+ ${parseTags(task.tags).map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}
+ </div>
+ </div>
+
+ ${renderLifecycleBar(task)}
+
+ <div class="tabs" id="task-tabs">
+ ${TABS.map((tab, i) => `
+ <button class="tab ${i === 0 ? 'active' : ''}" data-tab="${i}">${tab}</button>
+ `).join('')}
+ </div>
+
+ <div class="tab-content" id="tab-content">
+ ${renderTabContent(0, task)}
+ </div>
+
+ <div class="modal-footer">
+ <div class="pipeline-controls">
+ <button class="btn btn-primary btn-sm" id="run-pipeline-btn" title="Run full pipeline">Run Pipeline</button>
+ <button class="btn btn-secondary btn-sm" id="step-pipeline-btn" title="Run next step">Step</button>
+ <button class="btn btn-danger btn-sm" id="stop-pipeline-btn" title="Stop pipeline">Stop</button>
+ </div>
+ <div style="flex:1"></div>
+ <button class="btn btn-danger btn-sm" id="delete-task-btn">Delete</button>
+ </div>
+ </div>
+ `;
+
+ document.body.appendChild(overlay);
+
+ // Tab switching
+ overlay.querySelectorAll('.tab').forEach(tab => {
+ tab.addEventListener('click', () => {
+ overlay.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+ tab.classList.add('active');
+ const tabIdx = parseInt((tab as HTMLElement).dataset.tab!);
+ overlay.querySelector('#tab-content')!.innerHTML = renderTabContent(tabIdx, task);
+ });
+ });
+
+ // Close
+ overlay.querySelectorAll('.close-btn').forEach(btn => {
+ btn.addEventListener('click', () => overlay.remove());
+ });
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) overlay.remove();
+ });
+
+ // Pipeline controls
+ overlay.querySelector('#run-pipeline-btn')?.addEventListener('click', async () => {
+ try {
+ await api.runPipeline(projectName, task.id);
+ } catch (err) {
+ alert((err as Error).message);
+ }
+ });
+
+ overlay.querySelector('#step-pipeline-btn')?.addEventListener('click', async () => {
+ try {
+ await api.stepPipeline(projectName, task.id);
+ } catch (err) {
+ alert((err as Error).message);
+ }
+ });
+
+ overlay.querySelector('#stop-pipeline-btn')?.addEventListener('click', async () => {
+ try {
+ await api.stopPipeline(projectName, task.id);
+ } catch (err) {
+ alert((err as Error).message);
+ }
+ });
+
+ // Delete
+ overlay.querySelector('#delete-task-btn')?.addEventListener('click', async () => {
+ if (!confirm(`Delete task #${task.id}?`)) return;
+ try {
+ await api.deleteTask(projectName, task.id);
+ overlay.remove();
+ } catch (err) {
+ alert((err as Error).message);
+ }
+ });
+}
+
+function renderLifecycleBar(task: Task): string {
+ const currentIdx = STATUS_ORDER.indexOf(task.status);
+ return `
+ <div class="lifecycle-bar">
+ ${STATUS_ORDER.map((s, i) => {
+ let cls = 'lifecycle-step';
+ if (i < currentIdx) cls += ' completed';
+ else if (i === currentIdx) cls += ' current';
+ return `<div class="${cls}" title="${STATUS_LABELS[s]}"></div>`;
+ }).join('')}
+ </div>
+ `;
+}
+
+function renderTabContent(tabIdx: number, task: Task): string {
+ switch (tabIdx) {
+ case 0: // Requirements
+ return `
+ <div style="color:var(--text-secondary);font-size:13px">
+ ${task.description
+ ? `<div>${renderMarkdown(task.description)}</div>`
+ : '<p>No description provided.</p>'
+ }
+ ${task.done_when ? `
+ <h4 style="margin-top:16px;color:var(--text-primary)">Done When</h4>
+ <div>${renderMarkdown(task.done_when)}</div>
+ ` : ''}
+ </div>
+ `;
+
+ case 1: // Plan
+ return `
+ <div style="color:var(--text-secondary);font-size:13px">
+ ${task.plan
+ ? `<div>${renderMarkdown(task.plan)}</div>`
+ : '<p>No plan generated yet. Run the pipeline to create one.</p>'
+ }
+ ${task.decision_log ? `
+ <h4 style="margin-top:16px;color:var(--text-primary)">Decision Log</h4>
+ <div>${renderMarkdown(task.decision_log)}</div>
+ ` : ''}
+ </div>
+ `;
+
+ case 2: // Implementation
+ return `
+ <div style="color:var(--text-secondary);font-size:13px">
+ ${task.implementation_notes
+ ? `<div>${renderMarkdown(task.implementation_notes)}</div>`
+ : '<p>No implementation notes yet.</p>'
+ }
+ ${task.branch_name ? `<p style="margin-top:12px">Branch: <code>${escapeHtml(task.branch_name)}</code></p>` : ''}
+ ${task.diff ? `
+ <h4 style="margin-top:16px;color:var(--text-primary)">Diff</h4>
+ <div class="diff-viewer">${renderDiff(task.diff)}</div>
+ ` : ''}
+ </div>
+ `;
+
+ case 3: // Reviews
+ return `
+ <div style="color:var(--text-secondary);font-size:13px">
+ <h4 style="color:var(--text-primary)">Plan Reviews (${task.plan_review_count})</h4>
+ ${renderJsonArray(task.plan_review_comments, 'No plan reviews yet.')}
+
+ <h4 style="margin-top:16px;color:var(--text-primary)">Code Reviews (${task.impl_review_count})</h4>
+ ${renderJsonArray(task.review_comments, 'No code reviews yet.')}
+
+ ${task.gerrit_change_number ? `
+ <p style="margin-top:16px">Gerrit Change: <a href="/gerrit/${task.gerrit_change_number}" style="color:var(--accent-glow)">#${task.gerrit_change_number}</a></p>
+ ` : ''}
+ </div>
+ `;
+
+ case 4: // Tests
+ return `
+ <div style="color:var(--text-secondary);font-size:13px">
+ ${renderJsonArray(task.test_results, 'No test results yet. Run the pipeline to generate tests.')}
+ </div>
+ `;
+
+ case 5: // Agent Log
+ return `
+ <div class="agent-log">
+ ${renderAgentLog(task.agent_log)}
+ </div>
+ `;
+
+ default:
+ return '';
+ }
+}
+
+function renderDiff(diff: string): string {
+ return diff.split('\n').map(line => {
+ let cls = 'diff-line';
+ if (line.startsWith('+')) cls += ' diff-add';
+ else if (line.startsWith('-')) cls += ' diff-del';
+ else if (line.startsWith('@@')) cls += ' diff-hunk';
+ return `<div class="${cls}">${escapeHtml(line)}</div>`;
+ }).join('');
+}
+
+function renderJsonArray(jsonStr: string, emptyMsg: string): string {
+ try {
+ const items = JSON.parse(jsonStr);
+ if (!Array.isArray(items) || items.length === 0) return `<p>${emptyMsg}</p>`;
+ return items.map((item: unknown) => {
+ if (typeof item === 'string') return `<div class="log-entry"><div class="log-message">${escapeHtml(item)}</div></div>`;
+ if (typeof item === 'object' && item !== null) {
+ const obj = item as Record<string, unknown>;
+ return `<div class="log-entry">
+ ${obj.agent ? `<div class="log-agent">${escapeHtml(String(obj.agent))}</div>` : ''}
+ <div class="log-message">${escapeHtml(String(obj.message || obj.comment || JSON.stringify(obj)))}</div>
+ </div>`;
+ }
+ return '';
+ }).join('');
+ } catch {
+ return `<p>${emptyMsg}</p>`;
+ }
+}
+
+function renderAgentLog(logStr: string): string {
+ try {
+ const entries = JSON.parse(logStr);
+ if (!Array.isArray(entries) || entries.length === 0) {
+ return '<div class="empty-state"><p>No agent activity yet.</p></div>';
+ }
+ return entries.map((entry: Record<string, unknown>) => `
+ <div class="log-entry">
+ <span class="log-time">${entry.timestamp ? new Date(String(entry.timestamp)).toLocaleTimeString() : ''}</span>
+ <span class="log-agent">${escapeHtml(String(entry.agent || ''))}</span>
+ <span class="log-message">${escapeHtml(String(entry.message || ''))}</span>
+ </div>
+ `).join('');
+ } catch {
+ return '<div class="empty-state"><p>No agent activity yet.</p></div>';
+ }
+}
+
+function parseTags(tagsStr: string): string[] {
+ try {
+ const parsed = JSON.parse(tagsStr);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+function escapeHtml(text: string): string {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
diff --git a/admin-panel/src/client/index.html b/admin-panel/src/client/index.html
new file mode 100644
index 0000000..58af369
--- /dev/null
+++ b/admin-panel/src/client/index.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Bastion Admin</title>
+ <link rel="stylesheet" href="./styles/main.css" />
+</head>
+<body>
+ <div id="app">
+ <nav id="navbar">
+ <a href="/" class="nav-brand">BASTION</a>
+ <div class="nav-links">
+ <a href="/" data-route="/">Dashboard</a>
+ <a href="/settings" data-route="/settings">Settings</a>
+ </div>
+ </nav>
+ <main id="main-content"></main>
+ </div>
+ <script type="module" src="./main.ts"></script>
+</body>
+</html>
diff --git a/admin-panel/src/client/lib/api.ts b/admin-panel/src/client/lib/api.ts
new file mode 100644
index 0000000..d82693f
--- /dev/null
+++ b/admin-panel/src/client/lib/api.ts
@@ -0,0 +1,155 @@
+const BASE = '/api';
+
+async function request<T>(path: string, options?: RequestInit): Promise<T> {
+ const res = await fetch(`${BASE}${path}`, {
+ headers: { 'Content-Type': 'application/json' },
+ ...options,
+ });
+
+ if (res.status === 204) return undefined as T;
+
+ const data = await res.json();
+ if (!res.ok) {
+ throw new Error(data.error || `Request failed: ${res.status}`);
+ }
+ return data as T;
+}
+
+export const api = {
+ // Projects
+ getProjects: () => request<Project[]>('/projects'),
+ createProject: (data: CreateProject) =>
+ request<Project>('/projects', { method: 'POST', body: JSON.stringify(data) }),
+ getProject: (name: string) => request<Project>(`/projects/${name}`),
+ updateProject: (name: string, data: Partial<Project>) =>
+ request<Project>(`/projects/${name}`, { method: 'PATCH', body: JSON.stringify(data) }),
+ deleteProject: (name: string, deleteRepo = false) =>
+ request<void>(`/projects/${name}?delete_repo=${deleteRepo}`, { method: 'DELETE' }),
+
+ // Tasks
+ getTasks: (project: string) => request<Task[]>(`/projects/${project}/tasks`),
+ createTask: (project: string, data: CreateTask) =>
+ request<Task>(`/projects/${project}/tasks`, { method: 'POST', body: JSON.stringify(data) }),
+ getTask: (project: string, id: number) => request<Task>(`/projects/${project}/tasks/${id}`),
+ updateTask: (project: string, id: number, data: Partial<Task>) =>
+ request<Task>(`/projects/${project}/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
+ deleteTask: (project: string, id: number) =>
+ request<void>(`/projects/${project}/tasks/${id}`, { method: 'DELETE' }),
+ reorderTask: (project: string, id: number, rank: number, status?: string) =>
+ request<Task>(`/projects/${project}/tasks/${id}/reorder`, {
+ method: 'PATCH',
+ body: JSON.stringify({ rank, status }),
+ }),
+
+ // Pipeline
+ runPipeline: (project: string, taskId: number) =>
+ request<void>(`/projects/${project}/pipeline/run/${taskId}`, { method: 'POST' }),
+ stepPipeline: (project: string, taskId: number) =>
+ request<void>(`/projects/${project}/pipeline/step/${taskId}`, { method: 'POST' }),
+ stopPipeline: (project: string, taskId: number) =>
+ request<void>(`/projects/${project}/pipeline/stop/${taskId}`, { method: 'POST' }),
+ getPipelineStatus: (project: string, taskId: number) =>
+ request<PipelineStatus>(`/projects/${project}/pipeline/status/${taskId}`),
+
+ // Dashboard
+ getDashboardStats: () => request<DashboardStats>('/dashboard/stats'),
+ getDashboardActivity: () => request<ActivityEntry[]>('/dashboard/activity'),
+
+ // Settings
+ getSettings: () => request<Record<string, string>>('/settings'),
+ updateSettings: (data: Record<string, string>) =>
+ request<void>('/settings', { method: 'PATCH', body: JSON.stringify(data) }),
+};
+
+// Types
+export interface Project {
+ id: number;
+ name: string;
+ display_name: string;
+ description: string | null;
+ repo_name: string;
+ repo_path: string;
+ default_branch: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CreateProject {
+ name: string;
+ display_name: string;
+ description?: string;
+}
+
+export interface Task {
+ id: number;
+ title: string;
+ status: string;
+ priority: string;
+ level: number;
+ description: string | null;
+ plan: string | null;
+ decision_log: string | null;
+ done_when: string | null;
+ implementation_notes: string | null;
+ tags: string;
+ plan_review_comments: string;
+ review_comments: string;
+ test_results: string;
+ agent_log: string;
+ current_agent: string | null;
+ plan_review_count: number;
+ impl_review_count: number;
+ branch_name: string | null;
+ gerrit_change_id: string | null;
+ gerrit_change_number: number | null;
+ diff: string | null;
+ rank: number;
+ created_at: string;
+ started_at: string | null;
+ planned_at: string | null;
+ reviewed_at: string | null;
+ tested_at: string | null;
+ completed_at: string | null;
+}
+
+export interface CreateTask {
+ title: string;
+ description?: string;
+ priority?: string;
+ level?: number;
+ tags?: string[];
+}
+
+export interface PipelineStatus {
+ running: boolean;
+ currentAgent: string | null;
+ jobs: PipelineJob[];
+}
+
+export interface PipelineJob {
+ id: number;
+ task_id: number;
+ agent: string;
+ status: string;
+ model: string;
+ started_at: string | null;
+ completed_at: string | null;
+ error: string | null;
+ tokens_used: number;
+}
+
+export interface DashboardStats {
+ totalProjects: number;
+ totalTasks: number;
+ tasksByStatus: Record<string, number>;
+ completedToday: number;
+}
+
+export interface ActivityEntry {
+ project: string;
+ taskId: number;
+ taskTitle: string;
+ agent: string;
+ action: string;
+ timestamp: string;
+}
diff --git a/admin-panel/src/client/lib/markdown.ts b/admin-panel/src/client/lib/markdown.ts
new file mode 100644
index 0000000..c89fa77
--- /dev/null
+++ b/admin-panel/src/client/lib/markdown.ts
@@ -0,0 +1,39 @@
+// Minimal markdown renderer — handles the basics without pulling in a library
+export function renderMarkdown(text: string): string {
+ if (!text) return '';
+
+ let html = escapeHtml(text);
+
+ // Code blocks
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="lang-$1">$2</code></pre>');
+
+ // Inline code
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
+
+ // Headers
+ html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>');
+ html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>');
+ html = html.replace(/^# (.+)$/gm, '<h2>$1</h2>');
+
+ // Bold and italic
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
+
+ // Lists
+ html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
+ html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
+
+ // Line breaks
+ html = html.replace(/\n\n/g, '</p><p>');
+ html = `<p>${html}</p>`;
+ html = html.replace(/<p><\/p>/g, '');
+
+ return html;
+}
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;');
+}
diff --git a/admin-panel/src/client/lib/ws.ts b/admin-panel/src/client/lib/ws.ts
new file mode 100644
index 0000000..14e0cec
--- /dev/null
+++ b/admin-panel/src/client/lib/ws.ts
@@ -0,0 +1,79 @@
+type EventHandler = (data: unknown) => void;
+
+class WsClient {
+ private ws: WebSocket | null = null;
+ private handlers = new Map<string, Set<EventHandler>>();
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
+ private subscribedProjects = new Set<string>();
+
+ connect(): void {
+ if (this.ws?.readyState === WebSocket.OPEN) return;
+
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ this.ws = new WebSocket(`${protocol}//${location.host}/ws`);
+
+ this.ws.onopen = () => {
+ // Re-subscribe to projects
+ for (const project of this.subscribedProjects) {
+ this.send({ type: 'subscribe', project });
+ }
+ };
+
+ this.ws.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ const handlers = this.handlers.get(data.type);
+ if (handlers) {
+ for (const handler of handlers) {
+ handler(data);
+ }
+ }
+ // Also fire wildcard handlers
+ const wildcardHandlers = this.handlers.get('*');
+ if (wildcardHandlers) {
+ for (const handler of wildcardHandlers) {
+ handler(data);
+ }
+ }
+ } catch {
+ // ignore
+ }
+ };
+
+ this.ws.onclose = () => {
+ this.reconnectTimer = setTimeout(() => this.connect(), 3000);
+ };
+ }
+
+ disconnect(): void {
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
+ this.ws?.close();
+ this.ws = null;
+ }
+
+ subscribe(project: string): void {
+ this.subscribedProjects.add(project);
+ this.send({ type: 'subscribe', project });
+ }
+
+ unsubscribe(project: string): void {
+ this.subscribedProjects.delete(project);
+ this.send({ type: 'unsubscribe', project });
+ }
+
+ on(event: string, handler: EventHandler): () => void {
+ if (!this.handlers.has(event)) {
+ this.handlers.set(event, new Set());
+ }
+ this.handlers.get(event)!.add(handler);
+ return () => this.handlers.get(event)?.delete(handler);
+ }
+
+ private send(data: unknown): void {
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(data));
+ }
+ }
+}
+
+export const wsClient = new WsClient();
diff --git a/admin-panel/src/client/main.ts b/admin-panel/src/client/main.ts
new file mode 100644
index 0000000..87f25db
--- /dev/null
+++ b/admin-panel/src/client/main.ts
@@ -0,0 +1,63 @@
+import { renderDashboard } from './pages/dashboard.js';
+import { renderProjectPage } from './pages/project.js';
+import { renderSettings } from './pages/settings.js';
+import { wsClient } from './lib/ws.js';
+
+const mainContent = document.getElementById('main-content')!;
+
+// Simple client-side router
+type Route = {
+ pattern: RegExp;
+ handler: (container: HTMLElement, params: string[]) => Promise<void>;
+};
+
+const routes: Route[] = [
+ { pattern: /^\/$/, handler: (c) => renderDashboard(c) },
+ { pattern: /^\/project\/([a-z0-9-]+)$/, handler: (c, p) => renderProjectPage(c, p[1]) },
+ { pattern: /^\/settings$/, handler: (c) => renderSettings(c) },
+];
+
+function navigate(path: string): void {
+ for (const route of routes) {
+ const match = path.match(route.pattern);
+ if (match) {
+ updateNavActive(path);
+ route.handler(mainContent, Array.from(match));
+ return;
+ }
+ }
+ // 404
+ mainContent.innerHTML = `
+ <div class="empty-state">
+ <h3>Page not found</h3>
+ <p><a href="/" data-route="/" style="color:var(--accent-glow)">Back to Dashboard</a></p>
+ </div>
+ `;
+}
+
+function updateNavActive(path: string): void {
+ document.querySelectorAll('.nav-links a').forEach(a => {
+ const route = (a as HTMLElement).dataset.route;
+ a.classList.toggle('active', route === path || (route === '/' && path.startsWith('/project/')));
+ });
+}
+
+// Intercept link clicks for SPA navigation
+document.addEventListener('click', (e) => {
+ const link = (e.target as HTMLElement).closest('[data-route]');
+ if (link) {
+ e.preventDefault();
+ const path = (link as HTMLElement).dataset.route!;
+ history.pushState(null, '', path);
+ navigate(path);
+ }
+});
+
+// Handle browser back/forward
+window.addEventListener('popstate', () => {
+ navigate(location.pathname);
+});
+
+// Init
+wsClient.connect();
+navigate(location.pathname);
diff --git a/admin-panel/src/client/pages/dashboard.ts b/admin-panel/src/client/pages/dashboard.ts
new file mode 100644
index 0000000..ded6d3b
--- /dev/null
+++ b/admin-panel/src/client/pages/dashboard.ts
@@ -0,0 +1,150 @@
+import { api, type Project } from '../lib/api.js';
+
+export async function renderDashboard(container: HTMLElement): Promise<void> {
+ container.innerHTML = `
+ <div class="dashboard-header">
+ <h1>Projects</h1>
+ <button class="btn btn-primary" id="new-project-btn">+ New Project</button>
+ </div>
+ <div class="project-grid" id="project-grid">
+ <div class="empty-state">
+ <h3>Loading...</h3>
+ </div>
+ </div>
+ `;
+
+ const grid = container.querySelector('#project-grid')!;
+ const newBtn = container.querySelector('#new-project-btn')!;
+
+ // Load projects
+ try {
+ const projects = await api.getProjects();
+ renderProjects(grid, projects);
+ } catch (err) {
+ grid.innerHTML = `<div class="empty-state"><h3>Failed to load projects</h3><p>${(err as Error).message}</p></div>`;
+ }
+
+ newBtn.addEventListener('click', () => showCreateDialog(grid));
+}
+
+function renderProjects(grid: Element, projects: Project[]): void {
+ if (projects.length === 0) {
+ grid.innerHTML = `
+ <div class="empty-state">
+ <h3>No projects yet</h3>
+ <p>Create your first project to get started.</p>
+ </div>
+ `;
+ return;
+ }
+
+ grid.innerHTML = projects.map(p => `
+ <a href="/project/${p.name}" class="project-card" data-route="/project/${p.name}">
+ <h3>${escapeHtml(p.display_name)}</h3>
+ <div class="project-name">${escapeHtml(p.name)}</div>
+ ${p.description ? `<div class="project-desc">${escapeHtml(p.description)}</div>` : ''}
+ <div class="project-stats">
+ <span class="stat"><span class="stat-dot todo"></span> ${escapeHtml(p.repo_name)}</span>
+ <span class="stat">${timeAgo(p.created_at)}</span>
+ </div>
+ </a>
+ `).join('');
+}
+
+function showCreateDialog(grid: Element): void {
+ // Remove existing modal if present
+ document.querySelector('.modal-overlay')?.remove();
+
+ const overlay = document.createElement('div');
+ overlay.className = 'modal-overlay';
+ overlay.innerHTML = `
+ <div class="modal">
+ <div class="modal-header">
+ <h2>New Project</h2>
+ <button class="btn-icon close-btn">&times;</button>
+ </div>
+ <form id="create-project-form">
+ <div class="form-group">
+ <label for="proj-name">Project Name</label>
+ <input type="text" id="proj-name" placeholder="my-project" required pattern="[a-z0-9-]+" />
+ </div>
+ <div class="form-group">
+ <label for="proj-display">Display Name</label>
+ <input type="text" id="proj-display" placeholder="My Project" required />
+ </div>
+ <div class="form-group">
+ <label for="proj-desc">Description</label>
+ <textarea id="proj-desc" placeholder="What is this project about?"></textarea>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary close-btn">Cancel</button>
+ <button type="submit" class="btn btn-primary">Create Project</button>
+ </div>
+ </form>
+ </div>
+ `;
+
+ document.body.appendChild(overlay);
+
+ // Auto-generate name from display name
+ const displayInput = overlay.querySelector('#proj-display') as HTMLInputElement;
+ const nameInput = overlay.querySelector('#proj-name') as HTMLInputElement;
+ displayInput.addEventListener('input', () => {
+ nameInput.value = displayInput.value
+ .toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, '')
+ .replace(/\s+/g, '-');
+ });
+
+ // Close handlers
+ overlay.querySelectorAll('.close-btn').forEach(btn => {
+ btn.addEventListener('click', () => overlay.remove());
+ });
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) overlay.remove();
+ });
+
+ // Submit
+ const form = overlay.querySelector('#create-project-form') as HTMLFormElement;
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement;
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Creating...';
+
+ try {
+ await api.createProject({
+ name: nameInput.value,
+ display_name: displayInput.value,
+ description: (overlay.querySelector('#proj-desc') as HTMLTextAreaElement).value || undefined,
+ });
+ overlay.remove();
+ // Reload projects
+ const projects = await api.getProjects();
+ renderProjects(grid, projects);
+ } catch (err) {
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Create Project';
+ alert((err as Error).message);
+ }
+ });
+
+ displayInput.focus();
+}
+
+function escapeHtml(text: string): string {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+function timeAgo(dateStr: string): string {
+ const date = new Date(dateStr + 'Z');
+ const now = new Date();
+ const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
+
+ if (diff < 60) return 'just now';
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
+ return `${Math.floor(diff / 86400)}d ago`;
+}
diff --git a/admin-panel/src/client/pages/project.ts b/admin-panel/src/client/pages/project.ts
new file mode 100644
index 0000000..5e01d64
--- /dev/null
+++ b/admin-panel/src/client/pages/project.ts
@@ -0,0 +1,167 @@
+import { api, type Task, type Project } from '../lib/api.js';
+import { wsClient } from '../lib/ws.js';
+import { renderBoard } from '../components/board.js';
+
+let currentProject: string | null = null;
+let cleanupWs: (() => void) | null = null;
+
+export async function renderProjectPage(container: HTMLElement, projectName: string): Promise<void> {
+ // Cleanup previous subscriptions
+ if (currentProject && currentProject !== projectName) {
+ wsClient.unsubscribe(currentProject);
+ }
+ if (cleanupWs) cleanupWs();
+
+ currentProject = projectName;
+
+ container.innerHTML = `
+ <div class="board-header">
+ <div style="display:flex;align-items:center;gap:12px">
+ <a href="/" data-route="/" class="btn btn-secondary btn-sm">&larr; Back</a>
+ <h1 id="project-title">Loading...</h1>
+ </div>
+ <button class="btn btn-primary" id="new-task-btn">+ New Task</button>
+ </div>
+ <div id="board-root"></div>
+ `;
+
+ const boardRoot = container.querySelector('#board-root') as HTMLElement;
+ const titleEl = container.querySelector('#project-title')!;
+ const newTaskBtn = container.querySelector('#new-task-btn')!;
+
+ try {
+ const [project, tasks] = await Promise.all([
+ api.getProject(projectName),
+ api.getTasks(projectName),
+ ]);
+
+ titleEl.textContent = project.display_name;
+ renderBoard(boardRoot, projectName, tasks);
+
+ // WebSocket subscription
+ wsClient.subscribe(projectName);
+ const unsub1 = wsClient.on('task:created', (data: unknown) => {
+ const event = data as { project: string; task: Task };
+ if (event.project === projectName) {
+ refreshBoard(boardRoot, projectName);
+ }
+ });
+ const unsub2 = wsClient.on('task:updated', (data: unknown) => {
+ const event = data as { project: string; task: Task };
+ if (event.project === projectName) {
+ refreshBoard(boardRoot, projectName);
+ }
+ });
+ const unsub3 = wsClient.on('task:deleted', (data: unknown) => {
+ const event = data as { project: string };
+ if (event.project === projectName) {
+ refreshBoard(boardRoot, projectName);
+ }
+ });
+
+ cleanupWs = () => { unsub1(); unsub2(); unsub3(); };
+
+ } catch (err) {
+ boardRoot.innerHTML = `<div class="empty-state"><h3>Failed to load project</h3><p>${(err as Error).message}</p></div>`;
+ }
+
+ newTaskBtn.addEventListener('click', () => showCreateTaskDialog(projectName, boardRoot));
+}
+
+async function refreshBoard(boardRoot: HTMLElement, projectName: string): Promise<void> {
+ try {
+ const tasks = await api.getTasks(projectName);
+ renderBoard(boardRoot, projectName, tasks);
+ } catch {
+ // silently fail on refresh
+ }
+}
+
+function showCreateTaskDialog(projectName: string, boardRoot: HTMLElement): void {
+ document.querySelector('.modal-overlay')?.remove();
+
+ const overlay = document.createElement('div');
+ overlay.className = 'modal-overlay';
+ overlay.innerHTML = `
+ <div class="modal">
+ <div class="modal-header">
+ <h2>New Task</h2>
+ <button class="btn-icon close-btn">&times;</button>
+ </div>
+ <form id="create-task-form">
+ <div class="form-group">
+ <label for="task-title">Title</label>
+ <input type="text" id="task-title" placeholder="What needs to be done?" required />
+ </div>
+ <div class="form-group">
+ <label for="task-desc">Description</label>
+ <textarea id="task-desc" rows="4" placeholder="Detailed requirements..."></textarea>
+ </div>
+ <div style="display:flex;gap:12px">
+ <div class="form-group" style="flex:1">
+ <label for="task-priority">Priority</label>
+ <select id="task-priority">
+ <option value="low">Low</option>
+ <option value="medium" selected>Medium</option>
+ <option value="high">High</option>
+ </select>
+ </div>
+ <div class="form-group" style="flex:1">
+ <label for="task-level">Level</label>
+ <select id="task-level">
+ <option value="1">L1 — Quick</option>
+ <option value="2">L2 — Standard</option>
+ <option value="3" selected>L3 — Full</option>
+ </select>
+ </div>
+ </div>
+ <div class="form-group">
+ <label for="task-tags">Tags (comma-separated)</label>
+ <input type="text" id="task-tags" placeholder="frontend, bug-fix" />
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary close-btn">Cancel</button>
+ <button type="submit" class="btn btn-primary">Create Task</button>
+ </div>
+ </form>
+ </div>
+ `;
+
+ document.body.appendChild(overlay);
+
+ overlay.querySelectorAll('.close-btn').forEach(btn => {
+ btn.addEventListener('click', () => overlay.remove());
+ });
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) overlay.remove();
+ });
+
+ const form = overlay.querySelector('#create-task-form') as HTMLFormElement;
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement;
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Creating...';
+
+ const tagsStr = (overlay.querySelector('#task-tags') as HTMLInputElement).value;
+ const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
+
+ try {
+ await api.createTask(projectName, {
+ title: (overlay.querySelector('#task-title') as HTMLInputElement).value,
+ description: (overlay.querySelector('#task-desc') as HTMLTextAreaElement).value || undefined,
+ priority: (overlay.querySelector('#task-priority') as HTMLSelectElement).value,
+ level: parseInt((overlay.querySelector('#task-level') as HTMLSelectElement).value),
+ tags,
+ });
+ overlay.remove();
+ await refreshBoard(boardRoot, projectName);
+ } catch (err) {
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Create Task';
+ alert((err as Error).message);
+ }
+ });
+
+ (overlay.querySelector('#task-title') as HTMLInputElement).focus();
+}
diff --git a/admin-panel/src/client/pages/settings.ts b/admin-panel/src/client/pages/settings.ts
new file mode 100644
index 0000000..e78c69f
--- /dev/null
+++ b/admin-panel/src/client/pages/settings.ts
@@ -0,0 +1,59 @@
+export async function renderSettings(container: HTMLElement): Promise<void> {
+ container.innerHTML = `
+ <div class="settings-container">
+ <h1 style="margin-bottom:24px">Settings</h1>
+
+ <div class="settings-section">
+ <h2>API Configuration</h2>
+ <div class="form-group">
+ <label for="api-key">Anthropic API Key</label>
+ <input type="password" id="api-key" placeholder="sk-ant-..." style="width:100%" />
+ <small style="color:var(--text-secondary);margin-top:4px;display:block">
+ Configured via ANTHROPIC_API_KEY environment variable.
+ The API key is set in the container's environment, not stored in the database.
+ </small>
+ </div>
+ </div>
+
+ <div class="settings-section">
+ <h2>Agent Models</h2>
+ <p style="color:var(--text-secondary);margin-bottom:16px;font-size:13px">
+ Configure which Claude model each agent uses.
+ </p>
+ <div class="form-group">
+ <label>Planner (Plan generation)</label>
+ <select disabled><option>Claude Opus</option></select>
+ </div>
+ <div class="form-group">
+ <label>Critic (Plan review)</label>
+ <select disabled><option>Claude Sonnet</option></select>
+ </div>
+ <div class="form-group">
+ <label>Builder (Implementation)</label>
+ <select disabled><option>Claude Opus</option></select>
+ </div>
+ <div class="form-group">
+ <label>Shield (Test writing)</label>
+ <select disabled><option>Claude Sonnet</option></select>
+ </div>
+ <div class="form-group">
+ <label>Inspector (Code review)</label>
+ <select disabled><option>Claude Sonnet</option></select>
+ </div>
+ <div class="form-group">
+ <label>Ranger (Test runner)</label>
+ <select disabled><option>Claude Sonnet</option></select>
+ </div>
+ <small style="color:var(--text-secondary)">Model configuration will be available when the AI pipeline is enabled.</small>
+ </div>
+
+ <div class="settings-section">
+ <h2>Git / SSH</h2>
+ <p style="color:var(--text-secondary);font-size:13px">
+ SSH key for git-server and Gerrit access is mounted at <code>/app/.ssh/</code> in the container.
+ Ensure the public key is added to <code>/var/git_ssh_keys/</code> on the host.
+ </p>
+ </div>
+ </div>
+ `;
+}
diff --git a/admin-panel/src/client/styles/main.css b/admin-panel/src/client/styles/main.css
new file mode 100644
index 0000000..d2fe35b
--- /dev/null
+++ b/admin-panel/src/client/styles/main.css
@@ -0,0 +1,724 @@
+/* Cyberpunk Dark Theme */
+:root {
+ --bg-deep: #0A0D1F;
+ --bg-card: #1A1A2E;
+ --accent: #7B2FBE;
+ --accent-glow: #A855F7;
+ --alert: #FF2D78;
+ --text-secondary: #8B9BB4;
+ --text-primary: #E8ECF4;
+ --bg-input: #12152a;
+ --border: #2a2a4a;
+ --success: #22c55e;
+ --warning: #eab308;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html, body {
+ height: 100%;
+ background: var(--bg-deep);
+ color: var(--text-primary);
+ font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
+ font-size: 14px;
+ line-height: 1.5;
+}
+
+#app {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+/* Navbar */
+#navbar {
+ display: flex;
+ align-items: center;
+ padding: 0 24px;
+ height: 52px;
+ background: var(--bg-card);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.nav-brand {
+ font-size: 18px;
+ font-weight: 700;
+ letter-spacing: 3px;
+ color: var(--accent-glow);
+ text-decoration: none;
+ margin-right: 32px;
+}
+
+.nav-links {
+ display: flex;
+ gap: 4px;
+}
+
+.nav-links a {
+ color: var(--text-secondary);
+ text-decoration: none;
+ padding: 6px 14px;
+ border-radius: 6px;
+ font-size: 13px;
+ transition: all 0.15s;
+}
+
+.nav-links a:hover,
+.nav-links a.active {
+ color: var(--text-primary);
+ background: rgba(123, 47, 190, 0.15);
+}
+
+.nav-links a.active {
+ color: var(--accent-glow);
+}
+
+/* Main content */
+#main-content {
+ flex: 1;
+ overflow: auto;
+ padding: 24px;
+}
+
+/* Scrollbar */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--bg-deep);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--text-secondary);
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 16px;
+ border: none;
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: var(--accent-glow);
+ box-shadow: 0 0 20px rgba(168, 85, 247, 0.3);
+}
+
+.btn-secondary {
+ background: var(--border);
+ color: var(--text-primary);
+}
+
+.btn-secondary:hover {
+ background: var(--text-secondary);
+}
+
+.btn-danger {
+ background: transparent;
+ color: var(--alert);
+ border: 1px solid var(--alert);
+}
+
+.btn-danger:hover {
+ background: var(--alert);
+ color: white;
+}
+
+.btn-sm {
+ padding: 4px 10px;
+ font-size: 12px;
+}
+
+.btn-icon {
+ padding: 6px;
+ background: transparent;
+ color: var(--text-secondary);
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.btn-icon:hover {
+ color: var(--text-primary);
+ background: var(--border);
+}
+
+/* Form elements */
+input, textarea, select {
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ color: var(--text-primary);
+ padding: 8px 12px;
+ border-radius: 6px;
+ font-size: 13px;
+ font-family: inherit;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+input:focus, textarea:focus, select:focus {
+ border-color: var(--accent);
+}
+
+textarea {
+ resize: vertical;
+ min-height: 80px;
+}
+
+label {
+ display: block;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ margin-bottom: 4px;
+}
+
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-group input,
+.form-group textarea,
+.form-group select {
+ width: 100%;
+}
+
+/* Cards */
+.card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 20px;
+}
+
+/* Modal */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ animation: fadeIn 0.15s;
+}
+
+.modal {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ width: 90%;
+ max-width: 540px;
+ max-height: 90vh;
+ overflow-y: auto;
+ padding: 24px;
+ animation: slideUp 0.2s;
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 20px;
+}
+
+.modal-header h2 {
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 20px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border);
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes slideUp {
+ from { transform: translateY(20px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
+
+/* Dashboard */
+.dashboard-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 24px;
+}
+
+.dashboard-header h1 {
+ font-size: 24px;
+ font-weight: 600;
+}
+
+.project-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ gap: 16px;
+}
+
+.project-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 20px;
+ cursor: pointer;
+ transition: all 0.15s;
+ text-decoration: none;
+ color: inherit;
+ display: block;
+}
+
+.project-card:hover {
+ border-color: var(--accent);
+ box-shadow: 0 0 20px rgba(123, 47, 190, 0.15);
+ transform: translateY(-1px);
+}
+
+.project-card h3 {
+ font-size: 16px;
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+
+.project-card .project-name {
+ font-size: 12px;
+ color: var(--text-secondary);
+ margin-bottom: 8px;
+ font-family: monospace;
+}
+
+.project-card .project-desc {
+ color: var(--text-secondary);
+ font-size: 13px;
+ margin-bottom: 12px;
+}
+
+.project-card .project-stats {
+ display: flex;
+ gap: 12px;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.project-card .stat {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.stat-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+}
+
+.stat-dot.todo { background: var(--text-secondary); }
+.stat-dot.active { background: var(--accent-glow); }
+.stat-dot.done { background: var(--success); }
+
+/* Kanban Board */
+.board-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+}
+
+.board-header h1 {
+ font-size: 20px;
+ font-weight: 600;
+}
+
+.board-container {
+ display: flex;
+ gap: 12px;
+ overflow-x: auto;
+ height: calc(100vh - 140px);
+ padding-bottom: 16px;
+}
+
+.board-column {
+ flex: 0 0 260px;
+ display: flex;
+ flex-direction: column;
+ background: rgba(26, 26, 46, 0.3);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.column-header {
+ padding: 12px 14px;
+ background: var(--bg-card);
+ border-bottom: 2px solid var(--accent-glow);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-shrink: 0;
+}
+
+.column-header h3 {
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.column-count {
+ font-size: 11px;
+ color: var(--text-secondary);
+ background: var(--border);
+ padding: 1px 8px;
+ border-radius: 10px;
+}
+
+.column-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.column-body.drag-over {
+ background: rgba(123, 47, 190, 0.08);
+}
+
+/* Task Cards */
+.task-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--accent);
+ border-radius: 6px;
+ padding: 12px;
+ cursor: pointer;
+ transition: all 0.15s;
+ user-select: none;
+}
+
+.task-card:hover {
+ border-color: var(--accent);
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
+}
+
+.task-card.dragging {
+ opacity: 0.5;
+ transform: rotate(2deg);
+}
+
+.task-card.pipeline-active {
+ animation: pulseGlow 2s infinite;
+}
+
+@keyframes pulseGlow {
+ 0%, 100% { box-shadow: 0 0 5px rgba(168, 85, 247, 0.2); }
+ 50% { box-shadow: 0 0 20px rgba(168, 85, 247, 0.5); }
+}
+
+.task-card-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ margin-bottom: 6px;
+}
+
+.task-id {
+ font-size: 11px;
+ color: var(--text-secondary);
+ font-family: monospace;
+}
+
+.task-title {
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.4;
+ margin-bottom: 8px;
+}
+
+.task-meta {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.badge {
+ font-size: 10px;
+ font-weight: 600;
+ padding: 2px 8px;
+ border-radius: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.badge-high { background: var(--alert); color: white; }
+.badge-medium { background: var(--accent); color: white; }
+.badge-low { background: var(--border); color: var(--text-secondary); }
+
+.badge-level {
+ background: var(--border);
+ color: var(--text-secondary);
+}
+
+.badge-agent {
+ background: rgba(168, 85, 247, 0.2);
+ color: var(--accent-glow);
+}
+
+.tag {
+ font-size: 10px;
+ padding: 1px 6px;
+ border-radius: 3px;
+ background: var(--border);
+ color: var(--text-secondary);
+}
+
+/* Task Detail Modal */
+.task-modal {
+ max-width: 800px;
+}
+
+.task-modal .modal-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+}
+
+.task-modal .modal-header .top-row {
+ display: flex;
+ width: 100%;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.task-modal .tabs {
+ display: flex;
+ gap: 2px;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 16px;
+}
+
+.task-modal .tab {
+ padding: 8px 16px;
+ font-size: 13px;
+ color: var(--text-secondary);
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: all 0.15s;
+ background: none;
+ border-top: none;
+ border-left: none;
+ border-right: none;
+}
+
+.task-modal .tab:hover {
+ color: var(--text-primary);
+}
+
+.task-modal .tab.active {
+ color: var(--accent-glow);
+ border-bottom-color: var(--accent-glow);
+}
+
+.tab-content {
+ min-height: 200px;
+}
+
+.lifecycle-bar {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 16px;
+}
+
+.lifecycle-step {
+ flex: 1;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--border);
+}
+
+.lifecycle-step.completed {
+ background: var(--accent-glow);
+}
+
+.lifecycle-step.current {
+ background: var(--accent-glow);
+ animation: pulseGlow 2s infinite;
+}
+
+/* Agent Log */
+.agent-log {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.log-entry {
+ display: flex;
+ gap: 12px;
+ padding: 12px;
+ background: var(--bg-deep);
+ border-radius: 6px;
+ font-size: 13px;
+}
+
+.log-entry .log-time {
+ color: var(--text-secondary);
+ font-family: monospace;
+ font-size: 11px;
+ white-space: nowrap;
+}
+
+.log-entry .log-agent {
+ color: var(--accent-glow);
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.log-entry .log-message {
+ color: var(--text-secondary);
+}
+
+/* Diff Viewer */
+.diff-viewer {
+ font-family: 'Consolas', 'Monaco', monospace;
+ font-size: 12px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ overflow: auto;
+ max-height: 500px;
+}
+
+.diff-line {
+ padding: 1px 12px;
+ white-space: pre;
+}
+
+.diff-add {
+ background: rgba(34, 197, 94, 0.1);
+ color: #4ade80;
+}
+
+.diff-del {
+ background: rgba(255, 45, 120, 0.1);
+ color: #ff6b9d;
+}
+
+.diff-hunk {
+ color: var(--accent-glow);
+ background: rgba(168, 85, 247, 0.05);
+}
+
+/* Pipeline Controls */
+.pipeline-controls {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+/* Settings Page */
+.settings-container {
+ max-width: 600px;
+}
+
+.settings-section {
+ margin-bottom: 32px;
+}
+
+.settings-section h2 {
+ font-size: 16px;
+ margin-bottom: 16px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border);
+}
+
+/* Empty state */
+.empty-state {
+ text-align: center;
+ padding: 48px 24px;
+ color: var(--text-secondary);
+}
+
+.empty-state h3 {
+ font-size: 16px;
+ margin-bottom: 8px;
+ color: var(--text-primary);
+}
+
+/* Toast notifications */
+.toast-container {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ z-index: 2000;
+}
+
+.toast {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px 16px;
+ font-size: 13px;
+ animation: slideUp 0.2s;
+ max-width: 360px;
+}
+
+.toast.error {
+ border-color: var(--alert);
+}
+
+.toast.success {
+ border-color: var(--success);
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .board-container {
+ gap: 8px;
+ }
+
+ .board-column {
+ flex: 0 0 220px;
+ }
+
+ .project-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/admin-panel/src/server/agents/base-agent.ts b/admin-panel/src/server/agents/base-agent.ts
new file mode 100644
index 0000000..f9e49f7
--- /dev/null
+++ b/admin-panel/src/server/agents/base-agent.ts
@@ -0,0 +1,83 @@
+import { readFileSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+import { callClaude, type ClaudeResponse } from '../services/claude.service.js';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const TEMPLATES_DIR = join(__dirname, '..', 'templates');
+
+export interface AgentContext {
+ taskId: number;
+ title: string;
+ description: string;
+ plan?: string;
+ decisionLog?: string;
+ doneWhen?: string;
+ implementationNotes?: string;
+ diff?: string;
+ testResults?: string;
+ reviewComments?: string;
+ branchName?: string;
+ repoPath?: string;
+ workspacePath?: string;
+ fileList?: string;
+ fileContents?: string;
+}
+
+export interface AgentResult {
+ success: boolean;
+ content: string;
+ tokensUsed: number;
+ model: string;
+ updates: Record<string, unknown>; // Fields to update on the task
+ verdict?: 'approve' | 'reject'; // For review agents
+ message: string; // Log message
+}
+
+export abstract class BaseAgent {
+ abstract name: string;
+ abstract templateFile: string;
+ abstract model: string;
+
+ protected loadTemplate(): string {
+ return readFileSync(join(TEMPLATES_DIR, this.templateFile), 'utf-8');
+ }
+
+ protected fillTemplate(template: string, ctx: AgentContext): string {
+ return template
+ .replace(/\{\{taskId\}\}/g, String(ctx.taskId))
+ .replace(/\{\{title\}\}/g, ctx.title || '')
+ .replace(/\{\{description\}\}/g, ctx.description || '')
+ .replace(/\{\{plan\}\}/g, ctx.plan || '')
+ .replace(/\{\{decision_log\}\}/g, ctx.decisionLog || '')
+ .replace(/\{\{done_when\}\}/g, ctx.doneWhen || '')
+ .replace(/\{\{implementation_notes\}\}/g, ctx.implementationNotes || '')
+ .replace(/\{\{diff\}\}/g, ctx.diff || '')
+ .replace(/\{\{test_results\}\}/g, ctx.testResults || '')
+ .replace(/\{\{review_comments\}\}/g, ctx.reviewComments || '')
+ .replace(/\{\{branch_name\}\}/g, ctx.branchName || '')
+ .replace(/\{\{file_list\}\}/g, ctx.fileList || '')
+ .replace(/\{\{file_contents\}\}/g, ctx.fileContents || '');
+ }
+
+ async execute(ctx: AgentContext): Promise<AgentResult> {
+ const template = this.loadTemplate();
+ const prompt = this.fillTemplate(template, ctx);
+
+ const response = await callClaude({
+ model: this.model,
+ systemPrompt: this.getSystemPrompt(),
+ userMessage: prompt,
+ maxTokens: this.getMaxTokens(),
+ });
+
+ return this.parseResponse(response, ctx);
+ }
+
+ protected abstract getSystemPrompt(): string;
+ protected abstract parseResponse(response: ClaudeResponse, ctx: AgentContext): AgentResult;
+
+ protected getMaxTokens(): number {
+ return 8192;
+ }
+}
diff --git a/admin-panel/src/server/agents/builder.ts b/admin-panel/src/server/agents/builder.ts
new file mode 100644
index 0000000..4e555be
--- /dev/null
+++ b/admin-panel/src/server/agents/builder.ts
@@ -0,0 +1,64 @@
+import { BaseAgent, type AgentContext, type AgentResult } from './base-agent.js';
+import type { ClaudeResponse } from '../services/claude.service.js';
+
+export class BuilderAgent extends BaseAgent {
+ name = 'builder';
+ templateFile = 'builder.md';
+ model = 'claude-opus-4-20250514';
+
+ protected getSystemPrompt(): string {
+ return 'You are an expert software engineer. Write clean, correct, production-quality code. Follow the plan precisely. Output complete file contents — never use placeholders or ellipses.';
+ }
+
+ protected getMaxTokens(): number {
+ return 16384;
+ }
+
+ protected parseResponse(response: ClaudeResponse, _ctx: AgentContext): AgentResult {
+ const content = response.content;
+
+ // Extract files from ```FILE: path``` blocks
+ const files = parseFileBlocks(content);
+ const summary = extractSection(content, 'Summary') || '';
+ const implNotes = extractSection(content, 'Implementation Notes') || '';
+
+ return {
+ success: true,
+ content,
+ tokensUsed: response.tokensUsed,
+ model: response.model,
+ updates: {
+ implementation_notes: implNotes || summary,
+ status: 'impl_review',
+ // files are handled by pipeline.service.ts which reads them from content
+ },
+ message: `Implemented ${files.length} file(s)`,
+ };
+ }
+}
+
+export interface FileChange {
+ path: string;
+ content: string;
+}
+
+export function parseFileBlocks(text: string): FileChange[] {
+ const files: FileChange[] = [];
+ const regex = /```FILE:\s*(.+?)\n([\s\S]*?)```/g;
+ let match;
+
+ while ((match = regex.exec(text)) !== null) {
+ files.push({
+ path: match[1].trim(),
+ content: match[2],
+ });
+ }
+
+ return files;
+}
+
+function extractSection(text: string, heading: string): string | null {
+ const regex = new RegExp(`###\\s*${heading}\\s*\\n([\\s\\S]*?)(?=###|$)`, 'i');
+ const match = text.match(regex);
+ return match ? match[1].trim() : null;
+}
diff --git a/admin-panel/src/server/agents/critic.ts b/admin-panel/src/server/agents/critic.ts
new file mode 100644
index 0000000..b0613fb
--- /dev/null
+++ b/admin-panel/src/server/agents/critic.ts
@@ -0,0 +1,55 @@
+import { BaseAgent, type AgentContext, type AgentResult } from './base-agent.js';
+import type { ClaudeResponse } from '../services/claude.service.js';
+
+export class CriticAgent extends BaseAgent {
+ name = 'critic';
+ templateFile = 'critic.md';
+ model = 'claude-sonnet-4-20250514';
+
+ protected getSystemPrompt(): string {
+ return 'You are a thorough plan reviewer. Be constructive but rigorous. Approve plans that are solid enough to implement, reject plans with significant gaps or flaws.';
+ }
+
+ protected parseResponse(response: ClaudeResponse, ctx: AgentContext): AgentResult {
+ const content = response.content;
+ const firstLine = content.split('\n')[0].trim().toUpperCase();
+ const approved = firstLine.includes('APPROVE');
+ const rejected = firstLine.includes('REJECT');
+ const verdict: 'approve' | 'reject' = approved ? 'approve' : 'reject';
+
+ // Build review entry
+ const reviewEntry = {
+ agent: 'critic',
+ verdict,
+ content,
+ timestamp: new Date().toISOString(),
+ };
+
+ const updates: Record<string, unknown> = {};
+ const currentReviews = ctx.reviewComments ? JSON.parse(ctx.reviewComments || '[]') : [];
+ currentReviews.push(reviewEntry);
+ updates.plan_review_comments = JSON.stringify(currentReviews);
+
+ if (approved) {
+ updates.status = 'impl';
+ updates.plan_review_count = (ctx as unknown as Record<string, number>).plan_review_count
+ ? (ctx as unknown as Record<string, number>).plan_review_count + 1
+ : 1;
+ } else if (rejected) {
+ updates.status = 'plan'; // Send back to planning
+ updates.plan_review_count = (ctx as unknown as Record<string, number>).plan_review_count
+ ? (ctx as unknown as Record<string, number>).plan_review_count + 1
+ : 1;
+ }
+
+ return {
+ success: true,
+ content,
+ tokensUsed: response.tokensUsed,
+ model: response.model,
+ updates,
+ verdict,
+ message: `Plan review: ${verdict.toUpperCase()}`,
+ };
+ }
+}
diff --git a/admin-panel/src/server/agents/inspector.ts b/admin-panel/src/server/agents/inspector.ts
new file mode 100644
index 0000000..575c34e
--- /dev/null
+++ b/admin-panel/src/server/agents/inspector.ts
@@ -0,0 +1,54 @@
+import { BaseAgent, type AgentContext, type AgentResult } from './base-agent.js';
+import type { ClaudeResponse } from '../services/claude.service.js';
+
+export class InspectorAgent extends BaseAgent {
+ name = 'inspector';
+ templateFile = 'inspector.md';
+ model = 'claude-sonnet-4-20250514';
+
+ protected getSystemPrompt(): string {
+ return 'You are a senior code reviewer. Be thorough but pragmatic. Focus on correctness and security. Approve code that is production-ready, reject code with significant issues.';
+ }
+
+ protected parseResponse(response: ClaudeResponse, _ctx: AgentContext): AgentResult {
+ const content = response.content;
+ const firstLine = content.split('\n')[0].trim().toUpperCase();
+ const approved = firstLine.includes('APPROVE');
+ const verdict: 'approve' | 'reject' = approved ? 'approve' : 'reject';
+
+ const reviewEntry = {
+ agent: 'inspector',
+ verdict,
+ content,
+ timestamp: new Date().toISOString(),
+ };
+
+ const updates: Record<string, unknown> = {};
+
+ // Parse existing review comments
+ let currentReviews: unknown[];
+ try {
+ currentReviews = JSON.parse(_ctx.reviewComments || '[]');
+ } catch {
+ currentReviews = [];
+ }
+ currentReviews.push(reviewEntry);
+ updates.review_comments = JSON.stringify(currentReviews);
+
+ if (approved) {
+ updates.status = 'test';
+ } else {
+ updates.status = 'impl'; // Send back to builder
+ }
+
+ return {
+ success: true,
+ content,
+ tokensUsed: response.tokensUsed,
+ model: response.model,
+ updates,
+ verdict,
+ message: `Code review: ${verdict.toUpperCase()}`,
+ };
+ }
+}
diff --git a/admin-panel/src/server/agents/planner.ts b/admin-panel/src/server/agents/planner.ts
new file mode 100644
index 0000000..eb004f9
--- /dev/null
+++ b/admin-panel/src/server/agents/planner.ts
@@ -0,0 +1,45 @@
+import { BaseAgent, type AgentContext, type AgentResult } from './base-agent.js';
+import type { ClaudeResponse } from '../services/claude.service.js';
+
+export class PlannerAgent extends BaseAgent {
+ name = 'planner';
+ templateFile = 'planner.md';
+ model = 'claude-opus-4-20250514';
+
+ protected getSystemPrompt(): string {
+ return 'You are a meticulous software architect who creates clear, actionable implementation plans. Be thorough but concise. Focus on practical steps, not theory.';
+ }
+
+ protected getMaxTokens(): number {
+ return 16384;
+ }
+
+ protected parseResponse(response: ClaudeResponse, _ctx: AgentContext): AgentResult {
+ const content = response.content;
+
+ // Extract sections
+ const plan = content;
+ const doneWhen = extractSection(content, 'Done When') || '';
+ const decisionLog = extractSection(content, 'Decisions') || '';
+
+ return {
+ success: true,
+ content,
+ tokensUsed: response.tokensUsed,
+ model: response.model,
+ updates: {
+ plan,
+ done_when: doneWhen,
+ decision_log: decisionLog,
+ status: 'plan_review',
+ },
+ message: 'Generated implementation plan',
+ };
+ }
+}
+
+function extractSection(text: string, heading: string): string | null {
+ const regex = new RegExp(`###\\s*${heading}\\s*\\n([\\s\\S]*?)(?=###|$)`, 'i');
+ const match = text.match(regex);
+ return match ? match[1].trim() : null;
+}
diff --git a/admin-panel/src/server/agents/ranger.ts b/admin-panel/src/server/agents/ranger.ts
new file mode 100644
index 0000000..f1f3918
--- /dev/null
+++ b/admin-panel/src/server/agents/ranger.ts
@@ -0,0 +1,54 @@
+import { BaseAgent, type AgentContext, type AgentResult } from './base-agent.js';
+import type { ClaudeResponse } from '../services/claude.service.js';
+
+export class RangerAgent extends BaseAgent {
+ name = 'ranger';
+ templateFile = 'ranger.md';
+ model = 'claude-sonnet-4-20250514';
+
+ protected getSystemPrompt(): string {
+ return 'You are a QA engineer making the final pass/fail decision. Be fair but thorough. Only pass tasks that genuinely meet their acceptance criteria.';
+ }
+
+ protected parseResponse(response: ClaudeResponse, _ctx: AgentContext): AgentResult {
+ const content = response.content;
+ const firstLine = content.split('\n')[0].trim().toUpperCase();
+ const passed = firstLine.includes('PASS');
+ const verdict: 'approve' | 'reject' = passed ? 'approve' : 'reject';
+
+ const testEntry = {
+ agent: 'ranger',
+ verdict: passed ? 'pass' : 'fail',
+ content,
+ timestamp: new Date().toISOString(),
+ };
+
+ const updates: Record<string, unknown> = {};
+
+ let currentResults: unknown[];
+ try {
+ currentResults = JSON.parse(_ctx.testResults || '[]');
+ } catch {
+ currentResults = [];
+ }
+ currentResults.push(testEntry);
+ updates.test_results = JSON.stringify(currentResults);
+
+ if (passed) {
+ updates.status = 'done';
+ updates.completed_at = new Date().toISOString();
+ } else {
+ updates.status = 'impl'; // Send back to builder
+ }
+
+ return {
+ success: true,
+ content,
+ tokensUsed: response.tokensUsed,
+ model: response.model,
+ updates,
+ verdict,
+ message: `Final verdict: ${passed ? 'PASS' : 'FAIL'}`,
+ };
+ }
+}
diff --git a/admin-panel/src/server/agents/shield.ts b/admin-panel/src/server/agents/shield.ts
new file mode 100644
index 0000000..2ce0bc1
--- /dev/null
+++ b/admin-panel/src/server/agents/shield.ts
@@ -0,0 +1,62 @@
+import { BaseAgent, type AgentContext, type AgentResult } from './base-agent.js';
+import type { ClaudeResponse } from '../services/claude.service.js';
+
+export class ShieldAgent extends BaseAgent {
+ name = 'shield';
+ templateFile = 'shield.md';
+ model = 'claude-sonnet-4-20250514';
+
+ protected getSystemPrompt(): string {
+ return 'You are a test engineer who writes thorough, practical tests. Focus on verifying requirements and catching regressions. Use appropriate testing frameworks.';
+ }
+
+ protected getMaxTokens(): number {
+ return 12288;
+ }
+
+ protected parseResponse(response: ClaudeResponse, _ctx: AgentContext): AgentResult {
+ const content = response.content;
+
+ // Extract test files
+ const files = parseTestFiles(content);
+ const strategy = extractSection(content, 'Test Strategy') || '';
+
+ return {
+ success: true,
+ content,
+ tokensUsed: response.tokensUsed,
+ model: response.model,
+ updates: {
+ // Test files are written by pipeline service
+ // Test results will be populated after running
+ },
+ message: `Generated ${files.length} test file(s)`,
+ };
+ }
+}
+
+interface TestFile {
+ path: string;
+ content: string;
+}
+
+function parseTestFiles(text: string): TestFile[] {
+ const files: TestFile[] = [];
+ const regex = /```FILE:\s*(.+?)\n([\s\S]*?)```/g;
+ let match;
+
+ while ((match = regex.exec(text)) !== null) {
+ files.push({
+ path: match[1].trim(),
+ content: match[2],
+ });
+ }
+
+ return files;
+}
+
+function extractSection(text: string, heading: string): string | null {
+ const regex = new RegExp(`###\\s*${heading}\\s*\\n([\\s\\S]*?)(?=###|$)`, 'i');
+ const match = text.match(regex);
+ return match ? match[1].trim() : null;
+}
diff --git a/admin-panel/src/server/db.ts b/admin-panel/src/server/db.ts
new file mode 100644
index 0000000..9c83031
--- /dev/null
+++ b/admin-panel/src/server/db.ts
@@ -0,0 +1,82 @@
+import Database from 'better-sqlite3';
+import { readFileSync } from 'fs';
+import { join, dirname } from 'path';
+import { mkdirSync, existsSync } from 'fs';
+import { fileURLToPath } from 'url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const DATA_DIR = join(process.cwd(), 'data');
+const SCHEMA_PATH = join(__dirname, 'schema.sql');
+
+const dbCache = new Map<string, Database.Database>();
+
+function ensureDataDir(): void {
+ if (!existsSync(DATA_DIR)) {
+ mkdirSync(DATA_DIR, { recursive: true });
+ }
+}
+
+function loadSchema(): { globalSchema: string; projectSchema: string } {
+ const full = readFileSync(SCHEMA_PATH, 'utf-8');
+ const marker = '-- === PROJECT_SCHEMA ===';
+ const idx = full.indexOf(marker);
+ if (idx === -1) {
+ return { globalSchema: full, projectSchema: '' };
+ }
+ return {
+ globalSchema: full.substring(0, idx).trim(),
+ projectSchema: full.substring(idx + marker.length).trim(),
+ };
+}
+
+export function getAdminDb(): Database.Database {
+ if (dbCache.has('admin')) {
+ return dbCache.get('admin')!;
+ }
+ ensureDataDir();
+ const db = new Database(join(DATA_DIR, 'admin.db'));
+ db.pragma('journal_mode = WAL');
+ db.pragma('foreign_keys = ON');
+
+ const { globalSchema } = loadSchema();
+ db.exec(globalSchema);
+
+ dbCache.set('admin', db);
+ return db;
+}
+
+export function getProjectDb(projectName: string): Database.Database {
+ if (dbCache.has(projectName)) {
+ return dbCache.get(projectName)!;
+ }
+ ensureDataDir();
+ const db = new Database(join(DATA_DIR, `${projectName}.db`));
+ db.pragma('journal_mode = WAL');
+ db.pragma('foreign_keys = ON');
+
+ const { projectSchema } = loadSchema();
+ db.exec(projectSchema);
+
+ dbCache.set(projectName, db);
+ return db;
+}
+
+export function closeAll(): void {
+ for (const [name, db] of dbCache) {
+ db.close();
+ dbCache.delete(name);
+ }
+}
+
+export function getSetting(key: string): string | undefined {
+ const db = getAdminDb();
+ const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as
+ | { value: string }
+ | undefined;
+ return row?.value;
+}
+
+export function setSetting(key: string, value: string): void {
+ const db = getAdminDb();
+ db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, value);
+}
diff --git a/admin-panel/src/server/index.ts b/admin-panel/src/server/index.ts
new file mode 100644
index 0000000..f76602e
--- /dev/null
+++ b/admin-panel/src/server/index.ts
@@ -0,0 +1,46 @@
+import express from 'express';
+import { createServer } from 'http';
+import { join, dirname } from 'path';
+import { existsSync } from 'fs';
+import { fileURLToPath } from 'url';
+import { setupWebSocket } from './ws.js';
+import projectsRouter from './routes/projects.js';
+import tasksRouter from './routes/tasks.js';
+import pipelineRouter from './routes/pipeline.js';
+import gerritRouter from './routes/gerrit.js';
+import dashboardRouter from './routes/dashboard.js';
+import settingsRouter from './routes/settings.js';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const app = express();
+const server = createServer(app);
+const PORT = process.env.PORT || 3000;
+
+// Middleware
+app.use(express.json());
+
+// API routes
+app.use('/api/projects', projectsRouter);
+app.use('/api/projects/:project/tasks', tasksRouter);
+app.use('/api/projects/:project/pipeline', pipelineRouter);
+app.use('/api/projects/:project/gerrit', gerritRouter);
+app.use('/api/dashboard', dashboardRouter);
+app.use('/api/settings', settingsRouter);
+
+// Serve static client in production
+const clientDir = join(__dirname, '..', 'client');
+if (existsSync(clientDir)) {
+ app.use(express.static(clientDir));
+ app.get('*', (_req, res) => {
+ res.sendFile(join(clientDir, 'index.html'));
+ });
+}
+
+// WebSocket
+setupWebSocket(server);
+
+server.listen(PORT, () => {
+ console.log(`Admin panel running on port ${PORT}`);
+});
+
+export { app, server };
diff --git a/admin-panel/src/server/routes/dashboard.ts b/admin-panel/src/server/routes/dashboard.ts
new file mode 100644
index 0000000..3142e8e
--- /dev/null
+++ b/admin-panel/src/server/routes/dashboard.ts
@@ -0,0 +1,138 @@
+import { Router, Request, Response } from 'express';
+import { getAdminDb, getProjectDb } from '../db.js';
+
+const router = Router();
+
+interface Project {
+ id: number;
+ name: string;
+ display_name: string;
+}
+
+// GET /api/dashboard/stats
+router.get('/stats', (_req: Request, res: Response) => {
+ const adminDb = getAdminDb();
+ const projects = adminDb.prepare('SELECT * FROM projects').all() as Project[];
+
+ let totalTasks = 0;
+ let completedToday = 0;
+ const tasksByStatus: Record<string, number> = {};
+
+ const today = new Date().toISOString().split('T')[0];
+
+ for (const project of projects) {
+ try {
+ const db = getProjectDb(project.name);
+ const tasks = db.prepare('SELECT status, completed_at FROM tasks').all() as Array<{
+ status: string;
+ completed_at: string | null;
+ }>;
+
+ totalTasks += tasks.length;
+
+ for (const task of tasks) {
+ tasksByStatus[task.status] = (tasksByStatus[task.status] || 0) + 1;
+ if (task.completed_at && task.completed_at.startsWith(today)) {
+ completedToday++;
+ }
+ }
+ } catch {
+ // Project DB might not exist yet
+ }
+ }
+
+ res.json({
+ totalProjects: projects.length,
+ totalTasks,
+ tasksByStatus,
+ completedToday,
+ });
+});
+
+// GET /api/dashboard/activity
+router.get('/activity', (_req: Request, res: Response) => {
+ const adminDb = getAdminDb();
+ const projects = adminDb.prepare('SELECT * FROM projects').all() as Project[];
+
+ const activity: Array<{
+ project: string;
+ taskId: number;
+ taskTitle: string;
+ agent: string;
+ action: string;
+ timestamp: string;
+ }> = [];
+
+ for (const project of projects) {
+ try {
+ const db = getProjectDb(project.name);
+ const tasks = db.prepare('SELECT id, title, agent_log FROM tasks WHERE agent_log != \'[]\'').all() as Array<{
+ id: number;
+ title: string;
+ agent_log: string;
+ }>;
+
+ for (const task of tasks) {
+ try {
+ const logs = JSON.parse(task.agent_log) as Array<{
+ agent: string;
+ message: string;
+ timestamp: string;
+ }>;
+ for (const log of logs) {
+ activity.push({
+ project: project.name,
+ taskId: task.id,
+ taskTitle: task.title,
+ agent: log.agent,
+ action: log.message,
+ timestamp: log.timestamp,
+ });
+ }
+ } catch {
+ // Skip malformed logs
+ }
+ }
+ } catch {
+ // Project DB might not exist
+ }
+ }
+
+ // Sort by timestamp descending, take 20 most recent
+ activity.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
+ res.json(activity.slice(0, 20));
+});
+
+// GET /api/projects/:project/stats
+router.get('/projects/:project', (req: Request, res: Response) => {
+ const { project } = req.params;
+
+ const adminDb = getAdminDb();
+ if (!adminDb.prepare('SELECT id FROM projects WHERE name = ?').get(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ try {
+ const db = getProjectDb(project as string);
+ const tasks = db.prepare('SELECT status FROM tasks').all() as Array<{ status: string }>;
+ const tasksByStatus: Record<string, number> = {};
+ for (const task of tasks) {
+ tasksByStatus[task.status] = (tasksByStatus[task.status] || 0) + 1;
+ }
+
+ const totalJobs = db.prepare('SELECT COUNT(*) as count FROM pipeline_jobs').get() as { count: number };
+ const totalTokens = db.prepare('SELECT SUM(tokens_used) as total FROM pipeline_jobs').get() as { total: number | null };
+
+ res.json({
+ totalTasks: tasks.length,
+ tasksByStatus,
+ totalPipelineJobs: totalJobs.count,
+ totalTokensUsed: totalTokens.total || 0,
+ });
+ } catch {
+ res.json({ totalTasks: 0, tasksByStatus: {}, totalPipelineJobs: 0, totalTokensUsed: 0 });
+ }
+});
+
+export default router;
diff --git a/admin-panel/src/server/routes/gerrit.ts b/admin-panel/src/server/routes/gerrit.ts
new file mode 100644
index 0000000..b48ea53
--- /dev/null
+++ b/admin-panel/src/server/routes/gerrit.ts
@@ -0,0 +1,68 @@
+import { Router, Request, Response } from 'express';
+import { getAdminDb, getProjectDb } from '../db.js';
+import { getChange, getChangeByNumber } from '../services/gerrit.service.js';
+
+const router = Router({ mergeParams: true });
+
+type Params = { project: string; taskId: string };
+
+// GET /api/projects/:project/gerrit/:taskId
+router.get('/:taskId', async (req: Request<Params>, res: Response) => {
+ const { project, taskId } = req.params;
+
+ const adminDb = getAdminDb();
+ if (!adminDb.prepare('SELECT id FROM projects WHERE name = ?').get(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const db = getProjectDb(project);
+ const task = db.prepare('SELECT gerrit_change_id, gerrit_change_number FROM tasks WHERE id = ?').get(parseInt(taskId)) as {
+ gerrit_change_id: string | null;
+ gerrit_change_number: number | null;
+ } | undefined;
+
+ if (!task) {
+ res.status(404).json({ error: 'Task not found' });
+ return;
+ }
+
+ if (!task.gerrit_change_id && !task.gerrit_change_number) {
+ res.json({ change: null });
+ return;
+ }
+
+ try {
+ const change = task.gerrit_change_id
+ ? await getChange(task.gerrit_change_id)
+ : task.gerrit_change_number
+ ? await getChangeByNumber(task.gerrit_change_number)
+ : null;
+ res.json({ change });
+ } catch (err) {
+ res.status(502).json({ error: `Gerrit API error: ${(err as Error).message}` });
+ }
+});
+
+// GET /api/projects/:project/diff/:taskId
+router.get('/diff/:taskId', (req: Request<Params>, res: Response) => {
+ const { project, taskId } = req.params;
+
+ const adminDb = getAdminDb();
+ if (!adminDb.prepare('SELECT id FROM projects WHERE name = ?').get(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const db = getProjectDb(project);
+ const task = db.prepare('SELECT diff FROM tasks WHERE id = ?').get(parseInt(taskId)) as { diff: string | null } | undefined;
+
+ if (!task) {
+ res.status(404).json({ error: 'Task not found' });
+ return;
+ }
+
+ res.json({ diff: task.diff || '' });
+});
+
+export default router;
diff --git a/admin-panel/src/server/routes/pipeline.ts b/admin-panel/src/server/routes/pipeline.ts
new file mode 100644
index 0000000..39df35c
--- /dev/null
+++ b/admin-panel/src/server/routes/pipeline.ts
@@ -0,0 +1,82 @@
+import { Router, Request, Response } from 'express';
+import { getAdminDb } from '../db.js';
+import {
+ runFullPipeline,
+ runStep,
+ stopPipeline,
+ getPipelineStatus,
+ isPipelineRunning,
+} from '../services/pipeline.service.js';
+
+const router = Router({ mergeParams: true });
+
+type Params = { project: string; taskId: string };
+
+function projectExists(name: string): boolean {
+ const db = getAdminDb();
+ return !!db.prepare('SELECT id FROM projects WHERE name = ?').get(name);
+}
+
+// POST /api/projects/:project/pipeline/run/:taskId
+router.post('/run/:taskId', async (req: Request<Params>, res: Response) => {
+ const { project, taskId } = req.params;
+ if (!projectExists(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const id = parseInt(taskId);
+ if (isPipelineRunning(project, id)) {
+ res.status(409).json({ error: 'Pipeline already running for this task' });
+ return;
+ }
+
+ // Run async — don't block the response
+ runFullPipeline(project, id).catch(err => {
+ console.error(`Pipeline error for ${project}/${taskId}:`, err);
+ });
+
+ res.json({ status: 'started' });
+});
+
+// POST /api/projects/:project/pipeline/step/:taskId
+router.post('/step/:taskId', async (req: Request<Params>, res: Response) => {
+ const { project, taskId } = req.params;
+ if (!projectExists(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ try {
+ await runStep(project, parseInt(taskId));
+ res.json({ status: 'completed' });
+ } catch (err) {
+ res.status(400).json({ error: (err as Error).message });
+ }
+});
+
+// POST /api/projects/:project/pipeline/stop/:taskId
+router.post('/stop/:taskId', (req: Request<Params>, res: Response) => {
+ const { project, taskId } = req.params;
+ if (!projectExists(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ stopPipeline(project, parseInt(taskId));
+ res.json({ status: 'stopping' });
+});
+
+// GET /api/projects/:project/pipeline/status/:taskId
+router.get('/status/:taskId', (req: Request<Params>, res: Response) => {
+ const { project, taskId } = req.params;
+ if (!projectExists(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const status = getPipelineStatus(project, parseInt(taskId));
+ res.json(status);
+});
+
+export default router;
diff --git a/admin-panel/src/server/routes/projects.ts b/admin-panel/src/server/routes/projects.ts
new file mode 100644
index 0000000..b2e63a8
--- /dev/null
+++ b/admin-panel/src/server/routes/projects.ts
@@ -0,0 +1,138 @@
+import { Router, Request, Response } from 'express';
+import { getAdminDb, getProjectDb } from '../db.js';
+import { createBareRepo, setRepoDescription, repoExists, deleteRepo } from '../services/git.service.js';
+
+const router = Router();
+
+interface Project {
+ id: number;
+ name: string;
+ display_name: string;
+ description: string | null;
+ repo_name: string;
+ repo_path: string;
+ default_branch: string;
+ created_at: string;
+ updated_at: string;
+}
+
+// GET /api/projects
+router.get('/', (_req: Request, res: Response) => {
+ const db = getAdminDb();
+ const projects = db.prepare('SELECT * FROM projects ORDER BY created_at DESC').all();
+ res.json(projects);
+});
+
+// POST /api/projects
+router.post('/', (req: Request, res: Response) => {
+ const { name, display_name, description } = req.body;
+
+ if (!name || !display_name) {
+ res.status(400).json({ error: 'name and display_name are required' });
+ return;
+ }
+
+ // Sanitize name: lowercase, alphanumeric + hyphens
+ const safeName = name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
+ if (!safeName) {
+ res.status(400).json({ error: 'Invalid project name' });
+ return;
+ }
+
+ const repoName = `${safeName}.git`;
+ const db = getAdminDb();
+
+ // Check for duplicate
+ const existing = db.prepare('SELECT id FROM projects WHERE name = ?').get(safeName);
+ if (existing) {
+ res.status(409).json({ error: 'Project already exists' });
+ return;
+ }
+
+ // Create bare git repo
+ const repo = createBareRepo(repoName);
+ if (description) {
+ setRepoDescription(repoName, description);
+ }
+
+ // Insert into admin DB
+ const result = db.prepare(
+ 'INSERT INTO projects (name, display_name, description, repo_name, repo_path) VALUES (?, ?, ?, ?, ?)'
+ ).run(safeName, display_name, description || null, repoName, repo.path);
+
+ // Initialize project DB (creates tables)
+ getProjectDb(safeName);
+
+ const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(result.lastInsertRowid);
+ res.status(201).json(project);
+});
+
+// GET /api/projects/:name
+router.get('/:name', (req: Request, res: Response) => {
+ const db = getAdminDb();
+ const project = db.prepare('SELECT * FROM projects WHERE name = ?').get(req.params.name) as Project | undefined;
+ if (!project) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+ res.json(project);
+});
+
+// PATCH /api/projects/:name
+router.patch('/:name', (req: Request, res: Response) => {
+ const db = getAdminDb();
+ const project = db.prepare('SELECT * FROM projects WHERE name = ?').get(req.params.name) as Project | undefined;
+ if (!project) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const { display_name, description } = req.body;
+ const updates: string[] = [];
+ const params: unknown[] = [];
+
+ if (display_name !== undefined) {
+ updates.push('display_name = ?');
+ params.push(display_name);
+ }
+ if (description !== undefined) {
+ updates.push('description = ?');
+ params.push(description);
+ if (description) {
+ setRepoDescription(project.repo_name, description);
+ }
+ }
+
+ if (updates.length === 0) {
+ res.json(project);
+ return;
+ }
+
+ updates.push("updated_at = datetime('now')");
+ params.push(req.params.name);
+
+ db.prepare(`UPDATE projects SET ${updates.join(', ')} WHERE name = ?`).run(...params);
+ const updated = db.prepare('SELECT * FROM projects WHERE name = ?').get(req.params.name);
+ res.json(updated);
+});
+
+// DELETE /api/projects/:name
+router.delete('/:name', (req: Request, res: Response) => {
+ const db = getAdminDb();
+ const project = db.prepare('SELECT * FROM projects WHERE name = ?').get(req.params.name) as Project | undefined;
+ if (!project) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ db.prepare('DELETE FROM projects WHERE name = ?').run(req.params.name);
+
+ // Optionally delete repo (controlled by query param)
+ if (req.query.delete_repo === 'true') {
+ deleteRepo(project.repo_name);
+ }
+
+ res.status(204).send();
+});
+
+export default router;
diff --git a/admin-panel/src/server/routes/settings.ts b/admin-panel/src/server/routes/settings.ts
new file mode 100644
index 0000000..4eed6f1
--- /dev/null
+++ b/admin-panel/src/server/routes/settings.ts
@@ -0,0 +1,35 @@
+import { Router, Request, Response } from 'express';
+import { getAdminDb } from '../db.js';
+
+const router = Router();
+
+// GET /api/settings
+router.get('/', (_req: Request, res: Response) => {
+ const db = getAdminDb();
+ const rows = db.prepare('SELECT key, value FROM settings').all() as Array<{
+ key: string;
+ value: string;
+ }>;
+ const settings: Record<string, string> = {};
+ for (const row of rows) {
+ settings[row.key] = row.value;
+ }
+ res.json(settings);
+});
+
+// PATCH /api/settings
+router.patch('/', (req: Request, res: Response) => {
+ const db = getAdminDb();
+ const stmt = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)');
+
+ const updates = req.body as Record<string, string>;
+ for (const [key, value] of Object.entries(updates)) {
+ if (typeof key === 'string' && typeof value === 'string') {
+ stmt.run(key, value);
+ }
+ }
+
+ res.status(204).send();
+});
+
+export default router;
diff --git a/admin-panel/src/server/routes/tasks.ts b/admin-panel/src/server/routes/tasks.ts
new file mode 100644
index 0000000..1ab1e8d
--- /dev/null
+++ b/admin-panel/src/server/routes/tasks.ts
@@ -0,0 +1,248 @@
+import { Router, Request, Response } from 'express';
+import { getAdminDb, getProjectDb } from '../db.js';
+import { broadcast } from '../ws.js';
+
+const router = Router({ mergeParams: true });
+
+type Params = { project: string; id?: string };
+
+interface Task {
+ id: number;
+ title: string;
+ status: string;
+ priority: string;
+ level: number;
+ description: string | null;
+ plan: string | null;
+ decision_log: string | null;
+ done_when: string | null;
+ implementation_notes: string | null;
+ tags: string;
+ plan_review_comments: string;
+ review_comments: string;
+ test_results: string;
+ agent_log: string;
+ current_agent: string | null;
+ plan_review_count: number;
+ impl_review_count: number;
+ branch_name: string | null;
+ gerrit_change_id: string | null;
+ gerrit_change_number: number | null;
+ diff: string | null;
+ rank: number;
+ created_at: string;
+ started_at: string | null;
+ planned_at: string | null;
+ reviewed_at: string | null;
+ tested_at: string | null;
+ completed_at: string | null;
+}
+
+const VALID_STATUSES = ['todo', 'plan', 'plan_review', 'impl', 'impl_review', 'test', 'done'];
+const VALID_PRIORITIES = ['high', 'medium', 'low'];
+
+function getProject(projectName: string): boolean {
+ const db = getAdminDb();
+ return !!db.prepare('SELECT id FROM projects WHERE name = ?').get(projectName);
+}
+
+// GET /api/projects/:project/tasks
+router.get('/', (req: Request<Params>, res: Response) => {
+ const { project } = req.params;
+ if (!getProject(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const db = getProjectDb(project);
+ const tasks = db.prepare('SELECT * FROM tasks ORDER BY rank ASC, id ASC').all();
+ res.json(tasks);
+});
+
+// POST /api/projects/:project/tasks
+router.post('/', (req: Request<Params>, res: Response) => {
+ const { project } = req.params;
+ if (!getProject(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const { title, description, priority, level, tags, status } = req.body;
+ if (!title) {
+ res.status(400).json({ error: 'title is required' });
+ return;
+ }
+
+ const db = getProjectDb(project);
+
+ // Get max rank for ordering
+ const maxRank = (db.prepare('SELECT MAX(rank) as max FROM tasks').get() as { max: number | null })?.max || 0;
+
+ const result = db.prepare(
+ `INSERT INTO tasks (title, description, priority, level, tags, status, rank)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
+ ).run(
+ title,
+ description || null,
+ VALID_PRIORITIES.includes(priority) ? priority : 'medium',
+ [1, 2, 3].includes(level) ? level : 3,
+ JSON.stringify(tags || []),
+ VALID_STATUSES.includes(status) ? status : 'todo',
+ maxRank + 1
+ );
+
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(result.lastInsertRowid);
+ broadcast(project, { type: 'task:created', project, task });
+ res.status(201).json(task);
+});
+
+// GET /api/projects/:project/tasks/:id
+router.get('/:id', (req: Request<Params>, res: Response) => {
+ const { project, id } = req.params;
+ if (!getProject(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const db = getProjectDb(project);
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
+ if (!task) {
+ res.status(404).json({ error: 'Task not found' });
+ return;
+ }
+ res.json(task);
+});
+
+// PATCH /api/projects/:project/tasks/:id
+router.patch('/:id', (req: Request<Params>, res: Response) => {
+ const { project, id } = req.params;
+ if (!getProject(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const db = getProjectDb(project);
+ const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as Task | undefined;
+ if (!existing) {
+ res.status(404).json({ error: 'Task not found' });
+ return;
+ }
+
+ const allowedFields = [
+ 'title', 'status', 'priority', 'level', 'description', 'plan',
+ 'decision_log', 'done_when', 'implementation_notes', 'tags',
+ 'plan_review_comments', 'review_comments', 'test_results',
+ 'agent_log', 'current_agent', 'plan_review_count', 'impl_review_count',
+ 'branch_name', 'gerrit_change_id', 'gerrit_change_number', 'diff', 'rank'
+ ];
+
+ const updates: string[] = [];
+ const params: unknown[] = [];
+
+ for (const field of allowedFields) {
+ if (req.body[field] !== undefined) {
+ let value = req.body[field];
+
+ // Validate specific fields
+ if (field === 'status' && !VALID_STATUSES.includes(value)) continue;
+ if (field === 'priority' && !VALID_PRIORITIES.includes(value)) continue;
+ if (field === 'level' && ![1, 2, 3].includes(value)) continue;
+
+ // Stringify JSON fields
+ if (['tags', 'plan_review_comments', 'review_comments', 'test_results', 'agent_log'].includes(field)) {
+ value = typeof value === 'string' ? value : JSON.stringify(value);
+ }
+
+ updates.push(`${field} = ?`);
+ params.push(value);
+ }
+ }
+
+ // Set timestamp fields based on status changes
+ if (req.body.status) {
+ const now = new Date().toISOString();
+ switch (req.body.status) {
+ case 'plan':
+ if (!existing.started_at) {
+ updates.push('started_at = ?');
+ params.push(now);
+ }
+ break;
+ case 'impl':
+ updates.push('planned_at = ?');
+ params.push(now);
+ break;
+ case 'test':
+ updates.push('reviewed_at = ?');
+ params.push(now);
+ break;
+ case 'done':
+ updates.push('completed_at = ?');
+ params.push(now);
+ break;
+ }
+ }
+
+ if (updates.length === 0) {
+ res.json(existing);
+ return;
+ }
+
+ params.push(id);
+ db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`).run(...params);
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
+ broadcast(project, { type: 'task:updated', project, task });
+ res.json(task);
+});
+
+// DELETE /api/projects/:project/tasks/:id
+router.delete('/:id', (req: Request<Params>, res: Response) => {
+ const { project, id } = req.params;
+ if (!getProject(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const db = getProjectDb(project);
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
+ if (!task) {
+ res.status(404).json({ error: 'Task not found' });
+ return;
+ }
+
+ db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
+ broadcast(project, { type: 'task:deleted', project, taskId: Number(id) });
+ res.status(204).send();
+});
+
+// PATCH /api/projects/:project/tasks/:id/reorder
+router.patch('/:id/reorder', (req: Request<Params>, res: Response) => {
+ const { project, id } = req.params;
+ if (!getProject(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const { rank, status } = req.body;
+ if (rank === undefined) {
+ res.status(400).json({ error: 'rank is required' });
+ return;
+ }
+
+ const db = getProjectDb(project);
+ const updates: string[] = ['rank = ?'];
+ const params: unknown[] = [rank];
+
+ if (status && VALID_STATUSES.includes(status)) {
+ updates.push('status = ?');
+ params.push(status);
+ }
+
+ params.push(id);
+ db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`).run(...params);
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
+ broadcast(project, { type: 'task:updated', project, task });
+ res.json(task);
+});
+
+export default router;
diff --git a/admin-panel/src/server/schema.sql b/admin-panel/src/server/schema.sql
new file mode 100644
index 0000000..36c0e40
--- /dev/null
+++ b/admin-panel/src/server/schema.sql
@@ -0,0 +1,73 @@
+-- Global database schema (admin.db)
+
+CREATE TABLE IF NOT EXISTS projects (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ display_name TEXT NOT NULL,
+ description TEXT,
+ repo_name TEXT NOT NULL,
+ repo_path TEXT NOT NULL,
+ default_branch TEXT DEFAULT 'main',
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+);
+
+-- Default settings
+INSERT OR IGNORE INTO settings (key, value) VALUES
+ ('anthropic_model_planning', 'claude-opus-4-20250514'),
+ ('anthropic_model_review', 'claude-sonnet-4-20250514'),
+ ('anthropic_model_implementation', 'claude-opus-4-20250514'),
+ ('anthropic_model_testing', 'claude-sonnet-4-20250514');
+
+-- Per-project database schema (applied to {project-name}.db)
+-- This comment marks where project schema starts; code splits on this marker.
+-- === PROJECT_SCHEMA ===
+
+CREATE TABLE IF NOT EXISTS tasks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'todo',
+ priority TEXT NOT NULL DEFAULT 'medium',
+ level INTEGER NOT NULL DEFAULT 3,
+ description TEXT,
+ plan TEXT,
+ decision_log TEXT,
+ done_when TEXT,
+ implementation_notes TEXT,
+ tags TEXT DEFAULT '[]',
+ plan_review_comments TEXT DEFAULT '[]',
+ review_comments TEXT DEFAULT '[]',
+ test_results TEXT DEFAULT '[]',
+ agent_log TEXT DEFAULT '[]',
+ current_agent TEXT,
+ plan_review_count INTEGER DEFAULT 0,
+ impl_review_count INTEGER DEFAULT 0,
+ branch_name TEXT,
+ gerrit_change_id TEXT,
+ gerrit_change_number INTEGER,
+ diff TEXT,
+ rank REAL DEFAULT 0,
+ created_at TEXT DEFAULT (datetime('now')),
+ started_at TEXT,
+ planned_at TEXT,
+ reviewed_at TEXT,
+ tested_at TEXT,
+ completed_at TEXT
+);
+
+CREATE TABLE IF NOT EXISTS pipeline_jobs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ task_id INTEGER NOT NULL REFERENCES tasks(id),
+ agent TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ model TEXT NOT NULL,
+ started_at TEXT,
+ completed_at TEXT,
+ error TEXT,
+ tokens_used INTEGER DEFAULT 0
+);
diff --git a/admin-panel/src/server/services/claude.service.ts b/admin-panel/src/server/services/claude.service.ts
new file mode 100644
index 0000000..b9aae37
--- /dev/null
+++ b/admin-panel/src/server/services/claude.service.ts
@@ -0,0 +1,47 @@
+import Anthropic from '@anthropic-ai/sdk';
+
+let client: Anthropic | null = null;
+
+function getClient(): Anthropic {
+ if (!client) {
+ const apiKey = process.env.ANTHROPIC_API_KEY;
+ if (!apiKey) throw new Error('ANTHROPIC_API_KEY not configured');
+ client = new Anthropic({ apiKey });
+ }
+ return client;
+}
+
+export interface ClaudeResponse {
+ content: string;
+ tokensUsed: number;
+ model: string;
+}
+
+export async function callClaude(opts: {
+ model: string;
+ systemPrompt: string;
+ userMessage: string;
+ maxTokens?: number;
+}): Promise<ClaudeResponse> {
+ const anthropic = getClient();
+
+ const response = await anthropic.messages.create({
+ model: opts.model,
+ max_tokens: opts.maxTokens || 8192,
+ system: opts.systemPrompt,
+ messages: [{ role: 'user', content: opts.userMessage }],
+ });
+
+ const content = response.content
+ .filter(block => block.type === 'text')
+ .map(block => (block as { type: 'text'; text: string }).text)
+ .join('\n');
+
+ const tokensUsed = (response.usage?.input_tokens || 0) + (response.usage?.output_tokens || 0);
+
+ return {
+ content,
+ tokensUsed,
+ model: opts.model,
+ };
+}
diff --git a/admin-panel/src/server/services/gerrit.service.ts b/admin-panel/src/server/services/gerrit.service.ts
new file mode 100644
index 0000000..53c8f13
--- /dev/null
+++ b/admin-panel/src/server/services/gerrit.service.ts
@@ -0,0 +1,81 @@
+const GERRIT_URL = process.env.GERRIT_URL || 'http://gerrit:8080';
+
+export interface GerritChange {
+ id: string;
+ change_id: string;
+ _number: number;
+ subject: string;
+ status: string;
+ created: string;
+ updated: string;
+ mergeable?: boolean;
+ labels?: Record<string, unknown>;
+}
+
+export interface GerritReview {
+ message: string;
+ labels?: Record<string, number>;
+ comments?: Record<string, Array<{ line: number; message: string }>>;
+}
+
+async function gerritFetch(path: string, options?: RequestInit): Promise<unknown> {
+ const url = `${GERRIT_URL}/a${path}`;
+ const res = await fetch(url, {
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options?.headers,
+ },
+ ...options,
+ });
+
+ if (!res.ok) {
+ throw new Error(`Gerrit API error: ${res.status} ${res.statusText}`);
+ }
+
+ const text = await res.text();
+ // Gerrit prepends )]}' to JSON responses
+ const json = text.startsWith(")]}'") ? text.slice(4) : text;
+ return json ? JSON.parse(json) : null;
+}
+
+export async function getChange(changeId: string): Promise<GerritChange | null> {
+ try {
+ return (await gerritFetch(`/changes/${encodeURIComponent(changeId)}`)) as GerritChange;
+ } catch {
+ return null;
+ }
+}
+
+export async function getChangeByNumber(changeNumber: number): Promise<GerritChange | null> {
+ try {
+ return (await gerritFetch(`/changes/${changeNumber}`)) as GerritChange;
+ } catch {
+ return null;
+ }
+}
+
+export async function postReview(changeId: string, review: GerritReview): Promise<void> {
+ await gerritFetch(`/changes/${encodeURIComponent(changeId)}/revisions/current/review`, {
+ method: 'POST',
+ body: JSON.stringify(review),
+ });
+}
+
+export async function getChangeDiff(changeId: string): Promise<string> {
+ const result = await gerritFetch(
+ `/changes/${encodeURIComponent(changeId)}/revisions/current/patch`
+ );
+ // Patch is base64 encoded
+ if (typeof result === 'string') {
+ return Buffer.from(result, 'base64').toString('utf-8');
+ }
+ return '';
+}
+
+export async function queryChanges(query: string): Promise<GerritChange[]> {
+ try {
+ return (await gerritFetch(`/changes/?q=${encodeURIComponent(query)}`)) as GerritChange[];
+ } catch {
+ return [];
+ }
+}
diff --git a/admin-panel/src/server/services/git.service.ts b/admin-panel/src/server/services/git.service.ts
new file mode 100644
index 0000000..990f5df
--- /dev/null
+++ b/admin-panel/src/server/services/git.service.ts
@@ -0,0 +1,142 @@
+import { execSync } from 'child_process';
+import { existsSync, mkdirSync, writeFileSync } from 'fs';
+import { join, dirname } from 'path';
+
+const REPO_BASE = process.env.GIT_REPO_PATH || '/repos';
+
+export interface RepoInfo {
+ name: string;
+ path: string;
+ exists: boolean;
+}
+
+export function createBareRepo(repoName: string, defaultBranch = 'main'): RepoInfo {
+ const repoPath = join(REPO_BASE, repoName);
+
+ if (existsSync(repoPath)) {
+ return { name: repoName, path: repoPath, exists: true };
+ }
+
+ execSync(`git init --bare "${repoPath}"`, { stdio: 'pipe' });
+ execSync(`git -C "${repoPath}" symbolic-ref HEAD refs/heads/${defaultBranch}`, {
+ stdio: 'pipe',
+ });
+
+ // Set description for cgit
+ const descPath = join(repoPath, 'description');
+ execSync(`echo "Repository managed by admin-panel" > "${descPath}"`, { stdio: 'pipe' });
+
+ // Enable post-update hook for git daemon
+ const hookPath = join(repoPath, 'hooks', 'post-update');
+ if (!existsSync(hookPath)) {
+ execSync(`cp "${repoPath}/hooks/post-update.sample" "${hookPath}" 2>/dev/null || true`, {
+ stdio: 'pipe',
+ });
+ }
+
+ // Run git update-server-info for HTTP access
+ execSync(`git -C "${repoPath}" update-server-info`, { stdio: 'pipe' });
+
+ return { name: repoName, path: repoPath, exists: false };
+}
+
+export function setRepoDescription(repoName: string, description: string): void {
+ const descPath = join(REPO_BASE, repoName, 'description');
+ execSync(`echo "${description.replace(/"/g, '\\"')}" > "${descPath}"`, { stdio: 'pipe' });
+}
+
+export function repoExists(repoName: string): boolean {
+ return existsSync(join(REPO_BASE, repoName));
+}
+
+export function deleteRepo(repoName: string): void {
+ const repoPath = join(REPO_BASE, repoName);
+ if (existsSync(repoPath)) {
+ execSync(`rm -rf "${repoPath}"`, { stdio: 'pipe' });
+ }
+}
+
+// --- Workspace operations for Builder agent ---
+
+export async function cloneRepo(bareRepoPath: string, workspacePath: string): Promise<void> {
+ if (existsSync(workspacePath)) {
+ // Pull latest instead of re-cloning
+ execSync(`git -C "${workspacePath}" fetch origin`, { stdio: 'pipe' });
+ execSync(`git -C "${workspacePath}" reset --hard origin/main 2>/dev/null || true`, { stdio: 'pipe' });
+ return;
+ }
+ mkdirSync(workspacePath, { recursive: true });
+ execSync(`git clone "${bareRepoPath}" "${workspacePath}"`, { stdio: 'pipe' });
+
+ // Configure git user for commits
+ execSync(`git -C "${workspacePath}" config user.email "admin-panel@swave.lol"`, { stdio: 'pipe' });
+ execSync(`git -C "${workspacePath}" config user.name "Admin Panel"`, { stdio: 'pipe' });
+}
+
+export async function createBranch(workspacePath: string, branchName: string): Promise<void> {
+ // Create or switch to branch
+ try {
+ execSync(`git -C "${workspacePath}" checkout -b "${branchName}"`, { stdio: 'pipe' });
+ } catch {
+ // Branch might already exist
+ execSync(`git -C "${workspacePath}" checkout "${branchName}"`, { stdio: 'pipe' });
+ }
+}
+
+export async function writeFiles(
+ workspacePath: string,
+ files: Array<{ path: string; content: string }>
+): Promise<void> {
+ for (const file of files) {
+ const fullPath = join(workspacePath, file.path);
+ mkdirSync(dirname(fullPath), { recursive: true });
+ writeFileSync(fullPath, file.content, 'utf-8');
+ }
+}
+
+export async function commitAndDiff(
+ workspacePath: string,
+ message: string,
+ branchName: string
+): Promise<string> {
+ execSync(`git -C "${workspacePath}" add -A`, { stdio: 'pipe' });
+
+ // Check if there are changes to commit
+ try {
+ execSync(`git -C "${workspacePath}" diff --cached --quiet`, { stdio: 'pipe' });
+ // No changes
+ return '';
+ } catch {
+ // There are changes — commit them
+ }
+
+ execSync(`git -C "${workspacePath}" commit -m "${message.replace(/"/g, '\\"')}"`, { stdio: 'pipe' });
+
+ // Get diff against main
+ const diff = execSync(
+ `git -C "${workspacePath}" diff main..${branchName} 2>/dev/null || git -C "${workspacePath}" diff HEAD~1..HEAD`,
+ { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }
+ );
+
+ return diff;
+}
+
+export async function pushToGerrit(workspacePath: string): Promise<string> {
+ const output = execSync(
+ `git -C "${workspacePath}" push origin HEAD:refs/for/main 2>&1`,
+ { encoding: 'utf-8' }
+ );
+ return output;
+}
+
+export function getFileList(workspacePath: string): string {
+ if (!existsSync(workspacePath)) return '';
+ try {
+ return execSync(`find "${workspacePath}" -type f -not -path "*/.git/*" | sort`, {
+ encoding: 'utf-8',
+ maxBuffer: 1024 * 1024,
+ });
+ } catch {
+ return '';
+ }
+}
diff --git a/admin-panel/src/server/services/pipeline.service.ts b/admin-panel/src/server/services/pipeline.service.ts
new file mode 100644
index 0000000..afdf422
--- /dev/null
+++ b/admin-panel/src/server/services/pipeline.service.ts
@@ -0,0 +1,304 @@
+import { getProjectDb } from '../db.js';
+import { broadcast } from '../ws.js';
+import { BaseAgent, type AgentContext } from '../agents/base-agent.js';
+import { PlannerAgent } from '../agents/planner.js';
+import { CriticAgent } from '../agents/critic.js';
+import { BuilderAgent } from '../agents/builder.js';
+import { ShieldAgent } from '../agents/shield.js';
+import { InspectorAgent } from '../agents/inspector.js';
+import { RangerAgent } from '../agents/ranger.js';
+import { parseFileBlocks } from '../agents/builder.js';
+import { cloneRepo, createBranch, writeFiles, commitAndDiff } from './git.service.js';
+
+interface Task {
+ id: number;
+ title: string;
+ status: string;
+ priority: string;
+ level: number;
+ description: string | null;
+ plan: string | null;
+ decision_log: string | null;
+ done_when: string | null;
+ implementation_notes: string | null;
+ tags: string;
+ plan_review_comments: string;
+ review_comments: string;
+ test_results: string;
+ agent_log: string;
+ current_agent: string | null;
+ plan_review_count: number;
+ impl_review_count: number;
+ branch_name: string | null;
+ diff: string | null;
+ rank: number;
+}
+
+// Maps status → which agent runs
+const STATUS_AGENT_MAP: Record<string, () => BaseAgent> = {
+ todo: () => new PlannerAgent(),
+ plan: () => new PlannerAgent(),
+ plan_review: () => new CriticAgent(),
+ impl: () => new BuilderAgent(),
+ impl_review: () => new InspectorAgent(),
+ test: () => new RangerAgent(),
+};
+
+// Level determines which steps are skipped
+// L1 (quick): plan → impl → done (skip reviews and tests)
+// L2 (standard): plan → plan_review → impl → impl_review → done (skip tests)
+// L3 (full): all steps
+const LEVEL_FLOW: Record<number, string[]> = {
+ 1: ['todo', 'plan', 'impl', 'done'],
+ 2: ['todo', 'plan', 'plan_review', 'impl', 'impl_review', 'done'],
+ 3: ['todo', 'plan', 'plan_review', 'impl', 'impl_review', 'test', 'done'],
+};
+
+const MAX_RETRIES = 3;
+
+// Track running pipelines so we can stop them
+const runningPipelines = new Map<string, { stopped: boolean }>();
+
+function pipelineKey(project: string, taskId: number): string {
+ return `${project}:${taskId}`;
+}
+
+export function isPipelineRunning(project: string, taskId: number): boolean {
+ return runningPipelines.has(pipelineKey(project, taskId));
+}
+
+export function stopPipeline(project: string, taskId: number): void {
+ const key = pipelineKey(project, taskId);
+ const state = runningPipelines.get(key);
+ if (state) {
+ state.stopped = true;
+ }
+}
+
+export async function runStep(project: string, taskId: number): Promise<void> {
+ const db = getProjectDb(project);
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task | undefined;
+ if (!task) throw new Error('Task not found');
+
+ if (task.status === 'done') throw new Error('Task is already done');
+
+ const agentFactory = STATUS_AGENT_MAP[task.status];
+ if (!agentFactory) throw new Error(`No agent for status: ${task.status}`);
+
+ const agent = agentFactory();
+ await executeAgent(project, task, agent, db);
+}
+
+export async function runFullPipeline(project: string, taskId: number): Promise<void> {
+ const key = pipelineKey(project, taskId);
+ if (runningPipelines.has(key)) throw new Error('Pipeline already running');
+
+ const state = { stopped: false };
+ runningPipelines.set(key, state);
+
+ try {
+ const db = getProjectDb(project);
+ let retries = 0;
+
+ while (!state.stopped) {
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task | undefined;
+ if (!task) break;
+ if (task.status === 'done') break;
+
+ // Check if this status is in the level flow
+ const flow = LEVEL_FLOW[task.level] || LEVEL_FLOW[3];
+ if (!flow.includes(task.status)) {
+ // Skip to next valid status in the flow
+ const currentIdx = flow.indexOf(task.status);
+ if (currentIdx === -1) break;
+ }
+
+ const agentFactory = STATUS_AGENT_MAP[task.status];
+ if (!agentFactory) break;
+
+ const agent = agentFactory();
+
+ try {
+ const result = await executeAgent(project, task, agent, db);
+
+ // Check for review rejections (circuit breaker)
+ if (result.verdict === 'reject') {
+ retries++;
+ if (retries >= MAX_RETRIES) {
+ broadcast(project, {
+ type: 'pipeline:error',
+ project,
+ taskId,
+ error: `Circuit breaker: ${MAX_RETRIES} rejections reached. Pipeline stopped.`,
+ });
+ break;
+ }
+ } else {
+ retries = 0;
+ }
+ } catch (err) {
+ const errorMsg = err instanceof Error ? err.message : String(err);
+ broadcast(project, {
+ type: 'pipeline:error',
+ project,
+ taskId,
+ error: errorMsg,
+ });
+ break;
+ }
+ }
+
+ broadcast(project, { type: 'pipeline:done', project, taskId });
+ } finally {
+ runningPipelines.delete(key);
+ }
+}
+
+async function executeAgent(
+ project: string,
+ task: Task,
+ agent: BaseAgent,
+ db: ReturnType<typeof getProjectDb>
+): Promise<{ verdict?: 'approve' | 'reject' }> {
+ // Create pipeline job
+ const job = db.prepare(
+ `INSERT INTO pipeline_jobs (task_id, agent, status, model, started_at)
+ VALUES (?, ?, 'running', ?, datetime('now'))`
+ ).run(task.id, agent.name, agent.model);
+ const jobId = job.lastInsertRowid;
+
+ // Update current agent on task
+ db.prepare('UPDATE tasks SET current_agent = ? WHERE id = ?').run(agent.name, task.id);
+ broadcast(project, { type: 'pipeline:agent_start', project, taskId: task.id, agent: agent.name });
+
+ try {
+ // Build context
+ const ctx: AgentContext = {
+ taskId: task.id,
+ title: task.title,
+ description: task.description || '',
+ plan: task.plan || undefined,
+ decisionLog: task.decision_log || undefined,
+ doneWhen: task.done_when || undefined,
+ implementationNotes: task.implementation_notes || undefined,
+ diff: task.diff || undefined,
+ testResults: task.test_results || undefined,
+ reviewComments: task.review_comments || undefined,
+ branchName: task.branch_name || undefined,
+ };
+
+ // Execute agent
+ const result = await agent.execute(ctx);
+
+ // Handle Builder agent's file changes
+ if (agent.name === 'builder' && result.content) {
+ const files = parseFileBlocks(result.content);
+ if (files.length > 0) {
+ try {
+ const branchName = task.branch_name || `kanban/task-${task.id}`;
+ const adminDb = (await import('../db.js')).getAdminDb();
+ const projectRow = adminDb.prepare('SELECT * FROM projects WHERE name = ?').get(project) as { repo_path: string } | undefined;
+
+ if (projectRow) {
+ const workspacePath = `/app/workspaces/${project}-task-${task.id}`;
+ await cloneRepo(projectRow.repo_path, workspacePath);
+ await createBranch(workspacePath, branchName);
+ await writeFiles(workspacePath, files);
+ const diff = await commitAndDiff(workspacePath, `feat: ${task.title} [kanban #${task.id}]`, branchName);
+ result.updates.branch_name = branchName;
+ result.updates.diff = diff;
+ }
+ } catch (gitErr) {
+ console.error('Git operation failed:', gitErr);
+ // Don't fail the whole pipeline over git errors
+ result.updates.implementation_notes =
+ (result.updates.implementation_notes || '') +
+ `\n\nGit error: ${gitErr instanceof Error ? gitErr.message : String(gitErr)}`;
+ }
+ }
+ }
+
+ // Apply updates to task
+ const updates = result.updates;
+ const setClauses: string[] = [];
+ const params: unknown[] = [];
+
+ for (const [key, value] of Object.entries(updates)) {
+ setClauses.push(`${key} = ?`);
+ params.push(value);
+ }
+
+ // Clear current_agent
+ setClauses.push('current_agent = NULL');
+
+ if (setClauses.length > 0) {
+ params.push(task.id);
+ db.prepare(`UPDATE tasks SET ${setClauses.join(', ')} WHERE id = ?`).run(...params);
+ }
+
+ // Append to agent log
+ const logEntry = {
+ agent: agent.name,
+ message: result.message,
+ verdict: result.verdict,
+ tokensUsed: result.tokensUsed,
+ model: result.model,
+ timestamp: new Date().toISOString(),
+ };
+
+ let agentLog: unknown[];
+ try {
+ agentLog = JSON.parse(task.agent_log || '[]');
+ } catch {
+ agentLog = [];
+ }
+ agentLog.push(logEntry);
+ db.prepare('UPDATE tasks SET agent_log = ? WHERE id = ?').run(JSON.stringify(agentLog), task.id);
+
+ // Update pipeline job
+ db.prepare(
+ `UPDATE pipeline_jobs SET status = 'completed', completed_at = datetime('now'), tokens_used = ? WHERE id = ?`
+ ).run(result.tokensUsed, jobId);
+
+ // Broadcast completion
+ const updatedTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(task.id);
+ broadcast(project, {
+ type: 'pipeline:agent_complete',
+ project,
+ taskId: task.id,
+ agent: agent.name,
+ result: result.message,
+ });
+ broadcast(project, { type: 'task:updated', project, task: updatedTask });
+
+ return { verdict: result.verdict };
+
+ } catch (err) {
+ // Mark job as failed
+ const errorMsg = err instanceof Error ? err.message : String(err);
+ db.prepare(
+ `UPDATE pipeline_jobs SET status = 'failed', completed_at = datetime('now'), error = ? WHERE id = ?`
+ ).run(errorMsg, jobId);
+
+ // Clear current agent
+ db.prepare('UPDATE tasks SET current_agent = NULL WHERE id = ?').run(task.id);
+
+ throw err;
+ }
+}
+
+export function getPipelineStatus(project: string, taskId: number): {
+ running: boolean;
+ currentAgent: string | null;
+ jobs: unknown[];
+} {
+ const db = getProjectDb(project);
+ const task = db.prepare('SELECT current_agent FROM tasks WHERE id = ?').get(taskId) as { current_agent: string | null } | undefined;
+ const jobs = db.prepare('SELECT * FROM pipeline_jobs WHERE task_id = ? ORDER BY id DESC').all(taskId);
+
+ return {
+ running: isPipelineRunning(project, taskId),
+ currentAgent: task?.current_agent || null,
+ jobs,
+ };
+}
diff --git a/admin-panel/src/server/templates/builder.md b/admin-panel/src/server/templates/builder.md
new file mode 100644
index 0000000..eee2cf6
--- /dev/null
+++ b/admin-panel/src/server/templates/builder.md
@@ -0,0 +1,47 @@
+You are a senior software engineer implementing a feature based on an approved plan.
+
+## Task #{{taskId}}: {{title}}
+
+### Requirements
+{{description}}
+
+### Approved Plan
+{{plan}}
+
+### Done When
+{{done_when}}
+
+### Current Branch
+{{branch_name}}
+
+### Repository File Listing
+{{file_list}}
+
+### Relevant File Contents
+{{file_contents}}
+
+## Instructions
+
+Implement the changes described in the plan. For each file that needs to be created or modified, output the COMPLETE file contents.
+
+## Output Format
+
+For each file, use this exact format:
+
+```FILE: path/to/file.ts
+(complete file contents here)
+```
+
+After all files, provide a brief summary:
+
+### Summary
+(what you implemented and any notes)
+
+### Implementation Notes
+(any important details about the implementation)
+
+Important:
+- Output the COMPLETE contents of each file (not just the changes)
+- Include ALL imports, types, and code
+- Follow existing code style and conventions in the repo
+- Do not skip any file that needs changes
diff --git a/admin-panel/src/server/templates/critic.md b/admin-panel/src/server/templates/critic.md
new file mode 100644
index 0000000..dd68267
--- /dev/null
+++ b/admin-panel/src/server/templates/critic.md
@@ -0,0 +1,47 @@
+You are a critical plan reviewer. Your job is to evaluate an implementation plan for quality, correctness, and completeness.
+
+## Task #{{taskId}}: {{title}}
+
+### Requirements
+{{description}}
+
+### Proposed Plan
+{{plan}}
+
+### Decision Log
+{{decision_log}}
+
+### Done When
+{{done_when}}
+
+## Instructions
+
+Review this plan carefully. Consider:
+
+1. **Completeness**: Does the plan cover all requirements? Are any edge cases missed?
+2. **Correctness**: Is the technical approach sound? Any potential bugs or issues?
+3. **Simplicity**: Is the approach unnecessarily complex? Can it be simplified?
+4. **Security**: Any security concerns (injection, XSS, auth bypass)?
+5. **Testability**: Can the implementation be verified easily?
+
+## Output Format
+
+Start with your verdict on the FIRST line, exactly one of:
+```
+VERDICT: APPROVE
+```
+or
+```
+VERDICT: REJECT
+```
+
+Then provide your review:
+
+### Strengths
+- (what's good about this plan)
+
+### Issues
+- (any problems found — required for REJECT)
+
+### Suggestions
+- (improvements, even if approving)
diff --git a/admin-panel/src/server/templates/inspector.md b/admin-panel/src/server/templates/inspector.md
new file mode 100644
index 0000000..b392440
--- /dev/null
+++ b/admin-panel/src/server/templates/inspector.md
@@ -0,0 +1,61 @@
+You are a senior code reviewer. Your job is to review implemented code for quality, correctness, and adherence to the plan.
+
+## Task #{{taskId}}: {{title}}
+
+### Requirements
+{{description}}
+
+### Approved Plan
+{{plan}}
+
+### Done When
+{{done_when}}
+
+### Implementation Diff
+{{diff}}
+
+### Implementation Notes
+{{implementation_notes}}
+
+### Test Results
+{{test_results}}
+
+## Instructions
+
+Review the implementation diff carefully. Consider:
+
+1. **Correctness**: Does the code do what the plan describes? Any bugs?
+2. **Plan adherence**: Does the implementation match the plan?
+3. **Code quality**: Is the code clean, readable, and maintainable?
+4. **Security**: Any vulnerabilities (injection, XSS, auth issues)?
+5. **Performance**: Any obvious performance problems?
+6. **Error handling**: Are errors handled appropriately?
+7. **Test coverage**: Do the tests adequately verify the implementation?
+
+## Output Format
+
+Start with your verdict on the FIRST line, exactly one of:
+```
+VERDICT: APPROVE
+```
+or
+```
+VERDICT: REJECT
+```
+
+Then provide:
+
+### Score
+(1-10, where 10 is perfect)
+
+### Strengths
+- (what's done well)
+
+### Issues
+- (problems found — required for REJECT, include file:line references)
+
+### Suggestions
+- (improvements, even if approving)
+
+### Comments
+(line-specific comments for Gerrit, format: `file:line: comment`)
diff --git a/admin-panel/src/server/templates/planner.md b/admin-panel/src/server/templates/planner.md
new file mode 100644
index 0000000..5d1a2bb
--- /dev/null
+++ b/admin-panel/src/server/templates/planner.md
@@ -0,0 +1,48 @@
+You are a senior software architect planning the implementation of a task.
+
+## Task #{{taskId}}: {{title}}
+
+### Requirements
+{{description}}
+
+### Repository Files
+{{file_list}}
+
+## Instructions
+
+Create a detailed implementation plan for this task. Your plan should include:
+
+1. **Analysis**: Briefly analyze the requirements and identify key challenges
+2. **Approach**: Describe the technical approach you'll take
+3. **Steps**: Numbered list of concrete implementation steps
+4. **Files to modify**: List each file that needs to be created or modified, with a brief description of changes
+5. **Done When**: Clear acceptance criteria — when is this task complete?
+6. **Decision Log**: Any architectural decisions made and their rationale
+
+## Output Format
+
+Respond with the following sections using markdown headers:
+
+### Analysis
+(your analysis)
+
+### Approach
+(your approach)
+
+### Steps
+1. (step 1)
+2. (step 2)
+...
+
+### Files
+- `path/to/file.ts` — (what changes)
+...
+
+### Done When
+- (criterion 1)
+- (criterion 2)
+...
+
+### Decisions
+- (decision 1): (rationale)
+...
diff --git a/admin-panel/src/server/templates/ranger.md b/admin-panel/src/server/templates/ranger.md
new file mode 100644
index 0000000..74cc538
--- /dev/null
+++ b/admin-panel/src/server/templates/ranger.md
@@ -0,0 +1,50 @@
+You are a test runner and quality assurance agent. Your job is to evaluate test results and determine if the implementation passes.
+
+## Task #{{taskId}}: {{title}}
+
+### Done When
+{{done_when}}
+
+### Test Results
+{{test_results}}
+
+### Implementation Diff
+{{diff}}
+
+### Review Comments
+{{review_comments}}
+
+## Instructions
+
+Analyze the test results and review feedback. Determine if the task meets the "Done When" criteria.
+
+Consider:
+1. **Test pass/fail**: Did all tests pass?
+2. **Coverage**: Are the acceptance criteria covered by tests?
+3. **Review status**: Was the code review approved?
+4. **Quality**: Any remaining concerns?
+
+## Output Format
+
+Start with your verdict on the FIRST line, exactly one of:
+```
+VERDICT: PASS
+```
+or
+```
+VERDICT: FAIL
+```
+
+Then provide:
+
+### Summary
+(overall assessment)
+
+### Test Analysis
+- (breakdown of test results)
+
+### Remaining Issues
+- (any issues that prevent passing — required for FAIL)
+
+### Recommendation
+(what should happen next)
diff --git a/admin-panel/src/server/templates/shield.md b/admin-panel/src/server/templates/shield.md
new file mode 100644
index 0000000..27563ca
--- /dev/null
+++ b/admin-panel/src/server/templates/shield.md
@@ -0,0 +1,40 @@
+You are a TDD test engineer. Your job is to write comprehensive tests for an implementation.
+
+## Task #{{taskId}}: {{title}}
+
+### Requirements
+{{description}}
+
+### Done When
+{{done_when}}
+
+### Implementation Diff
+{{diff}}
+
+### Implementation Notes
+{{implementation_notes}}
+
+## Instructions
+
+Write tests that verify the implementation meets the requirements and "Done When" criteria. Consider:
+
+1. **Happy path**: Does the core functionality work?
+2. **Edge cases**: Boundary conditions, empty inputs, null values
+3. **Error cases**: Invalid inputs, missing data, failure modes
+4. **Integration**: Do components work together correctly?
+
+## Output Format
+
+For each test file, use this exact format:
+
+```FILE: path/to/test-file.test.ts
+(complete test file contents)
+```
+
+After the test files, provide:
+
+### Test Strategy
+- (what's being tested and why)
+
+### Coverage Notes
+- (what's covered, what's intentionally not covered)
diff --git a/admin-panel/src/server/ws.ts b/admin-panel/src/server/ws.ts
new file mode 100644
index 0000000..c0ffd8e
--- /dev/null
+++ b/admin-panel/src/server/ws.ts
@@ -0,0 +1,56 @@
+import { WebSocketServer, WebSocket } from 'ws';
+import type { Server } from 'http';
+
+let wss: WebSocketServer;
+
+interface Subscription {
+ ws: WebSocket;
+ projects: Set<string>;
+}
+
+const subscriptions: Subscription[] = [];
+
+export function setupWebSocket(server: Server): void {
+ wss = new WebSocketServer({ server, path: '/ws' });
+
+ wss.on('connection', (ws) => {
+ const sub: Subscription = { ws, projects: new Set() };
+ subscriptions.push(sub);
+
+ ws.on('message', (data) => {
+ try {
+ const msg = JSON.parse(data.toString());
+ if (msg.type === 'subscribe' && msg.project) {
+ sub.projects.add(msg.project);
+ } else if (msg.type === 'unsubscribe' && msg.project) {
+ sub.projects.delete(msg.project);
+ }
+ } catch {
+ // ignore malformed messages
+ }
+ });
+
+ ws.on('close', () => {
+ const idx = subscriptions.indexOf(sub);
+ if (idx !== -1) subscriptions.splice(idx, 1);
+ });
+ });
+}
+
+export function broadcast(project: string, event: Record<string, unknown>): void {
+ const payload = JSON.stringify(event);
+ for (const sub of subscriptions) {
+ if (sub.projects.has(project) && sub.ws.readyState === WebSocket.OPEN) {
+ sub.ws.send(payload);
+ }
+ }
+}
+
+export function broadcastAll(event: Record<string, unknown>): void {
+ const payload = JSON.stringify(event);
+ for (const sub of subscriptions) {
+ if (sub.ws.readyState === WebSocket.OPEN) {
+ sub.ws.send(payload);
+ }
+ }
+}