diff --git a/brain/active-projects.md b/brain/active-projects.md
index acc328c..528f951 100644
--- a/brain/active-projects.md
+++ b/brain/active-projects.md
@@ -20,3 +20,5 @@
## AgriAssist AI
- Status: On hold
- Hackathon project (slides, MVP, pitch strategy)
+
+- [Test goal](goal:c1c992ef) โ
diff --git a/brain/journal/2026-06-05.md b/brain/journal/2026-06-05.md
new file mode 100644
index 0000000..181cb85
--- /dev/null
+++ b/brain/journal/2026-06-05.md
@@ -0,0 +1,2 @@
+# Test Journal Entry
+This is a test entry.
\ No newline at end of file
diff --git a/dashboard/api.js b/dashboard/api.js
index a92c99c..bdb2284 100644
--- a/dashboard/api.js
+++ b/dashboard/api.js
@@ -16,6 +16,11 @@ const api = {
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
return r.json();
},
+ async patch(path, body = {}) {
+ const r = await fetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+ if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
+ return r.json();
+ },
async del(path) {
const r = await fetch(path, { method: 'DELETE' });
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
@@ -47,4 +52,41 @@ const api = {
discoverStandards: () => api.post('/api/standards/discover'),
chat: (agent, message, controller) => api.post('/api/chat', { agent, message }, controller),
getChatHistory: () => api.get('/api/chat/history'),
+ // Kanban
+ getKanbanBoard: (status) => api.get(status ? `/api/kanban/board?status=${encodeURIComponent(status)}` : '/api/kanban/board'),
+ getKanbanTask: (id) => api.get(`/api/kanban/tasks/${encodeURIComponent(id)}`),
+ createKanbanTask: (data) => api.post('/api/kanban/tasks', data),
+ updateKanbanTask: (id, data) => api.patch(`/api/kanban/tasks/${encodeURIComponent(id)}`, data),
+ completeKanbanTask: (id, summary) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/complete`, { summary }),
+ blockKanbanTask: (id, reason) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/block`, { reason }),
+ unblockKanbanTask: (id) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/unblock`, {}),
+ addKanbanComment: (id, message) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/comments`, { message }),
+ linkKanbanTasks: (parentId, childId) => api.post('/api/kanban/links', { parent_id: parentId, child_id: childId }),
+ unlinkKanbanTasks: (parentId, childId) => api.del(`/api/kanban/links?parent_id=${encodeURIComponent(parentId)}&child_id=${encodeURIComponent(childId)}`),
+ dispatchKanban: () => api.post('/api/kanban/dispatch', {}),
+ specifyKanbanTask: (id) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/specify`, {}),
+ decomposeKanbanTask: (id) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/decompose`, {}),
+ // Goals
+ getGoals: () => api.get('/api/goals'),
+ createGoal: (data) => api.post('/api/goals', data),
+ updateGoal: (id, data) => api.put(`/api/goals/${encodeURIComponent(id)}`, data),
+ deleteGoal: (id) => api.del(`/api/goals/${encodeURIComponent(id)}`),
+ // Journal
+ getJournalEntries: () => api.get('/api/journal/entries'),
+ getJournalEntry: (date) => api.get(`/api/journal/entries/${encodeURIComponent(date)}`),
+ saveJournalEntry: (date, content) => api.put(`/api/journal/entries/${encodeURIComponent(date)}`, { content }),
+ searchJournal: (query) => api.get(`/api/journal/search?q=${encodeURIComponent(query)}`),
+ // Agent Health
+ getAgentHealth: () => api.get('/api/agents/health'),
+ getAgentStats: (name) => api.get(`/api/agents/${encodeURIComponent(name)}/stats`),
+ refreshAgentHealth: () => api.post('/api/agents/health/refresh', {}),
+ // Smart Router
+ suggestRouter: (task) => api.post('/api/router/suggest', { task }),
+ routeTask: (task, agent) => api.post('/api/router/route', { task, agent }),
+ // Learning Analytics
+ getSkillAnalytics: () => api.get('/api/analytics/skills'),
+ getTrendAnalytics: () => api.get('/api/analytics/trends'),
+ // Session Replay
+ listSessions: () => api.get('/api/sessions/list'),
+ getSessionReplay: (id) => api.get(`/api/sessions/${encodeURIComponent(id)}/replay`),
};
diff --git a/dashboard/index.html b/dashboard/index.html
index 59c1e48..4f7899e 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -39,6 +39,15 @@
๐ง Memory
โฑScheduler
๐Audit
+
+ ๐Kanban Board
+ ๐ฏGoals
+ ๐Journal
+
+ ๐ฅAgent Health0
+ ๐งญSmart Router
+ ๐Learning Analytics
+ ๐Session Replay
๐ฐCost Analytics
๐Plugins
diff --git a/dashboard/pages/agent-health.js b/dashboard/pages/agent-health.js
new file mode 100644
index 0000000..8cd65ed
--- /dev/null
+++ b/dashboard/pages/agent-health.js
@@ -0,0 +1,115 @@
+let agentHealthInterval = null;
+
+async function renderAgentHealth() {
+ const content = document.getElementById('pageContent');
+ content.innerHTML = `
+
+
+ Health Overview
+
+ `;
+ await refreshAgentHealth();
+ if (document.getElementById('healthAutoRefresh')?.checked) {
+ startHealthAutoRefresh();
+ }
+}
+
+function startHealthAutoRefresh() {
+ stopHealthAutoRefresh();
+ agentHealthInterval = setInterval(refreshAgentHealth, 5000);
+}
+
+function stopHealthAutoRefresh() {
+ if (agentHealthInterval) {
+ clearInterval(agentHealthInterval);
+ agentHealthInterval = null;
+ }
+}
+
+function toggleHealthAutoRefresh() {
+ if (document.getElementById('healthAutoRefresh')?.checked) {
+ startHealthAutoRefresh();
+ } else {
+ stopHealthAutoRefresh();
+ }
+}
+
+async function refreshAgentHealth() {
+ try {
+ const data = await api.getAgentHealth();
+ const agents = data.agents || [];
+ const cards = document.getElementById('agentHealthCards');
+ if (!cards) return;
+ const agentIcons = { opencode: '๐ง', hermes: 'โก', gemini: '๐ง ' };
+ const agentColors = { opencode: 'purple', hermes: 'green', gemini: 'blue' };
+ cards.innerHTML = agents.map(a => `
+
+
+ ${agentIcons[a.name] || '๐ค'}
+
+
+
${a.name}
+
+
+ ${a.status}
+
+
+
+
${a.status === 'online' ? '100' : '0'}%
+
Uptime
+
+
+
${a.status === 'online' ? 'โ' : 'โ'}
+
Reachable
+
+
+
${new Date(data.updated).toLocaleTimeString()}
+
Last Check
+
+
+
+
+ `).join('');
+ const overview = document.getElementById('healthOverviewCard');
+ if (overview) {
+ const online = agents.filter(a => a.status === 'online').length;
+ const total = agents.length;
+ overview.innerHTML = `
+
+
+
System Status
+
+ ${online}/${total} agents online ยท Last updated: ${new Date(data.updated).toLocaleTimeString()}
+
+
+
+
+ ${online === total ? 'All Online' : online > 0 ? 'Partial' : 'Offline'}
+
+
+ `;
+ }
+ } catch (err) {
+ const cards = document.getElementById('agentHealthCards');
+ if (cards) cards.innerHTML = `โ
Failed to load health data
${escapeHtml(err.message)}
`;
+ }
+}
diff --git a/dashboard/pages/goals.js b/dashboard/pages/goals.js
new file mode 100644
index 0000000..30a7ea6
--- /dev/null
+++ b/dashboard/pages/goals.js
@@ -0,0 +1,176 @@
+async function renderGoals() {
+ const content = document.getElementById('pageContent');
+ content.innerHTML = `
+
+
+
+ `;
+ try {
+ const data = await api.getGoals();
+ const goals = data.goals || [];
+ const list = document.getElementById('goalList');
+ const totalEl = document.getElementById('goalTotalCount');
+ const activeEl = document.getElementById('goalActiveCount');
+ const completeEl = document.getElementById('goalCompleteCount');
+ const avgEl = document.getElementById('goalAvgProgress');
+ if (!list) return;
+ if (goals.length === 0) {
+ list.innerHTML = `๐ฏ
No goals yet
Create your first goal to start tracking progress
`;
+ if (totalEl) totalEl.textContent = '0';
+ if (activeEl) activeEl.textContent = '0';
+ if (completeEl) completeEl.textContent = '0';
+ if (avgEl) avgEl.textContent = '0%';
+ return;
+ }
+ const active = goals.filter(g => g.status === 'active').length;
+ const done = goals.filter(g => g.status === 'completed').length;
+ const avgProg = Math.round(goals.reduce((s, g) => s + (g.progress || 0), 0) / goals.length);
+ if (totalEl) totalEl.textContent = goals.length;
+ if (activeEl) activeEl.textContent = active;
+ if (completeEl) completeEl.textContent = done;
+ if (avgEl) avgEl.textContent = avgProg + '%';
+ list.innerHTML = goals.map(g => `
+
+
+ ${g.status}
+ ${g.category}
+ ${g.target_date ? `๐ฏ ${g.target_date}` : ''}
+
+
${escapeHtml(g.title)}
+ ${g.description ? `
${escapeHtml(g.description)}
` : ''}
+
+
+
${g.progress || 0}%
+
+
+
+ ${g.status !== 'completed' ? `` : ''}
+
+
+
+ `).join('');
+ } catch (err) {
+ showToast('Failed to load goals: ' + err.message, 'error');
+ }
+}
+
+function showCreateGoalModal() {
+ const modal = document.getElementById('modalContainer');
+ modal.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+}
+
+async function createGoal() {
+ const title = document.getElementById('goalTitle').value.trim();
+ if (!title) { showToast('Title is required', 'error'); return; }
+ try {
+ await api.createGoal({
+ title,
+ description: document.getElementById('goalDesc').value.trim(),
+ category: document.getElementById('goalCategory').value,
+ target_date: document.getElementById('goalDate').value,
+ });
+ showToast('Goal created!', 'success');
+ closeModal();
+ renderGoals();
+ } catch (err) {
+ showToast('Failed to create goal: ' + err.message, 'error');
+ }
+}
+
+async function updateGoalProgress(id, progress) {
+ try {
+ const status = progress >= 100 ? 'completed' : 'active';
+ await api.updateGoal(id, { progress, status });
+ renderGoals();
+ } catch (err) {
+ showToast('Failed to update goal: ' + err.message, 'error');
+ }
+}
+
+async function completeGoal(id) {
+ try {
+ await api.updateGoal(id, { progress: 100, status: 'completed' });
+ showToast('Goal completed! ๐', 'success');
+ renderGoals();
+ } catch (err) {
+ showToast('Failed to complete goal: ' + err.message, 'error');
+ }
+}
+
+async function deleteGoal(id) {
+ if (!confirm('Delete this goal?')) return;
+ try {
+ await api.deleteGoal(id);
+ showToast('Goal deleted', 'info');
+ renderGoals();
+ } catch (err) {
+ showToast('Failed to delete goal: ' + err.message, 'error');
+ }
+}
diff --git a/dashboard/pages/journal.js b/dashboard/pages/journal.js
new file mode 100644
index 0000000..1f464da
--- /dev/null
+++ b/dashboard/pages/journal.js
@@ -0,0 +1,165 @@
+let journalCurrentDate = '';
+
+async function renderJournal() {
+ const today = new Date().toISOString().split('T')[0];
+ journalCurrentDate = today;
+ const content = document.getElementById('pageContent');
+ content.innerHTML = `
+
+
+
+
+
+
${formatDateDisplay(today)}
+
+ Auto-save on
+
+
+
+
+
+ `;
+ loadJournalEntry(today);
+ loadJournalEntries();
+}
+
+function formatDateDisplay(dateStr) {
+ const d = new Date(dateStr + 'T00:00:00');
+ return d.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
+}
+
+let journalSaveTimer = null;
+function scheduleJournalSave() {
+ if (journalSaveTimer) clearTimeout(journalSaveTimer);
+ journalSaveTimer = setTimeout(saveJournalEntry, 2000);
+ const status = document.getElementById('journalSaveStatus');
+ if (status) status.textContent = 'Unsaved changes...';
+}
+
+async function saveJournalEntry() {
+ const textarea = document.getElementById('journalText');
+ if (!textarea) return;
+ const content = textarea.value;
+ const date = journalCurrentDate;
+ try {
+ await api.saveJournalEntry(date, content);
+ const status = document.getElementById('journalSaveStatus');
+ if (status) status.textContent = 'Saved โ';
+ const words = content.trim() ? content.trim().split(/\s+/).length : 0;
+ const wc = document.getElementById('journalWordCount');
+ if (wc) wc.textContent = words;
+ } catch (err) {
+ const status = document.getElementById('journalSaveStatus');
+ if (status) status.textContent = 'Save failed';
+ }
+}
+
+async function loadJournalEntry(date) {
+ journalCurrentDate = date;
+ const label = document.getElementById('journalDateLabel');
+ if (label) label.textContent = formatDateDisplay(date);
+ const textarea = document.getElementById('journalText');
+ if (!textarea) return;
+ try {
+ const data = await api.getJournalEntry(date);
+ textarea.value = data.content || '';
+ const words = data.content ? data.content.trim().split(/\s+/).length : 0;
+ const wc = document.getElementById('journalWordCount');
+ if (wc) wc.textContent = words;
+ const status = document.getElementById('journalSaveStatus');
+ if (status) status.textContent = 'Loaded โ';
+ } catch (err) {
+ textarea.value = '';
+ }
+}
+
+async function loadJournalEntries() {
+ try {
+ const data = await api.getJournalEntries();
+ const entries = data.entries || [];
+ const totalEl = document.getElementById('journalTotalCount');
+ if (totalEl) totalEl.textContent = entries.length;
+ let streak = 0;
+ const today = new Date();
+ for (let i = 0; i < 365; i++) {
+ const d = new Date(today);
+ d.setDate(d.getDate() - i);
+ const ds = d.toISOString().split('T')[0];
+ if (entries.some(e => e.date === ds)) { streak++; }
+ else if (i > 0) break;
+ }
+ const streakEl = document.getElementById('journalStreak');
+ if (streakEl) streakEl.textContent = streak;
+ const list = document.getElementById('journalEntriesList');
+ if (!list) return;
+ if (entries.length === 0) {
+ list.innerHTML = `๐
No journal entries yet
Write your first entry above
`;
+ return;
+ }
+ list.innerHTML = `
+ Recent Entries
+
+ ${entries.slice(0, 30).map(e => `
+
+ `).join('')}
+
+ `;
+ } catch (err) {
+ showToast('Failed to load entries: ' + err.message, 'error');
+ }
+}
+
+async function searchJournal() {
+ const q = document.getElementById('journalSearchInput').value.trim();
+ if (!q) { loadJournalEntries(); return; }
+ try {
+ const data = await api.searchJournal(q);
+ const results = data.results || [];
+ const list = document.getElementById('journalEntriesList');
+ if (!list) return;
+ if (results.length === 0) {
+ list.innerHTML = `๐
No results for "${escapeHtml(q)}"
`;
+ return;
+ }
+ list.innerHTML = `
+ Results for "${escapeHtml(q)}"
+ ${results.map(r => `
+
+
${r.date}
+
${escapeHtml(r.preview)}
+
+ `).join('')}
+ `;
+ } catch (err) {
+ showToast('Search failed: ' + err.message, 'error');
+ }
+}
diff --git a/dashboard/pages/kanban.js b/dashboard/pages/kanban.js
new file mode 100644
index 0000000..fee2bb3
--- /dev/null
+++ b/dashboard/pages/kanban.js
@@ -0,0 +1,290 @@
+let kanbanData = null;
+
+async function renderKanban() {
+ const content = document.getElementById('pageContent');
+ content.innerHTML = `
+
+
+
+
+
+
+
+ `;
+ await loadKanbanData();
+}
+
+async function loadKanbanData() {
+ try {
+ const data = await api.getKanbanBoard();
+ kanbanData = data;
+ renderKanbanBoard();
+ } catch (err) {
+ const board = document.getElementById('kanbanBoard');
+ if (board) board.innerHTML = `๐
Failed to load kanban
${escapeHtml(err.message)}
`;
+ }
+}
+
+function renderKanbanBoard() {
+ const board = document.getElementById('kanbanBoard');
+ if (!board || !kanbanData) return;
+ const columnsObj = kanbanData.columns || {};
+ const columns = Object.keys(columnsObj);
+ const allTasks = kanbanData.tasks || Object.values(columnsObj).flat();
+ const filterText = (document.getElementById('kanbanFilterInput')?.value || '').toLowerCase();
+ const filterPriority = document.getElementById('kanbanFilterPriority')?.value || 'all';
+ const filterCategory = document.getElementById('kanbanFilterCategory')?.value || 'all';
+ const columnLabels = { triage: 'Triage', todo: 'To Do', ready: 'Ready', in_progress: 'In Progress', blocked: 'Blocked', done: 'Done', backlog: 'Backlog', review: 'Review' };
+ const columnIcons = { triage: '๐', todo: '๐', ready: 'โ
', in_progress: '๐', blocked: '๐ซ', done: '๐', backlog: '๐', review: '๐' };
+ board.innerHTML = columns.map(col => {
+ let colTasks = (columnsObj[col] || []).filter(t => {
+ if (filterText) return (t.title || '').toLowerCase().includes(filterText) || (t.body || t.description || '').toLowerCase().includes(filterText);
+ return true;
+ }).filter(t => filterPriority === 'all' || (t.priority || 'medium') === filterPriority)
+ .filter(t => filterCategory === 'all' || (t.category || 'general') === filterCategory);
+ return `
+
+
+
+ ${columnIcons[col] || '๐'}
+ ${columnLabels[col] || col}
+ ${colTasks.length}
+
+
+
+ ${colTasks.length === 0 ? `
No tasks
` :
+ colTasks.map(t => `
+
+
+
${escapeHtml(t.title)}
+ ${t.body ? `
${escapeHtml(t.body.substring(0, 80))}${t.body.length > 80 ? '...' : ''}
` : ''}
+
+ ${t.assignee ? `๐ค ${escapeHtml(t.assignee)}` : ''}
+
+ ${t.status === 'blocked' ? `
๐ซ Blocked
` : ''}
+
+ `).join('')}
+
+
+ `;
+ }).join('');
+}
+
+function showAddKanbanTask() {
+ const modal = document.getElementById('modalContainer');
+ modal.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+}
+
+async function createKanbanTask() {
+ const title = document.getElementById('kanbanTitle').value.trim();
+ if (!title) { showToast('Title is required', 'error'); return; }
+ const tagsStr = document.getElementById('kanbanTags').value.trim();
+ const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
+ try {
+ await api.createKanbanTask({
+ title,
+ body: document.getElementById('kanbanDescription').value.trim(),
+ status: document.getElementById('kanbanStatus').value,
+ priority: document.getElementById('kanbanPriority').value,
+ assignee: document.getElementById('kanbanAssigned').value.trim(),
+ });
+ showToast('Task added to kanban board!', 'success');
+ closeModal();
+ renderKanban();
+ } catch (err) {
+ showToast('Failed to create task: ' + err.message, 'error');
+ }
+}
+
+let kanbanDraggedId = null;
+function onKanbanDrag(e, id) {
+ kanbanDraggedId = id;
+ e.dataTransfer.effectAllowed = 'move';
+}
+async function onKanbanDrop(e, status) {
+ e.preventDefault();
+ if (!kanbanDraggedId) return;
+ try {
+ await api.updateKanbanTask(kanbanDraggedId, { status });
+ kanbanDraggedId = null;
+ renderKanban();
+ } catch (err) {
+ showToast('Failed to move task: ' + err.message, 'error');
+ }
+}
+
+function showKanbanDetail(id) {
+ const task = kanbanData?.tasks?.find(t => t.id === id);
+ if (!task) return;
+ const modal = document.getElementById('modalContainer');
+ modal.innerHTML = `
+
+
+
+
+
+ ${task.status}
+ ${task.priority}
+ ${task.status === 'blocked' ? `๐ซ Blocked` : ''}
+
+ ${task.body ? `
${escapeHtml(task.body)}
` : ''}
+
+ ${task.assignee ? `๐ค ${escapeHtml(task.assignee)}` : ''}
+ ๐
${task.created || 'N/A'}
+ ${task.completed_at ? `โ
${task.completed_at}` : ''}
+
+
+ ${task.status !== 'done' ? `` : ''}
+ ${task.status !== 'blocked' ? `` : ``}
+
+
+
+
+
+ `;
+}
+
+async function completeKanbanTask(id) {
+ try {
+ await api.completeKanbanTask(id);
+ closeModal();
+ renderKanban();
+ showToast('Task completed! โ
', 'success');
+ } catch (err) {
+ showToast('Failed: ' + err.message, 'error');
+ }
+}
+
+async function blockKanbanTask(id) {
+ try {
+ await api.blockKanbanTask(id);
+ closeModal();
+ renderKanban();
+ showToast('Task blocked ๐ซ', 'warning');
+ } catch (err) {
+ showToast('Failed: ' + err.message, 'error');
+ }
+}
+
+async function unblockKanbanTask(id) {
+ try {
+ await api.unblockKanbanTask(id);
+ closeModal();
+ renderKanban();
+ showToast('Task unblocked ๐', 'success');
+ } catch (err) {
+ showToast('Failed: ' + err.message, 'error');
+ }
+}
+
+async function deleteKanbanTask(id) {
+ if (!confirm('Delete this task?')) return;
+ try {
+ await api.deleteKanbanTask(id);
+ closeModal();
+ renderKanban();
+ showToast('Task deleted', 'info');
+ } catch (err) {
+ showToast('Failed: ' + err.message, 'error');
+ }
+}
+
+function filterKanbanTasks() {
+ renderKanbanBoard();
+}
diff --git a/dashboard/pages/learning-analytics.js b/dashboard/pages/learning-analytics.js
new file mode 100644
index 0000000..88191d8
--- /dev/null
+++ b/dashboard/pages/learning-analytics.js
@@ -0,0 +1,103 @@
+async function renderLearningAnalytics() {
+ const content = document.getElementById('pageContent');
+ content.innerHTML = `
+
+ Skill Scores
+
+ Score Trends
+
+ Skill Details
+
+ `;
+ try {
+ const [skillData, trendData] = await Promise.all([api.getSkillAnalytics(), api.getTrendAnalytics()]);
+ const skills = skillData.skills || [];
+ const trends = trendData.trends || {};
+ const grid = document.getElementById('skillScoresGrid');
+ if (grid) {
+ if (skills.length === 0) {
+ grid.innerHTML = `๐
No skill data yet
Skill evaluations will appear here as they accumulate
`;
+ } else {
+ const topSkills = skills.sort((a, b) => (b.score || 0) - (a.score || 0)).slice(0, 6);
+ grid.innerHTML = topSkills.map(s => {
+ const pct = Math.round((s.score || 0) * 100);
+ return `
+
+
${escapeHtml(s.name)}
+
${s.evals || 0} evaluations
+
+
+ `;
+ }).join('');
+ }
+ }
+ const trendSection = document.getElementById('trendCharts');
+ if (trendSection) {
+ const trendEntries = Object.entries(trends).slice(0, 4);
+ if (trendEntries.length === 0) {
+ trendSection.innerHTML = `๐
No trend data
Trends will appear after multiple evaluations
`;
+ } else {
+ trendSection.innerHTML = trendEntries.map(([name, history]) => {
+ const vals = (history || []).slice(-10);
+ const maxVal = Math.max(...vals, 0.01);
+ const chartHeight = 120;
+ return `
+
+
${escapeHtml(name)}
+
+ ${vals.map((v, i) => {
+ const h = (v / maxVal) * (chartHeight - 25);
+ return `
`;
+ }).join('')}
+
+
+ ${vals.length} data points
+ Avg: ${(vals.reduce((a, b) => a + b, 0) / vals.length * 100).toFixed(0)}%
+
+
+ `;
+ }).join('');
+ }
+ }
+ const details = document.getElementById('skillDetailsList');
+ if (details) {
+ if (skills.length === 0) {
+ details.innerHTML = '';
+ } else {
+ details.innerHTML = skills.map(s => {
+ const pct = Math.round((s.score || 0) * 100);
+ return `
+
+
+
+
${escapeHtml(s.name)}
+
${s.evals || 0} evaluations ยท Best: ${((s.best || 0) * 100).toFixed(0)}%
+
+
${pct}%
+
+
+ `;
+ }).join('');
+ }
+ }
+ } catch (err) {
+ showToast('Failed to load analytics: ' + err.message, 'error');
+ }
+}
diff --git a/dashboard/pages/session-replay.js b/dashboard/pages/session-replay.js
new file mode 100644
index 0000000..ee90e89
--- /dev/null
+++ b/dashboard/pages/session-replay.js
@@ -0,0 +1,87 @@
+async function renderSessionReplay() {
+ const content = document.getElementById('pageContent');
+ content.innerHTML = `
+
+
+
+ `;
+ try {
+ const data = await api.listSessions();
+ const sessions = data.sessions || [];
+ const list = document.getElementById('sessionList');
+ if (sessions.length === 0) {
+ list.innerHTML = `๐ฌ
No sessions found
Sessions from opencode will appear here
`;
+ return;
+ }
+ list.innerHTML = `
+
+
+ | Session ID | Date | Size | |
+ ${sessions.slice(0, 50).map(s => `
+
+ | ${escapeHtml(s.id)} |
+ ${new Date(s.date).toLocaleString()} |
+ ${formatSize(s.size || 0)} |
+ |
+
+ `).join('')}
+
+ ${sessions.length > 50 ? `
Showing 50 of ${sessions.length} sessions
` : ''}
+
+ `;
+ } catch (err) {
+ document.getElementById('sessionList').innerHTML = `โ
Failed to load sessions
${escapeHtml(err.message)}
`;
+ }
+}
+
+async function replaySession(id) {
+ const detail = document.getElementById('sessionDetail');
+ detail.innerHTML = `Loading session ${escapeHtml(id)}... `;
+ try {
+ const data = await api.getSessionReplay(id);
+ const messages = data.messages || [];
+ const session = data.session || {};
+ detail.innerHTML = `
+
+
+
+
Session: ${escapeHtml(id)}
+
${messages.length} messages ยท ${new Date(session.created_at || Date.now()).toLocaleString()}
+
+
+
+
+
+ ${messages.map(m => {
+ const isUser = m.role === 'user' || m.role === 'human';
+ return `
+
+
${isUser ? '๐ค You' : '๐ค Assistant'}
+
${escapeHtml((m.content || '').substring(0, 500))}${(m.content || '').length > 500 ? '...' : ''}
+
${new Date(m.timestamp || Date.now()).toLocaleTimeString()}
+
+ `;
+ }).join('')}
+ ${messages.length === 0 ? '
Empty session
' : ''}
+
+ `;
+ } catch (err) {
+ detail.innerHTML = `โ Failed to replay session: ${escapeHtml(err.message)}
`;
+ }
+}
+
+function formatSize(bytes) {
+ if (bytes < 1024) return bytes + ' B';
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
+}
diff --git a/dashboard/pages/smart-router.js b/dashboard/pages/smart-router.js
new file mode 100644
index 0000000..ba8e319
--- /dev/null
+++ b/dashboard/pages/smart-router.js
@@ -0,0 +1,118 @@
+async function renderSmartRouter() {
+ const content = document.getElementById('pageContent');
+ content.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Routing Rules
+
+
+ | Agent | Best For | Keywords |
+ | ๐ง opencode | Code, DevOps, infra, git, file operations | code, deploy, git, terraform, docker, test, build, script |
+ | โก Hermes | Memory, scheduling, messaging, skills | memory, schedule, cron, reminder, brain, plugin, backup |
+ | ๐ง Gemini CLI | Research, analysis, study, document, review | research, analyze, search, explain, study, learn, report |
+
+
+ `;
+}
+
+async function suggestRouter() {
+ const task = document.getElementById('routerTaskInput').value.trim();
+ if (!task) { showToast('Describe your task first', 'warning'); return; }
+ const btn = document.querySelector('button[onclick="suggestRouter()"]');
+ if (btn) { btn.disabled = true; btn.textContent = 'โณ Thinking...'; }
+ try {
+ const data = await api.suggestRouter(task);
+ const result = document.getElementById('routerResult');
+ const agentIcons = { opencode: '๐ง', hermes: 'โก', gemini: '๐ง ' };
+ const confidenceColors = { high: 'var(--green)', medium: 'var(--yellow)', low: 'var(--text-muted)' };
+ result.innerHTML = `
+
+
+
+
+ ${agentIcons[data.suggested_agent] || '๐ค'} ${data.suggested_agent}
+
+
+ Confidence: ${data.confidence}
+ ${data.confidence === 'high' ? 'โ
' : data.confidence === 'medium' ? 'โ ๏ธ' : 'โ'}
+
+
+
+ Best Match
+
+
+
+ ${Object.entries(data.scores || {}).map(([agent, score]) => `
+
+ ${agentIcons[agent] || '๐ค'} ${agent}: ${score}
+
+ `).join('')}
+
+
+ `;
+ document.getElementById('routerAgentSelect').value = data.suggested_agent || 'auto';
+ } catch (err) {
+ showToast('Suggestion failed: ' + err.message, 'error');
+ } finally {
+ if (btn) { btn.disabled = false; btn.textContent = '๐ค Suggest Agent'; }
+ }
+}
+
+async function routeTask() {
+ const task = document.getElementById('routerTaskInput').value.trim();
+ if (!task) { showToast('Describe your task first', 'warning'); return; }
+ let agent = document.getElementById('routerAgentSelect').value;
+ if (agent === 'auto') {
+ showToast('Click "Suggest Agent" first or pick an agent manually', 'warning');
+ return;
+ }
+ const btn = document.querySelector('button[onclick="routeTask()"]');
+ if (btn) { btn.disabled = true; btn.textContent = 'โณ Routing...'; }
+ try {
+ const data = await api.routeTask(task, agent);
+ showToast(`โ
Task routed to ${agent}`, 'success');
+ const result = document.getElementById('routerResult');
+ result.innerHTML += `
+
+
+
โ
+
+
Task Routed
+
${data.message}
+
+
+
+ `;
+ } catch (err) {
+ showToast('Routing failed: ' + err.message, 'error');
+ } finally {
+ if (btn) { btn.disabled = false; btn.textContent = '๐ Route Task'; }
+ }
+}
diff --git a/dashboard/styles.css b/dashboard/styles.css
index fdb9cfe..54c27f9 100644
--- a/dashboard/styles.css
+++ b/dashboard/styles.css
@@ -820,3 +820,546 @@ pre {
.chat-sidebar { display: none; }
.chat-message { max-width: 95%; }
}
+
+/* โโโ v0.2.0 UI Modernization โโโ */
+
+/* Glass card variant */
+.glass-card {
+ background: rgba(28, 35, 51, 0.8);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border: 1px solid rgba(41, 48, 66, 0.6);
+ border-radius: var(--radius-lg);
+ padding: 20px;
+ transition: var(--transition);
+}
+[data-theme="light"] .glass-card {
+ background: rgba(255, 255, 255, 0.85);
+ border-color: rgba(226, 229, 240, 0.6);
+}
+.glass-card:hover {
+ border-color: var(--border-hover);
+ box-shadow: 0 8px 32px rgba(108, 92, 231, 0.1);
+}
+
+/* Glow border utility */
+.glow-border {
+ position: relative;
+}
+.glow-border::after {
+ content: '';
+ position: absolute;
+ inset: -1px;
+ border-radius: inherit;
+ background: linear-gradient(135deg, var(--accent), var(--pink), var(--accent));
+ z-index: -1;
+ opacity: 0;
+ transition: opacity 0.3s;
+}
+.glow-border:hover::after {
+ opacity: 0.4;
+}
+
+/* Section gradient headers */
+.section-title {
+ font-size: 14px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ background: linear-gradient(135deg, var(--text-secondary), var(--accent-light));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ margin-bottom: 16px;
+}
+
+/* Animated gradient button */
+.btn-gradient {
+ background: linear-gradient(135deg, var(--accent), var(--pink));
+ color: white;
+ border: none;
+ background-size: 200% 200%;
+ animation: gradientShift 3s ease infinite;
+}
+.btn-gradient:hover {
+ box-shadow: 0 4px 20px rgba(108, 92, 231, 0.3);
+ transform: translateY(-1px);
+}
+@keyframes gradientShift {
+ 0% { background-position: 0% 50%; }
+ 50% { background-position: 100% 50%; }
+ 100% { background-position: 0% 50%; }
+}
+
+/* Enhanced loading spinner */
+.loading-ring {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ background: conic-gradient(from 0deg, var(--bg-card), var(--accent), var(--pink), var(--bg-card));
+ animation: ringSpin 1s linear infinite;
+ mask: radial-gradient(farthest-side, transparent calc(100% - 3px), white calc(100% - 3px));
+ -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), white calc(100% - 3px));
+}
+@keyframes ringSpin { to { transform: rotate(360deg); } }
+
+/* Metric tile */
+.metric-tile {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 16px 20px;
+ border-radius: var(--radius);
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ transition: var(--transition);
+}
+.metric-tile:hover {
+ border-color: var(--border-hover);
+ box-shadow: 0 4px 16px rgba(0,0,0,0.1);
+}
+.metric-tile-value {
+ font-size: 24px;
+ font-weight: 800;
+ letter-spacing: -0.5px;
+ background: linear-gradient(135deg, var(--text-primary), var(--accent-light));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+.metric-tile-label {
+ font-size: 11px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 600;
+}
+
+/* Timeline (for session replay) */
+.timeline {
+ position: relative;
+ padding-left: 28px;
+}
+.timeline::before {
+ content: '';
+ position: absolute;
+ left: 10px;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ background: linear-gradient(to bottom, var(--accent), var(--pink));
+ opacity: 0.3;
+}
+.timeline-item {
+ position: relative;
+ padding: 8px 0 8px 16px;
+ border-left: 2px solid transparent;
+ transition: var(--transition);
+}
+.timeline-item::before {
+ content: '';
+ position: absolute;
+ left: -22px;
+ top: 14px;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--bg-card);
+ border: 2px solid var(--accent);
+}
+.timeline-item.active::before {
+ background: var(--accent);
+ box-shadow: 0 0 12px var(--accent-glow);
+}
+.timeline-item:hover {
+ border-left-color: var(--accent-glow);
+ background: var(--bg-card-hover);
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
+}
+.timeline-time {
+ font-size: 11px;
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+}
+.timeline-content {
+ font-size: 13px;
+ margin-top: 2px;
+}
+
+/* Kanban specific */
+.kanban-toolbar {
+ display: flex; align-items: center; gap: 10px;
+ margin-bottom: 14px; flex-wrap: wrap;
+}
+.kanban-blocked-badge {
+ display: inline-flex; align-items: center; gap: 4px;
+ font-size: 10px; font-weight: 600;
+ color: var(--red); background: var(--red-dim);
+ padding: 2px 8px; border-radius: 12px; margin-top: 6px;
+}
+.kanban-board {
+ display: flex;
+ gap: 14px;
+ flex: 1;
+ min-height: 0;
+ overflow-x: auto;
+ padding-bottom: 8px;
+}
+.kanban-column {
+ min-width: 260px;
+ max-width: 320px;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-secondary);
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border);
+ max-height: 100%;
+}
+.kanban-column-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+.kanban-column-title {
+ font-size: 12px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.kanban-column-count {
+ font-size: 11px;
+ color: var(--text-muted);
+ background: var(--bg-card);
+ padding: 1px 7px;
+ border-radius: 10px;
+ font-weight: 600;
+}
+.kanban-column-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.kanban-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 10px 12px;
+ cursor: pointer;
+ transition: var(--transition);
+}
+.kanban-card:hover {
+ border-color: var(--accent);
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+}
+.kanban-card-title {
+ font-size: 13px;
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+.kanban-card-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 11px;
+ color: var(--text-muted);
+}
+.kanban-card-priority {
+ font-size: 10px;
+ font-weight: 700;
+ padding: 1px 6px;
+ border-radius: 8px;
+ text-transform: uppercase;
+}
+.kanban-card-priority.high { background: var(--red-dim); color: var(--red); }
+.kanban-card-priority.medium { background: var(--yellow-dim); color: var(--yellow); }
+.kanban-card-priority.low { background: var(--blue-dim); color: var(--blue); }
+
+/* Goal cards */
+.goal-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 18px;
+ transition: var(--transition);
+ cursor: pointer;
+}
+.goal-card:hover {
+ border-color: var(--accent);
+ transform: translateY(-2px);
+ box-shadow: 0 8px 24px rgba(0,0,0,0.1);
+}
+.goal-card-title {
+ font-size: 15px;
+ font-weight: 700;
+ margin-bottom: 8px;
+}
+.goal-card-progress {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 10px;
+}
+.goal-card-progress-bar {
+ flex: 1;
+ height: 4px;
+ background: var(--bg-primary);
+ border-radius: 2px;
+ overflow: hidden;
+}
+.goal-card-progress-fill {
+ height: 100%;
+ border-radius: 2px;
+ background: linear-gradient(90deg, var(--accent), var(--green));
+ transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
+}
+.goal-card-progress-text {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text-secondary);
+ min-width: 36px;
+ text-align: right;
+}
+
+/* Journal entry */
+.journal-entry {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 20px;
+ min-height: 300px;
+}
+.journal-entry textarea {
+ width: 100%;
+ min-height: 250px;
+ background: transparent;
+ border: none;
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+ font-size: 13px;
+ line-height: 1.6;
+ resize: vertical;
+ outline: none;
+}
+.journal-entry textarea::placeholder {
+ color: var(--text-muted);
+}
+
+/* Agent health card */
+.agent-health-card {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 18px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ transition: var(--transition);
+}
+.agent-health-card:hover {
+ border-color: var(--border-hover);
+ box-shadow: 0 4px 16px rgba(0,0,0,0.08);
+}
+.agent-health-avatar {
+ width: 48px;
+ height: 48px;
+ border-radius: var(--radius);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 22px;
+ flex-shrink: 0;
+ background: var(--accent-glow);
+}
+.agent-health-info { flex: 1; }
+.agent-health-name {
+ font-size: 15px;
+ font-weight: 700;
+}
+.agent-health-status {
+ font-size: 12px;
+ margin-top: 2px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.agent-health-stats {
+ display: flex;
+ gap: 16px;
+ margin-top: 8px;
+}
+.agent-health-stat {
+ text-align: center;
+}
+.agent-health-stat-value {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+.agent-health-stat-label {
+ font-size: 10px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+/* Smart router */
+.router-suggestion {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 16px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ margin-bottom: 8px;
+ transition: var(--transition);
+}
+.router-suggestion:hover {
+ border-color: var(--accent);
+ background: var(--accent-glow);
+}
+.router-suggestion-agent {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--accent-light);
+}
+.router-suggestion-reason {
+ font-size: 12px;
+ color: var(--text-secondary);
+ flex: 1;
+}
+
+/* Learning analytics chart container */
+.chart-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 20px;
+}
+.chart-card-title {
+ font-size: 14px;
+ font-weight: 600;
+ margin-bottom: 16px;
+ color: var(--text-secondary);
+}
+
+/* Priority badges */
+.kanban-priority {
+ display: inline-flex; align-items: center;
+ padding: 1px 7px; border-radius: 10px;
+ font-size: 9px; font-weight: 700; text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+.priority-high { background: var(--red-dim); color: var(--red); }
+.priority-medium { background: var(--yellow-dim); color: var(--yellow); }
+.priority-low { background: var(--green-dim); color: var(--green); }
+
+/* Agent health dot */
+.agent-dot {
+ width: 8px; height: 8px; border-radius: 50%; display: inline-block;
+}
+.agent-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
+.agent-dot.warning { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
+.agent-dot.offline { background: var(--red); box-shadow: 0 0 6px var(--red); }
+
+/* Responsive additions */
+@media (max-width: 1100px) {
+ .kanban-column { min-width: 220px; }
+}
+@media (max-width: 600px) {
+ .kanban-board { flex-direction: column; }
+ .kanban-column { max-width: 100%; min-width: 0; max-height: 300px; }
+ .agent-health-stats { flex-wrap: wrap; gap: 8px; }
+}
+
+/* Session replay */
+.session-message {
+ padding: 12px 16px;
+ border-radius: var(--radius-lg);
+ font-size: 13px;
+ line-height: 1.5;
+ transition: var(--transition);
+}
+.session-message-user {
+ background: var(--accent-glow);
+ border: 1px solid var(--accent);
+ align-self: flex-end;
+ margin-left: 40px;
+}
+.session-message-assistant {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ align-self: flex-start;
+ margin-right: 40px;
+}
+.session-message-role {
+ font-size: 11px;
+ font-weight: 600;
+ margin-bottom: 4px;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+.session-message-content {
+ color: var(--text-primary);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.session-message-meta {
+ font-size: 10px;
+ color: var(--text-muted);
+ margin-top: 4px;
+ text-align: right;
+}
+
+/* Empty state */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 48px 24px;
+ text-align: center;
+ background: var(--bg-card);
+ border: 1px dashed var(--border);
+ border-radius: var(--radius-lg);
+}
+.empty-state-icon {
+ font-size: 36px;
+ margin-bottom: 12px;
+ opacity: 0.6;
+}
+.empty-state-title {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 4px;
+}
+.empty-state-desc {
+ font-size: 13px;
+ color: var(--text-muted);
+}
+
+/* Skeleton loader */
+.skeleton {
+ background: linear-gradient(90deg, var(--bg-card) 25%, var(--bg-card-hover) 50%, var(--bg-card) 75%);
+ background-size: 200% 100%;
+ animation: shimmer 1.5s infinite;
+ border-radius: var(--radius);
+}
+@keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
diff --git a/dashboard/utils.js b/dashboard/utils.js
index ba83422..393f11e 100644
--- a/dashboard/utils.js
+++ b/dashboard/utils.js
@@ -123,4 +123,11 @@ const PAGE_TITLES = {
settings: { title: 'Settings', breadcrumb: 'Configuration' },
'setup-wizard': { title: 'Setup Wizard', breadcrumb: 'Guided configuration' },
chat: { title: 'AI Chat', breadcrumb: 'Multi-agent terminal' },
+ kanban: { title: 'Kanban Board', breadcrumb: 'Multi-agent task management' },
+ goals: { title: 'Goals', breadcrumb: 'Project targets and progress' },
+ journal: { title: 'Journal', breadcrumb: 'Daily entries and notes' },
+ 'agent-health': { title: 'Agent Health', breadcrumb: 'Real-time agent monitoring' },
+ 'smart-router': { title: 'Smart Router', breadcrumb: 'Task routing intelligence' },
+ 'learning-analytics': { title: 'Learning Analytics', breadcrumb: 'Skill improvement tracking' },
+ 'session-replay': { title: 'Session Replay', breadcrumb: 'Conversation history playback' },
};
diff --git a/data/goals.json b/data/goals.json
new file mode 100644
index 0000000..3f7702f
--- /dev/null
+++ b/data/goals.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "c1c992ef",
+ "title": "Test goal",
+ "description": "",
+ "category": "development",
+ "target_date": "",
+ "status": "active",
+ "progress": 0,
+ "created": "2026-06-05T09:50:30.334519+00:00",
+ "updated": "2026-06-05T09:50:30.334541+00:00"
+ }
+]
\ No newline at end of file
diff --git a/data/kanban/0f822987.json b/data/kanban/0f822987.json
new file mode 100644
index 0000000..611d8f1
--- /dev/null
+++ b/data/kanban/0f822987.json
@@ -0,0 +1,12 @@
+{
+ "id": "0f822987",
+ "title": "Fix login bug",
+ "body": "The login page has a race condition",
+ "status": "todo",
+ "priority": "high",
+ "assignee": "opencode",
+ "comments": [],
+ "links": [],
+ "created": "2026-06-05T09:52:18.236452+00:00",
+ "updated": "2026-06-05T09:52:18.236473+00:00"
+}
\ No newline at end of file
diff --git a/data/kanban/8893ad14.json b/data/kanban/8893ad14.json
new file mode 100644
index 0000000..18adcf7
--- /dev/null
+++ b/data/kanban/8893ad14.json
@@ -0,0 +1,15 @@
+{
+ "id": "8893ad14",
+ "title": "Test kanban task",
+ "body": "",
+ "status": "blocked",
+ "priority": "medium",
+ "assignee": "",
+ "comments": [],
+ "links": [],
+ "created": "2026-06-05T09:50:30.293643+00:00",
+ "updated": "2026-06-05T09:52:18.274595+00:00",
+ "summary": "Test done",
+ "completed_at": "2026-06-05T09:50:46.473612+00:00",
+ "block_reason": "Waiting for API review"
+}
\ No newline at end of file
diff --git a/server.py b/server.py
index fbfc6f4..a3da146 100644
--- a/server.py
+++ b/server.py
@@ -631,6 +631,585 @@ def chat(req: ChatRequest):
def get_chat_history():
return load_chat_history()
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# v0.2.0 โ New Feature Endpoints
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+# โโโ Models โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+class KanbanTaskCreate(BaseModel):
+ title: str
+ body: str = ""
+ status: str = "triage"
+ priority: str = "medium"
+ assignee: str = ""
+
+class KanbanTaskUpdate(BaseModel):
+ title: Optional[str] = None
+ body: Optional[str] = None
+ status: Optional[str] = None
+ priority: Optional[str] = None
+ assignee: Optional[str] = None
+
+class KanbanComplete(BaseModel):
+ summary: str = ""
+
+class KanbanBlock(BaseModel):
+ reason: str = ""
+
+class KanbanCommentCreate(BaseModel):
+ message: str
+
+class KanbanLinkCreate(BaseModel):
+ parent_id: str
+ child_id: str
+
+class GoalCreate(BaseModel):
+ title: str
+ description: str = ""
+ category: str = "general"
+ target_date: str = ""
+
+class GoalUpdate(BaseModel):
+ title: Optional[str] = None
+ description: Optional[str] = None
+ category: Optional[str] = None
+ target_date: Optional[str] = None
+ progress: Optional[int] = None
+ status: Optional[str] = None
+
+class JournalSave(BaseModel):
+ content: str
+
+class RouterSuggest(BaseModel):
+ task: str
+
+class RouterRoute(BaseModel):
+ task: str
+ agent: str
+
+# โโโ Data Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+KANBAN_DIR = BASE_DIR / "data" / "kanban"
+GOALS_FILE = BASE_DIR / "data" / "goals.json"
+JOURNAL_DIR = BASE_DIR / "brain" / "journal"
+
+def ensure_dir(d: Path):
+ d.mkdir(parents=True, exist_ok=True)
+
+def load_kanban_tasks():
+ ensure_dir(KANBAN_DIR)
+ tasks = []
+ for f in sorted(KANBAN_DIR.glob("*.json")):
+ tasks.append(json.loads(f.read_text()))
+ return tasks
+
+def save_kanban_task(task: dict):
+ ensure_dir(KANBAN_DIR)
+ (KANBAN_DIR / f"{task['id']}.json").write_text(json.dumps(task, indent=2))
+
+def load_goals():
+ if GOALS_FILE.exists():
+ return json.loads(GOALS_FILE.read_text())
+ return []
+
+def save_goals(goals: list):
+ GOALS_FILE.write_text(json.dumps(goals, indent=2))
+
+# โโโ Routes: Kanban Board (13 endpoints) โโโโโโโโโโโโโโโโโโโโโโโโ
+
+@app.get("/api/kanban/board")
+def kanban_board(status: Optional[str] = None):
+ try:
+ tasks = load_kanban_tasks()
+ if status:
+ tasks = [t for t in tasks if t.get("status") == status]
+ columns = {"triage": [], "todo": [], "ready": [], "in_progress": [], "blocked": [], "done": []}
+ for t in tasks:
+ s = t.get("status", "triage")
+ if s in columns:
+ columns[s].append(t)
+ return {"columns": columns, "total": len(tasks)}
+ except Exception as e:
+ return {"error": str(e), "columns": {}, "total": 0}
+
+@app.get("/api/kanban/tasks/{task_id}")
+def kanban_get_task(task_id: str):
+ path = KANBAN_DIR / f"{task_id}.json"
+ if not path.exists():
+ raise HTTPException(404, "Task not found")
+ return json.loads(path.read_text())
+
+@app.post("/api/kanban/tasks")
+def kanban_create_task(data: KanbanTaskCreate):
+ try:
+ task = {
+ "id": str(uuid.uuid4())[:8],
+ "title": data.title,
+ "body": data.body,
+ "status": data.status,
+ "priority": data.priority,
+ "assignee": data.assignee,
+ "comments": [],
+ "links": [],
+ "created": get_timestamp(),
+ "updated": get_timestamp(),
+ }
+ save_kanban_task(task)
+ append_audit({"action": "kanban_task_created", "title": data.title})
+ return task
+ except Exception as e:
+ raise HTTPException(500, str(e))
+
+@app.patch("/api/kanban/tasks/{task_id}")
+def kanban_update_task(task_id: str, data: KanbanTaskUpdate):
+ path = KANBAN_DIR / f"{task_id}.json"
+ if not path.exists():
+ raise HTTPException(404, "Task not found")
+ task = json.loads(path.read_text())
+ for field in ["title", "body", "status", "priority", "assignee"]:
+ val = getattr(data, field, None)
+ if val is not None:
+ task[field] = val
+ task["updated"] = get_timestamp()
+ save_kanban_task(task)
+ append_audit({"action": "kanban_task_updated", "task_id": task_id})
+ return task
+
+@app.post("/api/kanban/tasks/{task_id}/complete")
+def kanban_complete_task(task_id: str, data: KanbanComplete):
+ path = KANBAN_DIR / f"{task_id}.json"
+ if not path.exists():
+ raise HTTPException(404, "Task not found")
+ task = json.loads(path.read_text())
+ task["status"] = "done"
+ task["summary"] = data.summary
+ task["completed_at"] = get_timestamp()
+ task["updated"] = get_timestamp()
+ save_kanban_task(task)
+ append_audit({"action": "kanban_task_completed", "task_id": task_id})
+ return task
+
+@app.post("/api/kanban/tasks/{task_id}/block")
+def kanban_block_task(task_id: str, data: KanbanBlock):
+ path = KANBAN_DIR / f"{task_id}.json"
+ if not path.exists():
+ raise HTTPException(404, "Task not found")
+ task = json.loads(path.read_text())
+ task["status"] = "blocked"
+ task["block_reason"] = data.reason
+ task["updated"] = get_timestamp()
+ save_kanban_task(task)
+ append_audit({"action": "kanban_task_blocked", "task_id": task_id})
+ return task
+
+@app.post("/api/kanban/tasks/{task_id}/unblock")
+def kanban_unblock_task(task_id: str):
+ path = KANBAN_DIR / f"{task_id}.json"
+ if not path.exists():
+ raise HTTPException(404, "Task not found")
+ task = json.loads(path.read_text())
+ task["status"] = "ready"
+ task["block_reason"] = ""
+ task["updated"] = get_timestamp()
+ save_kanban_task(task)
+ append_audit({"action": "kanban_task_unblocked", "task_id": task_id})
+ return task
+
+@app.post("/api/kanban/tasks/{task_id}/comments")
+def kanban_add_comment(task_id: str, data: KanbanCommentCreate):
+ path = KANBAN_DIR / f"{task_id}.json"
+ if not path.exists():
+ raise HTTPException(404, "Task not found")
+ task = json.loads(path.read_text())
+ comment = {
+ "id": str(uuid.uuid4())[:8],
+ "message": data.message,
+ "timestamp": get_timestamp(),
+ }
+ task.setdefault("comments", []).append(comment)
+ task["updated"] = get_timestamp()
+ save_kanban_task(task)
+ return task
+
+@app.post("/api/kanban/links")
+def kanban_add_link(data: KanbanLinkCreate):
+ for tid in [data.parent_id, data.child_id]:
+ path = KANBAN_DIR / f"{tid}.json"
+ if not path.exists():
+ raise HTTPException(404, f"Task {tid} not found")
+ t = json.loads(path.read_text())
+ t.setdefault("links", [])
+ link = {"parent": data.parent_id, "child": data.child_id}
+ if link not in t["links"]:
+ t["links"].append(link)
+ t["updated"] = get_timestamp()
+ save_kanban_task(t)
+ append_audit({"action": "kanban_link_added", "parent": data.parent_id, "child": data.child_id})
+ return {"status": "linked"}
+
+@app.delete("/api/kanban/links")
+def kanban_remove_link(parent_id: str = Query(...), child_id: str = Query(...)):
+ for tid in [parent_id, child_id]:
+ path = KANBAN_DIR / f"{tid}.json"
+ if path.exists():
+ t = json.loads(path.read_text())
+ t.setdefault("links", [])
+ t["links"] = [l for l in t["links"] if not (l.get("parent") == parent_id and l.get("child") == child_id)]
+ t["updated"] = get_timestamp()
+ save_kanban_task(t)
+ return {"status": "unlinked"}
+
+@app.post("/api/kanban/dispatch")
+def kanban_dispatch():
+ append_audit({"action": "kanban_dispatch_triggered"})
+ return {"status": "dispatch_triggered", "message": "Dispatcher notified"}
+
+@app.post("/api/kanban/tasks/{task_id}/specify")
+def kanban_specify_task(task_id: str):
+ path = KANBAN_DIR / f"{task_id}.json"
+ if not path.exists():
+ raise HTTPException(404, "Task not found")
+ task = json.loads(path.read_text())
+ if task.get("status") == "triage":
+ task["status"] = "todo"
+ task["updated"] = get_timestamp()
+ save_kanban_task(task)
+ return task
+
+@app.post("/api/kanban/tasks/{task_id}/decompose")
+def kanban_decompose_task(task_id: str):
+ path = KANBAN_DIR / f"{task_id}.json"
+ if not path.exists():
+ raise HTTPException(404, "Task not found")
+ task = json.loads(path.read_text())
+ children = []
+ for i, subtask in enumerate(task.get("body", "").split("\n")):
+ subtask = subtask.strip().lstrip("-* ")
+ if subtask:
+ child = {
+ "id": str(uuid.uuid4())[:8],
+ "title": subtask[:80],
+ "body": subtask,
+ "status": "todo",
+ "priority": task.get("priority", "medium"),
+ "assignee": "",
+ "comments": [],
+ "links": [{"parent": task_id, "child": ""}],
+ "created": get_timestamp(),
+ "updated": get_timestamp(),
+ }
+ child["links"][0]["child"] = child["id"]
+ save_kanban_task(child)
+ children.append(child)
+ return {"parent": task_id, "children": children}
+
+# โโโ Routes: Goals (4 endpoints) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+@app.get("/api/goals")
+def list_goals():
+ try:
+ return {"goals": load_goals()}
+ except Exception as e:
+ return {"goals": [], "error": str(e)}
+
+@app.post("/api/goals")
+def create_goal(data: GoalCreate):
+ try:
+ goals = load_goals()
+ goal = {
+ "id": str(uuid.uuid4())[:8],
+ "title": data.title,
+ "description": data.description,
+ "category": data.category,
+ "target_date": data.target_date,
+ "status": "active",
+ "progress": 0,
+ "created": get_timestamp(),
+ "updated": get_timestamp(),
+ }
+ goals.append(goal)
+ save_goals(goals)
+ # Auto-sync to brain/active-projects.md
+ active_path = BASE_DIR / "brain" / "active-projects.md"
+ if active_path.exists():
+ existing = active_path.read_text()
+ existing += f"\n- [{goal['title']}](goal:{goal['id']}) โ {goal['description'][:80]}\n"
+ active_path.write_text(existing)
+ append_audit({"action": "goal_created", "title": data.title})
+ return goal
+ except Exception as e:
+ raise HTTPException(500, str(e))
+
+@app.put("/api/goals/{goal_id}")
+def update_goal(goal_id: str, data: GoalUpdate):
+ try:
+ goals = load_goals()
+ for g in goals:
+ if g["id"] == goal_id:
+ for field in ["title", "description", "category", "target_date", "progress", "status"]:
+ val = getattr(data, field, None)
+ if val is not None:
+ g[field] = val
+ g["updated"] = get_timestamp()
+ save_goals(goals)
+ return g
+ raise HTTPException(404, "Goal not found")
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(500, str(e))
+
+@app.delete("/api/goals/{goal_id}")
+def delete_goal(goal_id: str):
+ try:
+ goals = load_goals()
+ goals = [g for g in goals if g["id"] != goal_id]
+ save_goals(goals)
+ append_audit({"action": "goal_deleted", "goal_id": goal_id})
+ return {"status": "deleted"}
+ except Exception as e:
+ raise HTTPException(500, str(e))
+
+# โโโ Routes: Journal (4 endpoints) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+@app.get("/api/journal/entries")
+def list_journal_entries():
+ try:
+ ensure_dir(JOURNAL_DIR)
+ entries = []
+ for f in sorted(JOURNAL_DIR.glob("*.md"), reverse=True):
+ entries.append({
+ "date": f.stem,
+ "preview": f.read_text()[:200],
+ "modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
+ })
+ return {"entries": entries}
+ except Exception as e:
+ return {"entries": [], "error": str(e)}
+
+@app.get("/api/journal/entries/{entry_date}")
+def get_journal_entry(entry_date: str):
+ try:
+ path = JOURNAL_DIR / f"{entry_date}.md"
+ ensure_dir(JOURNAL_DIR)
+ content = path.read_text() if path.exists() else ""
+ return {"date": entry_date, "content": content}
+ except Exception as e:
+ return {"date": entry_date, "content": "", "error": str(e)}
+
+@app.put("/api/journal/entries/{entry_date}")
+def save_journal_entry(entry_date: str, data: JournalSave):
+ try:
+ ensure_dir(JOURNAL_DIR)
+ path = JOURNAL_DIR / f"{entry_date}.md"
+ path.write_text(data.content)
+ append_audit({"action": "journal_saved", "date": entry_date})
+ return {"status": "saved", "date": entry_date}
+ except Exception as e:
+ raise HTTPException(500, str(e))
+
+@app.get("/api/journal/search")
+def search_journal(q: str = Query("")):
+ try:
+ ensure_dir(JOURNAL_DIR)
+ if not q:
+ return {"results": []}
+ results = []
+ for f in JOURNAL_DIR.glob("*.md"):
+ content = f.read_text()
+ if q.lower() in content.lower():
+ results.append({"date": f.stem, "preview": content[:200]})
+ return {"results": results, "query": q}
+ except Exception as e:
+ return {"results": [], "error": str(e)}
+
+# โโโ Routes: Agent Health (3 endpoints) โโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+@app.get("/api/agents/health")
+def get_agent_health():
+ try:
+ agents = []
+ for name in ["opencode", "hermes", "gemini"]:
+ info = check_agent(name)
+ info["uptime"] = 0
+ info["success_rate"] = 100
+ info["last_seen"] = get_timestamp()
+ agents.append(info)
+ return {"agents": agents, "updated": get_timestamp()}
+ except Exception as e:
+ return {"agents": [], "error": str(e), "updated": get_timestamp()}
+
+@app.get("/api/agents/{name}/stats")
+def get_agent_stats(name: str):
+ try:
+ if name not in ["opencode", "hermes", "gemini"]:
+ raise HTTPException(400, "Invalid agent")
+ info = check_agent(name)
+ return {
+ "name": name,
+ "status": info["status"],
+ "total_runs": 0,
+ "successful_runs": 0,
+ "failed_runs": 0,
+ "avg_response_time": 0,
+ "last_seen": get_timestamp(),
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(500, str(e))
+
+@app.post("/api/agents/health/refresh")
+def refresh_agent_health():
+ try:
+ agents = []
+ for name in ["opencode", "hermes", "gemini"]:
+ info = check_agent(name)
+ agents.append(info)
+ append_audit({"action": "agent_health_refreshed"})
+ return {"agents": agents, "updated": get_timestamp()}
+ except Exception as e:
+ return {"agents": [], "error": str(e)}
+
+# โโโ Routes: Smart Router (2 endpoints) โโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ROUTER_RULES = {
+ "opencode": ["code", "devops", "deploy", "git", "file", "terraform", "docker", "test", "build", "infra", "script"],
+ "hermes": ["memory", "schedule", "channel", "skill", "cron", "reminder", "brain", "plugin", "backup"],
+ "gemini": ["research", "analyze", "search", "compare", "explain", "study", "learn", "document", "report", "review"],
+}
+
+@app.post("/api/router/suggest")
+def router_suggest(data: RouterSuggest):
+ try:
+ task_lower = data.task.lower()
+ scores = {}
+ for agent, keywords in ROUTER_RULES.items():
+ scores[agent] = sum(1 for k in keywords if k in task_lower)
+ best = max(scores, key=scores.get)
+ confidence = "high" if scores[best] >= 2 else "medium" if scores[best] == 1 else "low"
+ return {
+ "suggested_agent": best,
+ "confidence": confidence,
+ "scores": scores,
+ "task": data.task,
+ }
+ except Exception as e:
+ return {"suggested_agent": "opencode", "confidence": "low", "error": str(e)}
+
+@app.post("/api/router/route")
+def router_route(data: RouterRoute):
+ try:
+ agent = data.agent.lower()
+ if agent not in ["opencode", "hermes", "gemini"]:
+ return {"status": "error", "message": f"Invalid agent: {agent}"}
+ append_audit({"action": "task_routed", "agent": agent, "task_preview": data.task[:50]})
+ return {
+ "status": "routed",
+ "agent": agent,
+ "task": data.task,
+ "message": f"Task routed to {agent}",
+ }
+ except Exception as e:
+ return {"status": "error", "message": str(e)}
+
+# โโโ Routes: Learning Analytics (2 endpoints) โโโโโโโโโโโโโโโโโโโ
+
+@app.get("/api/analytics/skills")
+def get_skill_analytics():
+ try:
+ skills_dir = BASE_DIR / "skills"
+ analytics = []
+ for d in sorted(skills_dir.iterdir()):
+ if d.is_dir() and not d.name.startswith("_"):
+ eval_path = d / "eval.json"
+ score_path = d / "score-history.json"
+ scores = json.loads(score_path.read_text()) if score_path.exists() else []
+ eval_data = json.loads(eval_path.read_text()) if eval_path.exists() else {}
+ avg_score = sum(s.get("score", 0) for s in scores) / len(scores) if scores else 0
+ analytics.append({
+ "name": d.name,
+ "total_runs": len(scores),
+ "avg_score": round(avg_score, 1),
+ "last_score": scores[-1].get("score", 0) if scores else 0,
+ "trend": "up" if len(scores) >= 2 and scores[-1].get("score", 0) > scores[-2].get("score", 0) else "down" if len(scores) >= 2 else "stable",
+ })
+ return {"skills": sorted(analytics, key=lambda x: x["total_runs"], reverse=True)}
+ except Exception as e:
+ return {"skills": [], "error": str(e)}
+
+@app.get("/api/analytics/trends")
+def get_trend_analytics():
+ try:
+ skills_dir = BASE_DIR / "skills"
+ trends = []
+ for d in sorted(skills_dir.iterdir()):
+ if d.is_dir() and not d.name.startswith("_"):
+ score_path = d / "score-history.json"
+ scores = json.loads(score_path.read_text()) if score_path.exists() else []
+ if scores:
+ trends.append({
+ "name": d.name,
+ "scores": [s.get("score", 0) for s in scores[-10:]],
+ "labels": [s.get("date", "") for s in scores[-10:]],
+ })
+ return {"trends": trends}
+ except Exception as e:
+ return {"trends": [], "error": str(e)}
+
+# โโโ Routes: Session Replay (2 endpoints) โโโโโโโโโโโโโโโโโโโโโโโ
+
+@app.get("/api/sessions/list")
+def list_sessions():
+ try:
+ sessions = []
+ sessions_dir = Path.home() / ".local" / "share" / "opencode"
+ log_dir = sessions_dir / "log"
+ if log_dir.exists():
+ for f in sorted(log_dir.glob("*.log"), reverse=True)[:20]:
+ sessions.append({
+ "id": f.stem,
+ "name": f.stem,
+ "size": f.stat().st_size,
+ "modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
+ "source": "opencode",
+ })
+ hermes_sessions = Path.home() / ".hermes" / "sessions.json"
+ if hermes_sessions.exists():
+ sessions.append({
+ "id": "hermes-sessions",
+ "name": "Hermes Session Archive",
+ "size": hermes_sessions.stat().st_size,
+ "modified": datetime.fromtimestamp(hermes_sessions.stat().st_mtime).isoformat(),
+ "source": "hermes",
+ })
+ return {"sessions": sessions}
+ except Exception as e:
+ return {"sessions": [], "error": str(e)}
+
+@app.get("/api/sessions/{session_id}/replay")
+def get_session_replay(session_id: str):
+ try:
+ sessions_dir = Path.home() / ".local" / "share" / "opencode"
+ log_file = sessions_dir / "log" / f"{session_id}.log"
+ if log_file.exists():
+ content = log_file.read_text()
+ lines = content.split("\n")
+ messages = []
+ for line in lines:
+ if "user:" in line.lower() or "assistant:" in line.lower():
+ messages.append(line)
+ return {
+ "session_id": session_id,
+ "lines": len(lines),
+ "messages": messages[:100],
+ "content": content[:5000],
+ }
+ return {"session_id": session_id, "messages": [], "content": "Session log not found"}
+ except Exception as e:
+ return {"session_id": session_id, "messages": [], "error": str(e)}
+
# โโโ Routes: Dashboard Static Files โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
dashboard_dir = BASE_DIR / "dashboard"