@rhize/skill-forge 0.19.0 → 0.20.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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **The supply-chain gate for agent skills.**
4
4
 
5
- Source version: `@rhize/skill-forge@0.19.0` (2026-09-05), 0.x beta (Pro features free until 1.0).
5
+ Source version: `@rhize/skill-forge@0.20.0` (2026-09-14), 0.x beta (Pro features free until 1.0).
6
6
  Npm publication is a separate tag-driven step; `npm view @rhize/skill-forge version` reports the
7
7
  latest published version.
8
8
 
@@ -12,16 +12,17 @@ latest published version.
12
12
 
13
13
  ## Why
14
14
 
15
- Agent skills (Claude Code skills, and the equivalent extension formats emerging in Cursor and
16
- Codex) are now distributed the way npm packages were a decade ago: a public registry, a one-line
17
- install command, and no vetting step in between. skills.sh alone lists 600k+ skills. A single
18
- `npx skills@latest add owner/name` drops arbitrary third-party instructions, scripts, and tool
19
- bindings directly into a working agent's context and file system — with the same trust model as
20
- copy-pasting a shell script from a stranger.
15
+ Agent skills can include third-party instructions and executable resources. Existing marketplaces
16
+ already provide safeguards: [skills.sh offers partner security audits and installation risk
17
+ information](https://vercel.com/changelog/automated-security-audits-now-available-for-skills-sh).
18
+ Skill Forge adds a local review and maintenance workflow around the skills you actually use:
19
+ provenance, overlap review, drift checks, and recoverable project refinements.
21
20
 
22
- skill-forge does not replace that install command; it wraps it. Every candidate skill is routed
23
- through **quarantine → profile → safety scan → overlap analysis → report → an explicit
24
- promote/hold/reject decision**, before it is allowed anywhere near your working skill set.
21
+ Candidates submitted through Skill Forge follow **quarantine → profile → safety scan → overlap
22
+ analysis (when available/configured) → report → an explicit promote/hold/reject decision**.
23
+ This gate applies to the Skill Forge path; it does not prevent direct installs through other tools
24
+ or replace host-managed restrictions. Passing a scan does not prove that a skill is safe.
25
+ See the [product spec](docs/PRODUCT.md) for current competitive positioning and evidence limits.
25
26
 
26
27
  ## Quickstart
27
28
 
@@ -157,8 +158,9 @@ including current caveats, in [docs/configuration.md](docs/configuration.md).
157
158
  **Is this related to the `skillforge` npm package?**
158
159
  No. [`skillforge`](https://www.npmjs.com/package/skillforge) is a Claude Skills *evaluation*
159
160
  framework — it tests whether a skill performs well. `skill-forge` (this package, hyphenated) is a
160
- supply-chain security gate for skill *installation* — it decides whether a skill is safe and
161
- non-redundant before it ever runs. Same neighborhood, different problem, name collision only.
161
+ review gate for skill *installation* and ongoing collection maintenance. It reports detected
162
+ risks and potential overlap; it does not certify safety or task effectiveness. The similarly named
163
+ packages are independently maintained.
162
164
 
163
165
  **Does skill-forge replace `npx skills@latest add`?**
164
166
  No, it wraps it. `add` uses the same install mechanisms (skills.sh, git, local copy) but stages
@@ -223,3 +225,12 @@ loading in a fresh task before treating the refinement as consumed. Roll back us
223
225
  the returned backup ID with `refine rollback <id> --project --yes`.
224
226
 
225
227
  See the [governance operating guide](docs/governance-improvements.md) for scope, schemas, examples and limits.
228
+
229
+ ## Advisory instruction diagnostics (0.20)
230
+
231
+ `audit` reports description, entrypoint and resource measurements separately, with review
232
+ suggestions for broad triggers and resource routing. These suggestions never change safety
233
+ findings, gate decisions or installed skills. Static reports keep actual host loading and
234
+ truncation unknown; rough token estimates are not measured usage. Comparisons show covered
235
+ skills and description/body deltas when both reports contain the new evidence. See
236
+ [the audit command](docs/commands/audit.md) for metric definitions and unavailable-resource handling.
package/dist/cli.js CHANGED
@@ -1271,6 +1271,20 @@ function compareAudits(current, previous) {
1271
1271
  }
1272
1272
  }
1273
1273
  result.comparable = true;
1274
+ const previousSkills = new Map(previous.inventory.skills.map((skill) => [skill.path, skill]));
1275
+ let coveredSkills = 0;
1276
+ let descriptionCodePointDelta = 0;
1277
+ let entrypointCodePointDelta = 0;
1278
+ for (const skill of current.inventory.skills) {
1279
+ const prior = previousSkills.get(skill.path);
1280
+ const currentDiagnostics = skill.instructionDiagnostics;
1281
+ const priorDiagnostics = prior?.instructionDiagnostics;
1282
+ if (!prior || !currentDiagnostics || !priorDiagnostics) continue;
1283
+ coveredSkills++;
1284
+ descriptionCodePointDelta += (currentDiagnostics.discovery.description?.unicodeCodePoints ?? 0) - (priorDiagnostics.discovery.description?.unicodeCodePoints ?? 0);
1285
+ entrypointCodePointDelta += currentDiagnostics.entrypoint.unicodeCodePoints - priorDiagnostics.entrypoint.unicodeCodePoints;
1286
+ }
1287
+ result.instructionDiagnostics = coveredSkills > 0 ? { comparable: true, hostLoad: "unknown", coveredSkills, descriptionCodePointDelta, entrypointCodePointDelta } : { comparable: false, hostLoad: "unknown", reason: "Instruction diagnostics are unavailable in one or both report inventories.", coveredSkills: 0 };
1274
1288
  return result;
1275
1289
  }
