@rhize/skill-forge 0.7.0 → 0.8.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/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { Command } from "commander";
5
5
  import { createRequire } from "module";
6
6
 
7
7
  // src/commands/add.ts
8
- import { join as join15 } from "path";
8
+ import { join as join18 } from "path";
9
9
 
10
10
  // src/config.ts
11
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
@@ -18,6 +18,16 @@ function getHomeDir() {
18
18
  function getConfigPath() {
19
19
  return join(getHomeDir(), "config.json");
20
20
  }
21
+ function normalizeBusinessProfile(raw) {
22
+ if (!raw || typeof raw !== "object") return void 0;
23
+ const toStringArray = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
24
+ return {
25
+ ...raw,
26
+ audiences: toStringArray(raw.audiences),
27
+ workflows: toStringArray(raw.workflows),
28
+ constraints: toStringArray(raw.constraints)
29
+ };
30
+ }
21
31
  function defaultConfig() {
22
32
  const home = getHomeDir();
23
33
  return {
@@ -42,7 +52,9 @@ function loadConfig() {
42
52
  defaultTarget: parsed.defaultTarget ?? defaults.defaultTarget,
43
53
  agents: parsed.agents ?? defaults.agents,
44
54
  handoffCommand: parsed.handoffCommand ?? defaults.handoffCommand,
45
- mcpTargets: parsed.mcpTargets ?? defaults.mcpTargets
55
+ mcpTargets: parsed.mcpTargets ?? defaults.mcpTargets,
56
+ businessProfile: normalizeBusinessProfile(parsed.businessProfile) ?? defaults.businessProfile,
57
+ foundationSkillPath: parsed.foundationSkillPath ?? defaults.foundationSkillPath
46
58
  };
47
59
  }
48
60
  function saveConfig(config) {
@@ -295,16 +307,132 @@ function moveDir(src, dest) {
295
307
  }
296
308
 
297
309
  // src/install/promoteMcp.ts
298
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
310
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
299
311
  import { dirname } from "path";
312
+
313
+ // src/gate/scanShared.ts
314
+ import { readFileSync as readFileSync2, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
315
+ import { extname, join as join5 } from "path";
316
+ var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
317
+ var BINARY_EXT = /* @__PURE__ */ new Set([
318
+ ".png",
319
+ ".jpg",
320
+ ".jpeg",
321
+ ".gif",
322
+ ".webp",
323
+ ".ico",
324
+ ".pdf",
325
+ ".zip",
326
+ ".tar",
327
+ ".gz",
328
+ ".woff",
329
+ ".woff2",
330
+ ".ttf",
331
+ ".eot",
332
+ ".mp4",
333
+ ".mp3",
334
+ ".wasm",
335
+ ".bin",
336
+ ".exe"
337
+ ]);
338
+ var MAX_FILE_BYTES = 2 * 1024 * 1024;
339
+ function* walk(dir) {
340
+ let entries;
341
+ try {
342
+ entries = readdirSync2(dir, { withFileTypes: true });
343
+ } catch {
344
+ return;
345
+ }
346
+ for (const entry of entries) {
347
+ const full = join5(dir, entry.name);
348
+ if (entry.isDirectory()) {
349
+ if (SKIP_DIRS2.has(entry.name)) continue;
350
+ yield* walk(full);
351
+ } else if (entry.isFile()) {
352
+ yield full;
353
+ }
354
+ }
355
+ }
356
+ function readTextFile(file) {
357
+ if (BINARY_EXT.has(extname(file).toLowerCase())) return void 0;
358
+ try {
359
+ if (statSync2(file).size > MAX_FILE_BYTES) return void 0;
360
+ const content = readFileSync2(file, "utf-8");
361
+ if (content.slice(0, 512).includes("\0")) return void 0;
362
+ return content;
363
+ } catch {
364
+ return void 0;
365
+ }
366
+ }
367
+ var BLOCK_SEVERITIES_BY_STRICTNESS = {
368
+ "block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
369
+ "block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
370
+ "warn-only": /* @__PURE__ */ new Set()
371
+ };
372
+ function verdictFor(findings, strictness) {
373
+ const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS[strictness];
374
+ if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
375
+ if (findings.length > 0) return "warn";
376
+ return "pass";
377
+ }
378
+ function formatSafetyFinding(f) {
379
+ return `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`;
380
+ }
381
+ function isPinnedPackageSpec(spec) {
382
+ const withoutScope = spec.startsWith("@") ? spec.slice(spec.indexOf("/") + 1) : spec;
383
+ const atIdx = withoutScope.lastIndexOf("@");
384
+ if (atIdx <= 0) return false;
385
+ return /^\d/.test(withoutScope.slice(atIdx + 1));
386
+ }
387
+ function isNpxCommand(command) {
388
+ const base = command.split(/[\\/]/).pop() ?? command;
389
+ return base.replace(/\.(cmd|exe)$/i, "").toLowerCase() === "npx";
390
+ }
391
+ function packageSpecBase(spec) {
392
+ const scopeEnd = spec.startsWith("@") ? spec.indexOf("/") + 1 : 0;
393
+ const atIdx = spec.lastIndexOf("@");
394
+ return atIdx > scopeEnd ? spec.slice(0, atIdx) : spec;
395
+ }
396
+ var NPX_VALUE_FLAGS = /* @__PURE__ */ new Set(["p", "package", "c", "call", "cache", "userconfig", "shell", "n", "node-arg", "npm", "registry"]);
397
+ var NPX_PACKAGE_SPEC_FLAGS = /* @__PURE__ */ new Set(["p", "package"]);
398
+ function collectNpxPackageSpecs(args) {
399
+ const packageFlagSpecs = [];
400
+ let positionalSpec;
401
+ for (let i = 0; i < args.length; i++) {
402
+ const arg = args[i];
403
+ if (arg === "--") break;
404
+ if (arg.startsWith("-")) {
405
+ const eqIdx = arg.indexOf("=");
406
+ const key = (eqIdx === -1 ? arg : arg.slice(0, eqIdx)).replace(/^-+/, "");
407
+ const attached = eqIdx === -1 ? void 0 : arg.slice(eqIdx + 1);
408
+ if (NPX_VALUE_FLAGS.has(key)) {
409
+ let value = attached;
410
+ if (value === void 0) {
411
+ value = args[i + 1];
412
+ i++;
413
+ }
414
+ if (NPX_PACKAGE_SPEC_FLAGS.has(key) && value !== void 0) {
415
+ packageFlagSpecs.push(value);
416
+ }
417
+ continue;
418
+ }
419
+ continue;
420
+ }
421
+ positionalSpec = arg;
422
+ break;
423
+ }
424
+ return { packageFlagSpecs, positionalSpec };
425
+ }
426
+
427
+ // src/install/promoteMcp.ts
300
428
  function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
301
429
  const force = options.force ?? false;
302
430
  const serverName = profile.name;
303
- const { command, args, envVarsToFill } = deriveServerEntry(profile);
431
+ const { command, args, envVarsToFill, autoPinned } = deriveServerEntry(profile);
304
432
  let config = {};
305
433
  let rawExisting;
306
434
  if (existsSync5(targetConfigPath)) {
307
- rawExisting = readFileSync2(targetConfigPath, "utf-8");
435
+ rawExisting = readFileSync3(targetConfigPath, "utf-8");
308
436
  try {
309
437
  config = JSON.parse(rawExisting);
310
438
  } catch (err) {
@@ -329,7 +457,7 @@ function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
329
457
  mkdirSync4(dirname(targetConfigPath), { recursive: true });
330
458
  writeFileSync2(targetConfigPath, `${JSON.stringify(config, null, 2)}
331
459
  `);
332
- return { target: targetConfigPath, serverName, envVarsToFill, backupPath };
460
+ return { target: targetConfigPath, serverName, envVarsToFill, backupPath, autoPinned };
333
461
  }
334
462
  function deriveServerEntry(profile) {
335
463
  const envNames = new Set(profile.envVars ?? []);
@@ -337,9 +465,41 @@ function deriveServerEntry(profile) {
337
465
  for (const key of Object.keys(profile.candidateEntry.env)) envNames.add(key);
338
466
  }
339
467
  if (profile.candidateEntry) {
468
+ const { command, args = [] } = profile.candidateEntry;
469
+ if (isNpxCommand(command)) {
470
+ const { packageFlagSpecs, positionalSpec } = collectNpxPackageSpecs(args);
471
+ const allSpecs = positionalSpec ? [...packageFlagSpecs, positionalSpec] : packageFlagSpecs;
472
+ const unpinned = allSpecs.filter((spec) => !isPinnedPackageSpec(spec));
473
+ if (unpinned.length > 0) {
474
+ const isSoleUnpinnedPositional = packageFlagSpecs.length === 0 && allSpecs.length === 1 && positionalSpec === unpinned[0];
475
+ if (isSoleUnpinnedPositional) {
476
+ const spec = unpinned[0];
477
+ const specPackage = packageSpecBase(spec);
478
+ if (profile.packageName && specPackage === profile.packageName && profile.version) {
479
+ const to = `${profile.packageName}@${profile.version}`;
480
+ const specIdx = args.indexOf(spec);
481
+ const pinnedArgs = [...args];
482
+ pinnedArgs[specIdx] = to;
483
+ return {
484
+ command,
485
+ args: pinnedArgs,
486
+ envVarsToFill: Array.from(envNames),
487
+ autoPinned: { from: spec, to }
488
+ };
489
+ }
490
+ const reason = profile.packageName && specPackage !== profile.packageName ? `the launch spec names package "${specPackage}", but the candidate's own package.json is "${profile.packageName}" \u2014 pinning it to a version scanned for a different package would be unsafe` : "no version was found in the candidate's own package.json to pin it to";
491
+ throw new UserError(
492
+ `cannot promote MCP server "${profile.name}": its documented launch command "npx ${spec}" is unpinned and can't be safely auto-pinned (${reason}). Pin the spec to an exact version in the candidate's config (e.g. \`${specPackage}@<version>\`) and try again \u2014 there is no override for this.`
493
+ );
494
+ }
495
+ throw new UserError(
496
+ `cannot promote MCP server "${profile.name}": its documented launch command "npx" has unpinned package spec(s) \u2014 ${unpinned.map((s) => `"${s}"`).join(", ")} \u2014 and this shape (a -p/--package dependency, or more than one spec) can't be safely auto-pinned. Pin every spec to an exact version in the candidate's config and try again \u2014 there is no override for this.`
497
+ );
498
+ }
499
+ }
340
500
  return {
341
- command: profile.candidateEntry.command,
342
- args: profile.candidateEntry.args ?? [],
501
+ command,
502
+ args,
343
503
  envVarsToFill: Array.from(envNames)
344
504
  };
345
505
  }
@@ -356,18 +516,22 @@ function deriveServerEntry(profile) {
356
516
  }
357
517
 
358
518
  // src/install/mcpCandidate.ts
359
- import { readFileSync as readFileSync3 } from "fs";
360
- function readPackageVersion(pkgPath) {
519
+ import { readFileSync as readFileSync4 } from "fs";
520
+ import { dirname as dirname2, join as join6 } from "path";
521
+ function readPackageInfo(pkgPath) {
361
522
  try {
362
- const pkg2 = JSON.parse(readFileSync3(pkgPath, "utf-8"));
363
- return typeof pkg2.version === "string" ? pkg2.version : void 0;
523
+ const pkg2 = JSON.parse(readFileSync4(pkgPath, "utf-8"));
524
+ return {
525
+ name: typeof pkg2.name === "string" ? pkg2.name : void 0,
526
+ version: typeof pkg2.version === "string" ? pkg2.version : void 0
527
+ };
364
528
  } catch {
365
- return void 0;
529
+ return {};
366
530
  }
367
531
  }
368
532
  function readConfigEntry(configPath, name) {
369
533
  try {
370
- const cfg = JSON.parse(readFileSync3(configPath, "utf-8"));
534
+ const cfg = JSON.parse(readFileSync4(configPath, "utf-8"));
371
535
  const servers = cfg.mcpServers ?? cfg.servers ?? {};
372
536
  const raw = servers[name] ?? {};
373
537
  return {
@@ -382,15 +546,23 @@ function readConfigEntry(configPath, name) {
382
546
  function resolveMcpCandidateDetail(profile) {
383
547
  const name = profile.servers[0];
384
548
  if (profile.config.endsWith("package.json")) {
385
- return { name, version: readPackageVersion(profile.config) };
549
+ return { name, version: readPackageInfo(profile.config).version };
386
550
  }
387
551
  const entry = readConfigEntry(profile.config, name);
388
- return { name, command: entry?.command, args: entry?.args, env: entry?.env };
552
+ const sidecar = readPackageInfo(join6(dirname2(profile.config), "package.json"));
553
+ return {
554
+ name,
555
+ command: entry?.command,
556
+ args: entry?.args,
557
+ env: entry?.env,
558
+ version: sidecar.version,
559
+ packageName: sidecar.name
560
+ };
389
561
  }
390
562
 
391
563
  // src/provenance.ts
392
- import { appendFileSync, existsSync as existsSync6, statSync as statSync2, writeFileSync as writeFileSync3 } from "fs";
393
- import { join as join5 } from "path";
564
+ import { appendFileSync, existsSync as existsSync6, statSync as statSync3, writeFileSync as writeFileSync3 } from "fs";
565
+ import { join as join7 } from "path";
394
566
  var FORGE_VERBS = ["DEFER", "ABSORB", "FORK", "REJECT", "WATCH"];
395
567
  var LEDGER_HEADER = "# Skill Forge \u2014 Provenance Ledger\n\nOne entry per external-skill ingestion decision.\n";
396
568
  function todayLocalIsoDate() {
@@ -404,7 +576,7 @@ function appendProvenance(entry, skillsRoot) {
404
576
  if (!FORGE_VERBS.includes(entry.verb)) {
405
577
  throw new Error(`verb must be one of ${FORGE_VERBS.join(", ")}`);
406
578
  }
407
- if (!existsSync6(skillsRoot) || !statSync2(skillsRoot).isDirectory()) {
579
+ if (!existsSync6(skillsRoot) || !statSync3(skillsRoot).isDirectory()) {
408
580
  throw new Error(`skills-root not a directory: ${skillsRoot}`);
409
581
  }
410
582
  const today = todayLocalIsoDate();
@@ -421,7 +593,7 @@ function appendProvenance(entry, skillsRoot) {
421
593
  - **Drift check:** \`${driftCheck}\`
422
594
  - **Notes:** ${entry.notes ?? ""}
423
595
  `;
424
- const ledgerPath = join5(skillsRoot, "SOURCES.md");
596
+ const ledgerPath = join7(skillsRoot, "SOURCES.md");
425
597
  if (!existsSync6(ledgerPath)) {
426
598
  writeFileSync3(ledgerPath, LEDGER_HEADER, "utf-8");
427
599
  }
@@ -431,7 +603,7 @@ function appendProvenance(entry, skillsRoot) {
431
603
 
432
604
  // src/commands/gatePipeline.ts
433
605
  import { readdirSync as readdirSync5 } from "fs";
434
- import { join as join11 } from "path";
606
+ import { join as join12 } from "path";
435
607
 
436
608
  // src/gate/profile.ts
437
609
  import { execFileSync } from "child_process";
@@ -447,82 +619,11 @@ import {
447
619
  statSync as statSync4
448
620
  } from "fs";
449
621
  import { tmpdir } from "os";
450
- import { basename as basename3, dirname as dirname2, extname as extname3, join as join8, resolve as resolve2 } from "path";
622
+ import { basename as basename3, dirname as dirname3, extname as extname3, join as join9, resolve as resolve2 } from "path";
451
623
 
452
624
  // src/gate/mcpCapabilities.ts
453
625
  import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
454
- import { extname as extname2, join as join7 } from "path";
455
-
456
- // src/gate/scanShared.ts
457
- import { readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
458
- import { extname, join as join6 } from "path";
459
- var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
460
- var BINARY_EXT = /* @__PURE__ */ new Set([
461
- ".png",
462
- ".jpg",
463
- ".jpeg",
464
- ".gif",
465
- ".webp",
466
- ".ico",
467
- ".pdf",
468
- ".zip",
469
- ".tar",
470
- ".gz",
471
- ".woff",
472
- ".woff2",
473
- ".ttf",
474
- ".eot",
475
- ".mp4",
476
- ".mp3",
477
- ".wasm",
478
- ".bin",
479
- ".exe"
480
- ]);
481
- var MAX_FILE_BYTES = 2 * 1024 * 1024;
482
- function* walk(dir) {
483
- let entries;
484
- try {
485
- entries = readdirSync2(dir, { withFileTypes: true });
486
- } catch {
487
- return;
488
- }
489
- for (const entry of entries) {
490
- const full = join6(dir, entry.name);
491
- if (entry.isDirectory()) {
492
- if (SKIP_DIRS2.has(entry.name)) continue;
493
- yield* walk(full);
494
- } else if (entry.isFile()) {
495
- yield full;
496
- }
497
- }
498
- }
499
- function readTextFile(file) {
500
- if (BINARY_EXT.has(extname(file).toLowerCase())) return void 0;
501
- try {
502
- if (statSync3(file).size > MAX_FILE_BYTES) return void 0;
503
- const content = readFileSync4(file, "utf-8");
504
- if (content.slice(0, 512).includes("\0")) return void 0;
505
- return content;
506
- } catch {
507
- return void 0;
508
- }
509
- }
510
- var BLOCK_SEVERITIES_BY_STRICTNESS = {
511
- "block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
512
- "block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
513
- "warn-only": /* @__PURE__ */ new Set()
514
- };
515
- function verdictFor(findings, strictness) {
516
- const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS[strictness];
517
- if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
518
- if (findings.length > 0) return "warn";
519
- return "pass";
520
- }
521
- function formatSafetyFinding(f) {
522
- return `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`;
523
- }
524
-
525
- // src/gate/mcpCapabilities.ts
626
+ import { extname as extname2, join as join8 } from "path";
526
627
  var SOURCE_EXT = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
527
628
  var MAX_ITEMS_PER_CATEGORY = 200;
528
629
  function readJsonSafe(file) {
@@ -628,7 +729,7 @@ var MCP_SDK_IMPORT_RE = /@modelcontextprotocol\/sdk/;
628
729
  function extractMcpCapabilities(packageDir) {
629
730
  const buckets = { tools: /* @__PURE__ */ new Set(), resources: /* @__PURE__ */ new Set(), prompts: /* @__PURE__ */ new Set() };
630
731
  let hasPackageJsonMarker = false;
631
- const pkgPath = join7(packageDir, "package.json");
732
+ const pkgPath = join8(packageDir, "package.json");
632
733
  if (existsSync7(pkgPath)) {
633
734
  const pkg2 = readJsonSafe(pkgPath);
634
735
  if (pkg2 && typeof pkg2 === "object" && !Array.isArray(pkg2)) {
@@ -636,7 +737,7 @@ function extractMcpCapabilities(packageDir) {
636
737
  }
637
738
  }
638
739
  for (const manifestName of [".mcp.json", "mcp.json"]) {
639
- const manifestPath = join7(packageDir, manifestName);
740
+ const manifestPath = join8(packageDir, manifestName);
640
741
  if (!existsSync7(manifestPath)) continue;
641
742
  const parsed = readJsonSafe(manifestPath);
642
743
  if (parsed !== void 0) {
@@ -744,7 +845,7 @@ function asProfile(candidatePath, artifact) {
744
845
  return asSkillProfile(profile, `at ${candidatePath}`);
745
846
  }
746
847
  function classifyInput(p) {
747
- if (existsSync8(join8(p, ".claude-plugin", "plugin.json"))) return "plugin";
848
+ if (existsSync8(join9(p, ".claude-plugin", "plugin.json"))) return "plugin";
748
849
  if (detectArtifactType(p) === "mcp") return "mcp";
749
850
  return "skill";
750
851
  }
@@ -752,7 +853,7 @@ var MCP_SDK_DEP2 = "@modelcontextprotocol/sdk";
752
853
  var MCP_KEYWORD2 = "mcp-server";
753
854
  var MCP_BIN_PREFIX2 = /^mcp-/i;
754
855
  function readPackageJsonSafe(root) {
755
- const pkgPath = join8(root, "package.json");
856
+ const pkgPath = join9(root, "package.json");
756
857
  if (!existsSync8(pkgPath)) return void 0;
757
858
  try {
758
859
  return JSON.parse(readFileSync6(pkgPath, "utf-8"));
@@ -776,7 +877,7 @@ function hasMcpPackageMarkers(pkg2) {
776
877
  return false;
777
878
  }
778
879
  function detectArtifactType(root) {
779
- if (existsSync8(join8(root, ".mcp.json")) || existsSync8(join8(root, "mcp.json"))) return "mcp";
880
+ if (existsSync8(join9(root, ".mcp.json")) || existsSync8(join9(root, "mcp.json"))) return "mcp";
780
881
  const pkg2 = readPackageJsonSafe(root);
781
882
  if (pkg2 && hasMcpPackageMarkers(pkg2)) return "mcp";
782
883
  return "skill";
@@ -786,22 +887,22 @@ function resolveSkillMd(inputPath) {
786
887
  let tmpDir;
787
888
  const initialStat = statSync4(currentPath);
788
889
  if (extname3(currentPath) === ".skill" || initialStat.isFile() && isZipFile(currentPath)) {
789
- tmpDir = mkdtempSync(join8(tmpdir(), "forge_"));
890
+ tmpDir = mkdtempSync(join9(tmpdir(), "forge_"));
790
891
  extractZip(currentPath, tmpDir);
791
892
  currentPath = tmpDir;
792
893
  }
793
894
  const stat = statSync4(currentPath);
794
895
  if (stat.isFile() && basename3(currentPath) === "SKILL.md") {
795
- return { skillMd: currentPath, root: dirname2(currentPath), tmpDir };
896
+ return { skillMd: currentPath, root: dirname3(currentPath), tmpDir };
796
897
  }
797
898
  if (stat.isDirectory()) {
798
- const direct = join8(currentPath, "SKILL.md");
899
+ const direct = join9(currentPath, "SKILL.md");
799
900
  if (existsSync8(direct)) {
800
901
  return { skillMd: direct, root: currentPath, tmpDir };
801
902
  }
802
903
  const matches = findSkillMdOneLevelDown(currentPath);
803
904
  if (matches.length === 1) {
804
- return { skillMd: matches[0], root: dirname2(matches[0]), tmpDir };
905
+ return { skillMd: matches[0], root: dirname3(matches[0]), tmpDir };
805
906
  }
806
907
  if (matches.length > 1) {
807
908
  cleanupTmp(tmpDir);
@@ -821,15 +922,15 @@ function findSkillMdOneLevelDown(dir) {
821
922
  const matches = /* @__PURE__ */ new Set();
822
923
  for (const entry of readdirSync3(dir, { withFileTypes: true })) {
823
924
  if (entry.isDirectory()) {
824
- const candidate = join8(dir, entry.name, "SKILL.md");
925
+ const candidate = join9(dir, entry.name, "SKILL.md");
825
926
  if (existsSync8(candidate)) matches.add(candidate);
826
927
  }
827
928
  }
828
- const skillsDir = join8(dir, "skills");
929
+ const skillsDir = join9(dir, "skills");
829
930
  if (isDir(skillsDir)) {
830
931
  for (const entry of readdirSync3(skillsDir, { withFileTypes: true })) {
831
932
  if (entry.isDirectory()) {
832
- const candidate = join8(skillsDir, entry.name, "SKILL.md");
933
+ const candidate = join9(skillsDir, entry.name, "SKILL.md");
833
934
  if (existsSync8(candidate)) matches.add(candidate);
834
935
  }
835
936
  }
@@ -913,7 +1014,7 @@ function detectLicense(root, fm) {
913
1014
  if (fm.license) return fm.license;
914
1015
  const names = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "license"];
915
1016
  for (const name of names) {
916
- const p = join8(root, name);
1017
+ const p = join9(root, name);
917
1018
  if (existsSync8(p) && statSync4(p).isFile()) {
918
1019
  const head = readFileSync6(p, "utf-8").slice(0, 2e3).toLowerCase();
919
1020
  for (const [hint, label] of Object.entries(SPDX_HINTS)) {
@@ -927,7 +1028,7 @@ function detectLicense(root, fm) {
927
1028
  function walkFiles(dir) {
928
1029
  const out = [];
929
1030
  for (const entry of readdirSync3(dir, { withFileTypes: true })) {
930
- const full = join8(dir, entry.name);
1031
+ const full = join9(dir, entry.name);
931
1032
  if (entry.isDirectory()) {
932
1033
  out.push(...walkFiles(full));
933
1034
  } else if (entry.isFile()) {
@@ -939,7 +1040,7 @@ function walkFiles(dir) {
939
1040
  function countResources(root) {
940
1041
  const resources = {};
941
1042
  for (const d of RESOURCE_DIRS) {
942
- const dp = join8(root, d);
1043
+ const dp = join9(root, d);
943
1044
  if (isDir(dp)) {
944
1045
  resources[d] = walkFiles(dp).length;
945
1046
  }
@@ -950,7 +1051,7 @@ function scanMcpAndImports(text, root) {
950
1051
  const mcpRefRe = /mcp__[a-zA-Z0-9_]+/g;
951
1052
  const mcpRefs = new Set(text.match(mcpRefRe) ?? []);
952
1053
  const imports = /* @__PURE__ */ new Set();
953
- const scriptsDir = join8(root, "scripts");
1054
+ const scriptsDir = join9(root, "scripts");
954
1055
  if (isDir(scriptsDir)) {
955
1056
  for (const f of walkFiles(scriptsDir)) {
956
1057
  if (!f.endsWith(".py")) continue;
@@ -999,19 +1100,19 @@ function profileSkillDir(inputPath) {
999
1100
  }
1000
1101
  }
1001
1102
  function profilePlugin(p) {
1002
- const manifestPath = join8(p, ".claude-plugin", "plugin.json");
1103
+ const manifestPath = join9(p, ".claude-plugin", "plugin.json");
1003
1104
  let manifest;
1004
1105
  try {
1005
1106
  manifest = JSON.parse(readFileSync6(manifestPath, "utf-8"));
1006
1107
  } catch (err) {
1007
1108
  throw new Error(`could not parse plugin.json under ${p}: ${err.message}`);
1008
1109
  }
1009
- const skillsDir = join8(p, "skills");
1110
+ const skillsDir = join9(p, "skills");
1010
1111
  let skills = [];
1011
1112
  if (isDir(skillsDir)) {
1012
- skills = readdirSync3(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync8(join8(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
1113
+ skills = readdirSync3(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync8(join9(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
1013
1114
  }
1014
- const commandsDir = join8(p, "commands");
1115
+ const commandsDir = join9(p, "commands");
1015
1116
  const commands = isDir(commandsDir) ? readdirSync3(commandsDir).filter((f) => f.endsWith(".md")).length : 0;
1016
1117
  const mc = manifest.mcpServers ?? manifest.mcp_servers ?? {};
1017
1118
  const mcpServers = typeof mc === "object" && mc !== null ? Object.keys(mc).sort() : [];
@@ -1029,7 +1130,7 @@ function profilePlugin(p) {
1029
1130
  };
1030
1131
  }
1031
1132
  function profileMcp(p) {
1032
- const explicitConfig = existsSync8(join8(p, ".mcp.json")) ? join8(p, ".mcp.json") : existsSync8(join8(p, "mcp.json")) ? join8(p, "mcp.json") : void 0;
1133
+ const explicitConfig = existsSync8(join9(p, ".mcp.json")) ? join9(p, ".mcp.json") : existsSync8(join9(p, "mcp.json")) ? join9(p, "mcp.json") : void 0;
1033
1134
  if (explicitConfig) {
1034
1135
  let cfg;
1035
1136
  try {
@@ -1049,7 +1150,7 @@ function profileMcp(p) {
1049
1150
  capabilities: extractMcpCapabilities(p)
1050
1151
  };
1051
1152
  }
1052
- const pkgPath = join8(p, "package.json");
1153
+ const pkgPath = join9(p, "package.json");
1053
1154
  let pkg2;
1054
1155
  try {
1055
1156
  pkg2 = JSON.parse(readFileSync6(pkgPath, "utf-8"));
@@ -1071,7 +1172,7 @@ function profileMcp(p) {
1071
1172
  // src/gate/safety.ts
1072
1173
  import { statSync as statSync5 } from "fs";
1073
1174
  import { execFile } from "child_process";
1074
- import { delimiter, join as join9 } from "path";
1175
+ import { delimiter, join as join10 } from "path";
1075
1176
  import { promisify } from "util";
1076
1177
  var execFileAsync = promisify(execFile);
1077
1178
  var DENY_RULES = [
@@ -1170,7 +1271,7 @@ function which(cmd) {
1170
1271
  const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
1171
1272
  for (const dir of dirs) {
1172
1273
  for (const ext of exts) {
1173
- const candidate = join9(dir, cmd + ext);
1274
+ const candidate = join10(dir, cmd + ext);
1174
1275
  try {
1175
1276
  if (statSync5(candidate).isFile()) return candidate;
1176
1277
  } catch {
@@ -1211,7 +1312,7 @@ async function scanSafety(targetPath, strictness = DEFAULT_STRICTNESS) {
1211
1312
 
1212
1313
  // src/gate/overlap.ts
1213
1314
  import { existsSync as existsSync9, readFileSync as readFileSync7, readdirSync as readdirSync4 } from "fs";
1214
- import { basename as basename4, dirname as dirname3, join as join10, resolve as resolve3 } from "path";
1315
+ import { basename as basename4, dirname as dirname4, join as join11, resolve as resolve3 } from "path";
1215
1316
  var STOPWORDS = new Set(
1216
1317
  `
1217
1318
  a an the and or of to for in on with use used using when whether this that these those is are be
@@ -1219,6 +1320,7 @@ your you it its as at by from into via per also any all not no than then so if u
1219
1320
  skill skills user claude code based comprehensive enhanced practical including include includes
1220
1321
  `.split(/\s+/).filter(Boolean)
1221
1322
  );
1323
+ var DEFAULT_SET_THRESHOLD = 0.45;
1222
1324
  function splitLimited(text, sep2, maxSplit) {
1223
1325
  const parts = [];
1224
1326
  let rest = text;
@@ -1233,7 +1335,7 @@ function splitLimited(text, sep2, maxSplit) {
1233
1335
  }
1234
1336
  function readFrontmatter(skillMdPath) {
1235
1337
  const text = readFileSync7(skillMdPath, "utf-8");
1236
- const meta = { name: basename4(dirname3(skillMdPath)), description: "" };
1338
+ const meta = { name: basename4(dirname4(skillMdPath)), description: "" };
1237
1339
  if (text.startsWith("---\n")) {
1238
1340
  const block = splitLimited(text, "---", 2)[1] ?? "";
1239
1341
  const nameMatch = block.match(/^name:\s*(.+)$/m);
@@ -1334,21 +1436,21 @@ function listSubdirs(dir) {
1334
1436
  } catch {
1335
1437
  return [];
1336
1438
  }
1337
- return entries.filter((e) => e.isDirectory()).map((e) => join10(dir, e.name));
1439
+ return entries.filter((e) => e.isDirectory()).map((e) => join11(dir, e.name));
1338
1440
  }
1339
1441
  function findSkillMdFiles(root) {
1340
1442
  const found = /* @__PURE__ */ new Set();
1341
1443
  for (const dir of listSubdirs(root)) {
1342
- const candidate = join10(dir, "SKILL.md");
1444
+ const candidate = join11(dir, "SKILL.md");
1343
1445
  if (existsSync9(candidate)) found.add(resolve3(candidate));
1344
1446
  }
1345
- for (const dir of listSubdirs(join10(root, "skills"))) {
1346
- const candidate = join10(dir, "SKILL.md");
1447
+ for (const dir of listSubdirs(join11(root, "skills"))) {
1448
+ const candidate = join11(dir, "SKILL.md");
1347
1449
  if (existsSync9(candidate)) found.add(resolve3(candidate));
1348
1450
  }
1349
1451
  for (const topDir of listSubdirs(root)) {
1350
- for (const dir of listSubdirs(join10(topDir, "skills"))) {
1351
- const candidate = join10(dir, "SKILL.md");
1452
+ for (const dir of listSubdirs(join11(topDir, "skills"))) {
1453
+ const candidate = join11(dir, "SKILL.md");
1352
1454
  if (existsSync9(candidate)) found.add(resolve3(candidate));
1353
1455
  }
1354
1456
  }
@@ -1361,7 +1463,7 @@ function scanOverlap(candidatePath, skillsRoot) {
1361
1463
  }
1362
1464
  let cpath = resolve3(expandHome(candidatePath));
1363
1465
  if (isDir(cpath)) {
1364
- cpath = join10(cpath, "SKILL.md");
1466
+ cpath = join11(cpath, "SKILL.md");
1365
1467
  }
1366
1468
  if (!existsSync9(cpath)) {
1367
1469
  throw new Error(`candidate SKILL.md not found: ${cpath}`);
@@ -1385,6 +1487,26 @@ function scanOverlap(candidatePath, skillsRoot) {
1385
1487
  ranking
1386
1488
  };
1387
1489
  }
1490
+ function scanOverlapPaths(skillMdPaths, threshold = DEFAULT_SET_THRESHOLD) {
1491
+ const metas = skillMdPaths.map(readFrontmatter);
1492
+ const pairs = [];
1493
+ for (let i = 0; i < metas.length; i++) {
1494
+ for (let j = i + 1; j < metas.length; j++) {
1495
+ const sc = Math.max(score(metas[i], metas[j]), score(metas[j], metas[i]));
1496
+ if (sc >= threshold) {
1497
+ pairs.push({
1498
+ a: metas[i].name,
1499
+ b: metas[j].name,
1500
+ aPath: skillMdPaths[i],
1501
+ bPath: skillMdPaths[j],
1502
+ score: round3(sc)
1503
+ });
1504
+ }
1505
+ }
1506
+ }
1507
+ pairs.sort((a, b) => b.score - a.score);
1508
+ return pairs;
1509
+ }
1388
1510
 
1389
1511
  // src/gate/mcpSafety.ts
1390
1512
  import { statSync as statSync6 } from "fs";
@@ -1416,21 +1538,6 @@ function looksHighEntropy(value) {
1416
1538
  const uniqueChars = new Set(value).size;
1417
1539
  return varietyCount >= 2 && uniqueChars / value.length > 0.4;
1418
1540
  }
1419
- function isPinnedPackageSpec(spec) {
1420
- const withoutScope = spec.startsWith("@") ? spec.slice(spec.indexOf("/") + 1) : spec;
1421
- const atIdx = withoutScope.lastIndexOf("@");
1422
- if (atIdx <= 0) return false;
1423
- return /^\d/.test(withoutScope.slice(atIdx + 1));
1424
- }
1425
- function isNpxCommand(command) {
1426
- const base = command.split(/[\\/]/).pop() ?? command;
1427
- return base.replace(/\.(cmd|exe)$/i, "").toLowerCase() === "npx";
1428
- }
1429
- function packageSpecBase(spec) {
1430
- const scopeEnd = spec.startsWith("@") ? spec.indexOf("/") + 1 : 0;
1431
- const atIdx = spec.lastIndexOf("@");
1432
- return atIdx > scopeEnd ? spec.slice(0, atIdx) : spec;
1433
- }
1434
1541
  function collectServerNodes(node, out) {
1435
1542
  if (Array.isArray(node)) {
1436
1543
  for (const item of node) collectServerNodes(item, out);
@@ -1461,14 +1568,17 @@ function scanJsonConfig(file, parsed) {
1461
1568
  if (command && isNpxCommand(command)) {
1462
1569
  const flagIdx = args.findIndex((a) => a === "-y" || a === "--yes");
1463
1570
  if (flagIdx !== -1) {
1464
- const pkgSpec = args.slice(flagIdx + 1).find((a) => !a.startsWith("-"));
1465
- if (pkgSpec && !isPinnedPackageSpec(pkgSpec)) {
1466
- findings.push({
1467
- severity: "MEDIUM",
1468
- rule: "mcp-unpinned-npx",
1469
- file,
1470
- detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${packageSpecBase(pkgSpec)}@<version>) so the resolved server code can't change silently`
1471
- });
1571
+ const { packageFlagSpecs, positionalSpec } = collectNpxPackageSpecs(args);
1572
+ const allSpecs = positionalSpec ? [...packageFlagSpecs, positionalSpec] : packageFlagSpecs;
1573
+ for (const pkgSpec of allSpecs) {
1574
+ if (!isPinnedPackageSpec(pkgSpec)) {
1575
+ findings.push({
1576
+ severity: "MEDIUM",
1577
+ rule: "mcp-unpinned-npx",
1578
+ file,
1579
+ detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${packageSpecBase(pkgSpec)}@<version>) so the resolved server code can't change silently`
1580
+ });
1581
+ }
1472
1582
  }
1473
1583
  }
1474
1584
  }
@@ -1883,7 +1993,7 @@ function resolveMcpCandidatePath(entry) {
1883
1993
  children = readdirSync5(entry.quarantinePath, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1884
1994
  } catch {
1885
1995
  }
1886
- return children.length === 1 ? join11(entry.quarantinePath, children[0]) : entry.quarantinePath;
1996
+ return children.length === 1 ? join12(entry.quarantinePath, children[0]) : entry.quarantinePath;
1887
1997
  }
1888
1998
  async function runGate(candidatePath, skillsRoot, {
1889
1999
  proFeatureLabel = "overlap analysis",
@@ -1911,9 +2021,9 @@ async function runGate(candidatePath, skillsRoot, {
1911
2021
  // src/queue.ts
1912
2022
  import { randomUUID as randomUUID2 } from "crypto";
1913
2023
  import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
1914
- import { join as join12 } from "path";
2024
+ import { join as join13 } from "path";
1915
2025
  function getQueuePath() {
1916
- return join12(getHomeDir(), "queue.json");
2026
+ return join13(getHomeDir(), "queue.json");
1917
2027
  }
1918
2028
  function readQueue() {
1919
2029
  const path = getQueuePath();
@@ -1951,15 +2061,15 @@ function generateId(existingIds) {
1951
2061
  // src/handoff.ts
1952
2062
  import { spawnSync } from "child_process";
1953
2063
  import { existsSync as existsSync12 } from "fs";
1954
- import { dirname as dirname4, join as join13 } from "path";
2064
+ import { dirname as dirname5, join as join14 } from "path";
1955
2065
  import { fileURLToPath } from "url";
1956
2066
  var KNOWN_AGENT_BINARIES = ["claude", "codex", "cursor-agent", "windsurf", "opencode", "gemini"];
1957
2067
  var HANDOFF_INSTRUCTION = "Read {prompt} and follow its instructions for the skill installed at {path}";
1958
- function resolvePromptPath() {
1959
- const here = dirname4(fileURLToPath(import.meta.url));
1960
- const builtCandidate = join13(here, "ingest-prompt.md");
2068
+ function resolvePromptPath(assetName = "ingest-prompt.md") {
2069
+ const here = dirname5(fileURLToPath(import.meta.url));
2070
+ const builtCandidate = join14(here, assetName);
1961
2071
  if (existsSync12(builtCandidate)) return builtCandidate;
1962
- return join13(here, "..", "assets", "ingest-prompt.md");
2072
+ return join14(here, "..", "assets", assetName);
1963
2073
  }
1964
2074
  function defaultIsOnPath(bin) {
1965
2075
  const finder = process.platform === "win32" ? "where" : "which";
@@ -1971,7 +2081,7 @@ function substitute(template, vars) {
1971
2081
  }
1972
2082
  function buildHandoffCommand(config, installedPath, deps = {}) {
1973
2083
  const isOnPath = deps.isOnPath ?? defaultIsOnPath;
1974
- const promptPath = resolvePromptPath();
2084
+ const promptPath = resolvePromptPath(deps.promptAsset);
1975
2085
  const vars = { path: installedPath, prompt: promptPath };
1976
2086
  if (config.handoffCommand && config.handoffCommand.length > 0) {
1977
2087
  return { argv: substitute(config.handoffCommand, vars), source: "template" };
@@ -1983,8 +2093,8 @@ function buildHandoffCommand(config, installedPath, deps = {}) {
1983
2093
  }
1984
2094
  return { argv: [], source: "none" };
1985
2095
  }
1986
- function printOnlyMessage(installedPath) {
1987
- const promptPath = resolvePromptPath();
2096
+ function printOnlyMessage(installedPath, promptAsset) {
2097
+ const promptPath = resolvePromptPath(promptAsset);
1988
2098
  return `[skill-forge] no agent handoff available \u2014 run one yourself:
1989
2099
  Prompt: ${promptPath}
1990
2100
  Skill path: ${installedPath}`;
@@ -1992,18 +2102,19 @@ function printOnlyMessage(installedPath) {
1992
2102
  function runHandoff(config, installedPath, options = {}) {
1993
2103
  const command = buildHandoffCommand(config, installedPath, options);
1994
2104
  const spawn2 = options.spawn ?? spawnSync;
2105
+ const log = options.log ?? console.log;
1995
2106
  if (command.source === "none" || command.argv.length === 0) {
1996
- const printed = printOnlyMessage(installedPath);
1997
- console.log(printed);
2107
+ const printed = printOnlyMessage(installedPath, options.promptAsset);
2108
+ log(printed);
1998
2109
  return { ran: false, command, printed };
1999
2110
  }
2000
2111
  const [bin, ...args] = command.argv;
2001
2112
  const result = spawn2(bin, args, { stdio: options.stdio ?? "inherit" });
2002
2113
  const error = result.error;
2003
2114
  if (error?.code === "ENOENT") {
2004
- const printed = printOnlyMessage(installedPath);
2005
- console.log(`[skill-forge] ${bin} not found on PATH \u2014 run this yourself:`);
2006
- console.log(printed);
2115
+ const printed = printOnlyMessage(installedPath, options.promptAsset);
2116
+ log(`[skill-forge] ${bin} not found on PATH \u2014 run this yourself:`);
2117
+ log(printed);
2007
2118
  return { ran: false, command, printed, error: error.message };
2008
2119
  }
2009
2120
  if (error) {
@@ -2014,14 +2125,14 @@ function runHandoff(config, installedPath, options = {}) {
2014
2125
  }
2015
2126
 
2016
2127
  // src/commands/init.ts
2017
- import { existsSync as existsSync13 } from "fs";
2128
+ import { existsSync as existsSync15 } from "fs";
2018
2129
  import { homedir as homedir4 } from "os";
2019
- import { createInterface } from "readline/promises";
2130
+ import { createInterface as createInterface3 } from "readline/promises";
2020
2131
 
2021
2132
  // src/agents.ts
2022
2133
  import { statSync as statSync7 } from "fs";
2023
2134
  import { homedir as homedir3 } from "os";
2024
- import { join as join14, resolve as resolve5 } from "path";
2135
+ import { join as join15, resolve as resolve5 } from "path";
2025
2136
  function isFile(p) {
2026
2137
  try {
2027
2138
  return statSync7(p).isFile();
@@ -2035,7 +2146,7 @@ function projectPath(root, cwd) {
2035
2146
  function globalPath(root, home) {
2036
2147
  if (!root) return void 0;
2037
2148
  if (root === "~") return home;
2038
- if (root.startsWith("~/")) return join14(home, root.slice(2));
2149
+ if (root.startsWith("~/")) return join15(home, root.slice(2));
2039
2150
  return root;
2040
2151
  }
2041
2152
  function makeAgent(spec) {
@@ -2255,6 +2366,845 @@ var AGENTS = [
2255
2366
  { id: "adal", displayName: "AdaL", projectRoot: ".adal/skills", globalRoot: "~/.adal/skills" }
2256
2367
  ].map(makeAgent);
2257
2368
 
2369
+ // src/commands/audit.ts
2370
+ import { existsSync as existsSync14, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "fs";
2371
+ import { dirname as dirname6, join as join17, resolve as resolvePath3 } from "path";
2372
+ import { createInterface as createInterface2 } from "readline/promises";
2373
+
2374
+ // src/audit.ts
2375
+ import {
2376
+ existsSync as existsSync13,
2377
+ lstatSync,
2378
+ mkdtempSync as mkdtempSync2,
2379
+ readFileSync as readFileSync10,
2380
+ readdirSync as readdirSync6,
2381
+ realpathSync,
2382
+ renameSync as renameSync2,
2383
+ rmSync as rmSync3,
2384
+ statSync as statSync8,
2385
+ writeFileSync as writeFileSync5
2386
+ } from "fs";
2387
+ import { basename as basename6, extname as extname5, join as join16, resolve as resolvePath2 } from "path";
2388
+ var OVERSIZED_SKILL_EST_TOKENS = 5e3;
2389
+ function toAuditFindings(source) {
2390
+ return source.map((f) => ({ severity: f.severity, rule: f.rule, target: f.file, detail: f.detail }));
2391
+ }
2392
+ function resolveConfiguredRoot(root) {
2393
+ return resolvePath2(expandHome(root));
2394
+ }
2395
+ function safeStat(p) {
2396
+ try {
2397
+ return statSync8(p);
2398
+ } catch {
2399
+ return void 0;
2400
+ }
2401
+ }
2402
+ function isDirFollowingSymlink(p) {
2403
+ return safeStat(p)?.isDirectory() ?? false;
2404
+ }
2405
+ function listSubdirsFollowingSymlinks(dir) {
2406
+ let entries;
2407
+ try {
2408
+ entries = readdirSync6(dir, { withFileTypes: true });
2409
+ } catch {
2410
+ return [];
2411
+ }
2412
+ const out = [];
2413
+ for (const entry of entries) {
2414
+ const full = join16(dir, entry.name);
2415
+ if (entry.isDirectory() || entry.isSymbolicLink() && isDirFollowingSymlink(full)) {
2416
+ out.push(full);
2417
+ }
2418
+ }
2419
+ return out;
2420
+ }
2421
+ function listSubdirsNoSymlinks(dir) {
2422
+ let entries;
2423
+ try {
2424
+ entries = readdirSync6(dir, { withFileTypes: true });
2425
+ } catch {
2426
+ return [];
2427
+ }
2428
+ return entries.filter((e) => e.isDirectory()).map((e) => join16(dir, e.name));
2429
+ }
2430
+ function findSkillDirsUnderRoot(root) {
2431
+ const found = /* @__PURE__ */ new Set();
2432
+ const rootSubdirs = listSubdirsFollowingSymlinks(root);
2433
+ for (const dir of rootSubdirs) {
2434
+ if (existsSync13(join16(dir, "SKILL.md"))) found.add(dir);
2435
+ }
2436
+ for (const dir of listSubdirsFollowingSymlinks(join16(root, "skills"))) {
2437
+ if (existsSync13(join16(dir, "SKILL.md"))) found.add(dir);
2438
+ }
2439
+ for (const topDir of rootSubdirs) {
2440
+ for (const dir of listSubdirsNoSymlinks(join16(topDir, "skills"))) {
2441
+ if (existsSync13(join16(dir, "SKILL.md"))) found.add(dir);
2442
+ }
2443
+ }
2444
+ return Array.from(found).filter((p) => !p.includes("/.git/"));
2445
+ }
2446
+ function collectSkillCandidates(roots) {
2447
+ const candidates = [];
2448
+ const rootMeta = /* @__PURE__ */ new Map();
2449
+ for (const root of roots) {
2450
+ const resolvedRoot = resolveConfiguredRoot(root);
2451
+ const exists = isDir(resolvedRoot);
2452
+ let realPath;
2453
+ if (exists) {
2454
+ try {
2455
+ realPath = realpathSync(resolvedRoot);
2456
+ } catch {
2457
+ realPath = void 0;
2458
+ }
2459
+ }
2460
+ rootMeta.set(root, { exists, realPath });
2461
+ if (!exists) continue;
2462
+ for (const dir of findSkillDirsUnderRoot(resolvedRoot)) {
2463
+ candidates.push({ configuredPath: dir, root });
2464
+ }
2465
+ }
2466
+ return { candidates, rootMeta };
2467
+ }
2468
+ function dedupeSkillCandidates(candidates, findings) {
2469
+ const byRealPath = /* @__PURE__ */ new Map();
2470
+ const order = [];
2471
+ for (const candidate of candidates) {
2472
+ let realPath;
2473
+ try {
2474
+ realPath = realpathSync(candidate.configuredPath);
2475
+ } catch (err) {
2476
+ findings.push({
2477
+ severity: "MEDIUM",
2478
+ rule: "unreadable-skill",
2479
+ target: candidate.configuredPath,
2480
+ detail: `could not resolve skill directory (broken symlink or cycle): ${err.message}`
2481
+ });
2482
+ continue;
2483
+ }
2484
+ const existing = byRealPath.get(realPath);
2485
+ if (existing) {
2486
+ if (existing.canonicalPath !== candidate.configuredPath && !existing.aliasPaths.includes(candidate.configuredPath)) {
2487
+ existing.aliasPaths.push(candidate.configuredPath);
2488
+ }
2489
+ continue;
2490
+ }
2491
+ byRealPath.set(realPath, { realPath, canonicalPath: candidate.configuredPath, root: candidate.root, aliasPaths: [] });
2492
+ order.push(realPath);
2493
+ }
2494
+ return order.map((realPath) => byRealPath.get(realPath));
2495
+ }
2496
+ function parseStrictFrontmatter(text) {
2497
+ if (!text.startsWith("---\n")) {
2498
+ return { valid: false, errors: ['missing frontmatter fence (SKILL.md must start with "---")'] };
2499
+ }
2500
+ const afterOpen = text.slice(4);
2501
+ const closeIdx = afterOpen.indexOf("\n---");
2502
+ if (closeIdx === -1) {
2503
+ return { valid: false, errors: ['unclosed frontmatter fence (no closing "---" found)'] };
2504
+ }
2505
+ const block = afterOpen.slice(0, closeIdx);
2506
+ const errors = [];
2507
+ const nameMatch = block.match(/^name:\s*(.+)$/m);
2508
+ const name = nameMatch ? stripQuotes(nameMatch[1].trim()) : void 0;
2509
+ if (!name) errors.push('missing required "name" field in frontmatter');
2510
+ const descMatch = block.match(/^description:\s*(.*)$/m);
2511
+ let description;
2512
+ if (descMatch) {
2513
+ const first = descMatch[1].trim();
2514
+ if ([">", ">-", "|", "|-", ""].includes(first)) {
2515
+ const lines = block.split(/\r\n|\r|\n/);
2516
+ const idx = lines.findIndex((l) => /^description:/.test(l));
2517
+ const buf = [];
2518
+ if (idx !== -1) {
2519
+ for (const l of lines.slice(idx + 1)) {
2520
+ if (/^\s+\S/.test(l)) buf.push(l.trim());
2521
+ else if (l.trim() === "") continue;
2522
+ else break;
2523
+ }
2524
+ }
2525
+ description = buf.join(" ").trim() || void 0;
2526
+ } else {
2527
+ description = stripQuotes(first);
2528
+ }
2529
+ }
2530
+ if (!description) errors.push('missing required "description" field in frontmatter');
2531
+ return { valid: errors.length === 0, name, description, errors };
2532
+ }
2533
+ function estimateTokens(text) {
2534
+ return Math.ceil(Buffer.byteLength(text, "utf-8") / 4);
2535
+ }
2536
+ function profileAuditedSkill(group, findings, unreadableSkillMdPaths) {
2537
+ const skillMdPath = join16(group.canonicalPath, "SKILL.md");
2538
+ const base = {
2539
+ path: group.canonicalPath,
2540
+ skillMdPath,
2541
+ root: group.root,
2542
+ aliasPaths: group.aliasPaths
2543
+ };
2544
+ let text;
2545
+ let sizeBytes;
2546
+ try {
2547
+ sizeBytes = statSync8(skillMdPath).size;
2548
+ text = readFileSync10(skillMdPath, "utf-8");
2549
+ } catch (err) {
2550
+ findings.push({
2551
+ severity: "MEDIUM",
2552
+ rule: "unreadable-skill",
2553
+ target: skillMdPath,
2554
+ detail: `could not read SKILL.md: ${err.message}`
2555
+ });
2556
+ unreadableSkillMdPaths.add(skillMdPath);
2557
+ return { ...base, name: basename6(group.canonicalPath), validFrontmatter: false, sizeBytes: 0, estTokens: 0 };
2558
+ }
2559
+ const fm = parseStrictFrontmatter(text);
2560
+ if (!fm.valid) {
2561
+ for (const error of fm.errors) {
2562
+ findings.push({ severity: "MEDIUM", rule: "malformed-frontmatter", target: skillMdPath, detail: error });
2563
+ }
2564
+ }
2565
+ return {
2566
+ ...base,
2567
+ name: fm.name ?? basename6(group.canonicalPath),
2568
+ validFrontmatter: fm.valid,
2569
+ description: fm.description,
2570
+ sizeBytes,
2571
+ estTokens: estimateTokens(text)
2572
+ };
2573
+ }
2574
+ function hasRecognizedMcpShape(parsed) {
2575
+ if (typeof parsed !== "object" || parsed === null) return false;
2576
+ const obj = parsed;
2577
+ const serversRaw = obj.mcpServers ?? obj.servers;
2578
+ return typeof serversRaw === "object" && serversRaw !== null;
2579
+ }
2580
+ function enumerateMcpServers(parsed) {
2581
+ if (!hasRecognizedMcpShape(parsed)) return [];
2582
+ const obj = parsed;
2583
+ const serversRaw = obj.mcpServers ?? obj.servers;
2584
+ const out = [];
2585
+ for (const [name, raw] of Object.entries(serversRaw)) {
2586
+ if (typeof raw !== "object" || raw === null) continue;
2587
+ const entry = raw;
2588
+ const command = typeof entry.command === "string" ? entry.command : void 0;
2589
+ const args = Array.isArray(entry.args) ? entry.args.filter((a) => typeof a === "string") : [];
2590
+ const env = entry.env && typeof entry.env === "object" && !Array.isArray(entry.env) ? entry.env : {};
2591
+ out.push({
2592
+ name,
2593
+ commandBasename: command ? basename6(command) : void 0,
2594
+ packageSpec: command && isNpxCommand(command) ? collectNpxPackageSpecs(args).positionalSpec : void 0,
2595
+ argCount: args.length,
2596
+ envCount: Object.keys(env).length
2597
+ });
2598
+ }
2599
+ return out;
2600
+ }
2601
+ function auditMcpTarget(targetPath, notices) {
2602
+ const findings = [];
2603
+ const isToml = extname5(targetPath).toLowerCase() === ".toml";
2604
+ const format = isToml ? "toml" : "json";
2605
+ if (!existsSync13(targetPath)) {
2606
+ findings.push({
2607
+ severity: "LOW",
2608
+ rule: "missing-mcp-target",
2609
+ target: targetPath,
2610
+ detail: "configured MCP target file does not exist"
2611
+ });
2612
+ return { target: { path: targetPath, format, status: "missing", servers: [] }, findings };
2613
+ }
2614
+ try {
2615
+ findings.push(...toAuditFindings(scanMcpFindings(targetPath)));
2616
+ } catch (err) {
2617
+ findings.push({
2618
+ severity: "MEDIUM",
2619
+ rule: "unreadable-mcp-target",
2620
+ target: targetPath,
2621
+ detail: `MCP safety scan failed: ${err.message}`
2622
+ });
2623
+ }
2624
+ if (isToml) {
2625
+ try {
2626
+ readFileSync10(targetPath, "utf-8");
2627
+ } catch (err) {
2628
+ findings.push({
2629
+ severity: "MEDIUM",
2630
+ rule: "unreadable-mcp-target",
2631
+ target: targetPath,
2632
+ detail: `could not read: ${err.message}`
2633
+ });
2634
+ return { target: { path: targetPath, format, status: "unreadable", servers: [] }, findings };
2635
+ }
2636
+ notices.push(`MCP target ${targetPath}: TOML format \u2014 structured server enumeration unsupported (textual safety scan only)`);
2637
+ return { target: { path: targetPath, format, status: "clean", servers: [] }, findings };
2638
+ }
2639
+ let raw;
2640
+ try {
2641
+ raw = readFileSync10(targetPath, "utf-8");
2642
+ } catch (err) {
2643
+ findings.push({
2644
+ severity: "MEDIUM",
2645
+ rule: "unreadable-mcp-target",
2646
+ target: targetPath,
2647
+ detail: `could not read: ${err.message}`
2648
+ });
2649
+ return { target: { path: targetPath, format, status: "unreadable", servers: [] }, findings };
2650
+ }
2651
+ let parsed;
2652
+ try {
2653
+ parsed = JSON.parse(raw);
2654
+ } catch (err) {
2655
+ findings.push({
2656
+ severity: "MEDIUM",
2657
+ rule: "malformed-mcp-target",
2658
+ target: targetPath,
2659
+ detail: `invalid JSON: ${err.message}`
2660
+ });
2661
+ return { target: { path: targetPath, format, status: "malformed", servers: [] }, findings };
2662
+ }
2663
+ if (!hasRecognizedMcpShape(parsed)) {
2664
+ findings.push({
2665
+ severity: "LOW",
2666
+ rule: "unrecognized-mcp-target-shape",
2667
+ target: targetPath,
2668
+ detail: 'valid JSON but no "mcpServers" or "servers" object was found \u2014 reporting 0 servers'
2669
+ });
2670
+ }
2671
+ return { target: { path: targetPath, format, status: "clean", servers: enumerateMcpServers(parsed) }, findings };
2672
+ }
2673
+ function clusterOverlapPairs(pairs) {
2674
+ const parent = /* @__PURE__ */ new Map();
2675
+ function find(x) {
2676
+ if (!parent.has(x)) parent.set(x, x);
2677
+ let root = x;
2678
+ while (parent.get(root) !== root) root = parent.get(root);
2679
+ let cur = x;
2680
+ while (parent.get(cur) !== root) {
2681
+ const next = parent.get(cur);
2682
+ parent.set(cur, root);
2683
+ cur = next;
2684
+ }
2685
+ return root;
2686
+ }
2687
+ function union(a, b) {
2688
+ const ra = find(a);
2689
+ const rb = find(b);
2690
+ if (ra !== rb) parent.set(ra, rb);
2691
+ }
2692
+ for (const pair of pairs) union(pair.aPath, pair.bPath);
2693
+ const groups = /* @__PURE__ */ new Map();
2694
+ for (const pair of pairs) {
2695
+ const root = find(pair.aPath);
2696
+ const group = groups.get(root) ?? { members: /* @__PURE__ */ new Set(), topScore: 0 };
2697
+ group.members.add(pair.aPath);
2698
+ group.members.add(pair.bPath);
2699
+ group.topScore = Math.max(group.topScore, pair.score);
2700
+ groups.set(root, group);
2701
+ }
2702
+ return Array.from(groups.values()).map((g) => ({ members: toClusterMembers(g.members), topScore: g.topScore, suggestedVerb: suggestVerbCode(g.topScore) })).sort((a, b) => b.topScore - a.topScore || a.members[0].localeCompare(b.members[0]));
2703
+ }
2704
+ function toClusterMembers(members) {
2705
+ const sorted = Array.from(members).sort();
2706
+ if (sorted.length < 2) {
2707
+ throw new Error(`internal error: overlap cluster has fewer than 2 members (${sorted.length})`);
2708
+ }
2709
+ return sorted;
2710
+ }
2711
+ async function auditConfiguredSet(config, options = {}) {
2712
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
2713
+ const oversizedThreshold = options.oversizedSkillTokens ?? OVERSIZED_SKILL_EST_TOKENS;
2714
+ const findings = [];
2715
+ const notices = [];
2716
+ const { candidates, rootMeta } = collectSkillCandidates(config.skillsRoots);
2717
+ const groups = dedupeSkillCandidates(candidates, findings);
2718
+ const unreadableSkillMdPaths = /* @__PURE__ */ new Set();
2719
+ const skills = groups.map((g) => profileAuditedSkill(g, findings, unreadableSkillMdPaths));
2720
+ const skillRoots = config.skillsRoots.map((root) => {
2721
+ const meta = rootMeta.get(root) ?? { exists: false };
2722
+ if (!meta.exists) {
2723
+ findings.push({ severity: "MEDIUM", rule: "missing-skills-root", target: root, detail: "configured skills root does not exist on disk" });
2724
+ } else if (!meta.realPath) {
2725
+ findings.push({
2726
+ severity: "MEDIUM",
2727
+ rule: "unreadable-skills-root",
2728
+ target: root,
2729
+ detail: "could not resolve skills root to a real path (broken symlink or cycle)"
2730
+ });
2731
+ }
2732
+ return {
2733
+ root,
2734
+ realPath: meta.realPath ?? resolveConfiguredRoot(root),
2735
+ exists: meta.exists,
2736
+ skillCount: skills.filter((s) => s.root === root).length
2737
+ };
2738
+ });
2739
+ const byName = /* @__PURE__ */ new Map();
2740
+ for (const skill of skills) {
2741
+ const arr = byName.get(skill.name) ?? [];
2742
+ arr.push(skill);
2743
+ byName.set(skill.name, arr);
2744
+ }
2745
+ for (const [name, group] of byName) {
2746
+ if (group.length > 1) {
2747
+ for (const skill of group) {
2748
+ const otherPaths = group.filter((g) => g.path !== skill.path).map((g) => g.path);
2749
+ findings.push({
2750
+ severity: "MEDIUM",
2751
+ rule: "duplicate-skill-name",
2752
+ target: skill.path,
2753
+ detail: `name "${name}" is duplicated \u2014 also found at ${otherPaths.join(", ")}`
2754
+ });
2755
+ }
2756
+ }
2757
+ }
2758
+ for (const skill of skills) {
2759
+ if (skill.estTokens > oversizedThreshold) {
2760
+ findings.push({
2761
+ severity: "LOW",
2762
+ rule: "oversized-skill",
2763
+ target: skill.skillMdPath,
2764
+ detail: `est. ${skill.estTokens} tokens exceeds the ${oversizedThreshold}-token threshold \u2014 consider trimming or splitting`
2765
+ });
2766
+ }
2767
+ }
2768
+ const safetyVerdictBySkillPath = /* @__PURE__ */ new Map();
2769
+ for (const skill of skills) {
2770
+ try {
2771
+ const result = await scanSafety(skill.path, config.strictness);
2772
+ findings.push(...toAuditFindings(result.findings));
2773
+ safetyVerdictBySkillPath.set(skill.path, result.verdict);
2774
+ } catch (err) {
2775
+ findings.push({
2776
+ severity: "MEDIUM",
2777
+ rule: "unreadable-skill",
2778
+ target: skill.path,
2779
+ detail: `safety scan failed: ${err.message}`
2780
+ });
2781
+ safetyVerdictBySkillPath.set(skill.path, "block");
2782
+ }
2783
+ }
2784
+ const mcpTargetPaths = config.mcpTargets ?? [];
2785
+ const mcpTargets = [];
2786
+ for (const targetPath of mcpTargetPaths) {
2787
+ const { target, findings: targetFindings } = auditMcpTarget(targetPath, notices);
2788
+ mcpTargets.push(target);
2789
+ findings.push(...targetFindings);
2790
+ }
2791
+ const overlapStatus = proFeatureStatus("installed-set overlap analysis");
2792
+ let overlapClusters = [];
2793
+ if (overlapStatus.enabled) {
2794
+ if (overlapStatus.notice) notices.push(overlapStatus.notice);
2795
+ try {
2796
+ const readableSkillMdPaths = skills.map((s) => s.skillMdPath).filter((p) => !unreadableSkillMdPaths.has(p));
2797
+ const pairs = scanOverlapPaths(readableSkillMdPaths, options.overlapThreshold);
2798
+ overlapClusters = clusterOverlapPairs(pairs);
2799
+ } catch (err) {
2800
+ findings.push({
2801
+ severity: "MEDIUM",
2802
+ rule: "overlap-scan-failed",
2803
+ target: "installed-set overlap analysis",
2804
+ detail: `cross-root overlap scan failed: ${err.message}`
2805
+ });
2806
+ notices.push(`cross-root overlap scan failed \u2014 overlap clusters unavailable this run: ${err.message}`);
2807
+ overlapClusters = [];
2808
+ }
2809
+ } else {
2810
+ notices.push(overlapStatus.notice ?? "Pro feature (installed-set overlap analysis) locked.");
2811
+ }
2812
+ const evolveEligible = skills.filter((s) => s.validFrontmatter && safetyVerdictBySkillPath.get(s.path) === "pass").map((s) => ({ name: s.name, path: s.path }));
2813
+ const foundation = config.foundationSkillPath ? {
2814
+ available: false,
2815
+ existingPath: config.foundationSkillPath,
2816
+ message: `a business-foundation skill already exists at ${config.foundationSkillPath}`
2817
+ } : {
2818
+ available: true,
2819
+ message: "no business-foundation skill yet \u2014 `skill-forge audit --foundation` can scaffold one to ground future skills in your business profile"
2820
+ };
2821
+ const findingCounts = { LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0 };
2822
+ for (const f of findings) findingCounts[f.severity]++;
2823
+ const summary = {
2824
+ rootCount: skillRoots.length,
2825
+ skillCount: skills.length,
2826
+ mcpTargetCount: mcpTargets.length,
2827
+ findingCounts,
2828
+ overlapClusterCount: overlapClusters.length,
2829
+ evolveEligibleCount: evolveEligible.length
2830
+ };
2831
+ return {
2832
+ schemaVersion: 1,
2833
+ generatedAt: now().toISOString(),
2834
+ inventory: { skillRoots, skills, mcpTargets },
2835
+ hygiene: findings,
2836
+ opportunities: { overlapClusters, evolveEligible, foundation, overlapLocked: !overlapStatus.enabled },
2837
+ notices,
2838
+ summary
2839
+ };
2840
+ }
2841
+ function bulletList(items, emptyPlaceholder) {
2842
+ return items.length > 0 ? items.map((i) => `- ${i}`) : [`- ${emptyPlaceholder}`];
2843
+ }
2844
+ function sanitizeForMarkdown(s) {
2845
+ return s.replace(/[\x00-\x1F\x7F-\x9F]/g, "");
2846
+ }
2847
+ function joinDefined(parts) {
2848
+ return parts.filter((v) => Boolean(v)).join(", ");
2849
+ }
2850
+ function renderRootsSection(roots) {
2851
+ return [
2852
+ "### Skill roots",
2853
+ "",
2854
+ ...bulletList(
2855
+ roots.map((r) => `${r.root}${r.exists ? "" : " (MISSING)"} \u2014 ${r.skillCount} skill${r.skillCount === 1 ? "" : "s"}`),
2856
+ "(none configured)"
2857
+ )
2858
+ ];
2859
+ }
2860
+ function renderSkillsSection(skills) {
2861
+ return [
2862
+ "### Skills",
2863
+ "",
2864
+ ...bulletList(
2865
+ skills.map((s) => {
2866
+ const flags = joinDefined([
2867
+ s.validFrontmatter ? void 0 : "INVALID FRONTMATTER",
2868
+ s.aliasPaths.length > 0 ? `${s.aliasPaths.length} alias path(s)` : void 0
2869
+ ]);
2870
+ return `${sanitizeForMarkdown(s.name)} \u2014 ${s.path} (est. ${s.estTokens} tokens)${flags ? ` [${flags}]` : ""}`;
2871
+ }),
2872
+ "(none found)"
2873
+ )
2874
+ ];
2875
+ }
2876
+ function renderMcpTargetsSection(targets) {
2877
+ const lines = ["### MCP targets", ""];
2878
+ if (targets.length === 0) {
2879
+ lines.push("- (none configured)");
2880
+ return lines;
2881
+ }
2882
+ for (const t of targets) {
2883
+ lines.push(`- ${t.path} \u2014 ${t.format}, ${t.status}`);
2884
+ for (const server of t.servers) {
2885
+ const detail = joinDefined([
2886
+ server.commandBasename ? sanitizeForMarkdown(server.commandBasename) : void 0,
2887
+ server.packageSpec ? sanitizeForMarkdown(server.packageSpec) : void 0,
2888
+ `${server.argCount} arg${server.argCount === 1 ? "" : "s"}`,
2889
+ `${server.envCount} env var${server.envCount === 1 ? "" : "s"}`
2890
+ ]);
2891
+ lines.push(` - ${sanitizeForMarkdown(server.name)} (${detail})`);
2892
+ }
2893
+ }
2894
+ return lines;
2895
+ }
2896
+ function renderFindingsSection(findings) {
2897
+ return [
2898
+ "## Hygiene findings",
2899
+ "",
2900
+ ...bulletList(
2901
+ findings.map((f) => `[${f.severity}] ${f.rule} \u2014 ${sanitizeForMarkdown(f.target)}: ${sanitizeForMarkdown(f.detail)}`),
2902
+ "(none \u2014 clean)"
2903
+ )
2904
+ ];
2905
+ }
2906
+ function renderOpportunitiesSection(report) {
2907
+ const { overlapClusters, evolveEligible, foundation } = report.opportunities;
2908
+ return [
2909
+ "## Opportunities",
2910
+ "",
2911
+ "### Overlap clusters",
2912
+ "",
2913
+ ...bulletList(
2914
+ overlapClusters.map((c) => `top score ${c.topScore} \u2014 ${c.suggestedVerb} \u2014 ${c.members.join(", ")}`),
2915
+ "(none \u2014 either no cross-root overlap found, or the Pro feature is locked, see Notices)"
2916
+ ),
2917
+ "",
2918
+ "### Evolve-eligible",
2919
+ "",
2920
+ ...bulletList(
2921
+ evolveEligible.map((c) => `${sanitizeForMarkdown(c.name)} \u2014 ${c.path}`),
2922
+ "(none)"
2923
+ ),
2924
+ "",
2925
+ "### Foundation skill",
2926
+ "",
2927
+ `- ${foundation.message}`
2928
+ ];
2929
+ }
2930
+ function renderAuditMarkdown(report) {
2931
+ const { summary } = report;
2932
+ const lines = [
2933
+ `# skill-forge audit \u2014 ${report.generatedAt}`,
2934
+ "",
2935
+ "## Summary",
2936
+ "",
2937
+ `- Skill roots: ${summary.rootCount}`,
2938
+ `- Skills: ${summary.skillCount}`,
2939
+ `- MCP targets: ${summary.mcpTargetCount}`,
2940
+ `- Findings: ${summary.findingCounts.CRITICAL} CRITICAL, ${summary.findingCounts.HIGH} HIGH, ${summary.findingCounts.MEDIUM} MEDIUM, ${summary.findingCounts.LOW} LOW`,
2941
+ `- Overlap clusters: ${summary.overlapClusterCount}`,
2942
+ `- Evolve-eligible skills: ${summary.evolveEligibleCount}`,
2943
+ "",
2944
+ "## Inventory",
2945
+ "",
2946
+ ...renderRootsSection(report.inventory.skillRoots),
2947
+ "",
2948
+ ...renderSkillsSection(report.inventory.skills),
2949
+ "",
2950
+ ...renderMcpTargetsSection(report.inventory.mcpTargets),
2951
+ "",
2952
+ ...renderFindingsSection(report.hygiene),
2953
+ "",
2954
+ ...renderOpportunitiesSection(report),
2955
+ "",
2956
+ "## Notices",
2957
+ "",
2958
+ ...bulletList(report.notices, "(none)")
2959
+ ];
2960
+ return lines.join("\n") + "\n";
2961
+ }
2962
+ function renderFoundationSkillMd(profile) {
2963
+ const description = profile.businessName ? `Business-foundation context for ${profile.businessName} \u2014 grounds new and existing skills in its audiences, workflows, and constraints. Generated by skill-forge audit.` : "Business-foundation context for this business \u2014 grounds new and existing skills in its audiences, workflows, and constraints. Generated by skill-forge audit.";
2964
+ const lines = [
2965
+ "---",
2966
+ "name: business-foundation",
2967
+ `description: ${description}`,
2968
+ "---",
2969
+ "",
2970
+ `# Business Foundation${profile.businessName ? `: ${profile.businessName}` : ""}`,
2971
+ "",
2972
+ profile.industry ? `**Industry:** ${profile.industry}` : void 0,
2973
+ "",
2974
+ "## Audiences",
2975
+ "",
2976
+ ...bulletList(profile.audiences, "(none captured yet)"),
2977
+ "",
2978
+ "## Workflows",
2979
+ "",
2980
+ ...bulletList(profile.workflows, "(none captured yet)"),
2981
+ "",
2982
+ "## Constraints",
2983
+ "",
2984
+ ...bulletList(profile.constraints, "(none captured yet)"),
2985
+ "",
2986
+ "## Integration guidance",
2987
+ "",
2988
+ "New and existing skills in this set should align with the business context above:",
2989
+ "- Match the audiences, terminology, and tone described here.",
2990
+ "- Respect the workflows and constraints listed above when proposing changes.",
2991
+ "- When curating or evolving skills (`skill-forge evolve`, `skill-forge add`), treat this file as ground truth for what the business actually needs \u2014 update it by re-running `skill-forge audit` rather than editing it out of band, so it stays a single source of truth."
2992
+ ];
2993
+ return lines.filter((l) => l !== void 0).join("\n") + "\n";
2994
+ }
2995
+ async function scaffoldFoundationSkill(config, profile, targetRoot) {
2996
+ const resolvedRoot = resolveConfiguredRoot(targetRoot);
2997
+ const configuredRoots = config.skillsRoots.map(resolveConfiguredRoot);
2998
+ if (!configuredRoots.includes(resolvedRoot)) {
2999
+ throw new UserError(`refusing to scaffold a foundation skill under ${resolvedRoot}: not one of the configured skillsRoots`);
3000
+ }
3001
+ if (!isDir(resolvedRoot)) {
3002
+ throw new UserError(`skills root does not exist: ${resolvedRoot}`);
3003
+ }
3004
+ const destDir = join16(resolvedRoot, "business-foundation");
3005
+ let destLstat;
3006
+ try {
3007
+ destLstat = lstatSync(destDir);
3008
+ } catch {
3009
+ destLstat = void 0;
3010
+ }
3011
+ if (destLstat?.isSymbolicLink()) {
3012
+ throw new UserError(`refusing to scaffold a foundation skill at ${destDir}: destination is a symlink`);
3013
+ }
3014
+ if (destLstat) {
3015
+ throw new UserError(`refusing to scaffold a foundation skill at ${destDir}: already exists`);
3016
+ }
3017
+ const content = renderFoundationSkillMd(profile);
3018
+ const tempDir = mkdtempSync2(join16(resolvedRoot, ".business-foundation-"));
3019
+ try {
3020
+ writeFileSync5(join16(tempDir, "SKILL.md"), content, { flag: "wx", mode: 384 });
3021
+ const safety = await scanSafety(tempDir, config.strictness);
3022
+ if (safety.verdict === "block") {
3023
+ throw new UserError(
3024
+ `refusing to scaffold a foundation skill: the generated SKILL.md was flagged by the safety scan (${safety.findings.map(formatSafetyFinding).join("; ")})`
3025
+ );
3026
+ }
3027
+ renameSync2(tempDir, destDir);
3028
+ } catch (err) {
3029
+ rmSync3(tempDir, { recursive: true, force: true });
3030
+ throw err;
3031
+ }
3032
+ try {
3033
+ appendProvenance(
3034
+ {
3035
+ name: "business-foundation",
3036
+ source: "skill-forge audit (business profile)",
3037
+ verb: "DEFER",
3038
+ target: destDir,
3039
+ took: "scaffolded a new business-foundation skill from the captured business profile",
3040
+ notes: "scaffolded by skill-forge audit"
3041
+ },
3042
+ resolvedRoot
3043
+ );
3044
+ } catch (err) {
3045
+ throw new UserError(
3046
+ `the business-foundation skill was created at ${destDir}, but recording the provenance entry failed: ${err.message}`
3047
+ );
3048
+ }
3049
+ return destDir;
3050
+ }
3051
+
3052
+ // src/decision.ts
3053
+ import { createInterface } from "readline/promises";
3054
+ function logOutcome(message, json) {
3055
+ if (json) console.error(message);
3056
+ else console.log(message);
3057
+ }
3058
+ function yesDecision(verdict) {
3059
+ return verdict === "block" ? "reject" : "promote";
3060
+ }
3061
+ async function promptDecision(verdict, subject) {
3062
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3063
+ const defaultChoice = verdict === "block" ? "reject" : verdict === "warn" ? "hold" : "promote";
3064
+ const prompt = `Promote, hold, or reject ${subject}? [promote/hold/reject] (default: ${defaultChoice}) `;
3065
+ try {
3066
+ process.stdout.write(prompt);
3067
+ for await (const raw of rl) {
3068
+ const answer = raw.trim().toLowerCase() || defaultChoice;
3069
+ if (answer === "p" || answer === "promote") return "promote";
3070
+ if (answer === "h" || answer === "hold") return "hold";
3071
+ if (answer === "r" || answer === "reject") return "reject";
3072
+ process.stdout.write(`Please answer promote, hold, or reject (or p/h/r).
3073
+ ${prompt}`);
3074
+ }
3075
+ return defaultChoice;
3076
+ } finally {
3077
+ rl.close();
3078
+ }
3079
+ }
3080
+
3081
+ // src/commands/audit.ts
3082
+ var CURATION_PROMPT_ASSET = "curation-prompt.md";
3083
+ function splitCsv(s) {
3084
+ return s.split(",").map((x) => x.trim()).filter(Boolean);
3085
+ }
3086
+ async function askAnswer(rl, prompt) {
3087
+ return (await rl.question(prompt)).trim().toLowerCase();
3088
+ }
3089
+ async function captureBusinessProfile(rl, existing) {
3090
+ console.log("\n[skill-forge] Business profile \u2014 grounds the business-foundation skill scaffold and the curation handoff prompt.");
3091
+ console.log(
3092
+ "[skill-forge] Do not enter credentials, API keys, tokens, or confidential customer data \u2014 this is stored in plain text in config.json."
3093
+ );
3094
+ if (existing) {
3095
+ const keep = await askAnswer(rl, `Reuse the existing business profile (captured ${existing.updatedAt})? [Y/n] `);
3096
+ if (keep === "" || keep === "y" || keep === "yes") return existing;
3097
+ }
3098
+ const businessName = (await rl.question("Business name (optional, Enter to skip): ")).trim() || void 0;
3099
+ const industry = (await rl.question("Industry (optional, Enter to skip): ")).trim() || void 0;
3100
+ const audiences = splitCsv(await rl.question("Audiences (comma-separated, optional): "));
3101
+ const workflows = splitCsv(await rl.question("Workflows (comma-separated, optional): "));
3102
+ const constraints = splitCsv(await rl.question("Constraints (comma-separated, optional): "));
3103
+ const profile = { businessName, industry, audiences, workflows, constraints, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
3104
+ console.log("\n[skill-forge] Business profile summary:");
3105
+ console.log(` Business name: ${profile.businessName ?? "(none)"}`);
3106
+ console.log(` Industry: ${profile.industry ?? "(none)"}`);
3107
+ console.log(` Audiences: ${profile.audiences.join(", ") || "(none)"}`);
3108
+ console.log(` Workflows: ${profile.workflows.join(", ") || "(none)"}`);
3109
+ console.log(` Constraints: ${profile.constraints.join(", ") || "(none)"}`);
3110
+ const confirm = await askAnswer(rl, "Save this business profile? [Y/n] ");
3111
+ if (confirm === "n" || confirm === "no") return existing;
3112
+ return profile;
3113
+ }
3114
+ function resolveReportPath(reportOption) {
3115
+ if (reportOption) return resolvePath3(reportOption);
3116
+ const iso = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3117
+ return join17(getHomeDir(), "reports", `audit-${iso}.md`);
3118
+ }
3119
+ function writeReport(reportPath, contents) {
3120
+ mkdirSync6(dirname6(reportPath), { recursive: true });
3121
+ try {
3122
+ writeFileSync6(reportPath, contents, { flag: "wx", mode: 384 });
3123
+ } catch (err) {
3124
+ if (err.code === "EEXIST") {
3125
+ throw new UserError(`report file already exists: ${reportPath} \u2014 pass a different --report path`);
3126
+ }
3127
+ throw err;
3128
+ }
3129
+ }
3130
+ function printTerminalSummary(report, reportPath) {
3131
+ const { summary } = report;
3132
+ console.log("[skill-forge] audit complete");
3133
+ console.log(` skill roots: ${summary.rootCount} skills: ${summary.skillCount} MCP targets: ${summary.mcpTargetCount}`);
3134
+ console.log(
3135
+ ` findings: ${summary.findingCounts.CRITICAL} CRITICAL, ${summary.findingCounts.HIGH} HIGH, ${summary.findingCounts.MEDIUM} MEDIUM, ${summary.findingCounts.LOW} LOW`
3136
+ );
3137
+ console.log(` overlap clusters: ${summary.overlapClusterCount} evolve-eligible: ${summary.evolveEligibleCount}`);
3138
+ console.log(` report -> ${reportPath}`);
3139
+ for (const notice of report.notices) console.log(` [notice] ${notice}`);
3140
+ console.log(
3141
+ " next steps: `skill-forge add <source>` to install something new; `skill-forge evolve <skill-dir>` to refine an evolve-eligible skill."
3142
+ );
3143
+ }
3144
+ async function maybeScaffoldFoundation(config, report, options, rl) {
3145
+ let wantFoundation = options.foundation ?? false;
3146
+ if (!wantFoundation && rl && config.businessProfile && report.opportunities.foundation.available) {
3147
+ const answer = await askAnswer(rl, "\nCreate a business-foundation skill now? [y/N] ");
3148
+ wantFoundation = answer === "y" || answer === "yes";
3149
+ }
3150
+ if (!wantFoundation) return config;
3151
+ if (!config.businessProfile) {
3152
+ throw new UserError(
3153
+ "--foundation requires a business profile \u2014 run `skill-forge audit` interactively first to capture one."
3154
+ );
3155
+ }
3156
+ const targetRoot = config.defaultTarget ?? config.skillsRoots[0];
3157
+ if (!targetRoot) {
3158
+ throw new UserError("cannot scaffold a foundation skill: no skills root configured.");
3159
+ }
3160
+ const foundationPath = await scaffoldFoundationSkill(config, config.businessProfile, targetRoot);
3161
+ const updated = { ...config, foundationSkillPath: foundationPath };
3162
+ saveConfig(updated);
3163
+ logOutcome(`[skill-forge] scaffolded business-foundation skill -> ${foundationPath}`, options.json);
3164
+ return updated;
3165
+ }
3166
+ async function maybeHandoff(config, reportPath, options, rl) {
3167
+ let wantHandoff = options.handoff ?? false;
3168
+ if (!wantHandoff && rl) {
3169
+ const answer = await askAnswer(rl, "\nHand the curation report to a coding agent now? [y/N] ");
3170
+ wantHandoff = answer === "y" || answer === "yes";
3171
+ }
3172
+ if (!wantHandoff) return;
3173
+ runHandoff(config, reportPath, { promptAsset: CURATION_PROMPT_ASSET, log: options.json ? console.error : console.log });
3174
+ }
3175
+ async function auditCommand(options = {}) {
3176
+ await maybeOfferInit({ json: options.json, yes: options.yes, suppressAuditOffer: true });
3177
+ if (!existsSync14(getConfigPath())) {
3178
+ throw new UserError(
3179
+ "no skill-forge config found \u2014 run `skill-forge init` first (audit requires a configured skill/MCP set; it never audits an invented default)."
3180
+ );
3181
+ }
3182
+ let config = loadConfig();
3183
+ const interactive = !options.json && !options.yes && Boolean(process.stdin.isTTY);
3184
+ const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : void 0;
3185
+ try {
3186
+ if (rl) {
3187
+ const profile = await captureBusinessProfile(rl, config.businessProfile);
3188
+ if (profile !== config.businessProfile) {
3189
+ config = { ...config, businessProfile: profile };
3190
+ saveConfig(config);
3191
+ }
3192
+ }
3193
+ const report = await auditConfiguredSet(config);
3194
+ const reportPath = resolveReportPath(options.report);
3195
+ writeReport(reportPath, renderAuditMarkdown(report));
3196
+ if (options.json) {
3197
+ console.log(JSON.stringify(report, null, 2));
3198
+ } else {
3199
+ printTerminalSummary(report, reportPath);
3200
+ }
3201
+ config = await maybeScaffoldFoundation(config, report, options, rl);
3202
+ await maybeHandoff(config, reportPath, options, rl);
3203
+ } finally {
3204
+ rl?.close();
3205
+ }
3206
+ }
3207
+
2258
3208
  // src/commands/init.ts
2259
3209
  function detectAgents(cwd, home) {
2260
3210
  const found = [];
@@ -2376,7 +3326,8 @@ async function promptHandoffAgent(rl, detected) {
2376
3326
  const idx = parseChoice(answer, detected.length);
2377
3327
  return idx === void 0 ? void 0 : detected[idx].agent;
2378
3328
  }
2379
- async function init(options = {}) {
3329
+ async function init(options = {}, deps = {}) {
3330
+ const runAudit = deps.runAudit ?? auditCommand;
2380
3331
  const cwd = options.cwd ?? process.cwd();
2381
3332
  const home = options.home ?? homedir4();
2382
3333
  const detected = detectAgents(cwd, home);
@@ -2395,6 +3346,7 @@ async function init(options = {}) {
2395
3346
  const config = buildConfig(detected, detected[0], void 0, detectedMcp);
2396
3347
  saveConfig(config);
2397
3348
  printSummary(config);
3349
+ await runAudit({ yes: true });
2398
3350
  return;
2399
3351
  }
2400
3352
  if (!process.stdin.isTTY) {
@@ -2403,7 +3355,8 @@ async function init(options = {}) {
2403
3355
  }
2404
3356
  printDetectionResults(detected);
2405
3357
  printMcpDetectionResults(detectedMcp);
2406
- const rl = createInterface({ input: process.stdin, output: process.stdout });
3358
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
3359
+ let runAuditNow = false;
2407
3360
  try {
2408
3361
  const selected = detected.length > 0 ? await promptMultiSelect(rl, detected) : [];
2409
3362
  const selectedMcp = detectedMcp.length > 0 ? await promptMcpMultiSelect(rl, detectedMcp) : [];
@@ -2416,15 +3369,24 @@ async function init(options = {}) {
2416
3369
  const config = buildConfig(selected, target, handoffAgent, selectedMcp);
2417
3370
  saveConfig(config);
2418
3371
  printSummary(config);
3372
+ if (options.offerAudit !== false) {
3373
+ const answer = (await rl.question("\nRun the skills & MCP audit now? [Y/n] ")).trim().toLowerCase();
3374
+ runAuditNow = answer === "" || answer === "y" || answer === "yes";
3375
+ }
2419
3376
  } finally {
2420
3377
  rl.close();
2421
3378
  }
3379
+ if (runAuditNow) {
3380
+ await runAudit();
3381
+ }
2422
3382
  }
2423
3383
  async function maybeOfferInit(options = {}) {
2424
3384
  if (options.json || options.yes || !process.stdin.isTTY) return;
2425
- if (existsSync13(getConfigPath())) return;
2426
- console.log("[skill-forge] No config found \u2014 run `skill-forge init` to detect your agents and set gate targets.");
2427
- const rl = createInterface({ input: process.stdin, output: process.stdout });
3385
+ if (existsSync15(getConfigPath())) return;
3386
+ console.log(
3387
+ "[skill-forge] No config found \u2014 run `skill-forge init` to detect your agents and set gate targets (init finishes with a `skill-forge audit` health check over what it just configured)."
3388
+ );
3389
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
2428
3390
  let answer = "";
2429
3391
  try {
2430
3392
  answer = (await rl.question("Run `skill-forge init` now? [y/N] ")).trim().toLowerCase();
@@ -2432,36 +3394,7 @@ async function maybeOfferInit(options = {}) {
2432
3394
  rl.close();
2433
3395
  }
2434
3396
  if (answer === "y" || answer === "yes") {
2435
- await init();
2436
- }
2437
- }
2438
-
2439
- // src/decision.ts
2440
- import { createInterface as createInterface2 } from "readline/promises";
2441
- function logOutcome(message, json) {
2442
- if (json) console.error(message);
2443
- else console.log(message);
2444
- }
2445
- function yesDecision(verdict) {
2446
- return verdict === "block" ? "reject" : "promote";
2447
- }
2448
- async function promptDecision(verdict, subject) {
2449
- const rl = createInterface2({ input: process.stdin, output: process.stdout });
2450
- const defaultChoice = verdict === "block" ? "reject" : verdict === "warn" ? "hold" : "promote";
2451
- const prompt = `Promote, hold, or reject ${subject}? [promote/hold/reject] (default: ${defaultChoice}) `;
2452
- try {
2453
- process.stdout.write(prompt);
2454
- for await (const raw of rl) {
2455
- const answer = raw.trim().toLowerCase() || defaultChoice;
2456
- if (answer === "p" || answer === "promote") return "promote";
2457
- if (answer === "h" || answer === "hold") return "hold";
2458
- if (answer === "r" || answer === "reject") return "reject";
2459
- process.stdout.write(`Please answer promote, hold, or reject (or p/h/r).
2460
- ${prompt}`);
2461
- }
2462
- return defaultChoice;
2463
- } finally {
2464
- rl.close();
3397
+ await init(options.suppressAuditOffer ? { offerAudit: false } : {});
2465
3398
  }
2466
3399
  }
2467
3400
 
@@ -2585,13 +3518,20 @@ async function addMcp(source, config, options) {
2585
3518
  // version may be absent — promoteMcp refuses the unpinnable npx fallback
2586
3519
  // rather than writing "npx pkg@unknown"; see deriveServerEntry in promoteMcp.ts.
2587
3520
  version: detail.version,
3521
+ // packageName (v0.7) — the candidate's own scanned package.json name, so
3522
+ // deriveServerEntry can auto-pin an unpinned npx spec in candidateEntry
3523
+ // only when it provably matches the package that was actually scanned.
3524
+ packageName: detail.packageName,
2588
3525
  envVars: detail.env ? Object.keys(detail.env) : void 0,
2589
3526
  candidateEntry: detail.command ? { command: detail.command, args: detail.args, env: detail.env } : void 0
2590
3527
  };
2591
- const mcpTargetPath = options.mcpTarget ?? config.mcpTargets?.[0] ?? join15(process.cwd(), ".mcp.json");
3528
+ const mcpTargetPath = options.mcpTarget ?? config.mcpTargets?.[0] ?? join18(process.cwd(), ".mcp.json");
2592
3529
  const result = promoteMcp(entry, serverProfile, mcpTargetPath, { force: options.force });
2593
3530
  reject(entry);
2594
3531
  logOutcome(`[skill-forge] promoted -> ${result.target} (server "${result.serverName}")`, options.json);
3532
+ if (result.autoPinned) {
3533
+ logOutcome(`[skill-forge] pinned launch spec ${result.autoPinned.from} -> ${result.autoPinned.to}`, options.json);
3534
+ }
2595
3535
  if (result.backupPath) {
2596
3536
  logOutcome(`[skill-forge] backed up existing config -> ${result.backupPath}`, options.json);
2597
3537
  }
@@ -2680,7 +3620,7 @@ async function scan(source, options = {}) {
2680
3620
  }
2681
3621
 
2682
3622
  // src/commands/list.ts
2683
- import { join as join16 } from "path";
3623
+ import { join as join19 } from "path";
2684
3624
  async function list() {
2685
3625
  await maybeOfferInit();
2686
3626
  const config = loadConfig();
@@ -2693,14 +3633,14 @@ async function list() {
2693
3633
  }
2694
3634
  console.log(`[skill-forge] ${ids.length} held in quarantine (${quarantineDir}):`);
2695
3635
  for (const id of ids) {
2696
- const skillDirs = findSkillDirs(join16(quarantineDir, id));
3636
+ const skillDirs = findSkillDirs(join19(quarantineDir, id));
2697
3637
  if (skillDirs.length === 0) {
2698
3638
  console.log(` ${id} (no SKILL.md found)`);
2699
3639
  continue;
2700
3640
  }
2701
3641
  const names = skillDirs.map((dir) => {
2702
3642
  try {
2703
- return readFrontmatter(join16(dir, "SKILL.md")).name;
3643
+ return readFrontmatter(join19(dir, "SKILL.md")).name;
2704
3644
  } catch {
2705
3645
  return dir;
2706
3646
  }
@@ -2722,19 +3662,19 @@ async function status() {
2722
3662
  }
2723
3663
 
2724
3664
  // src/evolve.ts
2725
- import { createInterface as createInterface3 } from "readline/promises";
3665
+ import { createInterface as createInterface4 } from "readline/promises";
2726
3666
  import { createHash } from "crypto";
2727
- import { cpSync as cpSync3, existsSync as existsSync14, lstatSync, mkdtempSync as mkdtempSync2, readFileSync as readFileSync10, rmSync as rmSync3 } from "fs";
3667
+ import { cpSync as cpSync3, existsSync as existsSync16, lstatSync as lstatSync2, mkdtempSync as mkdtempSync3, readFileSync as readFileSync11, rmSync as rmSync4 } from "fs";
2728
3668
  import { tmpdir as tmpdir2 } from "os";
2729
- import { basename as basename6, dirname as dirname5, join as join17, resolve as resolvePath2, sep } from "path";
3669
+ import { basename as basename7, dirname as dirname7, join as join20, resolve as resolvePath4, sep } from "path";
2730
3670
  var SKILLOPT_BIN = "skillopt-sleep";
2731
3671
  var PRO_FEATURE_LABEL2 = "self-evolution via SkillOpt-Sleep (evolve) \u2014 re-gating, provenance, and the pending-ingestion queue";
2732
3672
  async function evolve(targetSkillDir, options = {}, deps = {}) {
2733
3673
  const exec = deps.exec ?? defaultExec;
2734
3674
  const json = options.json;
2735
- const resolvedTargetDir = resolvePath2(targetSkillDir);
2736
- const skillMdPath = join17(resolvedTargetDir, "SKILL.md");
2737
- if (!existsSync14(skillMdPath)) {
3675
+ const resolvedTargetDir = resolvePath4(targetSkillDir);
3676
+ const skillMdPath = join20(resolvedTargetDir, "SKILL.md");
3677
+ if (!existsSync16(skillMdPath)) {
2738
3678
  throw new UserError(`no SKILL.md found at ${skillMdPath} \u2014 evolve requires an existing, installed skill directory`);
2739
3679
  }
2740
3680
  const proStatus = proFeatureStatus(PRO_FEATURE_LABEL2);
@@ -2761,7 +3701,7 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
2761
3701
  } else {
2762
3702
  notices.push('using the offline "mock" backend \u2014 deterministic, no network calls, no session data leaves this machine.');
2763
3703
  }
2764
- const project = resolvePath2(options.project ?? process.cwd());
3704
+ const project = resolvePath4(options.project ?? process.cwd());
2765
3705
  const present = await detectSkillopt(exec, project);
2766
3706
  if (!present) {
2767
3707
  throw new UserError(
@@ -2792,40 +3732,40 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
2792
3732
  }
2793
3733
  return;
2794
3734
  }
2795
- const stagingRoot = join17(project, ".skillopt-sleep", "staging");
2796
- const stagingDir = resolvePath2(parsed.staging_dir);
3735
+ const stagingRoot = join20(project, ".skillopt-sleep", "staging");
3736
+ const stagingDir = resolvePath4(parsed.staging_dir);
2797
3737
  if (stagingDir !== stagingRoot && !stagingDir.startsWith(stagingRoot + sep)) {
2798
3738
  throw new UserError(
2799
3739
  `skillopt-sleep reported a staging_dir outside the expected sandbox (${stagingRoot}): ${stagingDir} \u2014 refusing to use it`
2800
3740
  );
2801
3741
  }
2802
- const proposedSkillMd = join17(stagingDir, "proposed_SKILL.md");
2803
- const proposedClaudeMd = join17(stagingDir, "proposed_CLAUDE.md");
2804
- const reportMdPath = join17(stagingDir, "report.md");
2805
- if (!existsSync14(proposedSkillMd)) {
3742
+ const proposedSkillMd = join20(stagingDir, "proposed_SKILL.md");
3743
+ const proposedClaudeMd = join20(stagingDir, "proposed_CLAUDE.md");
3744
+ const reportMdPath = join20(stagingDir, "report.md");
3745
+ if (!existsSync16(proposedSkillMd)) {
2806
3746
  throw new UserError(`skillopt-sleep reported an accepted proposal but ${proposedSkillMd} is missing`);
2807
3747
  }
2808
- if (existsSync14(reportMdPath)) {
3748
+ if (existsSync16(reportMdPath)) {
2809
3749
  notices.push(`SkillOpt-Sleep's full report: ${reportMdPath}`);
2810
3750
  }
2811
3751
  assertNotSymlink(proposedSkillMd, "proposed_SKILL.md");
2812
3752
  assertNotSymlink(proposedClaudeMd, "proposed_CLAUDE.md");
2813
3753
  const scannedSkillHash = sha256File(proposedSkillMd);
2814
3754
  const scannedClaudeHash = sha256File(proposedClaudeMd);
2815
- const tempDir = mkdtempSync2(join17(tmpdir2(), "skill-forge-evolve-"));
3755
+ const tempDir = mkdtempSync3(join20(tmpdir2(), "skill-forge-evolve-"));
2816
3756
  let profile;
2817
3757
  let safety;
2818
3758
  try {
2819
- cpSync3(proposedSkillMd, join17(tempDir, "SKILL.md"));
2820
- if (existsSync14(proposedClaudeMd)) {
2821
- cpSync3(proposedClaudeMd, join17(tempDir, "CLAUDE.md"));
3759
+ cpSync3(proposedSkillMd, join20(tempDir, "SKILL.md"));
3760
+ if (existsSync16(proposedClaudeMd)) {
3761
+ cpSync3(proposedClaudeMd, join20(tempDir, "CLAUDE.md"));
2822
3762
  }
2823
3763
  profile = asSkillProfile(profileSkill(tempDir), "for the evolved proposal");
2824
3764
  const strictness = loadConfig().strictness;
2825
3765
  safety = await scanSafety(tempDir, strictness);
2826
3766
  } finally {
2827
3767
  try {
2828
- rmSync3(tempDir, { recursive: true, force: true });
3768
+ rmSync4(tempDir, { recursive: true, force: true });
2829
3769
  } catch (err) {
2830
3770
  console.error(`[skill-forge] failed to clean up temp re-gate dir ${tempDir}: ${err.message}`);
2831
3771
  }
@@ -2860,12 +3800,12 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
2860
3800
  throw new UserError(`skillopt-sleep adopt failed (exit ${adoptResult.code}): ${tail(adoptResult.stderr || adoptResult.stdout)}`);
2861
3801
  }
2862
3802
  logOutcome(`[skill-forge] adopted -> ${resolvedTargetDir}`, json);
2863
- const skillsRootForProvenance = dirname5(resolvedTargetDir);
3803
+ const skillsRootForProvenance = dirname7(resolvedTargetDir);
2864
3804
  let ledgerPath;
2865
3805
  try {
2866
3806
  ledgerPath = appendProvenance(
2867
3807
  {
2868
- name: profile.name ?? basename6(resolvedTargetDir),
3808
+ name: profile.name ?? basename7(resolvedTargetDir),
2869
3809
  source: resolvedTargetDir,
2870
3810
  version: profile.version,
2871
3811
  license: profile.license,
@@ -2907,7 +3847,7 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
2907
3847
  logOutcome("[skill-forge] queued for ingestion review", json);
2908
3848
  } else if (decision === "reject") {
2909
3849
  try {
2910
- rmSync3(stagingDir, { recursive: true, force: true });
3850
+ rmSync4(stagingDir, { recursive: true, force: true });
2911
3851
  } catch (err) {
2912
3852
  throw new UserError(
2913
3853
  `rejected the proposal, but removing the staging dir ${stagingDir} failed: ${err.message} \u2014 remove it manually.`
@@ -2917,13 +3857,13 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
2917
3857
  if (safetyVerdict === "block") process.exitCode = 1;
2918
3858
  } else {
2919
3859
  logOutcome(`[skill-forge] held \u2014 staging dir left in place -> ${stagingDir}`, json);
2920
- if (existsSync14(reportMdPath)) {
3860
+ if (existsSync16(reportMdPath)) {
2921
3861
  logOutcome(`[skill-forge] see ${reportMdPath} for the full evidence report`, json);
2922
3862
  }
2923
3863
  }
2924
3864
  }
2925
3865
  async function defaultConfirm(question) {
2926
- const rl = createInterface3({ input: process.stdin, output: process.stdout });
3866
+ const rl = createInterface4({ input: process.stdin, output: process.stdout });
2927
3867
  try {
2928
3868
  process.stdout.write(question);
2929
3869
  for await (const raw of rl) {
@@ -2959,10 +3899,10 @@ function validateRunOutput(raw) {
2959
3899
  return { accepted: Boolean(obj.accepted), staging_dir: stagingDirRaw ?? null };
2960
3900
  }
2961
3901
  function readManifest(stagingDir) {
2962
- const manifestPath = join17(stagingDir, "manifest.json");
3902
+ const manifestPath = join20(stagingDir, "manifest.json");
2963
3903
  let raw;
2964
3904
  try {
2965
- raw = readFileSync10(manifestPath, "utf-8");
3905
+ raw = readFileSync11(manifestPath, "utf-8");
2966
3906
  } catch (err) {
2967
3907
  throw new UserError(`could not read staging manifest ${manifestPath}: ${err.message}`);
2968
3908
  }
@@ -2996,18 +3936,18 @@ function validateManifest(raw, manifestPath) {
2996
3936
  };
2997
3937
  }
2998
3938
  function filesIdentical(a, b) {
2999
- if (!existsSync14(a) || !existsSync14(b)) return false;
3000
- return readFileSync10(a).equals(readFileSync10(b));
3939
+ if (!existsSync16(a) || !existsSync16(b)) return false;
3940
+ return readFileSync11(a).equals(readFileSync11(b));
3001
3941
  }
3002
3942
  function isAlreadyAdopted(stagingDir, manifest) {
3003
- const proposedSkillMd = join17(stagingDir, "proposed_SKILL.md");
3004
- const proposedClaudeMd = join17(stagingDir, "proposed_CLAUDE.md");
3943
+ const proposedSkillMd = join20(stagingDir, "proposed_SKILL.md");
3944
+ const proposedClaudeMd = join20(stagingDir, "proposed_CLAUDE.md");
3005
3945
  let sawComparison = false;
3006
- if (existsSync14(proposedSkillMd)) {
3946
+ if (existsSync16(proposedSkillMd)) {
3007
3947
  sawComparison = true;
3008
3948
  if (!filesIdentical(proposedSkillMd, manifest.live_skill_path)) return false;
3009
3949
  }
3010
- if (existsSync14(proposedClaudeMd) && manifest.live_memory_path) {
3950
+ if (existsSync16(proposedClaudeMd) && manifest.live_memory_path) {
3011
3951
  sawComparison = true;
3012
3952
  if (!filesIdentical(proposedClaudeMd, manifest.live_memory_path)) return false;
3013
3953
  }
@@ -3016,7 +3956,7 @@ function isAlreadyAdopted(stagingDir, manifest) {
3016
3956
  function assertNotSymlink(path, label) {
3017
3957
  let stat;
3018
3958
  try {
3019
- stat = lstatSync(path);
3959
+ stat = lstatSync2(path);
3020
3960
  } catch {
3021
3961
  return;
3022
3962
  }
@@ -3025,8 +3965,8 @@ function assertNotSymlink(path, label) {
3025
3965
  }
3026
3966
  }
3027
3967
  function sha256File(path) {
3028
- if (!existsSync14(path)) return null;
3029
- return createHash("sha256").update(readFileSync10(path)).digest("hex");
3968
+ if (!existsSync16(path)) return null;
3969
+ return createHash("sha256").update(readFileSync11(path)).digest("hex");
3030
3970
  }
3031
3971
 
3032
3972
  // src/commands/evolve.ts
@@ -3060,6 +4000,7 @@ program.command("scan <source>").description("Run the safety gate against a skil
3060
4000
  program.command("list").description("List skills currently in quarantine").action(list);
3061
4001
  program.command("status").description("Show skill-forge configuration and quarantine status").action(status);
3062
4002
  program.command("evolve <target-skill-dir>").description("Self-evolve a skill via SkillOpt-Sleep, re-gate the proposal, then promote/hold/reject (Pro)").option("--project <dir>", "project dir passed to SkillOpt-Sleep as --project (defaults to cwd)").option("--dry-run", "SkillOpt-Sleep dry-run: report only, nothing staged").option("--backend <name>", "SkillOpt-Sleep backend (default: mock, offline \u2014 a real backend requires --yes or interactive confirmation)").option("--lookback-hours <n>", "hours of session history for SkillOpt-Sleep to harvest", parseLookbackHours).option("-y, --yes", "skip interactive prompts; honor the re-gate verdict automatically").option("--json", "print the re-gate result as JSON instead of the terminal report").option("--force", "allow re-adopting a staging dir whose proposed content already matches the live skill").action(evolveCommand);
4003
+ program.command("audit").alias("doctor").description("Doctor-style health check over the configured skill/MCP set: inventory, hygiene findings, and consolidation/refinement opportunities").option("--json", "print the audit report as JSON instead of the terminal summary (non-interactive: skips the business-profile prompt)").option("-y, --yes", "skip interactive prompts (business profile, foundation, handoff); never implies --foundation or --handoff").option("--report <file>", "write the report to this path instead of the default ~/.skill-forge/reports/audit-<timestamp>.md (refuses an existing path)").option("--foundation", "authorizes exactly one skills-root write: scaffold a business-foundation skill from the captured business profile").option("--handoff", "authorizes launching the configured agent with the bundled curation prompt against the written report").action(auditCommand);
3063
4004
  program.command("init").description("Detect installed agents and set gate targets, default promotion target, and handoff agent").option("--defaults", "non-interactive: use all detected agents as gate targets, the first as the default target").option("--list", "print detected agent skill roots and exit \u2014 no writes").action(init);
3064
4005
  try {
3065
4006
  await program.parseAsync();