summaryrefslogtreecommitdiff
path: root/admin-panel/src/server
diff options
context:
space:
mode:
authorArseney300 <Arseney300@gmail.com>2026-03-05 09:47:09 +0700
committerArseney300 <Arseney300@gmail.com>2026-03-05 09:47:09 +0700
commit0885d20ac18c252f6735cd288448d5d93e95b80f (patch)
treeedfb6814e97d4000605d9f6a918c30c6aca9e10c /admin-panel/src/server
parent22749b796d7a5e4efa9494156a3fdcfe39028da7 (diff)
WIP: admin_panel: add featurefeature/admin_panel
Diffstat (limited to 'admin-panel/src/server')
-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
27 files changed, 2250 insertions, 0 deletions
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);
+ }
+ }
+}