@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.
@@ -154,16 +154,25 @@ interface InstallKnowledgeParams {
154
154
  agentPath: string;
155
155
  bundlePath: string;
156
156
  generateFacades?: boolean;
157
+ /** Agent format: 'filetree' skips package.json check and src/ patching */
158
+ format?: 'filetree' | 'typescript';
157
159
  }
158
160
 
159
161
  export async function installKnowledge(
160
162
  params: InstallKnowledgeParams,
161
163
  ): Promise<InstallKnowledgeResult> {
162
- const { agentPath, bundlePath, generateFacades = true } = params;
164
+ const { agentPath, bundlePath, generateFacades = true, format } = params;
163
165
  const warnings: string[] = [];
164
166
  const facadesGenerated: string[] = [];
165
167
  const sourceFilesPatched: string[] = [];
166
168
 
169
+ // ── File-tree agent path ─────────────────────────────────────────
170
+ if (format === 'filetree') {
171
+ return installKnowledgeFiletree(agentPath, bundlePath);
172
+ }
173
+
174
+ // ── TypeScript agent path (existing behavior) ────────────────────
175
+
167
176
  // ── Step 1: Validate agent path ──────────────────────────────────
168
177
 
169
178
  const pkgPath = join(agentPath, 'package.json');
@@ -363,6 +372,120 @@ export async function installKnowledge(
363
372
  };
364
373
  }
365
374
 
