aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
638 lines (564 loc) • 18.2 kB
JavaScript
/**
* Squad Analyzer Utility
*
* Analyzes existing squads and generates comprehensive reports
* with component inventory, coverage metrics, and improvement suggestions.
*
* Used by: squad-creator agent (*analyze-squad task)
*
* @module squad-analyzer
* @version 1.0.0
* @see Story SQS-11: Squad Analyze & Extend
*/
const fs = require('fs').promises;
const path = require('path');
const yaml = require('js-yaml');
/**
* Default path for squads directory
* @constant {string}
*/
const DEFAULT_SQUADS_PATH = './squads';
/**
* Component directories in a squad (from squad-schema.json)
* @constant {string[]}
*/
const COMPONENT_DIRECTORIES = [
'agents',
'tasks',
'workflows',
'checklists',
'templates',
'tools',
'scripts',
'data',
];
/**
* Config files to check for coverage
* @constant {string[]}
*/
const CONFIG_FILES = [
'README.md',
'config/coding-standards.md',
'config/tech-stack.md',
'config/source-tree.md',
];
/**
* Manifest file names in order of preference
* @constant {string[]}
*/
const MANIFEST_FILES = ['squad.yaml', 'config.yaml'];
/**
* Error codes for SquadAnalyzerError
* @enum {string}
*/
const ErrorCodes = {
SQUAD_NOT_FOUND: 'SQUAD_NOT_FOUND',
MANIFEST_NOT_FOUND: 'MANIFEST_NOT_FOUND',
YAML_PARSE_ERROR: 'YAML_PARSE_ERROR',
PERMISSION_DENIED: 'PERMISSION_DENIED',
ANALYSIS_FAILED: 'ANALYSIS_FAILED',
};
/**
* Custom error class for Squad Analyzer operations
* @extends Error
*/
class SquadAnalyzerError extends Error {
/**
* Create a SquadAnalyzerError
* @param {string} code - Error code from ErrorCodes enum
* @param {string} message - Human-readable error message
* @param {string} [suggestion] - Suggested fix for the error
*/
constructor(code, message, suggestion) {
super(message);
this.name = 'SquadAnalyzerError';
this.code = code;
this.suggestion = suggestion || '';
if (Error.captureStackTrace) {
Error.captureStackTrace(this, SquadAnalyzerError);
}
}
/**
* Create error for squad not found
* @param {string} squadName - Name of the squad
* @returns {SquadAnalyzerError}
*/
static squadNotFound(squadName) {
return new SquadAnalyzerError(
ErrorCodes.SQUAD_NOT_FOUND,
`Squad "${squadName}" not found`,
`Use *list-squads to see available squads, or *create-squad ${squadName} to create it`,
);
}
/**
* Create error for manifest not found
* @param {string} squadPath - Path to squad directory
* @returns {SquadAnalyzerError}
*/
static manifestNotFound(squadPath) {
return new SquadAnalyzerError(
ErrorCodes.MANIFEST_NOT_FOUND,
`No squad.yaml or config.yaml found in ${squadPath}`,
'Create squad.yaml with squad metadata',
);
}
}
/**
* Squad Analyzer class for analyzing squad structure and content
*/
class SquadAnalyzer {
/**
* Create a SquadAnalyzer instance
* @param {Object} [options={}] - Configuration options
* @param {string} [options.squadsPath] - Custom squads directory path
* @param {boolean} [options.verbose=false] - Enable verbose output
*/
constructor(options = {}) {
this.squadsPath = options.squadsPath || DEFAULT_SQUADS_PATH;
this.verbose = options.verbose || false;
}
/**
* Analyze a squad and generate complete report
* @param {string} squadName - Name of the squad to analyze
* @param {Object} [options={}] - Analysis options
* @param {boolean} [options.suggestions=true] - Include suggestions
* @param {boolean} [options.verbose=false] - Include file details
* @returns {Promise<Object>} Analysis result
*/
async analyze(squadName, options = {}) {
const includeSuggestions = options.suggestions !== false;
const verbose = options.verbose || this.verbose;
const squadPath = path.join(this.squadsPath, squadName);
// Check if squad exists
const exists = await this._directoryExists(squadPath);
if (!exists) {
throw SquadAnalyzerError.squadNotFound(squadName);
}
// Load manifest
const manifest = await this.loadManifest(squadPath);
// Build overview
const overview = this._buildOverview(manifest, squadName);
// Inventory components
const inventory = await this.inventoryComponents(squadPath, verbose);
// Calculate coverage
const coverage = this.calculateCoverage(inventory, manifest, squadPath);
// Generate suggestions
const suggestions = includeSuggestions
? this.generateSuggestions(inventory, coverage, manifest)
: [];
return {
overview,
inventory,
coverage,
suggestions,
squadPath,
};
}
/**
* Load and parse squad manifest
* @param {string} squadPath - Path to squad directory
* @returns {Promise<Object>} Parsed manifest
*/
async loadManifest(squadPath) {
for (const manifestFile of MANIFEST_FILES) {
const manifestPath = path.join(squadPath, manifestFile);
try {
const content = await fs.readFile(manifestPath, 'utf8');
return yaml.load(content);
} catch (error) {
if (error.code !== 'ENOENT') {
throw new SquadAnalyzerError(
ErrorCodes.YAML_PARSE_ERROR,
`Failed to parse ${manifestFile}: ${error.message}`,
'Check YAML syntax - use a YAML linter',
);
}
}
}
throw SquadAnalyzerError.manifestNotFound(squadPath);
}
/**
* Inventory all components in squad
* @param {string} squadPath - Path to squad directory
* @param {boolean} [verbose=false] - Include file content previews
* @returns {Promise<Object>} Component inventory by type
*/
async inventoryComponents(squadPath, verbose = false) {
const inventory = {};
for (const dir of COMPONENT_DIRECTORIES) {
const dirPath = path.join(squadPath, dir);
inventory[dir] = await this._listFiles(dirPath, verbose);
}
return inventory;
}
/**
* Calculate coverage metrics
* @param {Object} inventory - Component inventory
* @param {Object} manifest - Squad manifest
* @param {string} squadPath - Path to squad
* @returns {Object} Coverage metrics
*/
calculateCoverage(inventory, manifest, squadPath) {
// Agents coverage
const agentCount = inventory.agents.length;
const agentsWithTasks = this._countAgentsWithTasks(inventory);
const agentCoverage = agentCount > 0 ? Math.round((agentsWithTasks / agentCount) * 100) : 0;
// Tasks coverage (relative to agents)
const taskCount = inventory.tasks.length;
const expectedTasks = agentCount * 2; // Expect at least 2 tasks per agent
const taskCoverage =
expectedTasks > 0 ? Math.min(100, Math.round((taskCount / expectedTasks) * 100)) : 0;
// Directory coverage
const populatedDirs = COMPONENT_DIRECTORIES.filter((dir) => inventory[dir].length > 0).length;
const dirCoverage = Math.round((populatedDirs / COMPONENT_DIRECTORIES.length) * 100);
// Config coverage (check for common files)
const configCoverage = this._calculateConfigCoverage(squadPath, inventory);
return {
agents: {
total: agentCount,
withTasks: agentsWithTasks,
percentage: agentCoverage,
},
tasks: {
total: taskCount,
expected: expectedTasks,
percentage: taskCoverage,
},
directories: {
populated: populatedDirs,
total: COMPONENT_DIRECTORIES.length,
percentage: dirCoverage,
},
config: configCoverage,
};
}
/**
* Generate improvement suggestions
* @param {Object} inventory - Component inventory
* @param {Object} coverage - Coverage metrics
* @param {Object} manifest - Squad manifest
* @returns {Array} List of suggestions
*/
generateSuggestions(inventory, coverage, _manifest) {
const suggestions = [];
// Suggest adding tasks for agents without tasks
if (coverage.agents.withTasks < coverage.agents.total) {
const agentsWithoutTasks = coverage.agents.total - coverage.agents.withTasks;
suggestions.push({
priority: 'high',
category: 'tasks',
message: `Add tasks for ${agentsWithoutTasks} agent(s) without tasks`,
action: '*extend-squad --add task',
});
}
// Suggest workflows if none exist
if (inventory.workflows.length === 0 && inventory.tasks.length >= 3) {
suggestions.push({
priority: 'medium',
category: 'workflows',
message: 'Create workflows to combine related tasks',
action: '*extend-squad --add workflow',
});
}
// Suggest checklists if none exist
if (inventory.checklists.length === 0) {
suggestions.push({
priority: 'medium',
category: 'checklists',
message: 'Add validation checklists for quality assurance',
action: '*extend-squad --add checklist',
});
}
// Suggest config files
if (coverage.config.percentage < 100) {
const missing = coverage.config.missing || [];
if (missing.length > 0) {
suggestions.push({
priority: 'low',
category: 'config',
message: `Add missing config files: ${missing.join(', ')}`,
action: 'Create files in config/ directory',
});
}
}
// Suggest tools if none exist and agents have complex tasks
if (inventory.tools.length === 0 && inventory.tasks.length >= 5) {
suggestions.push({
priority: 'low',
category: 'tools',
message: 'Consider adding custom tools for automation',
action: '*extend-squad --add tool',
});
}
// Suggest templates if none exist
if (inventory.templates.length === 0) {
suggestions.push({
priority: 'low',
category: 'templates',
message: 'Add document templates for consistent output',
action: '*extend-squad --add template',
});
}
return suggestions;
}
/**
* Format analysis report for output
* @param {Object} analysis - Complete analysis
* @param {string} [format='console'] - Output format
* @returns {string} Formatted report
*/
formatReport(analysis, format = 'console') {
if (format === 'json') {
return JSON.stringify(analysis, null, 2);
}
if (format === 'markdown') {
return this._formatMarkdown(analysis);
}
return this._formatConsole(analysis);
}
// ============================================
// Private Helper Methods
// ============================================
/**
* Check if directory exists
* @private
*/
async _directoryExists(dirPath) {
try {
const stats = await fs.stat(dirPath);
return stats.isDirectory();
} catch {
return false;
}
}
/**
* List files in a directory
* @private
*/
async _listFiles(dirPath, verbose = false) {
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const files = entries
.filter((entry) => entry.isFile() && !entry.name.startsWith('.'))
.map((entry) => entry.name);
if (verbose) {
return files.map((file) => ({
name: file,
path: path.join(dirPath, file),
}));
}
return files;
} catch {
return [];
}
}
/**
* Build overview object from manifest
* @private
*/
_buildOverview(manifest, squadName) {
return {
name: manifest.name || squadName,
version: manifest.version || '0.0.0',
author: manifest.author || 'Unknown',
license: manifest.license || 'MIT',
description: manifest.description || '',
aiosMinVersion: manifest.aios?.minVersion || '2.1.0',
};
}
/**
* Count agents that have at least one task
* @private
*/
_countAgentsWithTasks(inventory) {
const agentIds = inventory.agents.map((file) => {
const name = typeof file === 'string' ? file : file.name;
return name.replace(/\.md$/, '');
});
let count = 0;
for (const agentId of agentIds) {
const hasTask = inventory.tasks.some((task) => {
const taskName = typeof task === 'string' ? task : task.name;
return taskName.startsWith(agentId + '-');
});
if (hasTask) {
count++;
}
}
return count;
}
/**
* Calculate config file coverage
* @private
*/
_calculateConfigCoverage(squadPath, inventory) {
const found = [];
const missing = [];
// Check README
const hasReadme = inventory.agents.length > 0; // Simplified check
if (hasReadme) {
found.push('README.md');
} else {
missing.push('README.md');
}
// For now, simplified - just check if config directory has files
const percentage = found.length > 0 ? 50 : 0;
return {
found,
missing,
percentage,
};
}
/**
* Format report for console output
* @private
*/
_formatConsole(analysis) {
const { overview, inventory, coverage, suggestions, squadPath: _squadPath } = analysis;
const lines = [];
// Header
lines.push(`=== Squad Analysis: ${overview.name} ===`);
lines.push('');
// Overview
lines.push('Overview');
lines.push(` Name: ${overview.name}`);
lines.push(` Version: ${overview.version}`);
lines.push(` Author: ${overview.author}`);
lines.push(` License: ${overview.license}`);
lines.push(` AIOS Min Version: ${overview.aiosMinVersion}`);
if (overview.description) {
lines.push(` Description: ${overview.description}`);
}
lines.push('');
// Components
lines.push('Components');
for (const dir of COMPONENT_DIRECTORIES) {
const files = inventory[dir];
const count = files.length;
const emptyIndicator = count === 0 ? ' <- Empty' : '';
lines.push(` ${dir}/ (${count})${emptyIndicator}`);
if (count > 0 && count <= 5) {
for (const file of files) {
const fileName = typeof file === 'string' ? file : file.name;
lines.push(` - ${fileName}`);
}
} else if (count > 5) {
for (let i = 0; i < 3; i++) {
const file = files[i];
const fileName = typeof file === 'string' ? file : file.name;
lines.push(` - ${fileName}`);
}
lines.push(` ... and ${count - 3} more`);
}
}
lines.push('');
// Coverage
lines.push('Coverage');
lines.push(
` Agents: ${this._formatBar(coverage.agents.percentage)} ${coverage.agents.percentage}% ` +
`(${coverage.agents.withTasks}/${coverage.agents.total} with tasks)`,
);
lines.push(
` Tasks: ${this._formatBar(coverage.tasks.percentage)} ${coverage.tasks.percentage}% ` +
`(${coverage.tasks.total} tasks)`,
);
lines.push(
` Directories: ${this._formatBar(coverage.directories.percentage)} ${coverage.directories.percentage}% ` +
`(${coverage.directories.populated}/${coverage.directories.total} populated)`,
);
lines.push(
` Config: ${this._formatBar(coverage.config.percentage)} ${coverage.config.percentage}%`,
);
lines.push('');
// Suggestions
if (suggestions.length > 0) {
lines.push('Suggestions');
suggestions.forEach((suggestion, index) => {
const priorityIcon =
suggestion.priority === 'high' ? '!' : suggestion.priority === 'medium' ? '*' : '-';
lines.push(` ${index + 1}. [${priorityIcon}] ${suggestion.message}`);
});
lines.push('');
}
// Next steps
lines.push(`Next: *extend-squad ${overview.name}`);
return lines.join('\n');
}
/**
* Format report as markdown
* @private
*/
_formatMarkdown(analysis) {
const { overview, inventory, coverage, suggestions } = analysis;
const lines = [];
lines.push(`# Squad Analysis: ${overview.name}`);
lines.push('');
lines.push(`**Generated:** ${new Date().toISOString()}`);
lines.push('');
lines.push('## Overview');
lines.push('');
lines.push('| Property | Value |');
lines.push('|----------|-------|');
lines.push(`| Name | ${overview.name} |`);
lines.push(`| Version | ${overview.version} |`);
lines.push(`| Author | ${overview.author} |`);
lines.push(`| License | ${overview.license} |`);
lines.push(`| AIOS Min Version | ${overview.aiosMinVersion} |`);
lines.push('');
lines.push('## Components');
lines.push('');
for (const dir of COMPONENT_DIRECTORIES) {
const files = inventory[dir];
lines.push(`### ${dir}/ (${files.length})`);
if (files.length > 0) {
files.forEach((file) => {
const fileName = typeof file === 'string' ? file : file.name;
lines.push(`- ${fileName}`);
});
} else {
lines.push('*Empty*');
}
lines.push('');
}
lines.push('## Coverage');
lines.push('');
lines.push('| Category | Percentage | Details |');
lines.push('|----------|------------|---------|');
lines.push(
`| Agents | ${coverage.agents.percentage}% | ${coverage.agents.withTasks}/${coverage.agents.total} with tasks |`,
);
lines.push(`| Tasks | ${coverage.tasks.percentage}% | ${coverage.tasks.total} total |`);
lines.push(
`| Directories | ${coverage.directories.percentage}% | ${coverage.directories.populated}/${coverage.directories.total} populated |`,
);
lines.push(`| Config | ${coverage.config.percentage}% | - |`);
lines.push('');
if (suggestions.length > 0) {
lines.push('## Suggestions');
lines.push('');
suggestions.forEach((suggestion, index) => {
lines.push(
`${index + 1}. **[${suggestion.priority.toUpperCase()}]** ${suggestion.message}`,
);
});
lines.push('');
}
return lines.join('\n');
}
/**
* Format progress bar
* @private
*/
_formatBar(percentage) {
const filled = Math.round(percentage / 10);
const empty = 10 - filled;
return '[' + '#'.repeat(filled) + '-'.repeat(empty) + ']';
}
}
module.exports = {
SquadAnalyzer,
SquadAnalyzerError,
ErrorCodes,
COMPONENT_DIRECTORIES,
CONFIG_FILES,
};