skill-probe 0.8.0 → 0.10.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 +173 -38
- package/assets/skill-probe-thief-demo.gif +0 -0
- package/assets/skill-probe-thief-demo.png +0 -0
- package/dist/adapters/claudeCode.d.ts +17 -1
- package/dist/adapters/claudeCode.js +49 -45
- package/dist/adapters/openCode.d.ts +14 -1
- package/dist/adapters/openCode.js +64 -47
- package/dist/baseline.d.ts +56 -0
- package/dist/baseline.js +121 -0
- package/dist/cli.js +99 -37
- package/dist/config.d.ts +4 -0
- package/dist/config.js +37 -0
- package/dist/context.d.ts +32 -9
- package/dist/context.js +124 -95
- package/dist/diagnose.d.ts +14 -5
- package/dist/diagnose.js +45 -24
- package/dist/doctor.d.ts +2 -1
- package/dist/doctor.js +72 -38
- package/dist/eval.d.ts +20 -5
- package/dist/eval.js +56 -21
- package/dist/fix.d.ts +9 -6
- package/dist/fix.js +82 -70
- package/dist/gen.d.ts +6 -5
- package/dist/gen.js +1 -21
- package/dist/llm.d.ts +2 -0
- package/dist/llm.js +23 -23
- package/dist/manifest.d.ts +40 -0
- package/dist/manifest.js +73 -0
- package/dist/orchestrator.d.ts +47 -5
- package/dist/orchestrator.js +82 -13
- package/dist/proc.d.ts +24 -0
- package/dist/proc.js +101 -0
- package/dist/report.d.ts +7 -2
- package/dist/report.js +121 -24
- package/dist/sandbox.d.ts +18 -0
- package/dist/sandbox.js +98 -0
- package/dist/skills.d.ts +48 -0
- package/dist/skills.js +282 -0
- package/dist/stats.d.ts +27 -5
- package/dist/stats.js +132 -8
- package/dist/types.d.ts +31 -1
- package/dist/types.js +9 -1
- package/package.json +1 -1
package/dist/report.js
CHANGED
|
@@ -1,24 +1,47 @@
|
|
|
1
1
|
/** Human table + machine JSON rendering. Every number ships with its CI and k; infrastructure
|
|
2
2
|
* errors are shown distinctly from behavioral results. */
|
|
3
|
-
import {
|
|
3
|
+
import { kToCertify } from "./stats.js";
|
|
4
|
+
export { kToCertify };
|
|
4
5
|
const pct = (x) => `${Math.round(x * 100)}%`;
|
|
5
6
|
const pval = (p) => (p < 0.001 ? "<0.001" : p.toFixed(3));
|
|
6
7
|
const LABEL = {
|
|
7
8
|
pass: "PASS ", fail: "FAIL ",
|
|
8
9
|
inconclusive: "INCONCLUSIVE", error: "ERROR ",
|
|
9
10
|
};
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
/** One line describing what the runtime actually ran with, when it told us. */
|
|
12
|
+
function environmentLine(m) {
|
|
13
|
+
const e = m?.environment;
|
|
14
|
+
if (!e)
|
|
15
|
+
return null;
|
|
16
|
+
const bits = [];
|
|
17
|
+
if (e.runtimeVersion)
|
|
18
|
+
bits.push(`runtime ${e.runtimeVersion}`);
|
|
19
|
+
if (e.resolvedModel && m.model === "(runtime default)")
|
|
20
|
+
bits.push(`model resolved to ${e.resolvedModel}`);
|
|
21
|
+
if (e.extraSkills) {
|
|
22
|
+
bits.push(e.extraSkills.length
|
|
23
|
+
? `${e.extraSkills.length} skill(s) outside the library also loaded (${e.extraSkills.slice(0, 6).join(", ")}${e.extraSkills.length > 6 ? ", …" : ""})`
|
|
24
|
+
: "no skills outside the library loaded");
|
|
15
25
|
}
|
|
16
|
-
|
|
26
|
+
bits.push(e.userConfig ? "personal runtime config INCLUDED"
|
|
27
|
+
: e.hermetic ? "hermetic (personal runtime config excluded)"
|
|
28
|
+
: "personal runtime config NOT isolated (this runtime can't exclude it)");
|
|
29
|
+
return ` ${bits.join(" · ")}`;
|
|
30
|
+
}
|
|
31
|
+
function theftLine(c) {
|
|
32
|
+
const counts = c.theft.map((s) => `${s} ${c.stats.dist[s]}/${c.stats.n}`).join(", ");
|
|
33
|
+
return ` ⚠ trigger-theft by: ${counts}` +
|
|
34
|
+
(c.verdict === "pass" ? " (reliability still clears the bar; --fail-on-theft makes this fail)" : "");
|
|
17
35
|
}
|
|
18
36
|
export function renderTable(a, opts = {}) {
|
|
19
37
|
const showCost = opts.showCost ?? true;
|
|
20
38
|
const out = [];
|
|
21
39
|
out.push(`skill-probe — runtime: ${a.runtime} model: ${a.model} threshold: ${pct(a.threshold)}`);
|
|
40
|
+
const env = environmentLine(a.manifest);
|
|
41
|
+
if (env)
|
|
42
|
+
out.push(env);
|
|
43
|
+
for (const w of a.warnings ?? [])
|
|
44
|
+
out.push(` ⚠ ${w}`);
|
|
22
45
|
out.push("");
|
|
23
46
|
for (const c of a.cases) {
|
|
24
47
|
const r = c.reliability;
|
|
@@ -35,11 +58,17 @@ export function renderTable(a, opts = {}) {
|
|
|
35
58
|
const dist = Object.entries(c.stats.dist)
|
|
36
59
|
.sort((p, q) => q[1] - p[1]).map(([s, n]) => `${s}×${n}`).join(", ");
|
|
37
60
|
out.push(` outcomes: ${dist}`);
|
|
61
|
+
if (c.notLoaded)
|
|
62
|
+
out.push(` ⛔ the runtime never loaded "${c.expected}" — it cannot fire (check its SKILL.md)`);
|
|
38
63
|
if (c.theft.length)
|
|
39
|
-
out.push(
|
|
64
|
+
out.push(theftLine(c));
|
|
65
|
+
if (c.expected !== null && c.stats.preempted > 0 && c.verdict !== "pass") {
|
|
66
|
+
out.push(` ⓘ ${c.stats.preempted}/${c.stats.n} probes called another tool first and loaded no skill — ` +
|
|
67
|
+
`activation may be under-counted (try --max-turns 2)`);
|
|
68
|
+
}
|
|
40
69
|
if (c.verdict === "inconclusive") {
|
|
41
|
-
out.push(` ↳
|
|
42
|
-
`
|
|
70
|
+
out.push(` ↳ not statistically decided at k=${r.k} — certifying ≥${pct(a.threshold)} needs ` +
|
|
71
|
+
`k≥${kToCertify(a.threshold, r.conf)} with every run firing (more if some miss)`);
|
|
43
72
|
}
|
|
44
73
|
}
|
|
45
74
|
out.push("");
|
|
@@ -57,6 +86,11 @@ export function renderMarkdown(a, opts = {}) {
|
|
|
57
86
|
const out = [];
|
|
58
87
|
const conf = a.cases[0]?.reliability.conf ?? 0.95;
|
|
59
88
|
out.push(`### skill-probe — \`${a.runtime}\` · model \`${a.model}\` · threshold ${pct(a.threshold)}`);
|
|
89
|
+
const env = environmentLine(a.manifest);
|
|
90
|
+
if (env)
|
|
91
|
+
out.push("", `_${env.trim()}_`);
|
|
92
|
+
for (const w of a.warnings ?? [])
|
|
93
|
+
out.push("", `> ⚠ ${w}`);
|
|
60
94
|
out.push("");
|
|
61
95
|
out.push(`| Verdict | Skill | Reliability (${pct(conf)} CI) | k | Notes |`);
|
|
62
96
|
out.push("|---|---|---|---|---|");
|
|
@@ -66,10 +100,12 @@ export function renderMarkdown(a, opts = {}) {
|
|
|
66
100
|
let notes = "";
|
|
67
101
|
if (c.verdict === "error")
|
|
68
102
|
notes = `infra error: ${c.stats.lastError ?? "untrustworthy"}`;
|
|
103
|
+
else if (c.notLoaded)
|
|
104
|
+
notes = `⛔ runtime never loaded \`${c.expected}\``;
|
|
69
105
|
else if (c.theft.length)
|
|
70
|
-
notes = `⚠ trigger-theft by ${c.theft.join(", ")}`;
|
|
106
|
+
notes = `⚠ trigger-theft by ${c.theft.map((s) => `${s} ${c.stats.dist[s]}/${c.stats.n}`).join(", ")}`;
|
|
71
107
|
else if (c.verdict === "inconclusive")
|
|
72
|
-
notes = `need
|
|
108
|
+
notes = `need k≥${kToCertify(a.threshold, r.conf)} to certify`;
|
|
73
109
|
const rel = c.verdict === "error" ? "—" : `${pct(r.pHat)} [${pct(r.ciLow)}, ${pct(r.ciHigh)}]`;
|
|
74
110
|
out.push(`| ${mark[c.verdict]} | \`${expect}\` | ${rel} | ${r.k} | ${notes} |`);
|
|
75
111
|
}
|
|
@@ -91,6 +127,11 @@ export function renderDiagnose(d, opts = {}) {
|
|
|
91
127
|
const out = [];
|
|
92
128
|
out.push(`skill-probe diagnose — runtime: ${d.runtime} model: ${d.model} threshold: ${pct(d.threshold)}`);
|
|
93
129
|
out.push(` actual = real activation · intended = forced-choice routing (does the model even pick it?)`);
|
|
130
|
+
const env = environmentLine(d.manifest);
|
|
131
|
+
if (env)
|
|
132
|
+
out.push(env);
|
|
133
|
+
for (const w of d.warnings ?? [])
|
|
134
|
+
out.push(` ⚠ ${w}`);
|
|
94
135
|
out.push("");
|
|
95
136
|
const dist = (m) => Object.entries(m).sort((p, q) => q[1] - p[1]).map(([s, n]) => `${s}×${n}`).join(", ") || "(none)";
|
|
96
137
|
for (const c of d.cases) {
|
|
@@ -131,6 +172,12 @@ export function renderContext(a, opts = {}) {
|
|
|
131
172
|
out.push(`skill-probe context — runtime: ${a.runtime} model: ${a.model} ` +
|
|
132
173
|
`threshold: ${pct(a.threshold)} library: ${a.librarySize} skills`);
|
|
133
174
|
out.push(` isolation = only that skill loaded · co-loaded = the full library (${a.librarySize}) loaded`);
|
|
175
|
+
out.push(` every condition runs in an identical sandbox (skills + project CLAUDE.md/AGENTS.md) — only the skill set differs`);
|
|
176
|
+
const env = environmentLine(a.manifest);
|
|
177
|
+
if (env)
|
|
178
|
+
out.push(env);
|
|
179
|
+
for (const w of a.warnings ?? [])
|
|
180
|
+
out.push(` ⚠ ${w}`);
|
|
134
181
|
const familyN = a.cases.filter((c) => !c.untrustworthy).length;
|
|
135
182
|
if (familyN > 1) {
|
|
136
183
|
out.push(` ${familyN} comparisons — interference decided on Benjamini-Hochberg adjusted p<0.05` +
|
|
@@ -141,6 +188,11 @@ export function renderContext(a, opts = {}) {
|
|
|
141
188
|
for (const c of a.cases) {
|
|
142
189
|
if (c.untrustworthy) {
|
|
143
190
|
out.push(` [⛔ ERROR] ${c.expected} | ${c.prompt}`);
|
|
191
|
+
if (c.loadIssue) {
|
|
192
|
+
out.push(` the runtime loaded the wrong skill set — comparison NOT trustworthy`);
|
|
193
|
+
out.push(` ${c.loadIssue}`);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
144
196
|
out.push(` infrastructure errors dominated — comparison NOT trustworthy ` +
|
|
145
197
|
`(isolation ${c.isolation.stats.errors} err/${c.isolation.stats.n} valid, ` +
|
|
146
198
|
`co-loaded ${c.coLoaded.stats.errors} err/${c.coLoaded.stats.n} valid)`);
|
|
@@ -156,12 +208,18 @@ export function renderContext(a, opts = {}) {
|
|
|
156
208
|
out.push(` Δ ${c.deltaP >= 0 ? "+" : ""}${pct(c.deltaP)} under load Fisher p=${pval(c.fisherP)}` +
|
|
157
209
|
(familyN > 1 ? ` · BH-adj p=${pval(c.fisherPAdj)}` : ""));
|
|
158
210
|
if (c.interference) {
|
|
211
|
+
const external = new Set(c.externalThieves ?? []);
|
|
159
212
|
const thieves = Object.keys(c.coLoaded.stats.dist)
|
|
160
|
-
.filter((s) => s !== c.expected && s !== "None")
|
|
213
|
+
.filter((s) => s !== c.expected && s !== "None")
|
|
214
|
+
.map((s) => (external.has(s) ? `${s} (outside the library)` : s));
|
|
161
215
|
out.push(` ↳ fires reliably alone but is suppressed when the library is co-loaded` +
|
|
162
216
|
(thieves.length ? ` (stolen by: ${thieves.join(", ")})` : ""));
|
|
163
217
|
if (c.ablation) {
|
|
164
|
-
if (c.ablation.length === 0) {
|
|
218
|
+
if (c.ablation.length === 0 && external.size) {
|
|
219
|
+
out.push(` leave-one-out: only skills outside the library stole these probes ` +
|
|
220
|
+
`(${[...external].join(", ")}) — they can't be removed; narrow or rename "${c.expected}" to stop colliding`);
|
|
221
|
+
}
|
|
222
|
+
else if (c.ablation.length === 0) {
|
|
165
223
|
out.push(` leave-one-out: no sibling stole these probes (suppressed to None) — ` +
|
|
166
224
|
`dilution, not theft; nothing to ablate`);
|
|
167
225
|
}
|
|
@@ -169,7 +227,7 @@ export function renderContext(a, opts = {}) {
|
|
|
169
227
|
out.push(` leave-one-out (each suspect removed, recovery vs co-loaded):`);
|
|
170
228
|
for (const abl of c.ablation) {
|
|
171
229
|
if (abl.untrustworthy) {
|
|
172
|
-
out.push(` − ${abl.removed}: infra errors dominated — run not trustworthy`);
|
|
230
|
+
out.push(` − ${abl.removed}: ${abl.loadIssue ?? "infra errors dominated"} — run not trustworthy`);
|
|
173
231
|
continue;
|
|
174
232
|
}
|
|
175
233
|
const tag = abl.culprit
|
|
@@ -201,6 +259,39 @@ export function renderContext(a, opts = {}) {
|
|
|
201
259
|
(showCost ? ` | cost $${a.totalCost.toFixed(4)}` : ""));
|
|
202
260
|
return out.join("\n");
|
|
203
261
|
}
|
|
262
|
+
/** regression gate vs a saved baseline: only significant (BH-corrected) drops fail the build. */
|
|
263
|
+
export function renderBaseline(cmp) {
|
|
264
|
+
const out = [];
|
|
265
|
+
out.push(`Baseline gate — vs baseline saved ${cmp.baselineDate} (skill-probe ${cmp.baselineVersion})`);
|
|
266
|
+
for (const m of cmp.mismatches)
|
|
267
|
+
out.push(` ⚠ ${m}`);
|
|
268
|
+
for (const r of cmp.cases) {
|
|
269
|
+
if (r.untrustworthy) {
|
|
270
|
+
out.push(` [⛔ NOT COMPARABLE] ${r.expected ?? "(decoy)"} | ${r.prompt} — a side has no valid probes`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!r.regressed && !r.improved)
|
|
274
|
+
continue; // stable cases are summarized, not listed
|
|
275
|
+
const flag = r.regressed ? "▼ REGRESSED" : "▲ improved";
|
|
276
|
+
out.push(` [${flag}] ${r.expected ?? "(decoy)"} | ${r.prompt}`);
|
|
277
|
+
out.push(` ${pct(r.before.pHat)} (${r.before.hits}/${r.before.n}) → ${pct(r.after.pHat)} ` +
|
|
278
|
+
`(${r.after.hits}/${r.after.n}) Δ${r.delta >= 0 ? "+" : ""}${pct(r.delta)} ` +
|
|
279
|
+
`p=${pval(r.fisherP)} · adj ${pval(r.fisherPAdj)}`);
|
|
280
|
+
}
|
|
281
|
+
const extras = [];
|
|
282
|
+
if (cmp.newCases.length)
|
|
283
|
+
extras.push(`${cmp.newCases.length} new case(s) not in baseline (measured, not gated)`);
|
|
284
|
+
if (cmp.missingCases.length)
|
|
285
|
+
extras.push(`${cmp.missingCases.length} baseline case(s) missing from this run`);
|
|
286
|
+
if (extras.length)
|
|
287
|
+
out.push(` ${extras.join(" · ")}`);
|
|
288
|
+
out.push(cmp.regressions
|
|
289
|
+
? `Gate: ▼ FAIL — ${cmp.regressions} significant regression(s)` +
|
|
290
|
+
(cmp.improvements ? `, ${cmp.improvements} improvement(s)` : "")
|
|
291
|
+
: `Gate: ✅ no significant regressions (${cmp.cases.length} gated` +
|
|
292
|
+
(cmp.improvements ? `, ${cmp.improvements} improved — consider re-saving the baseline` : "") + `)`);
|
|
293
|
+
return out.join("\n");
|
|
294
|
+
}
|
|
204
295
|
export function renderFix(f) {
|
|
205
296
|
const out = [];
|
|
206
297
|
out.push(`skill-probe fix — skill: ${f.skill}`);
|
|
@@ -208,19 +299,25 @@ export function renderFix(f) {
|
|
|
208
299
|
out.push(` old: ${f.oldDesc}`);
|
|
209
300
|
out.push(` new: ${f.newDesc}`);
|
|
210
301
|
out.push("");
|
|
211
|
-
|
|
212
|
-
`
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
302
|
+
if (f.n > 0 || f.errors > 0) {
|
|
303
|
+
out.push(` before: ${pct(f.before.pHat)} [${pct(f.before.ciLow)}, ${pct(f.before.ciHigh)}] ` +
|
|
304
|
+
`after: ${pct(f.after.pHat)} [${pct(f.after.ciLow)}, ${pct(f.after.ciHigh)}] ` +
|
|
305
|
+
`(${f.n} paired runs${f.errors ? `, ${f.errors} dropped to infra errors` : ""})` +
|
|
306
|
+
(f.totalCost ? ` cost $${f.totalCost.toFixed(4)}` : ""));
|
|
307
|
+
out.push(` P(rewrite improved reliability) = ${pct(f.pImprove)} ` +
|
|
308
|
+
`Δ = ${f.deltaMean >= 0 ? "+" : ""}${pct(f.deltaMean)} [${pct(f.deltaCi[0])}, ${pct(f.deltaCi[1])}]`);
|
|
309
|
+
out.push("");
|
|
310
|
+
}
|
|
311
|
+
if (f.note) {
|
|
312
|
+
out.push(` ↩ NOT APPLIED — ${f.note}. Description unchanged.`);
|
|
313
|
+
}
|
|
314
|
+
else if (f.applied) {
|
|
218
315
|
out.push(` ✅ APPLIED — P(improvement) ${pct(f.pImprove)} ≥ bar ${pct(f.applyBar)} and Δ>0.`);
|
|
219
316
|
out.push(` original backed up to: ${f.backupPath}`);
|
|
220
317
|
}
|
|
221
318
|
else {
|
|
222
|
-
out.push(` ↩
|
|
223
|
-
`(need P(improvement) ≥ ${pct(f.applyBar)} and Δ>0). Description unchanged
|
|
319
|
+
out.push(` ↩ NOT APPLIED — not enough evidence the rewrite helps ` +
|
|
320
|
+
`(need P(improvement) ≥ ${pct(f.applyBar)} and Δ>0). Description unchanged.`);
|
|
224
321
|
}
|
|
225
322
|
return out.join("\n");
|
|
226
323
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Project instruction files copied into every sandbox, relative to the project root. */
|
|
2
|
+
export declare const INSTRUCTION_FILES: string[];
|
|
3
|
+
export interface Sandbox {
|
|
4
|
+
cwd: string;
|
|
5
|
+
skills: string[];
|
|
6
|
+
cleanup: () => void;
|
|
7
|
+
}
|
|
8
|
+
/** Claude Code creates `<config>/projects/<cwd with non-alphanumerics as "-">/memory/` for every
|
|
9
|
+
* working directory it starts in, even with --no-session-persistence. Each sandbox is a fresh path,
|
|
10
|
+
* so without this a big context/ablation run would leave dozens of empty dirs per run. Removes the
|
|
11
|
+
* dir for THIS sandbox path only, and only if it contains nothing but empty directories — it never
|
|
12
|
+
* deletes a file. Exported for testing. */
|
|
13
|
+
export declare function removeRuntimeStateFor(sandboxPath: string, configDir?: string): void;
|
|
14
|
+
/** Build a sandbox whose .claude/skills/ holds exactly `skillNames` (copied from srcCwd, with any
|
|
15
|
+
* supporting files) plus srcCwd's instruction files. Removed on cleanup() or on process exit. */
|
|
16
|
+
export declare function buildSandbox(srcCwd: string, skillNames: string[]): Sandbox;
|
|
17
|
+
/** Run `fn` inside a sandbox, always cleaning up. */
|
|
18
|
+
export declare function withSandbox<T>(srcCwd: string, skillNames: string[], fn: (sb: Sandbox) => Promise<T>): Promise<T>;
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** Probe sandboxes: throwaway project dirs holding a chosen subset of the library.
|
|
2
|
+
*
|
|
3
|
+
* Every condition that is COMPARED against another (context's isolation / co-loaded / ablation runs,
|
|
4
|
+
* fix's before / after) must be built the same way, differing ONLY in which skills are present.
|
|
5
|
+
* Comparing a sandbox against the real project dir confounds the skill set with everything else in
|
|
6
|
+
* the project (CLAUDE.md, settings, files): a drop caused by the project's CLAUDE.md would read as
|
|
7
|
+
* "interference", and every leave-one-out run would "recover" and name an innocent sibling.
|
|
8
|
+
*
|
|
9
|
+
* Each sandbox gets the project's instruction files (they shape routing, so they're held constant)
|
|
10
|
+
* but not its settings/hooks/MCP config (side effects; can't run safely from another directory). */
|
|
11
|
+
import { mkdtempSync, mkdirSync, cpSync, rmSync, existsSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
12
|
+
import { homedir, tmpdir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { skillsRoot } from "./skills.js";
|
|
15
|
+
import { trackTempDir, untrackTempDir } from "./proc.js";
|
|
16
|
+
/** Project instruction files copied into every sandbox, relative to the project root. */
|
|
17
|
+
export const INSTRUCTION_FILES = ["CLAUDE.md", "AGENTS.md", join(".claude", "CLAUDE.md")];
|
|
18
|
+
/** True if `dir` holds nothing but (recursively) empty directories. */
|
|
19
|
+
function onlyEmptyDirs(dir) {
|
|
20
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
21
|
+
if (!e.isDirectory() || !onlyEmptyDirs(join(dir, e.name)))
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
/** Claude Code creates `<config>/projects/<cwd with non-alphanumerics as "-">/memory/` for every
|
|
27
|
+
* working directory it starts in, even with --no-session-persistence. Each sandbox is a fresh path,
|
|
28
|
+
* so without this a big context/ablation run would leave dozens of empty dirs per run. Removes the
|
|
29
|
+
* dir for THIS sandbox path only, and only if it contains nothing but empty directories — it never
|
|
30
|
+
* deletes a file. Exported for testing. */
|
|
31
|
+
export function removeRuntimeStateFor(sandboxPath, configDir) {
|
|
32
|
+
const root = join(configDir ?? process.env["CLAUDE_CONFIG_DIR"] ?? join(homedir(), ".claude"), "projects");
|
|
33
|
+
const paths = new Set([sandboxPath]);
|
|
34
|
+
try {
|
|
35
|
+
paths.add(realpathSync(sandboxPath));
|
|
36
|
+
}
|
|
37
|
+
catch { /* already removed */ }
|
|
38
|
+
for (const p of paths) {
|
|
39
|
+
const dir = join(root, p.replace(/[^a-zA-Z0-9]/g, "-"));
|
|
40
|
+
try {
|
|
41
|
+
if (statSync(dir).isDirectory() && onlyEmptyDirs(dir))
|
|
42
|
+
rmSync(dir, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
catch { /* absent or unreadable — leave it */ }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Build a sandbox whose .claude/skills/ holds exactly `skillNames` (copied from srcCwd, with any
|
|
48
|
+
* supporting files) plus srcCwd's instruction files. Removed on cleanup() or on process exit. */
|
|
49
|
+
export function buildSandbox(srcCwd, skillNames) {
|
|
50
|
+
const root = mkdtempSync(join(tmpdir(), "skill-probe-"));
|
|
51
|
+
let real = root;
|
|
52
|
+
try {
|
|
53
|
+
real = realpathSync(root);
|
|
54
|
+
}
|
|
55
|
+
catch { /* keep root */ }
|
|
56
|
+
const forgetState = () => { removeRuntimeStateFor(root); if (real !== root)
|
|
57
|
+
removeRuntimeStateFor(real); };
|
|
58
|
+
trackTempDir(root, forgetState);
|
|
59
|
+
const cleanup = () => {
|
|
60
|
+
rmSync(root, { recursive: true, force: true });
|
|
61
|
+
forgetState();
|
|
62
|
+
untrackTempDir(root);
|
|
63
|
+
};
|
|
64
|
+
try {
|
|
65
|
+
const destSkills = skillsRoot(root);
|
|
66
|
+
mkdirSync(destSkills, { recursive: true });
|
|
67
|
+
for (const name of skillNames) {
|
|
68
|
+
const src = join(skillsRoot(srcCwd), name);
|
|
69
|
+
if (!existsSync(src))
|
|
70
|
+
throw new Error(`skill not found in ${srcCwd}: ${name}`);
|
|
71
|
+
// dereference: a symlinked SKILL.md (or skill folder) must become a real copy, or a write
|
|
72
|
+
// into the sandbox would land in the user's real file
|
|
73
|
+
cpSync(src, join(destSkills, name), { recursive: true, dereference: true });
|
|
74
|
+
}
|
|
75
|
+
for (const rel of INSTRUCTION_FILES) {
|
|
76
|
+
const src = join(srcCwd, rel);
|
|
77
|
+
if (!existsSync(src))
|
|
78
|
+
continue;
|
|
79
|
+
mkdirSync(dirname(join(root, rel)), { recursive: true });
|
|
80
|
+
cpSync(src, join(root, rel), { dereference: true });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
cleanup();
|
|
85
|
+
throw e;
|
|
86
|
+
}
|
|
87
|
+
return { cwd: root, skills: [...skillNames], cleanup };
|
|
88
|
+
}
|
|
89
|
+
/** Run `fn` inside a sandbox, always cleaning up. */
|
|
90
|
+
export async function withSandbox(srcCwd, skillNames, fn) {
|
|
91
|
+
const sb = buildSandbox(srcCwd, skillNames);
|
|
92
|
+
try {
|
|
93
|
+
return await fn(sb);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
sb.cleanup();
|
|
97
|
+
}
|
|
98
|
+
}
|
package/dist/skills.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface SkillInfo {
|
|
2
|
+
/** the skill's folder name under .claude/skills/ — its identity everywhere in skill-probe */
|
|
3
|
+
name: string;
|
|
4
|
+
/** description, whitespace-normalized to one line ("" if missing) */
|
|
5
|
+
description: string;
|
|
6
|
+
/** `name:` from the frontmatter, if present (may differ from the folder name) */
|
|
7
|
+
frontmatterName?: string;
|
|
8
|
+
}
|
|
9
|
+
interface FieldSpan {
|
|
10
|
+
key: string;
|
|
11
|
+
value: string;
|
|
12
|
+
start: number;
|
|
13
|
+
end: number;
|
|
14
|
+
/** an unterminated quoted scalar — the span's extent is unknowable, so it must not be rewritten */
|
|
15
|
+
broken?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface Frontmatter {
|
|
18
|
+
/** false when the file doesn't open with a `---` frontmatter block */
|
|
19
|
+
ok: boolean;
|
|
20
|
+
fields: Record<string, string>;
|
|
21
|
+
/** line spans of each top-level key's entry (for in-place rewriting) */
|
|
22
|
+
spans: FieldSpan[];
|
|
23
|
+
lines: string[];
|
|
24
|
+
/** index of the closing `---` line */
|
|
25
|
+
close: number;
|
|
26
|
+
eol: string;
|
|
27
|
+
/** offsets[i] = where line i starts in the BOM-stripped text; offsets[lines.length] = its length */
|
|
28
|
+
offsets: number[];
|
|
29
|
+
}
|
|
30
|
+
export declare function parseFrontmatter(text: string): Frontmatter;
|
|
31
|
+
/** One-line form of a description, as a router would read it. */
|
|
32
|
+
export declare function normalizeDescription(s: string): string;
|
|
33
|
+
/** Format a string as a YAML scalar for a single `key: value` line: plain when unambiguous,
|
|
34
|
+
* otherwise double-quoted (JSON string syntax is valid YAML). */
|
|
35
|
+
export declare function yamlScalar(s: string): string;
|
|
36
|
+
/** Return `text` with its frontmatter `description` replaced by `desc` (written on one line).
|
|
37
|
+
* Every other byte of the file — including each line's own line ending — is preserved. Throws if
|
|
38
|
+
* there is no frontmatter block or the existing description is an unterminated quoted string. */
|
|
39
|
+
export declare function replaceDescription(text: string, desc: string): string;
|
|
40
|
+
export declare function readSkillFile(file: string, folder: string): SkillInfo;
|
|
41
|
+
export declare function skillsRoot(cwd: string): string;
|
|
42
|
+
export declare function skillFile(cwd: string, name: string): string;
|
|
43
|
+
/** Every folder under <cwd>/.claude/skills/, following symlinks (a symlinked skill folder is a
|
|
44
|
+
* skill like any other), sorted. Throws if there is no skills dir. */
|
|
45
|
+
export declare function skillFolders(cwd: string): string[];
|
|
46
|
+
/** Every skill folder under <cwd>/.claude/skills/ that has a SKILL.md, sorted by name. */
|
|
47
|
+
export declare function listSkills(cwd: string): SkillInfo[];
|
|
48
|
+
export {};
|