@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/README.md +86 -0
- package/dist/cli.js +946 -89
- package/dist/cli.js.map +1 -1
- package/dist/curation-prompt.md +121 -0
- package/package.json +3 -2
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
|
|
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,
|
|
2070
|
+
const builtCandidate = join14(here, assetName);
|
|
2038
2071
|
if (existsSync12(builtCandidate)) return builtCandidate;
|
|
2039
|
-
return join14(here, "..", "assets",
|
|
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
|
-
|
|
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
|
-
|
|
2083
|
-
|
|
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
|
|
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 =
|
|
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 (
|
|
2503
|
-
console.log(
|
|
2504
|
-
|
|
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] ??
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
3665
|
+
import { createInterface as createInterface4 } from "readline/promises";
|
|
2810
3666
|
import { createHash } from "crypto";
|
|
2811
|
-
import { cpSync as cpSync3, existsSync as
|
|
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
|
|
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 =
|
|
2820
|
-
const skillMdPath =
|
|
2821
|
-
if (!
|
|
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 =
|
|
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 =
|
|
2880
|
-
const stagingDir =
|
|
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 =
|
|
2887
|
-
const proposedClaudeMd =
|
|
2888
|
-
const reportMdPath =
|
|
2889
|
-
if (!
|
|
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 (
|
|
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 =
|
|
3755
|
+
const tempDir = mkdtempSync3(join20(tmpdir2(), "skill-forge-evolve-"));
|
|
2900
3756
|
let profile;
|
|
2901
3757
|
let safety;
|
|
2902
3758
|
try {
|
|
2903
|
-
cpSync3(proposedSkillMd,
|
|
2904
|
-
if (
|
|
2905
|
-
cpSync3(proposedClaudeMd,
|
|
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
|
-
|
|
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 =
|
|
3803
|
+
const skillsRootForProvenance = dirname7(resolvedTargetDir);
|
|
2948
3804
|
let ledgerPath;
|
|
2949
3805
|
try {
|
|
2950
3806
|
ledgerPath = appendProvenance(
|
|
2951
3807
|
{
|
|
2952
|
-
name: profile.name ??
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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 =
|
|
3902
|
+
const manifestPath = join20(stagingDir, "manifest.json");
|
|
3047
3903
|
let raw;
|
|
3048
3904
|
try {
|
|
3049
|
-
raw =
|
|
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 (!
|
|
3084
|
-
return
|
|
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 =
|
|
3088
|
-
const proposedClaudeMd =
|
|
3943
|
+
const proposedSkillMd = join20(stagingDir, "proposed_SKILL.md");
|
|
3944
|
+
const proposedClaudeMd = join20(stagingDir, "proposed_CLAUDE.md");
|
|
3089
3945
|
let sawComparison = false;
|
|
3090
|
-
if (
|
|
3946
|
+
if (existsSync16(proposedSkillMd)) {
|
|
3091
3947
|
sawComparison = true;
|
|
3092
3948
|
if (!filesIdentical(proposedSkillMd, manifest.live_skill_path)) return false;
|
|
3093
3949
|
}
|
|
3094
|
-
if (
|
|
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 =
|
|
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 (!
|
|
3113
|
-
return createHash("sha256").update(
|
|
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();
|