mcp-castor 2026.3.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.
Files changed (42) hide show
  1. package/README.md +487 -0
  2. package/bin/castor.js +706 -0
  3. package/index.js +206 -0
  4. package/package.json +97 -0
  5. package/skills/canary-test-staging/SKILL.md +24 -0
  6. package/skills/evo-mutation-rollback/SKILL.md +29 -0
  7. package/skills/hypothesis-generation/SKILL.md +26 -0
  8. package/skills/traceback-condensing/SKILL.md +26 -0
  9. package/src/castor_runner.js +469 -0
  10. package/src/config.js +1204 -0
  11. package/src/env.js +10 -0
  12. package/src/evo_engine.js +214 -0
  13. package/src/harness/core/events.js +75 -0
  14. package/src/harness/core/kernel.js +209 -0
  15. package/src/harness/evo/evaluator.js +156 -0
  16. package/src/harness/evo/evo_operator.js +550 -0
  17. package/src/harness/evo/lineage_dag.js +383 -0
  18. package/src/harness/evo/trace_repair.js +173 -0
  19. package/src/harness/evo/watchdog.js +72 -0
  20. package/src/harness/loop_detector.js +135 -0
  21. package/src/harness/runner.js +1216 -0
  22. package/src/harness/services/ast_service.js +1813 -0
  23. package/src/harness/services/event_logger.js +275 -0
  24. package/src/harness/services/mcp_bridge.js +408 -0
  25. package/src/harness/services/provider_vllm.js +728 -0
  26. package/src/harness/services/sandbox_fs.js +1238 -0
  27. package/src/harness/services/searxng_lifecycle.js +254 -0
  28. package/src/harness/services/shell_executor.js +264 -0
  29. package/src/harness/services/shell_validator.js +506 -0
  30. package/src/harness/services/web_service.js +828 -0
  31. package/src/platform.js +344 -0
  32. package/src/repetition_detector.js +139 -0
  33. package/src/semaphore.js +373 -0
  34. package/src/server_lifecycle.js +781 -0
  35. package/src/skills.js +400 -0
  36. package/src/state_pruner.js +392 -0
  37. package/src/task_registry.js +1357 -0
  38. package/src/telemetry.js +638 -0
  39. package/src/tools.js +997 -0
  40. package/src/wsl_bridge.js +629 -0
  41. package/src/wsl_env.js +171 -0
  42. package/stream_proxy.js +453 -0