1276
1290
  function loadAuditComparison(current, path) {
@@ -1284,6 +1298,7 @@ function renderAuditComparison(comparison) {
1284
1298
  const c = comparison;
1285
1299
  return ["## Changes since prior audit", "", ...c.comparable ? [
1286
1300
  `${c.added.length} new \xB7 ${c.changed.length} changed \xB7 ${c.resolved.length} no longer active \xB7 ${c.unchanged} unchanged.`,
1301
+ ...c.instructionDiagnostics?.comparable ? [`Instruction diagnostics: ${c.instructionDiagnostics.coveredSkills} shared skill(s); description ${signed(c.instructionDiagnostics.descriptionCodePointDelta ?? 0)} code points, entrypoint ${signed(c.instructionDiagnostics.entrypointCodePointDelta ?? 0)} code points.`] : c.instructionDiagnostics ? [`Instruction diagnostics unavailable: ${c.instructionDiagnostics.reason}`] : [],
1287
1302
  "No longer active can include accepted findings; it does not prove a security fix.",
1288
1303
  "",
1289
1304
  ...c.added.map((f) => `- NEW ${f.severity} [${sanitizeForMarkdown(f.rule)}] ${sanitizeForMarkdown(f.target)}`),
@@ -1291,12 +1306,16 @@ function renderAuditComparison(comparison) {
1291
1306
  ...c.resolved.map((f) => `- NO LONGER ACTIVE [${sanitizeForMarkdown(f.rule)}] ${sanitizeForMarkdown(f.target)}`)
1292
1307
  ] : [sanitizeForMarkdown(c.reason ?? "Comparison unavailable.")], ""];
1293
1308
  }
1309
+ function signed(value) {
1310
+ return value >= 0 ? `+${value}` : String(value);
1311
+ }
1294
1312
 
1295
1313
  // src/audit.ts
1296
1314
  import {
1297
1315
  existsSync as existsSync10,
1298
1316
  lstatSync as lstatSync3,
1299
1317
  mkdtempSync,
1318
+ opendirSync,
1300
1319
  readFileSync as readFileSync11,
1301
1320
  readdirSync as readdirSync5,
1302
1321
  realpathSync as realpathSync3,
@@ -2128,6 +2147,43 @@ function proFeatureStatus(feature, betaFree = BETA_FREE) {
2128
2147
  };
2129
2148
  }
2130
2149
 
2150
+ // src/instructionDiagnostics.ts
2151
+ function instructionTextMetrics(text) {
2152
+ const utf8Bytes = Buffer.byteLength(text, "utf8");
2153
+ return { utf8Bytes, unicodeCodePoints: Array.from(text).length, words: text.match(/\S+/gu)?.length ?? 0, estimatedTokens: Math.ceil(utf8Bytes / 4) };
2154
+ }
2155
+ function skillEntrypointBody(text) {
2156
+ const opening = text.match(/^---[ \t]*\r?\n/);
2157
+ if (!opening) return text;
2158
+ const closing = /^---[ \t]*(?:\r?\n|$)/gm;
2159
+ closing.lastIndex = opening[0].length;
2160
+ const match = closing.exec(text);
2161
+ return match ? text.slice(match.index + match[0].length) : text;
2162
+ }
2163
+ function diagnoseInstructions(name, description, entrypoint, resources) {
2164
+ const inputs = Array.isArray(resources) ? resources : Object.entries(resources).map(([directory, files]) => ({ directory, files }));
2165
+ const inventory = [...inputs].sort((a, b) => a.directory.localeCompare(b.directory)).map(({ directory, files, unavailableReason }) => ({
2166
+ directory,
2167
+ ...unavailableReason ? { status: "unavailable", reason: unavailableReason } : { status: "available", files: files ?? 0 },
2168
+ routedFromEntrypoint: new RegExp(`(?:^|[^A-Za-z0-9_-])${escapeRegExp(directory)}/`, "m").test(entrypoint)
2169
+ }));
2170
+ const advice = [];
2171
+ if (description && instructionTextMetrics(description).unicodeCodePoints > 500) advice.push({ kind: "long-description", detail: "description exceeds the advisory 500-code-point review bucket" });
2172
+ if (description && /\b(?:always|any|every|all)\b[^.\n]{0,90}\b(?:request|task|work|question)\b/i.test(description)) advice.push({ kind: "broad-trigger-wording", detail: "description contains broad/catchall trigger wording; review selection scope" });
2173
+ const entrypointMetrics = instructionTextMetrics(entrypoint);
2174
+ const entrypointLines = entrypoint === "" ? 0 : entrypoint.split(/\r\n|\r|\n/).length;
2175
+ if (entrypointLines > 500 || entrypointMetrics.words > 4e3) advice.push({ kind: "dense-entrypoint", detail: `entrypoint has ${entrypointLines} lines and ${entrypointMetrics.words} words; review routing before moving content` });
2176
+ const unavailable = inventory.filter((resource) => resource.status === "unavailable");
2177
+ if (unavailable.length > 0) advice.push({ kind: "resource-inventory-unavailable", detail: `resource inventory unavailable for ${unavailable.map((resource) => resource.directory).join(", ")}; no file count was inferred` });
2178
+ const unrouted = inventory.filter((resource) => resource.status === "available" && !resource.routedFromEntrypoint);
2179
+ if (unrouted.length > 0) advice.push({ kind: "unrouted-resources", detail: `entrypoint does not name ${unrouted.map((resource) => resource.directory).join(", ")}; confirm conditional routing or document it` });
2180
+ if (/\b(?:always|must)\s+(?:read|load)\b[^\n]*(?:references|reference|assets|templates|scripts|commands|hooks)\//i.test(entrypoint)) advice.push({ kind: "unconditional-resource-routing", detail: "entrypoint appears to require resource loading unconditionally; review whether a narrower trigger is possible" });
2181
+ return { hostLoad: "unknown", discovery: { ...name ? { name: instructionTextMetrics(name) } : {}, ...description ? { description: instructionTextMetrics(description) } : {} }, entrypoint: entrypointMetrics, resources: inventory, advice };
2182
+ }
2183
+ function escapeRegExp(value) {
2184
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2185
+ }
2186
+
2131
2187
  // src/audit.ts
2132
2188
  var OVERSIZED_SKILL_EST_TOKENS = 5e3;
2133
2189
  function toAuditFindings(source) {
@@ -2326,9 +2382,88 @@ function profileAuditedSkill(group, findings, unreadableSkillMdPaths, pluginId)
2326
2382
  validFrontmatter: fm.valid,
2327
2383
  description: fm.description,
2328
2384
  sizeBytes,
2329
- estTokens: estimateTokens(text)
2385
+ estTokens: estimateTokens(text),
2386
+ instructionDiagnostics: diagnoseInstructions(fm.name, fm.description, skillEntrypointBody(text), countSkillResources(group.canonicalPath))
2330
2387
  };
2331
2388
  }
2389
+ var RESOURCE_INVENTORY_SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
2390
+ var MAX_RESOURCE_INVENTORY_ENTRIES = 1e4;
2391
+ var MAX_RESOURCE_INVENTORY_DEPTH = 32;
2392
+ function countSkillResources(root) {
2393
+ const resourceDirs = ["scripts", "references", "reference", "commands", "hooks", "assets", "templates", "sub-skills"];
2394
+ const inventory = [];
2395
+ for (const directory of resourceDirs) {
2396
+ const resourceRoot = join13(root, directory);
2397
+ let rootStat;
2398
+ try {
2399
+ rootStat = lstatSync3(resourceRoot);
2400
+ } catch (error) {
2401
+ if (error.code !== "ENOENT") inventory.push({ directory, unavailableReason: `could not inspect resource directory: ${error.message}` });
2402
+ continue;
2403
+ }
2404
+ if (rootStat.isSymbolicLink()) {
2405
+ inventory.push({ directory, unavailableReason: "resource directory is a symlink and was not followed" });
2406
+ continue;
2407
+ }
2408
+ if (!rootStat.isDirectory()) continue;
2409
+ const pending = [{ path: resourceRoot, depth: 0 }];
2410
+ let files = 0;
2411
+ let entries = 0;
2412
+ let unavailableReason;
2413
+ while (pending.length > 0 && !unavailableReason) {
2414
+ const current = pending.pop();
2415
+ let handle;
2416
+ try {
2417
+ handle = opendirSync(current.path);
2418
+ } catch (error) {
2419
+ unavailableReason = `could not read resource subtree: ${error.message}`;
2420
+ break;
2421
+ }
2422
+ try {
2423
+ let entry;
2424
+ while ((entry = handle.readSync()) !== null) {
2425
+ if (++entries > MAX_RESOURCE_INVENTORY_ENTRIES) {
2426
+ unavailableReason = `resource inventory exceeded ${MAX_RESOURCE_INVENTORY_ENTRIES} entries`;
2427
+ break;
2428
+ }
2429
+ const path = join13(current.path, entry.name);
2430
+ let stat;
2431
+ try {
2432
+ stat = lstatSync3(path);
2433
+ } catch (error) {
2434
+ unavailableReason = `could not inspect resource entry: ${error.message}`;
2435
+ break;
2436
+ }
2437
+ if (stat.isSymbolicLink()) {
2438
+ unavailableReason = `resource subtree contains a symlink that was not followed: ${entry.name}`;
2439
+ break;
2440
+ }
2441
+ if (stat.isDirectory()) {
2442
+ if (RESOURCE_INVENTORY_SKIP_DIRS.has(entry.name)) {
2443
+ unavailableReason = `resource subtree contains skipped directory: ${entry.name}`;
2444
+ break;
2445
+ }
2446
+ if (current.depth >= MAX_RESOURCE_INVENTORY_DEPTH) {
2447
+ unavailableReason = `resource inventory exceeded depth ${MAX_RESOURCE_INVENTORY_DEPTH}`;
2448
+ break;
2449
+ }
2450
+ pending.push({ path, depth: current.depth + 1 });
2451
+ } else if (stat.isFile()) files++;
2452
+ }
2453
+ } catch (error) {
2454
+ unavailableReason = `could not read resource subtree: ${error.message}`;
2455
+ } finally {
2456
+ try {
2457
+ handle.closeSync();
2458
+ } catch (error) {
2459
+ if (!unavailableReason) unavailableReason = `could not close resource subtree: ${error.message}`;
2460
+ }
2461
+ }
2462
+ }
2463
+ inventory.push(unavailableReason ? { directory, unavailableReason } : { directory, files });
2464
+ }
2465
+ return inventory;
2466
+ }
2332
2467
  function hasRecognizedMcpShape(parsed) {
2333
2468
  if (typeof parsed !== "object" || parsed === null) return false;
2334
2469
  const obj = parsed;
@@ -2898,6 +3033,17 @@ function renderSkillsSection(skills) {
2898
3033
  )
2899
3034
  ];
2900
3035
  }
3036
+ function renderInstructionDiagnosticsSection(skills) {
3037
+ const lines = ["## Instruction diagnostics (advisory)", "", "- Static measurements only. Actual host loading and truncation are unknown; estimated tokens use ceil(UTF-8 bytes / 4) and are not observed usage."];
3038
+ for (const skill of skills) {
3039
+ const diagnostics = skill.instructionDiagnostics;
3040
+ if (!diagnostics) continue;
3041
+ const description = diagnostics.discovery.description;
3042
+ lines.push(`- ${sanitizeForMarkdown(skill.name)} \u2014 description: ${description ? `${description.utf8Bytes} UTF-8 bytes, ${description.unicodeCodePoints} Unicode code points, ${description.words} words, est. ${description.estimatedTokens} tokens` : "unavailable"}; entrypoint: ${diagnostics.entrypoint.utf8Bytes} UTF-8 bytes, ${diagnostics.entrypoint.unicodeCodePoints} Unicode code points, ${diagnostics.entrypoint.words} words, est. ${diagnostics.entrypoint.estimatedTokens} tokens; host load: unknown`);
3043
+ for (const advice of diagnostics.advice) lines.push(` - advisory ${advice.kind}: ${sanitizeForMarkdown(advice.detail)}`);
3044
+ }
3045
+ return lines;
3046
+ }
2901
3047
  function renderMcpTargetsSection(targets) {
2902
3048
  const lines = ["### MCP targets", ""];
2903
3049
  if (targets.length === 0) {
@@ -3055,6 +3201,8 @@ function renderAuditMarkdown(report) {
3055
3201
  "",
3056
3202
  ...renderSkillsSection(report.inventory.skills),
3057
3203
  "",
3204
+ ...renderInstructionDiagnosticsSection(report.inventory.skills),
3205
+ "",
3058
3206
  ...renderMcpTargetsSection(report.inventory.mcpTargets),
3059
3207
  "",
3060
3208
  ...renderFindingsSection(report.hygiene),
@@ -3807,6 +3955,7 @@ function profileSkillDir(inputPath) {
3807
3955
  resources,
3808
3956
  mcpDependencies: mcpDeps,
3809
3957
  externalPythonDeps: externalDeps,
3958
+ instructionDiagnostics: diagnoseInstructions(fm.name, fm.description, skillEntrypointBody(text), resources),
3810
3959
  artifactType: detectArtifactType(root),
3811
3960
  extends: fm.extends ?? []
3812
3961
  };