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.
- package/LICENSE +191 -0
- package/README.md +450 -0
- package/dist/analyzer/__tests__/analyzer.test.d.ts +2 -0
- package/dist/analyzer/__tests__/analyzer.test.d.ts.map +1 -0
- package/dist/analyzer/__tests__/analyzer.test.js +258 -0
- package/dist/analyzer/__tests__/analyzer.test.js.map +1 -0
- package/dist/analyzer/index.d.ts +44 -0
- package/dist/analyzer/index.d.ts.map +1 -0
- package/dist/analyzer/index.js +308 -0
- package/dist/analyzer/index.js.map +1 -0
- package/dist/bindings/__tests__/bindings.test.d.ts +2 -0
- package/dist/bindings/__tests__/bindings.test.d.ts.map +1 -0
- package/dist/bindings/__tests__/bindings.test.js +2089 -0
- package/dist/bindings/__tests__/bindings.test.js.map +1 -0
- package/dist/bindings/claude-code.d.ts +12 -0
- package/dist/bindings/claude-code.d.ts.map +1 -0
- package/dist/bindings/claude-code.js +2063 -0
- package/dist/bindings/claude-code.js.map +1 -0
- package/dist/bindings/codex.d.ts +18 -0
- package/dist/bindings/codex.d.ts.map +1 -0
- package/dist/bindings/codex.js +1633 -0
- package/dist/bindings/codex.js.map +1 -0
- package/dist/bindings/copilot-cli.d.ts +20 -0
- package/dist/bindings/copilot-cli.d.ts.map +1 -0
- package/dist/bindings/copilot-cli.js +1515 -0
- package/dist/bindings/copilot-cli.js.map +1 -0
- package/dist/bindings/cursor.d.ts +17 -0
- package/dist/bindings/cursor.d.ts.map +1 -0
- package/dist/bindings/cursor.js +1715 -0
- package/dist/bindings/cursor.js.map +1 -0
- package/dist/bindings/gemini-cli.d.ts +18 -0
- package/dist/bindings/gemini-cli.d.ts.map +1 -0
- package/dist/bindings/gemini-cli.js +1327 -0
- package/dist/bindings/gemini-cli.js.map +1 -0
- package/dist/bindings/index.d.ts +20 -0
- package/dist/bindings/index.d.ts.map +1 -0
- package/dist/bindings/index.js +33 -0
- package/dist/bindings/index.js.map +1 -0
- package/dist/bindings/kiro.d.ts +17 -0
- package/dist/bindings/kiro.d.ts.map +1 -0
- package/dist/bindings/kiro.js +1624 -0
- package/dist/bindings/kiro.js.map +1 -0
- package/dist/bindings/openclaw.d.ts +18 -0
- package/dist/bindings/openclaw.d.ts.map +1 -0
- package/dist/bindings/openclaw.js +1578 -0
- package/dist/bindings/openclaw.js.map +1 -0
- package/dist/bindings/types.d.ts +33 -0
- package/dist/bindings/types.d.ts.map +1 -0
- package/dist/bindings/types.js +23 -0
- package/dist/bindings/types.js.map +1 -0
- package/dist/cli/index.d.ts +19 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +679 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/docs/__tests__/docs.test.d.ts +2 -0
- package/dist/docs/__tests__/docs.test.d.ts.map +1 -0
- package/dist/docs/__tests__/docs.test.js +438 -0
- package/dist/docs/__tests__/docs.test.js.map +1 -0
- package/dist/docs/content.d.ts +16 -0
- package/dist/docs/content.d.ts.map +1 -0
- package/dist/docs/content.js +3273 -0
- package/dist/docs/content.js.map +1 -0
- package/dist/docs/index.d.ts +17 -0
- package/dist/docs/index.d.ts.map +1 -0
- package/dist/docs/index.js +117 -0
- package/dist/docs/index.js.map +1 -0
- package/dist/exporters/__tests__/exporters.test.d.ts +2 -0
- package/dist/exporters/__tests__/exporters.test.d.ts.map +1 -0
- package/dist/exporters/__tests__/exporters.test.js +731 -0
- package/dist/exporters/__tests__/exporters.test.js.map +1 -0
- package/dist/exporters/index.d.ts +13 -0
- package/dist/exporters/index.d.ts.map +1 -0
- package/dist/exporters/index.js +18 -0
- package/dist/exporters/index.js.map +1 -0
- package/dist/exporters/json.d.ts +8 -0
- package/dist/exporters/json.d.ts.map +1 -0
- package/dist/exporters/json.js +25 -0
- package/dist/exporters/json.js.map +1 -0
- package/dist/exporters/markdown.d.ts +9 -0
- package/dist/exporters/markdown.d.ts.map +1 -0
- package/dist/exporters/markdown.js +686 -0
- package/dist/exporters/markdown.js.map +1 -0
- package/dist/exporters/mermaid.d.ts +11 -0
- package/dist/exporters/mermaid.d.ts.map +1 -0
- package/dist/exporters/mermaid.js +161 -0
- package/dist/exporters/mermaid.js.map +1 -0
- package/dist/exporters/types.d.ts +22 -0
- package/dist/exporters/types.d.ts.map +1 -0
- package/dist/exporters/types.js +10 -0
- package/dist/exporters/types.js.map +1 -0
- package/dist/import/__tests__/import.test.d.ts +2 -0
- package/dist/import/__tests__/import.test.d.ts.map +1 -0
- package/dist/import/__tests__/import.test.js +781 -0
- package/dist/import/__tests__/import.test.js.map +1 -0
- package/dist/import/claude-code.d.ts +38 -0
- package/dist/import/claude-code.d.ts.map +1 -0
- package/dist/import/claude-code.js +897 -0
- package/dist/import/claude-code.js.map +1 -0
- package/dist/import/index.d.ts +18 -0
- package/dist/import/index.d.ts.map +1 -0
- package/dist/import/index.js +28 -0
- package/dist/import/index.js.map +1 -0
- package/dist/import/serializer.d.ts +11 -0
- package/dist/import/serializer.d.ts.map +1 -0
- package/dist/import/serializer.js +960 -0
- package/dist/import/serializer.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/parser/__tests__/parser.test.d.ts +2 -0
- package/dist/parser/__tests__/parser.test.d.ts.map +1 -0
- package/dist/parser/__tests__/parser.test.js +6514 -0
- package/dist/parser/__tests__/parser.test.js.map +1 -0
- package/dist/parser/ast.d.ts +708 -0
- package/dist/parser/ast.d.ts.map +1 -0
- package/dist/parser/ast.js +12 -0
- package/dist/parser/ast.js.map +1 -0
- package/dist/parser/index.d.ts +253 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +1985 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/lexer.d.ts +100 -0
- package/dist/parser/lexer.d.ts.map +1 -0
- package/dist/parser/lexer.js +304 -0
- package/dist/parser/lexer.js.map +1 -0
- package/dist/parser/validator.d.ts +42 -0
- package/dist/parser/validator.d.ts.map +1 -0
- package/dist/parser/validator.js +2568 -0
- package/dist/parser/validator.js.map +1 -0
- package/dist/scaffold/__tests__/scaffold.test.d.ts +7 -0
- package/dist/scaffold/__tests__/scaffold.test.d.ts.map +1 -0
- package/dist/scaffold/__tests__/scaffold.test.js +295 -0
- package/dist/scaffold/__tests__/scaffold.test.js.map +1 -0
- package/dist/scaffold/incremental.d.ts +43 -0
- package/dist/scaffold/incremental.d.ts.map +1 -0
- package/dist/scaffold/incremental.js +185 -0
- package/dist/scaffold/incremental.js.map +1 -0
- package/dist/scaffold/index.d.ts +13 -0
- package/dist/scaffold/index.d.ts.map +1 -0
- package/dist/scaffold/index.js +13 -0
- package/dist/scaffold/index.js.map +1 -0
- package/dist/scaffold/manifest.d.ts +30 -0
- package/dist/scaffold/manifest.d.ts.map +1 -0
- package/dist/scaffold/manifest.js +71 -0
- package/dist/scaffold/manifest.js.map +1 -0
- package/dist/scaffold/merge.d.ts +52 -0
- package/dist/scaffold/merge.d.ts.map +1 -0
- package/dist/scaffold/merge.js +134 -0
- package/dist/scaffold/merge.js.map +1 -0
- package/dist/scaffold/types.d.ts +51 -0
- package/dist/scaffold/types.d.ts.map +1 -0
- package/dist/scaffold/types.js +10 -0
- package/dist/scaffold/types.js.map +1 -0
- package/dist/sync/index.d.ts +34 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +392 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/visualizer/index.d.ts +17 -0
- package/dist/visualizer/index.d.ts.map +1 -0
- package/dist/visualizer/index.js +1976 -0
- package/dist/visualizer/index.js.map +1 -0
- package/examples/code-review.at +167 -0
- package/examples/data-processing.at +296 -0
- package/examples/openclaw-assistant.at +142 -0
- package/examples/scheduled-monitor.at +193 -0
- package/examples/simple-pipeline.at +163 -0
- package/package.json +77 -0
- package/skill/SKILL.md +403 -0
- package/skill/scripts/emit.ts +853 -0
- package/skill/scripts/visualize.ts +89 -0
- package/spec/grammar.md +1466 -0
- package/spec/reserved-keywords.md +147 -0
- 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
|