@rhize/skill-forge 0.7.1 → 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 join16 } 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) {
@@ -1308,6 +1320,7 @@ your you it its as at by from into via per also any all not no than then so if u
1308
1320
  skill skills user claude code based comprehensive enhanced practical including include includes
1309
1321
  `.split(/\s+/).filter(Boolean)
1310
1322
  );
1323
+ var DEFAULT_SET_THRESHOLD = 0.45;
1311
1324
  function splitLimited(text, sep2, maxSplit) {
1312
1325
  const parts = [];
1313
1326
  let rest = text;
@@ -1474,6 +1487,26 @@ function scanOverlap(candidatePath, skillsRoot) {
1474
1487
  ranking
1475
1488
  };
1476
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
+ }
1477
1510
 
1478
1511
  // src/gate/mcpSafety.ts
1479
1512
  import { statSync as statSync6 } from "fs";
@@ -2032,11 +2065,11 @@ import { dirname as dirname5, join as join14 } from "path";
2032
2065
  import { fileURLToPath } from "url";
2033
2066
  var KNOWN_AGENT_BINARIES = ["claude", "codex", "cursor-agent", "windsurf", "opencode", "gemini"];
2034
2067
  var HANDOFF_INSTRUCTION = "Read {prompt} and follow its instructions for the skill installed at {path}";
2035
- function resolvePromptPath() {
2068
+ function resolvePromptPath(assetName = "ingest-prompt.md") {
2036
2069
  const here = dirname5(fileURLToPath(import.meta.url));
2037
- const builtCandidate = join14(here, "ingest-prompt.md");
2070
+ const builtCandidate = join14(here, assetName);
2038
2071
  if (existsSync12(builtCandidate)) return builtCandidate;
2039
- return join14(here, "..", "assets", "ingest-prompt.md");
2072
+ return join14(here, "..", "assets", assetName);
2040
2073
  }
2041
2074
  function defaultIsOnPath(bin) {
2042
2075
  const finder = process.platform === "win32" ? "where" : "which";
@@ -2048,7 +2081,7 @@ function substitute(template, vars) {
2048
2081
  }
2049
2082
  function buildHandoffCommand(config, installedPath, deps = {}) {
2050
2083
  const isOnPath = deps.isOnPath ?? defaultIsOnPath;
2051
- const promptPath = resolvePromptPath();
2084
+ const promptPath = resolvePromptPath(deps.promptAsset);
2052
2085
  const vars = { path: installedPath, prompt: promptPath };
2053
2086
  if (config.handoffCommand && config.handoffCommand.length > 0) {
2054
2087
  return { argv: substitute(config.handoffCommand, vars), source: "template" };
@@ -2060,8 +2093,8 @@ function buildHandoffCommand(config, installedPath, deps = {}) {
2060
2093
  }
2061
2094
  return { argv: [], source: "none" };
2062
2095
  }
2063
- function printOnlyMessage(installedPath) {
2064
- const promptPath = resolvePromptPath();
2096
+ function printOnlyMessage(installedPath, promptAsset) {
2097
+ const promptPath = resolvePromptPath(promptAsset);
2065
2098
  return `[skill-forge] no agent handoff available \u2014 run one yourself:
2066
2099
  Prompt: ${promptPath}
2067
2100
  Skill path: ${installedPath}`;
@@ -2069,18 +2102,19 @@ function printOnlyMessage(installedPath) {
2069
2102
  function runHandoff(config, installedPath, options = {}) {
2070
2103
  const command = buildHandoffCommand(config, installedPath, options);
2071
2104
  const spawn2 = options.spawn ?? spawnSync;
2105
+ const log = options.log ?? console.log;
2072
2106
  if (command.source === "none" || command.argv.length === 0) {
2073
- const printed = printOnlyMessage(installedPath);
2074
- console.log(printed);
2107
+ const printed = printOnlyMessage(installedPath, options.promptAsset);
2108
+ log(printed);
2075
2109
  return { ran: false, command, printed };
2076
2110
  }
2077
2111
  const [bin, ...args] = command.argv;
2078
2112
  const result = spawn2(bin, args, { stdio: options.stdio ?? "inherit" });
2079
2113
  const error = result.error;
2080
2114
  if (error?.code === "ENOENT") {
2081
- const printed = printOnlyMessage(installedPath);
2082
- console.log(`[skill-forge] ${bin} not found on PATH \u2014 run this yourself:`);
2083
- 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);
2084
2118
  return { ran: false, command, printed, error: error.message };
2085
2119
  }
2086
2120
  if (error) {
@@ -2091,9 +2125,9 @@ function runHandoff(config, installedPath, options = {}) {
2091
2125
  }
2092
2126
 
2093
2127
  // src/commands/init.ts
2094
- import { existsSync as existsSync13 } from "fs";
2128
+ import { existsSync as existsSync15 } from "fs";
2095
2129
  import { homedir as homedir4 } from "os";
2096
- import { createInterface } from "readline/promises";
2130
+ import { createInterface as createInterface3 } from "readline/promises";
2097
2131
 
2098
2132
  // src/agents.ts
2099
2133
  import { statSync as statSync7 } from "fs";
@@ -2332,6 +2366,845 @@ var AGENTS = [
2332
2366
  { id: "adal", displayName: "AdaL", projectRoot: ".adal/skills", globalRoot: "~/.adal/skills" }
2333
2367
  ].map(makeAgent);
2334
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
+
2335
3208
  // src/commands/init.ts
2336
3209
  function detectAgents(cwd, home) {
2337
3210
  const found = [];
@@ -2453,7 +3326,8 @@ async function promptHandoffAgent(rl, detected) {
2453
3326
  const idx = parseChoice(answer, detected.length);
2454
3327
  return idx === void 0 ? void 0 : detected[idx].agent;
2455
3328
  }
2456
- async function init(options = {}) {
3329
+ async function init(options = {}, deps = {}) {
3330
+ const runAudit = deps.runAudit ?? auditCommand;
2457
3331
  const cwd = options.cwd ?? process.cwd();
2458
3332
  const home = options.home ?? homedir4();
2459
3333
  const detected = detectAgents(cwd, home);
@@ -2472,6 +3346,7 @@ async function init(options = {}) {
2472
3346
  const config = buildConfig(detected, detected[0], void 0, detectedMcp);
2473
3347
  saveConfig(config);
2474
3348
  printSummary(config);
3349
+ await runAudit({ yes: true });
2475
3350
  return;
2476
3351
  }
2477
3352
  if (!process.stdin.isTTY) {
@@ -2480,7 +3355,8 @@ async function init(options = {}) {
2480
3355
  }
2481
3356
  printDetectionResults(detected);
2482
3357
  printMcpDetectionResults(detectedMcp);
2483
- const rl = createInterface({ input: process.stdin, output: process.stdout });
3358
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
3359
+ let runAuditNow = false;
2484
3360
  try {
2485
3361
  const selected = detected.length > 0 ? await promptMultiSelect(rl, detected) : [];
2486
3362
  const selectedMcp = detectedMcp.length > 0 ? await promptMcpMultiSelect(rl, detectedMcp) : [];
@@ -2493,15 +3369,24 @@ async function init(options = {}) {
2493
3369
  const config = buildConfig(selected, target, handoffAgent, selectedMcp);
2494
3370
  saveConfig(config);
2495
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
+ }
2496
3376
  } finally {
2497
3377
  rl.close();
2498
3378
  }
3379
+ if (runAuditNow) {
3380
+ await runAudit();
3381
+ }
2499
3382
  }
2500
3383
  async function maybeOfferInit(options = {}) {
2501
3384
  if (options.json || options.yes || !process.stdin.isTTY) return;
2502
- if (existsSync13(getConfigPath())) return;
2503
- console.log("[skill-forge] No config found \u2014 run `skill-forge init` to detect your agents and set gate targets.");
2504
- 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 });
2505
3390
  let answer = "";
2506
3391
  try {
2507
3392
  answer = (await rl.question("Run `skill-forge init` now? [y/N] ")).trim().toLowerCase();
@@ -2509,36 +3394,7 @@ async function maybeOfferInit(options = {}) {
2509
3394
  rl.close();
2510
3395
  }
2511
3396
  if (answer === "y" || answer === "yes") {
2512
- await init();
2513
- }
2514
- }
2515
-
2516
- // src/decision.ts
2517
- import { createInterface as createInterface2 } from "readline/promises";
2518
- function logOutcome(message, json) {
2519
- if (json) console.error(message);
2520
- else console.log(message);
2521
- }
2522
- function yesDecision(verdict) {
2523
- return verdict === "block" ? "reject" : "promote";
2524
- }
2525
- async function promptDecision(verdict, subject) {
2526
- const rl = createInterface2({ input: process.stdin, output: process.stdout });
2527
- const defaultChoice = verdict === "block" ? "reject" : verdict === "warn" ? "hold" : "promote";
2528
- const prompt = `Promote, hold, or reject ${subject}? [promote/hold/reject] (default: ${defaultChoice}) `;
2529
- try {
2530
- process.stdout.write(prompt);
2531
- for await (const raw of rl) {
2532
- const answer = raw.trim().toLowerCase() || defaultChoice;
2533
- if (answer === "p" || answer === "promote") return "promote";
2534
- if (answer === "h" || answer === "hold") return "hold";
2535
- if (answer === "r" || answer === "reject") return "reject";
2536
- process.stdout.write(`Please answer promote, hold, or reject (or p/h/r).
2537
- ${prompt}`);
2538
- }
2539
- return defaultChoice;
2540
- } finally {
2541
- rl.close();
3397
+ await init(options.suppressAuditOffer ? { offerAudit: false } : {});
2542
3398
  }
2543
3399
  }
2544
3400
 
@@ -2669,7 +3525,7 @@ async function addMcp(source, config, options) {
2669
3525
  envVars: detail.env ? Object.keys(detail.env) : void 0,
2670
3526
  candidateEntry: detail.command ? { command: detail.command, args: detail.args, env: detail.env } : void 0
2671
3527
  };
2672
- const mcpTargetPath = options.mcpTarget ?? config.mcpTargets?.[0] ?? join16(process.cwd(), ".mcp.json");
3528
+ const mcpTargetPath = options.mcpTarget ?? config.mcpTargets?.[0] ?? join18(process.cwd(), ".mcp.json");
2673
3529
  const result = promoteMcp(entry, serverProfile, mcpTargetPath, { force: options.force });
2674
3530
  reject(entry);
2675
3531
  logOutcome(`[skill-forge] promoted -> ${result.target} (server "${result.serverName}")`, options.json);
@@ -2764,7 +3620,7 @@ async function scan(source, options = {}) {
2764
3620
  }
2765
3621
 
2766
3622
  // src/commands/list.ts
2767
- import { join as join17 } from "path";
3623
+ import { join as join19 } from "path";
2768
3624
  async function list() {
2769
3625
  await maybeOfferInit();
2770
3626
  const config = loadConfig();
@@ -2777,14 +3633,14 @@ async function list() {
2777
3633
  }
2778
3634
  console.log(`[skill-forge] ${ids.length} held in quarantine (${quarantineDir}):`);
2779
3635
  for (const id of ids) {
2780
- const skillDirs = findSkillDirs(join17(quarantineDir, id));
3636
+ const skillDirs = findSkillDirs(join19(quarantineDir, id));
2781
3637
  if (skillDirs.length === 0) {
2782
3638
  console.log(` ${id} (no SKILL.md found)`);
2783
3639
  continue;
2784
3640
  }
2785
3641
  const names = skillDirs.map((dir) => {
2786
3642
  try {
2787
- return readFrontmatter(join17(dir, "SKILL.md")).name;
3643
+ return readFrontmatter(join19(dir, "SKILL.md")).name;
2788
3644
  } catch {
2789
3645
  return dir;
2790
3646
  }
@@ -2806,19 +3662,19 @@ async function status() {
2806
3662
  }
2807
3663
 
2808
3664
  // src/evolve.ts
2809
- import { createInterface as createInterface3 } from "readline/promises";
3665
+ import { createInterface as createInterface4 } from "readline/promises";
2810
3666
  import { createHash } from "crypto";
2811
- 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";
2812
3668
  import { tmpdir as tmpdir2 } from "os";
2813
- import { basename as basename6, dirname as dirname6, join as join18, resolve as resolvePath2, sep } from "path";
3669
+ import { basename as basename7, dirname as dirname7, join as join20, resolve as resolvePath4, sep } from "path";
2814
3670
  var SKILLOPT_BIN = "skillopt-sleep";
2815
3671
  var PRO_FEATURE_LABEL2 = "self-evolution via SkillOpt-Sleep (evolve) \u2014 re-gating, provenance, and the pending-ingestion queue";
2816
3672
  async function evolve(targetSkillDir, options = {}, deps = {}) {
2817
3673
  const exec = deps.exec ?? defaultExec;
2818
3674
  const json = options.json;
2819
- const resolvedTargetDir = resolvePath2(targetSkillDir);
2820
- const skillMdPath = join18(resolvedTargetDir, "SKILL.md");
2821
- if (!existsSync14(skillMdPath)) {
3675
+ const resolvedTargetDir = resolvePath4(targetSkillDir);
3676
+ const skillMdPath = join20(resolvedTargetDir, "SKILL.md");
3677
+ if (!existsSync16(skillMdPath)) {
2822
3678
  throw new UserError(`no SKILL.md found at ${skillMdPath} \u2014 evolve requires an existing, installed skill directory`);
2823
3679
  }
2824
3680
  const proStatus = proFeatureStatus(PRO_FEATURE_LABEL2);
@@ -2845,7 +3701,7 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
2845
3701
  } else {
2846
3702
  notices.push('using the offline "mock" backend \u2014 deterministic, no network calls, no session data leaves this machine.');
2847
3703
  }
2848
- const project = resolvePath2(options.project ?? process.cwd());
3704
+ const project = resolvePath4(options.project ?? process.cwd());
2849
3705
  const present = await detectSkillopt(exec, project);
2850
3706
  if (!present) {
2851
3707
  throw new UserError(
@@ -2876,40 +3732,40 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
2876
3732
  }
2877
3733
  return;
2878
3734
  }
2879
- const stagingRoot = join18(project, ".skillopt-sleep", "staging");
2880
- const stagingDir = resolvePath2(parsed.staging_dir);
3735
+ const stagingRoot = join20(project, ".skillopt-sleep", "staging");
3736
+ const stagingDir = resolvePath4(parsed.staging_dir);
2881
3737
  if (stagingDir !== stagingRoot && !stagingDir.startsWith(stagingRoot + sep)) {
2882
3738
  throw new UserError(
2883
3739
  `skillopt-sleep reported a staging_dir outside the expected sandbox (${stagingRoot}): ${stagingDir} \u2014 refusing to use it`
2884
3740
  );
2885
3741
  }
2886
- const proposedSkillMd = join18(stagingDir, "proposed_SKILL.md");
2887
- const proposedClaudeMd = join18(stagingDir, "proposed_CLAUDE.md");
2888
- const reportMdPath = join18(stagingDir, "report.md");
2889
- 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)) {
2890
3746
  throw new UserError(`skillopt-sleep reported an accepted proposal but ${proposedSkillMd} is missing`);
2891
3747
  }
2892
- if (existsSync14(reportMdPath)) {
3748
+ if (existsSync16(reportMdPath)) {
2893
3749
  notices.push(`SkillOpt-Sleep's full report: ${reportMdPath}`);
2894
3750
  }
