@soleri/forge 9.7.2 → 9.8.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.
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
9
- import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs';
9
+ import { mkdirSync, rmSync, existsSync, readFileSync, readdirSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { tmpdir } from 'node:os';
12
12
  import { parse as parseYaml } from 'yaml';
@@ -197,6 +197,31 @@ describe('scaffoldFileTree', () => {
197
197
  expect(gitignore).toContain('_engine.md');
198
198
  });
199
199
 
200
+ it('generates conventions.md example instruction file', () => {
201
+ const result = scaffoldFileTree(MINIMAL_CONFIG, tempDir);
202
+ expect(result.success).toBe(true);
203
+
204
+ const content = readFileSync(join(result.agentDir, 'instructions', 'conventions.md'), 'utf-8');
205
+ expect(content).toContain('# Conventions');
206
+ expect(content).toContain('Naming Conventions');
207
+ expect(content).toContain('What to Avoid');
208
+ expect(content).toContain('kebab-case');
209
+ });
210
+
211
+ it('generates getting-started.md example instruction file', () => {
212
+ const result = scaffoldFileTree(MINIMAL_CONFIG, tempDir);
213
+ expect(result.success).toBe(true);
214
+
215
+ const content = readFileSync(
216
+ join(result.agentDir, 'instructions', 'getting-started.md'),
217
+ 'utf-8',
218
+ );
219
+ expect(content).toContain('Getting Started with Instructions');
220
+ expect(content).toContain('_engine.md');
221
+ expect(content).toContain('soleri dev');
222
+ expect(content).toContain('alphabetical order');
223
+ });
224
+
200
225
  it('fails if directory already exists', () => {
201
226
  scaffoldFileTree(MINIMAL_CONFIG, tempDir);
202
227
  const result2 = scaffoldFileTree(MINIMAL_CONFIG, tempDir);
@@ -252,4 +277,260 @@ describe('scaffoldFileTree', () => {
252
277
  expect(result.success).toBe(true);
253
278
  expect(result.summary).toContain('No build step needed');
254
279
  });
280
+
281
+ it('generates user.md with placeholder content', () => {
282
+ const result = scaffoldFileTree(MINIMAL_CONFIG, tempDir);
283
+ expect(result.success).toBe(true);
284
+
285
+ const userMdPath = join(result.agentDir, 'instructions', 'user.md');
286
+ expect(existsSync(userMdPath)).toBe(true);
287
+
288
+ const content = readFileSync(userMdPath, 'utf-8');
289
+ expect(content).toContain('# Your Custom Rules');
290
+ expect(content).toContain('priority placement in CLAUDE.md');
291
+ expect(content).toContain('Delete these instructions and replace with your own content.');
292
+ });
293
+
294
+ it('includes user.md in filesCreated', () => {
295
+ const result = scaffoldFileTree(MINIMAL_CONFIG, tempDir);
296
+ expect(result.success).toBe(true);
297
+ expect(result.filesCreated).toContain('instructions/user.md');
298
+ });
299
+
300
+ it('places user.md content before engine rules ref in CLAUDE.md', () => {
301
+ const result = scaffoldFileTree(MINIMAL_CONFIG, tempDir);
302
+ expect(result.success).toBe(true);
303
+
304
+ const claudeMd = readFileSync(join(result.agentDir, 'CLAUDE.md'), 'utf-8');
305
+ const userPos = claudeMd.indexOf('# Your Custom Rules');
306
+ const enginePos = claudeMd.indexOf('soleri:engine-rules-ref');
307
+
308
+ expect(userPos).toBeGreaterThan(-1);
309
+ expect(enginePos).toBeGreaterThan(-1);
310
+ expect(userPos).toBeLessThan(enginePos);
311
+ });
312
+
313
+ it('does not duplicate user.md in the alphabetical instructions section', () => {
314
+ const result = scaffoldFileTree(MINIMAL_CONFIG, tempDir);
315
+ expect(result.success).toBe(true);
316
+
317
+ const claudeMd = readFileSync(join(result.agentDir, 'CLAUDE.md'), 'utf-8');
318
+ // user.md content should appear exactly once
319
+ const matches = claudeMd.match(/# Your Custom Rules/g);
320
+ expect(matches).toHaveLength(1);
321
+ });
322
+
323
+ // ─── Skills Filter Tests ─────────────────────────────────────
324
+
325
+ it('default scaffold creates only essential skills (~7)', () => {
326
+ const result = scaffoldFileTree(MINIMAL_CONFIG, tempDir);
327
+ expect(result.success).toBe(true);
328
+
329
+ const skillDirs = readdirSync(join(result.agentDir, 'skills'), { withFileTypes: true })
330
+ .filter((d) => d.isDirectory())
331
+ .map((d) => d.name);
332
+
333
+ // Should have ~7 essential skills, not 30+
334
+ expect(skillDirs.length).toBeGreaterThanOrEqual(5);
335
+ expect(skillDirs.length).toBeLessThanOrEqual(10);
336
+
337
+ // Essential skills should be present
338
+ expect(skillDirs).toContain('agent-guide');
339
+ expect(skillDirs).toContain('vault-navigator');
340
+ expect(skillDirs).toContain('vault-capture');
341
+ expect(skillDirs).toContain('systematic-debugging');
342
+ expect(skillDirs).toContain('writing-plans');
343
+ expect(skillDirs).toContain('context-resume');
344
+ expect(skillDirs).toContain('agent-persona');
345
+
346
+ // Optional skills should NOT be present
347
+ expect(skillDirs).not.toContain('brainstorming');
348
+ expect(skillDirs).not.toContain('deep-review');
349
+ expect(skillDirs).not.toContain('code-patrol');
350
+ expect(skillDirs).not.toContain('yolo-mode');
351
+ });
352
+
353
+ it('skillsFilter: "all" creates all skills', () => {
354
+ const result = scaffoldFileTree(
355
+ { ...MINIMAL_CONFIG, id: 'all-skills', skillsFilter: 'all' },
356
+ tempDir,
357
+ );
358
+ expect(result.success).toBe(true);
359
+
360
+ const skillDirs = readdirSync(join(result.agentDir, 'skills'), { withFileTypes: true })
361
+ .filter((d) => d.isDirectory())
362
+ .map((d) => d.name);
363
+
364
+ // Should have all 30+ skills
365
+ expect(skillDirs.length).toBeGreaterThanOrEqual(25);
366
+ expect(skillDirs).toContain('brainstorming');
367
+ expect(skillDirs).toContain('deep-review');
368
+ expect(skillDirs).toContain('yolo-mode');
369
+ });
370
+
371
+ it('skillsFilter: explicit array creates exactly those skills', () => {
372
+ const result = scaffoldFileTree(
373
+ { ...MINIMAL_CONFIG, id: 'custom-skills', skillsFilter: ['vault-navigator', 'agent-guide'] },
374
+ tempDir,
375
+ );
376
+ expect(result.success).toBe(true);
377
+
378
+ const skillDirs = readdirSync(join(result.agentDir, 'skills'), { withFileTypes: true })
379
+ .filter((d) => d.isDirectory())
380
+ .map((d) => d.name);
381
+
382
+ expect(skillDirs).toEqual(['agent-guide', 'vault-navigator']);
383
+ });
384
+
385
+ it('CLAUDE.md only lists on-disk skills', () => {
386
+ // Default scaffold = essential only
387
+ const result = scaffoldFileTree({ ...MINIMAL_CONFIG, id: 'claude-md-skills' }, tempDir);
388
+ expect(result.success).toBe(true);
389
+
390
+ const claudeMd = readFileSync(join(result.agentDir, 'CLAUDE.md'), 'utf-8');
391
+
392
+ // Essential skills should appear
393
+ expect(claudeMd).toContain('vault-navigator');
394
+ expect(claudeMd).toContain('agent-guide');
395
+
396
+ // Optional skills should NOT appear (not on disk)
397
+ expect(claudeMd).not.toContain('brainstorming');
398
+ expect(claudeMd).not.toContain('yolo-mode');
399
+ });
400
+
401
+ it('skillsFilter default (essential) is not written to agent.yaml', () => {
402
+ const result = scaffoldFileTree(MINIMAL_CONFIG, tempDir);
403
+ expect(result.success).toBe(true);
404
+
405
+ const content = readFileSync(join(result.agentDir, 'agent.yaml'), 'utf-8');
406
+ expect(content).not.toContain('skillsFilter');
407
+ });
408
+
409
+ it('skillsFilter: "all" IS written to agent.yaml', () => {
410
+ const result = scaffoldFileTree(
411
+ { ...MINIMAL_CONFIG, id: 'written-filter', skillsFilter: 'all' },
412
+ tempDir,
413
+ );
414
+ expect(result.success).toBe(true);
415
+
416
+ const content = readFileSync(join(result.agentDir, 'agent.yaml'), 'utf-8');
417
+ const parsed = parseYaml(content);
418
+ expect(parsed.skillsFilter).toBe('all');
419
+ });
420
+
421
+ // ─── Workspace & Routing Tests ─────────────────────────────
422
+
423
+ it('creates workspace directories with CONTEXT.md when workspaces defined', () => {
424
+ const result = scaffoldFileTree(
425
+ {
426
+ ...MINIMAL_CONFIG,
427
+ workspaces: [
428
+ { id: 'design', name: 'Design', description: 'Design workspace' },
429
+ { id: 'review', name: 'Review', description: 'Review workspace' },
430
+ ],
431
+ },
432
+ tempDir,
433
+ );
434
+ expect(result.success).toBe(true);
435
+
436
+ // Workspace directories and CONTEXT.md files exist
437
+ expect(existsSync(join(result.agentDir, 'workspaces', 'design', 'CONTEXT.md'))).toBe(true);
438
+ expect(existsSync(join(result.agentDir, 'workspaces', 'review', 'CONTEXT.md'))).toBe(true);
439
+
440
+ // CONTEXT.md contains workspace name and description
441
+ const content = readFileSync(
442
+ join(result.agentDir, 'workspaces', 'design', 'CONTEXT.md'),
443
+ 'utf-8',
444
+ );
445
+ expect(content).toContain('# Design');
446
+ expect(content).toContain('Design workspace');
447
+ });
448
+
449
+ it('seeds default workspaces from domains when no explicit workspaces', () => {
450
+ const result = scaffoldFileTree(
451
+ {
452
+ ...MINIMAL_CONFIG,
453
+ domains: ['architecture'],
454
+ },
455
+ tempDir,
456
+ );
457
+ expect(result.success).toBe(true);
458
+
459
+ // Architecture domain seeds planning, src, docs workspaces
460
+ expect(existsSync(join(result.agentDir, 'workspaces', 'planning', 'CONTEXT.md'))).toBe(true);
461
+ expect(existsSync(join(result.agentDir, 'workspaces', 'src', 'CONTEXT.md'))).toBe(true);
462
+ expect(existsSync(join(result.agentDir, 'workspaces', 'docs', 'CONTEXT.md'))).toBe(true);
463
+ });
464
+
465
+ it('includes routing entries in agent.yaml', () => {
466
+ const result = scaffoldFileTree(
467
+ {
468
+ ...MINIMAL_CONFIG,
469
+ workspaces: [{ id: 'src', name: 'Source', description: 'Source code' }],
470
+ routing: [{ pattern: 'implement feature', workspace: 'src', skills: ['tdd'] }],
471
+ },
472
+ tempDir,
473
+ );
474
+ expect(result.success).toBe(true);
475
+
476
+ const content = readFileSync(join(result.agentDir, 'agent.yaml'), 'utf-8');
477
+ const parsed = parseYaml(content);
478
+
479
+ expect(parsed.workspaces).toHaveLength(1);
480
+ expect(parsed.workspaces[0].id).toBe('src');
481
+ expect(parsed.routing).toHaveLength(1);
482
+ expect(parsed.routing[0].pattern).toBe('implement feature');
483
+ expect(parsed.routing[0].workspace).toBe('src');
484
+ expect(parsed.routing[0].skills).toEqual(['tdd']);
485
+ });
486
+
487
+ it('creates no workspaces directory when no workspaces and no matching domains', () => {
488
+ const result = scaffoldFileTree(
489
+ {
490
+ ...MINIMAL_CONFIG,
491
+ domains: ['testing', 'quality'], // no workspace seeds for these
492
+ },
493
+ tempDir,
494
+ );
495
+ expect(result.success).toBe(true);
496
+
497
+ // No workspaces directory
498
+ expect(existsSync(join(result.agentDir, 'workspaces'))).toBe(false);
499
+ });
500
+
501
+ it('includes workspaces and routing sections in CLAUDE.md when defined', () => {
502
+ const result = scaffoldFileTree(
503
+ {
504
+ ...MINIMAL_CONFIG,
505
+ workspaces: [{ id: 'design', name: 'Design', description: 'Design patterns' }],
506
+ routing: [
507
+ { pattern: 'design component', workspace: 'design', skills: ['vault-navigator'] },
508
+ ],
509
+ },
510
+ tempDir,
511
+ );
512
+ expect(result.success).toBe(true);
513
+
514
+ const claudeMd = readFileSync(join(result.agentDir, 'CLAUDE.md'), 'utf-8');
515
+ expect(claudeMd).toContain('## Workspaces');
516
+ expect(claudeMd).toContain('Design patterns');
517
+ expect(claudeMd).toContain('## Task Routing');
518
+ expect(claudeMd).toContain('design component');
519
+ });
520
+
521
+ it('omits workspaces and routing sections from CLAUDE.md when not defined', () => {
522
+ // Use domains with no workspace seeds
523
+ const result = scaffoldFileTree(
524
+ {
525
+ ...MINIMAL_CONFIG,
526
+ domains: ['testing', 'quality'],
527
+ },
528
+ tempDir,
529
+ );
530
+ expect(result.success).toBe(true);
531
+
532
+ const claudeMd = readFileSync(join(result.agentDir, 'CLAUDE.md'), 'utf-8');
533
+ expect(claudeMd).not.toContain('## Workspaces');
534
+ expect(claudeMd).not.toContain('## Task Routing');
535
+ });
255
536
  });
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getEngineRulesContent, getEngineMarker } from '../templates/shared-rules.js';
3
+
4
+ describe('shared-rules', () => {
5
+ const content = getEngineRulesContent();
6
+
7
+ it('includes the engine marker', () => {
8
+ expect(content).toContain(`<!-- ${getEngineMarker()} -->`);
9
+ });
10
+
11
+ describe('Reconciliation Triggers', () => {
12
+ it('includes the Reconciliation Triggers section', () => {
13
+ expect(content).toContain('### Reconciliation Triggers');
14
+ });
15
+
16
+ it('includes the explicit trigger (user says "done")', () => {
17
+ expect(content).toContain('**Explicit**');
18
+ expect(content).toMatch(/User says.*done.*ship it.*looks good/);
19
+ });
20
+
21
+ it('includes the plan-complete trigger', () => {
22
+ expect(content).toContain('**Plan-complete**');
23
+ expect(content).toContain(
24
+ 'All tasks are complete. Want me to wrap up and capture what we learned, or is there more to fix?',
25
+ );
26
+ });
27
+
28
+ it('includes the idle trigger', () => {
29
+ expect(content).toContain('**Idle**');
30
+ expect(content).toContain(
31
+ "We've been idle on this plan. Ready to wrap up, or still working?",
32
+ );
33
+ });
34
+
35
+ it('includes the NEVER auto-complete rule', () => {
36
+ expect(content).toContain('**NEVER auto-complete without asking the user.**');
37
+ });
38
+
39
+ it('references orchestrate_status readiness field', () => {
40
+ expect(content).toContain('op:orchestrate_status');
41
+ expect(content).toContain('allTasksTerminal');
42
+ });
43
+ });
44
+
45
+ it('describes orchestrate_complete as user-gated in the Non-Negotiable Rule', () => {
46
+ expect(content).toContain('user-gated');
47
+ });
48
+ });
@@ -145,6 +145,32 @@ export const WorkflowDefinitionSchema = z.object({
145
145
  verificationCriteria: z.array(z.string()).optional().default([]),
146
146
  });
147
147
 
148
+ // ─── Workspace & Routing Schemas ─────────────────────────────────────
149
+
150
+ /** Workspace definition — scoped context area within an agent */
151
+ export const WorkspaceSchema = z.object({
152
+ /** Unique workspace identifier (kebab-case) */
153
+ id: z.string().min(1),
154
+ /** Human-readable workspace name */
155
+ name: z.string().min(1),
156
+ /** What this workspace is for */
157
+ description: z.string().min(1),
158
+ /** Context file name within the workspace directory. Default: CONTEXT.md */
159
+ contextFile: z.string().optional().default('CONTEXT.md'),
160
+ });
161
+
162
+ /** Routing entry — maps task patterns to workspaces */
163
+ export const RoutingEntrySchema = z.object({
164
+ /** Task pattern that triggers this route (e.g., "write script", "review code") */
165
+ pattern: z.string().min(1),
166
+ /** Target workspace id */
167
+ workspace: z.string().min(1),
168
+ /** Extra context files to load for this route */
169
+ context: z.array(z.string()).optional().default([]),
170
+ /** Skills to activate for this route */
171
+ skills: z.array(z.string()).optional().default([]),
172
+ });
173
+
148
174
  // ─── Main Agent Schema ────────────────────────────────────────────────
149
175
 
150
176
  /**
@@ -187,13 +213,53 @@ export const AgentYamlSchema = z.object({
187
213
  /** LLM client integration settings */
188
214
  setup: SetupConfigSchema.optional().default({}),
189
215
 
216
+ // ─── Skills ─────────────────────────────────────
217
+ /**
218
+ * Controls which skills are scaffolded.
219
+ * - 'essential' (default): ~7 core skills for a lightweight start
220
+ * - 'all': scaffold all available skills (backward compat)
221
+ * - string[]: scaffold only the named skills
222
+ */
223
+ skillsFilter: z
224
+ .union([z.literal('all'), z.literal('essential'), z.array(z.string())])
225
+ .optional()
226
+ .default('essential'),
227
+
228
+ // ─── Workspaces & Routing ───────────────────────
229
+ /** Scoped context areas within the agent */
230
+ workspaces: z.array(WorkspaceSchema).optional(),
231
+ /** Task pattern → workspace routing table */
232
+ routing: z.array(RoutingEntrySchema).optional(),
233
+
190
234
  // ─── Domain Packs ──────────────────────────────
191
235
  /** npm domain packs with custom ops and knowledge */
192
236
  packs: z.array(DomainPackSchema).optional(),
237
+
238
+ // ─── Git Initialization ────────────────────────
239
+ /** Git initialization configuration. If omitted, git is not initialized. */
240
+ git: z
241
+ .object({
242
+ /** Whether to run git init in the scaffolded agent directory */
243
+ init: z.boolean(),
244
+ /** Optional remote repository configuration */
245
+ remote: z
246
+ .object({
247
+ /** How to set up the remote: 'gh' creates via GitHub CLI, 'manual' uses a provided URL */
248
+ type: z.enum(['gh', 'manual']),
249
+ /** Remote URL (required for 'manual', auto-generated for 'gh') */
250
+ url: z.string().optional(),
251
+ /** Repository visibility for 'gh' type. Default: 'private' */
252
+ visibility: z.enum(['public', 'private']).optional().default('private'),
253
+ })
254
+ .optional(),
255
+ })
256
+ .optional(),
193
257
  });
