agentopology 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +450 -0
  3. package/dist/analyzer/__tests__/analyzer.test.d.ts +2 -0
  4. package/dist/analyzer/__tests__/analyzer.test.d.ts.map +1 -0
  5. package/dist/analyzer/__tests__/analyzer.test.js +258 -0
  6. package/dist/analyzer/__tests__/analyzer.test.js.map +1 -0
  7. package/dist/analyzer/index.d.ts +44 -0
  8. package/dist/analyzer/index.d.ts.map +1 -0
  9. package/dist/analyzer/index.js +308 -0
  10. package/dist/analyzer/index.js.map +1 -0
  11. package/dist/bindings/__tests__/bindings.test.d.ts +2 -0
  12. package/dist/bindings/__tests__/bindings.test.d.ts.map +1 -0
  13. package/dist/bindings/__tests__/bindings.test.js +2089 -0
  14. package/dist/bindings/__tests__/bindings.test.js.map +1 -0
  15. package/dist/bindings/claude-code.d.ts +12 -0
  16. package/dist/bindings/claude-code.d.ts.map +1 -0
  17. package/dist/bindings/claude-code.js +2063 -0
  18. package/dist/bindings/claude-code.js.map +1 -0
  19. package/dist/bindings/codex.d.ts +18 -0
  20. package/dist/bindings/codex.d.ts.map +1 -0
  21. package/dist/bindings/codex.js +1633 -0
  22. package/dist/bindings/codex.js.map +1 -0
  23. package/dist/bindings/copilot-cli.d.ts +20 -0
  24. package/dist/bindings/copilot-cli.d.ts.map +1 -0
  25. package/dist/bindings/copilot-cli.js +1515 -0
  26. package/dist/bindings/copilot-cli.js.map +1 -0
  27. package/dist/bindings/cursor.d.ts +17 -0
  28. package/dist/bindings/cursor.d.ts.map +1 -0
  29. package/dist/bindings/cursor.js +1715 -0
  30. package/dist/bindings/cursor.js.map +1 -0
  31. package/dist/bindings/gemini-cli.d.ts +18 -0
  32. package/dist/bindings/gemini-cli.d.ts.map +1 -0
  33. package/dist/bindings/gemini-cli.js +1327 -0
  34. package/dist/bindings/gemini-cli.js.map +1 -0
  35. package/dist/bindings/index.d.ts +20 -0
  36. package/dist/bindings/index.d.ts.map +1 -0
  37. package/dist/bindings/index.js +33 -0
  38. package/dist/bindings/index.js.map +1 -0
  39. package/dist/bindings/kiro.d.ts +17 -0
  40. package/dist/bindings/kiro.d.ts.map +1 -0
  41. package/dist/bindings/kiro.js +1624 -0
  42. package/dist/bindings/kiro.js.map +1 -0
  43. package/dist/bindings/openclaw.d.ts +18 -0
  44. package/dist/bindings/openclaw.d.ts.map +1 -0
  45. package/dist/bindings/openclaw.js +1578 -0
  46. package/dist/bindings/openclaw.js.map +1 -0
  47. package/dist/bindings/types.d.ts +33 -0
  48. package/dist/bindings/types.d.ts.map +1 -0
  49. package/dist/bindings/types.js +23 -0
  50. package/dist/bindings/types.js.map +1 -0
  51. package/dist/cli/index.d.ts +19 -0
  52. package/dist/cli/index.d.ts.map +1 -0
  53. package/dist/cli/index.js +679 -0
  54. package/dist/cli/index.js.map +1 -0
  55. package/dist/docs/__tests__/docs.test.d.ts +2 -0
  56. package/dist/docs/__tests__/docs.test.d.ts.map +1 -0
  57. package/dist/docs/__tests__/docs.test.js +438 -0
  58. package/dist/docs/__tests__/docs.test.js.map +1 -0
  59. package/dist/docs/content.d.ts +16 -0
  60. package/dist/docs/content.d.ts.map +1 -0
  61. package/dist/docs/content.js +3273 -0
  62. package/dist/docs/content.js.map +1 -0
  63. package/dist/docs/index.d.ts +17 -0
  64. package/dist/docs/index.d.ts.map +1 -0
  65. package/dist/docs/index.js +117 -0
  66. package/dist/docs/index.js.map +1 -0
  67. package/dist/exporters/__tests__/exporters.test.d.ts +2 -0
  68. package/dist/exporters/__tests__/exporters.test.d.ts.map +1 -0
  69. package/dist/exporters/__tests__/exporters.test.js +731 -0
  70. package/dist/exporters/__tests__/exporters.test.js.map +1 -0
  71. package/dist/exporters/index.d.ts +13 -0
  72. package/dist/exporters/index.d.ts.map +1 -0
  73. package/dist/exporters/index.js +18 -0
  74. package/dist/exporters/index.js.map +1 -0
  75. package/dist/exporters/json.d.ts +8 -0
  76. package/dist/exporters/json.d.ts.map +1 -0
  77. package/dist/exporters/json.js +25 -0
  78. package/dist/exporters/json.js.map +1 -0
  79. package/dist/exporters/markdown.d.ts +9 -0
  80. package/dist/exporters/markdown.d.ts.map +1 -0
  81. package/dist/exporters/markdown.js +686 -0
  82. package/dist/exporters/markdown.js.map +1 -0
  83. package/dist/exporters/mermaid.d.ts +11 -0
  84. package/dist/exporters/mermaid.d.ts.map +1 -0
  85. package/dist/exporters/mermaid.js +161 -0
  86. package/dist/exporters/mermaid.js.map +1 -0
  87. package/dist/exporters/types.d.ts +22 -0
  88. package/dist/exporters/types.d.ts.map +1 -0
  89. package/dist/exporters/types.js +10 -0
  90. package/dist/exporters/types.js.map +1 -0
  91. package/dist/import/__tests__/import.test.d.ts +2 -0
  92. package/dist/import/__tests__/import.test.d.ts.map +1 -0
  93. package/dist/import/__tests__/import.test.js +781 -0
  94. package/dist/import/__tests__/import.test.js.map +1 -0
  95. package/dist/import/claude-code.d.ts +38 -0
  96. package/dist/import/claude-code.d.ts.map +1 -0
  97. package/dist/import/claude-code.js +897 -0
  98. package/dist/import/claude-code.js.map +1 -0
  99. package/dist/import/index.d.ts +18 -0
  100. package/dist/import/index.d.ts.map +1 -0
  101. package/dist/import/index.js +28 -0
  102. package/dist/import/index.js.map +1 -0
  103. package/dist/import/serializer.d.ts +11 -0
  104. package/dist/import/serializer.d.ts.map +1 -0
  105. package/dist/import/serializer.js +960 -0
  106. package/dist/import/serializer.js.map +1 -0
  107. package/dist/index.d.ts +26 -0
  108. package/dist/index.d.ts.map +1 -0
  109. package/dist/index.js +21 -0
  110. package/dist/index.js.map +1 -0
  111. package/dist/parser/__tests__/parser.test.d.ts +2 -0
  112. package/dist/parser/__tests__/parser.test.d.ts.map +1 -0
  113. package/dist/parser/__tests__/parser.test.js +6514 -0
  114. package/dist/parser/__tests__/parser.test.js.map +1 -0
  115. package/dist/parser/ast.d.ts +708 -0
  116. package/dist/parser/ast.d.ts.map +1 -0
  117. package/dist/parser/ast.js +12 -0
  118. package/dist/parser/ast.js.map +1 -0
  119. package/dist/parser/index.d.ts +253 -0
  120. package/dist/parser/index.d.ts.map +1 -0
  121. package/dist/parser/index.js +1985 -0
  122. package/dist/parser/index.js.map +1 -0
  123. package/dist/parser/lexer.d.ts +100 -0
  124. package/dist/parser/lexer.d.ts.map +1 -0
  125. package/dist/parser/lexer.js +304 -0
  126. package/dist/parser/lexer.js.map +1 -0
  127. package/dist/parser/validator.d.ts +42 -0
  128. package/dist/parser/validator.d.ts.map +1 -0
  129. package/dist/parser/validator.js +2568 -0
  130. package/dist/parser/validator.js.map +1 -0
  131. package/dist/scaffold/__tests__/scaffold.test.d.ts +7 -0
  132. package/dist/scaffold/__tests__/scaffold.test.d.ts.map +1 -0
  133. package/dist/scaffold/__tests__/scaffold.test.js +295 -0
  134. package/dist/scaffold/__tests__/scaffold.test.js.map +1 -0
  135. package/dist/scaffold/incremental.d.ts +43 -0
  136. package/dist/scaffold/incremental.d.ts.map +1 -0
  137. package/dist/scaffold/incremental.js +185 -0
  138. package/dist/scaffold/incremental.js.map +1 -0
  139. package/dist/scaffold/index.d.ts +13 -0
  140. package/dist/scaffold/index.d.ts.map +1 -0
  141. package/dist/scaffold/index.js +13 -0
  142. package/dist/scaffold/index.js.map +1 -0
  143. package/dist/scaffold/manifest.d.ts +30 -0
  144. package/dist/scaffold/manifest.d.ts.map +1 -0
  145. package/dist/scaffold/manifest.js +71 -0
  146. package/dist/scaffold/manifest.js.map +1 -0
  147. package/dist/scaffold/merge.d.ts +52 -0
  148. package/dist/scaffold/merge.d.ts.map +1 -0
  149. package/dist/scaffold/merge.js +134 -0
  150. package/dist/scaffold/merge.js.map +1 -0
  151. package/dist/scaffold/types.d.ts +51 -0
  152. package/dist/scaffold/types.d.ts.map +1 -0
  153. package/dist/scaffold/types.js +10 -0
  154. package/dist/scaffold/types.js.map +1 -0
  155. package/dist/sync/index.d.ts +34 -0
  156. package/dist/sync/index.d.ts.map +1 -0
  157. package/dist/sync/index.js +392 -0
  158. package/dist/sync/index.js.map +1 -0
  159. package/dist/visualizer/index.d.ts +17 -0
  160. package/dist/visualizer/index.d.ts.map +1 -0
  161. package/dist/visualizer/index.js +1976 -0
  162. package/dist/visualizer/index.js.map +1 -0
  163. package/examples/code-review.at +167 -0
  164. package/examples/data-processing.at +296 -0
  165. package/examples/openclaw-assistant.at +142 -0
  166. package/examples/scheduled-monitor.at +193 -0
  167. package/examples/simple-pipeline.at +163 -0
  168. package/package.json +77 -0
  169. package/skill/SKILL.md +403 -0
  170. package/skill/scripts/emit.ts +853 -0
  171. package/skill/scripts/visualize.ts +89 -0
  172. package/spec/grammar.md +1466 -0
  173. package/spec/reserved-keywords.md +147 -0
  174. package/spec/validation.md +535 -0