2895
3751
  assertNotSymlink(proposedSkillMd, "proposed_SKILL.md");
2896
3752
  assertNotSymlink(proposedClaudeMd, "proposed_CLAUDE.md");
2897
3753
  const scannedSkillHash = sha256File(proposedSkillMd);
2898
3754
  const scannedClaudeHash = sha256File(proposedClaudeMd);
2899
- const tempDir = mkdtempSync2(join18(tmpdir2(), "skill-forge-evolve-"));
3755
+ const tempDir = mkdtempSync3(join20(tmpdir2(), "skill-forge-evolve-"));
2900
3756
  let profile;
2901
3757
  let safety;
2902
3758
  try {
2903
- cpSync3(proposedSkillMd, join18(tempDir, "SKILL.md"));
2904
- if (existsSync14(proposedClaudeMd)) {
2905
- cpSync3(proposedClaudeMd, join18(tempDir, "CLAUDE.md"));
3759
+ cpSync3(proposedSkillMd, join20(tempDir, "SKILL.md"));
3760
+ if (existsSync16(proposedClaudeMd)) {
3761
+ cpSync3(proposedClaudeMd, join20(tempDir, "CLAUDE.md"));
2906
3762
  }
2907
3763
  profile = asSkillProfile(profileSkill(tempDir), "for the evolved proposal");
2908
3764
  const strictness = loadConfig().strictness;
2909
3765
  safety = await scanSafety(tempDir, strictness);
2910
3766
  } finally {
2911
3767
  try {
2912
- rmSync3(tempDir, { recursive: true, force: true });
3768
+ rmSync4(tempDir, { recursive: true, force: true });
2913
3769
  } catch (err) {
2914
3770
  console.error(`[skill-forge] failed to clean up temp re-gate dir ${tempDir}: ${err.message}`);
2915
3771
  }
@@ -2944,12 +3800,12 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
2944
3800
  throw new UserError(`skillopt-sleep adopt failed (exit ${adoptResult.code}): ${tail(adoptResult.stderr || adoptResult.stdout)}`);
2945
3801
  }
2946
3802
  logOutcome(`[skill-forge] adopted -> ${resolvedTargetDir}`, json);
2947
- const skillsRootForProvenance = dirname6(resolvedTargetDir);
3803
+ const skillsRootForProvenance = dirname7(resolvedTargetDir);
2948
3804
  let ledgerPath;
2949
3805
  try {
2950
3806
  ledgerPath = appendProvenance(
2951
3807
  {
2952
- name: profile.name ?? basename6(resolvedTargetDir),
3808
+ name: profile.name ?? basename7(resolvedTargetDir),
2953
3809
  source: resolvedTargetDir,
2954
3810
  version: profile.version,
2955
3811
  license: profile.license,
@@ -2991,7 +3847,7 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
2991
3847
  logOutcome("[skill-forge] queued for ingestion review", json);
2992
3848
  } else if (decision === "reject") {
2993
3849
  try {
2994
- rmSync3(stagingDir, { recursive: true, force: true });
3850
+ rmSync4(stagingDir, { recursive: true, force: true });
2995
3851
  } catch (err) {
2996
3852
  throw new UserError(
2997
3853
  `rejected the proposal, but removing the staging dir ${stagingDir} failed: ${err.message} \u2014 remove it manually.`
@@ -3001,13 +3857,13 @@ async function evolve(targetSkillDir, options = {}, deps = {}) {
3001
3857
  if (safetyVerdict === "block") process.exitCode = 1;
3002
3858
  } else {
3003
3859
  logOutcome(`[skill-forge] held \u2014 staging dir left in place -> ${stagingDir}`, json);
3004
- if (existsSync14(reportMdPath)) {
3860
+ if (existsSync16(reportMdPath)) {
3005
3861
  logOutcome(`[skill-forge] see ${reportMdPath} for the full evidence report`, json);
3006
3862
  }
3007
3863
  }
3008
3864
  }
3009
3865
  async function defaultConfirm(question) {
3010
- const rl = createInterface3({ input: process.stdin, output: process.stdout });
3866
+ const rl = createInterface4({ input: process.stdin, output: process.stdout });
3011
3867
  try {
3012
3868
  process.stdout.write(question);
3013
3869
  for await (const raw of rl) {
@@ -3043,10 +3899,10 @@ function validateRunOutput(raw) {
3043
3899
  return { accepted: Boolean(obj.accepted), staging_dir: stagingDirRaw ?? null };
3044
3900
  }
3045
3901
  function readManifest(stagingDir) {
3046
- const manifestPath = join18(stagingDir, "manifest.json");
3902
+ const manifestPath = join20(stagingDir, "manifest.json");
3047
3903
  let raw;
3048
3904
  try {
3049
- raw = readFileSync10(manifestPath, "utf-8");
3905
+ raw = readFileSync11(manifestPath, "utf-8");
3050
3906
  } catch (err) {
3051
3907
  throw new UserError(`could not read staging manifest ${manifestPath}: ${err.message}`);
3052
3908
  }
@@ -3080,18 +3936,18 @@ function validateManifest(raw, manifestPath) {
3080
3936
  };
3081
3937
  }
3082
3938
  function filesIdentical(a, b) {
3083
- if (!existsSync14(a) || !existsSync14(b)) return false;
3084
- return readFileSync10(a).equals(readFileSync10(b));
3939
+ if (!existsSync16(a) || !existsSync16(b)) return false;
3940
+ return readFileSync11(a).equals(readFileSync11(b));
3085
3941
  }
3086
3942
  function isAlreadyAdopted(stagingDir, manifest) {
3087
- const proposedSkillMd = join18(stagingDir, "proposed_SKILL.md");
3088
- const proposedClaudeMd = join18(stagingDir, "proposed_CLAUDE.md");
3943
+ const proposedSkillMd = join20(stagingDir, "proposed_SKILL.md");
3944
+ const proposedClaudeMd = join20(stagingDir, "proposed_CLAUDE.md");
3089
3945
  let sawComparison = false;
3090
- if (existsSync14(proposedSkillMd)) {
3946
+ if (existsSync16(proposedSkillMd)) {
3091
3947
  sawComparison = true;
3092
3948
  if (!filesIdentical(proposedSkillMd, manifest.live_skill_path)) return false;
3093
3949
  }
3094
- if (existsSync14(proposedClaudeMd) && manifest.live_memory_path) {
3950
+ if (existsSync16(proposedClaudeMd) && manifest.live_memory_path) {
3095
3951
  sawComparison = true;
3096
3952
  if (!filesIdentical(proposedClaudeMd, manifest.live_memory_path)) return false;
3097
3953
  }
@@ -3100,7 +3956,7 @@ function isAlreadyAdopted(stagingDir, manifest) {
3100
3956
  function assertNotSymlink(path, label) {
3101
3957
  let stat;
3102
3958
  try {
3103
- stat = lstatSync(path);
3959
+ stat = lstatSync2(path);
3104
3960
  } catch {
3105
3961
  return;
3106
3962
  }
@@ -3109,8 +3965,8 @@ function assertNotSymlink(path, label) {
3109
3965
  }
3110
3966
  }
3111
3967
  function sha256File(path) {
3112
- if (!existsSync14(path)) return null;
3113
- return createHash("sha256").update(readFileSync10(path)).digest("hex");
3968
+ if (!existsSync16(path)) return null;
3969
+ return createHash("sha256").update(readFileSync11(path)).digest("hex");
3114
3970
  }
3115
3971
 
3116
3972
  // src/commands/evolve.ts
@@ -3144,6 +4000,7 @@ program.command("scan <source>").description("Run the safety gate against a skil
3144
4000
  program.command("list").description("List skills currently in quarantine").action(list);
3145
4001
  program.command("status").description("Show skill-forge configuration and quarantine status").action(status);
3146
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);
3147
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);
3148
4005
  try {
3149
4006
  await program.parseAsync();