package/src/skills.js ADDED
@@ -0,0 +1,400 @@
1
+ /**
2
+ * src/skills.js - Packaged skills library with keyword auto-injection.
3
+ *
4
+ * A "skill" is a reusable workflow recipe stored at:
5
+ *
6
+ * <repo>/skills/<name>/SKILL.md
7
+ *
8
+ * Each SKILL.md has a tiny YAML-ish frontmatter block (the lines between the
9
+ * first pair of `---` markers) followed by a markdown workflow body:
10
+ *
11
+ * ---
12
+ * name: my-skill
13
+ * description: One-line summary
14
+ * keywords: [traceback, stack trace, error digest]
15
+ * ---
16
+ * <workflow body in markdown>
17
+ *
18
+ * The parser is intentionally tiny and dependency-free:
19
+ * - frontmatter = lines between the first `---` and the next `---`
20
+ * - `key: value` lines; `keywords` may be a JSON-ish list `[a, b, c]`
21
+ * or a plain comma-separated string
22
+ * - the body is everything after the closing `---`
23
+ *
24
+ * `matchSkills({ prompt, cwd })` returns the skills whose keywords appear in
25
+ * the prompt (case-insensitive substring / word match) OR in the cwd path
26
+ * string (so a repo named "evo" can trigger an evo skill). Results are
27
+ * additive and budget-capped: at most 3 skills, each body capped at ~2000
28
+ * chars, and a total injected budget of ~6000 chars.
29
+ *
30
+ * `injectSkills(prompt, cwd)` appends a clearly-delimited block to the prompt
31
+ * ONLY when there are matches; otherwise it returns the prompt unchanged.
32
+ *
33
+ * Never-throw discipline: unreadable or malformed skills are skipped with a
34
+ * stderr note so a bad skill can never take down a dispatch.
35
+ */
36
+
37
+ import fs from "node:fs";
38
+ import path from "node:path";
39
+ import { fileURLToPath } from "node:url";
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Budgets
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** Max number of skills injected per dispatch. */
46
+ export const MAX_SKILLS = 3;
47
+ /** Max characters of a single skill body before it is truncated. */
48
+ export const MAX_BODY_CHARS = 2000;
49
+ /** Max total characters across all injected skill bodies. */
50
+ export const MAX_TOTAL_CHARS = 6000;
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Skills directory resolution (mirrors streamProxyPath in platform.js)
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Resolve the repo-root `skills/` directory from this module's own location
58
+ * so it is correct regardless of process.cwd(). This file lives at
59
+ * `<repo>/src/skills.js`, so the repo root is two levels up.
60
+ * @returns {string}
61
+ */
62
+ export function skillsDir() {
63
+ const repoRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
64
+ return path.join(repoRoot, "skills");
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Frontmatter parsing
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Split a SKILL.md document into { frontmatter, body }.
73
+ *
74
+ * The frontmatter is the block between the FIRST `---` line and the NEXT
75
+ * `---` line. If the document does not open with `---` (or has no closing
76
+ * `---`), the whole document is treated as the body with empty frontmatter.
77
+ *
78
+ * @param {string} text
79
+ * @returns {{ frontmatter: string, body: string }}
80
+ */
81
+ export function splitFrontmatter(text) {
82
+ // Normalize line endings so the split is stable across CRLF/LF files.
83
+ const normalized = String(text).replace(/\r\n/g, "\n");
84
+ const lines = normalized.split("\n");
85
+
86
+ // The first non-empty line must be exactly `---` to open frontmatter.
87
+ let openIdx = -1;
88
+ for (let i = 0; i < lines.length; i++) {
89
+ if (lines[i].trim() === "") continue;
90
+ if (lines[i].trim() === "---") openIdx = i;
91
+ break; // only the first non-empty line can open it
92
+ }
93
+
94
+ if (openIdx === -1) {
95
+ return { frontmatter: "", body: normalized };
96
+ }
97
+
98
+ let closeIdx = -1;
99
+ for (let i = openIdx + 1; i < lines.length; i++) {
100
+ if (lines[i].trim() === "---") {
101
+ closeIdx = i;
102
+ break;
103
+ }
104
+ }
105
+
106
+ if (closeIdx === -1) {
107
+ // Opened but never closed: treat the whole thing as body (malformed).
108
+ return { frontmatter: "", body: normalized };
109
+ }
110
+
111
+ const frontmatter = lines.slice(openIdx + 1, closeIdx).join("\n");
112
+ const body = lines.slice(closeIdx + 1).join("\n").trim();
113
+ return { frontmatter, body };
114
+ }
115
+
116
+ /**
117
+ * Parse a `keywords` value that may be a JSON-ish list `[a, b, c]` or a
118
+ * plain comma-separated string `a, b, c`.
119
+ * @param {string} raw
120
+ * @returns {string[]}
121
+ */
122
+ function parseKeywords(raw) {
123
+ if (raw == null) return [];
124
+ let s = String(raw).trim();
125
+ if (s.startsWith("[") && s.endsWith("]")) {
126
+ s = s.slice(1, -1);
127
+ }
128
+ return s
129
+ .split(",")
130
+ .map((k) => k.trim().replace(/^["']|["']$/g, ""))
131
+ .filter((k) => k.length > 0);
132
+ }
133
+
134
+ /**
135
+ * Parse a frontmatter block into a plain object. Only `key: value` lines are
136
+ * honored; anything else is ignored. Values are trimmed and de-quoted.
137
+ * @param {string} frontmatter
138
+ * @returns {Record<string, string>}
139
+ */
140
+ export function parseFrontmatter(frontmatter) {
141
+ const out = {};
142
+ if (!frontmatter) return out;
143
+ for (const line of frontmatter.split("\n")) {
144
+ const trimmed = line.trim();
145
+ if (!trimmed || trimmed.startsWith("#")) continue;
146
+ const colon = trimmed.indexOf(":");
147
+ if (colon === -1) continue;
148
+ const key = trimmed.slice(0, colon).trim();
149
+ let value = trimmed.slice(colon + 1).trim();
150
+ // Strip a single pair of surrounding quotes.
151
+ if (
152
+ (value.startsWith('"') && value.endsWith('"')) ||
153
+ (value.startsWith("'") && value.endsWith("'"))
154
+ ) {
155
+ value = value.slice(1, -1);
156
+ }
157
+ if (key) out[key] = value;
158
+ }
159
+ return out;
160
+ }
161
+
162
+ /**
163
+ * Parse a single SKILL.md file into a skill record.
164
+ * @param {string} text
165
+ * @param {string} dirName
166
+ * @returns {{ name: string, description: string, keywords: string[], body: string }|null}
167
+ */
168
+ export function parseSkill(text, dirName) {
169
+ const { frontmatter, body } = splitFrontmatter(text);
170
+ const fm = parseFrontmatter(frontmatter);
171
+ const name = (fm.name || dirName).trim() || dirName;
172
+ const description = (fm.description || "").trim();
173
+ const keywords = parseKeywords(fm.keywords);
174
+ return { name, description, keywords, body };
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Loading
179
+ // ---------------------------------------------------------------------------
180
+
181
+ /**
182
+ * Load and parse every skill under a skills directory.
183
+ * Unreadable or malformed entries are skipped with a stderr note.
184
+ *
185
+ * @param {string} [dir] skills directory (defaults to the repo-root skills/)
186
+ * @returns {Array<{ name: string, description: string, keywords: string[], body: string }>}
187
+ */
188
+ export function loadSkills(dir = skillsDir()) {
189
+ const out = [];
190
+ let entries;
191
+ try {
192
+ entries = fs.readdirSync(dir, { withFileTypes: true });
193
+ } catch {
194
+ // No skills/ directory at all: that is fine, just no skills.
195
+ return out;
196
+ }
197
+
198
+ for (const entry of entries) {
199
+ if (!entry.isDirectory()) continue;
200
+ const skillFile = path.join(dir, entry.name, "SKILL.md");
201
+ let text;
202
+ try {
203
+ text = fs.readFileSync(skillFile, "utf8");
204
+ } catch {
205
+ process.stderr.write(
206
+ `[skills] skipping '${entry.name}': SKILL.md not found or unreadable\n`
207
+ );
208
+ continue;
209
+ }
210
+ try {
211
+ const skill = parseSkill(text, entry.name);
212
+ if (!skill || !skill.body) {
213
+ process.stderr.write(
214
+ `[skills] skipping '${entry.name}': empty or malformed body\n`
215
+ );
216
+ continue;
217
+ }
218
+ out.push(skill);
219
+ } catch (err) {
220
+ process.stderr.write(
221
+ `[skills] skipping '${entry.name}': ${err.message}\n`
222
+ );
223
+ }
224
+ }
225
+ return out;
226
+ }
227
+
228
+ /**
229
+ * Introspection helper: list all loaded skills (name + description + keywords).
230
+ * @param {string} [dir] skills directory override (defaults to repo-root skills/)
231
+ * @returns {Array<{ name: string, description: string, keywords: string[] }>}
232
+ */
233
+ export function listSkills(dir) {
234
+ return loadSkills(dir).map(({ name, description, keywords }) => ({
235
+ name,
236
+ description,
237
+ keywords,
238
+ }));
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Matching
243
+ // ---------------------------------------------------------------------------
244
+
245
+ /**
246
+ * Case-insensitive substring / word match of a keyword against a haystack.
247
+ * @param {string} keyword
248
+ * @param {string} haystack
249
+ * @returns {boolean}
250
+ */
251
+ function keywordMatches(keyword, haystack) {
252
+ if (!keyword || !haystack) return false;
253
+ return haystack.toLowerCase().includes(keyword.toLowerCase());
254
+ }
255
+
256
+ /**
257
+ * Extracts terms following negative intent keywords (e.g. purge, remove, eliminate, delete, avoid, without).
258
+ * Strips common stop-words like 'all', 'the', 'any'.
259
+ * @param {string} text
260
+ * @returns {Set<string>}
261
+ */
262
+ export function extractNegatedKeywords(text) {
263
+ const negated = new Set();
264
+ if (!text) return negated;
265
+ const re = /\b(?:purge|eliminate|remove|delete|deprecate|scrub|do\s+not\s+use|avoid|without)\b(?:\s+(?:all|the|any))?\s+([a-zA-Z0-9_\-]+)/gi;
266
+ let m;
267
+ while ((m = re.exec(text)) !== null) {
268
+ if (m[1]) negated.add(m[1].toLowerCase());
269
+ }
270
+ return negated;
271
+ }
272
+
273
+ /**
274
+ * Checks whether a skill's name or any of its keywords match any extracted negated term.
275
+ * @param {{ name: string, keywords: string[] }} skill
276
+ * @param {Set<string>} negatedKeywords
277
+ * @returns {boolean}
278
+ */
279
+ export function isSkillNegated(skill, negatedKeywords) {
280
+ if (!skill || !negatedKeywords || negatedKeywords.size === 0) return false;
281
+ const nameLower = (skill.name || "").toLowerCase();
282
+ for (const neg of negatedKeywords) {
283
+ if (nameLower === neg || nameLower.includes(neg)) return true;
284
+ if (Array.isArray(skill.keywords)) {
285
+ for (const kw of skill.keywords) {
286
+ const kwLower = String(kw || "").toLowerCase();
287
+ if (kwLower === neg || kwLower.includes(neg)) return true;
288
+ }
289
+ }
290
+ }
291
+ return false;
292
+ }
293
+
294
+ /**
295
+ * Find skills whose keywords match the prompt text OR the cwd path string.
296
+ * Results are additive and budget-capped (MAX_SKILLS, MAX_BODY_CHARS,
297
+ * MAX_TOTAL_CHARS).
298
+ *
299
+ * If `explicitSkills` is provided as an array, keyword matching is bypassed and
300
+ * exactly those skills are returned (or an Error is thrown if an explicit skill
301
+ * does not exist).
302
+ *
303
+ * Excludes any skill whose name or keywords match terms following negative
304
+ * directives (e.g., "purge avo" blacklists avo skills to prevent contradictory
305
+ * instruction loops).
306
+ *
307
+ * @param {{ prompt?: string, cwd?: string, dir?: string, explicitSkills?: string[] }} params
308
+ * @returns {Array<{ name: string, description: string, body: string }>}
309
+ */
310
+ export function matchSkills({ prompt = "", cwd = "", dir, explicitSkills } = {}) {
311
+ const all = loadSkills(dir);
312
+
313
+ let candidateSkills = [];
314
+
315
+ if (Array.isArray(explicitSkills) && explicitSkills.length > 0) {
316
+ const skillMap = new Map(all.map((s) => [s.name.toLowerCase(), s]));
317
+ for (const name of explicitSkills) {
318
+ const found = skillMap.get(String(name).toLowerCase());
319
+ if (!found) {
320
+ throw new Error(`Explicit skill '${name}' requested but not found`);
321
+ }
322
+ candidateSkills.push(found);
323
+ }
324
+ } else {
325
+ const promptStr = String(prompt || "");
326
+ const cwdStr = String(cwd || "");
327
+ const negatedKeywords = extractNegatedKeywords(promptStr);
328
+
329
+ for (const skill of all) {
330
+ if (!Array.isArray(skill.keywords) || skill.keywords.length === 0) continue;
331
+ if (isSkillNegated(skill, negatedKeywords)) continue;
332
+ const hit = skill.keywords.some(
333
+ (kw) => keywordMatches(kw, promptStr) || keywordMatches(kw, cwdStr)
334
+ );
335
+ if (hit) candidateSkills.push(skill);
336
+ }
337
+ }
338
+
339
+ // Cap the number of skills first.
340
+ const capped = candidateSkills.slice(0, MAX_SKILLS);
341
+
342
+ // Enforce the per-body and total budgets.
343
+ const out = [];
344
+ let total = 0;
345
+ for (const skill of capped) {
346
+ let body = skill.body;
347
+ if (body.length > MAX_BODY_CHARS) {
348
+ body = body.slice(0, MAX_BODY_CHARS) + "\n...[truncated]";
349
+ }
350
+ if (total + body.length > MAX_TOTAL_CHARS) {
351
+ // Truncate to fit the remaining budget; drop if nothing fits.
352
+ const remaining = MAX_TOTAL_CHARS - total;
353
+ if (remaining <= 0) break;
354
+ body = body.slice(0, remaining) + "\n...[truncated]";
355
+ }
356
+ total += body.length;
357
+ out.push({ name: skill.name, description: skill.description, body });
358
+ }
359
+ return out;
360
+ }
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // Injection
364
+ // ---------------------------------------------------------------------------
365
+
366
+ /**
367
+ * Append a clearly-delimited skills block to the prompt ONLY when there are
368
+ * matching skills. Returns the prompt unchanged when nothing matches.
369
+ *
370
+ * @param {string} prompt
371
+ * @param {string} [cwd]
372
+ * @param {string} [dir] skills directory override (defaults to repo-root skills/)
373
+ * @param {string[]} [explicitSkills]
374
+ * @returns {string}
375
+ */
376
+ export function injectSkills(prompt, cwd, dir, explicitSkills) {
377
+ if (typeof prompt !== "string") return prompt;
378
+ if (prompt.includes("--- Matching skills (auto-injected from skills/) ---")) {
379
+ return prompt;
380
+ }
381
+
382
+ const matches = matchSkills({ prompt, cwd, dir, explicitSkills });
383
+ if (matches.length === 0) return prompt;
384
+
385
+ const parts = [
386
+ "",
387
+ "",
388
+ "--- Matching skills (auto-injected from skills/) ---",
389
+ ];
390
+ const seen = new Set();
391
+ for (const s of matches) {
392
+ if (seen.has(s.name)) continue;
393
+ seen.add(s.name);
394
+ parts.push(`### ${s.name}`);
395
+ parts.push(s.body);
396
+ parts.push("");
397
+ }
398
+ return `${prompt}\n${parts.join("\n")}`.replace(/\n{3,}/g, "\n\n").trimEnd();
399
+ }
400
+