@rhize/skill-forge 0.19.0 → 0.20.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,9 +2,9 @@
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).
6
- Npm publication is a separate tag-driven step; `npm view @rhize/skill-forge version` reports the
7
- latest published version.
5
+ Published version: `@rhize/skill-forge@0.20.0` (2026-09-14), 0.x beta (Pro features free until 1.0).
6
+ Released through the tag/OIDC workflow; `npm view @rhize/skill-forge version` reports the
7
+ current published version.
8
8
 
9
9
  > This project is unrelated to the [`skillforge`](https://www.npmjs.com/package/skillforge)
10
10
  > package on npm, which is a Claude Skills *evaluation* framework. `skill-forge` (this package,
@@ -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
 
@@ -127,7 +128,10 @@ per-feature implementation status.
127
128
  outright; a lower-severity finding produces `warn`; a clean scan is `pass`.
128
129
  - **npm-sourced MCP candidates:** fetched with `--ignore-scripts`, and every tarball member path is
129
130
  validated before extraction (a path-traversal guard).
130
- - **SkillSpector, when installed,** is purely additive — its absence never blocks the gate.
131
+ - **SkillSpector, when installed,** adds its individual findings, including modern `issues`
132
+ reports. Its absence preserves the built-in-only gate. A failed, malformed or incomplete
133
+ installed scan is an explicit HIGH `skillspector-unavailable` finding, which blocks under
134
+ default strictness. A LOW aggregate score cannot hide a HIGH individual issue.
131
135
 
132
136
  See [docs/security-model.md](docs/security-model.md) for the full rule table and current status of
133
137
  each check.
@@ -157,8 +161,9 @@ including current caveats, in [docs/configuration.md](docs/configuration.md).
157
161
  **Is this related to the `skillforge` npm package?**
158
162
  No. [`skillforge`](https://www.npmjs.com/package/skillforge) is a Claude Skills *evaluation*
159
163
  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.
164
+ review gate for skill *installation* and ongoing collection maintenance. It reports detected
165
+ risks and potential overlap; it does not certify safety or task effectiveness. The similarly named
166
+ packages are independently maintained.
162
167
 
163
168
  **Does skill-forge replace `npx skills@latest add`?**
164
169
  No, it wraps it. `add` uses the same install mechanisms (skills.sh, git, local copy) but stages
@@ -223,3 +228,12 @@ loading in a fresh task before treating the refinement as consumed. Roll back us
223
228
  the returned backup ID with `refine rollback <id> --project --yes`.
224
229
 
225
230
  See the [governance operating guide](docs/governance-improvements.md) for scope, schemas, examples and limits.
231
+
232
+ ## Advisory instruction diagnostics (0.20)
233
+
234
+ `audit` reports description, entrypoint and resource measurements separately, with review
235
+ suggestions for broad triggers and resource routing. These suggestions never change safety
236
+ findings, gate decisions or installed skills. Static reports keep actual host loading and
237
+ truncation unknown; rough token estimates are not measured usage. Comparisons show covered
238
+ skills and description/body deltas when both reports contain the new evidence. See
239
+ [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,
@@ -1496,10 +1515,68 @@ function which(cmd) {
1496
1515
  }
1497
1516
  return void 0;
1498
1517
  }
1499
- function normalizeSeverity(sev) {
1500
- const s = (sev ?? "").toUpperCase();
1501
- if (s === "LOW" || s === "MEDIUM" || s === "HIGH" || s === "CRITICAL") return s;
1502
- return "MEDIUM";
1518
+ function scannerUnavailable(targetPath) {
1519
+ return {
1520
+ severity: "HIGH",
1521
+ rule: "skillspector-unavailable",
1522
+ file: targetPath,
1523
+ detail: "Installed SkillSpector failed or returned an unsupported/incomplete report; external safety evidence is unavailable."
1524
+ };
1525
+ }
1526
+ function reportRecord(value) {
1527
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid report record");
1528
+ return value;
1529
+ }
1530
+ function reportSeverity(value) {
1531
+ if (typeof value !== "string") throw new Error("Missing severity");
1532
+ const severity = value.toUpperCase();
1533
+ if (severity === "LOW" || severity === "MEDIUM" || severity === "HIGH" || severity === "CRITICAL") return severity;
1534
+ throw new Error("Unknown severity");
1535
+ }
1536
+ function parseSkillSpectorReport(value, targetPath) {
1537
+ try {
1538
+ const report = reportRecord(value);
1539
+ const modern = "issues" in report;
1540
+ const raw = modern ? report.issues : report.filtered_findings ?? report.findings;
1541
+ if (!Array.isArray(raw)) throw new Error("Missing findings");
1542
+ const findings = raw.map((entry) => {
1543
+ const issue = reportRecord(entry);
1544
+ const rule = modern ? issue.id : issue.rule_id;
1545
+ const detail = modern ? issue.finding ?? issue.explanation : issue.message;
1546
+ if (typeof rule !== "string" || !rule || typeof detail !== "string") throw new Error("Invalid finding");
1547
+ const location = modern ? reportRecord(issue.location) : void 0;
1548
+ if (location && typeof location.file !== "string") throw new Error("Invalid location");
1549
+ const line = location?.start_line;
1550
+ return {
1551
+ severity: reportSeverity(issue.severity),
1552
+ rule,
1553
+ file: location?.file ?? targetPath,
1554
+ detail: typeof line === "number" && Number.isInteger(line) && line > 0 ? `${line}: ${detail}` : detail
1555
+ };
1556
+ });
1557
+ const assessment = modern ? reportRecord(report.risk_assessment) : void 0;
1558
+ const summary = assessment?.severity ?? report.risk_severity;
1559
+ if (modern || summary !== void 0) {
1560
+ const severity = reportSeverity(summary);
1561
+ if (severity !== "LOW") findings.push({
1562
+ severity,
1563
+ rule: "skillspector-summary",
1564
+ file: targetPath,
1565
+ detail: `SkillSpector aggregate risk: ${severity}.`
1566
+ });
1567
+ }
1568
+ if (modern) {
1569
+ const completeness = reportRecord(report.analysis_completeness);
1570
+ if (report.execution_successful !== true || completeness.is_complete !== true || completeness.execution_successful === false) {
1571
+ findings.push(scannerUnavailable(targetPath));
1572
+ }
1573
+ } else if (report.execution_successful === false) {
1574
+ findings.push(scannerUnavailable(targetPath));
1575
+ }
1576
+ return findings;
1577
+ } catch {
1578
+ return [scannerUnavailable(targetPath)];
1579
+ }
1503
1580
  }
1504
1581
  async function scanWithSkillSpector(targetPath) {
1505
1582
  const bin = which("skillspector");
@@ -1509,16 +1586,15 @@ async function scanWithSkillSpector(targetPath) {
1509
1586
  timeout: 6e4,
1510
1587
  maxBuffer: 10 * 1024 * 1024
1511
1588
  });
1512
- const parsed = JSON.parse(stdout);
1513
- const raw = parsed.filtered_findings ?? parsed.findings ?? [];
1514
- return raw.map((f) => ({
1515
- severity: normalizeSeverity(f.severity),
1516
- rule: f.rule_id ?? "skillspector",
1517
- file: targetPath,
1518
- detail: f.message ?? ""
1519
- }));
1520
- } catch {
1521
- return [];
1589
+ return parseSkillSpectorReport(JSON.parse(stdout), targetPath);
1590
+ } catch (error) {
1591
+ const failed = scannerUnavailable(targetPath);
1592
+ try {
1593
+ const stdout = error.stdout;
1594
+ return stdout ? [...parseSkillSpectorReport(JSON.parse(stdout), targetPath), failed] : [failed];
1595
+ } catch {
1596
+ return [failed];
1597
+ }
1522
1598
  }
1523
1599
  }
1524
1600
  async function scanSafety(targetPath, strictness = DEFAULT_STRICTNESS, capturedFiles) {
@@ -2128,6 +2204,43 @@ function proFeatureStatus(feature, betaFree = BETA_FREE) {
2128
2204
  };
2129
2205
  }
2130
2206
 
2207
+ // src/instructionDiagnostics.ts
2208
+ function instructionTextMetrics(text) {
2209
+ const utf8Bytes = Buffer.byteLength(text, "utf8");
2210
+ return { utf8Bytes, unicodeCodePoints: Array.from(text).length, words: text.match(/\S+/gu)?.length ?? 0, estimatedTokens: Math.ceil(utf8Bytes / 4) };
2211
+ }
2212
+ function skillEntrypointBody(text) {
2213
+ const opening = text.match(/^---[ \t]*\r?\n/);
2214
+ if (!opening) return text;
2215
+ const closing = /^---[ \t]*(?:\r?\n|$)/gm;
2216
+ closing.lastIndex = opening[0].length;
2217
+ const match = closing.exec(text);
2218
+ return match ? text.slice(match.index + match[0].length) : text;
2219
+ }
2220
+ function diagnoseInstructions(name, description, entrypoint, resources) {
2221
+ const inputs = Array.isArray(resources) ? resources : Object.entries(resources).map(([directory, files]) => ({ directory, files }));
2222
+ const inventory = [...inputs].sort((a, b) => a.directory.localeCompare(b.directory)).map(({ directory, files, unavailableReason }) => ({
2223
+ directory,
2224
+ ...unavailableReason ? { status: "unavailable", reason: unavailableReason } : { status: "available", files: files ?? 0 },
2225
+ routedFromEntrypoint: new RegExp(`(?:^|[^A-Za-z0-9_-])${escapeRegExp(directory)}/`, "m").test(entrypoint)
2226
+ }));
2227
+ const advice = [];
2228
+ if (description && instructionTextMetrics(description).unicodeCodePoints > 500) advice.push({ kind: "long-description", detail: "description exceeds the advisory 500-code-point review bucket" });
2229
+ 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" });
2230
+ const entrypointMetrics = instructionTextMetrics(entrypoint);
2231
+ const entrypointLines = entrypoint === "" ? 0 : entrypoint.split(/\r\n|\r|\n/).length;
2232
+ 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` });
2233
+ const unavailable = inventory.filter((resource) => resource.status === "unavailable");
2234
+ 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` });
2235
+ const unrouted = inventory.filter((resource) => resource.status === "available" && !resource.routedFromEntrypoint);
2236
+ 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` });
2237
+ 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" });
2238
+ return { hostLoad: "unknown", discovery: { ...name ? { name: instructionTextMetrics(name) } : {}, ...description ? { description: instructionTextMetrics(description) } : {} }, entrypoint: entrypointMetrics, resources: inventory, advice };
2239
+ }
2240
+ function escapeRegExp(value) {
2241
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2242
+ }
2243
+
2131
2244
  // src/audit.ts
2132
2245
  var OVERSIZED_SKILL_EST_TOKENS = 5e3;
2133
2246
  function toAuditFindings(source) {
@@ -2326,9 +2439,88 @@ function profileAuditedSkill(group, findings, unreadableSkillMdPaths, pluginId)
2326
2439
  validFrontmatter: fm.valid,
2327
2440
  description: fm.description,
2328
2441
  sizeBytes,
2329
- estTokens: estimateTokens(text)
2442
+ estTokens: estimateTokens(text),
2443
+ instructionDiagnostics: diagnoseInstructions(fm.name, fm.description, skillEntrypointBody(text), countSkillResources(group.canonicalPath))
2330
2444
  };
2331
2445
  }
2446
+ var RESOURCE_INVENTORY_SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
2447
+ var MAX_RESOURCE_INVENTORY_ENTRIES = 1e4;
2448
+ var MAX_RESOURCE_INVENTORY_DEPTH = 32;
2449
+ function countSkillResources(root) {
2450
+ const resourceDirs = ["scripts", "references", "reference", "commands", "hooks", "assets", "templates", "sub-skills"];
2451
+ const inventory = [];
2452
+ for (const directory of resourceDirs) {
2453
+ const resourceRoot = join13(root, directory);
2454
+ let rootStat;
2455
+ try {
2456
+ rootStat = lstatSync3(resourceRoot);
2457
+ } catch (error) {
2458
+ if (error.code !== "ENOENT") inventory.push({ directory, unavailableReason: `could not inspect resource directory: ${error.message}` });
2459
+ continue;
2460
+ }
2461
+ if (rootStat.isSymbolicLink()) {
2462
+ inventory.push({ directory, unavailableReason: "resource directory is a symlink and was not followed" });
2463
+ continue;
2464
+ }
2465
+ if (!rootStat.isDirectory()) continue;
2466
+ const pending = [{ path: resourceRoot, depth: 0 }];
2467
+ let files = 0;
2468
+ let entries = 0;
2469
+ let unavailableReason;
2470
+ while (pending.length > 0 && !unavailableReason) {
2471
+ const current = pending.pop();
2472
+ let handle;
2473
+ try {
2474
+ handle = opendirSync(current.path);
2475
+ } catch (error) {
2476
+ unavailableReason = `could not read resource subtree: ${error.message}`;
2477
+ break;
2478
+ }
2479
+ try {
2480
+ let entry;
2481
+ while ((entry = handle.readSync()) !== null) {
2482
+ if (++entries > MAX_RESOURCE_INVENTORY_ENTRIES) {
2483
+ unavailableReason = `resource inventory exceeded ${MAX_RESOURCE_INVENTORY_ENTRIES} entries`;
2484
+ break;
2485
+ }
2486
+ const path = join13(current.path, entry.name);
2487
+ let stat;
2488
+ try {
2489
+ stat = lstatSync3(path);
2490
+ } catch (error) {
2491
+ unavailableReason = `could not inspect resource entry: ${error.message}`;
2492
+ break;
2493
+ }
2494
+ if (stat.isSymbolicLink()) {
2495
+ unavailableReason = `resource subtree contains a symlink that was not followed: ${entry.name}`;
2496
+ break;
2497
+ }
2498
+ if (stat.isDirectory()) {
2499
+ if (RESOURCE_INVENTORY_SKIP_DIRS.has(entry.name)) {
2500
+ unavailableReason = `resource subtree contains skipped directory: ${entry.name}`;
2501
+ break;
2502
+ }
2503
+ if (current.depth >= MAX_RESOURCE_INVENTORY_DEPTH) {
2504
+ unavailableReason = `resource inventory exceeded depth ${MAX_RESOURCE_INVENTORY_DEPTH}`;
2505
+ break;
2506
+ }
2507
+ pending.push({ path, depth: current.depth + 1 });
2508
+ } else if (stat.isFile()) files++;
2509
+ }
2510
+ } catch (error) {
2511
+ unavailableReason = `could not read resource subtree: ${error.message}`;
2512
+ } finally {
2513
+ try {
2514
+ handle.closeSync();
2515
+ } catch (error) {
2516
+ if (!unavailableReason) unavailableReason = `could not close resource subtree: ${error.message}`;
2517
+ }
2518
+ }
2519
+ }
2520
+ inventory.push(unavailableReason ? { directory, unavailableReason } : { directory, files });
2521
+ }
2522
+ return inventory;
2523
+ }
2332
2524
  function hasRecognizedMcpShape(parsed) {
2333
2525
  if (typeof parsed !== "object" || parsed === null) return false;
2334
2526
  const obj = parsed;
@@ -2898,6 +3090,17 @@ function renderSkillsSection(skills) {
2898
3090
  )
2899
3091
  ];
2900
3092
  }
3093
+ function renderInstructionDiagnosticsSection(skills) {
3094
+ 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."];
3095
+ for (const skill of skills) {
3096
+ const diagnostics = skill.instructionDiagnostics;
3097
+ if (!diagnostics) continue;
3098
+ const description = diagnostics.discovery.description;
3099
+ 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`);
3100
+ for (const advice of diagnostics.advice) lines.push(` - advisory ${advice.kind}: ${sanitizeForMarkdown(advice.detail)}`);
3101
+ }
3102
+ return lines;
3103
+ }
2901
3104
  function renderMcpTargetsSection(targets) {
2902
3105
  const lines = ["### MCP targets", ""];
2903
3106
  if (targets.length === 0) {
@@ -3055,6 +3258,8 @@ function renderAuditMarkdown(report) {
3055
3258
  "",
3056
3259
  ...renderSkillsSection(report.inventory.skills),
3057
3260
  "",
3261
+ ...renderInstructionDiagnosticsSection(report.inventory.skills),
3262
+ "",
3058
3263
  ...renderMcpTargetsSection(report.inventory.mcpTargets),
3059
3264
  "",
3060
3265
  ...renderFindingsSection(report.hygiene),
@@ -3807,6 +4012,7 @@ function profileSkillDir(inputPath) {
3807
4012
  resources,
3808
4013
  mcpDependencies: mcpDeps,
3809
4014
  externalPythonDeps: externalDeps,
4015
+ instructionDiagnostics: diagnoseInstructions(fm.name, fm.description, skillEntrypointBody(text), resources),
3810
4016
  artifactType: detectArtifactType(root),
3811
4017
  extends: fm.extends ?? []
3812
4018
  };