194
258
 
195
259
  export type AgentYaml = z.infer<typeof AgentYamlSchema>;
196
260
  export type AgentYamlInput = z.input<typeof AgentYamlSchema>;
261
+ export type Workspace = z.infer<typeof WorkspaceSchema>;
262
+ export type RoutingEntry = z.infer<typeof RoutingEntrySchema>;
197
263
  export type WorkflowDefinition = z.infer<typeof WorkflowDefinitionSchema>;
198
264
  export type WorkflowGate = z.infer<typeof WorkflowGateSchema>;
199
265
  export type WorkflowTaskTemplate = z.infer<typeof WorkflowTaskTemplateSchema>;
@@ -56,7 +56,26 @@ export function composeClaudeMd(agentDir: string, tools?: ToolEntry[]): Composed
56
56
  // 5. Essential tools table
57
57
  sections.push(composeToolsTable(agentYaml, tools));
58
58
 
59
- // 6. Engine rules NOT inlined (they are injected once into ~/.claude/CLAUDE.md
59
+ // 6. User custom instructions (instructions/user.md) priority placement
60
+ // This file is user-editable and appears BEFORE engine rules and other instructions.
61
+ const userMdPath = join(agentDir, 'instructions', 'user.md');
62
+ if (existsSync(userMdPath)) {
63
+ const userContent = readFileSync(userMdPath, 'utf-8').trim();
64
+ if (userContent) {
65
+ sections.push(userContent);
66
+ sources.push(userMdPath);
67
+ }
68
+ }
69
+
70
+ // 6b. Workspaces section (if defined)
71
+ const workspacesSection = composeWorkspacesSection(agentYaml);
72
+ if (workspacesSection) sections.push(workspacesSection);
73
+
74
+ // 6c. Routing table (if defined)
75
+ const routingSection = composeRoutingTable(agentYaml);
76
+ if (routingSection) sections.push(routingSection);
77
+
78
+ // 7. Engine rules — NOT inlined (they are injected once into ~/.claude/CLAUDE.md
60
79
  // or project CLAUDE.md via `soleri install`). Including them here would
