chore: import upstream snapshot with attribution
CI / Test (ubuntu-latest, Node 18.x, bun) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, npm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, pnpm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, yarn) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 20.x, bun) (push) Failing after 17m13s
CI / Test (ubuntu-latest, Node 20.x, npm) (push) Failing after 18m42s
CI / Test (ubuntu-latest, Node 20.x, pnpm) (push) Failing after 15m0s
CI / Test (ubuntu-latest, Node 20.x, yarn) (push) Failing after 49m44s
CI / Test (ubuntu-latest, Node 22.x, bun) (push) Failing after 51m55s
CI / Test (ubuntu-latest, Node 22.x, pnpm) (push) Failing after 21m57s
CI / Test (ubuntu-latest, Node 22.x, npm) (push) Failing after 37m39s
CI / Test (ubuntu-latest, Node 22.x, yarn) (push) Failing after 34m7s
CI / Validate Components (push) Failing after 37m15s
CI / Python Tests (push) Failing after 10m1s
CI / Security Scan (push) Failing after 10m1s
CI / Lint (push) Failing after 17m12s
CI / Coverage (push) Failing after 20m19s
CI / Test (macos-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (ubuntu-latest, Node 18.x, bun) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, npm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, pnpm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, yarn) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 20.x, bun) (push) Failing after 17m13s
CI / Test (ubuntu-latest, Node 20.x, npm) (push) Failing after 18m42s
CI / Test (ubuntu-latest, Node 20.x, pnpm) (push) Failing after 15m0s
CI / Test (ubuntu-latest, Node 20.x, yarn) (push) Failing after 49m44s
CI / Test (ubuntu-latest, Node 22.x, bun) (push) Failing after 51m55s
CI / Test (ubuntu-latest, Node 22.x, pnpm) (push) Failing after 21m57s
CI / Test (ubuntu-latest, Node 22.x, npm) (push) Failing after 37m39s
CI / Test (ubuntu-latest, Node 22.x, yarn) (push) Failing after 34m7s
CI / Validate Components (push) Failing after 37m15s
CI / Python Tests (push) Failing after 10m1s
CI / Security Scan (push) Failing after 10m1s
CI / Lint (push) Failing after 17m12s
CI / Coverage (push) Failing after 20m19s
CI / Test (macos-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, yarn) (push) Has been cancelled
This commit is contained in:
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,263 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const provenance = require('./provenance');
|
||||
const tracker = require('./tracker');
|
||||
const versioning = require('./versioning');
|
||||
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
const PENDING_AMENDMENT_STATUSES = Object.freeze(new Set(['pending', 'proposed', 'queued', 'open']));
|
||||
|
||||
function roundRate(value) {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round(value * 10000) / 10000;
|
||||
}
|
||||
|
||||
function formatRate(value) {
|
||||
if (value === null) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function summarizeHealthReport(report) {
|
||||
const totalSkills = report.skills.length;
|
||||
const decliningSkills = report.skills.filter(skill => skill.declining).length;
|
||||
const healthySkills = totalSkills - decliningSkills;
|
||||
|
||||
return {
|
||||
total_skills: totalSkills,
|
||||
healthy_skills: healthySkills,
|
||||
declining_skills: decliningSkills,
|
||||
};
|
||||
}
|
||||
|
||||
function listSkillsInRoot(rootPath) {
|
||||
if (!rootPath || !fs.existsSync(rootPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(rootPath, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => ({
|
||||
skill_id: entry.name,
|
||||
skill_dir: path.join(rootPath, entry.name),
|
||||
}))
|
||||
.filter(entry => fs.existsSync(path.join(entry.skill_dir, 'SKILL.md')));
|
||||
}
|
||||
|
||||
function discoverSkills(options = {}) {
|
||||
const roots = provenance.getSkillRoots(options);
|
||||
const discoveredSkills = [
|
||||
...listSkillsInRoot(options.skillsRoot || roots.curated).map(skill => ({
|
||||
...skill,
|
||||
skill_type: provenance.SKILL_TYPES.CURATED,
|
||||
})),
|
||||
...listSkillsInRoot(options.learnedRoot || roots.learned).map(skill => ({
|
||||
...skill,
|
||||
skill_type: provenance.SKILL_TYPES.LEARNED,
|
||||
})),
|
||||
...listSkillsInRoot(options.importedRoot || roots.imported).map(skill => ({
|
||||
...skill,
|
||||
skill_type: provenance.SKILL_TYPES.IMPORTED,
|
||||
})),
|
||||
];
|
||||
|
||||
return discoveredSkills.reduce((skillsById, skill) => {
|
||||
if (!skillsById.has(skill.skill_id)) {
|
||||
skillsById.set(skill.skill_id, skill);
|
||||
}
|
||||
return skillsById;
|
||||
}, new Map());
|
||||
}
|
||||
|
||||
function calculateSuccessRate(records) {
|
||||
if (records.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const successfulRecords = records.filter(record => record.outcome === 'success').length;
|
||||
return roundRate(successfulRecords / records.length);
|
||||
}
|
||||
|
||||
function filterRecordsWithinDays(records, nowMs, days) {
|
||||
const cutoff = nowMs - (days * DAY_IN_MS);
|
||||
return records.filter(record => {
|
||||
const recordedAtMs = Date.parse(record.recorded_at);
|
||||
return !Number.isNaN(recordedAtMs) && recordedAtMs >= cutoff && recordedAtMs <= nowMs;
|
||||
});
|
||||
}
|
||||
|
||||
function getFailureTrend(successRate7d, successRate30d, warnThreshold) {
|
||||
if (successRate7d === null || successRate30d === null) {
|
||||
return 'stable';
|
||||
}
|
||||
|
||||
const delta = roundRate(successRate7d - successRate30d);
|
||||
if (delta <= (-1 * warnThreshold)) {
|
||||
return 'worsening';
|
||||
}
|
||||
|
||||
if (delta >= warnThreshold) {
|
||||
return 'improving';
|
||||
}
|
||||
|
||||
return 'stable';
|
||||
}
|
||||
|
||||
function countPendingAmendments(skillDir) {
|
||||
if (!skillDir) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return versioning.getEvolutionLog(skillDir, 'amendments')
|
||||
.filter(entry => {
|
||||
if (typeof entry.status === 'string') {
|
||||
return PENDING_AMENDMENT_STATUSES.has(entry.status);
|
||||
}
|
||||
|
||||
return entry.event === 'proposal';
|
||||
})
|
||||
.length;
|
||||
}
|
||||
|
||||
function getLastRun(records) {
|
||||
if (records.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return records
|
||||
.map(record => ({
|
||||
timestamp: record.recorded_at,
|
||||
timeMs: Date.parse(record.recorded_at),
|
||||
}))
|
||||
.filter(entry => !Number.isNaN(entry.timeMs))
|
||||
.sort((left, right) => left.timeMs - right.timeMs)
|
||||
.at(-1)?.timestamp || null;
|
||||
}
|
||||
|
||||
function collectSkillHealth(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 warnThreshold = typeof options.warnThreshold === 'number'
|
||||
? options.warnThreshold
|
||||
: Number(options.warnThreshold || 0.1);
|
||||
if (!Number.isFinite(warnThreshold) || warnThreshold < 0) {
|
||||
throw new Error(`Invalid warn threshold: ${options.warnThreshold}`);
|
||||
}
|
||||
|
||||
const records = tracker.readSkillExecutionRecords(options);
|
||||
const skillsById = discoverSkills(options);
|
||||
const recordsBySkill = records.reduce((groupedRecords, record) => {
|
||||
if (!groupedRecords.has(record.skill_id)) {
|
||||
groupedRecords.set(record.skill_id, []);
|
||||
}
|
||||
|
||||
groupedRecords.get(record.skill_id).push(record);
|
||||
return groupedRecords;
|
||||
}, new Map());
|
||||
|
||||
for (const skillId of recordsBySkill.keys()) {
|
||||
if (!skillsById.has(skillId)) {
|
||||
skillsById.set(skillId, {
|
||||
skill_id: skillId,
|
||||
skill_dir: null,
|
||||
skill_type: provenance.SKILL_TYPES.UNKNOWN,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const skills = Array.from(skillsById.values())
|
||||
.sort((left, right) => left.skill_id.localeCompare(right.skill_id))
|
||||
.map(skill => {
|
||||
const skillRecords = recordsBySkill.get(skill.skill_id) || [];
|
||||
const records7d = filterRecordsWithinDays(skillRecords, nowMs, 7);
|
||||
const records30d = filterRecordsWithinDays(skillRecords, nowMs, 30);
|
||||
const successRate7d = calculateSuccessRate(records7d);
|
||||
const successRate30d = calculateSuccessRate(records30d);
|
||||
const currentVersionNumber = skill.skill_dir ? versioning.getCurrentVersion(skill.skill_dir) : 0;
|
||||
const failureTrend = getFailureTrend(successRate7d, successRate30d, warnThreshold);
|
||||
|
||||
return {
|
||||
skill_id: skill.skill_id,
|
||||
skill_type: skill.skill_type,
|
||||
current_version: currentVersionNumber > 0 ? `v${currentVersionNumber}` : null,
|
||||
pending_amendments: countPendingAmendments(skill.skill_dir),
|
||||
success_rate_7d: successRate7d,
|
||||
success_rate_30d: successRate30d,
|
||||
failure_trend: failureTrend,
|
||||
declining: failureTrend === 'worsening',
|
||||
last_run: getLastRun(skillRecords),
|
||||
run_count_7d: records7d.length,
|
||||
run_count_30d: records30d.length,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
generated_at: now,
|
||||
warn_threshold: warnThreshold,
|
||||
skills,
|
||||
};
|
||||
}
|
||||
|
||||
function formatHealthReport(report, options = {}) {
|
||||
if (options.json) {
|
||||
return `${JSON.stringify(report, null, 2)}\n`;
|
||||
}
|
||||
|
||||
const summary = summarizeHealthReport(report);
|
||||
|
||||
if (!report.skills.length) {
|
||||
return [
|
||||
'ECC skill health',
|
||||
`Generated: ${report.generated_at}`,
|
||||
'',
|
||||
'No skill execution records found.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const lines = [
|
||||
'ECC skill health',
|
||||
`Generated: ${report.generated_at}`,
|
||||
`Skills: ${summary.total_skills} total, ${summary.healthy_skills} healthy, ${summary.declining_skills} declining`,
|
||||
'',
|
||||
'skill version 7d 30d trend pending last run',
|
||||
'--------------------------------------------------------------------------',
|
||||
];
|
||||
|
||||
for (const skill of report.skills) {
|
||||
const statusLabel = skill.declining ? '!' : ' ';
|
||||
lines.push([
|
||||
`${statusLabel}${skill.skill_id}`.padEnd(16),
|
||||
String(skill.current_version || '-').padEnd(9),
|
||||
formatRate(skill.success_rate_7d).padEnd(6),
|
||||
formatRate(skill.success_rate_30d).padEnd(6),
|
||||
skill.failure_trend.padEnd(11),
|
||||
String(skill.pending_amendments).padEnd(9),
|
||||
skill.last_run || '-',
|
||||
].join(' '));
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PENDING_AMENDMENT_STATUSES,
|
||||
calculateSuccessRate,
|
||||
collectSkillHealth,
|
||||
discoverSkills,
|
||||
filterRecordsWithinDays,
|
||||
formatHealthReport,
|
||||
summarizeHealthReport,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { ensureDir } = require('../utils');
|
||||
|
||||
const PROVENANCE_FILE_NAME = '.provenance.json';
|
||||
const SKILL_TYPES = Object.freeze({
|
||||
CURATED: 'curated',
|
||||
LEARNED: 'learned',
|
||||
IMPORTED: 'imported',
|
||||
UNKNOWN: 'unknown',
|
||||
});
|
||||
|
||||
function resolveRepoRoot(repoRoot) {
|
||||
if (repoRoot) {
|
||||
return path.resolve(repoRoot);
|
||||
}
|
||||
|
||||
return path.resolve(__dirname, '..', '..', '..');
|
||||
}
|
||||
|
||||
function resolveHomeDir(homeDir) {
|
||||
return homeDir ? path.resolve(homeDir) : os.homedir();
|
||||
}
|
||||
|
||||
function normalizeSkillDir(skillPath) {
|
||||
if (!skillPath || typeof skillPath !== 'string') {
|
||||
throw new Error('skillPath is required');
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(skillPath);
|
||||
if (path.basename(resolvedPath) === 'SKILL.md') {
|
||||
return path.dirname(resolvedPath);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
function isWithinRoot(targetPath, rootPath) {
|
||||
const relativePath = path.relative(rootPath, targetPath);
|
||||
return relativePath === '' || (
|
||||
!relativePath.startsWith('..')
|
||||
&& !path.isAbsolute(relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
function getSkillRoots(options = {}) {
|
||||
const repoRoot = resolveRepoRoot(options.repoRoot);
|
||||
const homeDir = resolveHomeDir(options.homeDir);
|
||||
|
||||
return {
|
||||
curated: path.join(repoRoot, 'skills'),
|
||||
learned: path.join(homeDir, '.claude', 'skills', 'learned'),
|
||||
imported: path.join(homeDir, '.claude', 'skills', 'imported'),
|
||||
};
|
||||
}
|
||||
|
||||
function classifySkillPath(skillPath, options = {}) {
|
||||
const skillDir = normalizeSkillDir(skillPath);
|
||||
const roots = getSkillRoots(options);
|
||||
|
||||
if (isWithinRoot(skillDir, roots.curated)) {
|
||||
return SKILL_TYPES.CURATED;
|
||||
}
|
||||
|
||||
if (isWithinRoot(skillDir, roots.learned)) {
|
||||
return SKILL_TYPES.LEARNED;
|
||||
}
|
||||
|
||||
if (isWithinRoot(skillDir, roots.imported)) {
|
||||
return SKILL_TYPES.IMPORTED;
|
||||
}
|
||||
|
||||
return SKILL_TYPES.UNKNOWN;
|
||||
}
|
||||
|
||||
function requiresProvenance(skillPath, options = {}) {
|
||||
const skillType = classifySkillPath(skillPath, options);
|
||||
return skillType === SKILL_TYPES.LEARNED || skillType === SKILL_TYPES.IMPORTED;
|
||||
}
|
||||
|
||||
function getProvenancePath(skillPath) {
|
||||
return path.join(normalizeSkillDir(skillPath), PROVENANCE_FILE_NAME);
|
||||
}
|
||||
|
||||
function isIsoTimestamp(value) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(value);
|
||||
return !Number.isNaN(timestamp);
|
||||
}
|
||||
|
||||
function validateProvenance(record) {
|
||||
const errors = [];
|
||||
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
||||
errors.push('provenance record must be an object');
|
||||
return {
|
||||
valid: false,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof record.source !== 'string' || record.source.trim().length === 0) {
|
||||
errors.push('source is required');
|
||||
}
|
||||
|
||||
if (!isIsoTimestamp(record.created_at)) {
|
||||
errors.push('created_at must be an ISO timestamp');
|
||||
}
|
||||
|
||||
if (typeof record.confidence !== 'number' || Number.isNaN(record.confidence)) {
|
||||
errors.push('confidence must be a number');
|
||||
} else if (record.confidence < 0 || record.confidence > 1) {
|
||||
errors.push('confidence must be between 0 and 1');
|
||||
}
|
||||
|
||||
if (typeof record.author !== 'string' || record.author.trim().length === 0) {
|
||||
errors.push('author is required');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
function assertValidProvenance(record) {
|
||||
const validation = validateProvenance(record);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid provenance metadata: ${validation.errors.join('; ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readProvenance(skillPath, options = {}) {
|
||||
const skillDir = normalizeSkillDir(skillPath);
|
||||
const provenancePath = getProvenancePath(skillDir);
|
||||
const provenanceRequired = options.required === true || requiresProvenance(skillDir, options);
|
||||
|
||||
if (!fs.existsSync(provenancePath)) {
|
||||
if (provenanceRequired) {
|
||||
throw new Error(`Missing provenance metadata for ${skillDir}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = JSON.parse(fs.readFileSync(provenancePath, 'utf8'));
|
||||
assertValidProvenance(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
function writeProvenance(skillPath, record, options = {}) {
|
||||
const skillDir = normalizeSkillDir(skillPath);
|
||||
|
||||
if (!requiresProvenance(skillDir, options)) {
|
||||
throw new Error(`Provenance metadata is only required for learned or imported skills: ${skillDir}`);
|
||||
}
|
||||
|
||||
assertValidProvenance(record);
|
||||
|
||||
const provenancePath = getProvenancePath(skillDir);
|
||||
ensureDir(skillDir);
|
||||
fs.writeFileSync(provenancePath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
||||
|
||||
return {
|
||||
path: provenancePath,
|
||||
record: { ...record },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROVENANCE_FILE_NAME,
|
||||
SKILL_TYPES,
|
||||
classifySkillPath,
|
||||
getProvenancePath,
|
||||
getSkillRoots,
|
||||
readProvenance,
|
||||
requiresProvenance,
|
||||
validateProvenance,
|
||||
writeProvenance,
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { appendFile } = require('../utils');
|
||||
|
||||
const VALID_OUTCOMES = new Set(['success', 'failure', 'partial']);
|
||||
const VALID_FEEDBACK = new Set(['accepted', 'corrected', 'rejected']);
|
||||
|
||||
function resolveHomeDir(homeDir) {
|
||||
return homeDir ? path.resolve(homeDir) : os.homedir();
|
||||
}
|
||||
|
||||
function getRunsFilePath(options = {}) {
|
||||
if (options.runsFilePath) {
|
||||
return path.resolve(options.runsFilePath);
|
||||
}
|
||||
|
||||
return path.join(resolveHomeDir(options.homeDir), '.claude', 'state', 'skill-runs.jsonl');
|
||||
}
|
||||
|
||||
function toNullableNumber(value, fieldName) {
|
||||
if (value === null || typeof value === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numericValue = Number(value);
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
throw new Error(`${fieldName} must be a number`);
|
||||
}
|
||||
|
||||
return numericValue;
|
||||
}
|
||||
|
||||
function normalizeExecutionRecord(input, options = {}) {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
throw new Error('skill execution payload must be an object');
|
||||
}
|
||||
|
||||
const skillId = input.skill_id || input.skillId;
|
||||
const skillVersion = input.skill_version || input.skillVersion;
|
||||
const taskDescription = input.task_description || input.task_attempted || input.taskAttempted;
|
||||
const outcome = input.outcome;
|
||||
const recordedAt = input.recorded_at || options.now || new Date().toISOString();
|
||||
const userFeedback = input.user_feedback || input.userFeedback || null;
|
||||
|
||||
if (typeof skillId !== 'string' || skillId.trim().length === 0) {
|
||||
throw new Error('skill_id is required');
|
||||
}
|
||||
|
||||
if (typeof skillVersion !== 'string' || skillVersion.trim().length === 0) {
|
||||
throw new Error('skill_version is required');
|
||||
}
|
||||
|
||||
if (typeof taskDescription !== 'string' || taskDescription.trim().length === 0) {
|
||||
throw new Error('task_description is required');
|
||||
}
|
||||
|
||||
if (!VALID_OUTCOMES.has(outcome)) {
|
||||
throw new Error('outcome must be one of success, failure, or partial');
|
||||
}
|
||||
|
||||
if (userFeedback !== null && !VALID_FEEDBACK.has(userFeedback)) {
|
||||
throw new Error('user_feedback must be accepted, corrected, rejected, or null');
|
||||
}
|
||||
|
||||
if (Number.isNaN(Date.parse(recordedAt))) {
|
||||
throw new Error('recorded_at must be an ISO timestamp');
|
||||
}
|
||||
|
||||
return {
|
||||
skill_id: skillId,
|
||||
skill_version: skillVersion,
|
||||
task_description: taskDescription,
|
||||
outcome,
|
||||
failure_reason: input.failure_reason || input.failureReason || null,
|
||||
tokens_used: toNullableNumber(input.tokens_used ?? input.tokensUsed, 'tokens_used'),
|
||||
duration_ms: toNullableNumber(input.duration_ms ?? input.durationMs, 'duration_ms'),
|
||||
user_feedback: userFeedback,
|
||||
recorded_at: recordedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function readJsonl(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readFileSync(filePath, 'utf8')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.reduce((rows, line) => {
|
||||
try {
|
||||
rows.push(JSON.parse(line));
|
||||
} catch {
|
||||
// Ignore malformed rows so analytics remain best-effort.
|
||||
}
|
||||
return rows;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function recordSkillExecution(input, options = {}) {
|
||||
const record = normalizeExecutionRecord(input, options);
|
||||
|
||||
if (options.stateStore && typeof options.stateStore.recordSkillExecution === 'function') {
|
||||
try {
|
||||
const result = options.stateStore.recordSkillExecution(record);
|
||||
return {
|
||||
storage: 'state-store',
|
||||
record,
|
||||
result,
|
||||
};
|
||||
} catch {
|
||||
// Fall back to JSONL until the formal state-store exists on this branch.
|
||||
}
|
||||
}
|
||||
|
||||
const runsFilePath = getRunsFilePath(options);
|
||||
appendFile(runsFilePath, `${JSON.stringify(record)}\n`);
|
||||
|
||||
return {
|
||||
storage: 'jsonl',
|
||||
path: runsFilePath,
|
||||
record,
|
||||
};
|
||||
}
|
||||
|
||||
function readSkillExecutionRecords(options = {}) {
|
||||
if (options.stateStore && typeof options.stateStore.listSkillExecutionRecords === 'function') {
|
||||
return options.stateStore.listSkillExecutionRecords();
|
||||
}
|
||||
|
||||
return readJsonl(getRunsFilePath(options));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
VALID_FEEDBACK,
|
||||
VALID_OUTCOMES,
|
||||
getRunsFilePath,
|
||||
normalizeExecutionRecord,
|
||||
readSkillExecutionRecords,
|
||||
recordSkillExecution,
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { appendFile, ensureDir } = require('../utils');
|
||||
|
||||
const VERSION_DIRECTORY_NAME = '.versions';
|
||||
const EVOLUTION_DIRECTORY_NAME = '.evolution';
|
||||
const EVOLUTION_LOG_TYPES = Object.freeze([
|
||||
'observations',
|
||||
'inspections',
|
||||
'amendments',
|
||||
]);
|
||||
|
||||
function normalizeSkillDir(skillPath) {
|
||||
if (!skillPath || typeof skillPath !== 'string') {
|
||||
throw new Error('skillPath is required');
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(skillPath);
|
||||
if (path.basename(resolvedPath) === 'SKILL.md') {
|
||||
return path.dirname(resolvedPath);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
function getSkillFilePath(skillPath) {
|
||||
return path.join(normalizeSkillDir(skillPath), 'SKILL.md');
|
||||
}
|
||||
|
||||
function ensureSkillExists(skillPath) {
|
||||
const skillFilePath = getSkillFilePath(skillPath);
|
||||
if (!fs.existsSync(skillFilePath)) {
|
||||
throw new Error(`Skill file not found: ${skillFilePath}`);
|
||||
}
|
||||
|
||||
return skillFilePath;
|
||||
}
|
||||
|
||||
function getVersionsDir(skillPath) {
|
||||
return path.join(normalizeSkillDir(skillPath), VERSION_DIRECTORY_NAME);
|
||||
}
|
||||
|
||||
function getEvolutionDir(skillPath) {
|
||||
return path.join(normalizeSkillDir(skillPath), EVOLUTION_DIRECTORY_NAME);
|
||||
}
|
||||
|
||||
function getEvolutionLogPath(skillPath, logType) {
|
||||
if (!EVOLUTION_LOG_TYPES.includes(logType)) {
|
||||
throw new Error(`Unknown evolution log type: ${logType}`);
|
||||
}
|
||||
|
||||
return path.join(getEvolutionDir(skillPath), `${logType}.jsonl`);
|
||||
}
|
||||
|
||||
function ensureSkillVersioning(skillPath) {
|
||||
ensureSkillExists(skillPath);
|
||||
|
||||
const versionsDir = getVersionsDir(skillPath);
|
||||
const evolutionDir = getEvolutionDir(skillPath);
|
||||
|
||||
ensureDir(versionsDir);
|
||||
ensureDir(evolutionDir);
|
||||
|
||||
for (const logType of EVOLUTION_LOG_TYPES) {
|
||||
const logPath = getEvolutionLogPath(skillPath, logType);
|
||||
if (!fs.existsSync(logPath)) {
|
||||
fs.writeFileSync(logPath, '', 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
versionsDir,
|
||||
evolutionDir,
|
||||
};
|
||||
}
|
||||
|
||||
function parseVersionNumber(fileName) {
|
||||
const match = /^v(\d+)\.md$/.exec(fileName);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
function listVersions(skillPath) {
|
||||
const versionsDir = getVersionsDir(skillPath);
|
||||
if (!fs.existsSync(versionsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(versionsDir)
|
||||
.map(fileName => {
|
||||
const version = parseVersionNumber(fileName);
|
||||
if (version === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = path.join(versionsDir, fileName);
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
return {
|
||||
version,
|
||||
path: filePath,
|
||||
created_at: stats.mtime.toISOString(),
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.version - right.version);
|
||||
}
|
||||
|
||||
function getCurrentVersion(skillPath) {
|
||||
const skillFilePath = getSkillFilePath(skillPath);
|
||||
if (!fs.existsSync(skillFilePath)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const versions = listVersions(skillPath);
|
||||
if (versions.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return versions[versions.length - 1].version;
|
||||
}
|
||||
|
||||
function appendEvolutionRecord(skillPath, logType, record) {
|
||||
ensureSkillVersioning(skillPath);
|
||||
appendFile(getEvolutionLogPath(skillPath, logType), `${JSON.stringify(record)}\n`);
|
||||
return { ...record };
|
||||
}
|
||||
|
||||
function readJsonl(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readFileSync(filePath, 'utf8')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.reduce((rows, line) => {
|
||||
try {
|
||||
rows.push(JSON.parse(line));
|
||||
} catch {
|
||||
// Ignore malformed rows so the log remains append-only and resilient.
|
||||
}
|
||||
return rows;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function getEvolutionLog(skillPath, logType) {
|
||||
return readJsonl(getEvolutionLogPath(skillPath, logType));
|
||||
}
|
||||
|
||||
function createVersion(skillPath, options = {}) {
|
||||
const skillFilePath = ensureSkillExists(skillPath);
|
||||
ensureSkillVersioning(skillPath);
|
||||
|
||||
const versions = listVersions(skillPath);
|
||||
const nextVersion = versions.length === 0 ? 1 : versions[versions.length - 1].version + 1;
|
||||
const snapshotPath = path.join(getVersionsDir(skillPath), `v${nextVersion}.md`);
|
||||
const skillContent = fs.readFileSync(skillFilePath, 'utf8');
|
||||
const createdAt = options.timestamp || new Date().toISOString();
|
||||
|
||||
fs.writeFileSync(snapshotPath, skillContent, 'utf8');
|
||||
appendEvolutionRecord(skillPath, 'amendments', {
|
||||
event: 'snapshot',
|
||||
version: nextVersion,
|
||||
reason: options.reason || null,
|
||||
author: options.author || null,
|
||||
status: 'applied',
|
||||
created_at: createdAt,
|
||||
});
|
||||
|
||||
return {
|
||||
version: nextVersion,
|
||||
path: snapshotPath,
|
||||
created_at: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackTo(skillPath, targetVersion, options = {}) {
|
||||
const normalizedTargetVersion = Number(targetVersion);
|
||||
if (!Number.isInteger(normalizedTargetVersion) || normalizedTargetVersion <= 0) {
|
||||
throw new Error(`Invalid target version: ${targetVersion}`);
|
||||
}
|
||||
|
||||
ensureSkillExists(skillPath);
|
||||
ensureSkillVersioning(skillPath);
|
||||
|
||||
const targetPath = path.join(getVersionsDir(skillPath), `v${normalizedTargetVersion}.md`);
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
throw new Error(`Version not found: v${normalizedTargetVersion}`);
|
||||
}
|
||||
|
||||
const currentVersion = getCurrentVersion(skillPath);
|
||||
const targetContent = fs.readFileSync(targetPath, 'utf8');
|
||||
fs.writeFileSync(getSkillFilePath(skillPath), targetContent, 'utf8');
|
||||
|
||||
const createdVersion = createVersion(skillPath, {
|
||||
timestamp: options.timestamp,
|
||||
reason: options.reason || `rollback to v${normalizedTargetVersion}`,
|
||||
author: options.author || null,
|
||||
});
|
||||
|
||||
appendEvolutionRecord(skillPath, 'amendments', {
|
||||
event: 'rollback',
|
||||
version: createdVersion.version,
|
||||
source_version: currentVersion,
|
||||
target_version: normalizedTargetVersion,
|
||||
reason: options.reason || null,
|
||||
author: options.author || null,
|
||||
status: 'applied',
|
||||
created_at: options.timestamp || new Date().toISOString(),
|
||||
});
|
||||
|
||||
return createdVersion;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EVOLUTION_DIRECTORY_NAME,
|
||||
EVOLUTION_LOG_TYPES,
|
||||
VERSION_DIRECTORY_NAME,
|
||||
appendEvolutionRecord,
|
||||
createVersion,
|
||||
ensureSkillVersioning,
|
||||
getCurrentVersion,
|
||||
getEvolutionDir,
|
||||
getEvolutionLog,
|
||||
getEvolutionLogPath,
|
||||
getVersionsDir,
|
||||
listVersions,
|
||||
rollbackTo,
|
||||
};
|
||||
Reference in New Issue
Block a user