diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index 7b0d69770..301529ac2 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -10,7 +10,7 @@ "id": "hand-seeded", "source": "manual-curation-pending-regeneration" }, - "registryDigest": "7144a76d4b920efacf5a8d678ec6757ea34410834c82f4e226b6a32af6f8c540", + "registryDigest": "cb89b3434da17c00988095077f61208ab81c2f0611f0edbd5c90d3672bce7ac1", "schemaVersion": 1, "triggers": { "skill:agent-runtime-gateway-smoke-test": [ diff --git a/scripts/lib/skill-evolution/tracker.js b/scripts/lib/skill-evolution/tracker.js index 8e6d48b10..effa107eb 100644 --- a/scripts/lib/skill-evolution/tracker.js +++ b/scripts/lib/skill-evolution/tracker.js @@ -103,7 +103,10 @@ function readJsonl(filePath) { .reduce((rows, line) => { try { const record = JSON.parse(line); - if (record && typeof record === 'object' && !Array.isArray(record)) { + if (record && typeof record === 'object' && !Array.isArray(record) + && typeof record.skill_id === 'string' && record.skill_id.trim().length > 0 + && VALID_OUTCOMES.has(record.outcome) + && typeof record.recorded_at === 'string' && !Number.isNaN(Date.parse(record.recorded_at))) { rows.push(record); } } catch { diff --git a/scripts/lib/skill-evolution/versioning.js b/scripts/lib/skill-evolution/versioning.js index 83a309d74..307fa599f 100644 --- a/scripts/lib/skill-evolution/versioning.js +++ b/scripts/lib/skill-evolution/versioning.js @@ -100,7 +100,14 @@ function listVersions(skillPath) { } const filePath = path.join(versionsDir, fileName); - const stats = fs.statSync(filePath); + let stats; + try { + stats = fs.statSync(filePath); + } catch (error) { + // A dangling link still occupies a path, but is not a readable snapshot. + if (error.code === 'ENOENT') return null; + throw error; + } if (!stats.isFile()) { return null; } @@ -146,7 +153,10 @@ function readJsonl(filePath) { .filter(Boolean) .reduce((rows, line) => { try { - rows.push(JSON.parse(line)); + const record = JSON.parse(line); + if (record && typeof record === 'object' && !Array.isArray(record)) { + rows.push(record); + } } catch { // Ignore malformed rows so the log remains append-only and resilient. } @@ -163,12 +173,18 @@ function createVersion(skillPath, options = {}) { 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 versionsDir = getVersionsDir(skillPath); + const occupiedPaths = new Set(fs.readdirSync(versionsDir)); + let nextVersion = versions.length === 0 ? 1 : versions[versions.length - 1].version + 1; + // Listing excludes non-files, but their paths remain user-owned and occupied. + while (occupiedPaths.has(`v${nextVersion}.md`)) { + nextVersion += 1; + } + const snapshotPath = path.join(versionsDir, `v${nextVersion}.md`); const skillContent = fs.readFileSync(skillFilePath, 'utf8'); const createdAt = options.timestamp || new Date().toISOString(); - fs.writeFileSync(snapshotPath, skillContent, 'utf8'); + fs.writeFileSync(snapshotPath, skillContent, { encoding: 'utf8', flag: 'wx' }); appendEvolutionRecord(skillPath, 'amendments', { event: 'snapshot', version: nextVersion, diff --git a/tests/lib/skill-evolution-storage.test.js b/tests/lib/skill-evolution-storage.test.js new file mode 100644 index 000000000..4b070ba06 --- /dev/null +++ b/tests/lib/skill-evolution-storage.test.js @@ -0,0 +1,70 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const versioning = require('../../scripts/lib/skill-evolution/versioning'); +const health = require('../../scripts/lib/skill-evolution/health'); + +let passed = 0; +let failed = 0; + +function createFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-evolution-storage-')); + const skillDir = path.join(root, 'skills', 'demo'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Original\n'); + return { root, skillDir, versionsDir: versioning.getVersionsDir(skillDir) }; +} + +function test(name, fn) { + const fixture = createFixture(); + try { + fn(fixture); + passed += 1; + console.log(` ✓ ${name}`); + } catch (error) { + failed += 1; + console.error(` ✗ ${name}: ${error.message}`); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +} + +test('snapshot creation preserves occupied directories and links plus earlier snapshots', fixture => { + const { root, skillDir, versionsDir } = fixture; + versioning.createVersion(skillDir); + fs.mkdirSync(path.join(versionsDir, 'v2.md')); + fs.writeFileSync(path.join(versionsDir, 'v2.md', 'keep.txt'), 'preserve'); + if (process.platform !== 'win32') { + fs.symlinkSync(path.join(root, 'missing'), path.join(versionsDir, 'v3.md')); + } + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Updated\n'); + const snapshot = versioning.createVersion(skillDir); + assert.equal(snapshot.version, process.platform === 'win32' ? 3 : 4); + assert.equal(fs.readFileSync(snapshot.path, 'utf8'), '# Updated\n'); + assert.equal(fs.readFileSync(path.join(versionsDir, 'v1.md'), 'utf8'), '# Original\n'); + assert.equal(fs.readFileSync(path.join(versionsDir, 'v2.md', 'keep.txt'), 'utf8'), 'preserve'); + assert.equal(versioning.getEvolutionLog(skillDir, 'amendments').at(-1).version, snapshot.version); + if (process.platform !== 'win32') { + assert.ok(fs.lstatSync(path.join(versionsDir, 'v3.md')).isSymbolicLink()); + fs.symlinkSync(path.join(versionsDir, 'v1.md'), path.join(versionsDir, 'v5.md')); + assert.deepEqual(versioning.listVersions(skillDir).map(entry => entry.version), [1, 4, 5]); + assert.equal(versioning.getCurrentVersion(skillDir), 5); + } +}); + +test('health skips malformed amendment rows while retaining a valid pending proposal', fixture => { + const { root, skillDir } = fixture; + versioning.ensureSkillVersioning(skillDir); + fs.writeFileSync(versioning.getEvolutionLogPath(skillDir, 'amendments'), + 'null\n[]\n42\n{bad}\n{"event":"proposal","status":"pending"}\n'); + const report = health.collectSkillHealth({ + skillsRoot: path.join(root, 'skills'), homeDir: root, now: '2026-03-15T12:00:00.000Z', + }); + assert.equal(report.skills.find(skill => skill.skill_id === 'demo').pending_amendments, 1); +}); + +console.log(`Results: Passed: ${passed}, Failed: ${failed}`); +process.exitCode = failed > 0 ? 1 : 0; diff --git a/tests/lib/skill-evolution.test.js b/tests/lib/skill-evolution.test.js index 705c692ab..b23b65757 100644 --- a/tests/lib/skill-evolution.test.js +++ b/tests/lib/skill-evolution.test.js @@ -320,7 +320,7 @@ function runTests() { duration_ms: 1, user_feedback: 'accepted', recorded_at: '2026-03-15T11:45:00.000Z', - })}\n{bad-json}\nnull\n42\nfalse\n"not a record"\n[]\n`, + })}\n{bad-json}\nnull\n42\nfalse\n"not a record"\n[]\n{}\n{"skill_id":42}\n{"skill_id":""}\n{"skill_id":"alpha","outcome":"invalid","recorded_at":"2026-03-15T11:45:00.000Z"}\n{"skill_id":"alpha","outcome":"success","recorded_at":"bad-date"}\n`, 'utf8' );