@moreih29/nexus-core 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/README.md +48 -63
  2. package/assets/agents/architect/body.ko.md +177 -0
  3. package/{agents → assets/agents}/architect/body.md +16 -0
  4. package/assets/agents/designer/body.ko.md +125 -0
  5. package/{agents → assets/agents}/designer/body.md +16 -0
  6. package/assets/agents/engineer/body.ko.md +106 -0
  7. package/{agents → assets/agents}/engineer/body.md +14 -0
  8. package/assets/agents/lead/body.ko.md +70 -0
  9. package/assets/agents/lead/body.md +70 -0
  10. package/assets/agents/postdoc/body.ko.md +122 -0
  11. package/{agents → assets/agents}/postdoc/body.md +16 -0
  12. package/assets/agents/researcher/body.ko.md +137 -0
  13. package/{agents → assets/agents}/researcher/body.md +15 -0
  14. package/assets/agents/reviewer/body.ko.md +138 -0
  15. package/{agents → assets/agents}/reviewer/body.md +15 -0
  16. package/assets/agents/strategist/body.ko.md +116 -0
  17. package/{agents → assets/agents}/strategist/body.md +16 -0
  18. package/assets/agents/tester/body.ko.md +195 -0
  19. package/{agents → assets/agents}/tester/body.md +15 -0
  20. package/assets/agents/writer/body.ko.md +122 -0
  21. package/{agents → assets/agents}/writer/body.md +14 -0
  22. package/assets/capability-matrix.yml +198 -0
  23. package/assets/hooks/agent-bootstrap/handler.test.ts +368 -0
  24. package/assets/hooks/agent-bootstrap/handler.ts +119 -0
  25. package/assets/hooks/agent-bootstrap/meta.yml +10 -0
  26. package/assets/hooks/agent-finalize/handler.test.ts +368 -0
  27. package/assets/hooks/agent-finalize/handler.ts +76 -0
  28. package/assets/hooks/agent-finalize/meta.yml +10 -0
  29. package/assets/hooks/capability-matrix.yml +313 -0
  30. package/assets/hooks/post-tool-telemetry/handler.test.ts +302 -0
  31. package/assets/hooks/post-tool-telemetry/handler.ts +49 -0
  32. package/assets/hooks/post-tool-telemetry/meta.yml +11 -0
  33. package/assets/hooks/prompt-router/handler.test.ts +801 -0
  34. package/assets/hooks/prompt-router/handler.ts +261 -0
  35. package/assets/hooks/prompt-router/meta.yml +11 -0
  36. package/assets/hooks/session-init/handler.test.ts +274 -0
  37. package/assets/hooks/session-init/handler.ts +30 -0
  38. package/assets/hooks/session-init/meta.yml +9 -0
  39. package/assets/lsp-servers.json +55 -0
  40. package/assets/schema/lsp-servers.schema.json +67 -0
  41. package/assets/skills/nx-init/body.ko.md +197 -0
  42. package/{skills → assets/skills}/nx-init/body.md +11 -0
  43. package/assets/skills/nx-plan/body.ko.md +361 -0
  44. package/{skills → assets/skills}/nx-plan/body.md +13 -0
  45. package/assets/skills/nx-run/body.ko.md +161 -0
  46. package/{skills → assets/skills}/nx-run/body.md +11 -0
  47. package/assets/skills/nx-sync/body.ko.md +92 -0
  48. package/{skills → assets/skills}/nx-sync/body.md +10 -0
  49. package/assets/tools/tool-name-map.yml +353 -0
  50. package/dist/hooks/opencode-mount.d.ts +35 -0
  51. package/dist/hooks/opencode-mount.d.ts.map +1 -0
  52. package/dist/hooks/opencode-mount.js +332 -0
  53. package/dist/hooks/opencode-mount.js.map +1 -0
  54. package/dist/hooks/runtime.d.ts +37 -0
  55. package/dist/hooks/runtime.d.ts.map +1 -0
  56. package/dist/hooks/runtime.js +274 -0
  57. package/dist/hooks/runtime.js.map +1 -0
  58. package/dist/hooks/types.d.ts +196 -0
  59. package/dist/hooks/types.d.ts.map +1 -0
  60. package/dist/hooks/types.js +85 -0
  61. package/dist/hooks/types.js.map +1 -0
  62. package/dist/lsp/cache.d.ts +9 -0
  63. package/dist/lsp/cache.d.ts.map +1 -0
  64. package/dist/lsp/cache.js +216 -0
  65. package/dist/lsp/cache.js.map +1 -0
  66. package/dist/lsp/client.d.ts +24 -0
  67. package/dist/lsp/client.d.ts.map +1 -0
  68. package/dist/lsp/client.js +166 -0
  69. package/dist/lsp/client.js.map +1 -0
  70. package/dist/lsp/detect.d.ts +77 -0
  71. package/dist/lsp/detect.d.ts.map +1 -0
  72. package/dist/lsp/detect.js +116 -0
  73. package/dist/lsp/detect.js.map +1 -0
  74. package/dist/mcp/server.d.ts +5 -0
  75. package/dist/mcp/server.d.ts.map +1 -0
  76. package/dist/mcp/server.js +34 -0
  77. package/dist/mcp/server.js.map +1 -0
  78. package/dist/mcp/tools/artifact.d.ts +4 -0
  79. package/dist/mcp/tools/artifact.d.ts.map +1 -0
  80. package/dist/mcp/tools/artifact.js +36 -0
  81. package/dist/mcp/tools/artifact.js.map +1 -0
  82. package/dist/mcp/tools/history.d.ts +3 -0
  83. package/dist/mcp/tools/history.d.ts.map +1 -0
  84. package/dist/mcp/tools/history.js +29 -0
  85. package/dist/mcp/tools/history.js.map +1 -0
  86. package/dist/mcp/tools/lsp.d.ts +13 -0
  87. package/dist/mcp/tools/lsp.d.ts.map +1 -0
  88. package/dist/mcp/tools/lsp.js +225 -0
  89. package/dist/mcp/tools/lsp.js.map +1 -0
  90. package/dist/mcp/tools/plan.d.ts +3 -0
  91. package/dist/mcp/tools/plan.d.ts.map +1 -0
  92. package/dist/mcp/tools/plan.js +317 -0
  93. package/dist/mcp/tools/plan.js.map +1 -0
  94. package/dist/mcp/tools/task.d.ts +3 -0
  95. package/dist/mcp/tools/task.d.ts.map +1 -0
  96. package/dist/mcp/tools/task.js +252 -0
  97. package/dist/mcp/tools/task.js.map +1 -0
  98. package/dist/shared/invocations.d.ts +74 -0
  99. package/dist/shared/invocations.d.ts.map +1 -0
  100. package/dist/shared/invocations.js +247 -0
  101. package/dist/shared/invocations.js.map +1 -0
  102. package/dist/shared/json-store.d.ts +37 -0
  103. package/dist/shared/json-store.d.ts.map +1 -0
  104. package/dist/shared/json-store.js +163 -0
  105. package/dist/shared/json-store.js.map +1 -0
  106. package/dist/shared/mcp-utils.d.ts +3 -0
  107. package/dist/shared/mcp-utils.d.ts.map +1 -0
  108. package/dist/shared/mcp-utils.js +6 -0
  109. package/dist/shared/mcp-utils.js.map +1 -0
  110. package/dist/shared/paths.d.ts +21 -0
  111. package/dist/shared/paths.d.ts.map +1 -0
  112. package/dist/shared/paths.js +81 -0
  113. package/dist/shared/paths.js.map +1 -0
  114. package/dist/shared/tool-log.d.ts +8 -0
  115. package/dist/shared/tool-log.d.ts.map +1 -0
  116. package/dist/shared/tool-log.js +22 -0
  117. package/dist/shared/tool-log.js.map +1 -0
  118. package/dist/types/state.d.ts +862 -0
  119. package/dist/types/state.d.ts.map +1 -0
  120. package/dist/types/state.js +66 -0
  121. package/dist/types/state.js.map +1 -0
  122. package/docs/consuming/codex-lead-merge.md +106 -0
  123. package/docs/plugin-guide.md +360 -0
  124. package/docs/plugin-template/claude/.github/workflows/build.yml +60 -0
  125. package/docs/plugin-template/claude/README.md +110 -0
  126. package/docs/plugin-template/claude/package.json +16 -0
  127. package/docs/plugin-template/codex/.github/workflows/build.yml +51 -0
  128. package/docs/plugin-template/codex/README.md +147 -0
  129. package/docs/plugin-template/codex/package.json +17 -0
  130. package/docs/plugin-template/opencode/.github/workflows/build.yml +61 -0
  131. package/docs/plugin-template/opencode/README.md +121 -0
  132. package/docs/plugin-template/opencode/package.json +25 -0
  133. package/package.json +21 -21
  134. package/scripts/build-agents.test.ts +1279 -0
  135. package/scripts/build-agents.ts +978 -0
  136. package/scripts/build-hooks.test.ts +1385 -0
  137. package/scripts/build-hooks.ts +584 -0
  138. package/scripts/cli.test.ts +367 -0
  139. package/scripts/cli.ts +547 -0
  140. package/agents/architect/meta.yml +0 -13
  141. package/agents/designer/meta.yml +0 -13
  142. package/agents/engineer/meta.yml +0 -11
  143. package/agents/postdoc/meta.yml +0 -13
  144. package/agents/researcher/meta.yml +0 -12
  145. package/agents/reviewer/meta.yml +0 -12
  146. package/agents/strategist/meta.yml +0 -13
  147. package/agents/tester/meta.yml +0 -12
  148. package/agents/writer/meta.yml +0 -11
  149. package/conformance/README.md +0 -311
  150. package/conformance/examples/plan.extension.schema.example.json +0 -25
  151. package/conformance/lifecycle/README.md +0 -48
  152. package/conformance/lifecycle/agent-complete.json +0 -44
  153. package/conformance/lifecycle/agent-resume.json +0 -43
  154. package/conformance/lifecycle/agent-spawn.json +0 -36
  155. package/conformance/lifecycle/memory-access-record.json +0 -27
  156. package/conformance/lifecycle/session-end.json +0 -48
  157. package/conformance/scenarios/full-plan-cycle.json +0 -147
  158. package/conformance/scenarios/task-deps-ordering.json +0 -95
  159. package/conformance/schema/fixture.schema.json +0 -354
  160. package/conformance/state-schemas/agent-tracker.schema.json +0 -63
  161. package/conformance/state-schemas/history.schema.json +0 -134
  162. package/conformance/state-schemas/memory-access.schema.json +0 -36
  163. package/conformance/state-schemas/plan.schema.json +0 -77
  164. package/conformance/state-schemas/tasks.schema.json +0 -98
  165. package/conformance/tools/artifact-write.json +0 -97
  166. package/conformance/tools/context.json +0 -172
  167. package/conformance/tools/history-search.json +0 -219
  168. package/conformance/tools/plan-decide.json +0 -139
  169. package/conformance/tools/plan-start.json +0 -81
  170. package/conformance/tools/plan-status.json +0 -127
  171. package/conformance/tools/plan-update.json +0 -341
  172. package/conformance/tools/task-add.json +0 -156
  173. package/conformance/tools/task-close.json +0 -161
  174. package/conformance/tools/task-list.json +0 -177
  175. package/conformance/tools/task-update.json +0 -167
  176. package/docs/behavioral-contracts.md +0 -145
  177. package/docs/consumer-implementation-guide.md +0 -840
  178. package/docs/memory-lifecycle-contract.md +0 -119
  179. package/docs/nexus-layout.md +0 -224
  180. package/docs/nexus-outputs-contract.md +0 -344
  181. package/docs/nexus-state-overview.md +0 -170
  182. package/docs/nexus-tools-contract.md +0 -438
  183. package/manifest.json +0 -448
  184. package/schema/README.md +0 -69
  185. package/schema/agent.schema.json +0 -23
  186. package/schema/common.schema.json +0 -17
  187. package/schema/manifest.schema.json +0 -78
  188. package/schema/memory-policy.schema.json +0 -98
  189. package/schema/skill.schema.json +0 -54
  190. package/schema/task-exceptions.schema.json +0 -40
  191. package/schema/vocabulary.schema.json +0 -167
  192. package/scripts/.gitkeep +0 -0
  193. package/scripts/conformance-coverage.ts +0 -466
  194. package/scripts/import-from-claude-nexus.ts +0 -403
  195. package/scripts/lib/frontmatter.ts +0 -71
  196. package/scripts/lib/lint.ts +0 -348
  197. package/scripts/lib/structure.ts +0 -159
  198. package/scripts/lib/validate.ts +0 -796
  199. package/scripts/validate.ts +0 -90
  200. package/skills/nx-init/meta.yml +0 -8
  201. package/skills/nx-plan/meta.yml +0 -10
  202. package/skills/nx-run/meta.yml +0 -8
  203. package/skills/nx-sync/meta.yml +0 -7
  204. package/vocabulary/capabilities.yml +0 -65
  205. package/vocabulary/categories.yml +0 -11
  206. package/vocabulary/invocations.yml +0 -147
  207. package/vocabulary/memory_policy.yml +0 -88
  208. package/vocabulary/resume-tiers.yml +0 -11
  209. package/vocabulary/tags.yml +0 -60
  210. package/vocabulary/task-exceptions.yml +0 -29
