@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 +23 -12
- package/dist/cli.js +150 -1
- package/dist/cli.js.map +1 -1
- package/package.json +2 -2
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.
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
};
|