61
80
  // triple-load the rules (~8k tokens duplicated per layer).
62
81
  // We emit a short reference so the agent knows rules exist.
@@ -72,11 +91,11 @@ export function composeClaudeMd(agentDir: string, tools?: ToolEntry[]): Composed
72
91
  sources.push(enginePath);
73
92
  }
74
93
 
75
- // 7. User instructions (instructions/*.md, excluding _engine.md)
94
+ // 8. User instructions (instructions/*.md, excluding _engine.md and user.md)
76
95
  const instructionsDir = join(agentDir, 'instructions');
77
96
  if (existsSync(instructionsDir)) {
78
97
  const files = readdirSync(instructionsDir)
79
- .filter((f) => f.endsWith('.md') && f !== '_engine.md')
98
+ .filter((f) => f.endsWith('.md') && f !== '_engine.md' && f !== 'user.md')
80
99
  .sort();
81
100
  for (const file of files) {
82
101
  const filePath = join(instructionsDir, file);
@@ -198,6 +217,47 @@ function composeToolsTable(agent: AgentYaml, tools?: ToolEntry[]): string {
198
217
  return lines.join('\n');
199
218
  }
200
219
 
220
+ function composeWorkspacesSection(agent: AgentYaml): string | null {
221
+ if (!agent.workspaces || agent.workspaces.length === 0) return null;
222
+
223
+ const lines: string[] = [
224
+ '## Workspaces',
225
+ '',
226
+ 'Scoped context areas — each workspace has its own CONTEXT.md with task-specific instructions.',
227
+ '',
228
+ '| Workspace | Description |',
229
+ '|-----------|-------------|',
230
+ ];
231
+
232
+ for (const ws of agent.workspaces) {
233
+ lines.push(`| \`${ws.id}\` | ${ws.description} |`);
234
+ }
235
+
236
+ return lines.join('\n');
237
+ }
238
+
239
+ function composeRoutingTable(agent: AgentYaml): string | null {
240
+ if (!agent.routing || agent.routing.length === 0) return null;
241
+
242
+ const lines: string[] = [
243
+ '## Task Routing',
244
+ '',
245
+ 'When a task matches a pattern below, navigate to the target workspace, load its CONTEXT.md, and activate the listed skills.',
246
+ 'If no pattern matches, use the default root context.',
247
+ '',
248
+ '| Task Pattern | Route To | Context | Skills |',
249
+ '|--------------|----------|---------|--------|',
250
+ ];
251
+
252
+ for (const route of agent.routing) {
253
+ const ctx = route.context.length > 0 ? route.context.join(', ') : '—';
254
+ const skills = route.skills.length > 0 ? route.skills.map((s) => `\`${s}\``).join(', ') : '—';
255
+ lines.push(`| ${route.pattern} | \`${route.workspace}\` | ${ctx} | ${skills} |`);
256
+ }
257
+
258
+ return lines.join('\n');
259
+ }
260
+
201
261
  function composeWorkflowIndex(workflowsDir: string): string | null {
202
262
  const dirs = readdirSync(workflowsDir, { withFileTypes: true })
203
263
  .filter((d) => d.isDirectory())
@@ -8,6 +8,7 @@
8
8
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
  import { execFileSync } from 'node:child_process';
11
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
11
12
  import { generateDomainFacade } from './templates/domain-facade.js';
12
13
  import { generateVaultOnlyDomainFacade } from './knowledge-installer.js';
13
14
  import { patchIndexTs, patchClaudeMdContent } from './patching.js';
@@ -17,6 +18,7 @@ interface AddDomainParams {
17
18
  agentPath: string;
18
19
  domain: string;
19
20
  noBuild?: boolean;
21
+ format?: 'filetree' | 'typescript';
20
22
  }
21
23
 
22
24
  /**
@@ -41,9 +43,17 @@ function isV5Agent(agentPath: string): boolean {
41
43
  * 6. Rebuild (unless noBuild)
42
44
  */
43
45
  export async function addDomain(params: AddDomainParams): Promise<AddDomainResult> {
44
- const { agentPath, domain, noBuild = false } = params;
46
+ const { agentPath, domain, noBuild = false, format } = params;
45
47
  const warnings: string[] = [];
46
48
 
49
+ // ── File-tree agent path ──
50
+
51
+ if (format === 'filetree') {
52
+ return addDomainFileTree(agentPath, domain);
53
+ }
54
+
55
+ // ── TypeScript agent path (default / backward compat) ──
56
+
47
57
  // ── Validate agent ──
48
58
 
49
59
  const pkgPath = join(agentPath, 'package.json');
@@ -174,6 +184,69 @@ export async function addDomain(params: AddDomainParams): Promise<AddDomainResul
174
184
  };
175
185
  }
176
186
 
187
+ /**
188
+ * Add a domain to a file-tree agent (agent.yaml + knowledge/).
189
+ * No facade generation, no src/ patching, no build step.
190
+ */
191
+ function addDomainFileTree(agentPath: string, domain: string): AddDomainResult {
192
+ // ── Validate domain name ──
193
+
194
+ if (!/^[a-z][a-z0-9-]*$/.test(domain)) {
195
+ return fail(agentPath, domain, `Invalid domain name "${domain}" — must be kebab-case`);
196
+ }
197
+
198
+ // ── Read and validate agent.yaml ──
199
+
200
+ const yamlPath = join(agentPath, 'agent.yaml');
201
+ if (!existsSync(yamlPath)) {
202
+ return fail(agentPath, domain, 'No agent.yaml found — is this a file-tree agent?');
203
+ }
204
+
205
+ let agentYaml: Record<string, unknown>;
206
+ try {
207
+ agentYaml = parseYaml(readFileSync(yamlPath, 'utf-8')) as Record<string, unknown>;
208
+ } catch {
209
+ return fail(agentPath, domain, 'Failed to parse agent.yaml — is it valid YAML?');
210
+ }
211
+
212
+ const agentId = (agentYaml.id as string) ?? '';
213
+ if (!agentId) {
214
+ return fail(agentPath, domain, 'agent.yaml is missing an "id" field');
215
+ }
216
+
217
+ // ── Check if domain already exists ──
218
+
219
+ const domains: string[] = Array.isArray(agentYaml.domains) ? (agentYaml.domains as string[]) : [];
220
+ if (domains.includes(domain)) {
221
+ return fail(agentPath, domain, `Domain "${domain}" already exists in agent.yaml`);
222
+ }
223
+
224
+ // ── Update agent.yaml domains array ──
225
+
226
+ agentYaml.domains = [...domains, domain];
227
+ writeFileSync(yamlPath, stringifyYaml(agentYaml), 'utf-8');
228
+
229
+ // ── Create knowledge/{domain}.json ──
230
+
231
+ const knowledgeDir = join(agentPath, 'knowledge');
232
+ mkdirSync(knowledgeDir, { recursive: true });
233
+
234
+ const bundlePath = join(knowledgeDir, `${domain}.json`);
235
+ const emptyBundle = JSON.stringify({ domain, entries: [] }, null, 2);
236
+ writeFileSync(bundlePath, emptyBundle, 'utf-8');
237
+
238
+ return {
239
+ success: true,
240
+ agentPath,
241
+ domain,
242
+ agentId,
243
+ facadeGenerated: false,
244
+ buildOutput: '',
245
+ warnings: [],
246
+ summary: `Added domain "${domain}" to ${agentId} (file-tree agent)`,
247
+ };
248
+ }
249
+
177
250
  function fail(agentPath: string, domain: string, message: string): AddDomainResult {
178
251
  return {
179
252
  success: false,