@cubis/foundry 0.3.46 → 0.3.47
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/mcp/src/tools/skillGet.ts +36 -5
- package/mcp/src/tools/skillTools.test.ts +143 -1
- package/mcp/src/vault/manifest.test.ts +71 -1
- package/mcp/src/vault/manifest.ts +128 -3
- package/mcp/src/vault/scanner.test.ts +35 -0
- package/mcp/src/vault/scanner.ts +91 -1
- package/package.json +1 -1
- package/workflows/workflows/agent-environment-setup/platforms/codex/rules/AGENTS.md +6 -0
- package/workflows/workflows/agent-environment-setup/platforms/copilot/rules/AGENTS.md +6 -0
- package/workflows/workflows/agent-environment-setup/platforms/copilot/rules/copilot-instructions.md +6 -0
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import type { VaultManifest } from "../vault/types.js";
|
|
10
|
-
import {
|
|
11
|
-
import { notFound } from "../utils/errors.js";
|
|
10
|
+
import { readSkillContentWithReferences } from "../vault/manifest.js";
|
|
11
|
+
import { invalidInput, notFound } from "../utils/errors.js";
|
|
12
12
|
import {
|
|
13
13
|
buildSkillToolMetrics,
|
|
14
14
|
estimateTokensFromText,
|
|
@@ -17,10 +17,16 @@ import {
|
|
|
17
17
|
export const skillGetName = "skill_get";
|
|
18
18
|
|
|
19
19
|
export const skillGetDescription =
|
|
20
|
-
"Get
|
|
20
|
+
"Get full content of a specific skill by ID. Returns SKILL.md content and optionally direct referenced markdown files.";
|
|
21
21
|
|
|
22
22
|
export const skillGetSchema = z.object({
|
|
23
23
|
id: z.string().describe("The skill ID (directory name) to retrieve"),
|
|
24
|
+
includeReferences: z
|
|
25
|
+
.boolean()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe(
|
|
28
|
+
"Whether to include direct local markdown references from SKILL.md (default: true)",
|
|
29
|
+
),
|
|
24
30
|
});
|
|
25
31
|
|
|
26
32
|
export async function handleSkillGet(
|
|
@@ -28,14 +34,38 @@ export async function handleSkillGet(
|
|
|
28
34
|
manifest: VaultManifest,
|
|
29
35
|
charsPerToken: number,
|
|
30
36
|
) {
|
|
31
|
-
const { id } = args;
|
|
37
|
+
const { id, includeReferences = true } = args;
|
|
38
|
+
|
|
39
|
+
if (id.startsWith("workflow-") || id.startsWith("agent-")) {
|
|
40
|
+
invalidInput(
|
|
41
|
+
`Skill id "${id}" appears to be a wrapper id. Use workflow/agent routing (for example $workflow-implement-track or $agent-backend-specialist) and call skill_get only for concrete skill ids.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
32
44
|
|
|
33
45
|
const skill = manifest.skills.find((s) => s.id === id);
|
|
34
46
|
if (!skill) {
|
|
35
47
|
notFound("Skill", id);
|
|
36
48
|
}
|
|
37
49
|
|
|
38
|
-
const
|
|
50
|
+
const { skillContent, references } = await readSkillContentWithReferences(
|
|
51
|
+
skill.path,
|
|
52
|
+
includeReferences,
|
|
53
|
+
);
|
|
54
|
+
const referenceSection =
|
|
55
|
+
references.length > 0
|
|
56
|
+
? [
|
|
57
|
+
"",
|
|
58
|
+
"## Referenced Files",
|
|
59
|
+
"",
|
|
60
|
+
...references.flatMap((ref) => [
|
|
61
|
+
`### ${ref.relativePath}`,
|
|
62
|
+
"",
|
|
63
|
+
ref.content.trimEnd(),
|
|
64
|
+
"",
|
|
65
|
+
]),
|
|
66
|
+
].join("\n")
|
|
67
|
+
: "";
|
|
68
|
+
const content = `${skillContent}${referenceSection}`;
|
|
39
69
|
const loadedSkillEstimatedTokens = estimateTokensFromText(
|
|
40
70
|
content,
|
|
41
71
|
charsPerToken,
|
|
@@ -55,6 +85,7 @@ export async function handleSkillGet(
|
|
|
55
85
|
},
|
|
56
86
|
],
|
|
57
87
|
structuredContent: {
|
|
88
|
+
references: references.map((ref) => ({ path: ref.relativePath })),
|
|
58
89
|
metrics,
|
|
59
90
|
},
|
|
60
91
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { mkdtempSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import type { VaultManifest } from "../vault/types.js";
|
|
@@ -147,6 +147,134 @@ describe("skill tools", () => {
|
|
|
147
147
|
expect(toolMetrics.loadedSkillEstimatedTokens).toBeGreaterThan(0);
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
it("includes referenced markdown files in skill_get by default", async () => {
|
|
151
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "mcp-skill-ref-default-"));
|
|
152
|
+
const skillFile = path.join(dir, "SKILL.md");
|
|
153
|
+
const referencesDir = path.join(dir, "references");
|
|
154
|
+
mkdirSync(referencesDir, { recursive: true });
|
|
155
|
+
writeFileSync(
|
|
156
|
+
skillFile,
|
|
157
|
+
[
|
|
158
|
+
"---",
|
|
159
|
+
"name: referenced-skill",
|
|
160
|
+
"description: skill with refs",
|
|
161
|
+
"---",
|
|
162
|
+
"# Skill",
|
|
163
|
+
"See [Guide](references/guide.md).",
|
|
164
|
+
].join("\n"),
|
|
165
|
+
"utf8",
|
|
166
|
+
);
|
|
167
|
+
writeFileSync(
|
|
168
|
+
path.join(referencesDir, "guide.md"),
|
|
169
|
+
"# Guide\nReferenced content",
|
|
170
|
+
"utf8",
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const skillBytes = statSync(skillFile).size;
|
|
174
|
+
const manifest: VaultManifest = {
|
|
175
|
+
categories: ["general"],
|
|
176
|
+
skills: [
|
|
177
|
+
{
|
|
178
|
+
id: "referenced-skill",
|
|
179
|
+
category: "general",
|
|
180
|
+
path: skillFile,
|
|
181
|
+
fileBytes: skillBytes,
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
fullCatalogBytes: skillBytes,
|
|
185
|
+
fullCatalogEstimatedTokens: Math.ceil(skillBytes / 4),
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const result = await handleSkillGet({ id: "referenced-skill" }, manifest, 4);
|
|
189
|
+
expect(result.content[0].text).toContain("## Referenced Files");
|
|
190
|
+
expect(result.content[0].text).toContain("### references/guide.md");
|
|
191
|
+
expect(result.content[0].text).toContain("Referenced content");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("skips referenced markdown files when includeReferences is false", async () => {
|
|
195
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "mcp-skill-ref-skip-"));
|
|
196
|
+
const skillFile = path.join(dir, "SKILL.md");
|
|
197
|
+
const referencesDir = path.join(dir, "references");
|
|
198
|
+
mkdirSync(referencesDir, { recursive: true });
|
|
199
|
+
writeFileSync(
|
|
200
|
+
skillFile,
|
|
201
|
+
[
|
|
202
|
+
"---",
|
|
203
|
+
"name: referenced-skill-no-refs",
|
|
204
|
+
"description: skill with refs",
|
|
205
|
+
"---",
|
|
206
|
+
"# Skill",
|
|
207
|
+
"See [Guide](references/guide.md).",
|
|
208
|
+
].join("\n"),
|
|
209
|
+
"utf8",
|
|
210
|
+
);
|
|
211
|
+
writeFileSync(path.join(referencesDir, "guide.md"), "# Guide", "utf8");
|
|
212
|
+
|
|
213
|
+
const skillBytes = statSync(skillFile).size;
|
|
214
|
+
const manifest: VaultManifest = {
|
|
215
|
+
categories: ["general"],
|
|
216
|
+
skills: [
|
|
217
|
+
{
|
|
218
|
+
id: "referenced-skill-no-refs",
|
|
219
|
+
category: "general",
|
|
220
|
+
path: skillFile,
|
|
221
|
+
fileBytes: skillBytes,
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
fullCatalogBytes: skillBytes,
|
|
225
|
+
fullCatalogEstimatedTokens: Math.ceil(skillBytes / 4),
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const result = await handleSkillGet(
|
|
229
|
+
{ id: "referenced-skill-no-refs", includeReferences: false },
|
|
230
|
+
manifest,
|
|
231
|
+
4,
|
|
232
|
+
);
|
|
233
|
+
expect(result.content[0].text).not.toContain("## Referenced Files");
|
|
234
|
+
expect(result.content[0].text).not.toContain("### references/guide.md");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("loads sibling markdown files when SKILL.md has no explicit links", async () => {
|
|
238
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "mcp-skill-ref-fallback-"));
|
|
239
|
+
const skillFile = path.join(dir, "SKILL.md");
|
|
240
|
+
writeFileSync(
|
|
241
|
+
skillFile,
|
|
242
|
+
[
|
|
243
|
+
"---",
|
|
244
|
+
"name: sibling-fallback-skill",
|
|
245
|
+
"description: fallback sibling",
|
|
246
|
+
"---",
|
|
247
|
+
"# Skill",
|
|
248
|
+
].join("\n"),
|
|
249
|
+
"utf8",
|
|
250
|
+
);
|
|
251
|
+
writeFileSync(path.join(dir, "overview.md"), "# Overview\nSibling", "utf8");
|
|
252
|
+
|
|
253
|
+
const skillBytes = statSync(skillFile).size;
|
|
254
|
+
const manifest: VaultManifest = {
|
|
255
|
+
categories: ["general"],
|
|
256
|
+
skills: [
|
|
257
|
+
{
|
|
258
|
+
id: "sibling-fallback-skill",
|
|
259
|
+
category: "general",
|
|
260
|
+
path: skillFile,
|
|
261
|
+
fileBytes: skillBytes,
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
fullCatalogBytes: skillBytes,
|
|
265
|
+
fullCatalogEstimatedTokens: Math.ceil(skillBytes / 4),
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const result = await handleSkillGet(
|
|
269
|
+
{ id: "sibling-fallback-skill" },
|
|
270
|
+
manifest,
|
|
271
|
+
4,
|
|
272
|
+
);
|
|
273
|
+
expect(result.content[0].text).toContain("## Referenced Files");
|
|
274
|
+
expect(result.content[0].text).toContain("### overview.md");
|
|
275
|
+
expect(result.content[0].text).toContain("Sibling");
|
|
276
|
+
});
|
|
277
|
+
|
|
150
278
|
it("throws when skill_get cannot find the requested skill", async () => {
|
|
151
279
|
const manifest = createManifest();
|
|
152
280
|
await expect(handleSkillGet({ id: "missing" }, manifest, 4)).rejects.toThrow(
|
|
@@ -154,6 +282,20 @@ describe("skill tools", () => {
|
|
|
154
282
|
);
|
|
155
283
|
});
|
|
156
284
|
|
|
285
|
+
it("throws a wrapper guidance error when skill_get receives workflow id", async () => {
|
|
286
|
+
const manifest = createManifest();
|
|
287
|
+
await expect(
|
|
288
|
+
handleSkillGet({ id: "workflow-implement-track" }, manifest, 4),
|
|
289
|
+
).rejects.toThrow("appears to be a wrapper id");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("throws a wrapper guidance error when skill_get receives agent id", async () => {
|
|
293
|
+
const manifest = createManifest();
|
|
294
|
+
await expect(
|
|
295
|
+
handleSkillGet({ id: "agent-backend-specialist" }, manifest, 4),
|
|
296
|
+
).rejects.toThrow("appears to be a wrapper id");
|
|
297
|
+
});
|
|
298
|
+
|
|
157
299
|
it("returns consolidated budget rollup for selected and loaded skills", () => {
|
|
158
300
|
const manifest = createManifest();
|
|
159
301
|
const result = handleSkillBudgetReport(
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
extractDescription,
|
|
9
9
|
parseDescriptionFromFrontmatter,
|
|
10
10
|
readFullSkillContent,
|
|
11
|
+
readSkillContentWithReferences,
|
|
11
12
|
} from "./manifest.js";
|
|
12
13
|
|
|
13
14
|
const tempDirs: string[] = [];
|
|
@@ -90,6 +91,75 @@ describe("skill content IO", () => {
|
|
|
90
91
|
|
|
91
92
|
await expect(readFullSkillContent(file)).resolves.toBe(body);
|
|
92
93
|
});
|
|
94
|
+
|
|
95
|
+
it("loads direct local markdown references from SKILL.md", async () => {
|
|
96
|
+
const dir = createTempDir("mcp-refs-read-");
|
|
97
|
+
const file = path.join(dir, "SKILL.md");
|
|
98
|
+
const refsDir = path.join(dir, "references");
|
|
99
|
+
const refFile = path.join(refsDir, "guide.md");
|
|
100
|
+
const nestedRefFile = path.join(dir, "notes.md");
|
|
101
|
+
|
|
102
|
+
writeFileSync(
|
|
103
|
+
file,
|
|
104
|
+
[
|
|
105
|
+
"---",
|
|
106
|
+
"name: ref-read",
|
|
107
|
+
"description: Read refs",
|
|
108
|
+
"---",
|
|
109
|
+
"# Skill",
|
|
110
|
+
"See [Guide](references/guide.md).",
|
|
111
|
+
"See [Notes](notes.md#section).",
|
|
112
|
+
"Ignore [External](https://example.com).",
|
|
113
|
+
].join("\n"),
|
|
114
|
+
"utf8",
|
|
115
|
+
);
|
|
116
|
+
mkdirSync(refsDir, { recursive: true });
|
|
117
|
+
writeFileSync(refFile, "# Guide\nReference body", "utf8");
|
|
118
|
+
writeFileSync(nestedRefFile, "# Notes\nMore details", "utf8");
|
|
119
|
+
|
|
120
|
+
const result = await readSkillContentWithReferences(file, true);
|
|
121
|
+
expect(result.skillContent).toContain("# Skill");
|
|
122
|
+
expect(result.references).toEqual([
|
|
123
|
+
{ relativePath: "references/guide.md", content: "# Guide\nReference body" },
|
|
124
|
+
{ relativePath: "notes.md", content: "# Notes\nMore details" },
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("skips references when includeReferences is false", async () => {
|
|
129
|
+
const dir = createTempDir("mcp-refs-skip-");
|
|
130
|
+
const file = path.join(dir, "SKILL.md");
|
|
131
|
+
const refFile = path.join(dir, "reference.md");
|
|
132
|
+
writeFileSync(
|
|
133
|
+
file,
|
|
134
|
+
["---", "name: skip", "description: skip", "---", "[Ref](reference.md)"].join(
|
|
135
|
+
"\n",
|
|
136
|
+
),
|
|
137
|
+
"utf8",
|
|
138
|
+
);
|
|
139
|
+
writeFileSync(refFile, "ref", "utf8");
|
|
140
|
+
|
|
141
|
+
const result = await readSkillContentWithReferences(file, false);
|
|
142
|
+
expect(result.references).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("falls back to sibling markdown files when SKILL.md has no links", async () => {
|
|
146
|
+
const dir = createTempDir("mcp-refs-fallback-");
|
|
147
|
+
const file = path.join(dir, "SKILL.md");
|
|
148
|
+
const siblingRef = path.join(dir, "overview.md");
|
|
149
|
+
writeFileSync(
|
|
150
|
+
file,
|
|
151
|
+
["---", "name: fallback", "description: fallback", "---", "# Skill"].join(
|
|
152
|
+
"\n",
|
|
153
|
+
),
|
|
154
|
+
"utf8",
|
|
155
|
+
);
|
|
156
|
+
writeFileSync(siblingRef, "# Overview\nSibling content", "utf8");
|
|
157
|
+
|
|
158
|
+
const result = await readSkillContentWithReferences(file, true);
|
|
159
|
+
expect(result.references).toEqual([
|
|
160
|
+
{ relativePath: "overview.md", content: "# Overview\nSibling content" },
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
93
163
|
});
|
|
94
164
|
|
|
95
165
|
describe("manifest enrichment", () => {
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Cubis Foundry MCP Server – vault manifest.
|
|
3
3
|
*
|
|
4
4
|
* Browse/search operations extract frontmatter description only (truncated).
|
|
5
|
-
* Full SKILL.md content is read
|
|
5
|
+
* Full SKILL.md content (and direct referenced markdown files) is read by skill_get.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
9
10
|
import type { SkillPointer, VaultManifest } from "./types.js";
|
|
10
11
|
import { logger } from "../utils/logger.js";
|
|
11
12
|
import { estimateTokensFromBytes } from "../telemetry/tokenBudget.js";
|
|
@@ -87,12 +88,136 @@ export function parseDescriptionFromFrontmatter(
|
|
|
87
88
|
|
|
88
89
|
/**
|
|
89
90
|
* Read the full content of a SKILL.md file.
|
|
90
|
-
* This is the ONLY function that reads full file content (lazy model).
|
|
91
91
|
*/
|
|
92
92
|
export async function readFullSkillContent(skillPath: string): Promise<string> {
|
|
93
93
|
return readFile(skillPath, "utf8");
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
const MARKDOWN_LINK_RE = /\[[^\]]+\]\(([^)]+)\)/g;
|
|
97
|
+
const MAX_REFERENCED_FILES = 25;
|
|
98
|
+
|
|
99
|
+
export interface ReferencedSkillFile {
|
|
100
|
+
relativePath: string;
|
|
101
|
+
content: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeLinkTarget(rawTarget: string): string | null {
|
|
105
|
+
let target = String(rawTarget || "").trim();
|
|
106
|
+
if (!target) return null;
|
|
107
|
+
|
|
108
|
+
// Support links wrapped in angle brackets: [x](<references/doc.md>)
|
|
109
|
+
if (target.startsWith("<") && target.endsWith(">")) {
|
|
110
|
+
target = target.slice(1, -1).trim();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Strip optional title segment: [x](path "title")
|
|
114
|
+
const firstSpace = target.search(/\s/);
|
|
115
|
+
if (firstSpace > 0) {
|
|
116
|
+
target = target.slice(0, firstSpace).trim();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Ignore anchors/query fragments for file loading
|
|
120
|
+
target = target.split("#")[0].split("?")[0].trim();
|
|
121
|
+
if (!target) return null;
|
|
122
|
+
|
|
123
|
+
// Skip URLs/protocol links and absolute paths
|
|
124
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(target)) return null;
|
|
125
|
+
if (/^[a-zA-Z]:[\\/]/.test(target)) return null; // Windows absolute path
|
|
126
|
+
if (path.isAbsolute(target)) return null;
|
|
127
|
+
|
|
128
|
+
return target;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function collectReferencedMarkdownTargets(skillContent: string): string[] {
|
|
132
|
+
const targets: string[] = [];
|
|
133
|
+
const seen = new Set<string>();
|
|
134
|
+
|
|
135
|
+
for (const match of skillContent.matchAll(MARKDOWN_LINK_RE)) {
|
|
136
|
+
const raw = match[1];
|
|
137
|
+
const normalized = normalizeLinkTarget(raw);
|
|
138
|
+
if (!normalized) continue;
|
|
139
|
+
if (!normalized.toLowerCase().endsWith(".md")) continue;
|
|
140
|
+
if (seen.has(normalized)) continue;
|
|
141
|
+
seen.add(normalized);
|
|
142
|
+
targets.push(normalized);
|
|
143
|
+
if (targets.length >= MAX_REFERENCED_FILES) break;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return targets;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function readReferencedMarkdownFiles(
|
|
150
|
+
skillPath: string,
|
|
151
|
+
skillContent: string,
|
|
152
|
+
): Promise<ReferencedSkillFile[]> {
|
|
153
|
+
const skillDir = path.dirname(skillPath);
|
|
154
|
+
let targets = collectReferencedMarkdownTargets(skillContent);
|
|
155
|
+
if (targets.length === 0) {
|
|
156
|
+
targets = await collectSiblingMarkdownTargets(skillDir);
|
|
157
|
+
}
|
|
158
|
+
const references: ReferencedSkillFile[] = [];
|
|
159
|
+
|
|
160
|
+
for (const target of targets) {
|
|
161
|
+
const resolved = path.resolve(skillDir, target);
|
|
162
|
+
const relative = path.relative(skillDir, resolved);
|
|
163
|
+
|
|
164
|
+
// Prevent path traversal outside the skill directory.
|
|
165
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (path.basename(resolved).toLowerCase() === "skill.md") continue;
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const content = await readFile(resolved, "utf8");
|
|
173
|
+
references.push({
|
|
174
|
+
relativePath: relative.split(path.sep).join("/"),
|
|
175
|
+
content,
|
|
176
|
+
});
|
|
177
|
+
} catch (err) {
|
|
178
|
+
logger.debug(`Failed to read referenced markdown ${resolved}: ${err}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return references;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function collectSiblingMarkdownTargets(skillDir: string): Promise<string[]> {
|
|
186
|
+
const entries = await readdir(skillDir, { withFileTypes: true }).catch(
|
|
187
|
+
() => [],
|
|
188
|
+
);
|
|
189
|
+
const targets: string[] = [];
|
|
190
|
+
|
|
191
|
+
for (const entry of entries) {
|
|
192
|
+
if (!entry.isFile()) continue;
|
|
193
|
+
if (entry.name.startsWith(".")) continue;
|
|
194
|
+
if (!entry.name.toLowerCase().endsWith(".md")) continue;
|
|
195
|
+
if (entry.name.toLowerCase() === "skill.md") continue;
|
|
196
|
+
targets.push(entry.name);
|
|
197
|
+
if (targets.length >= MAX_REFERENCED_FILES) break;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
targets.sort((a, b) => a.localeCompare(b));
|
|
201
|
+
return targets;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Read full SKILL.md content and any direct local markdown references declared
|
|
206
|
+
* in the skill document. Reference discovery is one-hop (no recursive crawling).
|
|
207
|
+
*/
|
|
208
|
+
export async function readSkillContentWithReferences(
|
|
209
|
+
skillPath: string,
|
|
210
|
+
includeReferences = true,
|
|
211
|
+
): Promise<{ skillContent: string; references: ReferencedSkillFile[] }> {
|
|
212
|
+
const skillContent = await readFullSkillContent(skillPath);
|
|
213
|
+
if (!includeReferences) {
|
|
214
|
+
return { skillContent, references: [] };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const references = await readReferencedMarkdownFiles(skillPath, skillContent);
|
|
218
|
+
return { skillContent, references };
|
|
219
|
+
}
|
|
220
|
+
|
|
96
221
|
/**
|
|
97
222
|
* Enrich skill pointers with descriptions (for browse/search results).
|
|
98
223
|
*/
|
|
@@ -22,6 +22,12 @@ function createSkill(root: string, id: string): void {
|
|
|
22
22
|
);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function createSkillFromContent(root: string, id: string, content: string): void {
|
|
26
|
+
const skillDir = path.join(root, id);
|
|
27
|
+
mkdirSync(skillDir, { recursive: true });
|
|
28
|
+
writeFileSync(path.join(skillDir, "SKILL.md"), content, "utf8");
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
afterEach(() => {
|
|
26
32
|
while (tempDirs.length > 0) {
|
|
27
33
|
const dir = tempDirs.pop();
|
|
@@ -68,4 +74,33 @@ describe("scanVaultRoots", () => {
|
|
|
68
74
|
expect(skills[0].id).toBe("qa-automation-engineer");
|
|
69
75
|
expect(skills[0].category).toBe("testing");
|
|
70
76
|
});
|
|
77
|
+
|
|
78
|
+
it("excludes codex wrapper skills from vault discovery", async () => {
|
|
79
|
+
const root = createTempDir("mcp-vault-wrapper-");
|
|
80
|
+
|
|
81
|
+
createSkill(root, "lint-and-validate");
|
|
82
|
+
createSkill(root, "tdd-workflow");
|
|
83
|
+
createSkill(root, "workflow-implement-track");
|
|
84
|
+
createSkill(root, "agent-backend-specialist");
|
|
85
|
+
createSkillFromContent(
|
|
86
|
+
root,
|
|
87
|
+
"custom-wrapper-id",
|
|
88
|
+
[
|
|
89
|
+
"---",
|
|
90
|
+
"name: custom-wrapper-id",
|
|
91
|
+
"description: Wrapper by metadata only",
|
|
92
|
+
"metadata:",
|
|
93
|
+
" wrapper: workflow",
|
|
94
|
+
"---",
|
|
95
|
+
"",
|
|
96
|
+
"# wrapper",
|
|
97
|
+
"",
|
|
98
|
+
].join("\n"),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const skills = await scanVaultRoots([root], "/unused");
|
|
102
|
+
const ids = skills.map((s) => s.id).sort();
|
|
103
|
+
|
|
104
|
+
expect(ids).toEqual(["lint-and-validate", "tdd-workflow"]);
|
|
105
|
+
});
|
|
71
106
|
});
|
package/mcp/src/vault/scanner.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Description extraction is deferred to browse/search operations.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readdir, stat } from "node:fs/promises";
|
|
8
|
+
import { open, readdir, stat } from "node:fs/promises";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { logger } from "../utils/logger.js";
|
|
11
11
|
import type { SkillPointer } from "./types.js";
|
|
@@ -40,6 +40,14 @@ export async function scanVaultRoots(
|
|
|
40
40
|
const skillStat = await stat(skillFile).catch(() => null);
|
|
41
41
|
if (!skillStat?.isFile()) continue;
|
|
42
42
|
|
|
43
|
+
const wrapperKind = await detectWrapperKind(entry, skillFile);
|
|
44
|
+
if (wrapperKind) {
|
|
45
|
+
logger.debug(
|
|
46
|
+
`Skipping wrapper skill ${entry} (${wrapperKind}) at ${skillFile}`,
|
|
47
|
+
);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
// Derive category from the skill's frontmatter or default to "general"
|
|
44
52
|
// At scan time we only store the path; category is derived from directory structure
|
|
45
53
|
skills.push({
|
|
@@ -55,6 +63,88 @@ export async function scanVaultRoots(
|
|
|
55
63
|
return skills;
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
const WRAPPER_PREFIXES = ["workflow-", "agent-"] as const;
|
|
67
|
+
const WRAPPER_KINDS = new Set(["workflow", "agent"]);
|
|
68
|
+
const FRONTMATTER_PREVIEW_BYTES = 8192;
|
|
69
|
+
|
|
70
|
+
function extractWrapperKindFromId(skillId: string): "workflow" | "agent" | null {
|
|
71
|
+
const lower = skillId.toLowerCase();
|
|
72
|
+
if (lower.startsWith(WRAPPER_PREFIXES[0])) return "workflow";
|
|
73
|
+
if (lower.startsWith(WRAPPER_PREFIXES[1])) return "agent";
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function readFrontmatterPreview(skillFile: string): Promise<string | null> {
|
|
78
|
+
const handle = await open(skillFile, "r").catch(() => null);
|
|
79
|
+
if (!handle) return null;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// Read only a small head chunk; wrapper metadata lives in frontmatter.
|
|
83
|
+
const buffer = Buffer.alloc(FRONTMATTER_PREVIEW_BYTES);
|
|
84
|
+
const { bytesRead } = await handle.read(
|
|
85
|
+
buffer,
|
|
86
|
+
0,
|
|
87
|
+
FRONTMATTER_PREVIEW_BYTES,
|
|
88
|
+
0,
|
|
89
|
+
);
|
|
90
|
+
return buffer.toString("utf8", 0, bytesRead);
|
|
91
|
+
} finally {
|
|
92
|
+
await handle.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseFrontmatter(rawPreview: string): string | null {
|
|
97
|
+
const match = rawPreview.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
98
|
+
return match?.[1] ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function extractMetadataWrapper(frontmatter: string): string | null {
|
|
102
|
+
const lines = frontmatter.split(/\r?\n/);
|
|
103
|
+
let inMetadata = false;
|
|
104
|
+
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
if (!inMetadata) {
|
|
107
|
+
if (/^\s*metadata\s*:\s*$/.test(line)) {
|
|
108
|
+
inMetadata = true;
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!line.trim()) continue;
|
|
114
|
+
if (!/^\s+/.test(line)) break;
|
|
115
|
+
|
|
116
|
+
const match = line.match(/^\s+wrapper\s*:\s*(.+)\s*$/);
|
|
117
|
+
if (!match) continue;
|
|
118
|
+
|
|
119
|
+
const value = match[1].trim().replace(/^['"]|['"]$/g, "").toLowerCase();
|
|
120
|
+
if (WRAPPER_KINDS.has(value)) {
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function detectWrapperKind(
|
|
129
|
+
skillId: string,
|
|
130
|
+
skillFile: string,
|
|
131
|
+
): Promise<"workflow" | "agent" | null> {
|
|
132
|
+
const byId = extractWrapperKindFromId(skillId);
|
|
133
|
+
if (byId) return byId;
|
|
134
|
+
|
|
135
|
+
const rawPreview = await readFrontmatterPreview(skillFile);
|
|
136
|
+
if (!rawPreview) return null;
|
|
137
|
+
const frontmatter = parseFrontmatter(rawPreview);
|
|
138
|
+
if (!frontmatter) return null;
|
|
139
|
+
|
|
140
|
+
const byMetadata = extractMetadataWrapper(frontmatter);
|
|
141
|
+
if (byMetadata === "workflow" || byMetadata === "agent") {
|
|
142
|
+
return byMetadata;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
58
148
|
/**
|
|
59
149
|
* Simple category derivation from skill ID conventions.
|
|
60
150
|
* Skills with common prefixes are grouped together.
|
package/package.json
CHANGED
|
@@ -79,6 +79,12 @@ After finishing skill selection/loading, publish:
|
|
|
79
79
|
- `loaded_skills`: skill IDs loaded via `skill_get`
|
|
80
80
|
- `skipped_skills`: considered but not loaded
|
|
81
81
|
|
|
82
|
+
Workflow boundary for this block:
|
|
83
|
+
|
|
84
|
+
- `selected_skills` / `loaded_skills` must never include workflow IDs.
|
|
85
|
+
- IDs like `workflow-implement-track` are workflow routes, not skills.
|
|
86
|
+
- Never call `skill_get` with `workflow-*`; keep workflow mentions in the workflow decision log using raw $workflow-* wrappers.
|
|
87
|
+
|
|
82
88
|
## Context Budget Block (Required, Estimated)
|
|
83
89
|
|
|
84
90
|
Immediately after the Skill Log block, publish estimated budget fields:
|
|
@@ -87,6 +87,12 @@ After finishing skill selection/loading, publish:
|
|
|
87
87
|
- `loaded_skills`: skill IDs loaded via `skill_get`
|
|
88
88
|
- `skipped_skills`: considered but not loaded
|
|
89
89
|
|
|
90
|
+
Workflow boundary for this block:
|
|
91
|
+
|
|
92
|
+
- `selected_skills` / `loaded_skills` must never include workflow IDs.
|
|
93
|
+
- IDs like `workflow-implement-track` are workflow routes, not skills.
|
|
94
|
+
- Never call `skill_get` with `workflow-*`; keep workflow mentions in workflow decisions (`/workflow`) and keep skill logs skill-only.
|
|
95
|
+
|
|
90
96
|
## Context Budget Block (Required, Estimated)
|
|
91
97
|
|
|
92
98
|
Immediately after the Skill Log block, publish estimated budget fields:
|
package/workflows/workflows/agent-environment-setup/platforms/copilot/rules/copilot-instructions.md
CHANGED
|
@@ -87,6 +87,12 @@ After finishing skill selection/loading, publish:
|
|
|
87
87
|
- `loaded_skills`: skill IDs loaded via `skill_get`
|
|
88
88
|
- `skipped_skills`: considered but not loaded
|
|
89
89
|
|
|
90
|
+
Workflow boundary for this block:
|
|
91
|
+
|
|
92
|
+
- `selected_skills` / `loaded_skills` must never include workflow IDs.
|
|
93
|
+
- IDs like `workflow-implement-track` are workflow routes, not skills.
|
|
94
|
+
- Never call `skill_get` with `workflow-*`; keep workflow mentions in workflow decisions (`/workflow`) and keep skill logs skill-only.
|
|
95
|
+
|
|
90
96
|
## Context Budget Block (Required, Estimated)
|
|
91
97
|
|
|
92
98
|
Immediately after the Skill Log block, publish estimated budget fields:
|