169 lines
5.7 KiB
TypeScript
169 lines
5.7 KiB
TypeScript
/**
|
|
* Skills 2.0 gap F — skill usage tracking + auto-retirement.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import { MindDB, ImprovementSignalStore } from '@waggle/core';
|
|
import { retireStaleSkills } from '../src/skill-retirement.js';
|
|
import {
|
|
recordSkillUsage,
|
|
loadSkillUsage,
|
|
saveSkillUsage,
|
|
forgetSkillUsage,
|
|
} from '../src/skill-usage.js';
|
|
|
|
function seedSkill(dir: string, name: string, mtimeMs: number, content = '# body'): string {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const p = path.join(dir, `${name}.md`);
|
|
fs.writeFileSync(p, content, 'utf-8');
|
|
const mtime = new Date(mtimeMs);
|
|
fs.utimesSync(p, mtime, mtime);
|
|
return p;
|
|
}
|
|
|
|
describe('skill-usage — gap F', () => {
|
|
let waggleHome: string;
|
|
|
|
beforeEach(() => {
|
|
waggleHome = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-usage-'));
|
|
});
|
|
afterEach(() => {
|
|
fs.rmSync(waggleHome, {
|
|
recursive: true,
|
|
force: true,
|
|
maxRetries: 10,
|
|
retryDelay: 100,
|
|
});
|
|
});
|
|
|
|
it('records a new skill usage and increments on repeat', () => {
|
|
const t0 = new Date('2026-01-01T00:00:00Z');
|
|
const first = recordSkillUsage(waggleHome, 'deploy', () => t0);
|
|
expect(first.count).toBe(1);
|
|
expect(first.lastUsedAt).toBe(t0.toISOString());
|
|
|
|
const t1 = new Date('2026-02-01T00:00:00Z');
|
|
const second = recordSkillUsage(waggleHome, 'deploy', () => t1);
|
|
expect(second.count).toBe(2);
|
|
expect(second.lastUsedAt).toBe(t1.toISOString());
|
|
});
|
|
|
|
it('loadSkillUsage returns {} when the file is missing', () => {
|
|
expect(loadSkillUsage(waggleHome)).toEqual({});
|
|
});
|
|
|
|
it('loadSkillUsage tolerates a corrupt index file', () => {
|
|
fs.writeFileSync(path.join(waggleHome, 'skill-usage.json'), 'not-json{', 'utf-8');
|
|
expect(loadSkillUsage(waggleHome)).toEqual({});
|
|
});
|
|
|
|
it('forgetSkillUsage removes a single entry without touching others', () => {
|
|
saveSkillUsage(waggleHome, {
|
|
a: { lastUsedAt: '2026-01-01T00:00:00.000Z', count: 1 },
|
|
b: { lastUsedAt: '2026-01-02T00:00:00.000Z', count: 3 },
|
|
});
|
|
forgetSkillUsage(waggleHome, 'a');
|
|
const idx = loadSkillUsage(waggleHome);
|
|
expect(idx.a).toBeUndefined();
|
|
expect(idx.b).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('retireStaleSkills — gap F', () => {
|
|
let waggleHome: string;
|
|
let skillsDir: string;
|
|
let db: MindDB;
|
|
let signals: ImprovementSignalStore;
|
|
|
|
const NOW = new Date('2026-04-15T00:00:00Z');
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
|
|
beforeEach(() => {
|
|
waggleHome = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-retire-'));
|
|
skillsDir = path.join(waggleHome, 'skills');
|
|
db = new MindDB(':memory:');
|
|
signals = new ImprovementSignalStore(db);
|
|
});
|
|
|
|
afterEach(() => {
|
|
db.close();
|
|
fs.rmSync(waggleHome, {
|
|
recursive: true,
|
|
force: true,
|
|
maxRetries: 10,
|
|
retryDelay: 100,
|
|
});
|
|
});
|
|
|
|
it('retires skills whose last-used is older than maxIdleDays', () => {
|
|
// Fresh skill — used 10 days ago — should NOT be retired
|
|
const fresh = seedSkill(skillsDir, 'fresh', NOW.getTime());
|
|
saveSkillUsage(waggleHome, {
|
|
fresh: { lastUsedAt: new Date(NOW.getTime() - 10 * DAY).toISOString(), count: 5 },
|
|
stale: { lastUsedAt: new Date(NOW.getTime() - 200 * DAY).toISOString(), count: 1 },
|
|
});
|
|
// Stale skill — used 200 days ago — SHOULD be retired
|
|
const stale = seedSkill(skillsDir, 'stale', NOW.getTime());
|
|
|
|
const report = retireStaleSkills(waggleHome, {
|
|
maxIdleDays: 90,
|
|
now: () => NOW,
|
|
improvementSignals: signals,
|
|
});
|
|
|
|
expect(report.scanned).toBe(2);
|
|
expect(report.retired).toEqual(['stale']);
|
|
expect(fs.existsSync(fresh)).toBe(true);
|
|
expect(fs.existsSync(stale)).toBe(false);
|
|
expect(report.archived.length).toBe(1);
|
|
expect(fs.existsSync(report.archived[0])).toBe(true);
|
|
|
|
// Usage entry for the retired skill is cleared
|
|
expect(loadSkillUsage(waggleHome).stale).toBeUndefined();
|
|
expect(loadSkillUsage(waggleHome).fresh).toBeDefined();
|
|
|
|
// Signal recorded
|
|
const recorded = signals.getByCategory('workflow_pattern').find(s => s.pattern_key === 'retire:stale');
|
|
expect(recorded).toBeDefined();
|
|
});
|
|
|
|
it('falls back to file mtime when no usage entry exists', () => {
|
|
// mtime 200 days ago, no usage entry
|
|
seedSkill(skillsDir, 'never-used', NOW.getTime() - 200 * DAY);
|
|
const report = retireStaleSkills(waggleHome, { maxIdleDays: 90, now: () => NOW });
|
|
expect(report.retired).toEqual(['never-used']);
|
|
});
|
|
|
|
it('dry_run reports but does not move files', () => {
|
|
const stalePath = seedSkill(skillsDir, 'stale', NOW.getTime() - 200 * DAY);
|
|
const report = retireStaleSkills(waggleHome, {
|
|
maxIdleDays: 90,
|
|
dryRun: true,
|
|
now: () => NOW,
|
|
});
|
|
expect(report.retired).toEqual(['stale']);
|
|
expect(report.archived.length).toBe(0);
|
|
expect(fs.existsSync(stalePath)).toBe(true); // File NOT moved
|
|
});
|
|
|
|
it('returns an empty report when the skills dir is missing', () => {
|
|
const report = retireStaleSkills(waggleHome, { maxIdleDays: 90, now: () => NOW });
|
|
expect(report.scanned).toBe(0);
|
|
expect(report.retired).toEqual([]);
|
|
});
|
|
|
|
it('keeps fresh skills untouched when all are within the window', () => {
|
|
seedSkill(skillsDir, 'a', NOW.getTime() - 5 * DAY);
|
|
seedSkill(skillsDir, 'b', NOW.getTime() - 20 * DAY);
|
|
saveSkillUsage(waggleHome, {
|
|
a: { lastUsedAt: new Date(NOW.getTime() - 5 * DAY).toISOString(), count: 1 },
|
|
b: { lastUsedAt: new Date(NOW.getTime() - 20 * DAY).toISOString(), count: 1 },
|
|
});
|
|
const report = retireStaleSkills(waggleHome, { maxIdleDays: 90, now: () => NOW });
|
|
expect(report.retired).toEqual([]);
|
|
expect(report.skipped.every(s => s.reason === 'fresh')).toBe(true);
|
|
});
|
|
});
|