@@ -0,0 +1,2089 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parse } from "../../parser/index.js";
3
+ import { bindings, claudeCodeBinding, kiroBinding, cursorBinding, } from "../index.js";
4
+ // ---------------------------------------------------------------------------
5
+ // Minimal but valid .at topology exercising most features
6
+ // ---------------------------------------------------------------------------
7
+ const TOPOLOGY_SOURCE = `
8
+ topology test-scaffold : [pipeline, fan-out] {
9
+
10
+ meta {
11
+ version: "1.0.0"
12
+ description: "A minimal topology for binding tests"
13
+ }
14
+
15
+ orchestrator {
16
+ model: opus
17
+ generates: "commands/run.md"
18
+ handles: [intake]
19
+ }
20
+
21
+ roles {
22
+ planner: "Plans work items"
23
+ builder: "Builds code"
24
+ }
25
+
26
+ action intake {
27
+ kind: external
28
+ source: "github-pr"
29
+ description: "Fetch PR data"
30
+ }
31
+
32
+ agent planner {
33
+ role: planner
34
+ model: sonnet
35
+ phase: 1
36
+ tools: [Read, Grep]
37
+ reads: ["workspace/input.md"]
38
+ writes: ["workspace/plan.md"]
39
+ permissions: supervised
40
+ outputs: {
41
+ status: ready | blocked
42
+ }
43
+ }
44
+
45
+ agent builder {
46
+ role: builder
47
+ model: opus
48
+ phase: 2
49
+ tools: [Read, Write, Bash]
50
+ reads: ["workspace/plan.md"]
51
+ writes: ["workspace/output.md"]
52
+ permissions: autonomous
53
+ disallowed-tools: [Edit]
54
+ }
55
+
56
+ flow {
57
+ intake -> planner
58
+ planner -> builder [when planner.status == ready]
59
+ }
60
+
61
+ gates {
62
+ gate quality-check {
63
+ after: builder
64
+ run: "scripts/lint.sh"
65
+ checks: [lint, types]
66
+ on-fail: halt
67
+ }
68
+ gate soft-review {
69
+ after: planner
70
+ run: "scripts/review.sh"
71
+ behavior: advisory
72
+ }
73
+ }
74
+
75
+ triggers {
76
+ command run {
77
+ pattern: "/run <TASK>"
78
+ argument: TASK
79
+ }
80
+ }
81
+
82
+ hooks {
83
+ hook post-build {
84
+ on: PostToolUse
85
+ matcher: Write
86
+ run: "scripts/post-build.sh"
87
+ type: command
88
+ }
89
+ }
90
+
91
+ mcp-servers {
92
+ filesystem {
93
+ command: "npx"
94
+ args: ["-y", "@modelcontextprotocol/server-filesystem"]
95
+ }
96
+ }
97
+
98
+ memory {
99
+ domains: true
100
+ references: true
101
+ metrics: true
102
+ workspace {
103
+ path: "workspace/"
104
+ structure: [input, plan, output]
105
+ }
106
+ }
107
+
108
+ settings {
109
+ allow: ["Read", "Grep", "Glob"]
110
+ deny: ["Bash"]
111
+ }
112
+ }
113
+ `;
114
+ const ast = parse(TOPOLOGY_SOURCE);
115
+ // ---------------------------------------------------------------------------
116
+ // Helpers
117
+ // ---------------------------------------------------------------------------
118
+ /** Scaffold a binding and return the generated file list. */
119
+ function scaffoldBinding(name) {
120
+ const binding = bindings[name];
121
+ if (!binding)
122
+ throw new Error(`Unknown binding: ${name}`);
123
+ return binding.scaffold(ast);
124
+ }
125
+ /** Assert standard structural invariants on scaffold output. */
126
+ function assertStructuralInvariants(files) {
127
+ it("returns a non-empty array of GeneratedFile", () => {
128
+ expect(files.length).toBeGreaterThan(0);
129
+ });
130
+ it("every file has a non-empty path", () => {
131
+ for (const f of files) {
132
+ expect(f.path).toBeTruthy();
133
+ expect(f.path.length).toBeGreaterThan(0);
134
+ }
135
+ });
136
+ it("every file has content defined (string, may be empty for .gitkeep)", () => {
137
+ for (const f of files) {
138
+ expect(typeof f.content).toBe("string");
139
+ }
140
+ });
141
+ it("non-.gitkeep files have non-empty content", () => {
142
+ for (const f of files) {
143
+ if (!f.path.endsWith(".gitkeep") && !f.path.endsWith(".jsonl")) {
144
+ expect(f.content.length, `Expected non-empty content for ${f.path}`).toBeGreaterThan(0);
145
+ }
146
+ }
147
+ });
148
+ it("no two files have the same path (no collisions)", () => {
149
+ const paths = files.map((f) => f.path);
150
+ const unique = new Set(paths);
151
+ expect(unique.size).toBe(paths.length);
152
+ });
153
+ }
154
+ // ---------------------------------------------------------------------------
155
+ // Registry tests
156
+ // ---------------------------------------------------------------------------
157
+ describe("Binding registry", () => {
158
+ it("contains all 7 bindings", () => {
159
+ expect(Object.keys(bindings)).toHaveLength(7);
160
+ });
161
+ it("all bindings have a name and description", () => {
162
+ for (const [key, binding] of Object.entries(bindings)) {
163
+ expect(binding.name, `binding "${key}" should have a name`).toBeTruthy();
164
+ expect(binding.description, `binding "${key}" should have a description`).toBeTruthy();
165
+ expect(binding.name.length).toBeGreaterThan(0);
166
+ expect(binding.description.length).toBeGreaterThan(0);
167
+ }
168
+ });
169
+ it("registry keys match binding names", () => {
170
+ for (const [key, binding] of Object.entries(bindings)) {
171
+ expect(binding.name).toBe(key);
172
+ }
173
+ });
174
+ });
175
+ // ---------------------------------------------------------------------------
176
+ // claude-code
177
+ // ---------------------------------------------------------------------------
178
+ describe("claude-code binding", () => {
179
+ const files = scaffoldBinding("claude-code");
180
+ assertStructuralInvariants(files);
181
+ it("produces AGENT.md files for each agent", () => {
182
+ const agentFiles = files.filter((f) => f.path.endsWith("/AGENT.md"));
183
+ expect(agentFiles.length).toBeGreaterThanOrEqual(2);
184
+ const paths = agentFiles.map((f) => f.path);
185
+ expect(paths).toContain(".claude/agents/planner/AGENT.md");
186
+ expect(paths).toContain(".claude/agents/builder/AGENT.md");
187
+ });
188
+ it("produces a SKILL.md for the topology", () => {
189
+ const skillFile = files.find((f) => f.path === ".claude/skills/test-scaffold/SKILL.md");
190
+ expect(skillFile).toBeDefined();
191
+ expect(skillFile.content).toContain("test-scaffold");
192
+ });
193
+ it("produces command files from triggers", () => {
194
+ const cmdFile = files.find((f) => f.path === ".claude/commands/run.md");
195
+ expect(cmdFile).toBeDefined();
196
+ expect(cmdFile.content).toContain("/run");
197
+ });
198
+ it("produces .claude/settings.json with hooks", () => {
199
+ const settingsFile = files.find((f) => f.path === ".claude/settings.json");
200
+ expect(settingsFile).toBeDefined();
201
+ const settings = JSON.parse(settingsFile.content);
202
+ expect(settings.hooks).toBeDefined();
203
+ });
204
+ it("produces .mcp.json for MCP servers", () => {
205
+ const mcpFile = files.find((f) => f.path === ".mcp.json");
206
+ expect(mcpFile).toBeDefined();
207
+ const mcp = JSON.parse(mcpFile.content);
208
+ expect(mcp.mcpServers.filesystem).toBeDefined();
209
+ });
210
+ it("produces a context file (.claude/CONTEXT.md, never root CLAUDE.md)", () => {
211
+ const contextFile = files.find((f) => f.path === ".claude/CONTEXT.md");
212
+ expect(contextFile).toBeDefined();
213
+ // Must never generate root-level CLAUDE.md
214
+ const rootClaudeMd = files.find((f) => f.path === "CLAUDE.md");
215
+ expect(rootClaudeMd).toBeUndefined();
216
+ });
217
+ it("emits disallowedTools in agent frontmatter (camelCase per Claude Code spec)", () => {
218
+ const builderFile = files.find((f) => f.path === ".claude/agents/builder/AGENT.md");
219
+ expect(builderFile).toBeDefined();
220
+ expect(builderFile.content).toContain("disallowedTools:");
221
+ expect(builderFile.content).toContain("Edit");
222
+ });
223
+ it("compiles enforced gate to settings.json PreToolUse hook", () => {
224
+ const settingsFile = files.find((f) => f.path === ".claude/settings.json");
225
+ expect(settingsFile).toBeDefined();
226
+ const settings = JSON.parse(settingsFile.content);
227
+ expect(settings.hooks.PreToolUse).toBeDefined();
228
+ const gateHook = settings.hooks.PreToolUse.find((h) => h.matcher === "Task" && JSON.stringify(h).includes("gate-quality-check"));
229
+ expect(gateHook).toBeDefined();
230
+ });
231
+ it("does NOT compile advisory gate to settings.json hook", () => {
232
+ const settingsFile = files.find((f) => f.path === ".claude/settings.json");
233
+ const settings = JSON.parse(settingsFile.content);
234
+ const allHookJson = JSON.stringify(settings.hooks);
235
+ expect(allHookJson).not.toContain("gate-soft-review");
236
+ });
237
+ it("generates gate wrapper script for enforced gate", () => {
238
+ const wrapperScript = files.find((f) => f.path.includes("gate-quality-check.sh"));
239
+ expect(wrapperScript).toBeDefined();
240
+ expect(wrapperScript.content).toContain("lint.sh");
241
+ expect(wrapperScript.content).toContain("exit 1");
242
+ });
243
+ it("does NOT generate gate wrapper script for advisory gate", () => {
244
+ const advisoryWrapper = files.find((f) => f.path.includes("gate-soft-review.sh"));
245
+ expect(advisoryWrapper).toBeUndefined();
246
+ });
247
+ });
248
+ // ---------------------------------------------------------------------------
249
+ // claude-code — Exotic features: groups, humans, circuit breakers, scale,
250
+ // isolation, variants, schemas, artifacts, conditional edges
251
+ // ---------------------------------------------------------------------------
252
+ describe("claude-code binding — exotic features", () => {
253
+ const EXOTIC_CC_SOURCE = `
254
+ topology debate-test : [fan-out, debate, pipeline] {
255
+ meta {
256
+ version: "2.0.0"
257
+ description: "Debate topology for exotic feature tests"
258
+ }
259
+
260
+ orchestrator {
261
+ model: opus
262
+ handles: [intake]
263
+ }
264
+
265
+ roles {
266
+ debater: "Argues a position"
267
+ judge: "Evaluates debate quality"
268
+ }
269
+
270
+ action intake {
271
+ kind: external
272
+ source: "user-input"
273
+ description: "Receive debate topic"
274
+ }
275
+
276
+ agent pro-debater {
277
+ role: debater
278
+ model: haiku
279
+ description: "Argues in favor"
280
+ prompt {
281
+ You are the PRO debater.
282
+ Argue strongly in favor.
283
+ }
284
+ temperature: 0.9
285
+ max-tokens: 500
286
+ thinking: high
287
+ thinking-budget: 2000
288
+ timeout: "5m"
289
+ max-turns: 10
290
+ retry: 3
291
+ }
292
+
293
+ agent con-debater {
294
+ role: debater
295
+ model: haiku
296
+ description: "Argues against"
297
+ prompt {
298
+ You are the CON debater.
299
+ Argue strongly against.
300
+ }
301
+ on-fail: retry
302
+ circuit-breaker {
303
+ threshold: 3
304
+ window: "5m"
305
+ cooldown: "30s"
306
+ }
307
+ }
308
+
309
+ agent researcher {
310
+ role: debater
311
+ model: sonnet
312
+ description: "Gathers evidence"
313
+ tools: [Read, WebSearch, Grep]
314
+ background: true
315
+ isolation: worktree
316
+ sandbox: "network-only"
317
+ fallback-chain: [haiku, sonnet]
318
+ scale {
319
+ mode: auto
320
+ by: "query-count"
321
+ min: 1
322
+ max: 3
323
+ }
324
+ }
325
+
326
+ agent judge-agent {
327
+ role: judge
328
+ model: opus
329
+ description: "Evaluates arguments"
330
+ output-format: json
331
+ input-schema {
332
+ topic: string
333
+ }
334
+ output-schema {
335
+ winner: string
336
+ score: number
337
+ }
338
+ produces: ["verdict"]
339
+ consumes: ["transcript"]
340
+ }
341
+
342
+ group debate-arena {
343
+ members: [pro-debater, con-debater]
344
+ speaker-selection: "round-robin"
345
+ max-rounds: 5
346
+ termination: "judge declares winner"
347
+ description: "Structured debate"
348
+ timeout: "30m"
349
+ }
350
+
351
+ human moderator {
352
+ description: "Human reviews debate"
353
+ timeout: "1h"
354
+ on-timeout: "skip"
355
+ }
356
+
357
+ gates {
358
+ gate fact-check {
359
+ after: debate-arena
360
+ before: judge-agent
361
+ run: "scripts/fact-check.sh"
362
+ checks: [sources, accuracy]
363
+ on-fail: halt
364
+ }
365
+ }
366
+
367
+ schemas {
368
+ schema debate-result {
369
+ winner: string
370
+ score: number
371
+ }
372
+ }
373
+
374
+ artifacts {
375
+ artifact transcript {
376
+ type: markdown
377
+ path: "workspace/debates/"
378
+ retention: "90d"
379
+ }
380
+ }
381
+
382
+ mcp-servers {
383
+ web-search {
384
+ command: "npx"
385
+ args: ["-y", "@modelcontextprotocol/server-web-search"]
386
+ env {
387
+ SEARCH_API_KEY: "\${SEARCH_API_KEY}"
388
+ }
389
+ }
390
+ }
391
+
392
+ hooks {
393
+ hook log-round {
394
+ on: PostToolUse
395
+ matcher: "Write"
396
+ run: "scripts/log-round.sh"
397
+ timeout: 5000
398
+ }
399
+ }
400
+
401
+ flow {
402
+ intake -> debate-arena
403
+ debate-arena -> moderator
404
+ moderator -> judge-agent
405
+ judge-agent -> intake [when judge-agent.winner == "rematch"] [max 3]
406
+ }
407
+ }
408
+ `;
409
+ const exoticAst = parse(EXOTIC_CC_SOURCE);
410
+ const exoticFiles = claudeCodeBinding.scaffold(exoticAst);
411
+ describe("group node → AGENT.md with frontmatter", () => {
412
+ const groupAgent = exoticFiles.find((f) => f.path === ".claude/agents/debate-arena/AGENT.md");
413
+ it("generates AGENT.md for group", () => {
414
+ expect(groupAgent).toBeDefined();
415
+ });
416
+ it("has frontmatter with name and description", () => {
417
+ expect(groupAgent.content).toContain("---");
418
+ expect(groupAgent.content).toContain("name: debate-arena");
419
+ expect(groupAgent.content).toContain("Structured debate");
420
+ });
421
+ it("lists members", () => {
422
+ expect(groupAgent.content).toContain("pro-debater");
423
+ expect(groupAgent.content).toContain("con-debater");
424
+ });
425
+ it("includes round instructions, max rounds, termination, timeout", () => {
426
+ // Group orchestrator should have step-by-step round instructions
427
+ expect(groupAgent.content).toContain("Round 1");
428
+ expect(groupAgent.content).toContain("Round 5");
429
+ expect(groupAgent.content).toContain("judge declares winner");
430
+ expect(groupAgent.content).toContain("30m");
431
+ });
432
+ it("references shared transcript file", () => {
433
+ expect(groupAgent.content).toContain(".claude/groups/debate-arena/transcript.md");
434
+ });
435
+ it("generates transcript template file", () => {
436
+ const transcript = exoticFiles.find((f) => f.path === ".claude/groups/debate-arena/transcript.md");
437
+ expect(transcript).toBeDefined();
438
+ expect(transcript.content).toContain("pro-debater");
439
+ expect(transcript.content).toContain("con-debater");
440
+ });
441
+ it("generates group config.json", () => {
442
+ const config = exoticFiles.find((f) => f.path === ".claude/groups/debate-arena/config.json");
443
+ expect(config).toBeDefined();
444
+ const parsed = JSON.parse(config.content);
445
+ expect(parsed.members).toContain("pro-debater");
446
+ expect(parsed.members).toContain("con-debater");
447
+ expect(parsed.speakerSelection).toBe("round-robin");
448
+ expect(parsed.maxRounds).toBe(5);
449
+ });
450
+ });
451
+ describe("group member agents get protocol injected", () => {
452
+ it("pro-debater AGENT.md includes Group Conversation Protocol", () => {
453
+ const pro = exoticFiles.find((f) => f.path === ".claude/agents/pro-debater/AGENT.md");
454
+ expect(pro.content).toContain("Group Conversation Protocol");
455
+ expect(pro.content).toContain(".claude/groups/debate-arena/transcript.md");
456
+ expect(pro.content).toContain("Append your response");
457
+ expect(pro.content).toContain("Do NOT");
458
+ });
459
+ it("con-debater AGENT.md includes Group Conversation Protocol", () => {
460
+ const con = exoticFiles.find((f) => f.path === ".claude/agents/con-debater/AGENT.md");
461
+ expect(con.content).toContain("Group Conversation Protocol");
462
+ expect(con.content).toContain("transcript.md");
463
+ });
464
+ it("non-member agents do NOT get group protocol", () => {
465
+ const researcher = exoticFiles.find((f) => f.path === ".claude/agents/researcher/AGENT.md");
466
+ expect(researcher.content).not.toContain("Group Conversation Protocol");
467
+ const judge = exoticFiles.find((f) => f.path === ".claude/agents/judge-agent/AGENT.md");
468
+ expect(judge.content).not.toContain("Group Conversation Protocol");
469
+ });
470
+ });
471
+ describe("human node → AGENT.md", () => {
472
+ const humanAgent = exoticFiles.find((f) => f.path === ".claude/agents/moderator/AGENT.md");
473
+ it("generates AGENT.md with timeout and on-timeout", () => {
474
+ expect(humanAgent).toBeDefined();
475
+ expect(humanAgent.content).toContain("1h");
476
+ expect(humanAgent.content).toContain("skip");
477
+ });
478
+ });
479
+ describe("agent frontmatter — Claude Code native fields", () => {
480
+ const researcher = exoticFiles.find((f) => f.path === ".claude/agents/researcher/AGENT.md");
481
+ it("includes isolation: worktree in frontmatter", () => {
482
+ expect(researcher.content).toContain("isolation: worktree");
483
+ });
484
+ it("includes background: true in frontmatter", () => {
485
+ expect(researcher.content).toContain("background: true");
486
+ });
487
+ it("includes sandbox in frontmatter", () => {
488
+ expect(researcher.content).toContain("sandbox:");
489
+ });
490
+ it("includes tools in frontmatter", () => {
491
+ expect(researcher.content).toContain("Read");
492
+ expect(researcher.content).toContain("WebSearch");
493
+ expect(researcher.content).toContain("Grep");
494
+ });
495
+ it("includes fallback-chain in frontmatter", () => {
496
+ expect(researcher.content).toContain("fallback-chain");
497
+ expect(researcher.content).toContain("haiku");
498
+ });
499
+ it("includes scale config in body", () => {
500
+ expect(researcher.content).toContain("Scale");
501
+ expect(researcher.content).toContain("auto");
502
+ });
503
+ });
504
+ describe("agent with thinking, retry, circuit breaker", () => {
505
+ it("pro-debater includes maxTurns in frontmatter", () => {
506
+ const pd = exoticFiles.find((f) => f.path === ".claude/agents/pro-debater/AGENT.md");
507
+ expect(pd.content).toContain("maxTurns: 10");
508
+ });
509
+ it("pro-debater includes thinking and temperature in body", () => {
510
+ const pd = exoticFiles.find((f) => f.path === ".claude/agents/pro-debater/AGENT.md");
511
+ expect(pd.content).toContain("high");
512
+ expect(pd.content).toContain("2000");
513
+ expect(pd.content).toContain("0.9");
514
+ });
515
+ it("con-debater includes circuit breaker in body", () => {
516
+ const cd = exoticFiles.find((f) => f.path === ".claude/agents/con-debater/AGENT.md");
517
+ expect(cd.content).toContain("Circuit Breaker");
518
+ expect(cd.content).toContain("3");
519
+ });
520
+ });
521
+ describe("MCP — no phantom servers, no leaked env vars", () => {
522
+ const mcpFile = exoticFiles.find((f) => f.path === ".mcp.json");
523
+ const config = JSON.parse(mcpFile.content);
524
+ it("web-search server is clean", () => {
525
+ expect(config.mcpServers["web-search"]).toBeDefined();
526
+ expect(config.mcpServers["web-search"].command).toBe("npx");
527
+ });
528
+ it("no phantom 'env' server", () => {
529
+ expect(config.mcpServers.env).toBeUndefined();
530
+ });
531
+ it("no leaked env vars at server top level", () => {
532
+ const server = config.mcpServers["web-search"];
533
+ const keys = Object.keys(server);
534
+ for (const key of keys) {
535
+ expect(["command", "args", "env", "url"]).toContain(key);
536
+ }
537
+ });
538
+ });
539
+ describe("flow — no duplicated [max N]", () => {
540
+ const skillFile = exoticFiles.find((f) => f.path === ".claude/skills/debate-test/SKILL.md");
541
+ it("conditional edge has single [max 3], not doubled", () => {
542
+ // Count occurrences of [max 3]
543
+ const matches = skillFile.content.match(/\[max 3\]/g) || [];
544
+ expect(matches.length).toBe(1);
545
+ });
546
+ it("condition is clean (no trailing ] artifact)", () => {
547
+ expect(skillFile.content).not.toMatch(/rematch"\]\s*\]\s*\[max/);
548
+ });
549
+ });
550
+ describe("schemas, artifacts, skills", () => {
551
+ it("schema generates markdown file", () => {
552
+ const schema = exoticFiles.find((f) => f.path.includes("schemas/debate-result"));
553
+ expect(schema).toBeDefined();
554
+ expect(schema.content).toContain("winner");
555
+ });
556
+ it("artifacts documented in CONTEXT.md", () => {
557
+ const ctx = exoticFiles.find((f) => f.path === ".claude/CONTEXT.md");
558
+ expect(ctx.content).toContain("transcript");
559
+ });
560
+ it("skills generate SKILL.md files", () => {
561
+ const topSkill = exoticFiles.find((f) => f.path === ".claude/skills/debate-test/SKILL.md");
562
+ expect(topSkill).toBeDefined();
563
+ });
564
+ });
565
+ describe("hooks and gates in settings.json", () => {
566
+ const settingsFile = exoticFiles.find((f) => f.path === ".claude/settings.json");
567
+ const settings = JSON.parse(settingsFile.content);
568
+ it("PostToolUse hook with matcher and timeout in milliseconds", () => {
569
+ expect(settings.hooks.PostToolUse).toBeDefined();
570
+ const hook = settings.hooks.PostToolUse.find((h) => h.matcher === "Write");
571
+ expect(hook).toBeDefined();
572
+ expect(hook.hooks[0].timeout).toBe(5000);
573
+ });
574
+ it("gate compiles to PreToolUse hook", () => {
575
+ expect(settings.hooks.PreToolUse).toBeDefined();
576
+ const gateHook = settings.hooks.PreToolUse.find((h) => JSON.stringify(h).includes("fact-check"));
577
+ expect(gateHook).toBeDefined();
578
+ });
579
+ });
580
+ });
581
+ // ---------------------------------------------------------------------------
582
+ // codex
583
+ // ---------------------------------------------------------------------------
584
+ describe("codex binding", () => {
585
+ const files = scaffoldBinding("codex");
586
+ assertStructuralInvariants(files);
587
+ it("produces .codex/config.toml", () => {
588
+ const toml = files.find((f) => f.path === ".codex/config.toml");
589
+ expect(toml).toBeDefined();
590
+ expect(toml.content).toContain("model");
591
+ // Must use valid Codex approval_policy values
592
+ expect(toml.content).toMatch(/approval_policy\s*=\s*"(untrusted|on-request|on-failure|never)"/);
593
+ // Must NOT contain legacy values
594
+ expect(toml.content).not.toContain('"suggest"');
595
+ expect(toml.content).not.toContain('"auto-edit"');
596
+ expect(toml.content).not.toContain('"full-auto"');
597
+ });
598
+ it("produces AGENTS.md", () => {
599
+ const agentsMd = files.find((f) => f.path === "AGENTS.md");
600
+ expect(agentsMd).toBeDefined();
601
+ expect(agentsMd.content).toContain("planner");
602
+ expect(agentsMd.content).toContain("builder");
603
+ });
604
+ it("does NOT produce .codex/instructions.md (Codex uses AGENTS.md)", () => {
605
+ const instructions = files.find((f) => f.path === ".codex/instructions.md");
606
+ expect(instructions).toBeUndefined();
607
+ });
608
+ it("produces gate scripts under .codex/scripts/", () => {
609
+ const gateScript = files.find((f) => f.path.startsWith(".codex/scripts/") && f.content.includes("Gate"));
610
+ expect(gateScript).toBeDefined();
611
+ });
612
+ it("marks gate enforcement level in AGENTS.md", () => {
613
+ const agentsMd = files.find((f) => f.path === "AGENTS.md");
614
+ expect(agentsMd.content).toContain("Enforcement:");
615
+ });
616
+ it("passes model values through without mapping", () => {
617
+ const toml = files.find((f) => f.path === ".codex/config.toml");
618
+ // Model should be the raw value from the .at file, not mapped
619
+ expect(toml.content).toContain('"opus"');
620
+ // Should NOT contain any OpenAI model IDs (no mapping)
621
+ expect(toml.content).not.toContain("gpt-");
622
+ const agentsMd = files.find((f) => f.path === "AGENTS.md");
623
+ // Agent models should appear verbatim
624
+ expect(agentsMd.content).toContain("**Model:** sonnet");
625
+ expect(agentsMd.content).toContain("**Model:** opus");
626
+ });
627
+ });
628
+ // ---------------------------------------------------------------------------
629
+ // gemini-cli
630
+ // ---------------------------------------------------------------------------
631
+ describe("gemini-cli binding", () => {
632
+ const files = scaffoldBinding("gemini-cli");
633
+ assertStructuralInvariants(files);
634
+ it("produces .gemini/CONTEXT.md context file (never root GEMINI.md)", () => {
635
+ const geminiMd = files.find((f) => f.path === ".gemini/CONTEXT.md");
636
+ expect(geminiMd).toBeDefined();
637
+ // Must never generate root-level GEMINI.md
638
+ const rootGeminiMd = files.find((f) => f.path === "GEMINI.md");
639
+ expect(rootGeminiMd).toBeUndefined();
640
+ });
641
+ it("produces .gemini/settings.json", () => {
642
+ const settings = files.find((f) => f.path === ".gemini/settings.json");
643
+ expect(settings).toBeDefined();
644
+ const parsed = JSON.parse(settings.content);
645
+ expect(parsed).toBeDefined();
646
+ });
647
+ it("produces .gemini/instructions.md", () => {
648
+ const instructions = files.find((f) => f.path === ".gemini/instructions.md");
649
+ expect(instructions).toBeDefined();
650
+ });
651
+ it("produces command files from triggers as .toml", () => {
652
+ const cmdFile = files.find((f) => f.path === ".gemini/commands/run.toml");
653
+ expect(cmdFile).toBeDefined();
654
+ expect(cmdFile.content).toContain('description =');
655
+ expect(cmdFile.content).toContain('prompt = """');
656
+ });
657
+ it("settings.json uses real Gemini CLI structure", () => {
658
+ const settings = files.find((f) => f.path === ".gemini/settings.json");
659
+ expect(settings).toBeDefined();
660
+ const parsed = JSON.parse(settings.content);
661
+ // general.defaultApprovalMode instead of toolApproval
662
+ expect(parsed.general).toBeDefined();
663
+ expect(parsed.general.defaultApprovalMode).toBeDefined();
664
+ expect(["default", "auto_edit", "plan"]).toContain(parsed.general.defaultApprovalMode);
665
+ // model.name only set for Gemini-compatible model names
666
+ // (non-Gemini models like haiku/opus are omitted to avoid crashing the CLI)
667
+ if (parsed.model) {
668
+ expect(parsed.model.name).toBeDefined();
669
+ }
670
+ // context.fileName points to the context file
671
+ expect(parsed.context).toBeDefined();
672
+ expect(parsed.context.fileName).toBe(".gemini/CONTEXT.md");
673
+ // No fictional keys
674
+ expect(parsed).not.toHaveProperty("toolApproval");
675
+ expect(parsed).not.toHaveProperty("allowedTools");
676
+ expect(parsed).not.toHaveProperty("deniedTools");
677
+ expect(parsed).not.toHaveProperty("thinkingConfig");
678
+ expect(parsed).not.toHaveProperty("modelConfig");
679
+ expect(parsed).not.toHaveProperty("sandbox");
680
+ });
681
+ it("includes MCP servers in settings.json (not separate file)", () => {
682
+ const settings = files.find((f) => f.path === ".gemini/settings.json");
683
+ const parsed = JSON.parse(settings.content);
684
+ expect(parsed.mcpServers).toBeDefined();
685
+ expect(parsed.mcpServers.filesystem).toBeDefined();
686
+ // No separate MCP file
687
+ const mcpFile = files.find((f) => f.path === ".gemini/settings/mcp.json");
688
+ expect(mcpFile).toBeUndefined();
689
+ });
690
+ it("includes hooks in settings.json with correct event mapping", () => {
691
+ const settings = files.find((f) => f.path === ".gemini/settings.json");
692
+ const parsed = JSON.parse(settings.content);
693
+ // The test topology has a PostToolUse hook -> should map to AfterTool
694
+ expect(parsed.hooks).toBeDefined();
695
+ expect(parsed.hooks.AfterTool).toBeDefined();
696
+ expect(parsed.hooksConfig).toEqual({ enabled: true });
697
+ });
698
+ });
699
+ // ---------------------------------------------------------------------------
700
+ // copilot-cli
701
+ // ---------------------------------------------------------------------------
702
+ describe("copilot-cli binding", () => {
703
+ const files = scaffoldBinding("copilot-cli");
704
+ assertStructuralInvariants(files);
705
+ it("produces .github/copilot-instructions.md", () => {
706
+ const instructions = files.find((f) => f.path === ".github/copilot-instructions.md");
707
+ expect(instructions).toBeDefined();
708
+ expect(instructions.content.length).toBeGreaterThan(0);
709
+ });
710
+ it("produces .github/instructions/*.instructions.md for each agent", () => {
711
+ const agentFiles = files.filter((f) => f.path.match(/\.github\/instructions\/.*\.instructions\.md$/));
712
+ expect(agentFiles.length).toBeGreaterThanOrEqual(2);
713
+ const paths = agentFiles.map((f) => f.path);
714
+ expect(paths).toContain(".github/instructions/planner.instructions.md");
715
+ expect(paths).toContain(".github/instructions/builder.instructions.md");
716
+ });
717
+ it("uses applyTo frontmatter derived from reads/writes paths", () => {
718
+ const planner = files.find((f) => f.path === ".github/instructions/planner.instructions.md");
719
+ expect(planner).toBeDefined();
720
+ expect(planner.content).toContain("applyTo:");
721
+ expect(planner.content).toContain("workspace/input.md");
722
+ expect(planner.content).toContain("workspace/plan.md");
723
+ });
724
+ it("does NOT include fictional frontmatter keys (tools, model, sandbox)", () => {
725
+ const builder = files.find((f) => f.path === ".github/instructions/builder.instructions.md");
726
+ expect(builder).toBeDefined();
727
+ // Frontmatter should only contain applyTo
728
+ const fmMatch = builder.content.match(/---\n([\s\S]*?)\n---/);
729
+ expect(fmMatch).toBeDefined();
730
+ const fmContent = fmMatch[1];
731
+ expect(fmContent).not.toContain("tools:");
732
+ expect(fmContent).not.toContain("model:");
733
+ expect(fmContent).not.toContain("sandbox:");
734
+ expect(fmContent).not.toContain("name:");
735
+ expect(fmContent).not.toContain("permissions:");
736
+ });
737
+ it("generates gate script for enforced gate", () => {
738
+ const gateScript = files.find((f) => f.path === "scripts/gate-quality-check.sh");
739
+ expect(gateScript).toBeDefined();
740
+ expect(gateScript.content).toContain("lint.sh");
741
+ });
742
+ it("does NOT generate gate script for advisory gate", () => {
743
+ const advisoryScript = files.find((f) => f.path === "scripts/gate-soft-review.sh");
744
+ expect(advisoryScript).toBeUndefined();
745
+ });
746
+ });
747
+ // ---------------------------------------------------------------------------
748
+ // openclaw
749
+ // ---------------------------------------------------------------------------
750
+ describe("openclaw binding", () => {
751
+ const files = scaffoldBinding("openclaw");
752
+ assertStructuralInvariants(files);
753
+ it("produces openclaw.json with correct schema", () => {
754
+ const config = files.find((f) => f.path === "openclaw.json");
755
+ expect(config).toBeDefined();
756
+ const parsed = JSON.parse(config.content);
757
+ expect(parsed).toBeDefined();
758
+ // agents.defaults exists with workspace and model
759
+ expect(parsed.agents.defaults).toBeDefined();
760
+ expect(parsed.agents.defaults.workspace).toBe("~/.openclaw/workspace");
761
+ expect(parsed.agents.defaults.model.primary).toContain("anthropic/claude-");
762
+ // Agent models use provider/model-id format
763
+ const agentList = parsed.agents.list;
764
+ expect(agentList.length).toBeGreaterThan(0);
765
+ for (const agent of agentList) {
766
+ expect(agent.model.primary).toMatch(/^[a-z]+\/claude-/);
767
+ }
768
+ // Gateway auth uses mode: "token" format
769
+ expect(parsed.gateway.auth.mode).toBe("token");
770
+ expect(parsed.gateway.auth.token).toBeDefined();
771
+ expect(parsed.gateway.auth).not.toHaveProperty("requireToken");
772
+ // models.providers instead of top-level providers array
773
+ expect(parsed.models).toBeDefined();
774
+ expect(parsed.models.providers).toBeDefined();
775
+ expect(parsed.models.providers.anthropic).toBeDefined();
776
+ expect(parsed).not.toHaveProperty("providers");
777
+ // Tools have flat allow/deny, no "defaults" nesting
778
+ expect(parsed.tools.allow).toBeDefined();
779
+ expect(parsed.tools.deny).toBeDefined();
780
+ expect(parsed.tools.agentToAgent.enabled).toBe(true);
781
+ expect(parsed.tools).not.toHaveProperty("defaults");
782
+ // No unknown top-level keys
783
+ expect(parsed).not.toHaveProperty("modelFallbackChain");
784
+ expect(parsed).not.toHaveProperty("sandboxDefaults");
785
+ expect(parsed).not.toHaveProperty("defaults");
786
+ });
787
+ it("produces SOUL.md", () => {
788
+ const soul = files.find((f) => f.path === "SOUL.md");
789
+ expect(soul).toBeDefined();
790
+ });
791
+ it("produces AGENTS.md with tool restrictions and topology overview", () => {
792
+ const agents = files.find((f) => f.path === "AGENTS.md");
793
+ expect(agents).toBeDefined();
794
+ expect(agents.content).toContain("planner");
795
+ // Tool restrictions in AGENTS.md
796
+ expect(agents.content).toContain("Tools allowed:");
797
+ // Topology overview merged from TEAM.md
798
+ expect(agents.content).toContain("Topology Overview");
799
+ });
800
+ it("produces TOOLS.md", () => {
801
+ const tools = files.find((f) => f.path === "TOOLS.md");
802
+ expect(tools).toBeDefined();
803
+ });
804
+ it("produces MEMORY.md", () => {
805
+ const memory = files.find((f) => f.path === "MEMORY.md");
806
+ expect(memory).toBeDefined();
807
+ });
808
+ it("produces BOOTSTRAP.md", () => {
809
+ const bootstrap = files.find((f) => f.path === "BOOTSTRAP.md");
810
+ expect(bootstrap).toBeDefined();
811
+ });
812
+ it("does NOT produce TEAM.md", () => {
813
+ const team = files.find((f) => f.path === "TEAM.md");
814
+ expect(team).toBeUndefined();
815
+ });
816
+ it("produces USER.md", () => {
817
+ const user = files.find((f) => f.path === "USER.md");
818
+ expect(user).toBeDefined();
819
+ expect(user.content).toContain("User Profile");
820
+ });
821
+ it("produces IDENTITY.md", () => {
822
+ const identity = files.find((f) => f.path === "IDENTITY.md");
823
+ expect(identity).toBeDefined();
824
+ expect(identity.content).toContain("Identity");
825
+ expect(identity.content).toContain("Name:");
826
+ });
827
+ it("produces memory/ and skills/ directories", () => {
828
+ const memoryDir = files.find((f) => f.path === "memory/.gitkeep");
829
+ const skillsDir = files.find((f) => f.path === "skills/.gitkeep");
830
+ expect(memoryDir).toBeDefined();
831
+ expect(skillsDir).toBeDefined();
832
+ });
833
+ });
834
+ // ---------------------------------------------------------------------------
835
+ // kiro (deep)
836
+ // ---------------------------------------------------------------------------
837
+ describe("kiro binding (deep)", () => {
838
+ // A richer topology that exercises: multiple agents with tools/prompts,
839
+ // hooks, triggers, mcp-servers, gates, memory, and different tool mappings.
840
+ const RICH_TOPOLOGY = `
841
+ topology deep-kiro-test : [pipeline, fan-out] {
842
+
843
+ meta {
844
+ version: "2.0.0"
845
+ description: "Rich topology for deep Kiro binding tests"
846
+ }
847
+
848
+ orchestrator {
849
+ model: opus
850
+ generates: "plans/run.md"
851
+ handles: [ingest]
852
+ }
853
+
854
+ roles {
855
+ analyzer: "Analyzes incoming data and produces structured reports"
856
+ coder: "Writes and modifies source code"
857
+ reviewer: "Reviews code changes for quality"
858
+ }
859
+
860
+ action ingest {
861
+ kind: external
862
+ source: "webhook"
863
+ description: "Ingest data from webhook"
864
+ }
865
+
866
+ agent analyzer {
867
+ role: analyzer
868
+ model: sonnet
869
+ phase: 1
870
+ tools: [Read, Grep]
871
+ reads: ["workspace/raw.md"]
872
+ writes: ["workspace/analysis.md"]
873
+ permissions: supervised
874
+ outputs: {
875
+ quality: good | bad | unknown
876
+ }
877
+ prompt {
878
+ Carefully analyze all incoming data.
879
+ Produce a structured report with findings.
880
+ }
881
+ }
882
+
883
+ agent coder {
884
+ role: coder
885
+ model: opus
886
+ phase: 2
887
+ tools: [Read, Write, Bash, Edit]
888
+ reads: ["workspace/analysis.md"]
889
+ writes: ["workspace/code/"]
890
+ permissions: autonomous
891
+ mcp-servers: [filesystem, github]
892
+ prompt {
893
+ Implement code changes based on the analysis report.
894
+ Follow existing patterns in the codebase.
895
+ }
896
+ hooks {
897
+ hook coder-post-write {
898
+ on: PostToolUse
899
+ matcher: Write
900
+ run: "scripts/format.sh"
901
+ type: command
902
+ }
903
+ }
904
+ }
905
+
906
+ agent reviewer {
907
+ role: reviewer
908
+ model: haiku
909
+ phase: 3
910
+ tools: [Read, Bash]
911
+ reads: ["workspace/code/"]
912
+ writes: ["workspace/review.md"]
913
+ permissions: supervised
914
+ }
915
+
916
+ flow {
917
+ ingest -> analyzer
918
+ analyzer -> coder [when analyzer.quality == good]
919
+ coder -> reviewer
920
+ reviewer -> coder [when reviewer.verdict == revise] [max 3]
921
+ }
922
+
923
+ gates {
924
+ gate lint-gate {
925
+ after: coder
926
+ run: "scripts/lint-check.sh"
927
+ checks: [lint, types, format]
928
+ on-fail: halt
929
+ }
930
+ gate security-gate {
931
+ before: reviewer
932
+ run: "scripts/security-scan.sh"
933
+ checks: [cve, secrets]
934
+ on-fail: halt
935
+ }
936
+ }
937
+
938
+ triggers {
939
+ command analyze {
940
+ pattern: "/analyze <INPUT>"
941
+ argument: INPUT
942
+ }
943
+ command deploy {
944
+ pattern: "/deploy <ENV>"
945
+ argument: ENV
946
+ }
947
+ }
948
+
949
+ hooks {
950
+ hook global-post-write {
951
+ on: PostToolUse
952
+ matcher: Write
953
+ run: "scripts/post-write.sh"
954
+ type: command
955
+ }
956
+ hook pre-bash {
957
+ on: PreToolUse
958
+ matcher: Bash
959
+ run: "scripts/pre-bash.sh"
960
+ type: command
961
+ }
962
+ }
963
+
964
+ mcp-servers {
965
+ filesystem {
966
+ command: "npx"
967
+ args: ["-y", "@modelcontextprotocol/server-filesystem"]
968
+ }
969
+ github {
970
+ command: "npx"
971
+ args: ["-y", "@modelcontextprotocol/server-github"]
972
+ env: {
973
+ GITHUB_TOKEN: "\${GITHUB_TOKEN}"
974
+ }
975
+ }
976
+ }
977
+
978
+ memory {
979
+ domains: true
980
+ references: true
981
+ metrics: true
982
+ workspace {
983
+ path: "workspace/"
984
+ structure: [raw, analysis, code, review]
985
+ }
986
+ }
987
+
988
+ settings {
989
+ allow: ["Read", "Grep", "Glob"]
990
+ deny: ["Bash"]
991
+ }
992
+ }
993
+ `;
994
+ const richAst = parse(RICH_TOPOLOGY);
995
+ const files = kiroBinding.scaffold(richAst);
996
+ // 1. Agent JSON validity
997
+ describe("agent JSON validity", () => {
998
+ const agentFiles = files.filter((f) => f.path.match(/\.kiro\/agents\/.*\.json$/));
999
+ it("produces a JSON file for each agent", () => {
1000
+ expect(agentFiles.length).toBe(3);
1001
+ const paths = agentFiles.map((f) => f.path);
1002
+ expect(paths).toContain(".kiro/agents/analyzer.json");
1003
+ expect(paths).toContain(".kiro/agents/coder.json");
1004
+ expect(paths).toContain(".kiro/agents/reviewer.json");
1005
+ });
1006
+ it("each agent JSON has id (name), description, and instructions (prompt) fields", () => {
1007
+ for (const f of agentFiles) {
1008
+ const json = JSON.parse(f.content);
1009
+ expect(json.name, `${f.path} should have a name field`).toBeDefined();
1010
+ expect(typeof json.name).toBe("string");
1011
+ expect(json.name.length).toBeGreaterThan(0);
1012
+ expect(json.description, `${f.path} should have a description field`).toBeDefined();
1013
+ expect(typeof json.description).toBe("string");
1014
+ expect(json.description.length).toBeGreaterThan(0);
1015
+ expect(json.prompt, `${f.path} should have a prompt (instructions) field`).toBeDefined();
1016
+ expect(typeof json.prompt).toBe("string");
1017
+ expect(json.prompt.length).toBeGreaterThan(0);
1018
+ }
1019
+ });
1020
+ });
1021
+ // 2. Steering docs have YAML frontmatter
1022
+ describe("steering docs", () => {
1023
+ const steeringFiles = files.filter((f) => f.path.startsWith(".kiro/steering/"));
1024
+ it("produces at least 3 steering files", () => {
1025
+ expect(steeringFiles.length).toBeGreaterThanOrEqual(3);
1026
+ });
1027
+ it("each steering file starts with YAML frontmatter (---)", () => {
1028
+ for (const f of steeringFiles) {
1029
+ expect(f.content.startsWith("---"), `${f.path} should start with YAML frontmatter (---)`).toBe(true);
1030
+ // Should have closing frontmatter delimiter too
1031
+ const secondDash = f.content.indexOf("---", 3);
1032
+ expect(secondDash, `${f.path} should have closing --- in frontmatter`).toBeGreaterThan(3);
1033
+ }
1034
+ });
1035
+ });
1036
+ // 3. MCP config matches topology's mcp-servers
1037
+ describe("MCP config", () => {
1038
+ const mcpFile = files.find((f) => f.path === ".kiro/settings/mcp.json");
1039
+ it("produces valid JSON for mcp.json", () => {
1040
+ expect(mcpFile).toBeDefined();
1041
+ expect(() => JSON.parse(mcpFile.content)).not.toThrow();
1042
+ });
1043
+ it("contains all declared MCP servers", () => {
1044
+ const mcp = JSON.parse(mcpFile.content);
1045
+ expect(mcp.mcpServers).toBeDefined();
1046
+ expect(mcp.mcpServers.filesystem).toBeDefined();
1047
+ expect(mcp.mcpServers.github).toBeDefined();
1048
+ });
1049
+ it("server entries have command and args fields", () => {
1050
+ const mcp = JSON.parse(mcpFile.content);
1051
+ for (const [name, config] of Object.entries(mcp.mcpServers)) {
1052
+ expect(config.command, `MCP server "${name}" should have a command`).toBeDefined();
1053
+ expect(config.args, `MCP server "${name}" should have args`).toBeDefined();
1054
+ }
1055
+ });
1056
+ it("preserves env field from topology", () => {
1057
+ const mcp = JSON.parse(mcpFile.content);
1058
+ // The parser stores env as-is from the topology; verify it exists on each server
1059
+ for (const [name, config] of Object.entries(mcp.mcpServers)) {
1060
+ expect(config.env, `MCP server "${name}" should have an env field`).toBeDefined();
1061
+ }
1062
+ });
1063
+ });
1064
+ // 4. Hook mapping — verify hook/gate scripts are generated
1065
+ describe("hook mapping", () => {
1066
+ it("generates script stubs for global hooks", () => {
1067
+ const postWriteScript = files.find((f) => f.path === ".kiro/scripts/post-write.sh");
1068
+ expect(postWriteScript, "should generate post-write.sh for global-post-write hook").toBeDefined();
1069
+ expect(postWriteScript.content).toContain("postToolUse");
1070
+ const preBashScript = files.find((f) => f.path === ".kiro/scripts/pre-bash.sh");
1071
+ expect(preBashScript, "should generate pre-bash.sh for pre-bash hook").toBeDefined();
1072
+ expect(preBashScript.content).toContain("preToolUse");
1073
+ });
1074
+ it("generates script stubs for gate scripts", () => {
1075
+ const lintScript = files.find((f) => f.path === ".kiro/scripts/lint-check.sh");
1076
+ expect(lintScript, "should generate lint-check.sh for lint-gate").toBeDefined();
1077
+ expect(lintScript.content).toContain("lint-gate");
1078
+ const securityScript = files.find((f) => f.path === ".kiro/scripts/security-scan.sh");
1079
+ expect(securityScript, "should generate security-scan.sh for security-gate").toBeDefined();
1080
+ expect(securityScript.content).toContain("security-gate");
1081
+ });
1082
+ });
1083
+ // 5. Flow as prose — verify AGENTS.md contains agent names and flow description
1084
+ describe("flow as prose in AGENTS.md", () => {
1085
+ const agentsMd = files.find((f) => f.path === "AGENTS.md");
1086
+ it("AGENTS.md is generated", () => {
1087
+ expect(agentsMd).toBeDefined();
1088
+ });
1089
+ it("contains all agent names", () => {
1090
+ const content = agentsMd.content;
1091
+ expect(content).toContain("analyzer");
1092
+ expect(content).toContain("coder");
1093
+ expect(content).toContain("reviewer");
1094
+ });
1095
+ it("contains a Flow section with edge descriptions", () => {
1096
+ const content = agentsMd.content;
1097
+ expect(content).toContain("## Flow");
1098
+ expect(content).toContain("analyzer -> coder");
1099
+ expect(content).toContain("coder -> reviewer");
1100
+ expect(content).toContain("reviewer -> coder");
1101
+ });
1102
+ it("contains gate descriptions", () => {
1103
+ const content = agentsMd.content;
1104
+ // Gate ids are title-cased in AGENTS.md (lint-gate -> Lint Gate)
1105
+ expect(content).toContain("Lint Gate");
1106
+ expect(content).toContain("Security Gate");
1107
+ });
1108
+ it("contains trigger descriptions", () => {
1109
+ const content = agentsMd.content;
1110
+ expect(content).toContain("/analyze");
1111
+ expect(content).toContain("/deploy");
1112
+ });
1113
+ });
1114
+ // 6. Tool mapping — verify mapped tool names in agent instructions
1115
+ describe("tool mapping", () => {
1116
+ it("maps Read to 'read' in agent tools", () => {
1117
+ const analyzerFile = files.find((f) => f.path === ".kiro/agents/analyzer.json");
1118
+ const json = JSON.parse(analyzerFile.content);
1119
+ expect(json.tools).toContain("read");
1120
+ });
1121
+ it("maps Write to 'write' in agent tools", () => {
1122
+ const coderFile = files.find((f) => f.path === ".kiro/agents/coder.json");
1123
+ const json = JSON.parse(coderFile.content);
1124
+ expect(json.tools).toContain("write");
1125
+ });
1126
+ it("maps Bash to 'shell' in agent tools", () => {
1127
+ const coderFile = files.find((f) => f.path === ".kiro/agents/coder.json");
1128
+ const json = JSON.parse(coderFile.content);
1129
+ expect(json.tools).toContain("shell");
1130
+ });
1131
+ it("maps Edit to 'write' in agent tools", () => {
1132
+ const coderFile = files.find((f) => f.path === ".kiro/agents/coder.json");
1133
+ const json = JSON.parse(coderFile.content);
1134
+ // Edit maps to "write", so coder should have "write" (from both Write and Edit)
1135
+ expect(json.tools).toContain("write");
1136
+ });
1137
+ it("maps Grep to lowercase 'grep' in agent tools", () => {
1138
+ const analyzerFile = files.find((f) => f.path === ".kiro/agents/analyzer.json");
1139
+ const json = JSON.parse(analyzerFile.content);
1140
+ expect(json.tools).toContain("grep");
1141
+ });
1142
+ });
1143
+ // 7. Prompt content — verify prompt text appears in agent JSON instructions
1144
+ describe("prompt content", () => {
1145
+ it("analyzer agent JSON contains its prompt text", () => {
1146
+ const analyzerFile = files.find((f) => f.path === ".kiro/agents/analyzer.json");
1147
+ const json = JSON.parse(analyzerFile.content);
1148
+ expect(json.prompt).toContain("Carefully analyze all incoming data");
1149
+ expect(json.prompt).toContain("structured report with findings");
1150
+ });
1151
+ it("coder agent JSON contains its prompt text", () => {
1152
+ const coderFile = files.find((f) => f.path === ".kiro/agents/coder.json");
1153
+ const json = JSON.parse(coderFile.content);
1154
+ expect(json.prompt).toContain("Implement code changes based on the analysis report");
1155
+ expect(json.prompt).toContain("Follow existing patterns in the codebase");
1156
+ });
1157
+ it("reviewer agent JSON has role text but no inline prompt", () => {
1158
+ const reviewerFile = files.find((f) => f.path === ".kiro/agents/reviewer.json");
1159
+ const json = JSON.parse(reviewerFile.content);
1160
+ // reviewer has no prompt block, so only role text should be present
1161
+ expect(json.prompt).toContain("Reviews code changes for quality");
1162
+ expect(json.prompt).not.toContain("## Instructions");
1163
+ });
1164
+ });
1165
+ // 8. Trigger prompts — verify .kiro/prompts/ files are generated
1166
+ describe("trigger prompts", () => {
1167
+ it("generates a prompt file for each trigger", () => {
1168
+ const analyzePrompt = files.find((f) => f.path === ".kiro/prompts/analyze.md");
1169
+ expect(analyzePrompt, "should generate analyze.md prompt").toBeDefined();
1170
+ const deployPrompt = files.find((f) => f.path === ".kiro/prompts/deploy.md");
1171
+ expect(deployPrompt, "should generate deploy.md prompt").toBeDefined();
1172
+ });
1173
+ it("prompt files contain the trigger pattern", () => {
1174
+ const analyzePrompt = files.find((f) => f.path === ".kiro/prompts/analyze.md");
1175
+ expect(analyzePrompt.content).toContain("/analyze <INPUT>");
1176
+ const deployPrompt = files.find((f) => f.path === ".kiro/prompts/deploy.md");
1177
+ expect(deployPrompt.content).toContain("/deploy <ENV>");
1178
+ });
1179
+ it("prompt files contain argument info", () => {
1180
+ const analyzePrompt = files.find((f) => f.path === ".kiro/prompts/analyze.md");
1181
+ expect(analyzePrompt.content).toContain("INPUT");
1182
+ const deployPrompt = files.find((f) => f.path === ".kiro/prompts/deploy.md");
1183
+ expect(deployPrompt.content).toContain("ENV");
1184
+ });
1185
+ it("prompt files contain agent summary table", () => {
1186
+ const analyzePrompt = files.find((f) => f.path === ".kiro/prompts/analyze.md");
1187
+ expect(analyzePrompt.content).toContain("analyzer");
1188
+ expect(analyzePrompt.content).toContain("coder");
1189
+ expect(analyzePrompt.content).toContain("reviewer");
1190
+ });
1191
+ });
1192
+ // Additional: memory directories
1193
+ describe("memory directories", () => {
1194
+ it("generates .kiro/runs/.gitkeep for workspace memory", () => {
1195
+ // The parser resolves the workspace sub-block from memory {}
1196
+ expect(files.find((f) => f.path === ".kiro/runs/.gitkeep")).toBeDefined();
1197
+ });
1198
+ it("structure steering mentions workspace layout", () => {
1199
+ const structureSteering = files.find((f) => f.path === ".kiro/steering/structure.md");
1200
+ expect(structureSteering).toBeDefined();
1201
+ // The workspace structure items appear in the steering doc
1202
+ expect(structureSteering.content).toContain("Workspace");
1203
+ });
1204
+ });
1205
+ // Additional: per-agent hooks and MCP resources
1206
+ describe("per-agent hooks and MCP resources", () => {
1207
+ it("coder agent JSON includes per-agent hooks as object keyed by event", () => {
1208
+ const coderFile = files.find((f) => f.path === ".kiro/agents/coder.json");
1209
+ const json = JSON.parse(coderFile.content);
1210
+ expect(json.hooks).toBeDefined();
1211
+ expect(json.hooks.postToolUse).toBeDefined();
1212
+ expect(json.hooks.postToolUse.length).toBeGreaterThanOrEqual(1);
1213
+ expect(json.hooks.postToolUse[0].command).toBe("scripts/format.sh");
1214
+ expect(json.hooks.postToolUse[0].matcher).toBe("fs_write");
1215
+ });
1216
+ it("coder agent JSON includes inline mcpServers config", () => {
1217
+ const coderFile = files.find((f) => f.path === ".kiro/agents/coder.json");
1218
+ const json = JSON.parse(coderFile.content);
1219
+ expect(json.mcpServers).toBeDefined();
1220
+ expect(json.mcpServers.filesystem).toBeDefined();
1221
+ expect(json.mcpServers.filesystem.command).toBe("npx");
1222
+ expect(json.mcpServers.github).toBeDefined();
1223
+ expect(json.mcpServers.github.command).toBe("npx");
1224
+ // type field should be stripped for stdio servers
1225
+ expect(json.mcpServers.filesystem.type).toBeUndefined();
1226
+ expect(json.mcpServers.github.type).toBeUndefined();
1227
+ });
1228
+ });
1229
+ });
1230
+ // ---------------------------------------------------------------------------
1231
+ // kiro
1232
+ // ---------------------------------------------------------------------------
1233
+ describe("kiro binding", () => {
1234
+ const files = scaffoldBinding("kiro");
1235
+ assertStructuralInvariants(files);
1236
+ it("produces .kiro/agents/*.json for each agent", () => {
1237
+ const agentFiles = files.filter((f) => f.path.match(/\.kiro\/agents\/.*\.json$/));
1238
+ expect(agentFiles.length).toBeGreaterThanOrEqual(2);
1239
+ const paths = agentFiles.map((f) => f.path);
1240
+ expect(paths).toContain(".kiro/agents/planner.json");
1241
+ expect(paths).toContain(".kiro/agents/builder.json");
1242
+ // Verify JSON is valid
1243
+ for (const f of agentFiles) {
1244
+ expect(() => JSON.parse(f.content)).not.toThrow();
1245
+ }
1246
+ });
1247
+ it("produces steering documents", () => {
1248
+ const steeringFiles = files.filter((f) => f.path.startsWith(".kiro/steering/"));
1249
+ expect(steeringFiles.length).toBeGreaterThanOrEqual(3);
1250
+ const paths = steeringFiles.map((f) => f.path);
1251
+ expect(paths).toContain(".kiro/steering/product.md");
1252
+ expect(paths).toContain(".kiro/steering/tech.md");
1253
+ expect(paths).toContain(".kiro/steering/structure.md");
1254
+ });
1255
+ it("produces .kiro/settings/mcp.json for MCP servers", () => {
1256
+ const mcpFile = files.find((f) => f.path === ".kiro/settings/mcp.json");
1257
+ expect(mcpFile).toBeDefined();
1258
+ const mcp = JSON.parse(mcpFile.content);
1259
+ expect(mcp.mcpServers.filesystem).toBeDefined();
1260
+ });
1261
+ it("produces AGENTS.md top-level flow narrative", () => {
1262
+ const agentsMd = files.find((f) => f.path === "AGENTS.md");
1263
+ expect(agentsMd).toBeDefined();
1264
+ expect(agentsMd.content).toContain("Flow");
1265
+ });
1266
+ it("produces prompt files from triggers", () => {
1267
+ const promptFile = files.find((f) => f.path === ".kiro/prompts/run.md");
1268
+ expect(promptFile).toBeDefined();
1269
+ });
1270
+ it("produces memory directories", () => {
1271
+ const runsKeep = files.find((f) => f.path === ".kiro/runs/.gitkeep");
1272
+ expect(runsKeep).toBeDefined();
1273
+ });
1274
+ it("generates .kiro/hooks/ JSON file for enforced gate", () => {
1275
+ const hookFile = files.find((f) => f.path === ".kiro/hooks/gate-quality-check.kiro.hook");
1276
+ expect(hookFile).toBeDefined();
1277
+ const hookJson = JSON.parse(hookFile.content);
1278
+ expect(hookJson.enabled).toBe(true);
1279
+ expect(hookJson.when.type).toBe("fileEdited");
1280
+ expect(hookJson.then.type).toBe("runCommand");
1281
+ expect(hookJson.then.command).toContain("lint.sh");
1282
+ });
1283
+ it("does NOT generate hook file for advisory gate", () => {
1284
+ const advisoryHook = files.find((f) => f.path === ".kiro/hooks/gate-soft-review.kiro.hook");
1285
+ expect(advisoryHook).toBeUndefined();
1286
+ });
1287
+ });
1288
+ // cursor — Ground-truth validation against real-world Cursor IDE configs
1289
+ //
1290
+ // Reference configs sourced from GitHub:
1291
+ // - maccman/ai-monorepo-scaffold (16 .mdc rules)
1292
+ // - marcoemrich/mad-tdd-mob-ai-driven (TDD rules)
1293
+ // - OthmanAdi/planning-with-files (hooks.json)
1294
+ // - mpaiva/project-starter-template-cursor (mcp.json)
1295
+ // - sanjeed5/awesome-cursor-rules-mdc (3,377 stars, 230+ rules)
1296
+ // ---------------------------------------------------------------------------
1297
+ describe("cursor binding — ground-truth validation", () => {
1298
+ const files = scaffoldBinding("cursor");
1299
+ assertStructuralInvariants(files);
1300
+ // -----------------------------------------------------------------------
1301
+ // .mdc frontmatter format — validated against real .mdc files
1302
+ // -----------------------------------------------------------------------
1303
+ describe("frontmatter matches real-world .mdc format", () => {
1304
+ const mdcFiles = files.filter((f) => f.path.endsWith(".mdc"));
1305
+ it("all .mdc files have YAML frontmatter", () => {
1306
+ for (const f of mdcFiles) {
1307
+ expect(f.content.startsWith("---\n"), `${f.path} missing frontmatter`).toBe(true);
1308
+ const secondDash = f.content.indexOf("---", 4);
1309
+ expect(secondDash).toBeGreaterThan(4);
1310
+ }
1311
+ });
1312
+ it("frontmatter always contains all 3 fields: description, globs, alwaysApply", () => {
1313
+ // Real .mdc files (maccman/ai-monorepo-scaffold) include all three fields
1314
+ // even when empty. e.g.: description:\n globs:\n alwaysApply: false
1315
+ for (const f of mdcFiles) {
1316
+ const fm = f.content.split("---")[1];
1317
+ expect(fm, `${f.path} missing description field`).toContain("description:");
1318
+ expect(fm, `${f.path} missing globs field`).toContain("globs:");
1319
+ expect(fm, `${f.path} missing alwaysApply field`).toContain("alwaysApply:");
1320
+ }
1321
+ });
1322
+ it("alwaysApply is 'true' or 'false', never omitted", () => {
1323
+ // Real configs always spell out the boolean value explicitly
1324
+ for (const f of mdcFiles) {
1325
+ const fm = f.content.split("---")[1];
1326
+ expect(fm).toMatch(/alwaysApply: (true|false)/);
1327
+ }
1328
+ });
1329
+ it("globs uses comma-separated string format (not YAML array)", () => {
1330
+ // Real: globs: **/*.ts, **/*.tsx (NOT globs:\n - **/*.ts)
1331
+ for (const f of mdcFiles) {
1332
+ const fm = f.content.split("---")[1];
1333
+ // Must NOT have YAML array syntax for globs
1334
+ expect(fm).not.toMatch(/globs:\n\s+-/);
1335
+ }
1336
+ });
1337
+ });
1338
+ // -----------------------------------------------------------------------
1339
+ // Rule type mapping — matches Cursor docs rule type table
1340
+ // -----------------------------------------------------------------------
1341
+ describe("rule type mapping matches Cursor rule type table", () => {
1342
+ it("topology-overview is 'Always Apply' (alwaysApply: true)", () => {
1343
+ // Like maccman/ai-monorepo-scaffold always.mdc
1344
+ const overview = files.find((f) => f.path === ".cursor/rules/topology-overview.mdc");
1345
+ const fm = overview.content.split("---")[1];
1346
+ expect(fm).toContain("alwaysApply: true");
1347
+ });
1348
+ it("regular agents are 'Apply Intelligently' (description set, alwaysApply: false)", () => {
1349
+ // Like maccman/ai-monorepo-scaffold trpc.mdc
1350
+ const planner = files.find((f) => f.path === ".cursor/rules/planner.mdc");
1351
+ const fm = planner.content.split("---")[1];
1352
+ expect(fm).toContain("alwaysApply: false");
1353
+ expect(fm).toMatch(/description: .+/); // non-empty description
1354
+ expect(fm).toMatch(/globs:\s*$/m); // empty globs
1355
+ });
1356
+ it("trigger rules are 'Apply Manually' (empty description, empty globs, alwaysApply: false)", () => {
1357
+ // Like marcoemrich/mad-tdd-mob-ai-driven greeting.mdc
1358
+ const trigger = files.find((f) => f.path === ".cursor/rules/trigger-run.mdc");
1359
+ const fm = trigger.content.split("---")[1];
1360
+ expect(fm).toContain("alwaysApply: false");
1361
+ });
1362
+ });
1363
+ // -----------------------------------------------------------------------
1364
+ // .mdc body format
1365
+ // -----------------------------------------------------------------------
1366
+ describe("rule body is standard Markdown", () => {
1367
+ it("agent rules have heading, instructions, tools sections", () => {
1368
+ const planner = files.find((f) => f.path === ".cursor/rules/planner.mdc");
1369
+ expect(planner.content).toContain("# Planner");
1370
+ expect(planner.content).toContain("## Instructions");
1371
+ expect(planner.content).toContain("## Tools");
1372
+ expect(planner.content).toContain("- Read");
1373
+ expect(planner.content).toContain("- Grep");
1374
+ });
1375
+ it("disallowed tools section exists for agents with disallowed-tools", () => {
1376
+ const builder = files.find((f) => f.path === ".cursor/rules/builder.mdc");
1377
+ expect(builder.content).toContain("Disallowed");
1378
+ expect(builder.content).toContain("Edit");
1379
+ });
1380
+ it("memory reads/writes are documented in body", () => {
1381
+ const planner = files.find((f) => f.path === ".cursor/rules/planner.mdc");
1382
+ expect(planner.content).toContain("workspace/input.md");
1383
+ expect(planner.content).toContain("workspace/plan.md");
1384
+ });
1385
+ });
1386
+ // -----------------------------------------------------------------------
1387
+ // MCP config — validated against real .cursor/mcp.json files
1388
+ // -----------------------------------------------------------------------
1389
+ describe("MCP config matches real-world .cursor/mcp.json format", () => {
1390
+ const mcpFile = files.find((f) => f.path === ".cursor/mcp.json");
1391
+ it("produces .cursor/mcp.json", () => {
1392
+ expect(mcpFile).toBeDefined();
1393
+ });
1394
+ it("has mcpServers top-level key (like mpaiva/project-starter-template-cursor)", () => {
1395
+ const config = JSON.parse(mcpFile.content);
1396
+ expect(config.mcpServers).toBeDefined();
1397
+ expect(typeof config.mcpServers).toBe("object");
1398
+ });
1399
+ it("server entries have command and args (stdio format)", () => {
1400
+ // Matches real config from mpaiva/project-starter-template-cursor
1401
+ const config = JSON.parse(mcpFile.content);
1402
+ const server = config.mcpServers.filesystem;
1403
+ expect(server).toBeDefined();
1404
+ expect(server.args).toBeInstanceOf(Array);
1405
+ expect(server.args).toContain("-y");
1406
+ expect(server.args).toContain("@modelcontextprotocol/server-filesystem");
1407
+ });
1408
+ it("does NOT force empty env object (real configs omit env when unused)", () => {
1409
+ // Real: ZackHu-2001/apply-bot mcp.json has no env field at all
1410
+ const config = JSON.parse(mcpFile.content);
1411
+ const server = config.mcpServers.filesystem;
1412
+ expect(server.env).toBeUndefined();
1413
+ });
1414
+ it("no extra fields beyond command, args, env", () => {
1415
+ // Real configs only have: command, args, optional env (or url for SSE)
1416
+ const config = JSON.parse(mcpFile.content);
1417
+ const server = config.mcpServers.filesystem;
1418
+ const keys = Object.keys(server);
1419
+ for (const key of keys) {
1420
+ expect(["command", "args", "env", "url"]).toContain(key);
1421
+ }
1422
+ });
1423
+ });
1424
+ // -----------------------------------------------------------------------
1425
+ // hooks.json — validated against OthmanAdi/planning-with-files hooks.json
1426
+ // -----------------------------------------------------------------------
1427
+ describe("hooks.json matches real-world Cursor hooks format", () => {
1428
+ const hooksFile = files.find((f) => f.path === ".cursor/hooks.json");
1429
+ it("produces .cursor/hooks.json", () => {
1430
+ expect(hooksFile).toBeDefined();
1431
+ });
1432
+ it("has version: 1 at top level", () => {
1433
+ // Real: OthmanAdi/planning-with-files hooks.json
1434
+ const config = JSON.parse(hooksFile.content);
1435
+ expect(config.version).toBe(1);
1436
+ });
1437
+ it("has hooks object at top level", () => {
1438
+ const config = JSON.parse(hooksFile.content);
1439
+ expect(config.hooks).toBeDefined();
1440
+ expect(typeof config.hooks).toBe("object");
1441
+ });
1442
+ it("hook event names are camelCase (NOT PascalCase like Claude Code)", () => {
1443
+ // Real: preToolUse, postToolUse, stop (NOT PreToolUse, PostToolUse, Stop)
1444
+ const config = JSON.parse(hooksFile.content);
1445
+ const events = Object.keys(config.hooks);
1446
+ for (const event of events) {
1447
+ // First char must be lowercase
1448
+ expect(event[0], `Event "${event}" should be camelCase`).toBe(event[0].toLowerCase());
1449
+ // Must NOT be PascalCase
1450
+ expect(event).not.toMatch(/^[A-Z]/);
1451
+ }
1452
+ });
1453
+ it("hook entries are flat objects (NOT nested hooks array like Claude Code)", () => {
1454
+ // Real Cursor: { command, matcher, timeout }
1455
+ // Claude Code: { matcher, hooks: [{ type, command, timeout }] }
1456
+ const config = JSON.parse(hooksFile.content);
1457
+ for (const entries of Object.values(config.hooks)) {
1458
+ for (const entry of entries) {
1459
+ expect(entry.command, "each hook entry must have a command").toBeDefined();
1460
+ // Must NOT have nested hooks array (that's Claude Code format)
1461
+ expect(entry).not.toHaveProperty("hooks");
1462
+ }
1463
+ }
1464
+ });
1465
+ it("hook entries do NOT have a 'type' field (Cursor has no prompt hooks)", () => {
1466
+ // Real Cursor hooks have no type field — all hooks are command-based
1467
+ // Claude Code has type: "command" | "prompt"
1468
+ const config = JSON.parse(hooksFile.content);
1469
+ for (const entries of Object.values(config.hooks)) {
1470
+ for (const entry of entries) {
1471
+ expect(entry).not.toHaveProperty("type");
1472
+ }
1473
+ }
1474
+ });
1475
+ it("failClosed is only present when true (default is false)", () => {
1476
+ // Real: OthmanAdi/planning-with-files doesn't include failClosed at all
1477
+ const config = JSON.parse(hooksFile.content);
1478
+ for (const entries of Object.values(config.hooks)) {
1479
+ for (const entry of entries) {
1480
+ if ("failClosed" in entry) {
1481
+ expect(entry.failClosed).toBe(true);
1482
+ }
1483
+ }
1484
+ }
1485
+ });
1486
+ it("timeout is in seconds (NOT milliseconds like Claude Code)", () => {
1487
+ // Real: timeout: 5 (seconds). Claude Code: timeout: 5000 (milliseconds)
1488
+ const config = JSON.parse(hooksFile.content);
1489
+ for (const entries of Object.values(config.hooks)) {
1490
+ for (const entry of entries) {
1491
+ if ("timeout" in entry) {
1492
+ // No reasonable hook timeout would be > 3600 seconds
1493
+ // If it's > 10000 it's probably in milliseconds (bug)
1494
+ expect(entry.timeout).toBeLessThan(10000);
1495
+ }
1496
+ }
1497
+ }
1498
+ });
1499
+ it("script paths use .cursor/hooks/ directory (like real configs)", () => {
1500
+ // Real: OthmanAdi uses .cursor/hooks/pre-tool-use.sh
1501
+ const config = JSON.parse(hooksFile.content);
1502
+ for (const entries of Object.values(config.hooks)) {
1503
+ for (const entry of entries) {
1504
+ const cmd = entry.command;
1505
+ if (cmd.includes(".cursor")) {
1506
+ expect(cmd).toMatch(/\.cursor\/hooks\//);
1507
+ // Must NOT use .cursor/scripts/hooks/ (non-standard)
1508
+ expect(cmd).not.toContain("/scripts/hooks/");
1509
+ }
1510
+ }
1511
+ }
1512
+ });
1513
+ it("gate with on-fail: halt compiles to failClosed: true", () => {
1514
+ // quality-check gate has on-fail: halt -> blocking
1515
+ const config = JSON.parse(hooksFile.content);
1516
+ const postHooks = config.hooks.postToolUse;
1517
+ const gateHook = postHooks.find((h) => h.command.includes("gate-quality-check"));
1518
+ expect(gateHook).toBeDefined();
1519
+ expect(gateHook.failClosed).toBe(true);
1520
+ });
1521
+ it("advisory gate does NOT have failClosed", () => {
1522
+ // soft-review gate has behavior: advisory -> no failClosed
1523
+ const config = JSON.parse(hooksFile.content);
1524
+ const postHooks = config.hooks.postToolUse;
1525
+ const gateHook = postHooks.find((h) => h.command.includes("gate-soft-review"));
1526
+ expect(gateHook).toBeDefined();
1527
+ expect(gateHook).not.toHaveProperty("failClosed");
1528
+ });
1529
+ });
1530
+ // -----------------------------------------------------------------------
1531
+ // Gate script files
1532
+ // -----------------------------------------------------------------------
1533
+ describe("gate scripts are generated at correct paths", () => {
1534
+ it("gate scripts live in .cursor/hooks/ (matching hooks.json references)", () => {
1535
+ const gateScripts = files.filter((f) => f.path.includes("gate-"));
1536
+ expect(gateScripts.length).toBeGreaterThanOrEqual(2);
1537
+ for (const f of gateScripts) {
1538
+ expect(f.path).toMatch(/^\.cursor\/hooks\/gate-/);
1539
+ }
1540
+ });
1541
+ it("gate scripts are executable bash with set -euo pipefail", () => {
1542
+ const gateScript = files.find((f) => f.path.includes("gate-quality-check"));
1543
+ expect(gateScript.content).toContain("#!/usr/bin/env bash");
1544
+ expect(gateScript.content).toContain("set -euo pipefail");
1545
+ });
1546
+ });
1547
+ // -----------------------------------------------------------------------
1548
+ // AGENTS.md context file
1549
+ // -----------------------------------------------------------------------
1550
+ describe("AGENTS.md context file", () => {
1551
+ const agentsMd = files.find((f) => f.path === "AGENTS.md");
1552
+ it("produces AGENTS.md at project root (Cursor supports AGENTS.md)", () => {
1553
+ expect(agentsMd).toBeDefined();
1554
+ });
1555
+ it("contains agent roster table", () => {
1556
+ expect(agentsMd.content).toContain("| Agent | ");
1557
+ expect(agentsMd.content).toContain("planner");
1558
+ expect(agentsMd.content).toContain("builder");
1559
+ });
1560
+ it("contains workflow section with delegation @-mention syntax", () => {
1561
+ // Cursor uses @rule-name for manual rule activation
1562
+ expect(agentsMd.content).toContain("@planner");
1563
+ expect(agentsMd.content).toContain("@builder");
1564
+ });
1565
+ });
1566
+ // -----------------------------------------------------------------------
1567
+ // Directory structure
1568
+ // -----------------------------------------------------------------------
1569
+ describe("output directory structure matches Cursor conventions", () => {
1570
+ it("all .mdc files are under .cursor/rules/", () => {
1571
+ const mdcFiles = files.filter((f) => f.path.endsWith(".mdc"));
1572
+ expect(mdcFiles.length).toBeGreaterThan(0);
1573
+ for (const f of mdcFiles) {
1574
+ expect(f.path).toMatch(/^\.cursor\/rules\//);
1575
+ }
1576
+ });
1577
+ it("mcp.json is at .cursor/mcp.json (NOT project root .mcp.json)", () => {
1578
+ const mcpFile = files.find((f) => f.path === ".cursor/mcp.json");
1579
+ expect(mcpFile).toBeDefined();
1580
+ // Must NOT generate .mcp.json at root (that's Claude Code convention)
1581
+ const rootMcp = files.find((f) => f.path === ".mcp.json");
1582
+ expect(rootMcp).toBeUndefined();
1583
+ });
1584
+ it("hooks.json is at .cursor/hooks.json", () => {
1585
+ const hooksFile = files.find((f) => f.path === ".cursor/hooks.json");
1586
+ expect(hooksFile).toBeDefined();
1587
+ });
1588
+ it("does NOT generate .cursorrules by default (only on opt-in)", () => {
1589
+ const cursorrules = files.find((f) => f.path === ".cursorrules");
1590
+ expect(cursorrules).toBeUndefined();
1591
+ });
1592
+ it("does NOT generate any .claude/ files", () => {
1593
+ const claudeFiles = files.filter((f) => f.path.startsWith(".claude/"));
1594
+ expect(claudeFiles.length).toBe(0);
1595
+ });
1596
+ it("does NOT generate any .github/ files", () => {
1597
+ const githubFiles = files.filter((f) => f.path.startsWith(".github/"));
1598
+ expect(githubFiles.length).toBe(0);
1599
+ });
1600
+ it("does NOT generate any .kiro/ files", () => {
1601
+ const kiroFiles = files.filter((f) => f.path.startsWith(".kiro/"));
1602
+ expect(kiroFiles.length).toBe(0);
1603
+ });
1604
+ });
1605
+ // -----------------------------------------------------------------------
1606
+ // Format anti-patterns (things we must NOT produce)
1607
+ // -----------------------------------------------------------------------
1608
+ describe("no Claude Code format leakage", () => {
1609
+ it("no PascalCase hook events in hooks.json", () => {
1610
+ const hooksFile = files.find((f) => f.path === ".cursor/hooks.json");
1611
+ if (hooksFile) {
1612
+ // Must not contain PascalCase events like PreToolUse, PostToolUse
1613
+ expect(hooksFile.content).not.toContain('"PreToolUse"');
1614
+ expect(hooksFile.content).not.toContain('"PostToolUse"');
1615
+ expect(hooksFile.content).not.toContain('"Stop"');
1616
+ expect(hooksFile.content).not.toContain('"SessionStart"');
1617
+ }
1618
+ });
1619
+ it("no timeout in milliseconds (all timeouts must be in seconds)", () => {
1620
+ const hooksFile = files.find((f) => f.path === ".cursor/hooks.json");
1621
+ if (hooksFile) {
1622
+ // Should not see "timeout": 5000 (that's Claude Code milliseconds)
1623
+ const config = JSON.parse(hooksFile.content);
1624
+ for (const entries of Object.values(config.hooks)) {
1625
+ for (const entry of entries) {
1626
+ if ("timeout" in entry) {
1627
+ expect(entry.timeout, "timeout should be in seconds, not milliseconds").toBeLessThan(1000);
1628
+ }
1629
+ }
1630
+ }
1631
+ }
1632
+ });
1633
+ it("no AGENT.md files (that is Claude Code naming)", () => {
1634
+ const agentMd = files.filter((f) => f.path.endsWith("/AGENT.md"));
1635
+ expect(agentMd.length).toBe(0);
1636
+ });
1637
+ it("no settings.json (that is Claude Code config)", () => {
1638
+ const settingsJson = files.find((f) => f.path.endsWith("settings.json"));
1639
+ expect(settingsJson).toBeUndefined();
1640
+ });
1641
+ });
1642
+ // -----------------------------------------------------------------------
1643
+ // All 5 example .at files — scaffold and validate format
1644
+ // -----------------------------------------------------------------------
1645
+ describe("scaffolds all example .at files with correct format", () => {
1646
+ const fs = require("fs");
1647
+ const path = require("path");
1648
+ const examplesDir = path.resolve(__dirname, "../../../examples");
1649
+ const atFiles = fs.readdirSync(examplesDir).filter((f) => f.endsWith(".at"));
1650
+ it("finds at least 5 example .at files", () => {
1651
+ expect(atFiles.length).toBeGreaterThanOrEqual(5);
1652
+ });
1653
+ for (const atFile of atFiles) {
1654
+ describe(`example: ${atFile}`, () => {
1655
+ const src = fs.readFileSync(path.join(examplesDir, atFile), "utf-8");
1656
+ const exAst = parse(src);
1657
+ const exFiles = cursorBinding.scaffold(exAst);
1658
+ it("scaffolds without errors", () => {
1659
+ expect(exFiles.length).toBeGreaterThan(0);
1660
+ });
1661
+ it("all .mdc files have correct 3-field frontmatter", () => {
1662
+ const mdcFiles = exFiles.filter((f) => f.path.endsWith(".mdc"));
1663
+ for (const f of mdcFiles) {
1664
+ expect(f.content.startsWith("---\n"), `${f.path}`).toBe(true);
1665
+ const fm = f.content.split("---")[1];
1666
+ expect(fm, `${f.path} missing description`).toContain("description:");
1667
+ expect(fm, `${f.path} missing globs`).toContain("globs:");
1668
+ expect(fm, `${f.path} missing alwaysApply`).toContain("alwaysApply:");
1669
+ expect(fm, `${f.path} uses YAML array globs`).not.toMatch(/globs:\n\s+-/);
1670
+ }
1671
+ });
1672
+ it("hooks.json (if present) uses Cursor format", () => {
1673
+ const hooksFile = exFiles.find((f) => f.path === ".cursor/hooks.json");
1674
+ if (!hooksFile)
1675
+ return;
1676
+ const config = JSON.parse(hooksFile.content);
1677
+ expect(config.version).toBe(1);
1678
+ for (const event of Object.keys(config.hooks)) {
1679
+ expect(event[0]).toBe(event[0].toLowerCase());
1680
+ }
1681
+ for (const entries of Object.values(config.hooks)) {
1682
+ for (const entry of entries) {
1683
+ expect(entry).not.toHaveProperty("type");
1684
+ if ("failClosed" in entry)
1685
+ expect(entry.failClosed).toBe(true);
1686
+ }
1687
+ }
1688
+ });
1689
+ it("mcp.json (if present) has only valid fields", () => {
1690
+ const mcpFile = exFiles.find((f) => f.path === ".cursor/mcp.json");
1691
+ if (!mcpFile)
1692
+ return;
1693
+ const config = JSON.parse(mcpFile.content);
1694
+ for (const server of Object.values(config.mcpServers)) {
1695
+ for (const key of Object.keys(server)) {
1696
+ expect(["command", "args", "env", "url"]).toContain(key);
1697
+ }
1698
+ }
1699
+ });
1700
+ it("no files from other binding targets", () => {
1701
+ for (const f of exFiles) {
1702
+ expect(f.path).not.toMatch(/^\.(claude|github|kiro)\//);
1703
+ expect(f.path).not.toMatch(/AGENT\.md$/);
1704
+ }
1705
+ });
1706
+ });
1707
+ }
1708
+ });
1709
+ // -----------------------------------------------------------------------
1710
+ // MCP field filtering — only valid Cursor MCP fields pass through
1711
+ // -----------------------------------------------------------------------
1712
+ describe("MCP config filters unknown fields", () => {
1713
+ it("does not leak env vars as top-level MCP server fields", () => {
1714
+ // The parser sometimes places env vars at the server top level.
1715
+ // The binding must filter these out, keeping only command/args/env/url.
1716
+ const mcpFile = files.find((f) => f.path === ".cursor/mcp.json");
1717
+ if (!mcpFile)
1718
+ return;
1719
+ const config = JSON.parse(mcpFile.content);
1720
+ for (const [name, server] of Object.entries(config.mcpServers)) {
1721
+ const validKeys = new Set(["command", "args", "env", "url"]);
1722
+ for (const key of Object.keys(server)) {
1723
+ expect(validKeys.has(key), `server "${name}" has invalid field "${key}"`).toBe(true);
1724
+ }
1725
+ }
1726
+ });
1727
+ });
1728
+ });
1729
+ // ---------------------------------------------------------------------------
1730
+ // cursor — Exotic features: groups, humans, variants, schemas, scale,
1731
+ // circuit breakers, artifacts, metering, conditional edges, env vars
1732
+ // ---------------------------------------------------------------------------
1733
+ describe("cursor binding — exotic features", () => {
1734
+ const EXOTIC_SOURCE = `
1735
+ topology debate-test : [fan-out, debate, pipeline] {
1736
+ meta {
1737
+ version: "2.0.0"
1738
+ description: "Debate topology testing exotic features"
1739
+ }
1740
+
1741
+ orchestrator {
1742
+ model: opus
1743
+ handles: [intake]
1744
+ }
1745
+
1746
+ roles {
1747
+ debater: "Argues a position"
1748
+ judge: "Evaluates debate quality"
1749
+ }
1750
+
1751
+ action intake {
1752
+ kind: external
1753
+ source: "user-input"
1754
+ description: "Receive debate topic"
1755
+ }
1756
+
1757
+ agent pro-debater {
1758
+ role: debater
1759
+ model: haiku
1760
+ description: "Argues in favor"
1761
+ prompt {
1762
+ You are the PRO debater.
1763
+ Argue strongly in favor of the proposition.
1764
+ }
1765
+ temperature: 0.9
1766
+ max-tokens: 500
1767
+ thinking: high
1768
+ thinking-budget: 2000
1769
+ output-format: text
1770
+ timeout: "5m"
1771
+ max-turns: 10
1772
+ retry: 3
1773
+ }
1774
+
1775
+ agent con-debater {
1776
+ role: debater
1777
+ model: haiku
1778
+ description: "Argues against"
1779
+ prompt {
1780
+ You are the CON debater.
1781
+ Argue strongly against the proposition.
1782
+ }
1783
+ temperature: 0.9
1784
+ max-tokens: 500
1785
+ on-fail: retry
1786
+ circuit-breaker {
1787
+ threshold: 3
1788
+ window: "5m"
1789
+ cooldown: "30s"
1790
+ }
1791
+ }
1792
+
1793
+ agent researcher {
1794
+ role: debater
1795
+ model: sonnet
1796
+ description: "Gathers evidence"
1797
+ tools: [Read, WebSearch, Grep]
1798
+ scale {
1799
+ mode: auto
1800
+ by: "query-count"
1801
+ min: 1
1802
+ max: 3
1803
+ }
1804
+ background: true
1805
+ sandbox: "network-only"
1806
+ rate-limit: "10/min"
1807
+ fallback-chain: [haiku, sonnet]
1808
+ }
1809
+
1810
+ agent judge-agent {
1811
+ role: judge
1812
+ model: opus
1813
+ description: "Evaluates arguments"
1814
+ prompt {
1815
+ Score each argument on evidence, logic, persuasion.
1816
+ }
1817
+ output-format: json
1818
+ input-schema {
1819
+ topic: string
1820
+ pro_arguments: string
1821
+ }
1822
+ output-schema {
1823
+ winner: string
1824
+ score: number
1825
+ }
1826
+ produces: ["verdict"]
1827
+ consumes: ["transcript"]
1828
+ }
1829
+
1830
+ group debate-arena {
1831
+ members: [pro-debater, con-debater]
1832
+ speaker-selection: "round-robin"
1833
+ max-rounds: 5
1834
+ termination: "judge declares winner"
1835
+ description: "Structured debate"
1836
+ timeout: "30m"
1837
+ }
1838
+
1839
+ human moderator {
1840
+ description: "Human reviews debate"
1841
+ timeout: "1h"
1842
+ on-timeout: "skip"
1843
+ }
1844
+
1845
+ gates {
1846
+ gate fact-check {
1847
+ after: debate-arena
1848
+ before: judge-agent
1849
+ run: "scripts/fact-check.sh"
1850
+ checks: [sources, accuracy]
1851
+ on-fail: halt
1852
+ behavior: blocking
1853
+ }
1854
+ }
1855
+
1856
+ schemas {
1857
+ schema debate-result {
1858
+ winner: string
1859
+ score: number
1860
+ }
1861
+ }
1862
+
1863
+ artifacts {
1864
+ artifact transcript {
1865
+ type: markdown
1866
+ path: "workspace/debates/"
1867
+ retention: "90d"
1868
+ }
1869
+ artifact verdict {
1870
+ type: json
1871
+ path: "workspace/verdicts/"
1872
+ depends-on: [transcript]
1873
+ }
1874
+ }
1875
+
1876
+ skills {
1877
+ skill research {
1878
+ description: "Deep research"
1879
+ scripts: ["research.sh"]
1880
+ }
1881
+ skill scoring {
1882
+ description: "Score arguments"
1883
+ user-invocable: true
1884
+ }
1885
+ }
1886
+
1887
+ env {
1888
+ DEBATE_MODE: "formal"
1889
+ }
1890
+
1891
+ mcp-servers {
1892
+ web-search {
1893
+ command: "npx"
1894
+ args: ["-y", "@modelcontextprotocol/server-web-search"]
1895
+ env {
1896
+ SEARCH_API_KEY: "\${SEARCH_API_KEY}"
1897
+ }
1898
+ }
1899
+ }
1900
+
1901
+ hooks {
1902
+ hook log-round {
1903
+ on: PostToolUse
1904
+ matcher: "Write"
1905
+ run: "scripts/log-round.sh"
1906
+ timeout: 5000
1907
+ }
1908
+ }
1909
+
1910
+ metering {
1911
+ track: [tokens-in, tokens-out, cost]
1912
+ per: [agent, run]
1913
+ output: "metrics/costs.jsonl"
1914
+ format: jsonl
1915
+ pricing: anthropic-2025
1916
+ }
1917
+
1918
+ flow {
1919
+ intake -> researcher
1920
+ intake -> debate-arena
1921
+ researcher -> debate-arena
1922
+ debate-arena -> moderator
1923
+ moderator -> judge-agent
1924
+ judge-agent -> intake [when judge-agent.winner == "rematch"] [max 3]
1925
+ }
1926
+ }
1927
+ `;
1928
+ const exoticAst = parse(EXOTIC_SOURCE);
1929
+ const exoticFiles = cursorBinding.scaffold(exoticAst);
1930
+ describe("group node (debate-arena)", () => {
1931
+ const groupRule = exoticFiles.find((f) => f.path === ".cursor/rules/debate-arena.mdc");
1932
+ it("generates .mdc rule for group", () => {
1933
+ expect(groupRule).toBeDefined();
1934
+ });
1935
+ it("lists members with @-mention syntax", () => {
1936
+ expect(groupRule.content).toContain("@pro-debater");
1937
+ expect(groupRule.content).toContain("@con-debater");
1938
+ });
1939
+ it("includes speaker selection, max rounds, termination, timeout", () => {
1940
+ expect(groupRule.content).toContain("round-robin");
1941
+ expect(groupRule.content).toContain("5");
1942
+ expect(groupRule.content).toContain("judge declares winner");
1943
+ expect(groupRule.content).toContain("30m");
1944
+ });
1945
+ it("has correct frontmatter (agent-requested, not always-apply)", () => {
1946
+ const fm = groupRule.content.split("---")[1];
1947
+ expect(fm).toContain("description: Structured debate");
1948
+ expect(fm).toContain("alwaysApply: false");
1949
+ });
1950
+ });
1951
+ describe("human node (moderator)", () => {
1952
+ const humanRule = exoticFiles.find((f) => f.path === ".cursor/rules/moderator.mdc");
1953
+ it("generates rule with review process, timeout, on-timeout", () => {
1954
+ expect(humanRule).toBeDefined();
1955
+ expect(humanRule.content).toContain("human review");
1956
+ expect(humanRule.content).toContain("1h");
1957
+ expect(humanRule.content).toContain("skip");
1958
+ });
1959
+ });
1960
+ describe("agent sampling params (pro-debater)", () => {
1961
+ const rule = exoticFiles.find((f) => f.path === ".cursor/rules/pro-debater.mdc");
1962
+ it("includes model, temperature, max tokens", () => {
1963
+ expect(rule.content).toContain("haiku");
1964
+ expect(rule.content).toContain("0.9");
1965
+ expect(rule.content).toContain("500");
1966
+ });
1967
+ it("includes thinking level and budget", () => {
1968
+ expect(rule.content).toContain("high");
1969
+ expect(rule.content).toContain("2000");
1970
+ });
1971
+ it("includes timeout, max turns, retry", () => {
1972
+ expect(rule.content).toContain("5m");
1973
+ expect(rule.content).toContain("10");
1974
+ expect(rule.content).toContain("3 times");
1975
+ });
1976
+ });
1977
+ describe("circuit breaker (con-debater)", () => {
1978
+ const rule = exoticFiles.find((f) => f.path === ".cursor/rules/con-debater.mdc");
1979
+ it("includes circuit breaker with threshold, window, cooldown", () => {
1980
+ expect(rule.content).toContain("Circuit Breaker");
1981
+ expect(rule.content).toContain("3");
1982
+ expect(rule.content).toContain("5m");
1983
+ expect(rule.content).toContain("30s");
1984
+ });
1985
+ });
1986
+ describe("scale, sandbox, rate-limit, fallback (researcher)", () => {
1987
+ const rule = exoticFiles.find((f) => f.path === ".cursor/rules/researcher.mdc");
1988
+ it("includes scale config", () => {
1989
+ expect(rule.content).toContain("auto");
1990
+ expect(rule.content).toContain("query-count");
1991
+ expect(rule.content).toContain("Min: 1");
1992
+ expect(rule.content).toContain("Max: 3");
1993
+ });
1994
+ it("includes background, sandbox, rate-limit, fallback chain", () => {
1995
+ expect(rule.content).toContain("background");
1996
+ expect(rule.content).toContain("network-only");
1997
+ expect(rule.content).toContain("10/min");
1998
+ expect(rule.content).toContain("haiku -> sonnet");
1999
+ });
2000
+ });
2001
+ describe("input/output schemas and artifacts (judge-agent)", () => {
2002
+ const rule = exoticFiles.find((f) => f.path === ".cursor/rules/judge-agent.mdc");
2003
+ it("includes input and output schema", () => {
2004
+ expect(rule.content).toContain("Input Schema");
2005
+ expect(rule.content).toContain("topic");
2006
+ expect(rule.content).toContain("Output Schema");
2007
+ expect(rule.content).toContain("winner");
2008
+ });
2009
+ it("includes produces/consumes", () => {
2010
+ expect(rule.content).toContain("verdict");
2011
+ expect(rule.content).toContain("transcript");
2012
+ });
2013
+ });
2014
+ describe("schema definitions as rules", () => {
2015
+ it("generates schema rule with fields", () => {
2016
+ const rule = exoticFiles.find((f) => f.path === ".cursor/rules/schema-debate-result.mdc");
2017
+ expect(rule).toBeDefined();
2018
+ expect(rule.content).toContain("winner");
2019
+ expect(rule.content).toContain("score");
2020
+ });
2021
+ });
2022
+ describe("skills as rules", () => {
2023
+ it("non-invocable skill has description in frontmatter", () => {
2024
+ const rule = exoticFiles.find((f) => f.path === ".cursor/rules/skill-research.mdc");
2025
+ expect(rule.content.split("---")[1]).toContain("description: Deep research");
2026
+ });
2027
+ it("user-invocable skill is manual (empty description)", () => {
2028
+ const rule = exoticFiles.find((f) => f.path === ".cursor/rules/skill-scoring.mdc");
2029
+ expect(rule.content.split("---")[1]).toMatch(/description:\s*$/m);
2030
+ });
2031
+ });
2032
+ describe("conditional edges and loops", () => {
2033
+ const overview = exoticFiles.find((f) => f.path === ".cursor/rules/topology-overview.mdc");
2034
+ it("renders condition without parser artifacts", () => {
2035
+ expect(overview.content).not.toMatch(/rematch"\]\s*\[max/);
2036
+ expect(overview.content).toContain('judge-agent.winner == "rematch"');
2037
+ });
2038
+ it("renders max iterations", () => {
2039
+ expect(overview.content).toContain("Repeat up to 3 times");
2040
+ });
2041
+ });
2042
+ describe("MCP with env vars — no phantom servers", () => {
2043
+ const mcpFile = exoticFiles.find((f) => f.path === ".cursor/mcp.json");
2044
+ const config = JSON.parse(mcpFile.content);
2045
+ it("includes env interpolation", () => {
2046
+ expect(config.mcpServers["web-search"].env.SEARCH_API_KEY).toBe("${SEARCH_API_KEY}");
2047
+ });
2048
+ it("no phantom 'env' server", () => {
2049
+ expect(config.mcpServers.env).toBeUndefined();
2050
+ });
2051
+ it("all servers have command or url", () => {
2052
+ for (const [, server] of Object.entries(config.mcpServers)) {
2053
+ expect("command" in server || "url" in server).toBe(true);
2054
+ }
2055
+ });
2056
+ });
2057
+ describe("hooks timeout conversion and gate compilation", () => {
2058
+ const hooksFile = exoticFiles.find((f) => f.path === ".cursor/hooks.json");
2059
+ const config = JSON.parse(hooksFile.content);
2060
+ it("converts 5000ms to 5s", () => {
2061
+ const hook = config.hooks.postToolUse.find((h) => h.command.includes("log-round"));
2062
+ expect(hook.timeout).toBe(5);
2063
+ });
2064
+ it("gate on-fail:halt -> failClosed:true", () => {
2065
+ // Gate with both after+before uses before -> preToolUse
2066
+ const allHooks = Object.values(config.hooks).flat();
2067
+ const gate = allHooks.find((h) => h.command.includes("fact-check"));
2068
+ expect(gate).toBeDefined();
2069
+ expect(gate.failClosed).toBe(true);
2070
+ });
2071
+ it("metering hook exists", () => {
2072
+ const metering = config.hooks.postToolUse.find((h) => h.command.includes("metering"));
2073
+ expect(metering).toBeDefined();
2074
+ });
2075
+ });
2076
+ describe("artifacts in overview and AGENTS.md", () => {
2077
+ it("overview mentions artifacts", () => {
2078
+ const overview = exoticFiles.find((f) => f.path === ".cursor/rules/topology-overview.mdc");
2079
+ expect(overview.content).toContain("transcript");
2080
+ expect(overview.content).toContain("verdict");
2081
+ });
2082
+ it("AGENTS.md mentions artifacts", () => {
2083
+ const agentsMd = exoticFiles.find((f) => f.path === "AGENTS.md");
2084
+ expect(agentsMd.content).toContain("transcript");
2085
+ expect(agentsMd.content).toContain("verdict");
2086
+ });
2087
+ });
2088
+ });
2089
+ //# sourceMappingURL=bindings.test.js.map