From e498f17dd23cd2a194d3defa4ae0be7536177a08 Mon Sep 17 00:00:00 2001 From: Voyvodka Date: Sat, 4 Apr 2026 16:19:43 +0300 Subject: [PATCH] feat: add GSD workflow tracking dashboard Scan .planning/ directories for GSD project data (phases, plans, research, verification artifacts) and display progress in a dedicated dashboard with KPI cards, charts, and per-phase token/cost correlation. --- cache.js | 195 +++++++++++- editors/gsd.js | 366 ++++++++++++++++++++++ server.js | 102 ++++++ ui/src/App.jsx | 5 +- ui/src/lib/api.js | 43 +++ ui/src/pages/GSD.jsx | 726 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1435 insertions(+), 2 deletions(-) create mode 100644 editors/gsd.js create mode 100644 ui/src/pages/GSD.jsx diff --git a/cache.js b/cache.js index 7926bb6..8d3ecae 100644 --- a/cache.js +++ b/cache.js @@ -7,7 +7,7 @@ const { calculateCost, getModelPricing, normalizeModelName } = require('./pricin const CACHE_DIR = path.join(os.homedir(), '.agentlytics'); const CACHE_DB = path.join(CACHE_DIR, 'cache.db'); -const SCHEMA_VERSION = 6; // bump this when schema changes to auto-revalidate +const SCHEMA_VERSION = 7; // bump this when schema changes to auto-revalidate /** * Normalize a folder path for consistent storage/lookup. @@ -154,6 +154,38 @@ function initDb() { CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages(chat_id); CREATE INDEX IF NOT EXISTS idx_tool_calls_name ON tool_calls(tool_name); CREATE INDEX IF NOT EXISTS idx_tool_calls_chat ON tool_calls(chat_id); + + CREATE TABLE IF NOT EXISTS gsd_projects ( + folder TEXT PRIMARY KEY, + name TEXT, + description TEXT, + milestone TEXT, + total_phases INTEGER DEFAULT 0, + completed_phases INTEGER DEFAULT 0, + active_phase TEXT, + todos INTEGER DEFAULT 0, + backlog INTEGER DEFAULT 0, + notes INTEGER DEFAULT 0, + last_modified INTEGER, + scanned_at INTEGER + ); + + CREATE TABLE IF NOT EXISTS gsd_phases ( + id TEXT PRIMARY KEY, + folder TEXT NOT NULL, + phase_number INTEGER, + phase_name TEXT, + status TEXT, + total_tasks INTEGER DEFAULT 0, + completed_tasks INTEGER DEFAULT 0, + has_plan INTEGER DEFAULT 0, + has_research INTEGER DEFAULT 0, + has_verification INTEGER DEFAULT 0, + last_modified INTEGER, + FOREIGN KEY (folder) REFERENCES gsd_projects(folder) + ); + + CREATE INDEX IF NOT EXISTS idx_gsd_phases_folder ON gsd_phases(folder); `); // Store schema version so future runs can detect mismatches @@ -358,6 +390,9 @@ function scanAll(onProgress, opts = {}) { db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('last_scan', Date.now().toString()); db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('total_chats', total.toString()); + // GSD scan + cacheGSDProjects(); + return { total, analyzed, skipped }; } @@ -839,6 +874,9 @@ async function scanAllAsync(onProgress, opts = {}) { db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('last_scan', Date.now().toString()); db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('total_chats', total.toString()); + // GSD scan + cacheGSDProjects(); + return { total, analyzed, skipped }; } @@ -1300,6 +1338,156 @@ function getCostAnalytics(opts = {}) { function getDb() { return db; } +// ============================================================ +// GSD cache functions +// ============================================================ + +const gsd = require('./editors/gsd'); + +function cacheGSDProjects() { + // Get all unique known folders from chats table + const rows = db.prepare('SELECT DISTINCT folder FROM chats WHERE folder IS NOT NULL').all(); + const knownFolders = rows.map(r => r.folder); + + const projects = gsd.getGSDProjects(knownFolders); + + const insProject = db.prepare(` + INSERT OR REPLACE INTO gsd_projects + (folder, name, description, milestone, total_phases, completed_phases, active_phase, todos, backlog, notes, last_modified, scanned_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const delPhases = db.prepare('DELETE FROM gsd_phases WHERE folder = ?'); + const insPhase = db.prepare(` + INSERT OR REPLACE INTO gsd_phases + (id, folder, phase_number, phase_name, status, total_tasks, completed_tasks, has_plan, has_research, has_verification, last_modified) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const tx = db.transaction(() => { + for (const p of projects) { + insProject.run( + p.folder, p.name, p.description, p.milestone, + p.totalPhases, p.completedPhases, p.activePhase, + p.todos, p.backlog, p.notes, + p.lastModified, Date.now() + ); + delPhases.run(p.folder); + const phases = gsd.getGSDPhases(p.folder); + for (const ph of phases) { + const id = `${p.folder}::${ph.phaseDir}`; + insPhase.run( + id, p.folder, ph.number, ph.name, ph.status, + ph.tasks.total, ph.tasks.completed, + ph.hasPlan ? 1 : 0, + ph.hasResearch ? 1 : 0, + ph.hasVerification ? 1 : 0, + ph.lastModified + ); + } + } + }); + tx(); +} + +function getCachedGSDProjects() { + const projects = db.prepare('SELECT * FROM gsd_projects ORDER BY last_modified DESC').all(); + for (const p of projects) { + try { + const phases = getGSDPhaseTokens(p.folder); + p.total_cost = phases.reduce((s, r) => s + (r.cost || 0), 0); + } catch { + p.total_cost = 0; + } + } + return projects; +} + +function getCachedGSDPhases(folder) { + return db.prepare('SELECT * FROM gsd_phases WHERE folder = ? ORDER BY phase_number ASC').all(folder); +} + +function getGSDPhaseTokens(folder) { + const phases = db.prepare( + 'SELECT id, phase_number, phase_name, status, last_modified FROM gsd_phases WHERE folder = ? ORDER BY phase_number ASC' + ).all(folder); + + if (phases.length === 0) return []; + + // Sort by last_modified to build sequential non-overlapping time windows. + // Phases with no last_modified are placed at the end. + const byTime = [...phases] + .filter(p => p.last_modified) + .sort((a, b) => a.last_modified - b.last_modified); + + const windowMap = new Map(); + for (let i = 0; i < byTime.length; i++) { + const start = i === 0 ? 0 : byTime[i - 1].last_modified; + const end = i === byTime.length - 1 ? Date.now() : byTime[i].last_modified; + windowMap.set(byTime[i].id, { start, end }); + } + + const stmt = db.prepare(` + SELECT cs.total_input_tokens, cs.total_output_tokens, + cs.total_cache_read, cs.total_cache_write, cs.models + FROM chats c JOIN chat_stats cs ON cs.chat_id = c.id + WHERE c.folder = ? AND COALESCE(c.last_updated_at, c.created_at) BETWEEN ? AND ? + `); + + return phases.map(ph => { + const win = windowMap.get(ph.id); + let totalInput = 0, totalOutput = 0, totalCacheRead = 0, totalCacheWrite = 0; + let sessionCount = 0; + const modelFreq = {}; + + if (win) { + const rows = stmt.all(folder, win.start, win.end); + for (const row of rows) { + totalInput += row.total_input_tokens || 0; + totalOutput += row.total_output_tokens || 0; + totalCacheRead += row.total_cache_read || 0; + totalCacheWrite += row.total_cache_write || 0; + sessionCount++; + try { + const models = JSON.parse(row.models || '[]'); + for (const m of models) { + const key = typeof m === 'string' ? m : (m && m.model); + if (key) modelFreq[key] = (modelFreq[key] || 0) + 1; + } + } catch { /* skip */ } + } + } + + const dominantModel = Object.entries(modelFreq).sort((a, b) => b[1] - a[1])[0]?.[0] || null; + const cost = dominantModel + ? (calculateCost(dominantModel, totalInput, totalOutput, totalCacheRead, totalCacheWrite) || 0) + : 0; + const totalTokens = totalInput + totalOutput; + + return { + id: ph.id, + phase_number: ph.phase_number, + phase_name: ph.phase_name, + status: ph.status, + total_tokens: totalTokens, + cost, + session_count: sessionCount, + }; + }); +} + +function getCachedGSDOverview() { + const projects = getCachedGSDProjects(); + const totalProjects = projects.length; + const totalPhases = projects.reduce((s, p) => s + p.total_phases, 0); + const completedPhases = projects.reduce((s, p) => s + p.completed_phases, 0); + const activePhases = projects + .filter(p => p.active_phase) + .map(p => ({ folder: p.folder, name: p.name, activePhase: p.active_phase })); + const executingPhases = db.prepare("SELECT COUNT(*) as c FROM gsd_phases WHERE status = 'executing'").get().c; + const plannedPhases = db.prepare("SELECT COUNT(*) as c FROM gsd_phases WHERE status = 'planned'").get().c; + return { totalProjects, totalPhases, completedPhases, activePhases, executingPhases, plannedPhases }; +} + module.exports = { initDb, scanAll, @@ -1317,4 +1505,9 @@ module.exports = { getCostBreakdown, getCostAnalytics, getDb, + cacheGSDProjects, + getCachedGSDProjects, + getCachedGSDPhases, + getCachedGSDOverview, + getGSDPhaseTokens, }; diff --git a/editors/gsd.js b/editors/gsd.js new file mode 100644 index 0000000..b9f5673 --- /dev/null +++ b/editors/gsd.js @@ -0,0 +1,366 @@ +const path = require('path'); +const fs = require('fs'); + +const name = 'gsd'; +const labels = { gsd: 'GSD Workflow' }; + +// ============================================================ +// Helpers +// ============================================================ + +function readFileSafe(filePath) { + try { return fs.readFileSync(filePath, 'utf-8'); } catch { return null; } +} + +function statSafe(filePath) { + try { return fs.statSync(filePath); } catch { return null; } +} + +function countFiles(dir) { + try { return fs.readdirSync(dir).filter(f => !f.startsWith('.')).length; } catch { return 0; } +} + +/** + * Parse YAML frontmatter from STATE.md. + * Returns { status, milestone, stoppedAt, progress } or null. + */ +function parseStateMd(content) { + if (!content) return null; + const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!m) return null; + const yaml = m[1]; + function get(key) { + const r = yaml.match(new RegExp(`^${key}:\\s*(.+)`, 'm')); + return r ? r[1].trim().replace(/^["']|["']$/g, '') : null; + } + function getInt(key) { const v = get(key); return v ? parseInt(v) : null; } + const progressBlock = yaml.match(/^progress:\s*\n((?:[ \t]+.+\n?)*)/m); + let progress = null; + if (progressBlock) { + const pb = progressBlock[1]; + function pgGet(key) { + const r = pb.match(new RegExp(`${key}:\\s*(\\d+)`)); + return r ? parseInt(r[1]) : null; + } + progress = { + total_phases: pgGet('total_phases'), + completed_phases: pgGet('completed_phases'), + total_plans: pgGet('total_plans'), + completed_plans: pgGet('completed_plans'), + }; + } + return { + status: get('status'), + milestone: get('milestone'), + stoppedAt: get('stopped_at'), + lastUpdated: get('last_updated'), + progress, + }; +} + +/** + * Parse ROADMAP.md phase checkboxes into a map of phase_number → completed. + * Phase lines look like: - [x] **Phase 1: Name** or - [ ] Phase 2: Name + */ +function parseRoadmapPhaseStatus(content) { + if (!content) return new Map(); + const statusMap = new Map(); // phase_number (int) → 'completed' | 'planned' + for (const line of content.split('\n')) { + const cbMatch = line.match(/^[-*]\s+\[([x ])\]\s+(.+)/i); + if (!cbMatch) continue; + const text = cbMatch[2]; + if (/\d+-\d+-PLAN\.md/i.test(text) || /PLAN\.md\s*[—–-]/i.test(text)) continue; + // Extract phase number from patterns: "Phase 1:", "Phase 01:", "**Phase 2:**" + const numMatch = text.match(/phase\s+(\d+)/i); + if (!numMatch) continue; + const num = parseInt(numMatch[1]); + if (!isNaN(num)) { + statusMap.set(num, cbMatch[1].toLowerCase() === 'x' ? 'completed' : 'planned'); + } + } + return statusMap; +} + +/** + * Parse PROJECT.md — first # heading = project name, rest = description. + */ +function parseProjectMd(content) { + if (!content) return { name: null, description: null }; + const lines = content.split('\n'); + let projectName = null; + const descLines = []; + for (const line of lines) { + const h1 = line.match(/^#\s+(.+)/); + if (h1 && !projectName) { + projectName = h1[1].trim(); + continue; + } + if (projectName && line.trim()) descLines.push(line.trim()); + } + return { + name: projectName, + description: descLines.slice(0, 3).join(' ').substring(0, 300) || null, + }; +} + +/** + * Parse ROADMAP.md for phase completion. + * Supports: - [ ] **Phase N: Name** ... and - [x] **Phase N: Name** ... + * Also supports emoji: ✅ completed, 🚧 in-progress, □ planned + */ +function parseRoadmapMd(content) { + if (!content) return []; + const phases = []; + for (const line of content.split('\n')) { + // Checkbox style: - [ ] or - [x] + // Only count lines that look like phase entries, NOT plan file listings (e.g. "01-01-PLAN.md") + const cbMatch = line.match(/^[-*]\s+\[([x ])\]\s+(.+)/i); + if (cbMatch) { + const text = cbMatch[2]; + // Skip plan file entries like "01-01-PLAN.md — description" + if (/\d+-\d+-PLAN\.md/i.test(text) || /PLAN\.md\s*[—–-]/i.test(text)) continue; + phases.push({ text: text.replace(/\*\*/g, '').trim(), completed: cbMatch[1].toLowerCase() === 'x' }); + continue; + } + // Emoji style: ✅ completed, 🚧 in-progress (milestone-level) + const emojiMatch = line.match(/^[-*]\s+(✅|🚧|⬜|□)\s+(.+)/); + if (emojiMatch) { + phases.push({ text: emojiMatch[2].replace(/\*\*/g, '').trim(), completed: emojiMatch[1] === '✅' }); + } + } + return phases; +} + +/** + * Detect the currently active milestone name from ROADMAP.md. + * Looks for 🚧 milestone entries or the first uncompleted milestone block. + */ +function detectActiveMilestone(content) { + if (!content) return null; + for (const line of content.split('\n')) { + const m = line.match(/🚧\s+\*?\*?([^*\n-]+)/); + if (m) return m[1].trim().split(' - ')[0].trim(); + // Also handle "in progress" text + if (/in.progress/i.test(line)) { + const nm = line.match(/\*?\*?([vV][\d.]+[^*]*)\*?\*?/); + if (nm) return nm[1].trim(); + } + } + return null; +} + +/** + * Extract phase number prefix from a phase directory name. + * e.g. "01-auth-ve-giris" → "01" + * e.g. "999.1-backlog-item" → "999.1" + */ +function extractPhasePrefix(dirName) { + const m = dirName.match(/^(\d+(?:\.\d+)?)-/); + return m ? m[1] : null; +} + +/** + * Parse checkbox tasks from a PLAN.md file. + */ +function parseCheckboxes(content) { + if (!content) return { total: 0, completed: 0, tasks: [] }; + const tasks = []; + for (const line of content.split('\n')) { + const m = line.match(/^[-*]\s+\[([x ])\]\s+(.+)/i); + if (!m) continue; + tasks.push({ name: m[2].trim(), completed: m[1].toLowerCase() === 'x' }); + } + return { total: tasks.length, completed: tasks.filter(t => t.completed).length, tasks }; +} + +// ============================================================ +// Public API +// ============================================================ + +/** + * Scan known project folders for GSD .planning/ directories. + * Returns project-level summary for each GSD project found. + */ +function getGSDProjects(knownFolders) { + const results = []; + + for (const folder of knownFolders) { + if (!folder) continue; + const planningDir = path.join(folder, '.planning'); + if (!fs.existsSync(planningDir)) continue; + + // Must have at least PROJECT.md or ROADMAP.md to be a GSD project + const hasProject = fs.existsSync(path.join(planningDir, 'PROJECT.md')); + const hasRoadmap = fs.existsSync(path.join(planningDir, 'ROADMAP.md')); + if (!hasProject && !hasRoadmap) continue; + + const projectContent = readFileSafe(path.join(planningDir, 'PROJECT.md')); + const roadmapContent = readFileSafe(path.join(planningDir, 'ROADMAP.md')); + const stateContent = readFileSafe(path.join(planningDir, 'STATE.md')); + const stateData = parseStateMd(stateContent); + + const { name: projectName, description } = parseProjectMd(projectContent); + + // Use filesystem as source of truth for phase counts + // Filter out malformed directory names (e.g. dirs with JSON content in name) + const phases = getGSDPhases(folder, roadmapContent); + const validPhases = phases.filter(ph => ph.number !== null); + + const totalPhases = validPhases.length; + const completedPhases = validPhases.filter(p => p.status === 'completed').length; + const firstIncomplete = validPhases.find(p => p.status !== 'completed'); + const activePhase = stateData?.stoppedAt || (firstIncomplete ? firstIncomplete.name : null); + + // Prefer STATE.md milestone, fallback to ROADMAP detection + const activeMilestone = stateData?.milestone || detectActiveMilestone(roadmapContent); + + // Count todos/seeds/quick (common GSD directories) + const todos = countFiles(path.join(planningDir, 'todos')) + + countFiles(path.join(planningDir, 'seeds')); + const notes = countFiles(path.join(planningDir, 'quick')); + const backlog = countFiles(path.join(planningDir, 'backlog')); + + const planStat = statSafe(planningDir); + const lastModified = planStat ? Math.round(planStat.mtimeMs) : null; + + results.push({ + folder, + name: projectName || path.basename(folder), + description, + milestone: activeMilestone, + totalPhases, + completedPhases, + activePhase, + todos, + backlog, + notes, + lastModified, + }); + } + + return results; +} + +/** + * Return phase details for a single GSD project. + * Phases live in .planning/phases// + * roadmapContent is optionally passed to cross-reference checkbox status. + */ +function getGSDPhases(folder, roadmapContent) { + if (roadmapContent === undefined) { + roadmapContent = readFileSafe(path.join(folder, '.planning', 'ROADMAP.md')); + } + const roadmapStatus = parseRoadmapPhaseStatus(roadmapContent); + const phasesDir = path.join(folder, '.planning', 'phases'); + let phaseDirs; + try { + phaseDirs = fs.readdirSync(phasesDir) + .filter(f => { + try { return fs.statSync(path.join(phasesDir, f)).isDirectory(); } catch { return false; } + }) + .sort((a, b) => { + // Sort by numeric prefix (supports decimals like 999.1) + const na = parseFloat(extractPhasePrefix(a) || '9999'); + const nb = parseFloat(extractPhasePrefix(b) || '9999'); + return na - nb; + }); + } catch { return []; } + + const phases = []; + for (const phaseDir of phaseDirs) { + const phaseFullDir = path.join(phasesDir, phaseDir); + const prefix = extractPhasePrefix(phaseDir); + + // Phase name: everything after the leading number prefix + const nameRaw = phaseDir.replace(/^\d+(?:\.\d+)?-/, '').replace(/-/g, ' '); + const phaseName = nameRaw.length > 0 ? nameRaw : phaseDir; + + // Phase number (numeric value for display) + const phaseNumber = prefix ? parseFloat(prefix) : null; + + // Detect artifact files using the phase prefix pattern + let hasPlan = false, hasResearch = false, hasVerification = false; + let planCount = 0, summaryCount = 0; + let latestMtime = 0; + + try { + const files = fs.readdirSync(phaseFullDir); + for (const f of files) { + const fPath = path.join(phaseFullDir, f); + const st = statSafe(fPath); + if (st && st.mtimeMs > latestMtime) latestMtime = st.mtimeMs; + + // PLAN files: {prefix}-{n}-PLAN.md + if (f.match(/PLAN\.md$/i)) { hasPlan = true; planCount++; } + // SUMMARY files: indicates a plan was executed + if (f.match(/SUMMARY\.md$/i)) summaryCount++; + // VERIFICATION + if (f.match(/VERIFICATION\.md$/i)) hasVerification = true; + // RESEARCH + if (f.match(/RESEARCH\.md$/i)) hasResearch = true; + } + } catch { /* skip */ } + + // Determine status: file-based first, then cross-reference ROADMAP.md checkboxes + let status = 'planned'; + if (hasVerification) { + status = 'completed'; + } else if (roadmapStatus.get(phaseNumber) === 'completed') { + // ROADMAP.md marks this phase [x] even without VERIFICATION.md + status = 'completed'; + } else if (summaryCount > 0) { + status = 'executing'; + } else if (hasPlan) { + status = 'planned'; + } + + phases.push({ + phaseDir, + number: phaseNumber, + name: phaseName, + status, + tasks: { total: planCount, completed: summaryCount }, + hasVerification, + hasPlan, + hasResearch, + lastModified: latestMtime || null, + }); + } + + return phases; +} + +/** + * Return combined PLAN.md content for a phase. + * Aggregates all *-PLAN.md files in order. + */ +function getGSDPlanDetail(folder, phaseDir) { + const phaseFullDir = path.join(folder, '.planning', 'phases', phaseDir); + if (!fs.existsSync(phaseFullDir)) return null; + + let files; + try { files = fs.readdirSync(phaseFullDir).filter(f => f.match(/PLAN\.md$/i)).sort(); } + catch { return null; } + if (files.length === 0) return null; + + const sections = []; + const allTasks = []; + + for (const f of files) { + const content = readFileSafe(path.join(phaseFullDir, f)); + if (!content) continue; + sections.push(`## ${f}\n\n${content}`); + const { tasks } = parseCheckboxes(content); + allTasks.push(...tasks); + } + + return { content: sections.join('\n\n---\n\n'), tasks: allTasks }; +} + +module.exports = { + name, + labels, + getGSDProjects, + getGSDPhases, + getGSDPlanDetail, +}; diff --git a/server.js b/server.js index 103dc73..af21037 100644 --- a/server.js +++ b/server.js @@ -727,6 +727,108 @@ app.get('/api/all-projects', (req, res) => { } }); +// ============================================================ +// GSD endpoints +// ============================================================ + +app.get('/api/gsd/projects', (req, res) => { + try { + res.json(cache.getCachedGSDProjects()); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.get('/api/gsd/phases', (req, res) => { + try { + const { folder } = req.query; + if (!folder) return res.status(400).json({ error: 'folder query param required' }); + res.json(cache.getCachedGSDPhases(folder)); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.get('/api/gsd/plan', (req, res) => { + try { + const { folder, phase } = req.query; + if (!folder || !phase) return res.status(400).json({ error: 'folder and phase query params required' }); + const gsd = require('./editors/gsd'); + const detail = gsd.getGSDPlanDetail(folder, phase); + if (!detail) return res.status(404).json({ error: 'Plan not found' }); + res.json(detail); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.get('/api/gsd/overview', (req, res) => { + try { + res.json(cache.getCachedGSDOverview()); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.get('/api/gsd/config', (req, res) => { + try { + const { folder } = req.query; + if (!folder) return res.status(400).json({ error: 'folder query param required' }); + const configPath = require('path').join(folder, '.planning', 'config.json'); + if (!fs.existsSync(configPath)) return res.json(null); + res.json(JSON.parse(fs.readFileSync(configPath, 'utf-8'))); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.get('/api/gsd/phase-tokens', (req, res) => { + try { + const { folder } = req.query; + if (!folder) return res.status(400).json({ error: 'folder query param required' }); + res.json(cache.getGSDPhaseTokens(folder)); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Generic .planning file reader +// type: 'state' (project-level STATE.md) | 'research' | 'verification' | 'summary' (phase-level, requires phase param) +app.get('/api/gsd/file', (req, res) => { + try { + const { folder, phase: phaseDir, type } = req.query; + if (!folder || !type) return res.status(400).json({ error: 'folder and type required' }); + + const planningDir = path.join(folder, '.planning'); + let content = null; + + if (type === 'state') { + const filePath = path.join(planningDir, 'STATE.md'); + if (fs.existsSync(filePath)) content = fs.readFileSync(filePath, 'utf-8'); + } else if (phaseDir) { + const phaseFullDir = path.join(planningDir, 'phases', phaseDir); + const pattern = type === 'research' ? /RESEARCH\.md$/i + : type === 'verification' ? /VERIFICATION\.md$/i + : type === 'summary' ? /SUMMARY\.md$/i + : null; + if (pattern && fs.existsSync(phaseFullDir)) { + const files = fs.readdirSync(phaseFullDir).filter(f => pattern.test(f)).sort(); + if (files.length > 0) { + const sections = files.map(f => { + const c = fs.readFileSync(path.join(phaseFullDir, f), 'utf-8'); + return files.length > 1 ? `## ${f}\n\n${c}` : c; + }); + content = sections.join('\n\n---\n\n'); + } + } + } + + res.json({ content }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + // SPA fallback app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 2400d39..6563a8d 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { Routes, Route, NavLink, useLocation } from 'react-router-dom' -import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, CreditCard, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Plug, Copy, Check, Settings as SettingsIcon, Package, ChevronDown } from 'lucide-react' +import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, CreditCard, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Plug, Copy, Check, Settings as SettingsIcon, Package, ChevronDown, Target } from 'lucide-react' import { fetchOverview, refetchAgents, fetchMode, fetchRelayConfig, getAuthToken, setOnAuthFailure } from './lib/api' import { useTheme } from './lib/theme' import { useLive } from './hooks/useLive' @@ -21,6 +21,7 @@ import Subscriptions from './pages/Subscriptions' import MCPs from './pages/MCPs' import RelayDashboard from './pages/RelayDashboard' import RelayUserDetail from './pages/RelayUserDetail' +import GSD from './pages/GSD' function NavDropdown({ icon: Icon, label, items }) { const [open, setOpen] = useState(false) @@ -154,6 +155,7 @@ export default function App() { { to: '/compare', icon: GitCompare, label: 'Compare' }, ]}, { to: '/artifacts', icon: Package, label: 'Artifacts' }, + { to: '/gsd', icon: Target, label: 'GSD' }, { to: '/mcps', icon: Plug, label: 'MCPs' }, { to: '/sql', icon: Database, label: 'SQL' }, ] @@ -284,6 +286,7 @@ export default function App() { } /> } /> } /> + } /> } /> )} diff --git a/ui/src/lib/api.js b/ui/src/lib/api.js index 907b675..995c634 100644 --- a/ui/src/lib/api.js +++ b/ui/src/lib/api.js @@ -234,6 +234,49 @@ export async function fetchMCPs() { return res.json(); } +// ── GSD API ── + +export async function fetchGSDProjects() { + const res = await fetch(`${BASE}/api/gsd/projects`); + return res.json(); +} + +export async function fetchGSDPhases(folder) { + const q = new URLSearchParams({ folder }); + const res = await fetch(`${BASE}/api/gsd/phases?${q}`); + return res.json(); +} + +export async function fetchGSDPlan(folder, phase) { + const q = new URLSearchParams({ folder, phase }); + const res = await fetch(`${BASE}/api/gsd/plan?${q}`); + return res.json(); +} + +export async function fetchGSDOverview() { + const res = await fetch(`${BASE}/api/gsd/overview`); + return res.json(); +} + +export async function fetchGSDConfig(folder) { + const q = new URLSearchParams({ folder }); + const res = await fetch(`${BASE}/api/gsd/config?${q}`); + return res.json(); +} + +export async function fetchGSDPhaseTokens(folder) { + const q = new URLSearchParams({ folder }); + const res = await fetch(`${BASE}/api/gsd/phase-tokens?${q}`); + return res.json(); +} + +export async function fetchGSDFile(folder, type, phaseDir) { + const q = new URLSearchParams({ folder, type }); + if (phaseDir) q.set('phase', phaseDir); + const res = await fetch(`${BASE}/api/gsd/file?${q}`); + return res.json(); +} + // ── Relay API ── export async function fetchMode() { diff --git a/ui/src/pages/GSD.jsx b/ui/src/pages/GSD.jsx new file mode 100644 index 0000000..5156470 --- /dev/null +++ b/ui/src/pages/GSD.jsx @@ -0,0 +1,726 @@ +import { useState, useEffect, useRef } from 'react' +import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js' +import { Doughnut, Bar } from 'react-chartjs-2' +import { Target, FileText, BookOpen, ShieldCheck, ChevronDown, ChevronRight, ListTodo, StickyNote, X, CheckCircle2, Circle, Loader2, HelpCircle, Layers } from 'lucide-react' +import { fetchGSDProjects, fetchGSDPhases, fetchGSDPlan, fetchGSDOverview, fetchGSDConfig, fetchGSDFile, fetchGSDPhaseTokens } from '../lib/api' +import { formatCost } from '../lib/constants' +import AnimatedLoader from '../components/AnimatedLoader' +import KpiCard from '../components/KpiCard' +import SectionTitle from '../components/SectionTitle' +import PageHeader from '../components/PageHeader' +import { useTheme } from '../lib/theme' + +ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement) + +const MONO = 'JetBrains Mono, monospace' + +function formatRelativeTime(ms) { + if (!ms) return '—' + const diff = Date.now() - ms + const m = Math.floor(diff / 60000) + if (m < 1) return 'just now' + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + const d = Math.floor(h / 24) + return `${d}d ago` +} + +function statusColor(status) { + if (status === 'completed') return '#22c55e' + if (status === 'executing') return '#f59e0b' + return 'var(--c-text3)' +} + +function ProgressBar({ value, total, color = '#6366f1' }) { + const pct = total > 0 ? Math.round((value / total) * 100) : 0 + return ( +
+
+
+
+ + {value}/{total} + +
+ ) +} + +// ============================================================ +// File sidebar (slide from right — generic markdown viewer) +// ============================================================ + +function FileSidebar({ title, subtitle, content, loading, onClose }) { + const scrollRef = useRef(null) + + return ( + <> +
+
+ {/* Header */} +
+ +
+
{title}
+ {subtitle && ( +
{subtitle}
+ )} +
+
+ + {/* Content */} +
+ {loading && ( +
Loading…
+ )} + {!loading && content === null && ( +
File not found.
+ )} + {!loading && content !== null && ( +
+              {content}
+            
+ )} +
+
+ + ) +} + +// ============================================================ +// Config popover +// ============================================================ + +const CONFIG_LABELS = { + mode: 'Mode', + model_profile: 'Model profile', + granularity: 'Granularity', + parallelization: 'Parallelization', + commit_docs: 'Commit docs', +} + +const WORKFLOW_LABELS = { + research: 'Research', + plan_check: 'Plan check', + verifier: 'Verifier', + nyquist_validation: 'Nyquist', + auto_advance: 'Auto advance', + ui_phase: 'UI phase', + skip_discuss: 'Skip discuss', +} + +function ConfigPopover({ folder, anchor, onClose }) { + const [config, setConfig] = useState(undefined) + const ref = useRef(null) + + useEffect(() => { + fetchGSDConfig(folder).then(setConfig).catch(() => setConfig(null)) + }, [folder]) + + useEffect(() => { + function handleClick(e) { + if (ref.current && !ref.current.contains(e.target)) onClose() + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [onClose]) + + // Position fixed relative to the anchor button rect + const top = anchor ? anchor.bottom + 6 : 0 + const right = anchor ? window.innerWidth - anchor.right : 0 + + function val(v) { + if (v === true) return on + if (v === false) return off + return {String(v)} + } + + return ( +
e.stopPropagation()} + > + {config === undefined && ( +
Loading…
+ )} + {config === null && ( +
No config.json found.
+ )} + {config && ( +
+ {/* Top-level settings */} +
+ {Object.entries(CONFIG_LABELS).map(([k, label]) => { + if (!(k in config)) return null + return ( +
+ {label} + {val(config[k])} +
+ ) + })} +
+ {/* Workflow toggles */} + {config.workflow && ( + <> +
workflow
+
+ {Object.entries(WORKFLOW_LABELS).map(([k, label]) => { + if (!(k in config.workflow)) return null + return ( +
+ {label} + {val(config.workflow[k])} +
+ ) + })} +
+ + )} +
+ )} +
+ ) +} + +// ============================================================ +// Phase row +// ============================================================ + +function PhaseRow({ phase, tokenData, onOpenFile }) { + // Phase comes from SQLite cache — flat fields (total_tasks, completed_tasks, has_plan, etc.) + const totalTasks = phase.total_tasks ?? 0 + const completedTasks = phase.completed_tasks ?? 0 + const hasPlan = !!phase.has_plan + const hasResearch = !!phase.has_research + const hasVerification = !!phase.has_verification + const status = phase.status ?? 'planned' + + const StatusIcon = status === 'completed' + ? CheckCircle2 + : status === 'executing' + ? Loader2 + : Circle + + return ( +
+ {/* Status icon — 28px */} +
+ +
+ + {/* Phase number — 32px */} +
+ + {phase.phase_number ?? '?'} + +
+ + {/* Phase name — flex-1 */} +
+ + {phase.phase_name} + +
+ + {/* Progress bar — always 100px */} +
+ {totalTasks > 0 + ? + : null} +
+ + {/* Artifacts — always 88px */} +
+ {hasPlan && ( + + )} + {hasResearch && ( + + )} + {hasVerification && ( + + )} +
+ + {/* Est. cost — always 64px */} +
+ {tokenData && tokenData.cost > 0 ? ( + + {formatCost(tokenData.cost)} + + ) : null} +
+ + {/* Time — always 56px */} +
+ + {formatRelativeTime(phase.last_modified)} + +
+
+ ) +} + +// ============================================================ +// Project card +// ============================================================ + +function projectBorderColor(project) { + if (project.total_phases === 0) return 'var(--c-border)' + if (project.completed_phases === project.total_phases) return '#22c55e' + if (project.completed_phases > 0) return '#f59e0b' + return 'var(--c-border)' +} + +function ProjectCard({ project, isExpanded, onToggle, onOpenFile, onOpenConfig, onOpenState }) { + const [phases, setPhases] = useState(null) + const [tokenMap, setTokenMap] = useState(null) // phase id → token data + const configBtnRef = useRef(null) + const stateBtnRef = useRef(null) + const pct = project.total_phases > 0 + ? Math.round((project.completed_phases / project.total_phases) * 100) + : 0 + const remaining = project.total_phases - project.completed_phases + + useEffect(() => { + if (isExpanded && phases === null) { + fetchGSDPhases(project.folder).then(setPhases) + fetchGSDPhaseTokens(project.folder).then(rows => { + const map = {} + for (const r of rows) map[r.id] = r + setTokenMap(map) + }).catch(() => setTokenMap({})) + } + }, [isExpanded, phases, project.folder]) + + return ( +
+
+ {/* Chevron — 24px */} + + + {/* Name — flex-1, clickable */} + + + {/* % — 36px */} +
+ 0 ? '#f59e0b' : 'var(--c-text3)', fontFamily: MONO }} + > + {pct}% + +
+ + {/* Progress bar — 112px */} +
+ +
+ + {/* Status pills — 80px */} +
+ {project.completed_phases > 0 && ( + + {project.completed_phases} + + )} + {remaining > 0 && ( + + {remaining} + + )} +
+ + {/* Counters — 52px */} +
+ {project.todos > 0 && ( + + {project.todos} + + )} + {project.notes > 0 && ( + + {project.notes} + + )} +
+ + {/* Est. cost — 60px */} +
+ 0 ? '#a78bfa' : 'var(--c-text3)', fontFamily: MONO }}> + {project.total_cost > 0 ? formatCost(project.total_cost) : '—'} + +
+ + {/* Updated — 56px */} +
+ + {formatRelativeTime(project.last_modified)} + +
+ + {/* State + Config buttons — 52px */} +
e.stopPropagation()}> + + +
+
+ + {isExpanded && ( +
+
+
+
#
+
phase
+
plans
+
artifacts
+
est. cost
+
updated
+
+ + {phases === null + ?
Loading phases…
+ : phases.length === 0 + ?
No phases found.
+ : phases.map(phase => ( + onOpenFile(ph, project.folder, type)} + /> + )) + } +
+ )} +
+ ) +} + +// ============================================================ +// Main page +// ============================================================ + +export default function GSD() { + const { dark } = useTheme() + const [projects, setProjects] = useState(null) + const [overview, setOverview] = useState(null) + const [allTokens, setAllTokens] = useState(null) // { totalCost } + const [expandedFolder, setExpandedFolder] = useState(null) + const [fileSidebar, setFileSidebar] = useState(null) // { title, subtitle, content, loading } + const [configPopover, setConfigPopover] = useState(null) // { folder, anchor } + const [loading, setLoading] = useState(true) + + const txtColor = dark ? '#888' : '#555' + const txtDim = dark ? '#555' : '#999' + const gridColor = dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.06)' + + useEffect(() => { + Promise.all([fetchGSDProjects(), fetchGSDOverview()]) + .then(([p, o]) => { + setProjects(p) + setOverview(o) + setLoading(false) + // total_cost is already computed server-side per project + const totalCost = (p || []).reduce((s, proj) => s + (proj.total_cost || 0), 0) + setAllTokens({ totalCost }) + }) + .catch(() => setLoading(false)) + }, []) + + function handleOpenConfig(folder, anchor) { + setConfigPopover(prev => prev?.folder === folder ? null : { folder, anchor }) + } + + function handleOpenFile(phase, folder, type) { + const phaseDir = phase.id?.split('::')?.[1] || '' + const phaseName = phase.phase_name ?? phase.name ?? '' + const titles = { plan: 'PLAN.md', research: 'RESEARCH.md', verification: 'VERIFICATION.md', summary: 'SUMMARY.md' } + const title = titles[type] ?? type.toUpperCase() + '.md' + + setFileSidebar({ title, subtitle: phaseName, content: null, loading: true }) + + if (type === 'plan') { + fetchGSDPlan(folder, phaseDir) + .then(d => setFileSidebar(prev => prev ? { ...prev, content: d?.content ?? null, loading: false } : null)) + .catch(() => setFileSidebar(prev => prev ? { ...prev, loading: false } : null)) + } else { + fetchGSDFile(folder, type, phaseDir) + .then(d => setFileSidebar(prev => prev ? { ...prev, content: d?.content ?? null, loading: false } : null)) + .catch(() => setFileSidebar(prev => prev ? { ...prev, loading: false } : null)) + } + } + + function handleOpenState(folder, projectName) { + setFileSidebar({ title: 'STATE.md', subtitle: projectName, content: null, loading: true }) + fetchGSDFile(folder, 'state') + .then(d => setFileSidebar(prev => prev ? { ...prev, content: d?.content ?? null, loading: false } : null)) + .catch(() => setFileSidebar(prev => prev ? { ...prev, loading: false } : null)) + } + + if (loading) return + + if (!projects || projects.length === 0) { + return ( +
+ +
+ +
No GSD projects found
+
+ GSD (Get Shit Done) is a structured AI workflow system that stores project plans and phases + inside a .planning/ directory. Run a data scan after + initializing a GSD project and it will appear here. +
+
+
+ ) + } + + const completionRate = overview && overview.totalPhases > 0 + ? Math.round((overview.completedPhases / overview.totalPhases) * 100) + : 0 + + const phaseStatuses = { + completed: overview?.completedPhases ?? 0, + executing: overview?.executingPhases ?? 0, + planned: overview?.plannedPhases ?? 0, + } + + const statusDonutData = { + labels: ['Completed', 'Executing', 'Planned'], + datasets: [{ + data: [phaseStatuses.completed, phaseStatuses.executing, phaseStatuses.planned], + backgroundColor: ['#22c55e', '#f59e0b', 'rgba(255,255,255,0.08)'], + borderWidth: 0, + }], + } + + const topProjects = [...projects].sort((a, b) => b.total_phases - a.total_phases).slice(0, 8) + const projectBarData = { + labels: topProjects.map(p => p.name.length > 16 ? p.name.slice(0, 15) + '…' : p.name), + datasets: [ + { + label: 'Completed', + data: topProjects.map(p => p.completed_phases), + backgroundColor: '#22c55e', + borderRadius: 2, + }, + { + label: 'Remaining', + data: topProjects.map(p => p.total_phases - p.completed_phases), + backgroundColor: 'rgba(99,102,241,0.3)', + borderRadius: 2, + }, + ], + } + + const donutOpts = { + responsive: true, maintainAspectRatio: false, cutout: '72%', + plugins: { + legend: { display: false }, + tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } }, + }, + } + + const barOpts = { + responsive: true, maintainAspectRatio: false, indexAxis: 'y', + scales: { + x: { stacked: true, grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 8, family: MONO } } }, + y: { stacked: true, grid: { display: false }, ticks: { color: txtColor, font: { size: 8, family: MONO } } }, + }, + plugins: { + legend: { display: false }, + tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } }, + }, + } + + return ( +
+ + + {/* KPIs */} +
+ + + + 0 ? `${completionRate}%` : '—'} /> + +
+ + {/* Charts */} +
+
+ phase status +
+
+ +
+
+ {[ + { label: 'Completed', count: phaseStatuses.completed, color: '#22c55e' }, + { label: 'Executing', count: phaseStatuses.executing, color: '#f59e0b' }, + { label: 'Planned', count: phaseStatuses.planned, color: 'var(--c-text3)' }, + ].map(({ label, count, color }) => ( +
+ + {label} + {count} +
+ ))} +
+
+
+ +
+ projects by phases +
+ +
+
+ Completed + Remaining +
+
+
+ + {/* Project cards */} +
+ projects + {projects.map(project => ( + setExpandedFolder(prev => prev === project.folder ? null : project.folder)} + onOpenFile={handleOpenFile} + onOpenConfig={handleOpenConfig} + onOpenState={handleOpenState} + /> + ))} +
+ + {/* Config popover — rendered at page level to escape overflow-hidden */} + {configPopover && ( + setConfigPopover(null)} + /> + )} + + {/* File sidebar */} + {fileSidebar && ( + setFileSidebar(null)} + /> + )} +
+ ) +}