@studio-foundation/runner 0.3.0-beta.1

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 (268) hide show
  1. package/ARCHITECTURE.md +53 -0
  2. package/LICENSE +663 -0
  3. package/README.md +125 -0
  4. package/configs/agents/analyst.agent.yaml +31 -0
  5. package/configs/agents/code-generator.agent.yaml +31 -0
  6. package/configs/agents/generic.agent.yaml +23 -0
  7. package/dist/__tests__/script-executor.test.d.ts +2 -0
  8. package/dist/__tests__/script-executor.test.d.ts.map +1 -0
  9. package/dist/__tests__/script-executor.test.js +173 -0
  10. package/dist/__tests__/script-executor.test.js.map +1 -0
  11. package/dist/index.d.ts +38 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +76 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/integrations/integration-loader.d.ts +20 -0
  16. package/dist/integrations/integration-loader.d.ts.map +1 -0
  17. package/dist/integrations/integration-loader.js +71 -0
  18. package/dist/integrations/integration-loader.js.map +1 -0
  19. package/dist/integrations/integration-loader.test.d.ts +2 -0
  20. package/dist/integrations/integration-loader.test.d.ts.map +1 -0
  21. package/dist/integrations/integration-loader.test.js +77 -0
  22. package/dist/integrations/integration-loader.test.js.map +1 -0
  23. package/dist/middleware/anonymization.d.ts +22 -0
  24. package/dist/middleware/anonymization.d.ts.map +1 -0
  25. package/dist/middleware/anonymization.js +37 -0
  26. package/dist/middleware/anonymization.js.map +1 -0
  27. package/dist/plugins/index.d.ts +5 -0
  28. package/dist/plugins/index.d.ts.map +1 -0
  29. package/dist/plugins/index.js +10 -0
  30. package/dist/plugins/index.js.map +1 -0
  31. package/dist/plugins/mcp-client.d.ts +19 -0
  32. package/dist/plugins/mcp-client.d.ts.map +1 -0
  33. package/dist/plugins/mcp-client.js +126 -0
  34. package/dist/plugins/mcp-client.js.map +1 -0
  35. package/dist/plugins/mcp-client.test.d.ts +2 -0
  36. package/dist/plugins/mcp-client.test.d.ts.map +1 -0
  37. package/dist/plugins/mcp-client.test.js +160 -0
  38. package/dist/plugins/mcp-client.test.js.map +1 -0
  39. package/dist/plugins/oauth-provider.d.ts +27 -0
  40. package/dist/plugins/oauth-provider.d.ts.map +1 -0
  41. package/dist/plugins/oauth-provider.js +140 -0
  42. package/dist/plugins/oauth-provider.js.map +1 -0
  43. package/dist/plugins/oauth-provider.test.d.ts +2 -0
  44. package/dist/plugins/oauth-provider.test.d.ts.map +1 -0
  45. package/dist/plugins/oauth-provider.test.js +144 -0
  46. package/dist/plugins/oauth-provider.test.js.map +1 -0
  47. package/dist/plugins/plugin-loader.d.ts +29 -0
  48. package/dist/plugins/plugin-loader.d.ts.map +1 -0
  49. package/dist/plugins/plugin-loader.js +71 -0
  50. package/dist/plugins/plugin-loader.js.map +1 -0
  51. package/dist/plugins/plugin-loader.test.d.ts +2 -0
  52. package/dist/plugins/plugin-loader.test.d.ts.map +1 -0
  53. package/dist/plugins/plugin-loader.test.js +87 -0
  54. package/dist/plugins/plugin-loader.test.js.map +1 -0
  55. package/dist/prompt-builder.d.ts +47 -0
  56. package/dist/prompt-builder.d.ts.map +1 -0
  57. package/dist/prompt-builder.js +263 -0
  58. package/dist/prompt-builder.js.map +1 -0
  59. package/dist/prompt-builder.test.d.ts +2 -0
  60. package/dist/prompt-builder.test.d.ts.map +1 -0
  61. package/dist/prompt-builder.test.js +154 -0
  62. package/dist/prompt-builder.test.js.map +1 -0
  63. package/dist/providers/anthropic.d.ts +14 -0
  64. package/dist/providers/anthropic.d.ts.map +1 -0
  65. package/dist/providers/anthropic.js +118 -0
  66. package/dist/providers/anthropic.js.map +1 -0
  67. package/dist/providers/anthropic.test.d.ts +2 -0
  68. package/dist/providers/anthropic.test.d.ts.map +1 -0
  69. package/dist/providers/anthropic.test.js +76 -0
  70. package/dist/providers/anthropic.test.js.map +1 -0
  71. package/dist/providers/mock.d.ts +17 -0
  72. package/dist/providers/mock.d.ts.map +1 -0
  73. package/dist/providers/mock.js +39 -0
  74. package/dist/providers/mock.js.map +1 -0
  75. package/dist/providers/ollama.d.ts +18 -0
  76. package/dist/providers/ollama.d.ts.map +1 -0
  77. package/dist/providers/ollama.js +155 -0
  78. package/dist/providers/ollama.js.map +1 -0
  79. package/dist/providers/ollama.test.d.ts +2 -0
  80. package/dist/providers/ollama.test.d.ts.map +1 -0
  81. package/dist/providers/ollama.test.js +123 -0
  82. package/dist/providers/ollama.test.js.map +1 -0
  83. package/dist/providers/openai-responses.d.ts +18 -0
  84. package/dist/providers/openai-responses.d.ts.map +1 -0
  85. package/dist/providers/openai-responses.js +175 -0
  86. package/dist/providers/openai-responses.js.map +1 -0
  87. package/dist/providers/openai.d.ts +16 -0
  88. package/dist/providers/openai.d.ts.map +1 -0
  89. package/dist/providers/openai.js +132 -0
  90. package/dist/providers/openai.js.map +1 -0
  91. package/dist/providers/openai.test.d.ts +2 -0
  92. package/dist/providers/openai.test.d.ts.map +1 -0
  93. package/dist/providers/openai.test.js +51 -0
  94. package/dist/providers/openai.test.js.map +1 -0
  95. package/dist/providers/provider.d.ts +42 -0
  96. package/dist/providers/provider.d.ts.map +1 -0
  97. package/dist/providers/provider.js +10 -0
  98. package/dist/providers/provider.js.map +1 -0
  99. package/dist/providers/registry.d.ts +42 -0
  100. package/dist/providers/registry.d.ts.map +1 -0
  101. package/dist/providers/registry.js +65 -0
  102. package/dist/providers/registry.js.map +1 -0
  103. package/dist/runner.d.ts +46 -0
  104. package/dist/runner.d.ts.map +1 -0
  105. package/dist/runner.js +324 -0
  106. package/dist/runner.js.map +1 -0
  107. package/dist/runner.test.d.ts +2 -0
  108. package/dist/runner.test.d.ts.map +1 -0
  109. package/dist/runner.test.js +280 -0
  110. package/dist/runner.test.js.map +1 -0
  111. package/dist/script-executor.d.ts +11 -0
  112. package/dist/script-executor.d.ts.map +1 -0
  113. package/dist/script-executor.js +85 -0
  114. package/dist/script-executor.js.map +1 -0
  115. package/dist/tools/builtin/git.d.ts +6 -0
  116. package/dist/tools/builtin/git.d.ts.map +1 -0
  117. package/dist/tools/builtin/git.js +294 -0
  118. package/dist/tools/builtin/git.js.map +1 -0
  119. package/dist/tools/builtin/patch.d.ts +6 -0
  120. package/dist/tools/builtin/patch.d.ts.map +1 -0
  121. package/dist/tools/builtin/patch.js +238 -0
  122. package/dist/tools/builtin/patch.js.map +1 -0
  123. package/dist/tools/builtin/repo-manager.d.ts +6 -0
  124. package/dist/tools/builtin/repo-manager.d.ts.map +1 -0
  125. package/dist/tools/builtin/repo-manager.js +171 -0
  126. package/dist/tools/builtin/repo-manager.js.map +1 -0
  127. package/dist/tools/builtin/search.d.ts +6 -0
  128. package/dist/tools/builtin/search.d.ts.map +1 -0
  129. package/dist/tools/builtin/search.js +104 -0
  130. package/dist/tools/builtin/search.js.map +1 -0
  131. package/dist/tools/builtin/shell.d.ts +6 -0
  132. package/dist/tools/builtin/shell.d.ts.map +1 -0
  133. package/dist/tools/builtin/shell.js +78 -0
  134. package/dist/tools/builtin/shell.js.map +1 -0
  135. package/dist/tools/builtin/studio-run.d.ts +12 -0
  136. package/dist/tools/builtin/studio-run.d.ts.map +1 -0
  137. package/dist/tools/builtin/studio-run.js +61 -0
  138. package/dist/tools/builtin/studio-run.js.map +1 -0
  139. package/dist/tools/builtin/web-search.d.ts +7 -0
  140. package/dist/tools/builtin/web-search.d.ts.map +1 -0
  141. package/dist/tools/builtin/web-search.js +94 -0
  142. package/dist/tools/builtin/web-search.js.map +1 -0
  143. package/dist/tools/builtin/web-search.test.d.ts +5 -0
  144. package/dist/tools/builtin/web-search.test.d.ts.map +1 -0
  145. package/dist/tools/builtin/web-search.test.js +90 -0
  146. package/dist/tools/builtin/web-search.test.js.map +1 -0
  147. package/dist/tools/errors.d.ts +4 -0
  148. package/dist/tools/errors.d.ts.map +1 -0
  149. package/dist/tools/errors.js +11 -0
  150. package/dist/tools/errors.js.map +1 -0
  151. package/dist/tools/errors.test.d.ts +2 -0
  152. package/dist/tools/errors.test.d.ts.map +1 -0
  153. package/dist/tools/errors.test.js +14 -0
  154. package/dist/tools/errors.test.js.map +1 -0
  155. package/dist/tools/plugin-loader.d.ts +31 -0
  156. package/dist/tools/plugin-loader.d.ts.map +1 -0
  157. package/dist/tools/plugin-loader.js +183 -0
  158. package/dist/tools/plugin-loader.js.map +1 -0
  159. package/dist/tools/plugin-loader.test.d.ts +2 -0
  160. package/dist/tools/plugin-loader.test.d.ts.map +1 -0
  161. package/dist/tools/plugin-loader.test.js +116 -0
  162. package/dist/tools/plugin-loader.test.js.map +1 -0
  163. package/dist/tools/skills/skill-loader.d.ts +23 -0
  164. package/dist/tools/skills/skill-loader.d.ts.map +1 -0
  165. package/dist/tools/skills/skill-loader.js +58 -0
  166. package/dist/tools/skills/skill-loader.js.map +1 -0
  167. package/dist/tools/skills/skill-loader.test.d.ts +2 -0
  168. package/dist/tools/skills/skill-loader.test.d.ts.map +1 -0
  169. package/dist/tools/skills/skill-loader.test.js +92 -0
  170. package/dist/tools/skills/skill-loader.test.js.map +1 -0
  171. package/dist/tools/tool-executor.d.ts +12 -0
  172. package/dist/tools/tool-executor.d.ts.map +1 -0
  173. package/dist/tools/tool-executor.js +76 -0
  174. package/dist/tools/tool-executor.js.map +1 -0
  175. package/dist/tools/tool-executor.test.d.ts +2 -0
  176. package/dist/tools/tool-executor.test.d.ts.map +1 -0
  177. package/dist/tools/tool-executor.test.js +83 -0
  178. package/dist/tools/tool-executor.test.js.map +1 -0
  179. package/dist/tools/tool-registry.d.ts +65 -0
  180. package/dist/tools/tool-registry.d.ts.map +1 -0
  181. package/dist/tools/tool-registry.js +112 -0
  182. package/dist/tools/tool-registry.js.map +1 -0
  183. package/dist/tools/yaml-executor.d.ts +22 -0
  184. package/dist/tools/yaml-executor.d.ts.map +1 -0
  185. package/dist/tools/yaml-executor.js +80 -0
  186. package/dist/tools/yaml-executor.js.map +1 -0
  187. package/dist/utils/race-signal.d.ts +6 -0
  188. package/dist/utils/race-signal.d.ts.map +1 -0
  189. package/dist/utils/race-signal.js +21 -0
  190. package/dist/utils/race-signal.js.map +1 -0
  191. package/dist/utils/race-signal.test.d.ts +2 -0
  192. package/dist/utils/race-signal.test.d.ts.map +1 -0
  193. package/dist/utils/race-signal.test.js +48 -0
  194. package/dist/utils/race-signal.test.js.map +1 -0
  195. package/package.json +36 -0
  196. package/src/__tests__/script-executor.test.ts +180 -0
  197. package/src/index.ts +68 -0
  198. package/src/integrations/integration-loader.test.ts +88 -0
  199. package/src/integrations/integration-loader.ts +68 -0
  200. package/src/middleware/anonymization.ts +38 -0
  201. package/src/plugins/index.ts +4 -0
  202. package/src/plugins/mcp-client.test.ts +148 -0
  203. package/src/plugins/mcp-client.ts +128 -0
  204. package/src/plugins/oauth-provider.test.ts +167 -0
  205. package/src/plugins/oauth-provider.ts +175 -0
  206. package/src/plugins/plugin-loader.test.ts +114 -0
  207. package/src/plugins/plugin-loader.ts +90 -0
  208. package/src/prompt-builder.test.ts +167 -0
  209. package/src/prompt-builder.ts +332 -0
  210. package/src/providers/anthropic.test.ts +101 -0
  211. package/src/providers/anthropic.ts +135 -0
  212. package/src/providers/mock.ts +57 -0
  213. package/src/providers/ollama.test.ts +166 -0
  214. package/src/providers/ollama.ts +152 -0
  215. package/src/providers/openai-responses.ts +212 -0
  216. package/src/providers/openai.test.ts +67 -0
  217. package/src/providers/openai.ts +139 -0
  218. package/src/providers/provider.ts +54 -0
  219. package/src/providers/registry.ts +77 -0
  220. package/src/runner.test.ts +343 -0
  221. package/src/runner.ts +396 -0
  222. package/src/script-executor.ts +107 -0
  223. package/src/tools/builtin/git.ts +311 -0
  224. package/src/tools/builtin/patch.ts +257 -0
  225. package/src/tools/builtin/repo-manager.ts +142 -0
  226. package/src/tools/builtin/search.ts +108 -0
  227. package/src/tools/builtin/shell.ts +82 -0
  228. package/src/tools/builtin/studio-run.ts +73 -0
  229. package/src/tools/builtin/web-search.test.ts +122 -0
  230. package/src/tools/builtin/web-search.ts +101 -0
  231. package/src/tools/errors.test.ts +12 -0
  232. package/src/tools/errors.ts +6 -0
  233. package/src/tools/plugin-loader.test.ts +130 -0
  234. package/src/tools/plugin-loader.ts +203 -0
  235. package/src/tools/skills/README.md +49 -0
  236. package/src/tools/skills/skill-loader.test.ts +106 -0
  237. package/src/tools/skills/skill-loader.ts +62 -0
  238. package/src/tools/tool-executor.test.ts +88 -0
  239. package/src/tools/tool-executor.ts +84 -0
  240. package/src/tools/tool-registry.ts +130 -0
  241. package/src/tools/yaml-executor.ts +120 -0
  242. package/src/utils/race-signal.test.ts +50 -0
  243. package/src/utils/race-signal.ts +17 -0
  244. package/templates/integrations/linear.integration.yaml +35 -0
  245. package/templates/integrations/slack.integration.yaml +22 -0
  246. package/templates/integrations/webhook.integration.yaml +17 -0
  247. package/templates/tools/git.tool.yaml +80 -0
  248. package/templates/tools/repo-manager.tool.yaml +64 -0
  249. package/templates/tools/search.tool.yaml +22 -0
  250. package/templates/tools/shell.tool.yaml +19 -0
  251. package/templates/tools/web-search.tool.yaml +24 -0
  252. package/tests/anonymization-middleware.test.ts +61 -0
  253. package/tests/anthropic.test.ts +87 -0
  254. package/tests/apply-patch.test.ts +355 -0
  255. package/tests/fixtures/tools/test-builtin.tool.yaml +14 -0
  256. package/tests/fixtures/tools/test-shell.tool.yaml +19 -0
  257. package/tests/mock-provider.test.ts +104 -0
  258. package/tests/openai.test.ts +72 -0
  259. package/tests/plugin-loader.test.ts +54 -0
  260. package/tests/prompt-builder.test.ts +468 -0
  261. package/tests/runner-anonymization.test.ts +89 -0
  262. package/tests/runner.test.ts +885 -0
  263. package/tests/studio-run.test.ts +94 -0
  264. package/tests/tool-executor.test.ts +115 -0
  265. package/tests/tool-registry.test.ts +84 -0
  266. package/tests/yaml-executor.test.ts +76 -0
  267. package/tsconfig.json +20 -0
  268. package/vitest.config.ts +7 -0
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { loadPlugins } from './plugin-loader.js';
6
+
7
+ let tmpDir: string;
8
+
9
+ beforeAll(async () => {
10
+ tmpDir = await mkdtemp(join(tmpdir(), 'studio-plugin-test-'));
11
+
12
+ // Plugin 1: code-review — has .mcp.json + skills
13
+ const plugin1 = join(tmpDir, 'code-review');
14
+ await mkdir(join(plugin1, 'skills'), { recursive: true });
15
+ await writeFile(
16
+ join(plugin1, '.mcp.json'),
17
+ JSON.stringify({
18
+ mcpServers: {
19
+ github: { command: 'npx', args: ['-y', '@mcp/server-github'] },
20
+ },
21
+ })
22
+ );
23
+ await writeFile(
24
+ join(plugin1, 'skills', 'review-guidelines.skill.md'),
25
+ '# Review Guidelines\n\nAlways check for security issues.'
26
+ );
27
+ await writeFile(
28
+ join(plugin1, 'skills', 'security-checklist.skill.md'),
29
+ '# Security Checklist\n\n- Check SQL injection\n- Check XSS'
30
+ );
31
+
32
+ // Plugin 2: analysis — skills only, no .mcp.json
33
+ const plugin2 = join(tmpDir, 'analysis');
34
+ await mkdir(join(plugin2, 'skills'), { recursive: true });
35
+ await writeFile(
36
+ join(plugin2, 'skills', 'analysis-tips.skill.md'),
37
+ '# Analysis Tips\n\nBe thorough.'
38
+ );
39
+
40
+ // Plugin 3: empty — no .mcp.json, no skills
41
+ await mkdir(join(tmpDir, 'empty-plugin'));
42
+
43
+ // Plugin 4: flat-plugin — flat .mcp.json (Claude Code format, no mcpServers wrapper)
44
+ const plugin4 = join(tmpDir, 'flat-plugin');
45
+ await mkdir(plugin4, { recursive: true });
46
+ await writeFile(
47
+ join(plugin4, '.mcp.json'),
48
+ JSON.stringify({
49
+ context7: { command: 'npx', args: ['-y', '@upstash/context7-mcp'] },
50
+ })
51
+ );
52
+ });
53
+
54
+ afterAll(async () => {
55
+ await rm(tmpDir, { recursive: true, force: true });
56
+ });
57
+
58
+ describe('loadPlugins', () => {
59
+ it('returns empty array when plugins dir does not exist', async () => {
60
+ const result = await loadPlugins('/nonexistent/path/to/plugins');
61
+ expect(result).toEqual([]);
62
+ });
63
+
64
+ it('loads all plugin directories', async () => {
65
+ const result = await loadPlugins(tmpDir);
66
+ expect(result).toHaveLength(4);
67
+ const names = result.map((p) => p.name).sort();
68
+ expect(names).toEqual(['analysis', 'code-review', 'empty-plugin', 'flat-plugin']);
69
+ });
70
+
71
+ it('parses .mcp.json into mcpServers', async () => {
72
+ const result = await loadPlugins(tmpDir);
73
+ const codeReview = result.find((p) => p.name === 'code-review')!;
74
+ expect(codeReview.mcpServers).toEqual({
75
+ github: { command: 'npx', args: ['-y', '@mcp/server-github'] },
76
+ });
77
+ });
78
+
79
+ it('sets mcpServers to empty object when no .mcp.json', async () => {
80
+ const result = await loadPlugins(tmpDir);
81
+ const analysis = result.find((p) => p.name === 'analysis')!;
82
+ expect(analysis.mcpServers).toEqual({});
83
+ });
84
+
85
+ it('loads skill files sorted by name', async () => {
86
+ const result = await loadPlugins(tmpDir);
87
+ const codeReview = result.find((p) => p.name === 'code-review')!;
88
+ expect(codeReview.skills).toHaveLength(2);
89
+ expect(codeReview.skills[0].name).toBe('review-guidelines');
90
+ expect(codeReview.skills[0].content).toContain('Review Guidelines');
91
+ expect(codeReview.skills[1].name).toBe('security-checklist');
92
+ });
93
+
94
+ it('returns empty skills array when no skills dir', async () => {
95
+ const result = await loadPlugins(tmpDir);
96
+ const empty = result.find((p) => p.name === 'empty-plugin')!;
97
+ expect(empty.skills).toEqual([]);
98
+ expect(empty.mcpServers).toEqual({});
99
+ });
100
+
101
+ it('sets path to absolute path of plugin directory', async () => {
102
+ const result = await loadPlugins(tmpDir);
103
+ const codeReview = result.find((p) => p.name === 'code-review')!;
104
+ expect(codeReview.path).toBe(join(tmpDir, 'code-review'));
105
+ });
106
+
107
+ it('parses flat .mcp.json without mcpServers wrapper — Claude Code format', async () => {
108
+ const result = await loadPlugins(tmpDir);
109
+ const flatPlugin = result.find((p) => p.name === 'flat-plugin')!;
110
+ expect(flatPlugin.mcpServers).toEqual({
111
+ context7: { command: 'npx', args: ['-y', '@upstash/context7-mcp'] },
112
+ });
113
+ });
114
+ });
@@ -0,0 +1,90 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, basename } from 'node:path';
4
+
5
+ export type MCPServerDef =
6
+ | { type?: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }
7
+ | {
8
+ type: 'http';
9
+ url: string;
10
+ headers?: Record<string, string>;
11
+ env?: Record<string, string>;
12
+ auth?: { type: 'oauth'; client_id?: string; client_secret?: string; scope?: string };
13
+ };
14
+
15
+ export interface SkillContent {
16
+ name: string; // filename without .skill.md
17
+ content: string; // markdown content
18
+ }
19
+
20
+ export interface PluginManifest {
21
+ name: string;
22
+ path: string;
23
+ mcpServers: Record<string, MCPServerDef>;
24
+ skills: SkillContent[];
25
+ }
26
+
27
+ export async function loadPlugins(pluginsDir: string): Promise<PluginManifest[]> {
28
+ if (!existsSync(pluginsDir)) return [];
29
+
30
+ let entries: import('node:fs').Dirent[];
31
+ try {
32
+ entries = await readdir(pluginsDir, { withFileTypes: true });
33
+ } catch {
34
+ return [];
35
+ }
36
+
37
+ const manifests: PluginManifest[] = [];
38
+ for (const entry of entries) {
39
+ if (!entry.isDirectory()) continue;
40
+ const pluginPath = join(pluginsDir, entry.name);
41
+ manifests.push(await loadPlugin(entry.name, pluginPath));
42
+ }
43
+ return manifests;
44
+ }
45
+
46
+ async function loadPlugin(name: string, pluginPath: string): Promise<PluginManifest> {
47
+ const mcpPath = join(pluginPath, '.mcp.json');
48
+ let mcpServers: Record<string, MCPServerDef> = {};
49
+ if (existsSync(mcpPath)) {
50
+ try {
51
+ const raw = await readFile(mcpPath, 'utf-8');
52
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
53
+ // Support both .mcp.json formats:
54
+ // 1. Wrapped Studio format: { "mcpServers": { "serverName": { ... } } }
55
+ // 2. Flat Claude Code format: { "serverName": { ... } }
56
+ // This ensures compatibility with both Studio MCP configurations and
57
+ // plugins from the Claude Code sandbox that use the flat format.
58
+ // Fixes STU-122: Plugin loader should support both .mcp.json formats
59
+ if ('mcpServers' in parsed && parsed.mcpServers !== null && typeof parsed.mcpServers === 'object') {
60
+ mcpServers = parsed.mcpServers as Record<string, MCPServerDef>;
61
+ } else {
62
+ mcpServers = parsed as Record<string, MCPServerDef>;
63
+ }
64
+ } catch {
65
+ // Malformed .mcp.json — skip silently
66
+ }
67
+ }
68
+
69
+ const skills = await loadSkillFiles(join(pluginPath, 'skills'));
70
+ return { name, path: pluginPath, mcpServers, skills };
71
+ }
72
+
73
+ async function loadSkillFiles(skillsDir: string): Promise<SkillContent[]> {
74
+ if (!existsSync(skillsDir)) return [];
75
+
76
+ let files: string[];
77
+ try {
78
+ files = await readdir(skillsDir);
79
+ } catch {
80
+ return [];
81
+ }
82
+
83
+ const skillFiles = files.filter((f) => f.endsWith('.skill.md')).sort();
84
+ const skills: SkillContent[] = [];
85
+ for (const file of skillFiles) {
86
+ const content = await readFile(join(skillsDir, file), 'utf-8');
87
+ skills.push({ name: basename(file, '.skill.md'), content });
88
+ }
89
+ return skills;
90
+ }
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildPrompt } from './prompt-builder.js';
3
+ import type { AgentConfig } from '@studio-foundation/contracts';
4
+ import type { SkillContent } from './tools/skills/skill-loader.js';
5
+
6
+ const AGENT: AgentConfig = {
7
+ name: 'test-agent',
8
+ provider: 'anthropic',
9
+ model: 'claude-haiku-4-5-20251001',
10
+ system_prompt: 'You are a helpful assistant.',
11
+ };
12
+
13
+ const TASK = { description: 'Do the thing.' };
14
+
15
+ describe('buildPrompt — previous_tool_results', () => {
16
+ it('renders write_file content from tc.arguments.content', () => {
17
+ const messages = buildPrompt({
18
+ agent: AGENT,
19
+ task: TASK,
20
+ context: {
21
+ previous_tool_results: {
22
+ 'code-generation': [
23
+ {
24
+ id: '1',
25
+ name: 'repo_manager-write_file',
26
+ arguments: { path: 'src/foo.ts', content: 'const x = 1;' },
27
+ result: { written: true },
28
+ },
29
+ ],
30
+ },
31
+ },
32
+ });
33
+ const userMsg = messages.find(m => m.role === 'user')!.content as string;
34
+ expect(userMsg).toContain('Previous Stage Discoveries (code-generation)');
35
+ expect(userMsg).toContain('const x = 1;');
36
+ // Should NOT show the useless {written: true} result as the body
37
+ expect(userMsg).not.toContain('"written": true');
38
+ });
39
+
40
+ it('renders read_file content from tc.result', () => {
41
+ const messages = buildPrompt({
42
+ agent: AGENT,
43
+ task: TASK,
44
+ context: {
45
+ previous_tool_results: {
46
+ 'brief-analysis': [
47
+ {
48
+ id: '1',
49
+ name: 'repo_manager-read_file',
50
+ arguments: { path: 'src/foo.ts' },
51
+ result: { path: 'src/foo.ts', content: 'const y = 2;' },
52
+ },
53
+ ],
54
+ },
55
+ },
56
+ });
57
+ const userMsg = messages.find(m => m.role === 'user')!.content as string;
58
+ expect(userMsg).toContain('Previous Stage Discoveries (brief-analysis)');
59
+ expect(userMsg).toContain('const y = 2;');
60
+ });
61
+
62
+ it('omits the section when previous_tool_results is absent', () => {
63
+ const messages = buildPrompt({ agent: AGENT, task: TASK, context: {} });
64
+ const userMsg = messages.find(m => m.role === 'user')!.content as string;
65
+ expect(userMsg).not.toContain('Previous Stage Discoveries');
66
+ });
67
+ });
68
+
69
+ describe('buildPrompt — startup_context', () => {
70
+ it('renders each startup_context key as a ### section under ## Pipeline Startup Context', () => {
71
+ const messages = buildPrompt({
72
+ agent: AGENT,
73
+ task: TASK,
74
+ context: {
75
+ startup_context: {
76
+ git_status: 'M src/foo.ts',
77
+ recent_commits: 'abc123 feat: stuff',
78
+ },
79
+ },
80
+ });
81
+ const userMsg = messages.find(m => m.role === 'user')!.content as string;
82
+ expect(userMsg).toContain('## Pipeline Startup Context');
83
+ expect(userMsg).toContain('### git_status');
84
+ expect(userMsg).toContain('M src/foo.ts');
85
+ expect(userMsg).toContain('### recent_commits');
86
+ expect(userMsg).toContain('abc123 feat: stuff');
87
+ });
88
+
89
+ it('omits the section when startup_context is absent', () => {
90
+ const messages = buildPrompt({
91
+ agent: AGENT,
92
+ task: TASK,
93
+ context: {},
94
+ });
95
+ const userMsg = messages.find(m => m.role === 'user')!.content as string;
96
+ expect(userMsg).not.toContain('Pipeline Startup Context');
97
+ });
98
+
99
+ it('omits the section when startup_context is empty', () => {
100
+ const messages = buildPrompt({
101
+ agent: AGENT,
102
+ task: TASK,
103
+ context: { startup_context: {} },
104
+ });
105
+ const userMsg = messages.find(m => m.role === 'user')!.content as string;
106
+ expect(userMsg).not.toContain('Pipeline Startup Context');
107
+ });
108
+ });
109
+
110
+ describe('buildPrompt — skills injection', () => {
111
+ it('injects a single skill into the system prompt', () => {
112
+ const skills: SkillContent[] = [
113
+ { name: 'commit-conventions', content: '# Commit Conventions\n\nUse conventional commits.' },
114
+ ];
115
+ const messages = buildPrompt({ agent: AGENT, task: TASK, context: {}, skills });
116
+ const sysMsg = messages.find(m => m.role === 'system')!.content as string;
117
+ expect(sysMsg).toContain('## Skill: commit-conventions');
118
+ expect(sysMsg).toContain('Use conventional commits.');
119
+ });
120
+
121
+ it('injects multiple skills separated by ---', () => {
122
+ const skills: SkillContent[] = [
123
+ { name: 'commit-conventions', content: 'Commit content.' },
124
+ { name: 'react-patterns', content: 'React content.' },
125
+ ];
126
+ const messages = buildPrompt({ agent: AGENT, task: TASK, context: {}, skills });
127
+ const sysMsg = messages.find(m => m.role === 'system')!.content as string;
128
+ expect(sysMsg).toContain('## Skill: commit-conventions');
129
+ expect(sysMsg).toContain('## Skill: react-patterns');
130
+ expect(sysMsg).toContain('---');
131
+ });
132
+
133
+ it('omits skill section when skills array is empty', () => {
134
+ const messages = buildPrompt({ agent: AGENT, task: TASK, context: {}, skills: [] });
135
+ const sysMsg = messages.find(m => m.role === 'system')!.content as string;
136
+ expect(sysMsg).not.toContain('## Skill:');
137
+ });
138
+
139
+ it('omits skill section when skills is not provided', () => {
140
+ const messages = buildPrompt({ agent: AGENT, task: TASK, context: {} });
141
+ const sysMsg = messages.find(m => m.role === 'system')!.content as string;
142
+ expect(sysMsg).not.toContain('## Skill:');
143
+ });
144
+ });
145
+
146
+ describe('buildPrompt — stage_name', () => {
147
+ it('renders ## Stage Name section when stage_name is provided', () => {
148
+ const messages = buildPrompt({
149
+ agent: AGENT,
150
+ task: TASK,
151
+ context: { stage_name: 'recipe-1' },
152
+ });
153
+ const userMsg = messages.find(m => m.role === 'user')!.content as string;
154
+ expect(userMsg).toContain('## Stage Name');
155
+ expect(userMsg).toContain('recipe-1');
156
+ });
157
+
158
+ it('does not render ## Stage Name when stage_name is absent', () => {
159
+ const messages = buildPrompt({
160
+ agent: AGENT,
161
+ task: TASK,
162
+ context: {},
163
+ });
164
+ const userMsg = messages.find(m => m.role === 'user')!.content as string;
165
+ expect(userMsg).not.toContain('## Stage Name');
166
+ });
167
+ });