@@ -0,0 +1,1279 @@
1
+ /**
2
+ * scripts/build-agents.test.ts
3
+ *
4
+ * Unit and integration tests for build-agents.ts.
5
+ *
6
+ * Scenarios:
7
+ * (1) expandInvocation — plain substitution, missing arg, invalid template, unknown invocation
8
+ * (2) Capability mapping — resolveClaudeDisallowedTools, resolveOpencodePermissions, resolveCodexConfig
9
+ * (3) Manifest generation — plugin.json / marketplace.json / package.json schema
10
+ * (4) Overwrite policy — managed overwrite, template skip, --force, --dry-run
11
+ * (5) Harness builders — buildForClaude / buildForOpencode / buildForCodex produce expected files
12
+ * (6) Error paths — malformed frontmatter, unknown capability, missing body.md
13
+ * (7) CLI arg parsing — --harness, --target, --dry-run, --force, --strict, --only
14
+ */
15
+
16
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
17
+ import {
18
+ mkdirSync,
19
+ mkdtempSync,
20
+ rmSync,
21
+ writeFileSync,
22
+ readFileSync,
23
+ existsSync,
24
+ } from "node:fs";
25
+ import { join, resolve } from "node:path";
26
+ import { tmpdir } from "node:os";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ import {
30
+ parseFrontmatter,
31
+ loadCapabilityMatrix,
32
+ loadInvocations,
33
+ resolveClaudeDisallowedTools,
34
+ resolveOpencodePermissions,
35
+ resolveCodexConfig,
36
+ resolveModel,
37
+ buildForClaude,
38
+ buildForOpencode,
39
+ buildForCodex,
40
+ applyOverwritePolicy,
41
+ parseArgs,
42
+ buildAgents,
43
+ ROOT,
44
+ type AssetEntry,
45
+ type CapabilityMatrix,
46
+ type BuildOptions,
47
+ } from "./build-agents.js";
48
+
49
+ import {
50
+ expandInvocations,
51
+ expandInvocationExpression,
52
+ parseInvocationCall,
53
+ type InvocationsMap,
54
+ } from "../src/shared/invocations.js";
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Constants
58
+ // ---------------------------------------------------------------------------
59
+
60
+ const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "../..");
61
+ const FIXTURE_BASE = join(REPO_ROOT, "tests/fixtures/build-agents");
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Fixture helpers
65
+ // ---------------------------------------------------------------------------
66
+
67
+ let tmpDirs: string[] = [];
68
+
69
+ afterEach(() => {
70
+ for (const dir of tmpDirs) {
71
+ try {
72
+ rmSync(dir, { recursive: true, force: true });
73
+ } catch {
74
+ // best effort
75
+ }
76
+ }
77
+ tmpDirs = [];
78
+ });
79
+
80
+ function makeTmp(): string {
81
+ const dir = mkdtempSync(join(tmpdir(), "build-agents-test-"));
82
+ tmpDirs.push(dir);
83
+ return dir;
84
+ }
85
+
86
+ /** Minimal capability matrix covering no_file_edit, no_task_create, no_task_update + model_tier */
87
+ function minimalCapMatrix(): CapabilityMatrix {
88
+ return {
89
+ capabilities: {
90
+ no_file_edit: {
91
+ claude: { disallowedTools: ["Edit", "Write", "MultiEdit", "NotebookEdit"] },
92
+ opencode: { permission: { edit: "deny" } },
93
+ codex: { sandbox_mode: "read-only", disabled_tools: [] },
94
+ },
95
+ no_task_create: {
96
+ claude: { disallowedTools: ["mcp__plugin_claude-nexus_nx__nx_task_add"] },
97
+ opencode: { permission: { nx_task_add: "deny" } },
98
+ codex: { sandbox_mode: null, disabled_tools: ["nx_task_add"] },
99
+ },
100
+ no_task_update: {
101
+ claude: { disallowedTools: ["mcp__plugin_claude-nexus_nx__nx_task_update"] },
102
+ opencode: { permission: { nx_task_update: "deny" } },
103
+ codex: { sandbox_mode: null, disabled_tools: ["nx_task_update"] },
104
+ },
105
+ },
106
+ model_tier: {
107
+ high: { claude: "claude-opus-4", codex: "gpt-5.4", opencode: null },
108
+ standard: { claude: "claude-sonnet-4", codex: "gpt-5.3-codex", opencode: null },
109
+ low: { claude: "claude-haiku-4", codex: "gpt-5.4-mini", opencode: null },
110
+ },
111
+ };
112
+ }
113
+
114
+ /** Minimal invocations map */
115
+ function minimalInvocations(): InvocationsMap {
116
+ return {
117
+ subagent_spawn: {
118
+ args: ["target_role", "prompt", "name"],
119
+ templates: {
120
+ claude: 'Agent({ subagent_type: "{target_role}", prompt: "{prompt}", description: "{name}" })',
121
+ opencode: 'task({ subagent_type: "{target_role}", prompt: "{prompt}", description: "{name}" })',
122
+ codex: 'spawn_agent("{target_role}", "{prompt}")',
123
+ },
124
+ },
125
+ skill_activation: {
126
+ args: ["skill", "mode"],
127
+ templates: {
128
+ claude: 'Skill({ command: "{skill}" })',
129
+ opencode: 'skill({ name: "{skill}" })',
130
+ codex: '${skill}',
131
+ },
132
+ },
133
+ task_register: {
134
+ args: ["label", "state"],
135
+ templates: {
136
+ claude: 'TaskCreate({ subject: "{label}" }) then nx_task_update({ taskId, status: "{state}" })',
137
+ opencode: 'nx_task_add({ subject: "{label}" }) then nx_task_update({ taskId, status: "{state}" })',
138
+ codex: 'update_plan([{ name: "{label}", state: "{state}" }])',
139
+ },
140
+ },
141
+ user_question: {
142
+ args: ["question", "options"],
143
+ templates: {
144
+ claude: 'AskUserQuestion({ questions: [{ question: "{question}", options: {options} }] })',
145
+ opencode: 'question({ question: "{question}", choices: {options} })',
146
+ codex: 'request_user_input({ prompt: "{question}", options: {options} })',
147
+ },
148
+ },
149
+ };
150
+ }
151
+
152
+ function makeAgentEntry(overrides?: Partial<AssetEntry>): AssetEntry {
153
+ return {
154
+ type: "agent",
155
+ name: "sample-architect",
156
+ frontmatter: {
157
+ name: "sample-architect",
158
+ description: "Sample architect agent for testing",
159
+ task: "Architecture, technical design",
160
+ alias_ko: "샘플아키텍트",
161
+ category: "how",
162
+ resume_tier: "persistent",
163
+ model_tier: "high",
164
+ capabilities: ["no_file_edit", "no_task_create", "no_task_update"],
165
+ id: "sample-architect",
166
+ },
167
+ body: "## Role\n\nYou are the Sample Architect.\n",
168
+ bodyPath: join(FIXTURE_BASE, "agents/sample-architect/body.md"),
169
+ ...overrides,
170
+ };
171
+ }
172
+
173
+ function makeEngineerEntry(): AssetEntry {
174
+ return {
175
+ type: "agent",
176
+ name: "sample-engineer",
177
+ frontmatter: {
178
+ name: "sample-engineer",
179
+ description: "Sample engineer agent for testing",
180
+ task: "Code implementation",
181
+ category: "do",
182
+ resume_tier: "bounded",
183
+ model_tier: "standard",
184
+ capabilities: ["no_task_create"],
185
+ id: "sample-engineer",
186
+ },
187
+ body: "## Role\n\nYou are the Sample Engineer.\n",
188
+ bodyPath: join(FIXTURE_BASE, "agents/sample-engineer/body.md"),
189
+ };
190
+ }
191
+
192
+ function makeSkillEntry(): AssetEntry {
193
+ return {
194
+ type: "skill",
195
+ name: "sample-skill",
196
+ frontmatter: {
197
+ name: "sample-skill",
198
+ description: "Sample skill for testing",
199
+ summary: "Sample skill — test only",
200
+ triggers: ["sample"],
201
+ category: "do", // skills don't really have category, but frontmatter requires it
202
+ model_tier: "standard",
203
+ capabilities: [],
204
+ id: "sample-skill",
205
+ },
206
+ body: "## Role\n\nThis is a sample skill.\n",
207
+ bodyPath: join(FIXTURE_BASE, "skills/sample-skill/body.md"),
208
+ };
209
+ }
210
+
211
+ function defaultBuildOpts(targetDir: string, overrides?: Partial<BuildOptions>): BuildOptions {
212
+ return {
213
+ harnesses: ["claude", "opencode", "codex"],
214
+ targetDir,
215
+ dryRun: false,
216
+ force: false,
217
+ strict: false,
218
+ ...overrides,
219
+ };
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Scenario 1: expandInvocations
224
+ // ---------------------------------------------------------------------------
225
+
226
+ describe("Scenario 1 — expandInvocations", () => {
227
+ const invocations = minimalInvocations();
228
+
229
+ test("subagent_spawn expands correctly for claude", () => {
230
+ const input = '{{subagent_spawn target_role=researcher prompt="Research the topic" name=Research}}';
231
+ const result = expandInvocations(input, "claude", invocations);
232
+ expect(result).toContain('Agent(');
233
+ expect(result).toContain('"researcher"');
234
+ expect(result).toContain('"Research the topic"');
235
+ });
236
+
237
+ test("subagent_spawn expands correctly for opencode", () => {
238
+ const input = '{{subagent_spawn target_role=engineer prompt="Fix the bug"}}';
239
+ const result = expandInvocations(input, "opencode", invocations);
240
+ expect(result).toContain('task(');
241
+ expect(result).toContain('"engineer"');
242
+ expect(result).toContain('"Fix the bug"');
243
+ });
244
+
245
+ test("subagent_spawn expands correctly for codex", () => {
246
+ const input = '{{subagent_spawn target_role=engineer prompt="Fix the bug"}}';
247
+ const result = expandInvocations(input, "codex", invocations);
248
+ expect(result).toContain('spawn_agent(');
249
+ expect(result).toContain('"engineer"');
250
+ });
251
+
252
+ test("skill_activation expands for claude", () => {
253
+ const input = "{{skill_activation skill=nx-plan}}";
254
+ const result = expandInvocations(input, "claude", invocations);
255
+ expect(result).toContain('Skill(');
256
+ expect(result).toContain("nx-plan");
257
+ });
258
+
259
+ test("skill_activation expands for codex", () => {
260
+ const input = "{{skill_activation skill=nx-plan}}";
261
+ const result = expandInvocations(input, "codex", invocations);
262
+ expect(result).toContain("nx-plan");
263
+ });
264
+
265
+ test("task_register expands for claude", () => {
266
+ const input = '{{task_register label="Fix the bug" state=in_progress}}';
267
+ const result = expandInvocations(input, "claude", invocations);
268
+ expect(result).toContain("TaskCreate");
269
+ expect(result).toContain("Fix the bug");
270
+ expect(result).toContain("in_progress");
271
+ });
272
+
273
+ test("task_register expands for codex", () => {
274
+ const input = '{{task_register label="Fix the bug" state=in_progress}}';
275
+ const result = expandInvocations(input, "codex", invocations);
276
+ expect(result).toContain("update_plan");
277
+ expect(result).toContain("Fix the bug");
278
+ expect(result).toContain("in_progress");
279
+ });
280
+
281
+ test("user_question expands for opencode", () => {
282
+ const input = '{{user_question question="Is this correct?" options=["yes","no"]}}';
283
+ const result = expandInvocations(input, "opencode", invocations);
284
+ expect(result).toContain("question(");
285
+ expect(result).toContain("Is this correct?");
286
+ });
287
+
288
+ test("unknown invocation returns error comment", () => {
289
+ const input = "{{unknown_invocation key=value}}";
290
+ const result = expandInvocations(input, "claude", invocations);
291
+ expect(result).toContain("[nexus] unknown invocation");
292
+ expect(result).toContain("unknown_invocation");
293
+ });
294
+
295
+ test("invalid template (empty) returns error comment", () => {
296
+ const input = "{{}}";
297
+ const result = expandInvocations(input, "claude", invocations);
298
+ expect(result).toContain("[nexus] invalid invocation");
299
+ });
300
+
301
+ test("multiple invocations in a single body are all expanded", () => {
302
+ const input = [
303
+ "Use {{skill_activation skill=nx-plan}} and",
304
+ "then {{task_register label=test state=done}}.",
305
+ ].join(" ");
306
+ const result = expandInvocations(input, "claude", invocations);
307
+ expect(result).toContain("Skill(");
308
+ expect(result).toContain("TaskCreate");
309
+ expect(result).not.toContain("{{");
310
+ });
311
+
312
+ test("plain text without invocations is unchanged", () => {
313
+ const input = "This is a plain body with no templates.";
314
+ const result = expandInvocations(input, "claude", invocations);
315
+ expect(result).toBe(input);
316
+ });
317
+
318
+ // Nested brace/bracket tests (T9 regression cases)
319
+
320
+ test("user_question with nested {} in options array expands — no raw {{ left (nx-init case 1)", () => {
321
+ const input =
322
+ '{{user_question question="Select a backup to delete (or cancel)" options=[<backup list...>, {label: Cancel, description: "Exit without changes"}]}}';
323
+ const result = expandInvocations(input, "claude", invocations);
324
+ expect(result).not.toContain("{{");
325
+ expect(result).toContain("AskUserQuestion");
326
+ expect(result).toContain("Select a backup to delete (or cancel)");
327
+ });
328
+
329
+ test("user_question with nested {} in options array expands — no raw {{ left (nx-init case 2)", () => {
330
+ const input =
331
+ '{{user_question question="Do you want to set up development rules now?" options=[{label: "Set up", description: "Coding conventions, test policy, commit rules, etc."}, {label: Skip, description: "Can be added later via [rule] tag"}]}}';
332
+ const result = expandInvocations(input, "claude", invocations);
333
+ expect(result).not.toContain("{{");
334
+ expect(result).toContain("AskUserQuestion");
335
+ expect(result).toContain("Do you want to set up development rules now?");
336
+ });
337
+
338
+ test("user_question with nested {} expands for opencode harness", () => {
339
+ const input =
340
+ '{{user_question question="Choose an option" options=[{label: "Yes"}, {label: "No"}]}}';
341
+ const result = expandInvocations(input, "opencode", invocations);
342
+ expect(result).not.toContain("{{");
343
+ expect(result).toContain("question(");
344
+ expect(result).toContain("Choose an option");
345
+ });
346
+
347
+ test("user_question with nested {} expands for codex harness", () => {
348
+ const input =
349
+ '{{user_question question="Pick one" options=[{label: A}, {label: B}]}}';
350
+ const result = expandInvocations(input, "codex", invocations);
351
+ expect(result).not.toContain("{{");
352
+ expect(result).toContain("request_user_input");
353
+ });
354
+
355
+ test("parseInvocationCall correctly extracts options with nested objects", () => {
356
+ const raw =
357
+ 'user_question question="Do you want to set up?" options=[{label: "Set up", description: "desc"}, {label: Skip}]';
358
+ const parsed = parseInvocationCall(raw);
359
+ expect(parsed).not.toBeNull();
360
+ expect(parsed!.name).toBe("user_question");
361
+ expect(parsed!.args.question).toBe("Do you want to set up?");
362
+ expect(parsed!.args.options).toBe('[{label: "Set up", description: "desc"}, {label: Skip}]');
363
+ });
364
+
365
+ test("parseInvocationCall correctly extracts plain non-whitespace value", () => {
366
+ const raw = "skill_activation skill=nx-plan";
367
+ const parsed = parseInvocationCall(raw);
368
+ expect(parsed).not.toBeNull();
369
+ expect(parsed!.args.skill).toBe("nx-plan");
370
+ });
371
+
372
+ test("multiple invocations including nested {} are all expanded with no raw {{ remaining", () => {
373
+ const line1 = '{{user_question question="Q1?" options=[{label: Yes}, {label: No}]}}';
374
+ const line2 = "{{skill_activation skill=nx-plan}}";
375
+ const input = `${line1}\n${line2}`;
376
+ const result = expandInvocations(input, "claude", invocations);
377
+ expect(result).not.toContain("{{");
378
+ expect(result).toContain("AskUserQuestion");
379
+ expect(result).toContain("Skill(");
380
+ });
381
+ });
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // Scenario 2: Capability mapping
385
+ // ---------------------------------------------------------------------------
386
+
387
+ describe("Scenario 2 — Capability mapping", () => {
388
+ const capMatrix = minimalCapMatrix();
389
+
390
+ test("resolveClaudeDisallowedTools: no_file_edit includes Edit, Write, MultiEdit, NotebookEdit", () => {
391
+ const tools = resolveClaudeDisallowedTools(["no_file_edit"], capMatrix);
392
+ expect(tools).toContain("Edit");
393
+ expect(tools).toContain("Write");
394
+ expect(tools).toContain("MultiEdit");
395
+ expect(tools).toContain("NotebookEdit");
396
+ });
397
+
398
+ test("resolveClaudeDisallowedTools: multiple capabilities are merged without duplicates", () => {
399
+ const tools = resolveClaudeDisallowedTools(
400
+ ["no_file_edit", "no_task_create", "no_task_update"],
401
+ capMatrix,
402
+ );
403
+ // no duplicates
404
+ expect(tools.length).toBe(new Set(tools).size);
405
+ expect(tools).toContain("Edit");
406
+ expect(tools).toContain("mcp__plugin_claude-nexus_nx__nx_task_add");
407
+ expect(tools).toContain("mcp__plugin_claude-nexus_nx__nx_task_update");
408
+ });
409
+
410
+ test("resolveClaudeDisallowedTools: empty capabilities → empty array", () => {
411
+ const tools = resolveClaudeDisallowedTools([], capMatrix);
412
+ expect(tools).toEqual([]);
413
+ });
414
+
415
+ test("resolveClaudeDisallowedTools: unknown capability throws", () => {
416
+ expect(() => resolveClaudeDisallowedTools(["unknown_cap"], capMatrix)).toThrow(
417
+ "Unknown capability",
418
+ );
419
+ });
420
+
421
+ test("resolveOpencodePermissions: no_file_edit → edit: deny", () => {
422
+ const perms = resolveOpencodePermissions(["no_file_edit"], capMatrix);
423
+ expect(perms.edit).toBe("deny");
424
+ });
425
+
426
+ test("resolveOpencodePermissions: multiple caps merge permissions", () => {
427
+ const perms = resolveOpencodePermissions(
428
+ ["no_file_edit", "no_task_create"],
429
+ capMatrix,
430
+ );
431
+ expect(perms.edit).toBe("deny");
432
+ expect(perms.nx_task_add).toBe("deny");
433
+ });
434
+
435
+ test("resolveCodexConfig: no_file_edit → sandbox_mode=read-only", () => {
436
+ const config = resolveCodexConfig(["no_file_edit"], capMatrix);
437
+ expect(config.sandbox_mode).toBe("read-only");
438
+ });
439
+
440
+ test("resolveCodexConfig: no_task_create → disabled_tools=[nx_task_add]", () => {
441
+ const config = resolveCodexConfig(["no_task_create"], capMatrix);
442
+ expect(config.disabled_tools).toContain("nx_task_add");
443
+ });
444
+
445
+ test("resolveCodexConfig: combined no_file_edit + no_task_create → sandbox + tools", () => {
446
+ const config = resolveCodexConfig(["no_file_edit", "no_task_create"], capMatrix);
447
+ expect(config.sandbox_mode).toBe("read-only");
448
+ expect(config.disabled_tools).toContain("nx_task_add");
449
+ });
450
+
451
+ test("resolveModel: high tier → claude-opus-4 for claude", () => {
452
+ const model = resolveModel("high", "claude", capMatrix);
453
+ expect(model).toBe("claude-opus-4");
454
+ });
455
+
456
+ test("resolveModel: standard tier → null for opencode (inherit user config)", () => {
457
+ const model = resolveModel("standard", "opencode", capMatrix);
458
+ expect(model).toBeNull();
459
+ });
460
+
461
+ test("resolveModel: unknown tier → null", () => {
462
+ const model = resolveModel("unknown_tier", "claude", capMatrix);
463
+ expect(model).toBeNull();
464
+ });
465
+ });
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // Scenario 3: Manifest generation
469
+ // ---------------------------------------------------------------------------
470
+
471
+ describe("Scenario 3 — Manifest file content", () => {
472
+ const capMatrix = minimalCapMatrix();
473
+ const invocations = minimalInvocations();
474
+
475
+ test("Claude: agents/<n>.md is created with frontmatter and body", () => {
476
+ const tmp = makeTmp();
477
+ const assets = [makeAgentEntry()];
478
+ buildForClaude(assets, capMatrix, invocations, defaultBuildOpts(tmp));
479
+
480
+ const outPath = join(tmp, "claude", "agents", "sample-architect.md");
481
+ expect(existsSync(outPath)).toBe(true);
482
+ const content = readFileSync(outPath, "utf-8");
483
+ expect(content).toContain("---");
484
+ expect(content).toContain("description:");
485
+ expect(content).toContain("disallowedTools:");
486
+ expect(content).toContain("Edit");
487
+ expect(content).toContain("## Role");
488
+ });
489
+
490
+ test("Claude: disallowedTools in agent .md contains all resolved tools", () => {
491
+ const tmp = makeTmp();
492
+ const assets = [makeAgentEntry()];
493
+ buildForClaude(assets, capMatrix, invocations, defaultBuildOpts(tmp));
494
+
495
+ const content = readFileSync(join(tmp, "claude", "agents", "sample-architect.md"), "utf-8");
496
+ expect(content).toContain("mcp__plugin_claude-nexus_nx__nx_task_add");
497
+ expect(content).toContain("mcp__plugin_claude-nexus_nx__nx_task_update");
498
+ expect(content).toContain("NotebookEdit");
499
+ });
500
+
501
+ test("Claude: model field is emitted from model_tier mapping", () => {
502
+ const tmp = makeTmp();
503
+ const assets = [makeAgentEntry()];
504
+ buildForClaude(assets, capMatrix, invocations, defaultBuildOpts(tmp));
505
+
506
+ const content = readFileSync(join(tmp, "claude", "agents", "sample-architect.md"), "utf-8");
507
+ expect(content).toContain("model: claude-opus-4");
508
+ });
509
+
510
+ test("Claude: skill SKILL.md is created with description and body", () => {
511
+ const tmp = makeTmp();
512
+ const assets = [makeSkillEntry()];
513
+ buildForClaude(assets, capMatrix, invocations, defaultBuildOpts(tmp));
514
+
515
+ const outPath = join(tmp, "claude", "skills", "sample-skill", "SKILL.md");
516
+ expect(existsSync(outPath)).toBe(true);
517
+ const content = readFileSync(outPath, "utf-8");
518
+ expect(content).toContain("description:");
519
+ expect(content).toContain("## Role");
520
+ });
521
+
522
+ test("Claude: plugin.json is created as Template", () => {
523
+ const tmp = makeTmp();
524
+ const assets = [makeAgentEntry()];
525
+ buildForClaude(assets, capMatrix, invocations, defaultBuildOpts(tmp));
526
+
527
+ const pluginPath = join(tmp, "claude", ".claude-plugin", "plugin.json");
528
+ expect(existsSync(pluginPath)).toBe(true);
529
+ const parsed = JSON.parse(readFileSync(pluginPath, "utf-8")) as { name: string; agents: unknown[] };
530
+ expect(parsed.name).toBe("claude-nexus");
531
+ expect(Array.isArray(parsed.agents)).toBe(true);
532
+ expect((parsed.agents as { id: string }[])[0]?.id).toBe("sample-architect");
533
+ });
534
+
535
+ test("Claude: marketplace.json is created as Template", () => {
536
+ const tmp = makeTmp();
537
+ const assets = [makeAgentEntry()];
538
+ buildForClaude(assets, capMatrix, invocations, defaultBuildOpts(tmp));
539
+
540
+ const marketPath = join(tmp, "claude", ".claude-plugin", "marketplace.json");
541
+ expect(existsSync(marketPath)).toBe(true);
542
+ const parsed = JSON.parse(readFileSync(marketPath, "utf-8")) as { agents: unknown[] };
543
+ expect(Array.isArray(parsed.agents)).toBe(true);
544
+ });
545
+
546
+ test("OpenCode: package.json is created as Template", () => {
547
+ const tmp = makeTmp();
548
+ const assets = [makeAgentEntry()];
549
+ buildForOpencode(assets, capMatrix, invocations, defaultBuildOpts(tmp));
550
+
551
+ const pkgPath = join(tmp, "opencode", "package.json");
552
+ expect(existsSync(pkgPath)).toBe(true);
553
+ const parsed = JSON.parse(readFileSync(pkgPath, "utf-8")) as { name: string };
554
+ expect(parsed.name).toBe("opencode-nexus");
555
+ });
556
+
557
+ test("OpenCode: src/agents/<n>.ts is created with AgentConfig export", () => {
558
+ const tmp = makeTmp();
559
+ const assets = [makeAgentEntry()];
560
+ buildForOpencode(assets, capMatrix, invocations, defaultBuildOpts(tmp));
561
+
562
+ const tsPath = join(tmp, "opencode", "src", "agents", "sample-architect.ts");
563
+ expect(existsSync(tsPath)).toBe(true);
564
+ const content = readFileSync(tsPath, "utf-8");
565
+ expect(content).toContain("AgentConfig");
566
+ expect(content).toContain("sampleArchitect");
567
+ expect(content).toContain("sample-architect");
568
+ expect(content).toContain("system:");
569
+ });
570
+
571
+ test("OpenCode: src/agents/<n>.ts does not contain unescaped backtick", () => {
572
+ const tmp = makeTmp();
573
+ // Use an entry with backtick in the body
574
+ const entry = makeAgentEntry({
575
+ body: "## Role\n\nUse `code` here and also \\`escaped\\`.\n",
576
+ });
577
+ buildForOpencode([entry], capMatrix, invocations, defaultBuildOpts(tmp));
578
+
579
+ const content = readFileSync(
580
+ join(tmp, "opencode", "src", "agents", "sample-architect.ts"),
581
+ "utf-8",
582
+ );
583
+ // The file should be parseable: no raw unescaped backtick breaking template literal
584
+ // A simple check: count backticks — should be exactly 2 (opening + closing of system field)
585
+ // plus possible escaped ones in the content
586
+ const lines = content.split("\n");
587
+ const systemLine = lines.findIndex((l) => l.includes("system:"));
588
+ expect(systemLine).toBeGreaterThanOrEqual(0);
589
+ });
590
+
591
+ test("OpenCode: src/index.ts imports all agents", () => {
592
+ const tmp = makeTmp();
593
+ const assets = [makeAgentEntry(), makeEngineerEntry()];
594
+ buildForOpencode(assets, capMatrix, invocations, defaultBuildOpts(tmp));
595
+
596
+ const indexContent = readFileSync(join(tmp, "opencode", "src", "index.ts"), "utf-8");
597
+ expect(indexContent).toContain("sample-architect");
598
+ expect(indexContent).toContain("sample-engineer");
599
+ expect(indexContent).toContain("export const agents");
600
+ });
601
+
602
+ test("OpenCode: .opencode/skills/<n>/SKILL.md is created", () => {
603
+ const tmp = makeTmp();
604
+ const assets = [makeSkillEntry()];
605
+ buildForOpencode(assets, capMatrix, invocations, defaultBuildOpts(tmp));
606
+
607
+ const skillPath = join(tmp, "opencode", ".opencode", "skills", "sample-skill", "SKILL.md");
608
+ expect(existsSync(skillPath)).toBe(true);
609
+ });
610
+
611
+ test("Codex: agents/<n>.toml is created with TOML agent block", () => {
612
+ const tmp = makeTmp();
613
+ const assets = [makeAgentEntry()];
614
+ buildForCodex(assets, capMatrix, invocations, defaultBuildOpts(tmp));
615
+
616
+ const tomlPath = join(tmp, "codex", "agents", "sample-architect.toml");
617
+ expect(existsSync(tomlPath)).toBe(true);
618
+ const content = readFileSync(tomlPath, "utf-8");
619
+ expect(content).toContain("[agents.sample-architect]");
620
+ expect(content).toContain("description =");
621
+ expect(content).toContain("sandbox_mode = ");
622
+ expect(content).toContain("gpt-5.4");
623
+ });
624
+
625
+ test("Codex: prompts/<n>.md is created", () => {
626
+ const tmp = makeTmp();
627
+ const assets = [makeAgentEntry()];
628
+ buildForCodex(assets, capMatrix, invocations, defaultBuildOpts(tmp));
629
+
630
+ const promptPath = join(tmp, "codex", "prompts", "sample-architect.md");
631
+ expect(existsSync(promptPath)).toBe(true);
632
+ });
633
+
634
+ test("Codex: plugin/.codex-plugin/plugin.json is created", () => {
635
+ const tmp = makeTmp();
636
+ const assets = [makeAgentEntry()];
637
+ buildForCodex(assets, capMatrix, invocations, defaultBuildOpts(tmp));
638
+
639
+ const pluginPath = join(tmp, "codex", "plugin", ".codex-plugin", "plugin.json");
640
+ expect(existsSync(pluginPath)).toBe(true);
641
+ const parsed = JSON.parse(readFileSync(pluginPath, "utf-8")) as { name: string };
642
+ expect(parsed.name).toBe("codex-nexus");
643
+ });
644
+
645
+ test("Codex: install/config.fragment.toml is created", () => {
646
+ const tmp = makeTmp();
647
+ const assets = [makeAgentEntry()];
648
+ buildForCodex(assets, capMatrix, invocations, defaultBuildOpts(tmp));
649
+
650
+ const fragmentPath = join(tmp, "codex", "install", "config.fragment.toml");
651
+ expect(existsSync(fragmentPath)).toBe(true);
652
+ const content = readFileSync(fragmentPath, "utf-8");
653
+ expect(content).toContain("[mcp_servers.nx]");
654
+ });
655
+
656
+ test("Codex: skills SKILL.md is created under plugin/skills/", () => {
657
+ const tmp = makeTmp();
658
+ const assets = [makeSkillEntry()];
659
+ buildForCodex(assets, capMatrix, invocations, defaultBuildOpts(tmp));
660
+
661
+ const skillPath = join(tmp, "codex", "plugin", "skills", "sample-skill", "SKILL.md");
662
+ expect(existsSync(skillPath)).toBe(true);
663
+ });
664
+ });
665
+
666
+ // ---------------------------------------------------------------------------
667
+ // Scenario 4: Overwrite policy
668
+ // ---------------------------------------------------------------------------
669
+
670
+ describe("Scenario 4 — Overwrite policy", () => {
671
+ const capMatrix = minimalCapMatrix();
672
+ const invocations = minimalInvocations();
673
+
674
+ test("Managed path: always overwrites existing file", () => {
675
+ const tmp = makeTmp();
676
+ const outPath = join(tmp, "managed.txt");
677
+ mkdirSync(tmp, { recursive: true });
678
+ writeFileSync(outPath, "old content");
679
+
680
+ const opts = defaultBuildOpts(tmp);
681
+ applyOverwritePolicy(outPath, "new content", true, opts);
682
+
683
+ expect(readFileSync(outPath, "utf-8")).toBe("new content");
684
+ });
685
+
686
+ test("Template path: skips if file exists (no --force)", () => {
687
+ const tmp = makeTmp();
688
+ const outPath = join(tmp, "template.txt");
689
+ mkdirSync(tmp, { recursive: true });
690
+ writeFileSync(outPath, "original");
691
+
692
+ const opts = defaultBuildOpts(tmp, { force: false });
693
+ applyOverwritePolicy(outPath, "overwritten", false, opts);
694
+
695
+ expect(readFileSync(outPath, "utf-8")).toBe("original");
696
+ });
697
+
698
+ test("Template path: overwrites with --force", () => {
699
+ const tmp = makeTmp();
700
+ const outPath = join(tmp, "template.txt");
701
+ mkdirSync(tmp, { recursive: true });
702
+ writeFileSync(outPath, "original");
703
+
704
+ const opts = defaultBuildOpts(tmp, { force: true });
705
+ applyOverwritePolicy(outPath, "overwritten", false, opts);
706
+
707
+ expect(readFileSync(outPath, "utf-8")).toBe("overwritten");
708
+ });
709
+
710
+ test("Template path: creates file if it does not exist (no --force needed)", () => {
711
+ const tmp = makeTmp();
712
+ const outPath = join(tmp, "new-template.txt");
713
+
714
+ const opts = defaultBuildOpts(tmp, { force: false });
715
+ applyOverwritePolicy(outPath, "new content", false, opts);
716
+
717
+ expect(existsSync(outPath)).toBe(true);
718
+ expect(readFileSync(outPath, "utf-8")).toBe("new content");
719
+ });
720
+
721
+ test("--dry-run: no files are written", () => {
722
+ const tmp = makeTmp();
723
+ const assets = [makeAgentEntry(), makeSkillEntry()];
724
+ const opts = defaultBuildOpts(tmp, { dryRun: true, harnesses: ["claude"] });
725
+
726
+ buildForClaude(assets, capMatrix, invocations, opts);
727
+
728
+ // No files should be written
729
+ expect(existsSync(join(tmp, "claude"))).toBe(false);
730
+ });
731
+
732
+ test("Plugin.json: buildForClaude skips existing plugin.json without --force", () => {
733
+ const tmp = makeTmp();
734
+ const pluginDir = join(tmp, "claude", ".claude-plugin");
735
+ mkdirSync(pluginDir, { recursive: true });
736
+ writeFileSync(join(pluginDir, "plugin.json"), '{"custom": "content"}');
737
+
738
+ const assets = [makeAgentEntry()];
739
+ buildForClaude(assets, capMatrix, invocations, defaultBuildOpts(tmp, { force: false }));
740
+
741
+ const content = readFileSync(join(pluginDir, "plugin.json"), "utf-8");
742
+ expect(content).toContain('"custom"');
743
+ });
744
+
745
+ test("Plugin.json: buildForClaude overwrites with --force", () => {
746
+ const tmp = makeTmp();
747
+ const pluginDir = join(tmp, "claude", ".claude-plugin");
748
+ mkdirSync(pluginDir, { recursive: true });
749
+ writeFileSync(join(pluginDir, "plugin.json"), '{"custom": "content"}');
750
+
751
+ const assets = [makeAgentEntry()];
752
+ buildForClaude(assets, capMatrix, invocations, defaultBuildOpts(tmp, { force: true }));
753
+
754
+ const content = readFileSync(join(pluginDir, "plugin.json"), "utf-8");
755
+ expect(content).toContain("claude-nexus");
756
+ expect(content).not.toContain('"custom"');
757
+ });
758
+ });
759
+
760
+ // ---------------------------------------------------------------------------
761
+ // Scenario 5: Harness builders — full build with invocation expansion
762
+ // ---------------------------------------------------------------------------
763
+
764
+ describe("Scenario 5 — Harness builders with invocation expansion", () => {
765
+ const capMatrix = minimalCapMatrix();
766
+ const invocations = minimalInvocations();
767
+
768
+ test("Claude: invocations in body are expanded", () => {
769
+ const tmp = makeTmp();
770
+ const entry = makeAgentEntry({
771
+ body: 'Use {{subagent_spawn target_role=researcher prompt="research task"}}.\n',
772
+ });
773
+ buildForClaude([entry], capMatrix, invocations, defaultBuildOpts(tmp));
774
+
775
+ const content = readFileSync(join(tmp, "claude", "agents", "sample-architect.md"), "utf-8");
776
+ expect(content).toContain("Agent(");
777
+ expect(content).toContain('"researcher"');
778
+ expect(content).not.toContain("{{");
779
+ });
780
+
781
+ test("OpenCode: invocations in body are expanded", () => {
782
+ const tmp = makeTmp();
783
+ const entry = makeAgentEntry({
784
+ body: 'Use {{subagent_spawn target_role=engineer prompt="fix it"}}.\n',
785
+ });
786
+ buildForOpencode([entry], capMatrix, invocations, defaultBuildOpts(tmp));
787
+
788
+ const content = readFileSync(
789
+ join(tmp, "opencode", "src", "agents", "sample-architect.ts"),
790
+ "utf-8",
791
+ );
792
+ expect(content).toContain("task(");
793
+ expect(content).toContain('"engineer"');
794
+ expect(content).not.toContain("{{");
795
+ });
796
+
797
+ test("Codex: invocations in body are expanded", () => {
798
+ const tmp = makeTmp();
799
+ const entry = makeAgentEntry({
800
+ body: 'Use {{task_register label="Do thing" state=in_progress}}.\n',
801
+ });
802
+ buildForCodex([entry], capMatrix, invocations, defaultBuildOpts(tmp));
803
+
804
+ const content = readFileSync(join(tmp, "codex", "agents", "sample-architect.toml"), "utf-8");
805
+ expect(content).toContain("update_plan");
806
+ expect(content).toContain("Do thing");
807
+ expect(content).not.toContain("{{");
808
+ });
809
+
810
+ test("All three harnesses: both agents and skills are built", () => {
811
+ const tmp = makeTmp();
812
+ const assets = [makeAgentEntry(), makeEngineerEntry(), makeSkillEntry()];
813
+ const opts = defaultBuildOpts(tmp);
814
+
815
+ buildForClaude(assets, capMatrix, invocations, opts);
816
+ buildForOpencode(assets, capMatrix, invocations, opts);
817
+ buildForCodex(assets, capMatrix, invocations, opts);
818
+
819
+ // Claude
820
+ expect(existsSync(join(tmp, "claude", "agents", "sample-architect.md"))).toBe(true);
821
+ expect(existsSync(join(tmp, "claude", "agents", "sample-engineer.md"))).toBe(true);
822
+ expect(existsSync(join(tmp, "claude", "skills", "sample-skill", "SKILL.md"))).toBe(true);
823
+
824
+ // OpenCode
825
+ expect(existsSync(join(tmp, "opencode", "src", "agents", "sample-architect.ts"))).toBe(true);
826
+ expect(existsSync(join(tmp, "opencode", "src", "agents", "sample-engineer.ts"))).toBe(true);
827
+ expect(existsSync(join(tmp, "opencode", ".opencode", "skills", "sample-skill", "SKILL.md"))).toBe(true);
828
+
829
+ // Codex
830
+ expect(existsSync(join(tmp, "codex", "agents", "sample-architect.toml"))).toBe(true);
831
+ expect(existsSync(join(tmp, "codex", "agents", "sample-engineer.toml"))).toBe(true);
832
+ expect(existsSync(join(tmp, "codex", "plugin", "skills", "sample-skill", "SKILL.md"))).toBe(true);
833
+ });
834
+ });
835
+
836
+ // ---------------------------------------------------------------------------
837
+ // Scenario 6: Error paths
838
+ // ---------------------------------------------------------------------------
839
+
840
+ describe("Scenario 6 — Error paths", () => {
841
+ test("parseFrontmatter: missing frontmatter throws", () => {
842
+ expect(() => parseFrontmatter("No frontmatter here", "test.md")).toThrow(
843
+ "Missing or malformed frontmatter",
844
+ );
845
+ });
846
+
847
+ test("parseFrontmatter: malformed YAML frontmatter throws", () => {
848
+ const raw = "---\nname: [\ninvalid: yaml:\n---\nbody";
849
+ expect(() => parseFrontmatter(raw, "test.md")).toThrow("YAML parse failure");
850
+ });
851
+
852
+ test("parseFrontmatter: missing required fields (id) throws", () => {
853
+ const raw = "---\nname: test\n---\nbody";
854
+ expect(() => parseFrontmatter(raw, "test.md")).toThrow("Missing required frontmatter fields");
855
+ });
856
+
857
+ test("parseFrontmatter: valid frontmatter parses correctly", () => {
858
+ const raw = "---\nname: test\nid: test\ncategory: do\nmodel_tier: standard\ncapabilities: []\n---\n## Body";
859
+ const { fm, body } = parseFrontmatter(raw, "test.md");
860
+ expect(fm.name).toBe("test");
861
+ expect(fm.id).toBe("test");
862
+ expect(body).toContain("## Body");
863
+ });
864
+
865
+ test("resolveClaudeDisallowedTools: unknown capability throws with capability name", () => {
866
+ const capMatrix = minimalCapMatrix();
867
+ expect(() => resolveClaudeDisallowedTools(["unknown_cap_xyz"], capMatrix)).toThrow(
868
+ "unknown_cap_xyz",
869
+ );
870
+ });
871
+
872
+ test("buildForClaude: unknown capability in frontmatter throws", () => {
873
+ const tmp = makeTmp();
874
+ const entry = makeAgentEntry({
875
+ frontmatter: {
876
+ ...makeAgentEntry().frontmatter,
877
+ capabilities: ["unknown_cap_xyz"],
878
+ },
879
+ });
880
+ const capMatrix = minimalCapMatrix();
881
+ expect(() =>
882
+ buildForClaude([entry], capMatrix, minimalInvocations(), defaultBuildOpts(tmp)),
883
+ ).toThrow();
884
+ });
885
+ });
886
+
887
+ // ---------------------------------------------------------------------------
888
+ // Scenario 7: CLI arg parsing
889
+ // ---------------------------------------------------------------------------
890
+
891
+ describe("Scenario 7 — CLI arg parsing", () => {
892
+ test("default options: all harnesses, ROOT/dist target", () => {
893
+ const opts = parseArgs(["bun", "build-agents.ts"]);
894
+ expect(opts.harnesses).toEqual(["claude", "opencode", "codex"]);
895
+ expect(opts.targetDir).toContain("dist");
896
+ expect(opts.dryRun).toBe(false);
897
+ expect(opts.force).toBe(false);
898
+ expect(opts.strict).toBe(false);
899
+ expect(opts.only).toBeUndefined();
900
+ });
901
+
902
+ test("--harness=claude: restricts to claude", () => {
903
+ const opts = parseArgs(["bun", "build-agents.ts", "--harness=claude"]);
904
+ expect(opts.harnesses).toEqual(["claude"]);
905
+ });
906
+
907
+ test("--harness=opencode: restricts to opencode", () => {
908
+ const opts = parseArgs(["bun", "build-agents.ts", "--harness=opencode"]);
909
+ expect(opts.harnesses).toEqual(["opencode"]);
910
+ });
911
+
912
+ test("--harness=codex: restricts to codex", () => {
913
+ const opts = parseArgs(["bun", "build-agents.ts", "--harness=codex"]);
914
+ expect(opts.harnesses).toEqual(["codex"]);
915
+ });
916
+
917
+ test("--harness=invalid: throws", () => {
918
+ expect(() => parseArgs(["bun", "build-agents.ts", "--harness=invalid"])).toThrow(
919
+ "Unknown harness",
920
+ );
921
+ });
922
+
923
+ test("--target sets targetDir to resolved path", () => {
924
+ const opts = parseArgs(["bun", "build-agents.ts", "--target=/tmp/test-out"]);
925
+ expect(opts.targetDir).toBe("/tmp/test-out");
926
+ });
927
+
928
+ test("--dry-run sets dryRun flag", () => {
929
+ const opts = parseArgs(["bun", "build-agents.ts", "--dry-run"]);
930
+ expect(opts.dryRun).toBe(true);
931
+ });
932
+
933
+ test("--force sets force flag", () => {
934
+ const opts = parseArgs(["bun", "build-agents.ts", "--force"]);
935
+ expect(opts.force).toBe(true);
936
+ });
937
+
938
+ test("--strict sets strict flag", () => {
939
+ const opts = parseArgs(["bun", "build-agents.ts", "--strict"]);
940
+ expect(opts.strict).toBe(true);
941
+ });
942
+
943
+ test("--only sets only filter", () => {
944
+ const opts = parseArgs(["bun", "build-agents.ts", "--only=architect"]);
945
+ expect(opts.only).toBe("architect");
946
+ });
947
+
948
+ test("combined flags all parse correctly", () => {
949
+ const opts = parseArgs([
950
+ "bun",
951
+ "build-agents.ts",
952
+ "--harness=claude",
953
+ "--target=/tmp/out",
954
+ "--dry-run",
955
+ "--force",
956
+ "--strict",
957
+ "--only=engineer",
958
+ ]);
959
+ expect(opts.harnesses).toEqual(["claude"]);
960
+ expect(opts.targetDir).toBe("/tmp/out");
961
+ expect(opts.dryRun).toBe(true);
962
+ expect(opts.force).toBe(true);
963
+ expect(opts.strict).toBe(true);
964
+ expect(opts.only).toBe("engineer");
965
+ });
966
+ });
967
+
968
+ // ---------------------------------------------------------------------------
969
+ // Scenario 8: Integration — buildAgents reads real assets
970
+ // ---------------------------------------------------------------------------
971
+
972
+ describe("Scenario 8 — Integration with real assets", () => {
973
+ test("buildAgents: claude harness completes without error", async () => {
974
+ const tmp = makeTmp();
975
+ await expect(
976
+ buildAgents({
977
+ harnesses: ["claude"],
978
+ targetDir: tmp,
979
+ dryRun: false,
980
+ force: false,
981
+ strict: false,
982
+ }),
983
+ ).resolves.toBeUndefined();
984
+
985
+ // Should have created at least one agent file
986
+ const agentsDir = join(tmp, "claude", "agents");
987
+ const { readdirSync: readdir } = await import("node:fs");
988
+ const files = readdir(agentsDir);
989
+ expect(files.length).toBeGreaterThan(0);
990
+ expect(files.some((f) => f.endsWith(".md"))).toBe(true);
991
+ });
992
+
993
+ test("buildAgents: opencode harness completes without error", async () => {
994
+ const tmp = makeTmp();
995
+ await expect(
996
+ buildAgents({
997
+ harnesses: ["opencode"],
998
+ targetDir: tmp,
999
+ dryRun: false,
1000
+ force: false,
1001
+ strict: false,
1002
+ }),
1003
+ ).resolves.toBeUndefined();
1004
+
1005
+ const agentsDir = join(tmp, "opencode", "src", "agents");
1006
+ const { readdirSync: readdir } = await import("node:fs");
1007
+ const files = readdir(agentsDir);
1008
+ expect(files.some((f) => f.endsWith(".ts"))).toBe(true);
1009
+ });
1010
+
1011
+ test("buildAgents: codex harness completes without error", async () => {
1012
+ const tmp = makeTmp();
1013
+ await expect(
1014
+ buildAgents({
1015
+ harnesses: ["codex"],
1016
+ targetDir: tmp,
1017
+ dryRun: false,
1018
+ force: false,
1019
+ strict: false,
1020
+ }),
1021
+ ).resolves.toBeUndefined();
1022
+
1023
+ const agentsDir = join(tmp, "codex", "agents");
1024
+ const { readdirSync: readdir } = await import("node:fs");
1025
+ const files = readdir(agentsDir);
1026
+ expect(files.some((f) => f.endsWith(".toml"))).toBe(true);
1027
+ });
1028
+
1029
+ test("buildAgents: --dry-run produces no output files", async () => {
1030
+ const tmp = makeTmp();
1031
+ await buildAgents({
1032
+ harnesses: ["claude"],
1033
+ targetDir: tmp,
1034
+ dryRun: true,
1035
+ force: false,
1036
+ strict: false,
1037
+ });
1038
+
1039
+ expect(existsSync(join(tmp, "claude"))).toBe(false);
1040
+ });
1041
+
1042
+ test("buildAgents: --only=architect restricts to architect agent", async () => {
1043
+ const tmp = makeTmp();
1044
+ await buildAgents({
1045
+ harnesses: ["claude"],
1046
+ targetDir: tmp,
1047
+ dryRun: false,
1048
+ force: false,
1049
+ strict: false,
1050
+ only: "architect",
1051
+ });
1052
+
1053
+ const agentsDir = join(tmp, "claude", "agents");
1054
+ const { readdirSync: readdir } = await import("node:fs");
1055
+ const files = readdir(agentsDir);
1056
+ expect(files).toHaveLength(1);
1057
+ expect(files[0]).toBe("architect.md");
1058
+ });
1059
+ });
1060
+
1061
+ // ---------------------------------------------------------------------------
1062
+ // Scenario 9: mode field — primary agent injection
1063
+ // ---------------------------------------------------------------------------
1064
+
1065
+ function makePrimaryAgentEntry(overrides?: Partial<AssetEntry>): AssetEntry {
1066
+ return {
1067
+ type: "agent",
1068
+ name: "sample-lead",
1069
+ frontmatter: {
1070
+ name: "sample-lead",
1071
+ description: "Sample primary orchestrator for testing",
1072
+ task: "Orchestration",
1073
+ category: "lead",
1074
+ resume_tier: "persistent",
1075
+ model_tier: "high",
1076
+ capabilities: [],
1077
+ id: "sample-lead",
1078
+ mode: "primary",
1079
+ },
1080
+ body: "## Identity\n\nYou are Lead.\n",
1081
+ bodyPath: join(FIXTURE_BASE, "agents/sample-lead/body.md"),
1082
+ ...overrides,
1083
+ };
1084
+ }
1085
+
1086
+ describe("Scenario 9 — mode field and primary agent injection", () => {
1087
+ const capMatrix = minimalCapMatrix();
1088
+ const invocations = minimalInvocations();
1089
+
1090
+ // --- Type parsing ---
1091
+
1092
+ test("parseFrontmatter: mode=primary parses correctly", () => {
1093
+ const raw =
1094
+ "---\nname: test\nid: test\ncategory: lead\nmodel_tier: high\ncapabilities: []\nmode: primary\n---\n## Body";
1095
+ const { fm } = parseFrontmatter(raw, "test.md");
1096
+ expect(fm.mode).toBe("primary");
1097
+ });
1098
+
1099
+ test("parseFrontmatter: mode=subagent parses correctly", () => {
1100
+ const raw =
1101
+ "---\nname: test\nid: test\ncategory: do\nmodel_tier: standard\ncapabilities: []\nmode: subagent\n---\n## Body";
1102
+ const { fm } = parseFrontmatter(raw, "test.md");
1103
+ expect(fm.mode).toBe("subagent");
1104
+ });
1105
+
1106
+ test("parseFrontmatter: mode=all parses correctly", () => {
1107
+ const raw =
1108
+ "---\nname: test\nid: test\ncategory: do\nmodel_tier: standard\ncapabilities: []\nmode: all\n---\n## Body";
1109
+ const { fm } = parseFrontmatter(raw, "test.md");
1110
+ expect(fm.mode).toBe("all");
1111
+ });
1112
+
1113
+ test("parseFrontmatter: invalid mode value throws", () => {
1114
+ const raw =
1115
+ "---\nname: test\nid: test\ncategory: do\nmodel_tier: standard\ncapabilities: []\nmode: invalid_mode\n---\n## Body";
1116
+ expect(() => parseFrontmatter(raw, "test.md")).toThrow('Invalid mode "invalid_mode"');
1117
+ });
1118
+
1119
+ test("parseFrontmatter: missing mode field defaults to undefined (treated as subagent)", () => {
1120
+ const raw =
1121
+ "---\nname: test\nid: test\ncategory: do\nmodel_tier: standard\ncapabilities: []\n---\n## Body";
1122
+ const { fm } = parseFrontmatter(raw, "test.md");
1123
+ expect(fm.mode).toBeUndefined();
1124
+ });
1125
+
1126
+ // --- Claude settings.json ---
1127
+
1128
+ test("Claude: settings.json is created when primary agent exists", () => {
1129
+ const tmp = makeTmp();
1130
+ const assets = [makePrimaryAgentEntry(), makeAgentEntry()];
1131
+ buildForClaude(assets, capMatrix, invocations, defaultBuildOpts(tmp));
1132
+
1133
+ const settingsPath = join(tmp, "claude", "settings.json");
1134
+ expect(existsSync(settingsPath)).toBe(true);
1135
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf-8")) as { agent: string };
1136
+ expect(parsed.agent).toBe("sample-lead");
1137
+ });
1138
+
1139
+ test("Claude: settings.json is NOT created when no primary agent exists", () => {
1140
+ const tmp = makeTmp();
1141
+ const assets = [makeAgentEntry(), makeEngineerEntry()]; // both mode=undefined (subagent default)
1142
+ buildForClaude(assets, capMatrix, invocations, defaultBuildOpts(tmp));
1143
+
1144
+ const settingsPath = join(tmp, "claude", "settings.json");
1145
+ expect(existsSync(settingsPath)).toBe(false);
1146
+ });
1147
+
1148
+ test("Claude: settings.json uses first primary when multiple primaries exist", () => {
1149
+ const tmp = makeTmp();
1150
+ const primary1 = makePrimaryAgentEntry();
1151
+ const primary2 = makePrimaryAgentEntry({
1152
+ name: "sample-lead-2",
1153
+ frontmatter: { ...makePrimaryAgentEntry().frontmatter, id: "sample-lead-2", name: "sample-lead-2" },
1154
+ });
1155
+ buildForClaude([primary1, primary2, makeAgentEntry()], capMatrix, invocations, defaultBuildOpts(tmp));
1156
+
1157
+ const settingsPath = join(tmp, "claude", "settings.json");
1158
+ expect(existsSync(settingsPath)).toBe(true);
1159
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf-8")) as { agent: string };
1160
+ expect(parsed.agent).toBe("sample-lead"); // first primary
1161
+ });
1162
+
1163
+ // --- OpenCode mode field ---
1164
+
1165
+ test("OpenCode: primary agent .ts file contains mode: 'primary'", () => {
1166
+ const tmp = makeTmp();
1167
+ const assets = [makePrimaryAgentEntry()];
1168
+ buildForOpencode(assets, capMatrix, invocations, defaultBuildOpts(tmp));
1169
+
1170
+ const tsPath = join(tmp, "opencode", "src", "agents", "sample-lead.ts");
1171
+ expect(existsSync(tsPath)).toBe(true);
1172
+ const content = readFileSync(tsPath, "utf-8");
1173
+ expect(content).toContain('mode: "primary"');
1174
+ });
1175
+
1176
+ test("OpenCode: subagent (mode=undefined) .ts file does NOT contain mode field", () => {
1177
+ const tmp = makeTmp();
1178
+ const assets = [makeAgentEntry()]; // no mode set
1179
+ buildForOpencode(assets, capMatrix, invocations, defaultBuildOpts(tmp));
1180
+
1181
+ const content = readFileSync(
1182
+ join(tmp, "opencode", "src", "agents", "sample-architect.ts"),
1183
+ "utf-8",
1184
+ );
1185
+ expect(content).not.toContain("mode:");
1186
+ });
1187
+
1188
+ // --- Codex AGENTS.fragment.md ---
1189
+
1190
+ test("Codex: AGENTS.fragment.md is created when primary agent exists", () => {
1191
+ const tmp = makeTmp();
1192
+ const assets = [makePrimaryAgentEntry(), makeAgentEntry()];
1193
+ buildForCodex(assets, capMatrix, invocations, defaultBuildOpts(tmp));
1194
+
1195
+ const fragmentPath = join(tmp, "codex", "install", "AGENTS.fragment.md");
1196
+ expect(existsSync(fragmentPath)).toBe(true);
1197
+ });
1198
+
1199
+ test("Codex: AGENTS.fragment.md contains start/end markers with agent id", () => {
1200
+ const tmp = makeTmp();
1201
+ const assets = [makePrimaryAgentEntry()];
1202
+ buildForCodex(assets, capMatrix, invocations, defaultBuildOpts(tmp));
1203
+
1204
+ const content = readFileSync(join(tmp, "codex", "install", "AGENTS.fragment.md"), "utf-8");
1205
+ expect(content).toContain("<!-- nexus-core:sample-lead:start -->");
1206
+ expect(content).toContain("<!-- nexus-core:sample-lead:end -->");
1207
+ });
1208
+
1209
+ test("Codex: AGENTS.fragment.md contains agent name heading and body", () => {
1210
+ const tmp = makeTmp();
1211
+ const assets = [makePrimaryAgentEntry()];
1212
+ buildForCodex(assets, capMatrix, invocations, defaultBuildOpts(tmp));
1213
+
1214
+ const content = readFileSync(join(tmp, "codex", "install", "AGENTS.fragment.md"), "utf-8");
1215
+ expect(content).toContain("# sample-lead");
1216
+ expect(content).toContain("## Identity");
1217
+ expect(content).toContain("You are Lead.");
1218
+ });
1219
+
1220
+ test("Codex: AGENTS.fragment.md is NOT created when no primary agent exists", () => {
1221
+ const tmp = makeTmp();
1222
+ const assets = [makeAgentEntry(), makeEngineerEntry()]; // both subagent default
1223
+ buildForCodex(assets, capMatrix, invocations, defaultBuildOpts(tmp));
1224
+
1225
+ const fragmentPath = join(tmp, "codex", "install", "AGENTS.fragment.md");
1226
+ expect(existsSync(fragmentPath)).toBe(false);
1227
+ });
1228
+
1229
+ test("Codex: AGENTS.fragment.md with multiple primaries has both marker blocks", () => {
1230
+ const tmp = makeTmp();
1231
+ const primary1 = makePrimaryAgentEntry();
1232
+ const primary2 = makePrimaryAgentEntry({
1233
+ name: "sample-lead-2",
1234
+ frontmatter: { ...makePrimaryAgentEntry().frontmatter, id: "sample-lead-2", name: "sample-lead-2" },
1235
+ });
1236
+ buildForCodex([primary1, primary2], capMatrix, invocations, defaultBuildOpts(tmp));
1237
+
1238
+ const content = readFileSync(join(tmp, "codex", "install", "AGENTS.fragment.md"), "utf-8");
1239
+ expect(content).toContain("<!-- nexus-core:sample-lead:start -->");
1240
+ expect(content).toContain("<!-- nexus-core:sample-lead:end -->");
1241
+ expect(content).toContain("<!-- nexus-core:sample-lead-2:start -->");
1242
+ expect(content).toContain("<!-- nexus-core:sample-lead-2:end -->");
1243
+ });
1244
+
1245
+ // --- Integration: real lead agent ---
1246
+
1247
+ test("Integration: buildForClaude creates settings.json with lead as primary from real assets", async () => {
1248
+ const tmp = makeTmp();
1249
+ await buildAgents({
1250
+ harnesses: ["claude"],
1251
+ targetDir: tmp,
1252
+ dryRun: false,
1253
+ force: false,
1254
+ strict: false,
1255
+ });
1256
+
1257
+ const settingsPath = join(tmp, "claude", "settings.json");
1258
+ expect(existsSync(settingsPath)).toBe(true);
1259
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf-8")) as { agent: string };
1260
+ expect(parsed.agent).toBe("lead");
1261
+ });
1262
+
1263
+ test("Integration: buildForCodex creates AGENTS.fragment.md with lead from real assets", async () => {
1264
+ const tmp = makeTmp();
1265
+ await buildAgents({
1266
+ harnesses: ["codex"],
1267
+ targetDir: tmp,
1268
+ dryRun: false,
1269
+ force: false,
1270
+ strict: false,
1271
+ });
1272
+
1273
+ const fragmentPath = join(tmp, "codex", "install", "AGENTS.fragment.md");
1274
+ expect(existsSync(fragmentPath)).toBe(true);
1275
+ const content = readFileSync(fragmentPath, "utf-8");
1276
+ expect(content).toContain("<!-- nexus-core:lead:start -->");
1277
+ expect(content).toContain("<!-- nexus-core:lead:end -->");
1278
+ });
1279
+ });