@cubis/foundry 0.3.45 → 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/Dockerfile CHANGED
@@ -1,4 +1,4 @@
1
- FROM node:22-alpine AS build
1
+ FROM --platform=$BUILDPLATFORM node:22-alpine AS build
2
2
 
3
3
  WORKDIR /app
4
4
  COPY package*.json ./
@@ -7,8 +7,8 @@
7
7
 
8
8
  import { z } from "zod";
9
9
  import type { VaultManifest } from "../vault/types.js";
10
- import { readFullSkillContent } from "../vault/manifest.js";
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 the full content of a specific skill by ID. Returns the complete SKILL.md file content.";
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 content = await readFullSkillContent(skill.path);
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 only by skill_get.
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
  });
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubis/foundry",
3
- "version": "0.3.45",
3
+ "version": "0.3.47",
4
4
  "description": "Cubis Foundry CLI for workflow-first AI agent environments",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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:
@@ -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: