@polymorphism-tech/morph-spec 4.10.1 → 4.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > Spec-driven development framework for multi-stack projects. Turns feature requests into implementation-ready code through structured, AI-orchestrated phases.
4
4
 
5
5
  **Package:** `@polymorphism-tech/morph-spec`
6
- **Version:** 4.10.1
6
+ **Version:** 4.10.3
7
7
  **Requires:** Node.js 18+, Claude Code
8
8
 
9
9
  ---
@@ -376,4 +376,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
376
376
 
377
377
  ---
378
378
 
379
- *morph-spec v4.10.1 by [Polymorphism Tech](https://polymorphism.tech)*
379
+ *morph-spec v4.10.3 by [Polymorphism Tech](https://polymorphism.tech)*
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "4.10.1",
3
+ "version": "4.10.3",
4
4
  "displayName": "MORPH-SPEC Framework",
5
5
  "description": "Spec-driven development with 38 agents and 8-phase workflow for .NET/Blazor/Next.js/Azure",
6
6
  "publisher": "polymorphism-tech",
@@ -198,4 +198,4 @@ These files are never edited directly. Use CLI commands or `morph-spec update` i
198
198
 
199
199
  ---
200
200
 
201
- *morph-spec v4.10.1 by Polymorphism Tech*
201
+ *morph-spec v4.10.3 by Polymorphism Tech*
@@ -203,4 +203,4 @@ morph-spec doctor
203
203
 
204
204
  ---
205
205
 
206
- *morph-spec v4.10.1 by Polymorphism Tech*
206
+ *morph-spec v4.10.3 by Polymorphism Tech*
@@ -178,6 +178,7 @@
178
178
  "tier": 2,
179
179
  "role": "domain-leader",
180
180
  "title": "Backend Squad Leader",
181
+ "stacks": ["dotnet", "blazor"],
181
182
  "domains": [
182
183
  "backend"
183
184
  ],
@@ -249,6 +250,7 @@
249
250
  "tier": 2,
250
251
  "role": "domain-leader",
251
252
  "title": "Infrastructure Squad Leader",
253
+ "stacks": ["azure", "docker"],
252
254
  "domains": [
253
255
  "infrastructure"
254
256
  ],
@@ -2322,6 +2324,7 @@
2322
2324
  "tier": 4,
2323
2325
  "role": "validator",
2324
2326
  "title": "Blazor Concurrency Validator",
2327
+ "stacks": ["blazor"],
2325
2328
  "domains": [
2326
2329
  "blazor-concurrency"
2327
2330
  ],
@@ -2428,6 +2431,7 @@
2428
2431
  },
2429
2432
  "nextjs-component-validator": {
2430
2433
  "tier": 4,
2434
+ "stacks": ["nextjs"],
2431
2435
  "title": "Next.js Component Validator",
2432
2436
  "description": "Tier-4 validator for Next.js Server/Client component patterns",
2433
2437
  "keywords": [
@@ -4,7 +4,7 @@ description: MORPH-SPEC Phase 3 (Clarify). Iterative clarification loop driven b
4
4
  argument-hint: "[feature-name]"
5
5
  user-invocable: false
6
6
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion
7
- cliVersion: "4.10.1"
7
+ cliVersion: "4.10.3"
8
8
  ---
9
9
 
10
10
  # MORPH Clarify — Phase 3
@@ -3,7 +3,7 @@ name: morph:phase-codebase-analysis
3
3
  description: MORPH-SPEC Design sub-phase that analyzes existing codebase and database schema, producing schema-analysis.md with real column names, types, relationships, and field mismatches. Use at the start of Design phase before generating contracts.cs to prevent incorrect field names or types.
4
4
  user-invocable: false
5
5
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion
6
- cliVersion: "4.10.1"
6
+ cliVersion: "4.10.3"
7
7
  ---
8
8
 
9
9
  # MORPH Codebase Analysis - Design Sub-phase
@@ -4,7 +4,7 @@ description: MORPH-SPEC Phase 2 (Design). Schema-first interactive design: reads
4
4
  argument-hint: "[feature-name]"
5
5
  user-invocable: false
6
6
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Agent
7
- cliVersion: "4.10.1"
7
+ cliVersion: "4.10.3"
8
8
  ---
9
9
 
10
10
  # MORPH Design — Phase 2
@@ -6,7 +6,7 @@ disable-model-invocation: true
6
6
  context: fork
7
7
  agent: general-purpose
8
8
  user-invocable: false
9
- cliVersion: "4.10.1"
9
+ cliVersion: "4.10.3"
10
10
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Agent
11
11
  ---
12
12
 
@@ -5,7 +5,7 @@ argument-hint: "[feature-name]"
5
5
  disable-model-invocation: true
6
6
  user-invocable: false
7
7
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion
8
- cliVersion: "4.10.1"
8
+ cliVersion: "4.10.3"
9
9
  ---
10
10
 
11
11
  # MORPH Plan — Phase 4
@@ -4,7 +4,7 @@ description: MORPH-SPEC Phase 1 (Setup). Reads project context, detects tech sta
4
4
  argument-hint: "[feature-name]"
5
5
  user-invocable: false
6
6
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion
7
- cliVersion: "4.10.1"
7
+ cliVersion: "4.10.3"
8
8
  ---
9
9
 
10
10
  # MORPH Setup — Phase 1
@@ -151,6 +151,26 @@ MCP Readiness:
151
151
 
152
152
  ---
153
153
 
154
+ ### Step 2.9: Check for Missing Agents
155
+
156
+ Run a quick check to verify all expected agents are installed for this project's stack:
157
+
158
+ ```bash
159
+ npx morph-spec doctor 2>&1 | grep -A 1 "agents completeness"
160
+ ```
161
+
162
+ If the output shows **missing agents**, inform the user:
163
+
164
+ > ⚠ Some agents for your stack are not installed. Run `npx morph-spec init --force` to install them.
165
+
166
+ Use `AskUserQuestion` with header `"Agents"` and options:
167
+ - **Install now** — run `npx morph-spec init --force` to reinstall with current stack tags
168
+ - **Continue without** — proceed with available agents (may lack specialized support)
169
+
170
+ If "Install now" is selected, run the command and continue.
171
+
172
+ ---
173
+
154
174
  ### Step 3: Confirm Stack
155
175
 
156
176
  Based on the proposal and context, confirm:
@@ -5,7 +5,7 @@ argument-hint: "[feature-name]"
5
5
  disable-model-invocation: true
6
6
  user-invocable: false
7
7
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion
8
- cliVersion: "4.10.1"
8
+ cliVersion: "4.10.3"
9
9
  ---
10
10
 
11
11
  # MORPH Tasks — Phase 4
@@ -4,7 +4,7 @@ description: MORPH-SPEC Phase 1.5 (UI/UX). Creates design-system.md, mockups.md,
4
4
  argument-hint: "[feature-name]"
5
5
  user-invocable: false
6
6
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion
7
- cliVersion: "4.10.1"
7
+ cliVersion: "4.10.3"
8
8
  ---
9
9
 
10
10
  # MORPH UI/UX Design - Phase 1.5
@@ -4,6 +4,7 @@ description: Guided workflow for mid-implementation scope escalation — analyze
4
4
  user-invocable: true
5
5
  argument-hint: "[feature-name]"
6
6
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion
7
+ cliVersion: "4.10.3"
7
8
  ---
8
9
 
9
10
  # Scope Escalation Workflow
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "4.10.1",
3
+ "version": "4.10.3",
4
4
  "description": "MORPH-SPEC: AI-First development framework with validation pipeline and multi-stack support",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -12,6 +12,8 @@ import {
12
12
  getInstalledCLIVersion
13
13
  } from '../../utils/version-checker.js';
14
14
  import { resetMorphSettings } from '../../utils/claude-settings-manager.js';
15
+ import { checkMissingAgents } from '../../utils/agents-installer.js';
16
+ import { getProjectTags } from '../../lib/stack-filter.js';
15
17
 
16
18
  const isWindows = platform() === 'win32';
17
19
  /**
@@ -687,11 +689,32 @@ export async function doctorCommand(options = {}) {
687
689
  }
688
690
 
689
691
  // Check .claude/agents/
690
- const agentsPath = join(claudePath, 'agents');
691
- if (await pathExists(agentsPath)) {
692
- const agentFiles = await fs.readdir(agentsPath);
692
+ const agentsPathClaude = join(claudePath, 'agents');
693
+ if (await pathExists(agentsPathClaude)) {
694
+ const agentFiles = await fs.readdir(agentsPathClaude);
693
695
  const morphAgents = agentFiles.filter(f => f.startsWith('morph-') && f.endsWith('.md'));
694
696
  checks.push({ name: '.claude/agents/', status: 'ok', msg: `${morphAgents.length} agents` });
697
+
698
+ // Check for missing agents based on stack tags
699
+ try {
700
+ const config = await readJson(configPath);
701
+ const projectTags = getProjectTags(config);
702
+ if (projectTags.length > 0) {
703
+ const frameworkDir = join(import.meta.dirname, '..', '..', '..', 'framework');
704
+ const { missing } = checkMissingAgents(targetPath, frameworkDir, projectTags);
705
+ if (missing.length > 0) {
706
+ const names = missing.map(m => m.name).join(', ');
707
+ checks.push({
708
+ name: 'agents completeness',
709
+ status: 'warn',
710
+ msg: `${missing.length} agent(s) missing for [${projectTags.join(', ')}]: ${names} — run morph-spec init --force`
711
+ });
712
+ hasWarnings = true;
713
+ } else {
714
+ checks.push({ name: 'agents completeness', status: 'ok', msg: `all agents for [${projectTags.join(', ')}]` });
715
+ }
716
+ }
717
+ } catch { /* config not readable — skip completeness check */ }
695
718
  } else {
696
719
  checks.push({ name: '.claude/agents/', status: 'warn', msg: 'missing — run morph-spec update' });
697
720
  hasWarnings = true;
@@ -1,4 +1,5 @@
1
- import { join } from 'path';
1
+ import { join, dirname } from 'path';
2
+ import { fileURLToPath } from 'url';
2
3
  import { homedir } from 'os';
3
4
  import fs from 'fs-extra';
4
5
  import ora from 'ora';
@@ -22,7 +23,10 @@ import {
22
23
  generateSetupInstructions,
23
24
  formatMcpStatusTable
24
25
  } from '../../lib/installers/mcp-installer.js';
25
- import { setupInfra } from '../../scripts/setup-infra.js';
26
+ import { setupInfra, installRulesForStack } from '../../scripts/setup-infra.js';
27
+ import { installSkills } from '../../utils/skills-installer.js';
28
+ import { installAgents, installDomainAgents } from '../../utils/agents-installer.js';
29
+ import { getProjectTags } from '../../lib/stack-filter.js';
26
30
 
27
31
  export async function initCommand(options) {
28
32
  const targetPath = options.path || process.cwd();
@@ -147,6 +151,9 @@ export async function initCommand(options) {
147
151
  if (structure?.uiLibrary) {
148
152
  config.project.uiLibrary = structure.uiLibrary;
149
153
  }
154
+ if (structure?.integrations?.length > 0) {
155
+ config.project.integrations = structure.integrations;
156
+ }
150
157
  await writeJson(configPath, config);
151
158
 
152
159
  spinner.stop();
@@ -155,6 +162,7 @@ export async function initCommand(options) {
155
162
  logger.info(`Stack detected: ${structure.stack}`);
156
163
  if (structure?.architecture) logger.dim(`Architecture: ${structure.architecture}`);
157
164
  if (structure?.uiLibrary) logger.dim(`UI Library: ${structure.uiLibrary}`);
165
+ if (structure?.integrations?.length > 0) logger.dim(`Integrations: ${structure.integrations.join(', ')}`);
158
166
 
159
167
  const { standardsChoice } = await inquirer.prompt([{
160
168
  type: 'list',
@@ -175,6 +183,28 @@ export async function initCommand(options) {
175
183
  logger.dim('Review and edit .morph/context/standards.md as needed.');
176
184
  }
177
185
 
186
+ // Re-install stack-filtered assets now that we know the stack
187
+ const stackTags = getProjectTags(config);
188
+ if (stackTags.length > 0) {
189
+ spinner.start('Re-installing stack-filtered assets...');
190
+ const frameworkDir = join(targetPath, '.morph', 'framework');
191
+ const fwSrcDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'framework');
192
+
193
+ // Rules
194
+ const rulesSrc = join(fwSrcDir, 'rules');
195
+ const rulesDest = join(targetPath, '.claude', 'rules');
196
+ installRulesForStack(rulesSrc, rulesDest, stackTags);
197
+
198
+ // Skills
199
+ await installSkills(targetPath, { projectTags: stackTags });
200
+
201
+ // Agents (tier 1/2/4 from agents.json + domain agents)
202
+ await installAgents(targetPath, fwSrcDir, { projectTags: stackTags });
203
+ await installDomainAgents(targetPath, fwSrcDir, { projectTags: stackTags });
204
+
205
+ spinner.succeed(`Stack-filtered assets reinstalled for [${stackTags.join(', ')}]`);
206
+ }
207
+
178
208
  spinner.start('Continuing...');
179
209
  }
180
210
  } catch (error) {
@@ -86,29 +86,39 @@ export function detectProject(targetPath, opts = {}) {
86
86
  }
87
87
 
88
88
  // ── .csproj detection ──────────────────────────────────────────────────
89
- if (structure.stack === 'unknown') {
90
- // Check for .csproj in common locations
91
- const csprojLocations = ['.', 'src', 'src/Api', 'src/Web'];
92
- for (const loc of csprojLocations) {
93
- try {
94
- const dir = join(targetPath, loc);
95
- if (existsSync(dir)) {
96
- const entries = readdirSyncSafe(dir);
97
- if (entries.some(e => e.endsWith('.csproj'))) {
98
- structure.stack = 'dotnet';
99
- // Check for Blazor
100
- for (const entry of entries.filter(e => e.endsWith('.csproj'))) {
101
- try {
102
- const content = readFileSync(join(dir, entry), 'utf8');
103
- if (content.includes('Microsoft.AspNetCore.Components') || content.includes('Blazor')) {
104
- structure.stack = 'blazor';
105
- }
106
- } catch { /* ignore */ }
89
+ let hasDotnet = false;
90
+ let hasBlazer = false;
91
+ const csprojLocations = ['.', 'src', 'src/Api', 'src/Web', 'src/backend'];
92
+ for (const loc of csprojLocations) {
93
+ try {
94
+ const dir = join(targetPath, loc);
95
+ if (existsSync(dir)) {
96
+ const entries = readdirSyncSafe(dir);
97
+ for (const entry of entries.filter(e => e.endsWith('.csproj'))) {
98
+ hasDotnet = true;
99
+ try {
100
+ const content = readFileSync(join(dir, entry), 'utf8');
101
+ if (content.includes('Microsoft.AspNetCore.Components') || content.includes('Blazor')) {
102
+ hasBlazer = true;
103
+ }
104
+ if (content.includes('Hangfire')) {
105
+ if (!structure.integrations.includes('hangfire')) structure.integrations.push('hangfire');
107
106
  }
108
- break;
109
- }
107
+ } catch { /* ignore */ }
110
108
  }
111
- } catch { /* ignore */ }
109
+ }
110
+ } catch { /* ignore */ }
111
+ }
112
+
113
+ // Determine stack: monorepo (nextjs+dotnet), blazor, dotnet, or keep frontend stack
114
+ if (hasDotnet) {
115
+ if (structure.stack === 'nextjs' || structure.stack === 'react' || structure.stack === 'vue') {
116
+ // Monorepo: frontend + .NET backend
117
+ structure.stack = `${structure.stack}+dotnet`;
118
+ } else if (hasBlazer) {
119
+ structure.stack = 'blazor';
120
+ } else if (structure.stack === 'unknown') {
121
+ structure.stack = 'dotnet';
112
122
  }
113
123
  }
114
124
 
@@ -137,7 +147,27 @@ export function detectProject(targetPath, opts = {}) {
137
147
  if (existsSync(join(targetPath, 'docker-compose.yml')) ||
138
148
  existsSync(join(targetPath, 'docker-compose.yaml')) ||
139
149
  existsSync(join(targetPath, 'Dockerfile'))) {
140
- structure.integrations.push('docker');
150
+ if (!structure.integrations.includes('docker')) structure.integrations.push('docker');
151
+ }
152
+
153
+ // ── Azure detection ────────────────────────────────────────────────────
154
+ const azureSignals = [
155
+ '.azure',
156
+ 'azure-pipelines.yml',
157
+ 'infra/main.bicep',
158
+ 'infra',
159
+ ];
160
+ const hasAzure = azureSignals.some(signal => {
161
+ const p = join(targetPath, signal);
162
+ if (!existsSync(p)) return false;
163
+ if (signal === 'infra') {
164
+ // Check for .bicep files inside infra/
165
+ return readdirSyncSafe(p).some(f => f.endsWith('.bicep'));
166
+ }
167
+ return true;
168
+ });
169
+ if (hasAzure && !structure.integrations.includes('azure')) {
170
+ structure.integrations.push('azure');
141
171
  }
142
172
  } catch {
143
173
  // Fail-safe — return defaults
@@ -37,13 +37,20 @@ export function parseStacks(content) {
37
37
  }
38
38
 
39
39
  /**
40
- * Extract project stack tags from config object.
41
- * Splits `project.stack` by '-' to produce tag array.
40
+ * Extract project tags from config object.
41
+ * Combines stack tags (from `project.stack` split by '-' or '+')
42
+ * with integration tags (from `project.integrations` array).
43
+ * Deduplicates the result.
42
44
  */
43
45
  export function getProjectTags(config) {
44
46
  const stack = config?.project?.stack;
45
- if (!stack || typeof stack !== 'string') return [];
46
- return stack.split('-').filter(Boolean);
47
+ const stackTags = (stack && typeof stack === 'string')
48
+ ? stack.split(/[-+]/).filter(Boolean)
49
+ : [];
50
+ const integrations = Array.isArray(config?.project?.integrations)
51
+ ? config.project.integrations
52
+ : [];
53
+ return [...new Set([...stackTags, ...integrations])];
47
54
  }
48
55
 
49
56
  /**
@@ -212,7 +212,7 @@ export async function setupInfra(targetPath, { _exec = execSync } = {}) {
212
212
 
213
213
  // --- 12. installAgents ---
214
214
  log('Step 12: Installing agents...');
215
- const agentCounts = await installAgents(targetPath, FRAMEWORK_DIR, { projectStack: null });
215
+ const agentCounts = await installAgents(targetPath, FRAMEWORK_DIR, { projectTags });
216
216
 
217
217
  // --- 13. installDomainAgents (filtered by stack) ---
218
218
  log('Step 13: Installing domain agents...');
@@ -22,7 +22,7 @@ import { parseStacks, shouldInstall } from '../lib/stack-filter.js';
22
22
  * @param {string|null} options.projectStack - Detected project stack (e.g. 'nextjs', 'blazor')
23
23
  */
24
24
  export async function installAgents(projectDir, frameworkDir = 'framework', options = {}) {
25
- const { projectStack = null } = options;
25
+ const { projectTags = [] } = options;
26
26
 
27
27
  const agentsJsonPath = join(frameworkDir, 'agents.json');
28
28
  const agentsJson = JSON.parse(readFileSync(agentsJsonPath, 'utf-8'));
@@ -38,21 +38,30 @@ export async function installAgents(projectDir, frameworkDir = 'framework', opti
38
38
  const targetDir = join(projectDir, '.claude', 'agents');
39
39
  mkdirSync(targetDir, { recursive: true });
40
40
 
41
- const DOTNET_STACKS = ['dotnet', 'blazor', 'dotnet-api', 'fullstack'];
42
41
  const eligible = agents.filter(a => {
43
42
  if (a.tier !== 1 && a.tier !== 2 && a.tier !== 4) return false;
44
43
  // Tier 4 validators must have a teammate with spawn_prompt
45
44
  if (a.tier === 4 && !a.teammate?.spawn_prompt) return false;
46
- // Skip dotnet-senior for non-.NET projects
47
- if (a.id === 'dotnet-senior' && projectStack && !DOTNET_STACKS.includes(projectStack)) {
48
- return false;
49
- }
45
+ // Filter by stacks if the agent declares them
46
+ const stacks = a.stacks ?? [];
47
+ if (!shouldInstall(stacks, projectTags)) return false;
50
48
  return true;
51
49
  });
52
50
 
53
51
  let tier1 = 0;
54
52
  let tier2 = 0;
55
53
  let tier4 = 0;
54
+ const installed = new Set();
55
+
56
+ // Build the full set of possible filenames for orphan cleanup
57
+ const allPossible = new Set();
58
+ for (const a of agents) {
59
+ if (a.tier !== 1 && a.tier !== 2 && a.tier !== 4) continue;
60
+ const slug = a.id ?? a.name?.toLowerCase().replace(/\s+/g, '-');
61
+ const prefix = a.tier === 4 ? 'morph-validator-' : 'morph-';
62
+ allPossible.add(`${prefix}${slug}.md`);
63
+ }
64
+
56
65
  for (const agent of eligible) {
57
66
  const slug = agent.id ?? agent.name?.toLowerCase().replace(/\s+/g, '-');
58
67
  const prefix = agent.tier === 4 ? 'morph-validator-' : 'morph-';
@@ -65,11 +74,20 @@ export async function installAgents(projectDir, frameworkDir = 'framework', opti
65
74
  const frontmatter = buildFrontmatter(agent, description);
66
75
  const content = `---\n${frontmatter}---\n\n${body}\n`;
67
76
  writeFileSync(targetPath, content, 'utf-8');
77
+ installed.add(filename);
68
78
 
69
79
  if (agent.tier === 1) tier1++;
70
80
  else if (agent.tier === 2) tier2++;
71
81
  else if (agent.tier === 4) tier4++;
72
82
  }
83
+
84
+ // Cleanup orphan morph-* / morph-validator-* files that were filtered out
85
+ for (const file of readdirSync(targetDir)) {
86
+ if (allPossible.has(file) && !installed.has(file)) {
87
+ unlinkSync(join(targetDir, file));
88
+ }
89
+ }
90
+
73
91
  return { tier1, tier2, tier4 };
74
92
  }
75
93
 
@@ -259,3 +277,69 @@ export async function installDomainAgents(projectDir, frameworkDir = 'framework'
259
277
 
260
278
  return { specialists };
261
279
  }
280
+
281
+ /**
282
+ * Check which agents SHOULD be installed but are NOT.
283
+ * Returns a list of missing agent names with their source (agents.json or domain).
284
+ *
285
+ * @param {string} projectDir - Target project directory
286
+ * @param {string} frameworkDir - Path to morph-spec framework/ directory
287
+ * @param {string[]} projectTags - Stack + integration tags
288
+ * @returns {{ missing: Array<{ name: string, stacks: string[], source: string }>, installed: number, expected: number }}
289
+ */
290
+ export function checkMissingAgents(projectDir, frameworkDir, projectTags = []) {
291
+ const targetDir = join(projectDir, '.claude', 'agents');
292
+ const installedFiles = new Set();
293
+ if (existsSync(targetDir)) {
294
+ for (const f of readdirSync(targetDir)) installedFiles.add(f);
295
+ }
296
+
297
+ const missing = [];
298
+ let expected = 0;
299
+
300
+ // Check tier 1/2/4 from agents.json
301
+ const agentsJsonPath = join(frameworkDir, 'agents.json');
302
+ if (existsSync(agentsJsonPath)) {
303
+ const agentsJson = JSON.parse(readFileSync(agentsJsonPath, 'utf-8'));
304
+ const agentsRaw = agentsJson.agents ?? agentsJson;
305
+ const agents = Array.isArray(agentsRaw)
306
+ ? agentsRaw
307
+ : Object.entries(agentsRaw)
308
+ .filter(([key]) => !key.startsWith('_comment'))
309
+ .map(([id, data]) => ({ id, ...data }));
310
+
311
+ for (const a of agents) {
312
+ if (a.tier !== 1 && a.tier !== 2 && a.tier !== 4) continue;
313
+ if (a.tier === 4 && !a.teammate?.spawn_prompt) continue;
314
+ const stacks = a.stacks ?? [];
315
+ if (!shouldInstall(stacks, projectTags)) continue;
316
+
317
+ expected++;
318
+ const slug = a.id ?? a.name?.toLowerCase().replace(/\s+/g, '-');
319
+ const prefix = a.tier === 4 ? 'morph-validator-' : 'morph-';
320
+ const filename = `${prefix}${slug}.md`;
321
+ if (!installedFiles.has(filename)) {
322
+ missing.push({ name: a.title ?? slug, stacks, source: `tier-${a.tier}` });
323
+ }
324
+ }
325
+ }
326
+
327
+ // Check domain agents
328
+ const domainsDir = join(frameworkDir, 'agents');
329
+ if (existsSync(domainsDir)) {
330
+ for (const filePath of collectSkillFiles(domainsDir)) {
331
+ const raw = readFileSync(filePath, 'utf-8');
332
+ const { name, stacks } = parseSkillFile(raw);
333
+ if (!name) continue;
334
+ if (!shouldInstall(stacks, projectTags)) continue;
335
+
336
+ expected++;
337
+ const filename = `morph-domain-${name}.md`;
338
+ if (!installedFiles.has(filename)) {
339
+ missing.push({ name, stacks, source: 'domain' });
340
+ }
341
+ }
342
+ }
343
+
344
+ return { missing, installed: installedFiles.size, expected };
345
+ }