@unblocklabs/skill-usage-audit 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,919 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Daily skill health evaluator for skill-usage-audit telemetry.
4
+ *
5
+ * Reads skill_executions from $SKILL_USAGE_AUDIT_DB_PATH (or ~/.openclaw/audits/skill-usage.db), computes
6
+ * per-skill health metrics, writes snapshots to SQLite, updates skills.status,
7
+ * and renders a human-readable markdown report.
8
+ */
9
+
10
+ import { mkdir } from "node:fs/promises";
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, resolve, join, basename, sep } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+ const DEFAULT_DB_PATH = process.env.SKILL_USAGE_AUDIT_DB_PATH || "~/.openclaw/audits/skill-usage.db";
18
+ const DEFAULT_REPORT_DIR = resolve(__dirname, "../../reports/skill-health");
19
+
20
+ const DEFAULT_WINDOW_DAYS = 7;
21
+ const DEFAULT_STABLE_MIN_USAGE = 10;
22
+ const DEFAULT_EXPERIMENTAL_MIN_USAGE = 10;
23
+ const DEFAULT_DEGRADED_SAMPLE_MIN = 5;
24
+ const DEFAULT_DEGRADED_MECHANICAL_FAIL_RATE = 0.2;
25
+ const DEFAULT_DEGRADED_IMPLIED_NEG_RATE = 0.3;
26
+ const DEFAULT_UNDERUSED_MAX = 2;
27
+
28
+ function resolveHome(pathLike) {
29
+ if (!pathLike || typeof pathLike !== "string") return pathLike;
30
+ if (!pathLike.startsWith("~")) return resolve(pathLike);
31
+ const home = process.env.HOME || process.env.USERPROFILE;
32
+ if (!home) return resolve(pathLike);
33
+ return resolve(home, pathLike.slice(2));
34
+ }
35
+
36
+ function parseArgs() {
37
+ const args = process.argv.slice(2);
38
+ const out = {
39
+ dbPath: DEFAULT_DB_PATH,
40
+ reportDir: DEFAULT_REPORT_DIR,
41
+ windowDays: DEFAULT_WINDOW_DAYS,
42
+ stableMinUsage: DEFAULT_STABLE_MIN_USAGE,
43
+ experimentalMinUsage: DEFAULT_EXPERIMENTAL_MIN_USAGE,
44
+ degradedSampleMin: DEFAULT_DEGRADED_SAMPLE_MIN,
45
+ degradedMechanicalRate: DEFAULT_DEGRADED_MECHANICAL_FAIL_RATE,
46
+ degradedImpliedRate: DEFAULT_DEGRADED_IMPLIED_NEG_RATE,
47
+ underusedMax: DEFAULT_UNDERUSED_MAX,
48
+ writeDb: true,
49
+ writeReport: true,
50
+ verbose: false,
51
+ includeFilesystemSkills: true,
52
+ };
53
+
54
+ const take = (idx) => {
55
+ const value = args[idx + 1];
56
+ if (value === undefined) {
57
+ throw new Error(`Missing value for ${args[idx]}`);
58
+ }
59
+ return value;
60
+ };
61
+
62
+ for (let i = 0; i < args.length; i += 1) {
63
+ const arg = args[i];
64
+
65
+ if (arg === "--db-path" || arg === "--db") {
66
+ out.dbPath = take(i);
67
+ i += 1;
68
+ continue;
69
+ }
70
+ if (arg === "--report-dir") {
71
+ out.reportDir = take(i);
72
+ i += 1;
73
+ continue;
74
+ }
75
+ if (arg.startsWith("--db-path=")) {
76
+ out.dbPath = arg.split("=")[1];
77
+ continue;
78
+ }
79
+ if (arg.startsWith("--report-dir=")) {
80
+ out.reportDir = arg.split("=")[1];
81
+ continue;
82
+ }
83
+ if (arg === "--window-days") {
84
+ out.windowDays = Number.parseInt(take(i), 10);
85
+ i += 1;
86
+ continue;
87
+ }
88
+ if (arg === "--windowDays") {
89
+ out.windowDays = Number.parseInt(take(i), 10);
90
+ i += 1;
91
+ continue;
92
+ }
93
+ if (arg === "--stable-min-usage") {
94
+ out.stableMinUsage = Number.parseInt(take(i), 10);
95
+ i += 1;
96
+ continue;
97
+ }
98
+ if (arg === "--experimental-min-usage") {
99
+ out.experimentalMinUsage = Number.parseInt(take(i), 10);
100
+ i += 1;
101
+ continue;
102
+ }
103
+ if (arg === "--degraded-sample-min") {
104
+ out.degradedSampleMin = Number.parseInt(take(i), 10);
105
+ i += 1;
106
+ continue;
107
+ }
108
+ if (arg === "--degraded-mechanical-rate") {
109
+ out.degradedMechanicalRate = Number.parseFloat(take(i));
110
+ i += 1;
111
+ continue;
112
+ }
113
+ if (arg === "--degraded-implied-rate") {
114
+ out.degradedImpliedRate = Number.parseFloat(take(i));
115
+ i += 1;
116
+ continue;
117
+ }
118
+ if (arg === "--underused-max") {
119
+ out.underusedMax = Number.parseInt(take(i), 10);
120
+ i += 1;
121
+ continue;
122
+ }
123
+ if (arg === "--no-update-status") {
124
+ out.writeDb = false;
125
+ continue;
126
+ }
127
+ if (arg === "--no-report") {
128
+ out.writeReport = false;
129
+ continue;
130
+ }
131
+ if (arg === "--no-filesystem-scan") {
132
+ out.includeFilesystemSkills = false;
133
+ continue;
134
+ }
135
+ if (arg === "--verbose") {
136
+ out.verbose = true;
137
+ continue;
138
+ }
139
+
140
+ if (arg === "--help" || arg === "-h") {
141
+ printUsage();
142
+ process.exit(0);
143
+ }
144
+
145
+ throw new Error(`Unknown arg: ${arg}`);
146
+ }
147
+
148
+ if (!Number.isFinite(out.windowDays) || out.windowDays <= 0) out.windowDays = DEFAULT_WINDOW_DAYS;
149
+ if (!Number.isFinite(out.stableMinUsage) || out.stableMinUsage <= 0) out.stableMinUsage = DEFAULT_STABLE_MIN_USAGE;
150
+ if (!Number.isFinite(out.experimentalMinUsage) || out.experimentalMinUsage <= 0) out.experimentalMinUsage = DEFAULT_EXPERIMENTAL_MIN_USAGE;
151
+ if (!Number.isFinite(out.degradedSampleMin) || out.degradedSampleMin <= 0) out.degradedSampleMin = DEFAULT_DEGRADED_SAMPLE_MIN;
152
+ if (!Number.isFinite(out.degradedMechanicalRate) || out.degradedMechanicalRate < 0) out.degradedMechanicalRate = DEFAULT_DEGRADED_MECHANICAL_FAIL_RATE;
153
+ if (!Number.isFinite(out.degradedImpliedRate) || out.degradedImpliedRate < 0) out.degradedImpliedRate = DEFAULT_DEGRADED_IMPLIED_NEG_RATE;
154
+ if (!Number.isFinite(out.underusedMax) || out.underusedMax < 0) out.underusedMax = DEFAULT_UNDERUSED_MAX;
155
+
156
+ return out;
157
+ }
158
+
159
+ function printUsage() {
160
+ const usage = `
161
+ Usage: node evaluate-skill-health.mjs [options]
162
+
163
+ Options:
164
+ --db-path <path> SQLite DB path (default: ${DEFAULT_DB_PATH})
165
+ --report-dir <path> Report output folder (default: ${DEFAULT_REPORT_DIR})
166
+ --window-days <n> Review window in days (default: ${DEFAULT_WINDOW_DAYS})
167
+ --stable-min-usage <n> Minimum usage for stable classification (default: ${DEFAULT_STABLE_MIN_USAGE})
168
+ --experimental-min-usage <n> Current-version usage threshold for experimental status (default: ${DEFAULT_EXPERIMENTAL_MIN_USAGE})
169
+ --degraded-sample-min <n> Minimum sample for degraded checks (default: ${DEFAULT_DEGRADED_SAMPLE_MIN})
170
+ --degraded-mechanical-rate <r> Mechanical fail threshold (default: ${DEFAULT_DEGRADED_MECHANICAL_FAIL_RATE})
171
+ --degraded-implied-rate <r> Implied negative threshold (default: ${DEFAULT_DEGRADED_IMPLIED_NEG_RATE})
172
+ --underused-max <n> Underused upper bound (default: ${DEFAULT_UNDERUSED_MAX})
173
+ --no-update-status Skip updating skills.status
174
+ --no-report Skip writing markdown reports
175
+ --no-filesystem-scan Skip scanning workspace/.openclaw for SKILL.md
176
+ --verbose Print extra debug lines
177
+ -h, --help Show this help
178
+ `;
179
+ console.log(usage.trimStart());
180
+ }
181
+
182
+ function buildWorkspaceRoots() {
183
+ const workspaceRoot = resolve(__dirname, "../..");
184
+ const home = process.env.HOME || process.env.USERPROFILE;
185
+ if (!home) {
186
+ return {
187
+ workspaceSkills: resolve(workspaceRoot, "skills"),
188
+ openclawSkills: undefined,
189
+ extensionSkillsDir: undefined,
190
+ workspaceRoot,
191
+ };
192
+ }
193
+
194
+ return {
195
+ workspaceSkills: resolve(workspaceRoot, "skills"),
196
+ openclawSkills: resolve(home, ".openclaw", "skills"),
197
+ extensionSkillsDir: resolve(home, ".openclaw", "extensions"),
198
+ workspaceRoot,
199
+ };
200
+ }
201
+
202
+ function inferSkillNameFromPath(skillPath) {
203
+ const dir = dirname(skillPath);
204
+ const base = basename(skillPath);
205
+ if (base.toLowerCase() === "skill.md") return basename(dir);
206
+ return basename(skillPath);
207
+ }
208
+
209
+ async function listSkillFiles(root, maxDepth = 5) {
210
+ if (!root) return [];
211
+ const out = [];
212
+ const seen = new Set();
213
+
214
+ const walk = async (dir, depth) => {
215
+ if (depth > maxDepth) return;
216
+ let entries;
217
+ try {
218
+ entries = await import("node:fs/promises").then((m) => m.readdir(dir, { withFileTypes: true }));
219
+ } catch {
220
+ return;
221
+ }
222
+
223
+ for (const entry of entries) {
224
+ if (!entry.isDirectory() && !entry.isFile()) continue;
225
+ const next = resolve(dir, entry.name);
226
+ const lower = entry.name.toLowerCase();
227
+
228
+ if (entry.isDirectory()) {
229
+ if (lower === ".git" || lower === "node_modules" || lower === ".openclaw") {
230
+ continue;
231
+ }
232
+ if (next.includes(`${sep}dist${sep}`) || next.includes(`${sep}build${sep}`)) {
233
+ continue;
234
+ }
235
+ await walk(next, depth + 1);
236
+ continue;
237
+ }
238
+
239
+ if (lower === "skill.md") {
240
+ if (!seen.has(next)) {
241
+ seen.add(next);
242
+ out.push(next);
243
+ }
244
+ }
245
+ }
246
+ };
247
+
248
+ await walk(root, 0);
249
+ return out;
250
+ }
251
+
252
+ async function collectFilesystemSkills(roots, include) {
253
+ if (!include) return new Map();
254
+
255
+ const skills = new Map();
256
+
257
+ const add = (path) => {
258
+ const skillName = inferSkillNameFromPath(path);
259
+ const current = skills.get(skillName);
260
+ const candidate = { skillName, path };
261
+ if (!current) {
262
+ skills.set(skillName, candidate);
263
+ return;
264
+ }
265
+
266
+ // Prefer workspace skills over extension/bundled when duplicates exist.
267
+ if (!current.path.includes("/skills/") && path.includes("/skills/")) {
268
+ skills.set(skillName, candidate);
269
+ }
270
+ };
271
+
272
+ for (const root of [roots.workspaceSkills, roots.openclawSkills, roots.extensionSkillsDir]) {
273
+ const files = await listSkillFiles(root, root === roots.extensionSkillsDir ? 3 : 3);
274
+ for (const f of files) add(f);
275
+ }
276
+
277
+ return skills;
278
+ }
279
+
280
+ function createSqliteBackend(dbPath) {
281
+ const normalized = resolveHome(dbPath);
282
+ const kind = (name) => ({
283
+ better: "better-sqlite3",
284
+ node: "node:sqlite",
285
+ missing: "none",
286
+ }[name] || "unknown");
287
+
288
+ return (async () => {
289
+ try {
290
+ const sqlite = await import("better-sqlite3");
291
+ const BetterSqlite3 = sqlite.default || sqlite;
292
+ const db = new BetterSqlite3(normalized);
293
+ db.pragma("journal_mode = WAL");
294
+ db.pragma("foreign_keys = ON");
295
+ return {
296
+ kind: kind("better"),
297
+ db,
298
+ exec: (sql) => db.exec(sql),
299
+ prepare: (sql) => {
300
+ const stmt = db.prepare(sql);
301
+ return {
302
+ run: (params) => Array.isArray(params) ? stmt.run(...params) : stmt.run(params),
303
+ get: (params) => (Array.isArray(params) ? stmt.get(...params) : stmt.get(params)),
304
+ all: (params) => (Array.isArray(params) ? stmt.all(...params) : stmt.all(params)),
305
+ };
306
+ },
307
+ close: () => db.close(),
308
+ };
309
+ } catch {
310
+ // fallback to node:sqlite
311
+ }
312
+
313
+ try {
314
+ const sqlite = await import("node:sqlite");
315
+ if (!sqlite?.DatabaseSync) throw new Error("DatabaseSync missing");
316
+ const db = new sqlite.DatabaseSync(normalized);
317
+ db.exec("PRAGMA journal_mode = WAL;");
318
+ db.exec("PRAGMA foreign_keys = ON;");
319
+ return {
320
+ kind: kind("node"),
321
+ db,
322
+ exec: (sql) => db.exec(sql),
323
+ prepare: (sql) => {
324
+ const stmt = db.prepare(sql);
325
+ return {
326
+ run: (params) => Array.isArray(params) ? stmt.run(...params) : stmt.run(params),
327
+ get: (params) => (Array.isArray(params) ? stmt.get(...params) : stmt.get(params)),
328
+ all: (params) => (Array.isArray(params) ? stmt.all(...params) : stmt.all(params)),
329
+ };
330
+ },
331
+ close: () => db.close(),
332
+ };
333
+ } catch {
334
+ throw new Error("No sqlite backend available (tried better-sqlite3 and node:sqlite)");
335
+ }
336
+ })();
337
+ }
338
+
339
+ function createSchema(db) {
340
+ const createSql = `
341
+ CREATE TABLE IF NOT EXISTS skill_events (
342
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
343
+ ts TEXT NOT NULL,
344
+ type TEXT NOT NULL,
345
+ session_id TEXT,
346
+ session_key TEXT,
347
+ run_id TEXT,
348
+ agent_id TEXT,
349
+ channel_id TEXT,
350
+ message_provider TEXT,
351
+ tool_name TEXT,
352
+ tool_call_id TEXT,
353
+ params TEXT,
354
+ duration_ms INTEGER,
355
+ success INTEGER,
356
+ error TEXT,
357
+ skill_name TEXT,
358
+ skill_path TEXT,
359
+ skill_source TEXT,
360
+ skill_block_count INTEGER,
361
+ skill_block_names TEXT,
362
+ skill_block_locations TEXT
363
+ );
364
+
365
+ CREATE TABLE IF NOT EXISTS skill_versions (
366
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
367
+ skill_name TEXT NOT NULL,
368
+ skill_path TEXT NOT NULL,
369
+ version_hash TEXT NOT NULL,
370
+ first_seen_at TEXT NOT NULL,
371
+ notes TEXT,
372
+ UNIQUE(skill_name, skill_path, version_hash)
373
+ );
374
+
375
+ CREATE TABLE IF NOT EXISTS skills (
376
+ skill_name TEXT PRIMARY KEY,
377
+ skill_path TEXT NOT NULL,
378
+ current_version_hash TEXT,
379
+ status TEXT DEFAULT 'stable',
380
+ last_modified_at TEXT,
381
+ last_used_at TEXT,
382
+ total_executions INTEGER DEFAULT 0
383
+ );
384
+
385
+ CREATE TABLE IF NOT EXISTS skill_executions (
386
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
387
+ ts TEXT NOT NULL,
388
+ session_key TEXT,
389
+ run_id TEXT,
390
+ skill_name TEXT NOT NULL,
391
+ skill_path TEXT NOT NULL,
392
+ version_hash TEXT,
393
+ intent_context TEXT,
394
+ mechanical_success INTEGER,
395
+ semantic_outcome TEXT,
396
+ followup_messages TEXT,
397
+ implied_outcome TEXT,
398
+ error TEXT,
399
+ duration_ms INTEGER,
400
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
401
+ );
402
+
403
+ CREATE TABLE IF NOT EXISTS skill_feedback (
404
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
405
+ execution_id INTEGER REFERENCES skill_executions(id),
406
+ source TEXT,
407
+ label TEXT,
408
+ notes TEXT,
409
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
410
+ );
411
+
412
+ CREATE TABLE IF NOT EXISTS skill_health_snapshots (
413
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
414
+ ts TEXT NOT NULL,
415
+ skill_name TEXT NOT NULL,
416
+ version_hash TEXT,
417
+ usage_count INTEGER DEFAULT 0,
418
+ mechanical_failure_rate REAL DEFAULT 0,
419
+ implied_negative_rate REAL DEFAULT 0,
420
+ status_recommendation TEXT,
421
+ notes TEXT,
422
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
423
+ );
424
+
425
+ CREATE INDEX IF NOT EXISTS idx_skill_executions_name_ts
426
+ ON skill_executions(skill_name, ts);
427
+ CREATE INDEX IF NOT EXISTS idx_skill_health_snapshots_name_ts
428
+ ON skill_health_snapshots(skill_name, ts);
429
+ `;
430
+
431
+ db.exec(createSql);
432
+ }
433
+
434
+ function percent(value) {
435
+ if (!Number.isFinite(value)) return "0.00%";
436
+ return `${(value * 100).toFixed(2)}%`;
437
+ }
438
+
439
+ function toNumber(value, fallback = 0) {
440
+ const parsed = Number.parseFloat(value);
441
+ return Number.isFinite(parsed) ? parsed : fallback;
442
+ }
443
+
444
+ function clamp(value) {
445
+ if (!Number.isFinite(value)) return 0;
446
+ if (value < 0) return 0;
447
+ if (value > 1) return 1;
448
+ return value;
449
+ }
450
+
451
+ function computeVersionRate(summary, metricField, defaultIfZero = 0) {
452
+ if (!summary) return defaultIfZero;
453
+ const sample = metricField === "implied"
454
+ ? summary.impliedSample
455
+ : summary.mechanicalSample;
456
+ const failures = metricField === "implied"
457
+ ? summary.impliedNegative
458
+ : summary.mechanicalFailures;
459
+ if (!sample || sample <= 0) return defaultIfZero;
460
+ return clamp(failures / sample);
461
+ }
462
+
463
+ function dedupeLine(s) {
464
+ return s.replace(/\s+/g, " ").trim();
465
+ }
466
+
467
+ function evaluateSkills({
468
+ executionsBySkill,
469
+ skillMeta,
470
+ options,
471
+ }) {
472
+ const reportBuckets = {
473
+ stable: [],
474
+ experimental: [],
475
+ degraded: [],
476
+ underused: [],
477
+ unused: [],
478
+ };
479
+
480
+ const versionCompareNotes = [];
481
+ const rowsToPersist = [];
482
+
483
+ for (const skill of skillMeta.values()) {
484
+ const {
485
+ skillName,
486
+ skillPath,
487
+ currentVersionHash,
488
+ dbStatus,
489
+ } = skill;
490
+
491
+ const st = executionsBySkill.get(skillName) || {
492
+ usageCount: 0,
493
+ versions: new Map(),
494
+ latestTs: null,
495
+ };
496
+
497
+ const versions = [...st.versions.entries()].map(([versionHash, summary]) => ({
498
+ versionHash,
499
+ usage: summary.usage,
500
+ mechanicalSample: summary.mechanicalSample,
501
+ mechanicalFailures: summary.mechanicalFailures,
502
+ impliedSample: summary.impliedSample,
503
+ impliedNegative: summary.impliedNegative,
504
+ mechanicalRate: computeVersionRate({
505
+ mechanicalSample: summary.mechanicalSample,
506
+ mechanicalFailures: summary.mechanicalFailures,
507
+ }, "mechanical", 0),
508
+ impliedRate: computeVersionRate({
509
+ impliedSample: summary.impliedSample,
510
+ impliedNegative: summary.impliedNegative,
511
+ }, "implied", 0),
512
+ }));
513
+
514
+
515
+ const totalMechanic = versions.reduce((acc, row) => acc + row.mechanicalFailures, 0);
516
+ const totalMechSample = versions.reduce((acc, row) => acc + row.mechanicalSample, 0);
517
+ const totalImpliedNeg = versions.reduce((acc, row) => acc + row.impliedNegative, 0);
518
+ const totalImpliedSample = versions.reduce((acc, row) => acc + row.impliedSample, 0);
519
+
520
+ const totalMechanicalRate = totalMechSample ? totalMechanic / totalMechSample : 0;
521
+ const totalImpliedRate = totalImpliedSample ? totalImpliedNeg / totalImpliedSample : 0;
522
+
523
+ const knownVersionsSorted = [...versions].sort((a, b) => {
524
+ if (a.versionHash === (currentVersionHash || "")) return -1;
525
+ if (b.versionHash === (currentVersionHash || "")) return 1;
526
+ return b.usage - a.usage;
527
+ });
528
+
529
+ const current =
530
+ knownVersionsSorted.find((v) => v.versionHash === currentVersionHash) ||
531
+ knownVersionsSorted[0];
532
+
533
+ const currentUsage = current?.usage || 0;
534
+ const currentMechRate = current?.mechanicalRate ?? totalMechanicalRate;
535
+ const currentImpliedRate = current?.impliedRate ?? totalImpliedRate;
536
+ const currentMechSample = current?.mechanicalSample || 0;
537
+ const currentImpliedSample = current?.impliedSample || 0;
538
+
539
+ const rec = (() => {
540
+ if (st.usageCount === 0) return "unused";
541
+ if (st.usageCount <= options.underusedMax) return "underused";
542
+ if (currentUsage < options.experimentalMinUsage) return "experimental";
543
+
544
+ const isDegraded =
545
+ (currentMechSample >= options.degradedSampleMin && currentMechRate > options.degradedMechanicalRate) ||
546
+ (currentImpliedSample >= options.degradedSampleMin && currentImpliedRate > options.degradedImpliedRate);
547
+
548
+ if (isDegraded) return "degraded";
549
+ if (st.usageCount >= options.stableMinUsage && currentMechRate <= options.degradedMechanicalRate && currentImpliedRate <= options.degradedImpliedRate) {
550
+ return "stable";
551
+ }
552
+ return "experimental";
553
+ })();
554
+
555
+ const dbStatusUpdate = rec === "unused"
556
+ ? "unused"
557
+ : rec === "degraded"
558
+ ? "degraded"
559
+ : rec === "stable"
560
+ ? "stable"
561
+ : "experimental";
562
+
563
+ const versionNotes = [];
564
+
565
+ if (versions.length > 1) {
566
+ versionNotes.push("Multiple versions observed in window:");
567
+ const sortedForNotes = [...versions].sort((a, b) => b.usage - a.usage);
568
+ for (const v of sortedForNotes) {
569
+ const short = v.versionHash ? v.versionHash.slice(0, 10) : "(no-hash)";
570
+ versionNotes.push(
571
+ `- ${short} usage=${v.usage} fail=${percent(v.mechanicalRate)} neg=${percent(v.impliedRate)}`,
572
+ );
573
+ }
574
+ if (currentVersionHash) {
575
+ const curr = versions.find((x) => x.versionHash === currentVersionHash);
576
+ const prior = versions.find((x) => x.versionHash !== currentVersionHash && x.usage > 0);
577
+ if (curr && prior) {
578
+ versionCompareNotes.push(
579
+ `${skillName}: current ${curr.versionHash?.slice(0, 8) || "(no-hash)"} (${curr.usage} runs)` +
580
+ ` vs prior ${prior.versionHash?.slice(0, 8) || "(no-hash)"} (${prior.usage} runs)`,
581
+ );
582
+ }
583
+ }
584
+ }
585
+
586
+ const rowNote = versionNotes.length ? `${versionNotes.join(" ")}` : "single version in window";
587
+
588
+ rowsToPersist.push({
589
+ skillName,
590
+ versionHash: current?.versionHash || null,
591
+ usageCount: st.usageCount,
592
+ mechanicalFailureRate: currentMechRate,
593
+ impliedNegativeRate: currentImpliedRate,
594
+ statusRecommendation: dbStatusUpdate,
595
+ notes: dedupeLine(rowNote),
596
+ dbStatus,
597
+ previousStatus: dbStatus,
598
+ recommendation: rec,
599
+ mechanicalFailureRateOverall: totalMechanicalRate,
600
+ impliedNegativeRateOverall: totalImpliedRate,
601
+ latestTs: st.latestTs,
602
+ skillPath,
603
+ });
604
+
605
+ if (rec === "stable") {
606
+ reportBuckets.stable.push(rowsToPersist.at(-1));
607
+ } else if (rec === "degraded") {
608
+ reportBuckets.degraded.push(rowsToPersist.at(-1));
609
+ } else if (rec === "underused") {
610
+ reportBuckets.underused.push(rowsToPersist.at(-1));
611
+ } else if (rec === "unused") {
612
+ reportBuckets.unused.push(rowsToPersist.at(-1));
613
+ } else {
614
+ reportBuckets.experimental.push(rowsToPersist.at(-1));
615
+ }
616
+ }
617
+
618
+ return { reportBuckets, versionCompareNotes, rowsToPersist };
619
+ }
620
+
621
+ function buildReport({
622
+ at,
623
+ windowStart,
624
+ windowEnd,
625
+ options,
626
+ buckets,
627
+ versionCompareNotes,
628
+ rows,
629
+ rowsUpdated,
630
+ }) {
631
+ const totalSkills = rows.length;
632
+ const stable = buckets.stable.length;
633
+ const experimental = buckets.experimental.length;
634
+ const degraded = buckets.degraded.length;
635
+ const underused = buckets.underused.length;
636
+ const unused = buckets.unused.length;
637
+
638
+ const formatLine = (row) => {
639
+ const currentVersion = row.versionHash ? row.versionHash.slice(0, 10) : "(none)";
640
+ const status = row.recommendation;
641
+ const lines = [
642
+ `- **${row.skillName}**`,
643
+ ` - status: ${status}`,
644
+ ` - usage: ${row.usageCount}`,
645
+ ` - mechanical failure: ${percent(row.mechanicalFailureRate)}`,
646
+ ` - implied negative: ${percent(row.impliedNegativeRate)}`,
647
+ ` - current version: ${currentVersion}`,
648
+ ];
649
+ if (row.dbStatus) lines.push(` - previous db status: ${row.dbStatus}`);
650
+ if (row.dbStatus !== row.statusRecommendation) {
651
+ lines.push(` - db status update: ${row.statusRecommendation}`);
652
+ }
653
+ return lines.join("\n");
654
+ };
655
+
656
+ const topFollowups = [];
657
+ if (buckets.degraded.length) {
658
+ topFollowups.push("Investigate degraded skills and check for recent regressions or dependency/environment drift.");
659
+ }
660
+ if (buckets.underused.length || buckets.unused.length) {
661
+ topFollowups.push("Review underused/unused skills for possible retirement, documentation refresh, or re-scoping.");
662
+ }
663
+ if (buckets.experimental.length) {
664
+ topFollowups.push("Keep experimental skills monitored; enforce minimum samples before promoting to stable.");
665
+ }
666
+ if (!topFollowups.length) {
667
+ topFollowups.push("No immediate follow-ups from current threshold set.");
668
+ }
669
+
670
+ const section = (title, items) => {
671
+ if (!items.length) return `\n## ${title}\n\n- _No items_.\n`;
672
+ return `\n## ${title}\n\n${items.map(formatLine).join("\n")}\n`;
673
+ };
674
+
675
+ const versionNotesSection = versionCompareNotes.length
676
+ ? `\n## Version comparison notes\n\n${versionCompareNotes.map((line) => `- ${line}`).join("\n")}\n`
677
+ : "\n## Version comparison notes\n\n- _No multi-version comparison opportunities in this window._\n";
678
+
679
+ const updatedRowCount = rowsUpdated.length;
680
+
681
+ return `# Skill Health Report\n\n` +
682
+ `Generated: ${at.toISOString()}\n` +
683
+ `Window: ${windowStart} to ${windowEnd} (${Math.max(0, Math.ceil((new Date(windowEnd) - new Date(windowStart)) / 86400000))} day(s))\n` +
684
+ `Database: ${options.dbPath}\n` +
685
+ `Report mode: deterministic / no-LLM\n` +
686
+ `\n## Summary counts\n` +
687
+ `- skills evaluated: ${totalSkills}\n` +
688
+ `- stable: ${stable}\n` +
689
+ `- experimental: ${experimental}\n` +
690
+ `- degraded: ${degraded}\n` +
691
+ `- underused: ${underused}\n` +
692
+ `- unused: ${unused}\n` +
693
+ `- snapshots written: ${updatedRowCount}\n` +
694
+ section("Stable skills", buckets.stable) +
695
+ section("Experimental skills", buckets.experimental) +
696
+ section("Degraded skills", buckets.degraded) +
697
+ section("Underused / unused skills", [...buckets.underused, ...buckets.unused]) +
698
+ versionNotesSection +
699
+ `\n## Recommended follow-ups\n\n${topFollowups.map((x) => `- ${x}`).join("\n")}\n`;
700
+ }
701
+
702
+ (async () => {
703
+ const options = parseArgs();
704
+ options.dbPath = resolveHome(options.dbPath);
705
+ options.reportDir = resolveHome(options.reportDir);
706
+
707
+ if (options.verbose) {
708
+ console.log("SKILL HEALTH: starting", JSON.stringify(options, null, 2));
709
+ }
710
+
711
+ const roots = buildWorkspaceRoots();
712
+ const fsSkills = options.includeFilesystemSkills ? await collectFilesystemSkills(roots, true) : new Map();
713
+
714
+ const db = await createSqliteBackend(options.dbPath);
715
+ const startedAt = new Date().toISOString();
716
+ try {
717
+ createSchema(db);
718
+
719
+ const getNow = new Date();
720
+ const windowStart = new Date(getNow.getTime() - options.windowDays * 24 * 60 * 60 * 1000).toISOString();
721
+ const windowEnd = getNow.toISOString();
722
+
723
+ const executionRowsStmt = db.prepare(
724
+ `SELECT skill_name, version_hash, mechanical_success, implied_outcome, ts
725
+ FROM skill_executions
726
+ WHERE ts >= ?
727
+ ORDER BY skill_name, ts ASC`,
728
+ );
729
+
730
+ const skillRowsStmt = db.prepare(
731
+ `SELECT skill_name, skill_path, current_version_hash, status
732
+ FROM skills`,
733
+ );
734
+
735
+ const updateSkillStmt = db.prepare(
736
+ `INSERT INTO skills (
737
+ skill_name,
738
+ skill_path,
739
+ current_version_hash,
740
+ status,
741
+ last_modified_at,
742
+ last_used_at,
743
+ total_executions
744
+ ) VALUES (
745
+ ?, ?, ?, ?, ?, ?, 0
746
+ ) ON CONFLICT(skill_name) DO UPDATE SET
747
+ skill_path = excluded.skill_path,
748
+ current_version_hash = COALESCE(excluded.current_version_hash, skills.current_version_hash),
749
+ status = excluded.status,
750
+ last_modified_at = excluded.last_modified_at,
751
+ last_used_at = COALESCE(excluded.last_used_at, skills.last_used_at)
752
+ `,
753
+ );
754
+
755
+ const upsertSnapshotStmt = db.prepare(
756
+ `INSERT INTO skill_health_snapshots (
757
+ ts, skill_name, version_hash, usage_count,
758
+ mechanical_failure_rate, implied_negative_rate,
759
+ status_recommendation, notes
760
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
761
+ );
762
+
763
+ const executions = executionRowsStmt.all([windowStart]) || [];
764
+ const executionsBySkill = new Map();
765
+
766
+ for (const row of executions) {
767
+ const skillName = row.skill_name;
768
+ if (!skillName) continue;
769
+
770
+ const existing = executionsBySkill.get(skillName) || {
771
+ usageCount: 0,
772
+ versions: new Map(),
773
+ latestTs: null,
774
+ };
775
+ const versionKey = row.version_hash || "(no-version)";
776
+ const versionSummary = existing.versions.get(versionKey) || {
777
+ usage: 0,
778
+ mechanicalSuccesses: 0,
779
+ mechanicalFailures: 0,
780
+ mechanicalSample: 0,
781
+ impliedNegative: 0,
782
+ impliedSample: 0,
783
+ };
784
+
785
+ existing.usageCount += 1;
786
+ versionSummary.usage += 1;
787
+
788
+ if (row.mechanical_success === 0 || row.mechanical_success === 1) {
789
+ versionSummary.mechanicalSample += 1;
790
+ if (row.mechanical_success === 0) versionSummary.mechanicalFailures += 1;
791
+ else versionSummary.mechanicalSuccesses += 1;
792
+ }
793
+
794
+ if (row.implied_outcome) {
795
+ versionSummary.impliedSample += 1;
796
+ if (String(row.implied_outcome).toLowerCase() === "negative") {
797
+ versionSummary.impliedNegative += 1;
798
+ }
799
+ }
800
+
801
+ if (!existing.latestTs || String(row.ts) > String(existing.latestTs)) {
802
+ existing.latestTs = String(row.ts);
803
+ }
804
+
805
+ existing.versions.set(versionKey, versionSummary);
806
+ executionsBySkill.set(skillName, existing);
807
+ }
808
+
809
+ const skillMeta = new Map();
810
+
811
+ for (const [skillName, data] of fsSkills.entries()) {
812
+ skillMeta.set(skillName, {
813
+ skillName,
814
+ skillPath: data.path,
815
+ currentVersionHash: null,
816
+ dbStatus: null,
817
+ });
818
+ }
819
+
820
+ for (const row of skillRowsStmt.all([]) || []) {
821
+ const existing = skillMeta.get(row.skill_name) || {
822
+ skillName: row.skill_name,
823
+ skillPath: row.skill_path,
824
+ currentVersionHash: null,
825
+ dbStatus: null,
826
+ };
827
+
828
+ existing.currentVersionHash = row.current_version_hash || existing.currentVersionHash;
829
+ existing.dbStatus = row.status || existing.dbStatus;
830
+ if (row.skill_path) existing.skillPath = existing.skillPath || row.skill_path;
831
+ skillMeta.set(row.skill_name, existing);
832
+ }
833
+
834
+ const executionOnlySkills = [...executionsBySkill.keys()];
835
+ for (const skillName of executionOnlySkills) {
836
+ if (!skillMeta.has(skillName)) {
837
+ skillMeta.set(skillName, {
838
+ skillName,
839
+ skillPath: `(not-in-filesystem)`,
840
+ currentVersionHash: null,
841
+ dbStatus: null,
842
+ });
843
+ }
844
+ }
845
+
846
+ const dbNow = new Date().toISOString();
847
+ const { reportBuckets, versionCompareNotes, rowsToPersist } = evaluateSkills({
848
+ executionsBySkill,
849
+ skillMeta,
850
+ options,
851
+ });
852
+
853
+ const rowsUpdated = [];
854
+
855
+ if (options.writeDb) {
856
+ for (const row of rowsToPersist) {
857
+ updateSkillStmt.run([
858
+ row.skillName,
859
+ row.skillPath,
860
+ row.versionHash,
861
+ row.statusRecommendation,
862
+ dbNow,
863
+ row.latestTs,
864
+ ]);
865
+
866
+ upsertSnapshotStmt.run([
867
+ startedAt,
868
+ row.skillName,
869
+ row.versionHash,
870
+ row.usageCount,
871
+ clamp(toNumber(row.mechanicalFailureRate, 0)),
872
+ clamp(toNumber(row.impliedNegativeRate, 0)),
873
+ row.statusRecommendation,
874
+ row.notes,
875
+ ]);
876
+ rowsUpdated.push(row.skillName);
877
+ }
878
+ }
879
+
880
+ if (options.writeReport) {
881
+ await mkdir(options.reportDir, { recursive: true });
882
+
883
+ const now = new Date();
884
+ const reportPath = join(options.reportDir, "latest.md");
885
+ const datedPath = join(options.reportDir, now.toISOString().slice(0, 10));
886
+ const report = buildReport({
887
+ at: now,
888
+ windowStart,
889
+ windowEnd: startedAt,
890
+ options,
891
+ buckets: reportBuckets,
892
+ versionCompareNotes,
893
+ rows: rowsToPersist,
894
+ rowsUpdated,
895
+ });
896
+ await import("node:fs/promises").then((m) => m.writeFile(reportPath, report, "utf8"));
897
+ await import("node:fs/promises").then((m) => m.writeFile(`${datedPath}.md`, report, "utf8"));
898
+ }
899
+
900
+ const totalBuckets = Object.values(reportBuckets).reduce((acc, v) => acc + v.length, 0);
901
+ const status = {
902
+ skills: totalBuckets,
903
+ stable: reportBuckets.stable.length,
904
+ degraded: reportBuckets.degraded.length,
905
+ experimental: reportBuckets.experimental.length,
906
+ underused: reportBuckets.underused.length,
907
+ unused: reportBuckets.unused.length,
908
+ };
909
+
910
+ console.log(`Evaluated ${status.skills} skills.`);
911
+ console.log(`stable=${status.stable} experimental=${status.experimental} degraded=${status.degraded} underused=${status.underused} unused=${status.unused}`);
912
+ } finally {
913
+ db.close();
914
+ }
915
+ })().catch((error) => {
916
+ console.error("Skill health evaluator failed:", String(error?.message || error));
917
+ process.exitCode = 1;
918
+ });
919
+