feat(design): skill health dashboard mockup (#518)

* feat(Design): skill health dashboard mockup

* fix(comments): code according to comments
This commit is contained in:
Aryan Tejani
2026-03-17 02:31:41 +05:30
committed by GitHub
parent 10879da823
commit 89044e8c33
6 changed files with 934 additions and 3 deletions

51
commands/skill-health.md Normal file
View File

@@ -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

View File

@@ -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,
};

View File

@@ -8,7 +8,7 @@ const tracker = require('./tracker');
const versioning = require('./versioning'); const versioning = require('./versioning');
const DAY_IN_MS = 24 * 60 * 60 * 1000; 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) { function roundRate(value) {
if (value === null) { if (value === null) {
@@ -253,8 +253,11 @@ function formatHealthReport(report, options = {}) {
} }
module.exports = { module.exports = {
PENDING_AMENDMENT_STATUSES,
calculateSuccessRate,
collectSkillHealth, collectSkillHealth,
discoverSkills, discoverSkills,
filterRecordsWithinDays,
formatHealthReport, formatHealthReport,
summarizeHealthReport, summarizeHealthReport,
}; };

View File

@@ -4,14 +4,17 @@ const provenance = require('./provenance');
const versioning = require('./versioning'); const versioning = require('./versioning');
const tracker = require('./tracker'); const tracker = require('./tracker');
const health = require('./health'); const health = require('./health');
const dashboard = require('./dashboard');
module.exports = { module.exports = {
...provenance, ...provenance,
...versioning, ...versioning,
...tracker, ...tracker,
...health, ...health,
...dashboard,
provenance, provenance,
versioning, versioning,
tracker, tracker,
health, health,
dashboard,
}; };

View File

@@ -2,6 +2,7 @@
'use strict'; 'use strict';
const { collectSkillHealth, formatHealthReport } = require('./lib/skill-evolution/health'); const { collectSkillHealth, formatHealthReport } = require('./lib/skill-evolution/health');
const { renderDashboard } = require('./lib/skill-evolution/dashboard');
function showHelp() { function showHelp() {
console.log(` console.log(`
@@ -15,6 +16,8 @@ Options:
--home <path> Override home directory for learned/imported skill roots --home <path> Override home directory for learned/imported skill roots
--runs-file <path> Override skill run JSONL path --runs-file <path> Override skill run JSONL path
--now <timestamp> Override current time for deterministic reports --now <timestamp> Override current time for deterministic reports
--dashboard Show rich health dashboard with charts
--panel <name> Show only a specific panel (success-rate, failures, amendments, versions)
--warn-threshold <n> Decline sensitivity threshold (default: 0.1) --warn-threshold <n> Decline sensitivity threshold (default: 0.1)
--help Show this help text --help Show this help text
`); `);
@@ -87,6 +90,17 @@ function parseArgs(argv) {
continue; 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}`); throw new Error(`Unknown argument: ${arg}`);
} }
@@ -102,8 +116,13 @@ function main() {
process.exit(0); process.exit(0);
} }
const report = collectSkillHealth(options); if (options.dashboard || options.panel) {
process.stdout.write(formatHealthReport(report, { json: options.json })); 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) { } catch (error) {
process.stderr.write(`Error: ${error.message}\n`); process.stderr.write(`Error: ${error.message}\n`);
process.exit(1); process.exit(1);

View File

@@ -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();