From 89044e8c3396ad477538082add6cf36bb0addff5 Mon Sep 17 00:00:00 2001 From: Aryan Tejani <146083392+AryanTejani@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:31:41 +0530 Subject: [PATCH] feat(design): skill health dashboard mockup (#518) * feat(Design): skill health dashboard mockup * fix(comments): code according to comments --- commands/skill-health.md | 51 +++ scripts/lib/skill-evolution/dashboard.js | 401 ++++++++++++++++++++ scripts/lib/skill-evolution/health.js | 5 +- scripts/lib/skill-evolution/index.js | 3 + scripts/skills-health.js | 23 +- tests/lib/skill-dashboard.test.js | 454 +++++++++++++++++++++++ 6 files changed, 934 insertions(+), 3 deletions(-) create mode 100644 commands/skill-health.md create mode 100644 scripts/lib/skill-evolution/dashboard.js create mode 100644 tests/lib/skill-dashboard.test.js diff --git a/commands/skill-health.md b/commands/skill-health.md new file mode 100644 index 00000000..b9cb64f5 --- /dev/null +++ b/commands/skill-health.md @@ -0,0 +1,51 @@ +--- +name: skill-health +description: Show skill portfolio health dashboard with charts and analytics +command: true +--- + +# Skill Health Dashboard + +Shows a comprehensive health dashboard for all skills in the portfolio with success rate sparklines, failure pattern clustering, pending amendments, and version history. + +## Implementation + +Run the skill health CLI in dashboard mode: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/skills-health.js" --dashboard +``` + +For a specific panel only: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/skills-health.js" --dashboard --panel failures +``` + +For machine-readable output: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/skills-health.js" --dashboard --json +``` + +## Usage + +``` +/skill-health # Full dashboard view +/skill-health --panel failures # Only failure clustering panel +/skill-health --json # Machine-readable JSON output +``` + +## What to Do + +1. Run the skills-health.js script with --dashboard flag +2. Display the output to the user +3. If any skills are declining, highlight them and suggest running /evolve +4. If there are pending amendments, suggest reviewing them + +## Panels + +- **Success Rate (30d)** — Sparkline charts showing daily success rates per skill +- **Failure Patterns** — Clustered failure reasons with horizontal bar chart +- **Pending Amendments** — Amendment proposals awaiting review +- **Version History** — Timeline of version snapshots per skill diff --git a/scripts/lib/skill-evolution/dashboard.js b/scripts/lib/skill-evolution/dashboard.js new file mode 100644 index 00000000..7049707b --- /dev/null +++ b/scripts/lib/skill-evolution/dashboard.js @@ -0,0 +1,401 @@ +'use strict'; + +const health = require('./health'); +const tracker = require('./tracker'); +const versioning = require('./versioning'); + +const DAY_IN_MS = 24 * 60 * 60 * 1000; +const SPARKLINE_CHARS = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588'; +const EMPTY_BLOCK = '\u2591'; +const FILL_BLOCK = '\u2588'; +const DEFAULT_PANEL_WIDTH = 64; +const VALID_PANELS = new Set(['success-rate', 'failures', 'amendments', 'versions']); + +function sparkline(values) { + if (!Array.isArray(values) || values.length === 0) { + return ''; + } + + return values.map(value => { + if (value === null || value === undefined) { + return EMPTY_BLOCK; + } + + const clamped = Math.max(0, Math.min(1, value)); + const index = Math.min(Math.round(clamped * (SPARKLINE_CHARS.length - 1)), SPARKLINE_CHARS.length - 1); + return SPARKLINE_CHARS[index]; + }).join(''); +} + +function horizontalBar(value, max, width) { + if (max <= 0 || width <= 0) { + return EMPTY_BLOCK.repeat(width || 0); + } + + const filled = Math.round((Math.min(value, max) / max) * width); + const empty = width - filled; + return FILL_BLOCK.repeat(filled) + EMPTY_BLOCK.repeat(empty); +} + +function panelBox(title, lines, width) { + const innerWidth = width || DEFAULT_PANEL_WIDTH; + const output = []; + output.push('\u250C\u2500 ' + title + ' ' + '\u2500'.repeat(Math.max(0, innerWidth - title.length - 4)) + '\u2510'); + + for (const line of lines) { + const truncated = line.length > innerWidth - 2 + ? line.slice(0, innerWidth - 2) + : line; + output.push('\u2502 ' + truncated.padEnd(innerWidth - 2) + '\u2502'); + } + + output.push('\u2514' + '\u2500'.repeat(innerWidth - 1) + '\u2518'); + return output.join('\n'); +} + +function bucketByDay(records, nowMs, days) { + const buckets = []; + for (let i = days - 1; i >= 0; i -= 1) { + const dayEnd = nowMs - (i * DAY_IN_MS); + const dayStart = dayEnd - DAY_IN_MS; + const dateStr = new Date(dayEnd).toISOString().slice(0, 10); + buckets.push({ date: dateStr, start: dayStart, end: dayEnd, records: [] }); + } + + for (const record of records) { + const recordMs = Date.parse(record.recorded_at); + if (Number.isNaN(recordMs)) { + continue; + } + + for (const bucket of buckets) { + if (recordMs > bucket.start && recordMs <= bucket.end) { + bucket.records.push(record); + break; + } + } + } + + return buckets.map(bucket => ({ + date: bucket.date, + rate: bucket.records.length > 0 + ? health.calculateSuccessRate(bucket.records) + : null, + runs: bucket.records.length, + })); +} + +function getTrendArrow(successRate7d, successRate30d) { + if (successRate7d === null || successRate30d === null) { + return '\u2192'; + } + + const delta = successRate7d - successRate30d; + if (delta >= 0.1) { + return '\u2197'; + } + + if (delta <= -0.1) { + return '\u2198'; + } + + return '\u2192'; +} + +function formatPercent(value) { + if (value === null) { + return 'n/a'; + } + + return `${Math.round(value * 100)}%`; +} + +function groupRecordsBySkill(records) { + return records.reduce((grouped, record) => { + const skillId = record.skill_id; + if (!grouped.has(skillId)) { + grouped.set(skillId, []); + } + + grouped.get(skillId).push(record); + return grouped; + }, new Map()); +} + +function renderSuccessRatePanel(records, skills, options = {}) { + const nowMs = Date.parse(options.now || new Date().toISOString()); + const days = options.days || 30; + const width = options.width || DEFAULT_PANEL_WIDTH; + const recordsBySkill = groupRecordsBySkill(records); + + const skillData = []; + const skillIds = Array.from(new Set([ + ...Array.from(recordsBySkill.keys()), + ...skills.map(s => s.skill_id), + ])).sort(); + + for (const skillId of skillIds) { + const skillRecords = recordsBySkill.get(skillId) || []; + const dailyRates = bucketByDay(skillRecords, nowMs, days); + const rateValues = dailyRates.map(b => b.rate); + const records7d = health.filterRecordsWithinDays(skillRecords, nowMs, 7); + const records30d = health.filterRecordsWithinDays(skillRecords, nowMs, 30); + const current7d = health.calculateSuccessRate(records7d); + const current30d = health.calculateSuccessRate(records30d); + const trend = getTrendArrow(current7d, current30d); + + skillData.push({ + skill_id: skillId, + daily_rates: dailyRates, + sparkline: sparkline(rateValues), + current_7d: current7d, + trend, + }); + } + + const lines = []; + if (skillData.length === 0) { + lines.push('No skill execution data available.'); + } else { + for (const skill of skillData) { + const nameCol = skill.skill_id.slice(0, 14).padEnd(14); + const sparkCol = skill.sparkline.slice(0, 30); + const rateCol = formatPercent(skill.current_7d).padStart(5); + lines.push(`${nameCol} ${sparkCol} ${rateCol} ${skill.trend}`); + } + } + + return { + text: panelBox('Success Rate (30d)', lines, width), + data: { skills: skillData }, + }; +} + +function renderFailureClusterPanel(records, options = {}) { + const width = options.width || DEFAULT_PANEL_WIDTH; + const failures = records.filter(r => r.outcome === 'failure'); + + const clusterMap = new Map(); + for (const record of failures) { + const reason = (record.failure_reason || 'unknown').toLowerCase().trim(); + if (!clusterMap.has(reason)) { + clusterMap.set(reason, { count: 0, skill_ids: new Set() }); + } + + const cluster = clusterMap.get(reason); + cluster.count += 1; + cluster.skill_ids.add(record.skill_id); + } + + const clusters = Array.from(clusterMap.entries()) + .map(([pattern, data]) => ({ + pattern, + count: data.count, + skill_ids: Array.from(data.skill_ids).sort(), + percentage: failures.length > 0 + ? Math.round((data.count / failures.length) * 100) + : 0, + })) + .sort((a, b) => b.count - a.count || a.pattern.localeCompare(b.pattern)); + + const maxCount = clusters.length > 0 ? clusters[0].count : 0; + const lines = []; + + if (clusters.length === 0) { + lines.push('No failure patterns detected.'); + } else { + for (const cluster of clusters) { + const label = cluster.pattern.slice(0, 20).padEnd(20); + const bar = horizontalBar(cluster.count, maxCount, 16); + const skillCount = cluster.skill_ids.length; + const suffix = skillCount === 1 ? 'skill' : 'skills'; + lines.push(`${label} ${bar} ${String(cluster.count).padStart(3)} (${skillCount} ${suffix})`); + } + } + + return { + text: panelBox('Failure Patterns', lines, width), + data: { clusters, total_failures: failures.length }, + }; +} + +function renderAmendmentPanel(skillsById, options = {}) { + const width = options.width || DEFAULT_PANEL_WIDTH; + const amendments = []; + + for (const [skillId, skill] of skillsById) { + if (!skill.skill_dir) { + continue; + } + + const log = versioning.getEvolutionLog(skill.skill_dir, 'amendments'); + for (const entry of log) { + const status = typeof entry.status === 'string' ? entry.status : null; + const isPending = status + ? health.PENDING_AMENDMENT_STATUSES.has(status) + : entry.event === 'proposal'; + + if (isPending) { + amendments.push({ + skill_id: skillId, + event: entry.event || 'proposal', + status: status || 'pending', + created_at: entry.created_at || null, + }); + } + } + } + + amendments.sort((a, b) => { + const timeA = a.created_at ? Date.parse(a.created_at) : 0; + const timeB = b.created_at ? Date.parse(b.created_at) : 0; + return timeB - timeA; + }); + + const lines = []; + if (amendments.length === 0) { + lines.push('No pending amendments.'); + } else { + for (const amendment of amendments) { + const name = amendment.skill_id.slice(0, 14).padEnd(14); + const event = amendment.event.padEnd(10); + const status = amendment.status.padEnd(10); + const time = amendment.created_at ? amendment.created_at.slice(0, 19) : '-'; + lines.push(`${name} ${event} ${status} ${time}`); + } + + lines.push(''); + lines.push(`${amendments.length} amendment${amendments.length === 1 ? '' : 's'} pending review`); + } + + return { + text: panelBox('Pending Amendments', lines, width), + data: { amendments, total: amendments.length }, + }; +} + +function renderVersionTimelinePanel(skillsById, options = {}) { + const width = options.width || DEFAULT_PANEL_WIDTH; + const skillVersions = []; + + for (const [skillId, skill] of skillsById) { + if (!skill.skill_dir) { + continue; + } + + const versions = versioning.listVersions(skill.skill_dir); + if (versions.length === 0) { + continue; + } + + const amendmentLog = versioning.getEvolutionLog(skill.skill_dir, 'amendments'); + const reasonByVersion = new Map(); + for (const entry of amendmentLog) { + if (entry.version && entry.reason) { + reasonByVersion.set(entry.version, entry.reason); + } + } + + skillVersions.push({ + skill_id: skillId, + versions: versions.map(v => ({ + version: v.version, + created_at: v.created_at, + reason: reasonByVersion.get(v.version) || null, + })), + }); + } + + skillVersions.sort((a, b) => a.skill_id.localeCompare(b.skill_id)); + + const lines = []; + if (skillVersions.length === 0) { + lines.push('No version history available.'); + } else { + for (const skill of skillVersions) { + lines.push(skill.skill_id); + for (const version of skill.versions) { + const date = version.created_at ? version.created_at.slice(0, 10) : '-'; + const reason = version.reason || '-'; + lines.push(` v${version.version} \u2500\u2500 ${date} \u2500\u2500 ${reason}`); + } + } + } + + return { + text: panelBox('Version History', lines, width), + data: { skills: skillVersions }, + }; +} + +function renderDashboard(options = {}) { + const now = options.now || new Date().toISOString(); + const nowMs = Date.parse(now); + if (Number.isNaN(nowMs)) { + throw new Error(`Invalid now timestamp: ${now}`); + } + + const dashboardOptions = { ...options, now }; + const records = tracker.readSkillExecutionRecords(dashboardOptions); + const skillsById = health.discoverSkills(dashboardOptions); + const report = health.collectSkillHealth(dashboardOptions); + const summary = health.summarizeHealthReport(report); + + const panelRenderers = { + 'success-rate': () => renderSuccessRatePanel(records, report.skills, dashboardOptions), + 'failures': () => renderFailureClusterPanel(records, dashboardOptions), + 'amendments': () => renderAmendmentPanel(skillsById, dashboardOptions), + 'versions': () => renderVersionTimelinePanel(skillsById, dashboardOptions), + }; + + const selectedPanel = options.panel || null; + if (selectedPanel && !VALID_PANELS.has(selectedPanel)) { + throw new Error(`Unknown panel: ${selectedPanel}. Valid panels: ${Array.from(VALID_PANELS).join(', ')}`); + } + + const panels = {}; + const textParts = []; + + const header = [ + 'ECC Skill Health Dashboard', + `Generated: ${now}`, + `Skills: ${summary.total_skills} total, ${summary.healthy_skills} healthy, ${summary.declining_skills} declining`, + '', + ]; + + textParts.push(header.join('\n')); + + if (selectedPanel) { + const result = panelRenderers[selectedPanel](); + panels[selectedPanel] = result.data; + textParts.push(result.text); + } else { + for (const [panelName, renderer] of Object.entries(panelRenderers)) { + const result = renderer(); + panels[panelName] = result.data; + textParts.push(result.text); + } + } + + const text = textParts.join('\n\n') + '\n'; + const data = { + generated_at: now, + summary, + panels, + }; + + return { text, data }; +} + +module.exports = { + VALID_PANELS, + bucketByDay, + horizontalBar, + panelBox, + renderAmendmentPanel, + renderDashboard, + renderFailureClusterPanel, + renderSuccessRatePanel, + renderVersionTimelinePanel, + sparkline, +}; diff --git a/scripts/lib/skill-evolution/health.js b/scripts/lib/skill-evolution/health.js index 7c14648f..309f1609 100644 --- a/scripts/lib/skill-evolution/health.js +++ b/scripts/lib/skill-evolution/health.js @@ -8,7 +8,7 @@ const tracker = require('./tracker'); const versioning = require('./versioning'); const DAY_IN_MS = 24 * 60 * 60 * 1000; -const PENDING_AMENDMENT_STATUSES = new Set(['pending', 'proposed', 'queued', 'open']); +const PENDING_AMENDMENT_STATUSES = Object.freeze(new Set(['pending', 'proposed', 'queued', 'open'])); function roundRate(value) { if (value === null) { @@ -253,8 +253,11 @@ function formatHealthReport(report, options = {}) { } module.exports = { + PENDING_AMENDMENT_STATUSES, + calculateSuccessRate, collectSkillHealth, discoverSkills, + filterRecordsWithinDays, formatHealthReport, summarizeHealthReport, }; diff --git a/scripts/lib/skill-evolution/index.js b/scripts/lib/skill-evolution/index.js index 0132083f..baed650c 100644 --- a/scripts/lib/skill-evolution/index.js +++ b/scripts/lib/skill-evolution/index.js @@ -4,14 +4,17 @@ const provenance = require('./provenance'); const versioning = require('./versioning'); const tracker = require('./tracker'); const health = require('./health'); +const dashboard = require('./dashboard'); module.exports = { ...provenance, ...versioning, ...tracker, ...health, + ...dashboard, provenance, versioning, tracker, health, + dashboard, }; diff --git a/scripts/skills-health.js b/scripts/skills-health.js index 3195ce23..4cea9ec5 100644 --- a/scripts/skills-health.js +++ b/scripts/skills-health.js @@ -2,6 +2,7 @@ 'use strict'; const { collectSkillHealth, formatHealthReport } = require('./lib/skill-evolution/health'); +const { renderDashboard } = require('./lib/skill-evolution/dashboard'); function showHelp() { console.log(` @@ -15,6 +16,8 @@ Options: --home Override home directory for learned/imported skill roots --runs-file Override skill run JSONL path --now Override current time for deterministic reports + --dashboard Show rich health dashboard with charts + --panel Show only a specific panel (success-rate, failures, amendments, versions) --warn-threshold Decline sensitivity threshold (default: 0.1) --help Show this help text `); @@ -87,6 +90,17 @@ function parseArgs(argv) { continue; } + if (arg === '--dashboard') { + options.dashboard = true; + continue; + } + + if (arg === '--panel') { + options.panel = requireValue(argv, index, '--panel'); + index += 1; + continue; + } + throw new Error(`Unknown argument: ${arg}`); } @@ -102,8 +116,13 @@ function main() { process.exit(0); } - const report = collectSkillHealth(options); - process.stdout.write(formatHealthReport(report, { json: options.json })); + if (options.dashboard || options.panel) { + const result = renderDashboard(options); + process.stdout.write(options.json ? `${JSON.stringify(result.data, null, 2)}\n` : result.text); + } else { + const report = collectSkillHealth(options); + process.stdout.write(formatHealthReport(report, { json: options.json })); + } } catch (error) { process.stderr.write(`Error: ${error.message}\n`); process.exit(1); diff --git a/tests/lib/skill-dashboard.test.js b/tests/lib/skill-dashboard.test.js new file mode 100644 index 00000000..b93473ef --- /dev/null +++ b/tests/lib/skill-dashboard.test.js @@ -0,0 +1,454 @@ +/** + * Tests for skill health dashboard. + * + * Run with: node tests/lib/skill-dashboard.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const dashboard = require('../../scripts/lib/skill-evolution/dashboard'); +const versioning = require('../../scripts/lib/skill-evolution/versioning'); +const provenance = require('../../scripts/lib/skill-evolution/provenance'); + +const HEALTH_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'skills-health.js'); + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (error) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function createTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function cleanupTempDir(dirPath) { + fs.rmSync(dirPath, { recursive: true, force: true }); +} + +function createSkill(skillRoot, name, content) { + const skillDir = path.join(skillRoot, name); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); + return skillDir; +} + +function appendJsonl(filePath, rows) { + const lines = rows.map(row => JSON.stringify(row)).join('\n'); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${lines}\n`); +} + +function runCli(args) { + return spawnSync(process.execPath, [HEALTH_SCRIPT, ...args], { + encoding: 'utf8', + }); +} + +function runTests() { + console.log('\n=== Testing skill dashboard ===\n'); + + let passed = 0; + let failed = 0; + + const repoRoot = createTempDir('skill-dashboard-repo-'); + const homeDir = createTempDir('skill-dashboard-home-'); + const skillsRoot = path.join(repoRoot, 'skills'); + const learnedRoot = path.join(homeDir, '.claude', 'skills', 'learned'); + const importedRoot = path.join(homeDir, '.claude', 'skills', 'imported'); + const runsFile = path.join(homeDir, '.claude', 'state', 'skill-runs.jsonl'); + const now = '2026-03-15T12:00:00.000Z'; + + fs.mkdirSync(skillsRoot, { recursive: true }); + fs.mkdirSync(learnedRoot, { recursive: true }); + fs.mkdirSync(importedRoot, { recursive: true }); + + try { + console.log('Chart primitives:'); + + if (test('sparkline maps float values to Unicode block characters', () => { + const result = dashboard.sparkline([1, 0.5, 0]); + assert.strictEqual(result.length, 3); + assert.strictEqual(result[0], '\u2588'); + assert.strictEqual(result[2], '\u2581'); + })) passed++; else failed++; + + if (test('sparkline returns empty string for empty array', () => { + assert.strictEqual(dashboard.sparkline([]), ''); + })) passed++; else failed++; + + if (test('sparkline renders null values as empty block', () => { + const result = dashboard.sparkline([null, 0.5, null]); + assert.strictEqual(result[0], '\u2591'); + assert.strictEqual(result[2], '\u2591'); + assert.strictEqual(result.length, 3); + })) passed++; else failed++; + + if (test('horizontalBar renders correct fill ratio', () => { + const result = dashboard.horizontalBar(5, 10, 10); + const filled = (result.match(/\u2588/g) || []).length; + const empty = (result.match(/\u2591/g) || []).length; + assert.strictEqual(filled, 5); + assert.strictEqual(empty, 5); + assert.strictEqual(result.length, 10); + })) passed++; else failed++; + + if (test('horizontalBar handles zero value', () => { + const result = dashboard.horizontalBar(0, 10, 10); + const filled = (result.match(/\u2588/g) || []).length; + assert.strictEqual(filled, 0); + assert.strictEqual(result.length, 10); + })) passed++; else failed++; + + if (test('panelBox renders box-drawing characters with title', () => { + const result = dashboard.panelBox('Test Panel', ['line one', 'line two'], 30); + assert.match(result, /\u250C/); + assert.match(result, /\u2510/); + assert.match(result, /\u2514/); + assert.match(result, /\u2518/); + assert.match(result, /Test Panel/); + assert.match(result, /line one/); + assert.match(result, /line two/); + })) passed++; else failed++; + + console.log('\nTime-series bucketing:'); + + if (test('bucketByDay groups records into daily bins', () => { + const nowMs = Date.parse(now); + const records = [ + { skill_id: 'alpha', outcome: 'success', recorded_at: '2026-03-15T10:00:00.000Z' }, + { skill_id: 'alpha', outcome: 'failure', recorded_at: '2026-03-15T08:00:00.000Z' }, + { skill_id: 'alpha', outcome: 'success', recorded_at: '2026-03-14T10:00:00.000Z' }, + ]; + + const buckets = dashboard.bucketByDay(records, nowMs, 3); + assert.strictEqual(buckets.length, 3); + const todayBucket = buckets[buckets.length - 1]; + assert.strictEqual(todayBucket.runs, 2); + assert.strictEqual(todayBucket.rate, 0.5); + })) passed++; else failed++; + + if (test('bucketByDay returns null rate for empty days', () => { + const nowMs = Date.parse(now); + const buckets = dashboard.bucketByDay([], nowMs, 5); + assert.strictEqual(buckets.length, 5); + for (const bucket of buckets) { + assert.strictEqual(bucket.rate, null); + assert.strictEqual(bucket.runs, 0); + } + })) passed++; else failed++; + + console.log('\nPanel renderers:'); + + const alphaSkillDir = createSkill(skillsRoot, 'alpha', '# Alpha\n'); + const betaSkillDir = createSkill(learnedRoot, 'beta', '# Beta\n'); + + versioning.createVersion(alphaSkillDir, { + timestamp: '2026-03-14T11:00:00.000Z', + author: 'observer', + reason: 'bootstrap', + }); + + fs.writeFileSync(path.join(alphaSkillDir, 'SKILL.md'), '# Alpha v2\n'); + versioning.createVersion(alphaSkillDir, { + timestamp: '2026-03-15T11:00:00.000Z', + author: 'observer', + reason: 'accepted-amendment', + }); + + versioning.createVersion(betaSkillDir, { + timestamp: '2026-03-14T11:00:00.000Z', + author: 'observer', + reason: 'bootstrap', + }); + + const { appendFile } = require('../../scripts/lib/utils'); + const alphaAmendmentsPath = path.join(alphaSkillDir, '.evolution', 'amendments.jsonl'); + appendFile(alphaAmendmentsPath, JSON.stringify({ + event: 'proposal', + status: 'pending', + created_at: '2026-03-15T07:00:00.000Z', + }) + '\n'); + + appendJsonl(runsFile, [ + { + skill_id: 'alpha', + skill_version: 'v2', + task_description: 'Success task', + outcome: 'success', + failure_reason: null, + tokens_used: 100, + duration_ms: 1000, + user_feedback: 'accepted', + recorded_at: '2026-03-14T10:00:00.000Z', + }, + { + skill_id: 'alpha', + skill_version: 'v2', + task_description: 'Failed task', + outcome: 'failure', + failure_reason: 'Regression', + tokens_used: 100, + duration_ms: 1000, + user_feedback: 'rejected', + recorded_at: '2026-03-13T10:00:00.000Z', + }, + { + skill_id: 'alpha', + skill_version: 'v1', + task_description: 'Older success', + outcome: 'success', + failure_reason: null, + tokens_used: 100, + duration_ms: 1000, + user_feedback: 'accepted', + recorded_at: '2026-02-20T10:00:00.000Z', + }, + { + skill_id: 'beta', + skill_version: 'v1', + task_description: 'Beta success', + outcome: 'success', + failure_reason: null, + tokens_used: 90, + duration_ms: 800, + user_feedback: 'accepted', + recorded_at: '2026-03-15T09:00:00.000Z', + }, + { + skill_id: 'beta', + skill_version: 'v1', + task_description: 'Beta failure', + outcome: 'failure', + failure_reason: 'Bad import', + tokens_used: 90, + duration_ms: 800, + user_feedback: 'corrected', + recorded_at: '2026-02-20T09:00:00.000Z', + }, + ]); + + const testRecords = [ + { skill_id: 'alpha', outcome: 'success', failure_reason: null, recorded_at: '2026-03-14T10:00:00.000Z' }, + { skill_id: 'alpha', outcome: 'failure', failure_reason: 'Regression', recorded_at: '2026-03-13T10:00:00.000Z' }, + { skill_id: 'alpha', outcome: 'success', failure_reason: null, recorded_at: '2026-02-20T10:00:00.000Z' }, + { skill_id: 'beta', outcome: 'success', failure_reason: null, recorded_at: '2026-03-15T09:00:00.000Z' }, + { skill_id: 'beta', outcome: 'failure', failure_reason: 'Bad import', recorded_at: '2026-02-20T09:00:00.000Z' }, + ]; + + if (test('renderSuccessRatePanel produces one row per skill with sparklines', () => { + const skills = [{ skill_id: 'alpha' }, { skill_id: 'beta' }]; + const result = dashboard.renderSuccessRatePanel(testRecords, skills, { now }); + + assert.ok(result.text.includes('Success Rate')); + assert.ok(result.data.skills.length >= 2); + + const alpha = result.data.skills.find(s => s.skill_id === 'alpha'); + assert.ok(alpha); + assert.ok(Array.isArray(alpha.daily_rates)); + assert.strictEqual(alpha.daily_rates.length, 30); + assert.ok(typeof alpha.sparkline === 'string'); + assert.ok(alpha.sparkline.length > 0); + })) passed++; else failed++; + + if (test('renderFailureClusterPanel groups failures by reason', () => { + const failureRecords = [ + { skill_id: 'alpha', outcome: 'failure', failure_reason: 'Regression' }, + { skill_id: 'alpha', outcome: 'failure', failure_reason: 'Regression' }, + { skill_id: 'beta', outcome: 'failure', failure_reason: 'Bad import' }, + { skill_id: 'alpha', outcome: 'success', failure_reason: null }, + ]; + + const result = dashboard.renderFailureClusterPanel(failureRecords); + assert.ok(result.text.includes('Failure Patterns')); + assert.strictEqual(result.data.clusters.length, 2); + assert.strictEqual(result.data.clusters[0].pattern, 'regression'); + assert.strictEqual(result.data.clusters[0].count, 2); + assert.strictEqual(result.data.total_failures, 3); + })) passed++; else failed++; + + if (test('renderAmendmentPanel lists pending amendments', () => { + const skillsById = new Map(); + skillsById.set('alpha', { skill_id: 'alpha', skill_dir: alphaSkillDir }); + + const result = dashboard.renderAmendmentPanel(skillsById); + assert.ok(result.text.includes('Pending Amendments')); + assert.ok(result.data.total >= 1); + assert.ok(result.data.amendments.some(a => a.skill_id === 'alpha')); + })) passed++; else failed++; + + if (test('renderVersionTimelinePanel shows version history', () => { + const skillsById = new Map(); + skillsById.set('alpha', { skill_id: 'alpha', skill_dir: alphaSkillDir }); + skillsById.set('beta', { skill_id: 'beta', skill_dir: betaSkillDir }); + + const result = dashboard.renderVersionTimelinePanel(skillsById); + assert.ok(result.text.includes('Version History')); + assert.ok(result.data.skills.length >= 1); + + const alphaVersions = result.data.skills.find(s => s.skill_id === 'alpha'); + assert.ok(alphaVersions); + assert.ok(alphaVersions.versions.length >= 2); + })) passed++; else failed++; + + console.log('\nFull dashboard:'); + + if (test('renderDashboard produces all four panels', () => { + const result = dashboard.renderDashboard({ + skillsRoot, + learnedRoot, + importedRoot, + homeDir, + runsFilePath: runsFile, + now, + warnThreshold: 0.1, + }); + + assert.ok(result.text.includes('ECC Skill Health Dashboard')); + assert.ok(result.text.includes('Success Rate')); + assert.ok(result.text.includes('Failure Patterns')); + assert.ok(result.text.includes('Pending Amendments')); + assert.ok(result.text.includes('Version History')); + assert.ok(result.data.generated_at === now); + assert.ok(result.data.summary); + assert.ok(result.data.panels['success-rate']); + assert.ok(result.data.panels['failures']); + assert.ok(result.data.panels['amendments']); + assert.ok(result.data.panels['versions']); + })) passed++; else failed++; + + if (test('renderDashboard supports single panel selection', () => { + const result = dashboard.renderDashboard({ + skillsRoot, + learnedRoot, + importedRoot, + homeDir, + runsFilePath: runsFile, + now, + panel: 'failures', + }); + + assert.ok(result.text.includes('Failure Patterns')); + assert.ok(!result.text.includes('Version History')); + assert.ok(result.data.panels['failures']); + assert.ok(!result.data.panels['versions']); + })) passed++; else failed++; + + if (test('renderDashboard rejects unknown panel names', () => { + assert.throws(() => { + dashboard.renderDashboard({ + skillsRoot, + learnedRoot, + importedRoot, + homeDir, + runsFilePath: runsFile, + now, + panel: 'nonexistent', + }); + }, /Unknown panel/); + })) passed++; else failed++; + + console.log('\nCLI integration:'); + + if (test('CLI --dashboard --json returns valid JSON with all panels', () => { + const result = runCli([ + '--dashboard', + '--json', + '--skills-root', skillsRoot, + '--learned-root', learnedRoot, + '--imported-root', importedRoot, + '--home', homeDir, + '--runs-file', runsFile, + '--now', now, + ]); + + assert.strictEqual(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(payload.panels); + assert.ok(payload.panels['success-rate']); + assert.ok(payload.panels['failures']); + assert.ok(payload.summary); + })) passed++; else failed++; + + if (test('CLI --panel failures --json returns only the failures panel', () => { + const result = runCli([ + '--dashboard', + '--panel', 'failures', + '--json', + '--skills-root', skillsRoot, + '--learned-root', learnedRoot, + '--imported-root', importedRoot, + '--home', homeDir, + '--runs-file', runsFile, + '--now', now, + ]); + + assert.strictEqual(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(payload.panels['failures']); + assert.ok(!payload.panels['versions']); + })) passed++; else failed++; + + if (test('CLI --help mentions --dashboard', () => { + const result = runCli(['--help']); + assert.strictEqual(result.status, 0); + assert.match(result.stdout, /--dashboard/); + assert.match(result.stdout, /--panel/); + })) passed++; else failed++; + + console.log('\nEdge cases:'); + + if (test('dashboard renders gracefully with no execution records', () => { + const emptyRunsFile = path.join(homeDir, '.claude', 'state', 'empty-runs.jsonl'); + fs.mkdirSync(path.dirname(emptyRunsFile), { recursive: true }); + fs.writeFileSync(emptyRunsFile, '', 'utf8'); + + const emptySkillsRoot = path.join(repoRoot, 'empty-skills'); + fs.mkdirSync(emptySkillsRoot, { recursive: true }); + + const result = dashboard.renderDashboard({ + skillsRoot: emptySkillsRoot, + learnedRoot: path.join(homeDir, '.claude', 'skills', 'empty-learned'), + importedRoot: path.join(homeDir, '.claude', 'skills', 'empty-imported'), + homeDir, + runsFilePath: emptyRunsFile, + now, + }); + + assert.ok(result.text.includes('ECC Skill Health Dashboard')); + assert.ok(result.text.includes('No failure patterns detected')); + assert.strictEqual(result.data.summary.total_skills, 0); + })) passed++; else failed++; + + if (test('failure cluster panel handles all successes', () => { + const successRecords = [ + { skill_id: 'alpha', outcome: 'success', failure_reason: null }, + { skill_id: 'beta', outcome: 'success', failure_reason: null }, + ]; + + const result = dashboard.renderFailureClusterPanel(successRecords); + assert.strictEqual(result.data.clusters.length, 0); + assert.strictEqual(result.data.total_failures, 0); + assert.ok(result.text.includes('No failure patterns detected')); + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + } finally { + cleanupTempDir(repoRoot); + cleanupTempDir(homeDir); + } + + process.exit(failed > 0 ? 1 : 0); +} + +runTests();