@rhize/skill-forge 0.6.1 → 0.7.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/LICENSE +1 -0
- package/README.md +70 -5
- package/dist/cli.js +386 -55
- package/dist/cli.js.map +1 -1
- package/dist/ingest-prompt.md +14 -1
- package/package.json +1 -1
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -65,11 +65,12 @@ anything ending in `.git`), or a local filesystem path.
|
|
|
65
65
|
## Commands
|
|
66
66
|
|
|
67
67
|
```
|
|
68
|
-
skill-forge init [options]
|
|
69
|
-
skill-forge add <source> [options]
|
|
70
|
-
skill-forge scan <source> [options]
|
|
71
|
-
skill-forge
|
|
72
|
-
skill-forge
|
|
68
|
+
skill-forge init [options] Detect installed agents and set gate targets / handoff agent
|
|
69
|
+
skill-forge add <source> [options] Quarantine-install a skill and run it through the gate
|
|
70
|
+
skill-forge scan <source> [options] Gate a skill without installing it (always cleans up)
|
|
71
|
+
skill-forge evolve <skill-dir> [options] Self-evolve an installed skill via SkillOpt-Sleep, re-gate, decide (Pro)
|
|
72
|
+
skill-forge list List skills currently held in quarantine
|
|
73
|
+
skill-forge status Show configuration and quarantine summary
|
|
73
74
|
```
|
|
74
75
|
|
|
75
76
|
### `init`
|
|
@@ -132,6 +133,69 @@ anything — the quarantine sandbox is always cleaned up afterward, on success o
|
|
|
132
133
|
nonzero when the safety verdict is `block`. `--json` prints the same gate-result payload shape as
|
|
133
134
|
`add`'s.
|
|
134
135
|
|
|
136
|
+
### `evolve` (v0.7)
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
skill-forge evolve .claude/skills/my-skill
|
|
140
|
+
skill-forge evolve .claude/skills/my-skill --yes
|
|
141
|
+
skill-forge evolve .claude/skills/my-skill --dry-run
|
|
142
|
+
skill-forge evolve .claude/skills/my-skill --backend claude --yes
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Pro (free during the 0.x beta). Orchestrates [microsoft/SkillOpt](https://github.com/microsoft/SkillOpt)'s
|
|
146
|
+
`skillopt-sleep` CLI (`pip install skillopt`) to propose a self-evolution of an already-installed
|
|
147
|
+
skill — harvest recent sessions, generate a candidate replacement `SKILL.md`/`CLAUDE.md`, and stage
|
|
148
|
+
it — then runs the **staged proposal**, never the live skill, back through skill-forge's own static
|
|
149
|
+
safety ruleset before you decide anything. SkillOpt-Sleep's own validation gate is score-only; it
|
|
150
|
+
never content-vets the generated markdown. This closes that gap.
|
|
151
|
+
|
|
152
|
+
| Option | Effect |
|
|
153
|
+
|---|---|
|
|
154
|
+
| `--project <dir>` | Project dir passed to SkillOpt-Sleep as `--project`. Defaults to cwd. |
|
|
155
|
+
| `--dry-run` | Uses SkillOpt-Sleep's `dry-run` subcommand — report only, nothing staged. |
|
|
156
|
+
| `--backend <name>` | SkillOpt-Sleep backend. Defaults to `mock` (offline, deterministic, no network). |
|
|
157
|
+
| `--lookback-hours <n>` | Hours of session history for SkillOpt-Sleep to harvest. |
|
|
158
|
+
| `-y, --yes` | Skips interactive prompts (the disclosure confirm below, and the promote/hold/reject prompt) and honors the re-gate verdict automatically. |
|
|
159
|
+
| `--json` | Prints the re-gate result as JSON instead of the terminal report. Implies non-interactive, same as `add`'s `--json`. |
|
|
160
|
+
| `--force` | Allows re-adopting a staging dir whose proposed content already matches the live skill (see the double-adopt guard below). |
|
|
161
|
+
|
|
162
|
+
**Requires `skillopt-sleep` on PATH** — skill-forge never installs it for you. If it's missing,
|
|
163
|
+
`evolve` prints `pip install skillopt` + a docs pointer and exits, rather than attempting an
|
|
164
|
+
auto-install (the same "detect, don't install" discipline the rest of the gate follows).
|
|
165
|
+
|
|
166
|
+
**Data boundary.** The default `mock` backend is fully offline — harvesting and proposal
|
|
167
|
+
generation both run locally, no session data leaves the machine. Any other backend (`claude`,
|
|
168
|
+
`codex`, `azure_openai`, ...) sends truncated excerpts from harvested sessions and derived tasks to
|
|
169
|
+
the provider you selected; per SkillOpt-Sleep's own docs this is not currently guaranteed to be
|
|
170
|
+
secret-free. `evolve` prints that disclosure and requires either `--yes` or an interactive `y/N`
|
|
171
|
+
confirmation before a non-`mock` run proceeds — `--json` is non-interactive, so a non-`mock` run
|
|
172
|
+
under `--json` without `--yes` is refused rather than silently sending data off-machine.
|
|
173
|
+
|
|
174
|
+
**Re-gate.** SkillOpt-Sleep only ever *stages* a proposal (`<project>/.skillopt-sleep/staging/<timestamp>/`
|
|
175
|
+
— full replacement files, never auto-adopted). `evolve` copies just `proposed_SKILL.md` (and
|
|
176
|
+
`proposed_CLAUDE.md`, if present) into a fresh temporary directory and runs skill-forge's own
|
|
177
|
+
static safety ruleset over that copy — the same one `add`/`scan` use, at your configured
|
|
178
|
+
`strictness`. `report.json`/`diagnostics.json` (SkillOpt-Sleep's own redacted holdout evidence) are
|
|
179
|
+
never scanned. The result renders through the same terminal report / `--json` shape as `add`/`scan`.
|
|
180
|
+
|
|
181
|
+
**Decision.** Same promote/hold/reject semantics as `add`: `--yes`/`--json` honor the re-gate
|
|
182
|
+
verdict (a `block` is rejected); otherwise you're prompted.
|
|
183
|
+
|
|
184
|
+
- **promote** — runs SkillOpt-Sleep's own `adopt --staging <dir>` (which backs up the live file(s)
|
|
185
|
+
before copying the proposal over them), then records a provenance entry to the target skill's
|
|
186
|
+
`SOURCES.md` and a pending-ingestion queue entry (`origin: "evolve"`, see
|
|
187
|
+
[docs/queue-schema.md](docs/queue-schema.md)) so a later ingest pass reviews the evolution rather
|
|
188
|
+
than an external source.
|
|
189
|
+
- **hold** — leaves the staging dir exactly as SkillOpt-Sleep produced it (its own `status` command
|
|
190
|
+
still lists it); `report.md` has the full evidence.
|
|
191
|
+
- **reject** — deletes the staging dir.
|
|
192
|
+
|
|
193
|
+
**Double-adopt guard.** SkillOpt-Sleep's `adopt` has no confirmation of its own, and adopting the
|
|
194
|
+
same staging dir twice overwrites *its* backup — the pre-evolution original would be lost. Before
|
|
195
|
+
adopting, `evolve` refuses (unless `--force`) when the staged proposal is already byte-identical to
|
|
196
|
+
the live skill it would replace, since that's the signature of a staging dir that was already
|
|
197
|
+
adopted once.
|
|
198
|
+
|
|
135
199
|
### `list` / `status`
|
|
136
200
|
|
|
137
201
|
`list` shows what's currently held in quarantine (installed via `add`, answered "hold", not yet
|
|
@@ -237,6 +301,7 @@ JSON. Detection/overlap is agent-format-agnostic; writing is JSON-only.
|
|
|
237
301
|
| Overlap analysis against your configured skill set | | ✓ |
|
|
238
302
|
| Provenance ledger (`SOURCES.md` audit trail) | | ✓ |
|
|
239
303
|
| Pending-ingestion queue + `--ingest` handoff | | ✓ |
|
|
304
|
+
| `evolve` — SkillOpt-Sleep self-evolution, re-gating, provenance, queueing (v0.7) | | ✓ |
|
|
240
305
|
| Set-level organizer (capability registry, redundancy, dependency graph) | | ✓ |
|
|
241
306
|
|
|
242
307
|
Free is the complete safety gate on its own — quarantine, profile, safety scan, and an explicit
|
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,6 @@ import { Command } from "commander";
|
|
|
5
5
|
import { createRequire } from "module";
|
|
6
6
|
|
|
7
7
|
// src/commands/add.ts
|
|
8
|
-
import { createInterface as createInterface2 } from "readline/promises";
|
|
9
8
|
import { join as join15 } from "path";
|
|
10
9
|
|
|
11
10
|
// src/config.ts
|
|
@@ -519,6 +518,9 @@ function verdictFor(findings, strictness) {
|
|
|
519
518
|
if (findings.length > 0) return "warn";
|
|
520
519
|
return "pass";
|
|
521
520
|
}
|
|
521
|
+
function formatSafetyFinding(f) {
|
|
522
|
+
return `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`;
|
|
523
|
+
}
|
|
522
524
|
|
|
523
525
|
// src/gate/mcpCapabilities.ts
|
|
524
526
|
var SOURCE_EXT = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
@@ -725,6 +727,22 @@ function profileSkill(inputPath) {
|
|
|
725
727
|
}
|
|
726
728
|
return profileSkillDir(p);
|
|
727
729
|
}
|
|
730
|
+
function asSkillProfile(profile, context) {
|
|
731
|
+
if (profile.kind !== "skill") {
|
|
732
|
+
throw new Error(`expected a skill candidate ${context}, found a ${profile.kind}`);
|
|
733
|
+
}
|
|
734
|
+
return profile;
|
|
735
|
+
}
|
|
736
|
+
function asProfile(candidatePath, artifact) {
|
|
737
|
+
const profile = profileSkill(candidatePath);
|
|
738
|
+
if (artifact === "mcp") {
|
|
739
|
+
if (profile.kind !== "mcp") {
|
|
740
|
+
throw new Error(`expected an MCP candidate at ${candidatePath} (--artifact mcp), found a ${profile.kind}`);
|
|
741
|
+
}
|
|
742
|
+
return profile;
|
|
743
|
+
}
|
|
744
|
+
return asSkillProfile(profile, `at ${candidatePath}`);
|
|
745
|
+
}
|
|
728
746
|
function classifyInput(p) {
|
|
729
747
|
if (existsSync8(join8(p, ".claude-plugin", "plugin.json"))) return "plugin";
|
|
730
748
|
if (detectArtifactType(p) === "mcp") return "mcp";
|
|
@@ -1201,14 +1219,14 @@ your you it its as at by from into via per also any all not no than then so if u
|
|
|
1201
1219
|
skill skills user claude code based comprehensive enhanced practical including include includes
|
|
1202
1220
|
`.split(/\s+/).filter(Boolean)
|
|
1203
1221
|
);
|
|
1204
|
-
function splitLimited(text,
|
|
1222
|
+
function splitLimited(text, sep2, maxSplit) {
|
|
1205
1223
|
const parts = [];
|
|
1206
1224
|
let rest = text;
|
|
1207
1225
|
for (let i = 0; i < maxSplit; i++) {
|
|
1208
|
-
const idx = rest.indexOf(
|
|
1226
|
+
const idx = rest.indexOf(sep2);
|
|
1209
1227
|
if (idx === -1) break;
|
|
1210
1228
|
parts.push(rest.slice(0, idx));
|
|
1211
|
-
rest = rest.slice(idx +
|
|
1229
|
+
rest = rest.slice(idx + sep2.length);
|
|
1212
1230
|
}
|
|
1213
1231
|
parts.push(rest);
|
|
1214
1232
|
return parts;
|
|
@@ -1743,6 +1761,14 @@ function proFeatureStatus(feature, betaFree = BETA_FREE) {
|
|
|
1743
1761
|
}
|
|
1744
1762
|
|
|
1745
1763
|
// src/report/render.ts
|
|
1764
|
+
var SAFETY_VERDICT_MAP = {
|
|
1765
|
+
pass: "ALLOW",
|
|
1766
|
+
warn: "CAUTION",
|
|
1767
|
+
block: "BLOCK"
|
|
1768
|
+
};
|
|
1769
|
+
function mapSafetyFindings(findings) {
|
|
1770
|
+
return findings.map((f) => ({ severity: f.severity, rule: f.rule, message: `${f.file}: ${f.detail}` }));
|
|
1771
|
+
}
|
|
1746
1772
|
var BORDER = "\u2501".repeat(55);
|
|
1747
1773
|
var LABEL_WIDTH = 20;
|
|
1748
1774
|
function padLabel(text) {
|
|
@@ -1817,29 +1843,14 @@ function renderReport(result) {
|
|
|
1817
1843
|
function renderJson(result) {
|
|
1818
1844
|
return JSON.stringify(result, null, 2);
|
|
1819
1845
|
}
|
|
1820
|
-
|
|
1821
|
-
// src/commands/gatePipeline.ts
|
|
1822
|
-
var SAFETY_VERDICT_MAP = {
|
|
1823
|
-
pass: "ALLOW",
|
|
1824
|
-
warn: "CAUTION",
|
|
1825
|
-
block: "BLOCK"
|
|
1826
|
-
};
|
|
1827
1846
|
function printGateOutput(gateResult, json) {
|
|
1828
1847
|
if (!json) {
|
|
1829
1848
|
for (const notice of gateResult.notices ?? []) console.log(`[skill-forge] ${notice}`);
|
|
1830
1849
|
}
|
|
1831
1850
|
console.log(json ? renderJson(gateResult) : renderReport(gateResult));
|
|
1832
1851
|
}
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
const expectedKind = artifact === "mcp" ? "mcp" : "skill";
|
|
1836
|
-
if (profile.kind !== expectedKind) {
|
|
1837
|
-
throw new Error(
|
|
1838
|
-
artifact === "mcp" ? `expected an MCP candidate at ${candidatePath} (--artifact mcp), found a ${profile.kind}` : `expected a skill candidate at ${candidatePath}, found a ${profile.kind}`
|
|
1839
|
-
);
|
|
1840
|
-
}
|
|
1841
|
-
return profile;
|
|
1842
|
-
}
|
|
1852
|
+
|
|
1853
|
+
// src/commands/gatePipeline.ts
|
|
1843
1854
|
function tryOverlap(candidatePath, skillsRoot) {
|
|
1844
1855
|
try {
|
|
1845
1856
|
return scanOverlap(candidatePath, skillsRoot);
|
|
@@ -1891,11 +1902,7 @@ async function runGate(candidatePath, skillsRoot, {
|
|
|
1891
1902
|
notices: proStatus.notice ? [proStatus.notice] : void 0,
|
|
1892
1903
|
safety: {
|
|
1893
1904
|
verdict: SAFETY_VERDICT_MAP[safety.verdict],
|
|
1894
|
-
findings: safety.findings
|
|
1895
|
-
severity: f.severity,
|
|
1896
|
-
rule: f.rule,
|
|
1897
|
-
message: `${f.file}: ${f.detail}`
|
|
1898
|
-
}))
|
|
1905
|
+
findings: mapSafetyFindings(safety.findings)
|
|
1899
1906
|
}
|
|
1900
1907
|
};
|
|
1901
1908
|
return { safety, gateResult, proEnabled: proStatus.enabled };
|
|
@@ -2429,13 +2436,38 @@ async function maybeOfferInit(options = {}) {
|
|
|
2429
2436
|
}
|
|
2430
2437
|
}
|
|
2431
2438
|
|
|
2432
|
-
// src/
|
|
2433
|
-
|
|
2434
|
-
var MCP_PRO_FEATURE_LABEL = "overlap analysis and the pending-ingestion queue";
|
|
2439
|
+
// src/decision.ts
|
|
2440
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
2435
2441
|
function logOutcome(message, json) {
|
|
2436
2442
|
if (json) console.error(message);
|
|
2437
2443
|
else console.log(message);
|
|
2438
2444
|
}
|
|
2445
|
+
function yesDecision(verdict) {
|
|
2446
|
+
return verdict === "block" ? "reject" : "promote";
|
|
2447
|
+
}
|
|
2448
|
+
async function promptDecision(verdict, subject) {
|
|
2449
|
+
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
2450
|
+
const defaultChoice = verdict === "block" ? "reject" : verdict === "warn" ? "hold" : "promote";
|
|
2451
|
+
const prompt = `Promote, hold, or reject ${subject}? [promote/hold/reject] (default: ${defaultChoice}) `;
|
|
2452
|
+
try {
|
|
2453
|
+
process.stdout.write(prompt);
|
|
2454
|
+
for await (const raw of rl) {
|
|
2455
|
+
const answer = raw.trim().toLowerCase() || defaultChoice;
|
|
2456
|
+
if (answer === "p" || answer === "promote") return "promote";
|
|
2457
|
+
if (answer === "h" || answer === "hold") return "hold";
|
|
2458
|
+
if (answer === "r" || answer === "reject") return "reject";
|
|
2459
|
+
process.stdout.write(`Please answer promote, hold, or reject (or p/h/r).
|
|
2460
|
+
${prompt}`);
|
|
2461
|
+
}
|
|
2462
|
+
return defaultChoice;
|
|
2463
|
+
} finally {
|
|
2464
|
+
rl.close();
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
// src/commands/add.ts
|
|
2469
|
+
var PRO_FEATURE_LABEL = "overlap analysis, the provenance ledger, and the pending-ingestion queue";
|
|
2470
|
+
var MCP_PRO_FEATURE_LABEL = "overlap analysis and the pending-ingestion queue";
|
|
2439
2471
|
async function add(source, options = {}) {
|
|
2440
2472
|
await maybeOfferInit({ json: options.json, yes: options.yes });
|
|
2441
2473
|
const config = loadConfig();
|
|
@@ -2472,7 +2504,7 @@ async function add(source, options = {}) {
|
|
|
2472
2504
|
throw new Error(`expected a skill candidate, found a ${profile.kind}`);
|
|
2473
2505
|
}
|
|
2474
2506
|
printGateOutput(gateResult, options.json);
|
|
2475
|
-
const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
|
|
2507
|
+
const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict, "this skill");
|
|
2476
2508
|
let installedPath = null;
|
|
2477
2509
|
if (decision === "promote") {
|
|
2478
2510
|
const result = promote(entry, targetRoot);
|
|
@@ -2512,7 +2544,7 @@ async function add(source, options = {}) {
|
|
|
2512
2544
|
gate: {
|
|
2513
2545
|
license: profile.license ?? null,
|
|
2514
2546
|
safetyVerdict: safety.verdict,
|
|
2515
|
-
safetyFindings: safety.findings.map(
|
|
2547
|
+
safetyFindings: safety.findings.map(formatSafetyFinding),
|
|
2516
2548
|
overlapTop: overlap ? overlap.ranking.slice(0, 3).map((r) => ({ skill: r.skill, score: r.score })) : [],
|
|
2517
2549
|
suggestedVerb: overlap ? suggestVerbCode(overlap.nearestScore) : null
|
|
2518
2550
|
},
|
|
@@ -2544,7 +2576,7 @@ async function addMcp(source, config, options) {
|
|
|
2544
2576
|
);
|
|
2545
2577
|
}
|
|
2546
2578
|
printGateOutput(gateResult, options.json);
|
|
2547
|
-
const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
|
|
2579
|
+
const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict, "this skill");
|
|
2548
2580
|
let installedPath = null;
|
|
2549
2581
|
if (decision === "promote") {
|
|
2550
2582
|
const detail = resolveMcpCandidateDetail(profile);
|
|
@@ -2585,7 +2617,7 @@ async function addMcp(source, config, options) {
|
|
|
2585
2617
|
gate: {
|
|
2586
2618
|
license: null,
|
|
2587
2619
|
safetyVerdict: safety.verdict,
|
|
2588
|
-
safetyFindings: safety.findings.map(
|
|
2620
|
+
safetyFindings: safety.findings.map(formatSafetyFinding),
|
|
2589
2621
|
overlapTop: overlap ? overlap.ranking.slice(0, 3).map((r) => ({ skill: r.skill, score: r.score })) : [],
|
|
2590
2622
|
suggestedVerb: overlap ? suggestVerbCode(overlap.nearestScore) : null
|
|
2591
2623
|
},
|
|
@@ -2598,28 +2630,6 @@ async function addMcp(source, config, options) {
|
|
|
2598
2630
|
}
|
|
2599
2631
|
}
|
|
2600
2632
|
}
|
|
2601
|
-
function yesDecision(verdict) {
|
|
2602
|
-
return verdict === "block" ? "reject" : "promote";
|
|
2603
|
-
}
|
|
2604
|
-
async function promptDecision(verdict) {
|
|
2605
|
-
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
2606
|
-
const defaultChoice = verdict === "block" ? "reject" : verdict === "warn" ? "hold" : "promote";
|
|
2607
|
-
const prompt = `Promote, hold, or reject this skill? [promote/hold/reject] (default: ${defaultChoice}) `;
|
|
2608
|
-
try {
|
|
2609
|
-
process.stdout.write(prompt);
|
|
2610
|
-
for await (const raw of rl) {
|
|
2611
|
-
const answer = raw.trim().toLowerCase() || defaultChoice;
|
|
2612
|
-
if (answer === "p" || answer === "promote") return "promote";
|
|
2613
|
-
if (answer === "h" || answer === "hold") return "hold";
|
|
2614
|
-
if (answer === "r" || answer === "reject") return "reject";
|
|
2615
|
-
process.stdout.write(`Please answer promote, hold, or reject (or p/h/r).
|
|
2616
|
-
${prompt}`);
|
|
2617
|
-
}
|
|
2618
|
-
return defaultChoice;
|
|
2619
|
-
} finally {
|
|
2620
|
-
rl.close();
|
|
2621
|
-
}
|
|
2622
|
-
}
|
|
2623
2633
|
|
|
2624
2634
|
// src/commands/scan.ts
|
|
2625
2635
|
async function scan(source, options = {}) {
|
|
@@ -2711,6 +2721,319 @@ async function status() {
|
|
|
2711
2721
|
console.log(` held in quarantine: ${heldCount}`);
|
|
2712
2722
|
}
|
|
2713
2723
|
|
|
2724
|
+
// src/evolve.ts
|
|
2725
|
+
import { createInterface as createInterface3 } from "readline/promises";
|
|
2726
|
+
import { createHash } from "crypto";
|
|
2727
|
+
import { cpSync as cpSync3, existsSync as existsSync14, lstatSync, mkdtempSync as mkdtempSync2, readFileSync as readFileSync10, rmSync as rmSync3 } from "fs";
|
|
2728
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
2729
|
+
import { basename as basename6, dirname as dirname5, join as join17, resolve as resolvePath2, sep } from "path";
|
|
2730
|
+
var SKILLOPT_BIN = "skillopt-sleep";
|
|
2731
|
+
var PRO_FEATURE_LABEL2 = "self-evolution via SkillOpt-Sleep (evolve) \u2014 re-gating, provenance, and the pending-ingestion queue";
|
|
2732
|
+
async function evolve(targetSkillDir, options = {}, deps = {}) {
|
|
2733
|
+
const exec = deps.exec ?? defaultExec;
|
|
2734
|
+
const json = options.json;
|
|
2735
|
+
const resolvedTargetDir = resolvePath2(targetSkillDir);
|
|
2736
|
+
const skillMdPath = join17(resolvedTargetDir, "SKILL.md");
|
|
2737
|
+
if (!existsSync14(skillMdPath)) {
|
|
2738
|
+
throw new UserError(`no SKILL.md found at ${skillMdPath} \u2014 evolve requires an existing, installed skill directory`);
|
|
2739
|
+
}
|
|
2740
|
+
const proStatus = proFeatureStatus(PRO_FEATURE_LABEL2);
|
|
2741
|
+
if (!proStatus.enabled) {
|
|
2742
|
+
throw new UserError(proStatus.notice ?? "evolve is a Pro feature \u2014 set SKILL_FORGE_LICENSE or config.licenseKey to a valid license to unlock.");
|
|
2743
|
+
}
|
|
2744
|
+
const notices = proStatus.notice ? [proStatus.notice] : [];
|
|
2745
|
+
const backend = options.backend ?? "mock";
|
|
2746
|
+
if (backend !== "mock") {
|
|
2747
|
+
const disclosure = `data boundary: this run uses the "${backend}" backend. Per SkillOpt-Sleep's own docs, harvesting itself is local and read-only, but a real backend sends truncated excerpts from harvested sessions and derived tasks to the provider you selected \u2014 this is not currently guaranteed to be secret-free.`;
|
|
2748
|
+
if (options.yes) {
|
|
2749
|
+
notices.push(`${disclosure} Proceeding (--yes).`);
|
|
2750
|
+
} else if (json) {
|
|
2751
|
+
throw new UserError(`${disclosure} Pass --yes to proceed \u2014 --json is non-interactive and cannot prompt for confirmation.`);
|
|
2752
|
+
} else {
|
|
2753
|
+
console.log(`[skill-forge] ${disclosure}`);
|
|
2754
|
+
const confirmFn = deps.confirm ?? defaultConfirm;
|
|
2755
|
+
const confirmed = await confirmFn("Proceed and send session data to this backend? [y/N] ");
|
|
2756
|
+
if (!confirmed) {
|
|
2757
|
+
throw new UserError("evolve cancelled \u2014 data-boundary disclosure was not accepted.");
|
|
2758
|
+
}
|
|
2759
|
+
notices.push(`${disclosure} Confirmed interactively.`);
|
|
2760
|
+
}
|
|
2761
|
+
} else {
|
|
2762
|
+
notices.push('using the offline "mock" backend \u2014 deterministic, no network calls, no session data leaves this machine.');
|
|
2763
|
+
}
|
|
2764
|
+
const project = resolvePath2(options.project ?? process.cwd());
|
|
2765
|
+
const present = await detectSkillopt(exec, project);
|
|
2766
|
+
if (!present) {
|
|
2767
|
+
throw new UserError(
|
|
2768
|
+
"`skillopt-sleep` not found on PATH. Install it with `pip install skillopt` (https://github.com/microsoft/SkillOpt) and re-run `skill-forge evolve`."
|
|
2769
|
+
);
|
|
2770
|
+
}
|
|
2771
|
+
const subcommand = options.dryRun ? "dry-run" : "run";
|
|
2772
|
+
const runArgs = [subcommand, "--project", project, "--target-skill-path", skillMdPath, "--json", "--backend", backend];
|
|
2773
|
+
if (options.lookbackHours !== void 0) {
|
|
2774
|
+
runArgs.push("--lookback-hours", String(options.lookbackHours));
|
|
2775
|
+
}
|
|
2776
|
+
const runResult = await exec(SKILLOPT_BIN, runArgs, { cwd: project });
|
|
2777
|
+
if (runResult.code !== 0) {
|
|
2778
|
+
throw new UserError(`skillopt-sleep ${subcommand} failed (exit ${runResult.code}): ${tail(runResult.stderr || runResult.stdout)}`);
|
|
2779
|
+
}
|
|
2780
|
+
let rawOutput;
|
|
2781
|
+
try {
|
|
2782
|
+
rawOutput = JSON.parse(runResult.stdout);
|
|
2783
|
+
} catch (err) {
|
|
2784
|
+
throw new UserError(`could not parse skillopt-sleep JSON output: ${err.message}`);
|
|
2785
|
+
}
|
|
2786
|
+
const parsed = validateRunOutput(rawOutput);
|
|
2787
|
+
if (!parsed.accepted || !parsed.staging_dir) {
|
|
2788
|
+
if (json) {
|
|
2789
|
+
console.log(JSON.stringify({ ok: true, accepted: parsed.accepted, stagingDir: parsed.staging_dir }, null, 2));
|
|
2790
|
+
} else {
|
|
2791
|
+
console.log("[skill-forge] skillopt-sleep produced no accepted proposal \u2014 nothing to re-gate or adopt.");
|
|
2792
|
+
}
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2795
|
+
const stagingRoot = join17(project, ".skillopt-sleep", "staging");
|
|
2796
|
+
const stagingDir = resolvePath2(parsed.staging_dir);
|
|
2797
|
+
if (stagingDir !== stagingRoot && !stagingDir.startsWith(stagingRoot + sep)) {
|
|
2798
|
+
throw new UserError(
|
|
2799
|
+
`skillopt-sleep reported a staging_dir outside the expected sandbox (${stagingRoot}): ${stagingDir} \u2014 refusing to use it`
|
|
2800
|
+
);
|
|
2801
|
+
}
|
|
2802
|
+
const proposedSkillMd = join17(stagingDir, "proposed_SKILL.md");
|
|
2803
|
+
const proposedClaudeMd = join17(stagingDir, "proposed_CLAUDE.md");
|
|
2804
|
+
const reportMdPath = join17(stagingDir, "report.md");
|
|
2805
|
+
if (!existsSync14(proposedSkillMd)) {
|
|
2806
|
+
throw new UserError(`skillopt-sleep reported an accepted proposal but ${proposedSkillMd} is missing`);
|
|
2807
|
+
}
|
|
2808
|
+
if (existsSync14(reportMdPath)) {
|
|
2809
|
+
notices.push(`SkillOpt-Sleep's full report: ${reportMdPath}`);
|
|
2810
|
+
}
|
|
2811
|
+
assertNotSymlink(proposedSkillMd, "proposed_SKILL.md");
|
|
2812
|
+
assertNotSymlink(proposedClaudeMd, "proposed_CLAUDE.md");
|
|
2813
|
+
const scannedSkillHash = sha256File(proposedSkillMd);
|
|
2814
|
+
const scannedClaudeHash = sha256File(proposedClaudeMd);
|
|
2815
|
+
const tempDir = mkdtempSync2(join17(tmpdir2(), "skill-forge-evolve-"));
|
|
2816
|
+
let profile;
|
|
2817
|
+
let safety;
|
|
2818
|
+
try {
|
|
2819
|
+
cpSync3(proposedSkillMd, join17(tempDir, "SKILL.md"));
|
|
2820
|
+
if (existsSync14(proposedClaudeMd)) {
|
|
2821
|
+
cpSync3(proposedClaudeMd, join17(tempDir, "CLAUDE.md"));
|
|
2822
|
+
}
|
|
2823
|
+
profile = asSkillProfile(profileSkill(tempDir), "for the evolved proposal");
|
|
2824
|
+
const strictness = loadConfig().strictness;
|
|
2825
|
+
safety = await scanSafety(tempDir, strictness);
|
|
2826
|
+
} finally {
|
|
2827
|
+
try {
|
|
2828
|
+
rmSync3(tempDir, { recursive: true, force: true });
|
|
2829
|
+
} catch (err) {
|
|
2830
|
+
console.error(`[skill-forge] failed to clean up temp re-gate dir ${tempDir}: ${err.message}`);
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
const safetyVerdict = safety.verdict;
|
|
2834
|
+
const gateResult = {
|
|
2835
|
+
profile,
|
|
2836
|
+
safety: {
|
|
2837
|
+
verdict: SAFETY_VERDICT_MAP[safetyVerdict],
|
|
2838
|
+
findings: mapSafetyFindings(safety.findings)
|
|
2839
|
+
},
|
|
2840
|
+
notices
|
|
2841
|
+
};
|
|
2842
|
+
printGateOutput(gateResult, json);
|
|
2843
|
+
if (options.dryRun) {
|
|
2844
|
+
logOutcome("[skill-forge] --dry-run: report only, nothing staged or adopted.", json);
|
|
2845
|
+
return;
|
|
2846
|
+
}
|
|
2847
|
+
const decision = options.yes || json ? yesDecision(safetyVerdict) : await promptDecision(safetyVerdict, "this evolution proposal");
|
|
2848
|
+
if (decision === "promote") {
|
|
2849
|
+
const manifest = readManifest(stagingDir);
|
|
2850
|
+
if (isAlreadyAdopted(stagingDir, manifest) && !options.force) {
|
|
2851
|
+
throw new UserError(
|
|
2852
|
+
`staging dir ${stagingDir} looks already adopted \u2014 its proposed content is byte-identical to the live skill (and memory, if present). Pass --force to re-adopt anyway.`
|
|
2853
|
+
);
|
|
2854
|
+
}
|
|
2855
|
+
if (sha256File(proposedSkillMd) !== scannedSkillHash || sha256File(proposedClaudeMd) !== scannedClaudeHash) {
|
|
2856
|
+
throw new UserError(`proposal changed after it was re-gated (staging dir: ${stagingDir}) \u2014 refusing to adopt`);
|
|
2857
|
+
}
|
|
2858
|
+
const adoptResult = await exec(SKILLOPT_BIN, ["adopt", "--staging", stagingDir, "--project", project], { cwd: project });
|
|
2859
|
+
if (adoptResult.code !== 0) {
|
|
2860
|
+
throw new UserError(`skillopt-sleep adopt failed (exit ${adoptResult.code}): ${tail(adoptResult.stderr || adoptResult.stdout)}`);
|
|
2861
|
+
}
|
|
2862
|
+
logOutcome(`[skill-forge] adopted -> ${resolvedTargetDir}`, json);
|
|
2863
|
+
const skillsRootForProvenance = dirname5(resolvedTargetDir);
|
|
2864
|
+
let ledgerPath;
|
|
2865
|
+
try {
|
|
2866
|
+
ledgerPath = appendProvenance(
|
|
2867
|
+
{
|
|
2868
|
+
name: profile.name ?? basename6(resolvedTargetDir),
|
|
2869
|
+
source: resolvedTargetDir,
|
|
2870
|
+
version: profile.version,
|
|
2871
|
+
license: profile.license,
|
|
2872
|
+
verb: "DEFER",
|
|
2873
|
+
target: resolvedTargetDir,
|
|
2874
|
+
took: `self-evolved via SkillOpt-Sleep (staging: ${stagingDir})`,
|
|
2875
|
+
notes: `self-evolved via SkillOpt-Sleep; re-gated (verdict: ${safetyVerdict})`
|
|
2876
|
+
},
|
|
2877
|
+
skillsRootForProvenance
|
|
2878
|
+
);
|
|
2879
|
+
} catch (err) {
|
|
2880
|
+
throw new UserError(
|
|
2881
|
+
`adopt succeeded and the live skill at ${resolvedTargetDir} has already changed, but recording the provenance entry failed: ${err.message}`
|
|
2882
|
+
);
|
|
2883
|
+
}
|
|
2884
|
+
logOutcome(`[skill-forge] provenance recorded -> ${ledgerPath}`, json);
|
|
2885
|
+
try {
|
|
2886
|
+
appendQueueEntry({
|
|
2887
|
+
source: resolvedTargetDir,
|
|
2888
|
+
sourceType: "local",
|
|
2889
|
+
installedPath: resolvedTargetDir,
|
|
2890
|
+
quarantinePath: stagingDir,
|
|
2891
|
+
gate: {
|
|
2892
|
+
license: profile.license ?? null,
|
|
2893
|
+
safetyVerdict,
|
|
2894
|
+
safetyFindings: safety.findings.map(formatSafetyFinding),
|
|
2895
|
+
overlapTop: [],
|
|
2896
|
+
suggestedVerb: null
|
|
2897
|
+
},
|
|
2898
|
+
status: "pending",
|
|
2899
|
+
artifactType: "skill",
|
|
2900
|
+
origin: "evolve"
|
|
2901
|
+
});
|
|
2902
|
+
} catch (err) {
|
|
2903
|
+
throw new UserError(
|
|
2904
|
+
`adopt succeeded and the live skill at ${resolvedTargetDir} has already changed (provenance was recorded), but appending the pending-ingestion queue entry failed: ${err.message}`
|
|
2905
|
+
);
|
|
2906
|
+
}
|
|
2907
|
+
logOutcome("[skill-forge] queued for ingestion review", json);
|
|
2908
|
+
} else if (decision === "reject") {
|
|
2909
|
+
try {
|
|
2910
|
+
rmSync3(stagingDir, { recursive: true, force: true });
|
|
2911
|
+
} catch (err) {
|
|
2912
|
+
throw new UserError(
|
|
2913
|
+
`rejected the proposal, but removing the staging dir ${stagingDir} failed: ${err.message} \u2014 remove it manually.`
|
|
2914
|
+
);
|
|
2915
|
+
}
|
|
2916
|
+
logOutcome("[skill-forge] rejected \u2014 staging dir removed", json);
|
|
2917
|
+
if (safetyVerdict === "block") process.exitCode = 1;
|
|
2918
|
+
} else {
|
|
2919
|
+
logOutcome(`[skill-forge] held \u2014 staging dir left in place -> ${stagingDir}`, json);
|
|
2920
|
+
if (existsSync14(reportMdPath)) {
|
|
2921
|
+
logOutcome(`[skill-forge] see ${reportMdPath} for the full evidence report`, json);
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
async function defaultConfirm(question) {
|
|
2926
|
+
const rl = createInterface3({ input: process.stdin, output: process.stdout });
|
|
2927
|
+
try {
|
|
2928
|
+
process.stdout.write(question);
|
|
2929
|
+
for await (const raw of rl) {
|
|
2930
|
+
return /^y(es)?$/i.test(raw.trim());
|
|
2931
|
+
}
|
|
2932
|
+
return false;
|
|
2933
|
+
} finally {
|
|
2934
|
+
rl.close();
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
async function detectSkillopt(exec, cwd) {
|
|
2938
|
+
try {
|
|
2939
|
+
const result = await exec(SKILLOPT_BIN, ["--help"], { cwd });
|
|
2940
|
+
return result.code === 0;
|
|
2941
|
+
} catch {
|
|
2942
|
+
return false;
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
function tail(text, maxLines = 20) {
|
|
2946
|
+
return text.trim().split("\n").slice(-maxLines).join("\n");
|
|
2947
|
+
}
|
|
2948
|
+
function validateRunOutput(raw) {
|
|
2949
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
2950
|
+
throw new UserError(`skillopt-sleep produced unexpected JSON output (expected an object): ${JSON.stringify(raw)}`);
|
|
2951
|
+
}
|
|
2952
|
+
const obj = raw;
|
|
2953
|
+
const stagingDirRaw = obj.staging_dir;
|
|
2954
|
+
if (stagingDirRaw !== null && stagingDirRaw !== void 0 && typeof stagingDirRaw !== "string") {
|
|
2955
|
+
throw new UserError(
|
|
2956
|
+
`skillopt-sleep produced an unexpected "staging_dir" (expected a string or null): ${JSON.stringify(stagingDirRaw)}`
|
|
2957
|
+
);
|
|
2958
|
+
}
|
|
2959
|
+
return { accepted: Boolean(obj.accepted), staging_dir: stagingDirRaw ?? null };
|
|
2960
|
+
}
|
|
2961
|
+
function readManifest(stagingDir) {
|
|
2962
|
+
const manifestPath = join17(stagingDir, "manifest.json");
|
|
2963
|
+
let raw;
|
|
2964
|
+
try {
|
|
2965
|
+
raw = readFileSync10(manifestPath, "utf-8");
|
|
2966
|
+
} catch (err) {
|
|
2967
|
+
throw new UserError(`could not read staging manifest ${manifestPath}: ${err.message}`);
|
|
2968
|
+
}
|
|
2969
|
+
let parsed;
|
|
2970
|
+
try {
|
|
2971
|
+
parsed = JSON.parse(raw);
|
|
2972
|
+
} catch (err) {
|
|
2973
|
+
throw new UserError(`could not parse staging manifest ${manifestPath}: ${err.message}`);
|
|
2974
|
+
}
|
|
2975
|
+
return validateManifest(parsed, manifestPath);
|
|
2976
|
+
}
|
|
2977
|
+
function validateManifest(raw, manifestPath) {
|
|
2978
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
2979
|
+
throw new UserError(`staging manifest ${manifestPath} has an unexpected shape (expected an object)`);
|
|
2980
|
+
}
|
|
2981
|
+
const obj = raw;
|
|
2982
|
+
if (typeof obj.live_skill_path !== "string") {
|
|
2983
|
+
throw new UserError(`staging manifest ${manifestPath} is missing a valid "live_skill_path" string`);
|
|
2984
|
+
}
|
|
2985
|
+
if (obj.live_memory_path !== void 0 && obj.live_memory_path !== null && typeof obj.live_memory_path !== "string") {
|
|
2986
|
+
throw new UserError(`staging manifest ${manifestPath} has an unexpected "live_memory_path" (expected a string or null)`);
|
|
2987
|
+
}
|
|
2988
|
+
if (typeof obj.has_skill !== "boolean" || typeof obj.has_memory !== "boolean") {
|
|
2989
|
+
throw new UserError(`staging manifest ${manifestPath} is missing valid "has_skill"/"has_memory" booleans`);
|
|
2990
|
+
}
|
|
2991
|
+
return {
|
|
2992
|
+
live_skill_path: obj.live_skill_path,
|
|
2993
|
+
live_memory_path: obj.live_memory_path ?? null,
|
|
2994
|
+
has_skill: obj.has_skill,
|
|
2995
|
+
has_memory: obj.has_memory
|
|
2996
|
+
};
|
|
2997
|
+
}
|
|
2998
|
+
function filesIdentical(a, b) {
|
|
2999
|
+
if (!existsSync14(a) || !existsSync14(b)) return false;
|
|
3000
|
+
return readFileSync10(a).equals(readFileSync10(b));
|
|
3001
|
+
}
|
|
3002
|
+
function isAlreadyAdopted(stagingDir, manifest) {
|
|
3003
|
+
const proposedSkillMd = join17(stagingDir, "proposed_SKILL.md");
|
|
3004
|
+
const proposedClaudeMd = join17(stagingDir, "proposed_CLAUDE.md");
|
|
3005
|
+
let sawComparison = false;
|
|
3006
|
+
if (existsSync14(proposedSkillMd)) {
|
|
3007
|
+
sawComparison = true;
|
|
3008
|
+
if (!filesIdentical(proposedSkillMd, manifest.live_skill_path)) return false;
|
|
3009
|
+
}
|
|
3010
|
+
if (existsSync14(proposedClaudeMd) && manifest.live_memory_path) {
|
|
3011
|
+
sawComparison = true;
|
|
3012
|
+
if (!filesIdentical(proposedClaudeMd, manifest.live_memory_path)) return false;
|
|
3013
|
+
}
|
|
3014
|
+
return sawComparison;
|
|
3015
|
+
}
|
|
3016
|
+
function assertNotSymlink(path, label) {
|
|
3017
|
+
let stat;
|
|
3018
|
+
try {
|
|
3019
|
+
stat = lstatSync(path);
|
|
3020
|
+
} catch {
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
if (stat.isSymbolicLink()) {
|
|
3024
|
+
throw new UserError(`refusing to re-gate ${label}: ${path} is a symlink (a staged proposal must be a plain file)`);
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
function sha256File(path) {
|
|
3028
|
+
if (!existsSync14(path)) return null;
|
|
3029
|
+
return createHash("sha256").update(readFileSync10(path)).digest("hex");
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
// src/commands/evolve.ts
|
|
3033
|
+
async function evolveCommand(targetSkillDir, options = {}) {
|
|
3034
|
+
await evolve(targetSkillDir, options);
|
|
3035
|
+
}
|
|
3036
|
+
|
|
2714
3037
|
// src/cli.ts
|
|
2715
3038
|
var require2 = createRequire(import.meta.url);
|
|
2716
3039
|
var pkg = require2("../package.json");
|
|
@@ -2720,6 +3043,13 @@ function parseArtifact(value) {
|
|
|
2720
3043
|
}
|
|
2721
3044
|
return value;
|
|
2722
3045
|
}
|
|
3046
|
+
function parseLookbackHours(value) {
|
|
3047
|
+
const n = Number(value);
|
|
3048
|
+
if (value.trim() === "" || !Number.isFinite(n) || n < 0) {
|
|
3049
|
+
throw new UserError(`--lookback-hours must be a non-negative number (got "${value}")`);
|
|
3050
|
+
}
|
|
3051
|
+
return n;
|
|
3052
|
+
}
|
|
2723
3053
|
var program = new Command();
|
|
2724
3054
|
program.name("skill-forge").description("The supply-chain gate for agent skills").version(pkg.version);
|
|
2725
3055
|
program.command("add <source>").description("Quarantine-install a skill and run it through the safety gate").option("-y, --yes", "skip the interactive prompt; honor the gate verdict automatically").option("-t, --target <dir>", "skills root to promote into (defaults to config defaultTarget, then skillsRoots[0])").option("--json", "print the gate result as JSON instead of the terminal report").option("--ingest", "after a successful promote, hand off the bundled ingest prompt to a coding agent").option("--artifact <type>", 'gate as a "skill" or an "mcp" server (default: skill)', parseArtifact).option(
|
|
@@ -2729,6 +3059,7 @@ program.command("add <source>").description("Quarantine-install a skill and run
|
|
|
2729
3059
|
program.command("scan <source>").description("Run the safety gate against a skill without installing it").option("--json", "print the gate result as JSON instead of the terminal report").option("--artifact <type>", 'gate as a "skill" or an "mcp" server (default: skill)', parseArtifact).action(scan);
|
|
2730
3060
|
program.command("list").description("List skills currently in quarantine").action(list);
|
|
2731
3061
|
program.command("status").description("Show skill-forge configuration and quarantine status").action(status);
|
|
3062
|
+
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);
|
|
2732
3063
|
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);
|
|
2733
3064
|
try {
|
|
2734
3065
|
await program.parseAsync();
|