375
+ // ---------- File-tree agent installer ----------
376
+
377
+ /**
378
+ * Install knowledge bundles into a file-tree agent.
379
+ * Writes to {agentPath}/knowledge/ — no package.json, no src/ patching, no build step.
380
+ * The engine picks up knowledge bundles from this directory at runtime.
381
+ */
382
+ async function installKnowledgeFiletree(
383
+ agentPath: string,
384
+ bundlePath: string,
385
+ ): Promise<InstallKnowledgeResult> {
386
+ const warnings: string[] = [];
387
+
388
+ // Derive agentId from agent.yaml
389
+ let agentId = '';
390
+ const yamlPath = join(agentPath, 'agent.yaml');
391
+ if (existsSync(yamlPath)) {
392
+ try {
393
+ const raw = readFileSync(yamlPath, 'utf-8');
394
+ // Simple extraction — avoid importing yaml parser here
395
+ const idMatch = raw.match(/^id:\s*["']?([^\s"']+)/m);
396
+ if (idMatch) agentId = idMatch[1];
397
+ } catch {
398
+ // best-effort
399
+ }
400
+ }
401
+ if (!agentId) {
402
+ return fail(agentPath, '', 'No agent.yaml with valid id found — is this a file-tree agent?');
403
+ }
404
+
405
+ // Read and validate bundles
406
+ const bundleFiles = collectBundleFiles(bundlePath);
407
+ if (bundleFiles.length === 0) {
408
+ return fail(agentPath, agentId, `No .json bundle files found at ${bundlePath}`);
409
+ }
410
+
411
+ const bundles: Array<{ file: string; bundle: Bundle }> = [];
412
+ const issues: string[] = [];
413
+
414
+ for (const file of bundleFiles) {
415
+ try {
416
+ const raw = readFileSync(file, 'utf-8');
417
+ const parsed = JSON.parse(raw) as Bundle;
418
+ const fileIssues = validateBundle(parsed, file);
419
+ if (fileIssues.length > 0) {
420
+ issues.push(...fileIssues);
421
+ } else {
422
+ bundles.push({ file, bundle: parsed });
423
+ }
424
+ } catch (err) {
425
+ issues.push(
426
+ `${basename(file)}: invalid JSON — ${err instanceof Error ? err.message : String(err)}`,
427
+ );
428
+ }
429
+ }
430
+
431
+ if (bundles.length === 0) {
432
+ return fail(agentPath, agentId, `All bundles failed validation:\n${issues.join('\n')}`);
433
+ }
434
+
435
+ if (issues.length > 0) {
436
+ warnings.push(...issues);
437
+ }
438
+
439
+ // Determine new vs existing domains
440
+ const knowledgeDir = join(agentPath, 'knowledge');
441
+ mkdirSync(knowledgeDir, { recursive: true });
442
+
443
+ const existingFiles = readdirSync(knowledgeDir).filter((f) => f.endsWith('.json'));
444
+ const existingDomains = new Set(existingFiles.map((f) => f.replace(/\.json$/, '')));
445
+ const domainsAdded: string[] = [];
446
+ const domainsUpdated: string[] = [];
447
+
448
+ for (const { bundle } of bundles) {
449
+ if (existingDomains.has(bundle.domain)) {
450
+ domainsUpdated.push(bundle.domain);
451
+ } else {
452
+ domainsAdded.push(bundle.domain);
453
+ }
454
+ }
455
+
456
+ // Copy bundles to knowledge/
457
+ for (const { file, bundle } of bundles) {
458
+ const dest = join(knowledgeDir, `${bundle.domain}.json`);
459
+ copyFileSync(file, dest);
460
+ }
461
+
462
+ // No facade generation, no src/ patching, no build step for file-tree agents
463
+
464
+ const entriesTotal = bundles.reduce((sum, { bundle }) => sum + bundle.entries.length, 0);
465
+
466
+ const summaryParts = [
467
+ `Installed ${bundles.length} bundle(s) with ${entriesTotal} entries into ${agentId} (file-tree)`,
468
+ ];
469
+ if (domainsAdded.length > 0) summaryParts.push(`New domains: ${domainsAdded.join(', ')}`);
470
+ if (domainsUpdated.length > 0) summaryParts.push(`Updated domains: ${domainsUpdated.join(', ')}`);
471
+ if (warnings.length > 0) summaryParts.push(`${warnings.length} warning(s)`);
472
+
473
+ return {
474
+ success: true,
475
+ agentPath,
476
+ agentId,
477
+ bundlesInstalled: bundles.length,
478
+ entriesTotal,
479
+ domainsAdded,
480
+ domainsUpdated,
481
+ facadesGenerated: [],
482
+ sourceFilesPatched: [],
483
+ buildOutput: '',
484
+ warnings,
485
+ summary: summaryParts.join('. ') + '.',
486
+ };
487
+ }
488
+
366
489
  // ---------- Helpers ----------
367
490
 
368
491
  function fail(agentPath: string, agentId: string, message: string): InstallKnowledgeResult {
package/src/lib.ts CHANGED
@@ -21,7 +21,12 @@ export type {
21
21
  export { AgentConfigSchema, SETUP_TARGETS, MODEL_PRESETS } from './types.js';
22
22
 
23
23
  // ─── v7 File-Tree Agent ──────────────────────────────────────────────
24
- export { scaffoldFileTree } from './scaffold-filetree.js';
24
+ export {
25
+ scaffoldFileTree,
26
+ SKILLS_REGISTRY,
27
+ ESSENTIAL_SKILLS,
28
+ resolveSkillsFilter,
29
+ } from './scaffold-filetree.js';
25
30
  export type { FileTreeScaffoldResult } from './scaffold-filetree.js';
26
31
  export { AgentYamlSchema, TONES } from './agent-schema.js';
27
32
  export type { AgentYaml, AgentYamlInput } from './agent-schema.js';
@@ -18,6 +18,64 @@ import { composeClaudeMd } from './compose-claude-md.js';
18
18
  import { generateSkills } from './templates/skills.js';
19
19
  import type { AgentConfig } from './types.js';
20
20
 
21
+ // ─── Skills Registry ─────────────────────────────────────────────────
22
+
23
+ /**
24
+ * Skills classified as essential (always scaffolded by default) or optional
25
+ * (installed on demand via `soleri skills install`).
26
+ */
27
+ export const SKILLS_REGISTRY: Record<string, 'essential' | 'optional'> = {
28
+ 'agent-guide': 'essential',
29
+ 'agent-persona': 'essential',
30
+ 'vault-navigator': 'essential',
31
+ 'vault-capture': 'essential',
32
+ 'systematic-debugging': 'essential',
33
+ 'writing-plans': 'essential',
34
+ 'context-resume': 'essential',
35
+ // ─── Optional (installed on demand) ────────────
36
+ 'agent-dev': 'optional',
37
+ 'agent-issues': 'optional',
38
+ 'brain-debrief': 'optional',
39
+ brainstorming: 'optional',
40
+ 'code-patrol': 'optional',
41
+ 'deep-review': 'optional',
42
+ 'deliver-and-ship': 'optional',
43
+ 'discovery-phase': 'optional',
44
+ 'env-setup': 'optional',
45
+ 'executing-plans': 'optional',
46
+ 'finishing-a-development-branch': 'optional',
47
+ 'fix-and-learn': 'optional',
48
+ 'health-check': 'optional',
49
+ 'knowledge-harvest': 'optional',
50
+ 'mcp-doctor': 'optional',
51
+ 'onboard-me': 'optional',
52
+ 'parallel-execute': 'optional',
53
+ retrospective: 'optional',
54
+ 'second-opinion': 'optional',
55
+ 'subagent-driven-development': 'optional',
56
+ 'test-driven-development': 'optional',
57
+ 'using-git-worktrees': 'optional',
58
+ 'vault-curate': 'optional',
59
+ 'vault-smells': 'optional',
60
+ 'verification-before-completion': 'optional',
61
+ 'yolo-mode': 'optional',
62
+ };
63
+
64
+ /** Names of essential skills (always scaffolded when skillsFilter is 'essential'). */
65
+ export const ESSENTIAL_SKILLS = Object.entries(SKILLS_REGISTRY)
66
+ .filter(([, tier]) => tier === 'essential')
67
+ .map(([name]) => name);
68
+
69
+ /**
70
+ * Resolve the skill names to scaffold based on the skillsFilter config value.
71
+ * Returns null when all skills should be included (no filtering).
72
+ */
73
+ export function resolveSkillsFilter(skillsFilter: 'all' | 'essential' | string[]): string[] | null {
74
+ if (skillsFilter === 'all') return null; // null = include all
75
+ if (skillsFilter === 'essential') return ESSENTIAL_SKILLS;
76
+ return skillsFilter; // explicit list
77
+ }
78
+
21
79
  // ─── Types ────────────────────────────────────────────────────────────
22
80
 
23
81
  export interface FileTreeScaffoldResult {
@@ -235,6 +293,164 @@ Before crossing a context window boundary — \`/clear\`, context compaction, or
235
293
  },
236
294
  ];
237
295
 
296
+ // ─── Example Instruction Files ───────────────────────────────────────
297
+
298
+ const INSTRUCTIONS_CONVENTIONS = `# Conventions
299
+
300
+ <!-- Customize this file with your project's naming conventions, coding standards, and rules. -->
301
+ <!-- This file is composed into CLAUDE.md automatically — your agent will follow these rules. -->
302
+
303
+ ## Naming Conventions
304
+
305
+ - Use \`kebab-case\` for file and directory names
306
+ - Use \`camelCase\` for variables and functions
307
+ - Use \`PascalCase\` for classes, types, and interfaces
308
+ - Prefix private helpers with \`_\` (e.g., \`_validateInput\`)
309
+
310
+ ## File Organization
311
+
312
+ - Source code goes in \`src/\`
313
+ - Tests live next to the code they test (\`*.test.ts\`)
314
+ - Shared utilities go in \`src/utils/\`
315
+ - Types and interfaces go in \`src/types/\`
316
+
317
+ ## Code Standards
318
+
319
+ - Every function must have a JSDoc comment explaining its purpose
320
+ - Prefer \`const\` over \`let\`; never use \`var\`
321
+ - Maximum file length: 300 lines — split if larger
322
+ - No default exports — use named exports only
323
+
324
+ ## What to Avoid
325
+
326
+ - Do not add new npm dependencies without approval
327
+ - Do not use \`any\` type — use \`unknown\` and narrow
328
+ - Do not commit commented-out code
329
+ - Do not use hardcoded values — extract to constants or config
330
+ `;
331
+
332
+ const INSTRUCTIONS_GETTING_STARTED = `# Getting Started with Instructions
333
+
334
+ This folder contains your agent's custom behavioral rules. Every \`.md\` file here
335
+ is automatically composed into \`CLAUDE.md\` when you run \`soleri dev\`.
336
+
337
+ ## How It Works
338
+
339
+ 1. Create a new \`.md\` file in this folder (e.g., \`api-guidelines.md\`)
340
+ 2. Write your rules, conventions, or guidelines in Markdown
341
+ 3. Run \`soleri dev\` — it watches for changes and regenerates \`CLAUDE.md\`
342
+ 4. Your agent now follows these rules in every conversation
343
+
344
+ ## File Naming
345
+
346
+ - Files are included in **alphabetical order** (prefix with numbers to control order)
347
+ - \`_engine.md\` is auto-generated by Soleri — **do not edit it manually**
348
+ - \`domain.md\` was generated from your agent's domain config
349
+
350
+ ## Tips
351
+
352
+ - Keep each file focused on one topic (conventions, workflows, constraints)
353
+ - Use clear headings — your agent reads these as instructions
354
+ - Add "What to Avoid" sections — agents benefit from explicit anti-patterns
355
+ - See the [Soleri docs](https://soleri.ai/docs) for more examples
356
+ `;
357
+
358
+ // ─── Workspace & Routing Seeds ───────────────────────────────────────
359
+
360
+ /** Default workspaces seeded based on agent domains. */
361
+ const DOMAIN_WORKSPACE_SEEDS: Record<string, { id: string; name: string; description: string }[]> =
362
+ {
363
+ // Design-related domains
364
+ design: [
365
+ {
366
+ id: 'design',
367
+ name: 'Design',
368
+ description: 'Design system patterns, tokens, and components',
369
+ },
370
+ { id: 'review', name: 'Review', description: 'Design review and accessibility audits' },
371
+ ],
372
+ 'ui-design': [
373
+ { id: 'design', name: 'Design', description: 'UI design patterns, tokens, and components' },
374
+ { id: 'review', name: 'Review', description: 'Design review and accessibility audits' },
375
+ ],
376
+ accessibility: [
377
+ { id: 'design', name: 'Design', description: 'Accessible design patterns and tokens' },
378
+ { id: 'review', name: 'Review', description: 'Accessibility audits and compliance checks' },
379
+ ],
380
+ // Dev-related domains
381
+ architecture: [
382
+ {
383
+ id: 'planning',
384
+ name: 'Planning',
385
+ description: 'Architecture decisions and technical planning',
386
+ },
387
+ { id: 'src', name: 'Source', description: 'Implementation code and modules' },
388
+ { id: 'docs', name: 'Documentation', description: 'Technical documentation and ADRs' },
389
+ ],
390
+ backend: [
391
+ { id: 'planning', name: 'Planning', description: 'Backend architecture and API design' },
392
+ { id: 'src', name: 'Source', description: 'Implementation code and modules' },
393
+ { id: 'docs', name: 'Documentation', description: 'API documentation and guides' },
394
+ ],
395
+ frontend: [
396
+ {
397
+ id: 'planning',
398
+ name: 'Planning',
399
+ description: 'Frontend architecture and component design',
400
+ },
401
+ { id: 'src', name: 'Source', description: 'Implementation code and components' },
402
+ {
403
+ id: 'docs',
404
+ name: 'Documentation',
405
+ description: 'Component documentation and style guides',
406
+ },
407
+ ],
408
+ security: [
409
+ {
410
+ id: 'planning',
411
+ name: 'Planning',
412
+ description: 'Security architecture and threat modeling',
413
+ },
414
+ { id: 'src', name: 'Source', description: 'Security implementations and policies' },
415
+ { id: 'docs', name: 'Documentation', description: 'Security documentation and runbooks' },
416
+ ],
417
+ };
418
+
419
+ /** Default routing entries seeded based on agent domains. */
420
+ const DOMAIN_ROUTING_SEEDS: Record<
421
+ string,
422
+ { pattern: string; workspace: string; skills: string[] }[]
423
+ > = {
424
+ design: [
425
+ { pattern: 'design component', workspace: 'design', skills: ['vault-navigator'] },
426
+ { pattern: 'review design', workspace: 'review', skills: ['deep-review'] },
427
+ ],
428
+ 'ui-design': [
429
+ { pattern: 'design component', workspace: 'design', skills: ['vault-navigator'] },
430
+ { pattern: 'review design', workspace: 'review', skills: ['deep-review'] },
431
+ ],
432
+ architecture: [
433
+ { pattern: 'plan architecture', workspace: 'planning', skills: ['writing-plans'] },
434
+ { pattern: 'implement feature', workspace: 'src', skills: ['test-driven-development'] },
435
+ { pattern: 'write documentation', workspace: 'docs', skills: ['vault-capture'] },
436
+ ],
437
+ backend: [
438
+ { pattern: 'plan API', workspace: 'planning', skills: ['writing-plans'] },
439
+ { pattern: 'implement endpoint', workspace: 'src', skills: ['test-driven-development'] },
440
+ { pattern: 'write docs', workspace: 'docs', skills: ['vault-capture'] },
441
+ ],
442
+ frontend: [
443
+ { pattern: 'plan component', workspace: 'planning', skills: ['writing-plans'] },
444
+ { pattern: 'implement component', workspace: 'src', skills: ['test-driven-development'] },
445
+ { pattern: 'write docs', workspace: 'docs', skills: ['vault-capture'] },
446
+ ],
447
+ security: [
448
+ { pattern: 'threat model', workspace: 'planning', skills: ['writing-plans'] },
449
+ { pattern: 'implement policy', workspace: 'src', skills: ['test-driven-development'] },
450
+ { pattern: 'write runbook', workspace: 'docs', skills: ['vault-capture'] },
451
+ ],
452
+ };
453
+
238
454
  // ─── Main Scaffolder ──────────────────────────────────────────────────
239
455
 
240
456
  /**
@@ -324,6 +540,13 @@ export function scaffoldFileTree(input: AgentYamlInput, outputDir: string): File
324
540
  'AGENTS.md',
325
541
  'instructions/_engine.md',
326
542
  '',
543
+ '# OS',
544
+ '.DS_Store',
545
+ '',
546
+ '# Editor / IDE state',
547
+ '.obsidian/',
548
+ '.opencode/',
549
+ '',
327
550
  ].join('\n'),
328
551
  filesCreated,
329
552
  );
@@ -332,6 +555,24 @@ export function scaffoldFileTree(input: AgentYamlInput, outputDir: string): File
332
555
  writeFile(agentDir, 'instructions/_engine.md', getEngineRulesContent(), filesCreated);
333
556
 
334
557
  // ─── 6. Write user instruction files ────────────────────────
558
+ // Generate user.md — user-editable file with priority placement in CLAUDE.md
559
+ const userMdContent = [
560
+ '# Your Custom Rules',
561
+ '',
562
+ 'Add your agent-specific rules, constraints, and preferences here.',
563
+ 'This file gets priority placement in CLAUDE.md — it appears before engine rules.',
564
+ '',
565
+ '## Examples of what to put here:',
566
+ '- Project-specific conventions',
567
+ '- Communication preferences',
568
+ '- Domain expertise to emphasize',
569
+ '- Things to always/never do',
570
+ '',
571
+ 'Delete these instructions and replace with your own content.',
572
+ '',
573
+ ].join('\n');
574
+ writeFile(agentDir, 'instructions/user.md', userMdContent, filesCreated);
575
+
335
576
  // Generate domain-specific instruction file if agent has specialized domains
336
577
  if (config.domains.length > 0) {
337
578
  const domainLines = [
@@ -347,6 +588,15 @@ export function scaffoldFileTree(input: AgentYamlInput, outputDir: string): File
347
588
  writeFile(agentDir, 'instructions/domain.md', domainLines.join('\n'), filesCreated);
348
589
  }
349
590
 
591
+ // ─── 6b. Write example instruction files ─────────────────────
592
+ writeFile(agentDir, 'instructions/conventions.md', INSTRUCTIONS_CONVENTIONS, filesCreated);
593
+ writeFile(
594
+ agentDir,
595
+ 'instructions/getting-started.md',
596
+ INSTRUCTIONS_GETTING_STARTED,
597
+ filesCreated,
598
+ );
599
+
350
600
  // ─── 7. Write workflows ─────────────────────────────────────
351
601
  for (const wf of BUILTIN_WORKFLOWS) {
352
602
  writeFile(agentDir, `workflows/${wf.name}/prompt.md`, wf.prompt, filesCreated);
@@ -355,7 +605,11 @@ export function scaffoldFileTree(input: AgentYamlInput, outputDir: string): File
355
605
  }
356
606
 
357
607
  // ─── 8. Copy bundled skills (with placeholder substitution) ─
358
- const skills = generateSkills({ id: config.id } as AgentConfig);
608
+ const resolvedSkills = resolveSkillsFilter(config.skillsFilter);
609
+ const skills = generateSkills({
610
+ id: config.id,
611
+ skills: resolvedSkills ?? undefined,
612
+ } as AgentConfig);
359
613
  for (const [relativePath, content] of skills) {
360
614
  mkdirSync(join(agentDir, dirname(relativePath)), { recursive: true });
361
615
  writeFile(agentDir, relativePath, content, filesCreated);
@@ -381,7 +635,33 @@ export function scaffoldFileTree(input: AgentYamlInput, outputDir: string): File
381
635
  totalSeeded += starterEntries.length;
382
636
  }
383
637
 
384
- // ─── 9. Generate CLAUDE.md ──────────────────────────────────
638
+ // ─── 9b. Create workspace directories with CONTEXT.md ──────
639
+ // Resolve workspaces: use explicit config or seed from domains
640
+ const resolvedWorkspaces = resolveWorkspaces(config);
641
+ if (resolvedWorkspaces.length > 0) {
642
+ for (const ws of resolvedWorkspaces) {
643
+ const wsDir = join(agentDir, 'workspaces', ws.id);
644
+ mkdirSync(wsDir, { recursive: true });
645
+ const contextContent = [
646
+ `# ${ws.name}`,
647
+ '',
648
+ ws.description,
649
+ '',
650
+ '## Instructions',
651
+ '',
652
+ `<!-- Add workspace-specific instructions here for the "${ws.name}" context. -->`,
653
+ '',
654
+ ].join('\n');
655
+ writeFile(
656
+ agentDir,
657
+ `workspaces/${ws.id}/${ws.contextFile ?? 'CONTEXT.md'}`,
658
+ contextContent,
659
+ filesCreated,
660
+ );
661
+ }
662
+ }
663
+
664
+ // ─── 10. Generate CLAUDE.md ──────────────────────────────────
385
665
  const { content: claudeMd } = composeClaudeMd(agentDir);
386
666
  writeFile(agentDir, 'CLAUDE.md', claudeMd, filesCreated);
387
667
 
@@ -476,6 +756,34 @@ function buildAgentYaml(config: AgentYaml): Record<string, unknown> {
476
756
  setup.model = config.setup.model;
477
757
  if (Object.keys(setup).length > 0) yaml.setup = setup;
478
758
 
759
+ // Skills filter — only include if not the default ('essential')
760
+ if (config.skillsFilter && config.skillsFilter !== 'essential') {
761
+ yaml.skillsFilter = config.skillsFilter;
762
+ }
763
+
764
+ // Workspaces
765
+ const resolvedWs = resolveWorkspaces(config);
766
+ if (resolvedWs.length > 0) {
767
+ yaml.workspaces = resolvedWs.map((ws) =>
768
+ Object.assign(
769
+ { id: ws.id, name: ws.name, description: ws.description },
770
+ ws.contextFile !== `CONTEXT.md` ? { contextFile: ws.contextFile } : {},
771
+ ),
772
+ );
773
+ }
774
+
775
+ // Routing
776
+ const resolvedRouting = resolveRouting(config);
777
+ if (resolvedRouting.length > 0) {
778
+ yaml.routing = resolvedRouting.map((r) =>
779
+ Object.assign(
780
+ { pattern: r.pattern, workspace: r.workspace },
781
+ r.context.length > 0 ? { context: r.context } : {},
782
+ r.skills.length > 0 ? { skills: r.skills } : {},
783
+ ),
784
+ );
785
+ }
786
+
479
787
  // Packs
480
788
  if (config.packs && config.packs.length > 0) {
481
789
  yaml.packs = config.packs;
@@ -484,6 +792,76 @@ function buildAgentYaml(config: AgentYaml): Record<string, unknown> {
484
792
  return yaml;
485
793
  }
486
794
 
795
+ // ─── Workspace & Routing Helpers ─────────────────────────────────────
796
+
797
+ /**
798
+ * Resolve workspaces: use explicit config or seed from domains.
799
+ * Deduplicates by workspace id.
800
+ */
801
+ function resolveWorkspaces(
802
+ config: AgentYaml,
803
+ ): { id: string; name: string; description: string; contextFile: string }[] {
804
+ // If explicitly defined, use those
805
+ if (config.workspaces && config.workspaces.length > 0) {
806
+ return config.workspaces.map((ws) => ({
807
+ id: ws.id,
808
+ name: ws.name,
809
+ description: ws.description,
810
+ contextFile: ws.contextFile ?? 'CONTEXT.md',
811
+ }));
812
+ }
813
+
814
+ // Otherwise, seed from domains
815
+ const seen = new Set<string>();
816
+ const workspaces: { id: string; name: string; description: string; contextFile: string }[] = [];
817
+
818
+ for (const domain of config.domains) {
819
+ const seeds = DOMAIN_WORKSPACE_SEEDS[domain];
820
+ if (!seeds) continue;
821
+ for (const seed of seeds) {
822
+ if (seen.has(seed.id)) continue;
823
+ seen.add(seed.id);
824
+ workspaces.push({ ...seed, contextFile: 'CONTEXT.md' });
825
+ }
826
+ }
827
+
828
+ return workspaces;
829
+ }
830
+
831
+ /**
832
+ * Resolve routing entries: use explicit config or seed from domains.
833
+ * Deduplicates by pattern string.
834
+ */
835
+ function resolveRouting(
836
+ config: AgentYaml,
837
+ ): { pattern: string; workspace: string; context: string[]; skills: string[] }[] {
838
+ // If explicitly defined, use those
839
+ if (config.routing && config.routing.length > 0) {
840
+ return config.routing.map((r) => ({
841
+ pattern: r.pattern,
842
+ workspace: r.workspace,
843
+ context: r.context ?? [],
844
+ skills: r.skills ?? [],
845
+ }));
846
+ }
847
+
848
+ // Otherwise, seed from domains
849
+ const seen = new Set<string>();
850
+ const routes: { pattern: string; workspace: string; context: string[]; skills: string[] }[] = [];
851
+
852
+ for (const domain of config.domains) {
853
+ const seeds = DOMAIN_ROUTING_SEEDS[domain];
854
+ if (!seeds) continue;
855
+ for (const seed of seeds) {
856
+ if (seen.has(seed.pattern)) continue;
857
+ seen.add(seed.pattern);
858
+ routes.push({ ...seed, context: [] });
859
+ }
860
+ }
861
+
862
+ return routes;
863
+ }
864
+
487
865
  // ─── Starter Pack Helpers ────────────────────────────────────────────
488
866
 
489
867
  /** Domain aliases — map agent domains to starter pack directories. */
package/src/scaffolder.ts CHANGED
@@ -163,6 +163,13 @@ export function previewScaffold(config: AgentConfig): ScaffoldPreview {
163
163
  });
164
164
  }
165
165
 
166
+ if (opencodeSetup && config.hookPacks?.length) {
167
+ files.push({
168
+ path: '.opencode/plugins/',
169
+ description: `OpenCode enforcement plugin (${config.hookPacks.join(', ')})`,
170
+ });
171
+ }
172
+
166
173
  if (config.telegram) {
167
174
  files.push(
168
175
  {
@@ -334,6 +341,10 @@ export function scaffold(config: AgentConfig): ScaffoldResult {
334
341
  dirs.push('.claude');
335
342
  }
336
343
 
344
+ if (opencodeSetup && config.hookPacks?.length) {
345
+ dirs.push('.opencode/plugins');
346
+ }
347
+
337
348
  for (const dir of dirs) {
338
349
  mkdirSync(join(agentDir, dir), { recursive: true });
339
350
  }
@@ -570,6 +581,12 @@ export function scaffold(config: AgentConfig): ScaffoldResult {
570
581
  summaryLines.push(`${config.hookPacks.length} hook pack(s) bundled in .claude/`);
571
582
  }
572
583
 
584
+ if (opencodeSetup && config.hookPacks?.length) {
585
+ summaryLines.push(
586
+ `${config.hookPacks.length} hook pack(s) bundled as OpenCode plugin in .opencode/plugins/`,
587
+ );
588
+ }
589
+
573
590
  for (const registration of mcpRegistrations) {
574
591
  if (registration.result.registered) {
575
592
  summaryLines.push(