@rhize/skill-forge 0.17.0 → 0.17.1

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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **The supply-chain gate for agent skills.**
4
4
 
5
- Status: published — `@rhize/skill-forge@0.17.0` on npm (released 2026-09-04),
5
+ Status: published — `@rhize/skill-forge@0.17.1` on npm (released 2026-09-05),
6
6
  0.x beta (Pro features free until 1.0)
7
7
 
8
8
  > This project is unrelated to the [`skillforge`](https://www.npmjs.com/package/skillforge)
package/dist/cli.js CHANGED
@@ -1193,12 +1193,20 @@ function refineRmRf(line, _ctx) {
1193
1193
  }
1194
1194
  var DOC_CUE_RE = /\b(never|not|don't|do not|must not|reject|refuse|attack|dangerous|unsafe|avoid|warning)\b/i;
1195
1195
  var DOC_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".mdx", ".markdown"]);
1196
- var NO_FENCES = [];
1196
+ var INSTALL_CUE_RE = /\b(install|run|paste|set ?up)\b/gi;
1197
+ var NEGATION_BEFORE_CUE_RE = /\b(never|not|don't|avoid|shouldn't|mustn't)(\s+\w+)?\s*$/i;
1198
+ function hasUnnegatedInstallCue(line) {
1199
+ for (const m of line.matchAll(INSTALL_CUE_RE)) {
1200
+ if (!NEGATION_BEFORE_CUE_RE.test(line.slice(0, m.index))) return true;
1201
+ }
1202
+ return false;
1203
+ }
1197
1204
  function refineCurlPipeShell(line, ctx) {
1198
1205
  const base = { severity: "CRITICAL", message: "downloads a remote script and pipes it directly into a shell" };
1199
1206
  if (ctx.inFence) return base;
1200
1207
  if (!DOC_EXTENSIONS.has(extname2(ctx.file).toLowerCase())) return base;
1201
1208
  if (!DOC_CUE_RE.test(line)) return base;
1209
+ if (hasUnnegatedInstallCue(line)) return base;
1202
1210
  return { ...base, classification: "documentation-example" };
1203
1211
  }
1204
1212
  function computeFenceFlags(lines) {
@@ -1296,11 +1304,11 @@ function scanStaticRules(targetPath) {
1296
1304
  const content = readTextFile(file);
1297
1305
  if (content === void 0) continue;
1298
1306
  const lines = content.split("\n");
1299
- const fenceFlags = DOC_EXTENSIONS.has(extname2(file).toLowerCase()) ? computeFenceFlags(lines) : NO_FENCES;
1307
+ const fenceFlags = DOC_EXTENSIONS.has(extname2(file).toLowerCase()) ? computeFenceFlags(lines) : void 0;
1300
1308
  for (const rule of DENY_RULES) {
1301
1309
  for (let i = 0; i < lines.length; i++) {
1302
1310
  if (rule.pattern.test(lines[i])) {
1303
- const refined = rule.refine ? rule.refine(lines[i], { inFence: fenceFlags[i], file }) : { severity: rule.severity, message: rule.message };
1311
+ const refined = rule.refine ? rule.refine(lines[i], { inFence: fenceFlags?.[i] ?? false, file }) : { severity: rule.severity, message: rule.message };
1304
1312
  if (!refined) continue;
1305
1313
  findings.push({
1306
1314
  severity: refined.severity,
@@ -2304,15 +2312,7 @@ function toClusterMembers(members) {
2304
2312
  function annotateSiblingClusters(clusters, nameByPath) {
2305
2313
  return clusters.map((cluster) => {
2306
2314
  const names = cluster.members.map((p) => nameByPath.get(p) ?? basename6(p));
2307
- let allSiblings = true;
2308
- outer: for (let i = 0; i < names.length && allSiblings; i++) {
2309
- for (let j = i + 1; j < names.length; j++) {
2310
- if (!isStackSiblingPair(names[i], names[j])) {
2311
- allSiblings = false;
2312
- break outer;
2313
- }
2314
- }
2315
- }
2315
+ const allSiblings = names.every((a, i) => names.slice(i + 1).every((b) => isStackSiblingPair(a, b)));
2316
2316
  return allSiblings ? { ...cluster, siblingVariants: true, suggestedVerb: "DEFER" } : cluster;
2317
2317
  });
2318
2318
  }
@@ -2351,7 +2351,7 @@ function collapseIdenticalContent(skills, configuredRootOrder) {
2351
2351
  const ai = configuredRootOrder.indexOf(a.root);
2352
2352
  const bi = configuredRootOrder.indexOf(b.root);
2353
2353
  if (ai !== bi) return ai - bi;
2354
- return a.path.localeCompare(b.path);
2354
+ return comparePaths(a.path, b.path);
2355
2355
  });
2356
2356
  const canonical = sorted[0];
2357
2357
  for (const duplicate of sorted.slice(1)) {
@@ -2362,23 +2362,41 @@ function collapseIdenticalContent(skills, configuredRootOrder) {
2362
2362
  }
2363
2363
  return { skills: skills.filter((s) => !toRemove.has(s)), collapsedCount };
2364
2364
  }
2365
- function matchUsage(skill, usage, notices) {
2365
+ function comparePaths(a, b) {
2366
+ return a < b ? -1 : a > b ? 1 : 0;
2367
+ }
2368
+ function buildUsageJoinIndex(usage, skills) {
2369
+ const bareKeys = /* @__PURE__ */ new Map();
2370
+ for (const key of usage.keys()) {
2371
+ const bare = key.includes(":") ? key.slice(key.lastIndexOf(":") + 1) : key;
2372
+ bareKeys.set(bare, [...bareKeys.get(bare) ?? [], key]);
2373
+ }
2374
+ const inventoryNameCounts = /* @__PURE__ */ new Map();
2375
+ for (const skill of skills) inventoryNameCounts.set(skill.name, (inventoryNameCounts.get(skill.name) ?? 0) + 1);
2376
+ return { bareKeys, inventoryNameCounts, noticed: /* @__PURE__ */ new Set() };
2377
+ }
2378
+ function matchUsage(skill, usage, index, notices) {
2366
2379
  if (usage.has(skill.name)) return usage.get(skill.name);
2367
2380
  if (skill.pluginId) {
2368
2381
  const composite = `${skill.pluginId.split("@")[0]}:${skill.name}`;
2369
2382
  if (usage.has(composite)) return usage.get(composite);
2370
2383
  }
2371
- const bareMatches = [...usage.keys()].filter((k) => (k.includes(":") ? k.slice(k.lastIndexOf(":") + 1) : k) === skill.name);
2372
- if (bareMatches.length === 1) return usage.get(bareMatches[0]);
2373
- if (bareMatches.length > 1) {
2374
- notices.push(`usage snapshot: ambiguous bare name "${skill.name}" matches ${bareMatches.length} entries \u2014 skipping attribution`);
2384
+ const bareMatches = index.bareKeys.get(skill.name) ?? [];
2385
+ if (bareMatches.length === 0) return void 0;
2386
+ const installedWithName = index.inventoryNameCounts.get(skill.name) ?? 0;
2387
+ if (bareMatches.length === 1 && installedWithName === 1) return usage.get(bareMatches[0]);
2388
+ if (!index.noticed.has(skill.name)) {
2389
+ index.noticed.add(skill.name);
2390
+ notices.push(
2391
+ bareMatches.length > 1 ? `usage snapshot: ambiguous bare name "${skill.name}" matches ${bareMatches.length} entries \u2014 skipping attribution` : `usage snapshot: bare name "${skill.name}" matches ${installedWithName} installed skills \u2014 skipping attribution`
2392
+ );
2375
2393
  }
2376
2394
  return void 0;
2377
2395
  }
2378
2396
  function isUnderInstallPath(target, installPath) {
2379
2397
  return target === installPath || target.startsWith(installPath + sep2);
2380
2398
  }
2381
- function computePluginSummaries(pluginRoots, activeFindings, skills, usage) {
2399
+ function computePluginSummaries(pluginRoots, activeFindings, skills, usage, usageJoined) {
2382
2400
  return pluginRoots.map((pr) => {
2383
2401
  const findingCounts = { LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0 };
2384
2402
  for (const f of activeFindings) {
@@ -2391,9 +2409,10 @@ function computePluginSummaries(pluginRoots, activeFindings, skills, usage) {
2391
2409
  const observedSkillCount = hasUsage ? skills.filter((s) => s.pluginId === pr.pluginId && (s.usage ?? 0) > 0).length : void 0;
2392
2410
  let recommendation;
2393
2411
  const reasons = [];
2412
+ const longWindow = windowDays !== void 0 && windowDays >= 28;
2394
2413
  if (findingCounts.HIGH > 0 || findingCounts.CRITICAL > 0) {
2395
2414
  recommendation = "review";
2396
- } else if (exhaustive && windowDays !== void 0 && windowDays >= 28 && skillCount > 0 && observedSkillCount === 0) {
2415
+ } else if (exhaustive && longWindow && skillCount > 0 && observedSkillCount === 0 && (usageJoined ?? 0) > 0) {
2397
2416
  recommendation = "unobserved";
2398
2417
  } else if (exhaustive && (observedSkillCount ?? 0) >= 1) {
2399
2418
  recommendation = "keep";
@@ -2401,9 +2420,10 @@ function computePluginSummaries(pluginRoots, activeFindings, skills, usage) {
2401
2420
  recommendation = "unknown";
2402
2421
  if (skillCount === 0) reasons.push("no skills to observe");
2403
2422
  if (!hasUsage) reasons.push("no usage telemetry");
2423
+ else if (usage.usage.size === 0) reasons.push("usage snapshot is empty or unreadable (see notices)");
2404
2424
  else if (!exhaustive) reasons.push("usage snapshot is not exhaustive (top-50 only)");
2405
- else if (windowDays === void 0 || windowDays < 28) reasons.push(`window ${windowDays ?? "unknown"} < 28 days`);
2406
- if (reasons.length === 0) reasons.push("no usage telemetry");
2425
+ else if (!longWindow) reasons.push(`window ${windowDays ?? "unknown"} < 28 days`);
2426
+ else if ((usageJoined ?? 0) === 0) reasons.push("usage snapshot joined 0 installed skills");
2407
2427
  }
2408
2428
  return {
2409
2429
  pluginId: pr.pluginId,
@@ -2413,9 +2433,9 @@ function computePluginSummaries(pluginRoots, activeFindings, skills, usage) {
2413
2433
  findingCounts,
2414
2434
  observedSkillCount,
2415
2435
  recommendation,
2416
- reasons: [...new Set(reasons)].sort()
2436
+ reasons: reasons.sort()
2417
2437
  };
2418
- }).sort((a, b) => a.pluginId.localeCompare(b.pluginId));
2438
+ }).sort((a, b) => comparePaths(a.pluginId, b.pluginId));
2419
2439
  }
2420
2440
  function defaultWhich(cmd) {
2421
2441
  const pathEnv = process.env.PATH ?? "";
@@ -2624,8 +2644,9 @@ async function auditConfiguredSet(config, options = {}) {
2624
2644
  };
2625
2645
  const usage = options.usage;
2626
2646
  if (usage) {
2647
+ const index = buildUsageJoinIndex(usage.usage, skills);
2627
2648
  for (const skill of skills) {
2628
- const matched = matchUsage(skill, usage.usage, notices);
2649
+ const matched = matchUsage(skill, usage.usage, index, notices);
2629
2650
  if (matched !== void 0) skill.usage = matched;
2630
2651
  }
2631
2652
  }
@@ -2633,7 +2654,7 @@ async function auditConfiguredSet(config, options = {}) {
2633
2654
  if (usage && usageJoined === 0) {
2634
2655
  notices.push(`usage snapshot (${usage.sourcePath}) joined 0 of ${skills.length} skill(s)`);
2635
2656
  }
2636
- const unobservedInWindow = usage && usage.exhaustive ? skills.filter((s) => (s.usage ?? 0) === 0).map((s) => ({ name: s.name, path: s.path, pluginId: s.pluginId })) : [];
2657
+ const unobservedInWindow = usage && usage.exhaustive && usage.windowDays !== void 0 && usage.windowDays >= 28 && (usageJoined ?? 0) > 0 ? skills.filter((s) => (s.usage ?? 0) === 0).map((s) => ({ name: s.name, path: s.path, pluginId: s.pluginId })) : [];
2637
2658
  const findingCounts = { LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0 };
2638
2659
  for (const f of activeFindings) findingCounts[f.severity]++;
2639
2660
  const summary = {
@@ -2648,7 +2669,7 @@ async function auditConfiguredSet(config, options = {}) {
2648
2669
  documentationExampleCount: activeFindings.filter((f) => f.classification === "documentation-example").length,
2649
2670
  ...usage ? { usageJoined, usageExhaustive: usage.exhaustive, usageWindowDays: usage.windowDays } : {}
2650
2671
  };
2651
- const plugins = options.pluginRoots ? computePluginSummaries(pluginRoots, activeFindings, skills, usage) : void 0;
2672
+ const plugins = options.pluginRoots ? computePluginSummaries(pluginRoots, activeFindings, skills, usage, usageJoined) : void 0;
2652
2673
  const tooling = checkTooling(options.which ?? defaultWhich, options.env ?? process.env);
2653
2674
  return {
2654
2675
  schemaVersion: 1,
@@ -2731,7 +2752,7 @@ function renderFindingsSection(findings) {
2731
2752
  const docExamples = findings.filter((f) => f.classification === "documentation-example");
2732
2753
  const lines = ["## Hygiene findings", "", ...bulletList(live.map(renderFindingLine), "(none \u2014 clean)")];
2733
2754
  if (docExamples.length > 0) {
2734
- lines.push("", "### Probable documentation examples (still CRITICAL \u2014 review)", "", ...bulletList(docExamples.map(renderFindingLine), ""));
2755
+ lines.push("", "### Heuristically classified as documentation examples (still CRITICAL \u2014 review each; the heuristic can be gamed)", "", ...bulletList(docExamples.map(renderFindingLine), ""));
2735
2756
  }
2736
2757
  return lines;
2737
2758
  }
@@ -2778,7 +2799,9 @@ function renderUsageSection(report) {
2778
2799
  const lines = ["## Usage", ""];
2779
2800
  lines.push(`- Matched: ${summary.usageJoined ?? 0} of ${summary.skillCount} skill(s)`);
2780
2801
  lines.push(`- Window: ${summary.usageWindowDays !== void 0 ? `${summary.usageWindowDays} day(s)` : "unknown"}`);
2781
- if (summary.usageExhaustive) {
2802
+ if ((summary.usageJoined ?? 0) === 0) {
2803
+ lines.push("- Coverage: nothing joined (see Notices)");
2804
+ } else if (summary.usageExhaustive) {
2782
2805
  lines.push("- Coverage: exhaustive (every skill counted this window)");
2783
2806
  } else {
2784
2807
  lines.push("- Coverage: top-50 only; absence is not zero usage");
@@ -2788,6 +2811,8 @@ function renderUsageSection(report) {
2788
2811
  for (const s of report.opportunities.unobservedInWindow) {
2789
2812
  lines.push(`- ${sanitizeForMarkdown(s.name)} \u2014 ${s.path}${s.pluginId ? ` (plugin ${sanitizeForMarkdown(s.pluginId)})` : ""}`);
2790
2813
  }
2814
+ } else if (summary.usageExhaustive && (summary.usageJoined ?? 0) > 0 && (summary.usageWindowDays ?? 0) < 28) {
2815
+ lines.push("- Unobserved list: suppressed \u2014 window shorter than 28 days proves nothing about monthly-use skills");
2791
2816
  }
2792
2817
  return lines;
2793
2818
  }
@@ -2843,6 +2868,7 @@ function renderAuditMarkdown(report) {
2843
2868
  `- Overlap clusters: ${summary.overlapClusterCount}`,
2844
2869
  `- Evolve-eligible skills: ${summary.evolveEligibleCount}`,
2845
2870
  `- Collapsed duplicates: ${summary.collapsedDuplicateCount}`,
2871
+ `- Documentation-example classified (heuristic; counted above as CRITICAL): ${summary.documentationExampleCount}`,
2846
2872
  "",
2847
2873
  "## Inventory",
2848
2874
  "",
@@ -2973,7 +2999,9 @@ function summarizeAudit(report, reportPath) {
2973
2999
  for (const skill of report.inventory.skills) {
2974
3000
  rootOfSkillPath.set(skill.path, skill.root);
2975
3001
  }
3002
+ const pluginRootPaths = new Set(report.inventory.skillRoots.filter((r) => r.origin === "plugin").map((r) => r.root));
2976
3003
  const byRoot = {};
3004
+ const pluginCounts = emptyCounts();
2977
3005
  for (const finding2 of report.hygiene) {
2978
3006
  let root;
2979
3007
  for (let dir = finding2.target; ; dir = dirname5(dir)) {
@@ -2981,19 +3009,24 @@ function summarizeAudit(report, reportPath) {
2981
3009
  if (root || dirname5(dir) === dir) break;
2982
3010
  }
2983
3011
  if (!root) continue;
3012
+ if (pluginRootPaths.has(root)) {
3013
+ bump(pluginCounts, finding2.severity);
3014
+ continue;
3015
+ }
2984
3016
  byRoot[root] ??= emptyCounts();
2985
3017
  bump(byRoot[root], finding2.severity);
2986
3018
  }
3019
+ const findings = emptyCounts();
3020
+ for (const severity of Object.keys(findings)) {
3021
+ findings[severity] = report.summary.findingCounts[severity] - pluginCounts[severity];
3022
+ }
2987
3023
  return {
2988
3024
  schemaVersion: 1,
2989
3025
  lastRunAt: report.generatedAt,
2990
3026
  reportPath,
2991
3027
  skillCount: report.summary.skillCount,
2992
3028
  mcpTargetCount: report.summary.mcpTargetCount,
2993
- // Taken from the report rather than re-accumulated: `audit.ts` already counted exactly this
2994
- // array, and a second tally is how the terminal summary and the markdown report end up
2995
- // disagreeing about the same run.
2996
- findings: { ...report.summary.findingCounts },
3029
+ findings,
2997
3030
  byRoot,
2998
3031
  overlapClusters: report.opportunities.overlapClusters.map((c) => ({
2999
3032
  // Cluster members are SKILL.md paths; the skill name is the parent directory name. Stripped
@@ -4696,16 +4729,17 @@ function extractEnabledMap(data) {
4696
4729
  return out;
4697
4730
  }
4698
4731
  function extractRegistry(data) {
4699
- if (typeof data !== "object" || data === null) return {};
4732
+ const empty = { entries: {}, malformed: {} };
4733
+ if (typeof data !== "object" || data === null) return empty;
4700
4734
  const pluginsRaw = data.plugins;
4701
- if (typeof pluginsRaw !== "object" || pluginsRaw === null) return {};
4735
+ if (typeof pluginsRaw !== "object" || pluginsRaw === null) return empty;
4702
4736
  const out = {};
4737
+ const malformed = {};
4703
4738
  for (const [id, entriesRaw] of Object.entries(pluginsRaw)) {
4704
4739
  if (!Array.isArray(entriesRaw)) continue;
4705
4740
  const entries = [];
4706
4741
  for (const raw of entriesRaw) {
4707
- if (typeof raw !== "object" || raw === null) continue;
4708
- const rec = raw;
4742
+ const rec = typeof raw === "object" && raw !== null ? raw : {};
4709
4743
  const scope = rec.scope;
4710
4744
  const installPath = rec.installPath;
4711
4745
  const version = rec.version;
@@ -4716,11 +4750,13 @@ function extractRegistry(data) {
4716
4750
  installPath,
4717
4751
  version
4718
4752
  });
4753
+ } else {
4754
+ malformed[id] = (malformed[id] ?? 0) + 1;
4719
4755
  }
4720
4756
  }
4721
4757
  if (entries.length > 0) out[id] = entries;
4722
4758
  }
4723
- return out;
4759
+ return { entries: out, malformed };
4724
4760
  }
4725
4761
  function splitPluginId(id) {
4726
4762
  const idx = id.lastIndexOf("@");
@@ -4799,8 +4835,17 @@ function resolveDeclaredPath(installPath, realInstall, declaredPath, explicit, p
4799
4835
  }
4800
4836
  const found = [];
4801
4837
  for (const entry of entries) {
4802
- if (!entry.isDirectory()) continue;
4803
- const childPath = join20(real, entry.name);
4838
+ let childPath = join20(real, entry.name);
4839
+ if (entry.isSymbolicLink()) {
4840
+ const target = safeRealpath(childPath);
4841
+ if (!target || !isInside2(target, realInstall)) {
4842
+ notices.push(`plugin ${pluginId}: skill dir symlink escapes the install root, skipping: ${join20(declaredPath, entry.name)}`);
4843
+ continue;
4844
+ }
4845
+ childPath = target;
4846
+ } else if (!entry.isDirectory()) {
4847
+ continue;
4848
+ }
4804
4849
  if (existsSync16(join20(childPath, "SKILL.md"))) found.push(childPath);
4805
4850
  }
4806
4851
  return found;
@@ -4820,7 +4865,7 @@ function resolveSkillDirs(installPath, pluginId) {
4820
4865
  skillDirs.add(dir);
4821
4866
  }
4822
4867
  }
4823
- return { skillDirs: Array.from(skillDirs).sort(), notices };
4868
+ return { skillDirs: Array.from(skillDirs).sort(), notices, realInstall };
4824
4869
  }
4825
4870
  function discoverClaudePluginRoots(options = {}) {
4826
4871
  const homeDir = options.homeDir ?? homedir4();
@@ -4851,27 +4896,44 @@ function discoverClaudePluginRoots(options = {}) {
4851
4896
  }
4852
4897
  const merged = { ...globalEnabled, ...localEnabled };
4853
4898
  const enabledIds = Object.keys(merged).filter((id) => merged[id] === true).sort();
4854
- const registry = extractRegistry(registryResult.data);
4899
+ const { entries: registry, malformed } = extractRegistry(registryResult.data);
4855
4900
  const roots = [];
4856
4901
  for (const id of enabledIds) {
4857
4902
  const entries = registry[id];
4903
+ const dropped = malformed[id] ?? 0;
4904
+ if (dropped > 0) {
4905
+ notices.push(`plugin ${id}: ignored ${dropped} malformed installed_plugins.json entr${dropped === 1 ? "y" : "ies"} (missing installPath/version or unknown scope)`);
4906
+ }
4858
4907
  if (!entries || entries.length === 0) {
4859
- notices.push(`plugin ${id} is enabled but has no installed_plugins.json entry \u2014 skipping`);
4908
+ notices.push(`plugin ${id} is enabled but has no ${dropped > 0 ? "usable " : ""}installed_plugins.json entry \u2014 skipping`);
4860
4909
  continue;
4861
4910
  }
4862
4911
  const entry = selectInstallEntry(entries, cwd);
4863
4912
  const { plugin, marketplace } = splitPluginId(id);
4864
- const { skillDirs, notices: manifestNotices } = resolveSkillDirs(entry.installPath, id);
4913
+ const { skillDirs, notices: manifestNotices, realInstall } = resolveSkillDirs(entry.installPath, id);
4865
4914
  notices.push(...manifestNotices);
4866
- roots.push({ pluginId: id, marketplace, plugin, version: entry.version, installPath: entry.installPath, skillDirs });
4915
+ roots.push({ pluginId: id, marketplace, plugin, version: entry.version, installPath: realInstall ?? entry.installPath, skillDirs });
4867
4916
  }
4868
- roots.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
4917
+ roots.sort((a, b) => a.pluginId < b.pluginId ? -1 : a.pluginId > b.pluginId ? 1 : 0);
4869
4918
  return { roots, notices };
4870
4919
  }
4871
4920
 
4872
4921
  // src/usageSnapshot.ts
4873
4922
  import { readFileSync as readFileSync17, readdirSync as readdirSync9 } from "fs";
4874
4923
  import { extname as extname7, join as join21, resolve as resolvePath4 } from "path";
4924
+ function isValidCount(value) {
4925
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
4926
+ }
4927
+ function toSnapshot(parsed, sourcePath) {
4928
+ return {
4929
+ usage: parsed.usage,
4930
+ exhaustive: parsed.exhaustive,
4931
+ windowDays: parsed.windowDays,
4932
+ generatedAt: parsed.generatedAt,
4933
+ sourcePath,
4934
+ ...parsed.warning ? { warning: parsed.warning } : {}
4935
+ };
4936
+ }
4875
4937
  function emptySnapshot(sourcePath, warning) {
4876
4938
  return { usage: /* @__PURE__ */ new Map(), exhaustive: false, sourcePath, warning };
4877
4939
  }
@@ -4888,31 +4950,35 @@ function parseSnapshotFile(raw) {
4888
4950
  const generatedAt = typeof report.generated_at === "string" ? report.generated_at : void 0;
4889
4951
  const windowDays = typeof report.window_days === "number" ? report.window_days : void 0;
4890
4952
  const uniqueSkillsUsed = typeof report.unique_skills_used === "number" ? report.unique_skills_used : void 0;
4891
- const skillTotals = report.skill_totals && typeof report.skill_totals === "object" ? report.skill_totals : void 0;
4953
+ const skillTotalsRaw = report.skill_totals;
4954
+ const skillTotals = skillTotalsRaw && typeof skillTotalsRaw === "object" && !Array.isArray(skillTotalsRaw) ? skillTotalsRaw : void 0;
4955
+ const warning = skillTotalsRaw !== void 0 && skillTotals === void 0 ? 'usage snapshot: "skill_totals" is not an object \u2014 ignoring it' : void 0;
4892
4956
  const topSkills = Array.isArray(report.top_skills) ? report.top_skills : void 0;
4893
4957
  const usage = /* @__PURE__ */ new Map();
4894
4958
  if (skillTotals) {
4895
4959
  for (const [name, count] of Object.entries(skillTotals)) {
4896
- if (typeof count === "number") usage.set(name, count);
4960
+ if (isValidCount(count)) usage.set(name, count);
4897
4961
  }
4898
4962
  } else if (topSkills) {
4899
4963
  for (const row of topSkills) {
4900
- if (Array.isArray(row) && row.length === 2 && typeof row[0] === "string" && typeof row[1] === "number") {
4964
+ if (Array.isArray(row) && row.length === 2 && typeof row[0] === "string" && isValidCount(row[1])) {
4901
4965
  usage.set(row[0], row[1]);
4902
4966
  }
4903
4967
  }
4904
4968
  }
4905
4969
  const exhaustive = Boolean(skillTotals) || topSkills !== void 0 && uniqueSkillsUsed !== void 0 && topSkills.length === uniqueSkillsUsed;
4906
- return { usage, exhaustive, windowDays, generatedAt };
4970
+ return { usage, exhaustive, windowDays, generatedAt, warning };
4907
4971
  }
4908
4972
  function pickLatest(candidates) {
4909
4973
  return candidates.reduce((best, cur) => {
4910
4974
  if (!best) return cur;
4911
- if (cur.generatedAt === void 0 && best.generatedAt === void 0) return cur.path > best.path ? cur : best;
4912
- if (cur.generatedAt === void 0) return best;
4913
- if (best.generatedAt === void 0) return cur;
4914
- if (cur.generatedAt === best.generatedAt) return cur.path > best.path ? cur : best;
4915
- return cur.generatedAt > best.generatedAt ? cur : best;
4975
+ const curAt = cur.parsed.generatedAt;
4976
+ const bestAt = best.parsed.generatedAt;
4977
+ if (curAt === void 0 && bestAt === void 0) return cur.path > best.path ? cur : best;
4978
+ if (curAt === void 0) return best;
4979
+ if (bestAt === void 0) return cur;
4980
+ if (curAt === bestAt) return cur.path > best.path ? cur : best;
4981
+ return curAt > bestAt ? cur : best;
4916
4982
  }, void 0);
4917
4983
  }
4918
4984
  function loadUsageSnapshot(pathOrDir, opts = {}) {
@@ -4930,16 +4996,16 @@ function loadUsageSnapshot(pathOrDir, opts = {}) {
4930
4996
  const candidates = [];
4931
4997
  for (const name of entries) {
4932
4998
  const filePath = join21(resolved, name);
4933
- let raw3;
4999
+ let raw2;
4934
5000
  try {
4935
- raw3 = readFileSync17(filePath, "utf-8");
5001
+ raw2 = readFileSync17(filePath, "utf-8");
4936
5002
  } catch {
4937
5003
  continue;
4938
5004
  }
4939
- const parsed3 = parseSnapshotFile(raw3);
4940
- if ("error" in parsed3) continue;
4941
- if (opts.windowDays !== void 0 && parsed3.windowDays !== opts.windowDays) continue;
4942
- candidates.push({ path: filePath, generatedAt: parsed3.generatedAt });
5005
+ const parsed2 = parseSnapshotFile(raw2);
5006
+ if ("error" in parsed2) continue;
5007
+ if (opts.windowDays !== void 0 && parsed2.windowDays !== opts.windowDays) continue;
5008
+ candidates.push({ path: filePath, parsed: parsed2 });
4943
5009
  }
4944
5010
  const picked = pickLatest(candidates);
4945
5011
  if (!picked) {
@@ -4948,10 +5014,7 @@ function loadUsageSnapshot(pathOrDir, opts = {}) {
4948
5014
  opts.windowDays !== void 0 ? `no usage snapshot in ${resolved} matches --usage-window ${opts.windowDays}` : `no readable usage snapshot found in ${resolved}`
4949
5015
  );
4950
5016
  }
4951
- const raw2 = readFileSync17(picked.path, "utf-8");
4952
- const parsed2 = parseSnapshotFile(raw2);
4953
- if ("error" in parsed2) return emptySnapshot(picked.path, parsed2.error);
4954
- return { usage: parsed2.usage, exhaustive: parsed2.exhaustive, windowDays: parsed2.windowDays, generatedAt: parsed2.generatedAt, sourcePath: picked.path };
5017
+ return toSnapshot(picked.parsed, picked.path);
4955
5018
  }
4956
5019
  let raw;
4957
5020
  try {
@@ -4961,7 +5024,7 @@ function loadUsageSnapshot(pathOrDir, opts = {}) {
4961
5024
  }
4962
5025
  const parsed = parseSnapshotFile(raw);
4963
5026
  if ("error" in parsed) return emptySnapshot(resolved, parsed.error);
4964
- return { usage: parsed.usage, exhaustive: parsed.exhaustive, windowDays: parsed.windowDays, generatedAt: parsed.generatedAt, sourcePath: resolved };
5027
+ return toSnapshot(parsed, resolved);
4965
5028
  }
4966
5029
 
4967
5030
  // src/commands/audit.ts
@@ -7902,6 +7965,15 @@ async function routineCommand(options = {}, deps = {}) {
7902
7965
  for (const notice of notices) log(`[skill-forge] ${notice}`);
7903
7966
  const audit2 = await auditConfiguredSet(config, { accepted, pluginRoots, usage });
7904
7967
  const auditState = summarizeAudit(audit2);
7968
+ if (audit2.plugins) {
7969
+ const critical = audit2.plugins.reduce((n, p) => n + p.findingCounts.CRITICAL, 0);
7970
+ const high = audit2.plugins.reduce((n, p) => n + p.findingCounts.HIGH, 0);
7971
+ if (critical + high > 0) {
7972
+ const notice = `plugin-origin findings (${critical} CRITICAL, ${high} HIGH) are excluded from --fail-on and the add/scan advisory \u2014 see the Plugins section for per-plugin review recommendations`;
7973
+ notices.push(notice);
7974
+ log(`[skill-forge] ${notice}`);
7975
+ }
7976
+ }
7905
7977
  const degraded = [];
7906
7978
  let watch;
7907
7979
  if (!options.skipWatch) {