@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 CHANGED
@@ -18,6 +18,7 @@ Pro Modules
18
18
  - src/gate/mcpOverlap.ts
19
19
  - src/provenance.ts
20
20
  - src/queue.ts
21
+ - src/evolve.ts
21
22
 
22
23
  ...and the portions of built artifacts (e.g. dist/cli.js in the published npm
23
24
  package) generated from these files. Each Pro Module carries a header
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] 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 list List skills currently held in quarantine
72
- skill-forge status Show configuration and quarantine summary
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, sep, maxSplit) {
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(sep);
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 + sep.length);
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
- function asProfile(candidatePath, artifact) {
1834
- const profile = profileSkill(candidatePath);
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.map((f) => ({
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/commands/add.ts
2433
- var PRO_FEATURE_LABEL = "overlap analysis, the provenance ledger, and the pending-ingestion queue";
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((f) => `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`),
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((f) => `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`),
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();