skillgauntlet 0.0.0-stage → 1.1.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.
@@ -0,0 +1,174 @@
1
+ // Codewhale (formerly DeepSeek-TUI) as a trigger host.
2
+ //
3
+ // Its stream is one JSON object per line (`schema: codewhale.exec-stream`). A skill is read through a
4
+ // `load_skill` tool: `{"type":"tool_use","name":"load_skill","input":{"name":"<skill>"}}`, and the
5
+ // result starts with `# Skill: <skill>`. `input.query` is a search and `input.name == "list"` lists the
6
+ // catalogue; neither selects a skill. That differs from Claude Code, where the model picks from a
7
+ // fixed list, so a rate measured here is a Codewhale number and is never merged with another host's.
8
+ //
9
+ // Two facts from live runs shape the launch:
10
+ // * a skill in the workspace's own .agents/skills is loaded only once the workspace is trusted, which a
11
+ // tool-restricted exec run is not; a skill in the *global* root (<home>/.agents/skills) is loaded
12
+ // without trust. So the trial gets a throw-away home directory holding only the tested skill. That
13
+ // also keeps the user's own skills out of the catalogue.
14
+ // * `--auto` means full access and Windows has no execution sandbox, so the run is limited with
15
+ // `--allowed-tools load_skill`: the model can look a skill up and can do nothing else.
16
+ //
17
+ // Usage statistics are on by default in a fresh home; they are switched off in the throw-away home
18
+ // before the first request.
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { execFileSync, spawn } from 'node:child_process';
23
+ import { launchSpec } from './spawn.js';
24
+
25
+ function copyDir(src, dst) {
26
+ fs.mkdirSync(dst, { recursive: true });
27
+ for (const e of fs.readdirSync(src, { withFileTypes: true })) {
28
+ if (e.name === '.git') continue;
29
+ const s = path.join(src, e.name);
30
+ const d = path.join(dst, e.name);
31
+ if (e.isDirectory()) copyDir(s, d);
32
+ else if (e.isFile()) fs.copyFileSync(s, d);
33
+ }
34
+ }
35
+
36
+ function killTree(child) {
37
+ if (!child || child.killed) return;
38
+ try {
39
+ if (process.platform === 'win32') execFileSync('taskkill', ['/T', '/F', '/PID', String(child.pid)], { stdio: 'ignore' });
40
+ else child.kill('SIGKILL');
41
+ } catch { /* already gone */ }
42
+ }
43
+
44
+ // A throw-away home with only the tested skill in its global skills root, and an empty working directory.
45
+ export function isolatedHome(skillDir, skillName) {
46
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-cw-home-'));
47
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-cw-cwd-'));
48
+ if (skillDir) copyDir(skillDir, path.join(home, '.agents', 'skills', skillName));
49
+ return {
50
+ home,
51
+ cwd,
52
+ env: { ...process.env, HOME: home, USERPROFILE: home },
53
+ cleanup() {
54
+ for (const d of [home, cwd]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
55
+ },
56
+ };
57
+ }
58
+
59
+ function telemetryOff(bin, env) {
60
+ const launch = launchSpec(bin, ['config', 'set', 'telemetry', 'false'], 'codewhale');
61
+ try {
62
+ execFileSync(launch.cmd, launch.args, { env, stdio: 'ignore', timeout: 30000, shell: launch.shell, windowsHide: true });
63
+ } catch { /* the setting is a courtesy; a failure here must not hide the trial's own result */ }
64
+ }
65
+
66
+ // The verdict for one stream line, or null to keep reading.
67
+ export function judgeLine(ev, skillName, rec) {
68
+ if (ev.type === 'tool_use' && ev.name === 'load_skill') {
69
+ rec.tools.push('load_skill');
70
+ rec.firstTool = rec.firstTool || 'load_skill';
71
+ const name = typeof ev.input?.name === 'string' ? ev.input.name : '';
72
+ if (!name || name === 'list') return null; // a search or a catalogue listing selects nothing
73
+ if (name === skillName || name.endsWith(`:${skillName}`)) return { verdict: 'triggered', invokedSkill: name };
74
+ return { verdict: 'other_skill', invokedSkill: name };
75
+ }
76
+ if (ev.type === 'tool_use') {
77
+ rec.tools.push(ev.name);
78
+ rec.firstTool = rec.firstTool || ev.name;
79
+ return { verdict: 'not_triggered' };
80
+ }
81
+ if (ev.type === 'error') return { verdict: 'error', error: String(ev.error || 'error').slice(0, 500) };
82
+ if (ev.type === 'done') return { verdict: 'not_triggered' };
83
+ return null;
84
+ }
85
+
86
+ export function runCodewhaleTrial({ bin, skillDir, skillName, query, model, timeout = 180 }) {
87
+ return new Promise((resolve) => {
88
+ const iso = isolatedHome(skillDir, skillName);
89
+ telemetryOff(bin, iso.env);
90
+ const args = ['exec', '--allowed-tools', 'load_skill', '--output-format', 'stream-json'];
91
+ if (model) args.push('--model', model);
92
+ args.push('--', query);
93
+ const rec = { host: 'codewhale', verdict: null, invokedSkill: null, firstTool: null, tools: [], exploreSteps: 0, error: null,
94
+ events: { lines: 0, toolUse: 0, types: {} } };
95
+ const launch = launchSpec(bin, args, 'codewhale');
96
+ const child = spawn(launch.cmd, launch.args, {
97
+ cwd: iso.cwd, env: iso.env, shell: launch.shell, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
98
+ });
99
+ let timedOut = false;
100
+ const timer = setTimeout(() => { timedOut = true; killTree(child); }, timeout * 1000);
101
+ let stderr = '';
102
+ child.stderr.setEncoding('utf8');
103
+ child.stderr.on('data', (c) => { if (stderr.length < 4000) stderr += c; });
104
+ let buf = '';
105
+ let settled = false;
106
+ const finish = (verdict, extra = {}) => {
107
+ if (settled) return;
108
+ settled = true;
109
+ rec.verdict = verdict;
110
+ Object.assign(rec, extra);
111
+ killTree(child);
112
+ };
113
+ child.stdout.setEncoding('utf8');
114
+ child.stdout.on('data', (chunk) => {
115
+ buf += chunk;
116
+ let nl;
117
+ while ((nl = buf.indexOf('\n')) >= 0) {
118
+ const line = buf.slice(0, nl).trim();
119
+ buf = buf.slice(nl + 1);
120
+ if (!line) continue;
121
+ let ev;
122
+ try { ev = JSON.parse(line); } catch { continue; }
123
+ rec.events.lines += 1;
124
+ rec.events.types[ev.type] = (rec.events.types[ev.type] || 0) + 1;
125
+ if (ev.type === 'tool_use') rec.events.toolUse += 1;
126
+ if (ev.type === 'metadata') rec.modelResolved = ev.meta?.model;
127
+ const v = judgeLine(ev, skillName, rec);
128
+ if (v) { finish(v.verdict, v); return; }
129
+ }
130
+ });
131
+ child.on('error', (err) => { rec.error = String(err.message || err); finish('error'); });
132
+ child.on('close', () => {
133
+ clearTimeout(timer);
134
+ if (!settled) {
135
+ if (timedOut) finish('timeout', { error: `no verdict within ${timeout}s` });
136
+ else if (rec.error) finish('error');
137
+ else if (!rec.events.lines) {
138
+ finish('error', { error: (stderr.trim().split(/\r?\n/).filter(Boolean).slice(0, 2).join(' ') || 'no output from the CLI').slice(0, 500) });
139
+ } else finish('not_triggered');
140
+ }
141
+ iso.cleanup();
142
+ resolve(rec);
143
+ });
144
+ });
145
+ }
146
+
147
+ // One-shot answer with no tools: the text is the concatenated `content` events.
148
+ export function draftWithCodewhale({ bin, prompt, model, timeout }) {
149
+ const iso = isolatedHome(null, null);
150
+ telemetryOff(bin, iso.env);
151
+ const args = ['exec', '--output-format', 'stream-json'];
152
+ if (model) args.push('--model', model);
153
+ args.push('--', prompt);
154
+ const launch = launchSpec(bin, args, 'codewhale');
155
+ let out;
156
+ try {
157
+ out = execFileSync(launch.cmd, launch.args, {
158
+ encoding: 'utf8', timeout: timeout * 1000, maxBuffer: 32 * 1024 * 1024, cwd: iso.cwd, env: iso.env,
159
+ shell: launch.shell, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
160
+ });
161
+ } catch (err) {
162
+ // A rejected key still prints its reason as an `error` event on stdout.
163
+ const events = String(err.stdout || '').split(/\r?\n/).filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } });
164
+ const e = events.find((x) => x?.type === 'error');
165
+ const why = e ? String(e.error).split(/\r?\n/)[0] : (err.killed ? `no answer within ${timeout}s` : String(err.stderr || err.message).split(/\r?\n/).find(Boolean));
166
+ throw new Error(`the drafting call failed: ${why}`);
167
+ } finally {
168
+ iso.cleanup();
169
+ }
170
+ const events = out.split(/\r?\n/).filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
171
+ const err = events.find((x) => x.type === 'error');
172
+ if (err) throw new Error(`the drafting call failed: ${String(err.error).split(/\r?\n/)[0]}`);
173
+ return events.filter((x) => x.type === 'content').map((x) => x.content).join('');
174
+ }
package/lib/codex.js ADDED
@@ -0,0 +1,318 @@
1
+ // Codex host. What Codex exposes is narrower than Claude Code's, and the difference is structural,
2
+ // not a missing flag.
3
+ //
4
+ // Schema, confirmed against a live run on 2026-10-27 (codex-cli 0.159.2, ChatGPT account):
5
+ // events : thread.started / turn.started / turn.completed / turn.failed / item.started /
6
+ // item.updated / item.completed / error
7
+ // items : command_execution, mcp_tool_call, file_change, todo_list, agent_message, user_message,
8
+ // agent_reasoning, error
9
+ // usage : turn.completed carries input_tokens, cached_input_tokens, output_tokens
10
+ //
11
+ // There is no skill item. In Codex a skill is host-side context: it is selected and injected, not
12
+ // called as a tool, so "did the model choose this skill" has no observable answer. `skills_used`
13
+ // exists only in the telemetry profile struct (unique_skills_used, total_skills_used), not in the
14
+ // stream. Reporting a trigger rate here would mean inventing one.
15
+ //
16
+ // So this host supports what is observable: whether the agent acted, what it ran, what it changed,
17
+ // and therefore the paired with/without probe and the contract checks. Not a trigger rate.
18
+ //
19
+ // Two traps found by that run: with --sandbox read-only every exec_command is refused by policy, so
20
+ // a probe needs workspace-write; and without --ignore-user-config a stale model id in
21
+ // ~/.codex/config.toml fails every turn for reasons unrelated to the skill.
22
+ //
23
+ // A third trap, found by the probe itself: installing the skill into .codex/skills as well as
24
+ // .agents/skills made the skill-installed side report a read-only workspace while the clean side
25
+ // wrote files, which turned the comparison into a false positive. Only the documented path is used.
26
+ import { execFileSync, spawn } from 'node:child_process';
27
+ import fs from 'node:fs';
28
+ import os from 'node:os';
29
+ import path from 'node:path';
30
+ import { runnerFor, quoteForCmd } from './spawn.js';
31
+
32
+ const KNOWN_ITEMS = new Set([
33
+ 'command_execution', 'mcp_tool_call', 'file_change', 'todo_list',
34
+ 'agent_message', 'user_message', 'agent_reasoning', 'error',
35
+ ]);
36
+ const ACTION_ITEMS = new Set(['command_execution', 'mcp_tool_call', 'file_change', 'web_search']);
37
+
38
+ export function parseCodexJsonl(text) {
39
+ const events = [];
40
+ const items = [];
41
+ const unknownItemTypes = new Set();
42
+ const errors = [];
43
+ let tokenUsage = null;
44
+ let completed = false;
45
+ for (const line of text.split(/\r?\n/)) {
46
+ if (!line.trim()) continue;
47
+ let obj;
48
+ try { obj = JSON.parse(line); } catch { continue; }
49
+ const type = obj.type || obj.msg?.type || '';
50
+ events.push({ type, raw: obj });
51
+ if (type === 'turn.completed') completed = true;
52
+ if (type === 'turn.failed' || type === 'error') {
53
+ const msg = obj.error?.message || obj.message || obj.error || type;
54
+ errors.push(typeof msg === 'string' ? msg : JSON.stringify(msg));
55
+ }
56
+ if (type.startsWith('item.')) {
57
+ const item = obj.item || obj.msg?.item;
58
+ if (item && typeof item === 'object') {
59
+ items.push({ phase: type.slice(5), ...item });
60
+ if (item.type === 'error') errors.push(String(item.message || item.error || 'error item'));
61
+ if (item.type && !KNOWN_ITEMS.has(item.type)) unknownItemTypes.add(item.type);
62
+ }
63
+ }
64
+ if (type === 'turn.completed') tokenUsage = obj.usage || obj.token_usage || null;
65
+ }
66
+ return { events, items, unknownItemTypes: [...unknownItemTypes], errors, completed, tokenUsage };
67
+ }
68
+
69
+ function detailOf(item) {
70
+ switch (item.type) {
71
+ case 'command_execution':
72
+ return (item.command || item.cmd || item.args || []).toString().slice(0, 200);
73
+ case 'mcp_tool_call':
74
+ return [item.server, item.name || item.tool].filter(Boolean).join(':');
75
+ case 'file_change': {
76
+ const paths = item.paths || item.changes?.map?.((c) => c.path) || item.path || [];
77
+ return Array.isArray(paths) ? paths.join(', ') : String(paths);
78
+ }
79
+ default:
80
+ return '';
81
+ }
82
+ }
83
+
84
+ export function firstAction(items) {
85
+ const hit = items.find((i) => ACTION_ITEMS.has(i.type) && i.phase !== 'started') || items.find((i) => ACTION_ITEMS.has(i.type));
86
+ return hit ? { kind: hit.type, detail: detailOf(hit) } : null;
87
+ }
88
+
89
+ export function finalMessage(items) {
90
+ const msgs = items.filter((i) => i.type === 'agent_message' || i.type === 'assistant_message');
91
+ const last = msgs[msgs.length - 1];
92
+ return last ? String(last.message || last.text || last.content || '').slice(0, 2000) : '';
93
+ }
94
+
95
+ export function changedFiles(items) {
96
+ const out = new Set();
97
+ for (const i of items.filter((x) => x.type === 'file_change')) {
98
+ const paths = i.paths || i.path || [];
99
+ for (const p of Array.isArray(paths) ? paths : [paths]) if (p) out.add(String(p));
100
+ }
101
+ return [...out].sort();
102
+ }
103
+
104
+ function copyDir(src, dst) {
105
+ fs.mkdirSync(dst, { recursive: true });
106
+ for (const e of fs.readdirSync(src, { withFileTypes: true })) {
107
+ if (e.name === '.git') continue;
108
+ const s = path.join(src, e.name);
109
+ const d = path.join(dst, e.name);
110
+ if (e.isDirectory()) copyDir(s, d);
111
+ else if (e.isFile()) fs.copyFileSync(s, d);
112
+ }
113
+ }
114
+
115
+ // Codex reads `.agents/skills/` per the shared convention; several guides also claim
116
+ // `.codex/skills/`. Installing into both looked safer, but a `.codex/` directory in the workspace is
117
+ // also where Codex looks for project config, and its presence changed the sandbox outcome of a run
118
+ // (the skill-installed side reported a read-only workspace while the clean side wrote files). A probe
119
+ // is only meaningful if both sides run under identical conditions, so only the documented path is used.
120
+ function installSkill(tmp, skillDir, skillName) {
121
+ copyDir(skillDir, path.join(tmp, '.agents', 'skills', skillName));
122
+ }
123
+
124
+ function listFiles(root) {
125
+ const out = [];
126
+ const walk = (dir) => {
127
+ let entries = [];
128
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
129
+ for (const e of entries) {
130
+ if (e.name === '.agents' || e.name === '.codex' || e.name === '.git') continue;
131
+ const p = path.join(dir, e.name);
132
+ if (e.isDirectory()) walk(p);
133
+ else out.push(path.relative(root, p).replace(/\\/g, '/'));
134
+ }
135
+ };
136
+ walk(root);
137
+ return out.sort();
138
+ }
139
+
140
+ // A minimal model catalog for a DeepSeek model, using the field names of DeepSeek's own Codex guide. The
141
+ // long agent instructions of the official file are replaced by a short fixed text, so a probe through this
142
+ // catalog does not run with DeepSeek's recommended prompt; it is the same on both sides of the comparison.
143
+ const INSTRUCTIONS = 'You are a coding agent running in a terminal. Do what the user asks by running commands and editing files in the current directory, then stop.';
144
+
145
+ export function deepseekCatalog(model) {
146
+ return `${JSON.stringify({
147
+ models: [{
148
+ slug: model,
149
+ display_name: model,
150
+ description: 'DeepSeek model for a SkillGauntlet probe',
151
+ default_reasoning_level: 'high',
152
+ supported_reasoning_levels: [
153
+ { effort: 'low', description: 'Fast' },
154
+ { effort: 'medium', description: 'Balanced' },
155
+ { effort: 'high', description: 'Thorough' },
156
+ ],
157
+ shell_type: 'shell_command',
158
+ visibility: 'list',
159
+ supported_in_api: true,
160
+ priority: 1,
161
+ minimal_client_version: '0.144.0',
162
+ apply_patch_tool_type: 'freeform',
163
+ web_search_tool_type: 'text',
164
+ input_modalities: ['text'],
165
+ truncation_policy: { mode: 'tokens', limit: 10000 },
166
+ supports_parallel_tool_calls: true,
167
+ supports_reasoning_summaries: true,
168
+ supports_search_tool: false,
169
+ context_window: 1048576,
170
+ max_context_window: 1048576,
171
+ effective_context_window_percent: 95,
172
+ auto_review_model_override: model,
173
+ prefer_websockets: false,
174
+ support_verbosity: true,
175
+ default_verbosity: 'low',
176
+ supports_image_detail_original: false,
177
+ tool_mode: null,
178
+ multi_agent_version: 'v2',
179
+ use_responses_lite: false,
180
+ include_skills_usage_instructions: false,
181
+ auto_compact_token_limit: null,
182
+ comp_hash: '3000',
183
+ reasoning_summary_format: 'experimental',
184
+ default_reasoning_summary: 'none',
185
+ availability_nux: null,
186
+ upgrade: null,
187
+ default_service_tier: null,
188
+ model_messages: { instructions_template: INSTRUCTIONS, instructions_variables: ['', '', ''], approvals: null },
189
+ experimental_supported_tools: [],
190
+ base_instructions: INSTRUCTIONS,
191
+ }],
192
+ }, null, 2)}
193
+ `;
194
+ }
195
+
196
+ export function runCodexTrial({
197
+ bin, skillDir, skillName, query, model, sandbox = 'read-only', timeout = 180, keepDir = false,
198
+ ignoreUserConfig = true, keepRaw = false, reasoningEffort = null, effort = process.env.SG_CODEX_EFFORT,
199
+ }) {
200
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-codex-'));
201
+ if (skillDir) installSkill(tmp, skillDir, skillName);
202
+ const before = listFiles(tmp);
203
+ const args = ['exec', '--json', '-C', tmp, '--skip-git-repo-check'];
204
+ // --approve-for-me replaces --sandbox rather than combining with it: the two flags are mutually
205
+ // exclusive, and that flag carries the workspace-write sandbox. Without it, a non-interactive run
206
+ // refuses every command ("blocked by policy"), the turn ends with an apology, and no
207
+ // command_execution item is ever produced. The sandbox-bypass flag is never used, because it would
208
+ // run code we are deliberately treating as untrusted without any sandbox at all.
209
+ if (sandbox === 'read-only') args.push('--sandbox', 'read-only');
210
+ else args.push('--approve-for-me');
211
+ if (ignoreUserConfig) args.push('--ignore-user-config');
212
+ if (model) args.push('-m', model);
213
+ // DeepSeek's Codex guide points a model provider at its Responses endpoint. With --ignore-user-config the
214
+ // user's config.toml is not read, so the provider is passed inline and the key comes from the environment.
215
+ let catalogFile = null;
216
+ if (/^deepseek/i.test(model || '')) {
217
+ // The catalog entry carries auto_review_model_override: --approve-for-me asks a review model, named
218
+ // codex-auto-review by default, which DeepSeek's API rejects. It is a per-model catalog field, not a
219
+ // config key (passing it with -c is reported as "ignored").
220
+ catalogFile = path.join(os.tmpdir(), `sg-codex-catalog-${process.pid}-${Date.now()}.json`);
221
+ fs.writeFileSync(catalogFile, deepseekCatalog(model));
222
+ args.push('-c', 'model_provider="deepseek"',
223
+ '-c', 'model_providers.deepseek={name="deepseek", base_url="https://api.deepseek.com/", wire_api="responses", env_key="DEEPSEEK_API_KEY"}',
224
+ '-c', `model_catalog_json="${catalogFile.replace(/\\/g, '/')}"`);
225
+ }
226
+ // --ignore-user-config also drops the user's effort setting, so it has to be passed explicitly. The
227
+ // value is left unquoted: codex falls back to a plain string, and quotes get mangled on the cmd shim.
228
+ const pinnedEffort = reasoningEffort || effort;
229
+ if (pinnedEffort) args.push('-c', `model_reasoning_effort=${pinnedEffort}`);
230
+ args.push(query);
231
+ const runner = runnerFor(bin, 'codex');
232
+ const argv = [...runner.prefix, ...args];
233
+ const cmdArgs = runner.shell ? argv.map(quoteForCmd) : argv;
234
+
235
+ return new Promise((resolve) => {
236
+ const started = Date.now();
237
+ const child = spawn(runner.cmd, cmdArgs, {
238
+ cwd: tmp, env: process.env, windowsHide: true, shell: runner.shell,
239
+ // Without this codex waits on stdin ("Reading additional input from stdin...") forever,
240
+ // because an inherited pipe stays open.
241
+ stdio: ['ignore', 'pipe', 'pipe'],
242
+ });
243
+ let out = '';
244
+ let err = '';
245
+ const timer = setTimeout(() => {
246
+ try {
247
+ if (process.platform === 'win32') execFileSync('taskkill', ['/T', '/F', '/PID', String(child.pid)], { stdio: 'ignore' });
248
+ else child.kill('SIGKILL');
249
+ } catch { /* already gone */ }
250
+ setTimeout(() => finish(), 3000);
251
+ }, timeout * 1000);
252
+ child.stdout.setEncoding('utf8');
253
+ child.stdout.on('data', (c) => { out += c; });
254
+ child.stderr.setEncoding('utf8');
255
+ child.stderr.on('data', (c) => { err += c; });
256
+ let finished = false;
257
+ const finish = () => {
258
+ if (finished) return;
259
+ finished = true;
260
+ clearTimeout(timer);
261
+ // An agent can leave a background process (a browser daemon) holding our pipes, in which case
262
+ // 'close' never fires and the run would hang past its timeout.
263
+ try { child.stdout.destroy(); child.stderr.destroy(); } catch { /* already closed */ }
264
+ const parsed = parseCodexJsonl(out);
265
+ // file_change items only appear for patch-style edits; on Windows the agent usually writes
266
+ // through a shell command, so the filesystem diff is the dependable record of what it did.
267
+ const after = listFiles(tmp);
268
+ const rec = {
269
+ host: 'codex',
270
+ // What was asked of the CLI, so an archived record says which model and settings produced it.
271
+ invocation: { model: model || null, effort: effort || null, sandbox, ignoreUserConfig },
272
+ // Deliberately not a trigger verdict: the stream cannot answer that question.
273
+ verdict: 'unobservable',
274
+ firstAction: firstAction(parsed.items),
275
+ commands: parsed.items.filter((i) => i.type === 'command_execution').map(detailOf),
276
+ finalMessage: finalMessage(parsed.items),
277
+ changedFiles: changedFiles(parsed.items),
278
+ createdFiles: after.filter((f) => !before.includes(f)),
279
+ itemTypes: [...new Set(parsed.items.map((i) => i.type).filter(Boolean))],
280
+ unknownItemTypes: parsed.unknownItemTypes,
281
+ tokenUsage: parsed.tokenUsage,
282
+ eventTypes: [...new Set(parsed.events.map((e) => e.type).filter(Boolean))],
283
+ eventCount: parsed.events.length,
284
+ completed: parsed.completed,
285
+ errors: parsed.errors,
286
+ latencyMs: Date.now() - started,
287
+ stderr: err.slice(-500),
288
+ rawLines: parsed.events.length,
289
+ };
290
+ if (catalogFile) { try { fs.rmSync(catalogFile, { force: true }); } catch { /* best effort */ } }
291
+ // The unparsed event stream, so a batch record can be re-read without re-running.
292
+ if (keepRaw) rec.rawJsonl = out;
293
+ if (keepDir) rec.dir = tmp;
294
+ else { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ } }
295
+ resolve(rec);
296
+ };
297
+ child.on('error', (e) => { err += String(e.message || e); finish(); });
298
+ child.on('close', finish);
299
+ // Give buffered output a moment to arrive after the process exits, then stop waiting for the pipes.
300
+ child.on('exit', () => setTimeout(() => finish(), 2000));
301
+ });
302
+ }
303
+
304
+ // Paired probe: the same request with and without the skill installed. It returns facts, not a
305
+ // verdict. Two runs of the same request never execute identical command strings, so "the first
306
+ // action differs" is true almost always and means nothing; an automatic influenced/not-influenced
307
+ // flag would be a coin flip dressed up as a finding. The report shows both sides and the reader
308
+ // decides.
309
+ export async function probeActivation(opts) {
310
+ const withSkill = await runCodexTrial({ ...opts, sandbox: 'workspace-write' });
311
+ const without = await runCodexTrial({ ...opts, skillDir: null, sandbox: 'workspace-write' });
312
+ return {
313
+ withSkill,
314
+ without,
315
+ actionKindIdentical: withSkill.firstAction?.kind === without.firstAction?.kind,
316
+ artifactsIdentical: JSON.stringify(withSkill.createdFiles) === JSON.stringify(without.createdFiles),
317
+ };
318
+ }
package/lib/depth.js ADDED
@@ -0,0 +1,55 @@
1
+ // Test-strength presets. The point of exposing these is that a user can spend one minute and
2
+ // a tenth of a cent before deciding whether the full run is worth it.
3
+ //
4
+ // Cost model, per trial:
5
+ // Anthropic via Claude Code -> subscription quota, not cash; ~1% of a Pro week per ~120 trials
6
+ // DeepSeek via any host -> ~0.04 CNY per trial (its endpoint ignores cache_control,
7
+ // so the whole prompt is billed at the cache-miss price)
8
+ // Times assume 2 parallel trials at ~4 s each.
9
+ //
10
+ // Labels and hints live in lib/i18n.js (depthQuick, depthQuickHint, ...). No amount of money is shown
11
+ // to the user: the estimate was never accurate enough to print.
12
+
13
+ export const DEPTHS = {
14
+ quick: {
15
+ id: 'quick',
16
+ sets: 1,
17
+ perClause: 2,
18
+ negatives: 4,
19
+ runs: 1,
20
+ maxExplore: 0,
21
+ layers: ['static', 'trigger'],
22
+ },
23
+ standard: {
24
+ id: 'standard',
25
+ sets: 2,
26
+ perClause: 3,
27
+ negatives: 10,
28
+ runs: 3,
29
+ maxExplore: 0,
30
+ layers: ['static', 'trigger', 'gain'],
31
+ },
32
+ deep: {
33
+ id: 'deep',
34
+ sets: 3,
35
+ perClause: 4,
36
+ negatives: 10,
37
+ runs: 3,
38
+ maxExplore: 3,
39
+ layers: ['static', 'trigger', 'gain', 'robustness'],
40
+ },
41
+ };
42
+
43
+ export function estimate(depth, clauses = 3) {
44
+ const d = DEPTHS[depth];
45
+ const perSet = d.perClause * clauses + d.negatives;
46
+ const queries = perSet * d.sets;
47
+ const trials = queries * d.runs;
48
+ return {
49
+ ...d,
50
+ clauses,
51
+ queries,
52
+ trials,
53
+ minutes: Math.max(1, Math.round((trials * 4) / 2 / 60)),
54
+ };
55
+ }
@@ -0,0 +1,83 @@
1
+ // Locating skills: a local directory, or owner/repo cloned at a pinned commit.
2
+ import { execFileSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ const SKIP = new Set(['.git', 'node_modules', '.cache', 'dist', 'build', 'vendor', '__pycache__']);
8
+
9
+ export function findSkills(root, { maxDepth = 6 } = {}) {
10
+ const found = [];
11
+ const walk = (dir, depth) => {
12
+ if (depth > maxDepth) return;
13
+ let entries;
14
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
15
+ if (entries.some((e) => e.isFile() && e.name === 'SKILL.md')) {
16
+ found.push(dir);
17
+ return; // a skill dir does not contain nested skills
18
+ }
19
+ for (const e of entries) {
20
+ if (!e.isDirectory() || SKIP.has(e.name) || e.name.startsWith('.')) continue;
21
+ walk(path.join(dir, e.name), depth + 1);
22
+ }
23
+ };
24
+ walk(path.resolve(root), 0);
25
+ return found.sort();
26
+ }
27
+
28
+ export function readFrontmatter(skillDir) {
29
+ const file = path.join(skillDir, 'SKILL.md');
30
+ let text = '';
31
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return { fm: {}, text: '', file }; }
32
+ const fm = {};
33
+ const m = text.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/);
34
+ if (m) {
35
+ let key = null;
36
+ for (const line of m[1].split(/\r?\n/)) {
37
+ const kv = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
38
+ if (kv) {
39
+ key = kv[1];
40
+ fm[key] = kv[2].replace(/^["']|["']$/g, '').trim();
41
+ } else if (key && /^\s+\S/.test(line)) {
42
+ fm[key] = `${fm[key]} ${line.trim()}`.trim();
43
+ }
44
+ }
45
+ }
46
+ return { fm, text, file };
47
+ }
48
+
49
+ function cacheRoot() {
50
+ const base = process.env.SKILLGAUNTLET_CACHE
51
+ || path.join(os.homedir(), '.cache', 'skillgauntlet');
52
+ fs.mkdirSync(base, { recursive: true });
53
+ return base;
54
+ }
55
+
56
+ // Accepts: a local path, or owner/repo, or a github URL (with optional #ref).
57
+ export function resolveSpec(spec) {
58
+ const local = path.resolve(spec);
59
+ if (fs.existsSync(local)) return { kind: 'local', dir: local, source: { path: spec } };
60
+
61
+ const m = spec.match(/^(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:#(.+))?$/);
62
+ if (!m) {
63
+ const err = new Error(`cannot read this address: ${spec} (use a local directory or owner/repo)`);
64
+ err.messageKey = 'badSource';
65
+ err.vars = { spec };
66
+ throw err;
67
+ }
68
+ const [, owner, repo, ref] = m;
69
+ const slug = `${owner}__${repo}${ref ? `@${ref}` : ''}`.replace(/[^\w.@-]/g, '_');
70
+ const dir = path.join(cacheRoot(), slug);
71
+ if (!fs.existsSync(path.join(dir, '.git'))) {
72
+ fs.rmSync(dir, { recursive: true, force: true });
73
+ const args = ['clone', '--quiet', '--depth', '1'];
74
+ if (ref) args.push('--branch', ref);
75
+ args.push(`https://github.com/${owner}/${repo}.git`, dir);
76
+ execFileSync('git', args, { stdio: 'inherit' });
77
+ }
78
+ let commit = '';
79
+ try {
80
+ commit = execFileSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
81
+ } catch { /* keep empty */ }
82
+ return { kind: 'git', dir, source: { repo: `https://github.com/${owner}/${repo}`, commit, ref: ref || null } };
83
+ }