diff --git a/.aios-core/core/orchestration/swarm-intelligence.js b/.aios-core/core/orchestration/swarm-intelligence.js new file mode 100644 index 0000000000..45e0618c4c --- /dev/null +++ b/.aios-core/core/orchestration/swarm-intelligence.js @@ -0,0 +1,2 @@ +// Backward compatibility wrapper — canonical implementation lives in .aiox-core +module.exports = require('../../../.aiox-core/core/orchestration/swarm-intelligence'); diff --git a/.aiox-core/core/orchestration/swarm-intelligence.js b/.aiox-core/core/orchestration/swarm-intelligence.js new file mode 100644 index 0000000000..e4f18013e8 --- /dev/null +++ b/.aiox-core/core/orchestration/swarm-intelligence.js @@ -0,0 +1,1038 @@ +/** + * Agent Swarm Intelligence + * Story ORCH-5 - Emergent intelligence from multi-agent collaboration + * @module aiox-core/orchestration/swarm-intelligence + * @version 1.0.0 + */ + +'use strict'; + +const { EventEmitter } = require('events'); +const fs = require('fs').promises; +const path = require('path'); +const crypto = require('crypto'); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CONSTANTS +// ═══════════════════════════════════════════════════════════════════════════════ + +const PERSISTENCE_DIR = '.aiox'; +const PERSISTENCE_FILE = 'swarms.json'; + +const VOTING_STRATEGIES = { + MAJORITY: 'majority', + WEIGHTED: 'weighted', + UNANIMOUS: 'unanimous', + QUORUM: 'quorum', +}; + +const PROPOSAL_STATUS = { + PENDING: 'pending', + APPROVED: 'approved', + REJECTED: 'rejected', + EXPIRED: 'expired', +}; + +const SWARM_STATUS = { + ACTIVE: 'active', + DISSOLVED: 'dissolved', +}; + +const LEADER_CRITERIA = { + MOST_CAPABLE: 'most-capable', + HIGHEST_REPUTATION: 'highest-reputation', + ROUND_ROBIN: 'round-robin', +}; + +const VOTE_OPTIONS = { + APPROVE: 'approve', + REJECT: 'reject', + ABSTAIN: 'abstain', +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Generate a unique identifier + * @returns {string} UUID-like identifier + */ +function generateId() { + return crypto.randomBytes(8).toString('hex'); +} + +/** + * Validate confidence value is between 0 and 1 + * @param {number} confidence + * @returns {boolean} + */ +function isValidConfidence(confidence) { + return typeof confidence === 'number' && confidence >= 0 && confidence <= 1; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// SWARM INTELLIGENCE +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * SwarmIntelligence - Emergent intelligence from multi-agent collaboration + * + * Provides swarm creation, agent coordination, collective decision-making + * via voting strategies, shared knowledge management, and leader election. + * + * @extends EventEmitter + */ +class SwarmIntelligence extends EventEmitter { + /** + * Creates a new SwarmIntelligence instance + * @param {string} projectRoot - Project root directory for persistence + * @param {Object} [options] - Configuration options + * @param {boolean} [options.debug=false] - Enable debug logging + * @param {boolean} [options.persist=true] - Enable persistence to disk + */ + constructor(projectRoot, options = {}) { + super(); + + if (!projectRoot || typeof projectRoot !== 'string') { + throw new Error('projectRoot is required and must be a string'); + } + + this.projectRoot = projectRoot; + this.options = { + debug: options.debug ?? false, + persist: options.persist ?? true, + }; + + /** @type {Map} Active swarms indexed by ID */ + this.swarms = new Map(); + + /** @type {Object} Global statistics */ + this._stats = { + swarmsCreated: 0, + swarmsDissolved: 0, + proposalsCreated: 0, + proposalsResolved: 0, + knowledgeShared: 0, + leadersElected: 0, + totalVotes: 0, + }; + + /** @type {number} Round-robin index tracking per swarm */ + this._roundRobinIndex = new Map(); + + this._persistPath = path.join(projectRoot, PERSISTENCE_DIR, PERSISTENCE_FILE); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SWARM MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Create a named swarm with configuration + * @param {string} name - Swarm name + * @param {Object} [config] - Swarm configuration + * @param {number} [config.minAgents=2] - Minimum agents required + * @param {number} [config.maxAgents=50] - Maximum agents allowed + * @param {number} [config.consensusThreshold=0.6] - Consensus threshold (0-1) + * @param {string} [config.votingStrategy='majority'] - Voting strategy + * @returns {Object} Created swarm + */ + createSwarm(name, config = {}) { + if (!name || typeof name !== 'string') { + throw new Error('Swarm name is required and must be a string'); + } + + const votingStrategy = config.votingStrategy ?? VOTING_STRATEGIES.MAJORITY; + if (!Object.values(VOTING_STRATEGIES).includes(votingStrategy)) { + throw new Error(`Invalid voting strategy: ${votingStrategy}. Must be one of: ${Object.values(VOTING_STRATEGIES).join(', ')}`); + } + + const consensusThreshold = config.consensusThreshold ?? 0.6; + if (typeof consensusThreshold !== 'number' || consensusThreshold < 0 || consensusThreshold > 1) { + throw new Error('consensusThreshold must be a number between 0 and 1'); + } + + const minAgents = config.minAgents ?? 2; + const maxAgents = config.maxAgents ?? 50; + + if (minAgents < 1) { + throw new Error('minAgents must be at least 1'); + } + if (maxAgents < minAgents) { + throw new Error('maxAgents must be >= minAgents'); + } + + const id = generateId(); + const swarm = { + id, + name, + agents: new Map(), + proposals: [], + knowledgeBase: [], + leader: null, + config: { + minAgents, + maxAgents, + consensusThreshold, + votingStrategy, + }, + createdAt: new Date().toISOString(), + status: SWARM_STATUS.ACTIVE, + }; + + this.swarms.set(id, swarm); + this._stats.swarmsCreated++; + this._roundRobinIndex.set(id, 0); + + this.emit('swarm:created', { swarmId: id, name, config: swarm.config }); + this._log(`Swarm created: ${name} (${id})`); + this._persistAsync(); + + return swarm; + } + + /** + * Agent joins a swarm with declared capabilities + * @param {string} swarmId - Swarm identifier + * @param {string} agentId - Agent identifier + * @param {string[]} [capabilities=[]] - Agent capabilities + * @returns {Object} Updated swarm + */ + joinSwarm(swarmId, agentId, capabilities = []) { + const swarm = this._getActiveSwarm(swarmId); + + if (!agentId || typeof agentId !== 'string') { + throw new Error('agentId is required and must be a string'); + } + + if (swarm.agents.has(agentId)) { + throw new Error(`Agent ${agentId} is already a member of swarm ${swarmId}`); + } + + if (swarm.agents.size >= swarm.config.maxAgents) { + throw new Error(`Swarm ${swarmId} has reached maximum capacity (${swarm.config.maxAgents})`); + } + + const agent = { + id: agentId, + capabilities: Array.isArray(capabilities) ? [...capabilities] : [], + joinedAt: new Date().toISOString(), + reputation: 1.0, + votesCount: 0, + }; + + swarm.agents.set(agentId, agent); + + this.emit('swarm:joined', { swarmId, agentId, capabilities: agent.capabilities }); + this._log(`Agent ${agentId} joined swarm ${swarmId}`); + this._persistAsync(); + + return swarm; + } + + /** + * Agent leaves a swarm + * @param {string} swarmId - Swarm identifier + * @param {string} agentId - Agent identifier + * @returns {Object} Updated swarm + */ + leaveSwarm(swarmId, agentId) { + const swarm = this._getActiveSwarm(swarmId); + + if (!swarm.agents.has(agentId)) { + throw new Error(`Agent ${agentId} is not a member of swarm ${swarmId}`); + } + + swarm.agents.delete(agentId); + + // If the leader left, clear leadership + if (swarm.leader === agentId) { + swarm.leader = null; + } + + this.emit('swarm:left', { swarmId, agentId }); + this._log(`Agent ${agentId} left swarm ${swarmId}`); + this._persistAsync(); + + return swarm; + } + + /** + * Dissolve a swarm + * @param {string} swarmId - Swarm identifier + * @returns {Object} Dissolved swarm summary + */ + dissolveSwarm(swarmId) { + const swarm = this._getActiveSwarm(swarmId); + + swarm.status = SWARM_STATUS.DISSOLVED; + swarm.dissolvedAt = new Date().toISOString(); + this._stats.swarmsDissolved++; + + const summary = { + id: swarm.id, + name: swarm.name, + agentCount: swarm.agents.size, + proposalCount: swarm.proposals.length, + knowledgeCount: swarm.knowledgeBase.length, + dissolvedAt: swarm.dissolvedAt, + }; + + this.emit('swarm:dissolved', { swarmId, summary }); + this._log(`Swarm dissolved: ${swarm.name} (${swarmId})`); + this._persistAsync(); + + return summary; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // DECISION MAKING + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Submit a proposal for collective voting + * @param {string} swarmId - Swarm identifier + * @param {Object} proposal - Proposal details + * @param {string} proposal.description - Proposal description + * @param {string} proposal.proposedBy - Agent ID proposing + * @param {string} [proposal.type='general'] - Proposal type + * @param {number} [proposal.deadlineMs=300000] - Deadline in milliseconds (default 5 min) + * @returns {Object} Created proposal + */ + proposeDecision(swarmId, proposal) { + const swarm = this._getActiveSwarm(swarmId); + + if (!proposal || typeof proposal !== 'object') { + throw new Error('proposal is required and must be an object'); + } + if (!proposal.description || typeof proposal.description !== 'string') { + throw new Error('proposal.description is required'); + } + if (!proposal.proposedBy || typeof proposal.proposedBy !== 'string') { + throw new Error('proposal.proposedBy is required'); + } + if (!swarm.agents.has(proposal.proposedBy)) { + throw new Error(`Agent ${proposal.proposedBy} is not a member of swarm ${swarmId}`); + } + + const id = generateId(); + const deadlineMs = proposal.deadlineMs ?? 300000; + const created = { + id, + swarmId, + proposedBy: proposal.proposedBy, + description: proposal.description, + type: proposal.type ?? 'general', + votes: new Map(), + status: PROPOSAL_STATUS.PENDING, + createdAt: new Date().toISOString(), + deadline: new Date(Date.now() + deadlineMs).toISOString(), + }; + + swarm.proposals.push(created); + this._stats.proposalsCreated++; + + this.emit('proposal:created', { swarmId, proposalId: id, proposedBy: proposal.proposedBy }); + this._log(`Proposal created in swarm ${swarmId}: ${proposal.description}`); + this._persistAsync(); + + return created; + } + + /** + * Cast a vote on a proposal + * @param {string} swarmId - Swarm identifier + * @param {string} proposalId - Proposal identifier + * @param {string} agentId - Voting agent ID + * @param {string} voteValue - Vote: 'approve', 'reject', or 'abstain' + * @param {number} [confidence=1.0] - Confidence level (0-1) + * @returns {Object} Updated proposal + */ + vote(swarmId, proposalId, agentId, voteValue, confidence = 1.0) { + const swarm = this._getActiveSwarm(swarmId); + const proposal = this._getPendingProposal(swarm, proposalId); + + if (!swarm.agents.has(agentId)) { + throw new Error(`Agent ${agentId} is not a member of swarm ${swarmId}`); + } + + if (!Object.values(VOTE_OPTIONS).includes(voteValue)) { + throw new Error(`Invalid vote: ${voteValue}. Must be one of: ${Object.values(VOTE_OPTIONS).join(', ')}`); + } + + if (!isValidConfidence(confidence)) { + throw new Error('confidence must be a number between 0 and 1'); + } + + if (proposal.votes.has(agentId)) { + throw new Error(`Agent ${agentId} has already voted on proposal ${proposalId}`); + } + + // Check deadline + if (new Date(proposal.deadline) < new Date()) { + proposal.status = PROPOSAL_STATUS.EXPIRED; + throw new Error(`Proposal ${proposalId} has expired`); + } + + proposal.votes.set(agentId, { + agentId, + vote: voteValue, + confidence, + timestamp: new Date().toISOString(), + }); + + // Update agent stats + const agent = swarm.agents.get(agentId); + agent.votesCount++; + this._stats.totalVotes++; + + this.emit('proposal:voted', { swarmId, proposalId, agentId, vote: voteValue, confidence }); + this._log(`Agent ${agentId} voted '${voteValue}' on proposal ${proposalId} (confidence: ${confidence})`); + this._persistAsync(); + + return proposal; + } + + /** + * Resolve a proposal based on votes and configured strategy + * @param {string} swarmId - Swarm identifier + * @param {string} proposalId - Proposal identifier + * @returns {Object} Resolution result + */ + resolveProposal(swarmId, proposalId) { + const swarm = this._getActiveSwarm(swarmId); + const proposal = this._getProposal(swarm, proposalId); + + if (proposal.status !== PROPOSAL_STATUS.PENDING) { + throw new Error(`Proposal ${proposalId} is already resolved (status: ${proposal.status})`); + } + + // Reject expired proposals before applying voting strategy + if (new Date(proposal.deadline) < new Date()) { + proposal.status = PROPOSAL_STATUS.REJECTED; + proposal.resolvedAt = new Date().toISOString(); + proposal.resolution = { approved: false, reason: 'deadline_expired' }; + this._stats.proposalsResolved++; + + this.emit('proposal:resolved', { + swarmId, + proposalId, + status: proposal.status, + result: proposal.resolution, + }); + this._log(`Proposal ${proposalId} rejected: deadline expired`); + this._persistAsync(); + + return { + proposalId, + status: proposal.status, + ...proposal.resolution, + }; + } + + const result = this._applyVotingStrategy(swarm, proposal); + + proposal.status = result.approved ? PROPOSAL_STATUS.APPROVED : PROPOSAL_STATUS.REJECTED; + proposal.resolvedAt = new Date().toISOString(); + proposal.resolution = result; + this._stats.proposalsResolved++; + + // Update reputation based on alignment with result + this._updateReputations(swarm, proposal, result.approved); + + this.emit('proposal:resolved', { + swarmId, + proposalId, + status: proposal.status, + result, + }); + this._log(`Proposal ${proposalId} resolved: ${proposal.status}`); + this._persistAsync(); + + return { + proposalId, + status: proposal.status, + ...result, + }; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // VOTING STRATEGIES + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Apply the configured voting strategy to determine outcome + * @param {Object} swarm - Swarm object + * @param {Object} proposal - Proposal object + * @returns {Object} Strategy result { approved, approveCount, rejectCount, abstainCount, details } + * @private + */ + _applyVotingStrategy(swarm, proposal) { + const votes = Array.from(proposal.votes.values()); + const approveVotes = votes.filter((v) => v.vote === VOTE_OPTIONS.APPROVE); + const rejectVotes = votes.filter((v) => v.vote === VOTE_OPTIONS.REJECT); + const abstainVotes = votes.filter((v) => v.vote === VOTE_OPTIONS.ABSTAIN); + + const base = { + approveCount: approveVotes.length, + rejectCount: rejectVotes.length, + abstainCount: abstainVotes.length, + totalVotes: votes.length, + totalAgents: swarm.agents.size, + }; + + switch (swarm.config.votingStrategy) { + case VOTING_STRATEGIES.MAJORITY: + return this._majorityStrategy(base); + + case VOTING_STRATEGIES.WEIGHTED: + return this._weightedStrategy(swarm, approveVotes, rejectVotes, base); + + case VOTING_STRATEGIES.UNANIMOUS: + return this._unanimousStrategy(base); + + case VOTING_STRATEGIES.QUORUM: + return this._quorumStrategy(swarm, base); + + default: + return this._majorityStrategy(base); + } + } + + /** + * Majority: simple majority of non-abstain votes + * @private + */ + _majorityStrategy(base) { + const nonAbstain = base.approveCount + base.rejectCount; + const approved = nonAbstain > 0 && base.approveCount > nonAbstain / 2; + return { ...base, approved, strategy: VOTING_STRATEGIES.MAJORITY }; + } + + /** + * Weighted: votes weighted by agent reputation and confidence + * @private + */ + _weightedStrategy(swarm, approveVotes, rejectVotes, base) { + let approveWeight = 0; + let rejectWeight = 0; + + for (const v of approveVotes) { + const agent = swarm.agents.get(v.agentId); + const reputation = agent ? agent.reputation : 1.0; + approveWeight += v.confidence * reputation; + } + + for (const v of rejectVotes) { + const agent = swarm.agents.get(v.agentId); + const reputation = agent ? agent.reputation : 1.0; + rejectWeight += v.confidence * reputation; + } + + const totalWeight = approveWeight + rejectWeight; + const approved = totalWeight > 0 && approveWeight > totalWeight / 2; + + return { + ...base, + approved, + approveWeight: Math.round(approveWeight * 100) / 100, + rejectWeight: Math.round(rejectWeight * 100) / 100, + strategy: VOTING_STRATEGIES.WEIGHTED, + }; + } + + /** + * Unanimous: all non-abstain votes must approve + * @private + */ + _unanimousStrategy(base) { + const nonAbstain = base.approveCount + base.rejectCount; + const approved = nonAbstain > 0 && base.rejectCount === 0; + return { ...base, approved, strategy: VOTING_STRATEGIES.UNANIMOUS }; + } + + /** + * Quorum: requires consensusThreshold proportion of total agents to approve + * @private + */ + _quorumStrategy(swarm, base) { + const quorumRequired = Math.ceil(swarm.agents.size * swarm.config.consensusThreshold); + // Abstains and rejects don't count toward quorum + const hasQuorum = base.approveCount >= quorumRequired; + const approved = hasQuorum && base.approveCount > base.rejectCount; + + return { + ...base, + approved, + quorumRequired, + hasQuorum, + strategy: VOTING_STRATEGIES.QUORUM, + }; + } + + /** + * Update agent reputations based on vote alignment with the final result + * Agents who voted with the majority gain reputation, others lose slightly + * @param {Object} swarm - Swarm object + * @param {Object} proposal - Resolved proposal + * @param {boolean} approved - Whether the proposal was approved + * @private + */ + _updateReputations(swarm, proposal, approved) { + for (const [agentId, voteData] of proposal.votes) { + const agent = swarm.agents.get(agentId); + if (!agent) continue; + + const alignedWithResult = + (approved && voteData.vote === VOTE_OPTIONS.APPROVE) || + (!approved && voteData.vote === VOTE_OPTIONS.REJECT); + + if (voteData.vote === VOTE_OPTIONS.ABSTAIN) { + // Abstaining has no reputation effect + continue; + } + + if (alignedWithResult) { + agent.reputation = Math.min(2.0, agent.reputation + 0.05); + } else { + agent.reputation = Math.max(0.1, agent.reputation - 0.03); + } + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // KNOWLEDGE MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Share a knowledge artifact with the swarm + * @param {string} swarmId - Swarm identifier + * @param {string} agentId - Sharing agent ID + * @param {Object} knowledge - Knowledge artifact + * @param {string} knowledge.topic - Knowledge topic + * @param {*} knowledge.content - Knowledge content + * @param {string[]} [knowledge.tags=[]] - Searchable tags + * @returns {Object} Created knowledge entry + */ + shareKnowledge(swarmId, agentId, knowledge) { + const swarm = this._getActiveSwarm(swarmId); + + if (!swarm.agents.has(agentId)) { + throw new Error(`Agent ${agentId} is not a member of swarm ${swarmId}`); + } + + if (!knowledge || typeof knowledge !== 'object') { + throw new Error('knowledge is required and must be an object'); + } + if (!knowledge.topic || typeof knowledge.topic !== 'string') { + throw new Error('knowledge.topic is required'); + } + if (knowledge.content === undefined || knowledge.content === null) { + throw new Error('knowledge.content is required'); + } + + const id = generateId(); + const entry = { + id, + sharedBy: agentId, + topic: knowledge.topic, + content: knowledge.content, + tags: Array.isArray(knowledge.tags) ? [...knowledge.tags] : [], + timestamp: new Date().toISOString(), + citations: 0, + }; + + swarm.knowledgeBase.push(entry); + this._stats.knowledgeShared++; + + this.emit('knowledge:shared', { swarmId, agentId, knowledgeId: id, topic: knowledge.topic }); + this._log(`Knowledge shared in swarm ${swarmId}: ${knowledge.topic}`); + this._persistAsync(); + + return entry; + } + + /** + * Query the collective knowledge base of a swarm + * @param {string} swarmId - Swarm identifier + * @param {Object} query - Query parameters + * @param {string} [query.topic] - Filter by topic (substring match) + * @param {string[]} [query.tags] - Filter by tags (any match) + * @param {string} [query.sharedBy] - Filter by agent + * @param {number} [query.limit=10] - Max results + * @returns {Object[]} Matching knowledge entries + */ + queryKnowledge(swarmId, query = {}) { + const swarm = this._getActiveSwarm(swarmId); + let results = [...swarm.knowledgeBase]; + + if (query.topic) { + const topicLower = query.topic.toLowerCase(); + results = results.filter((k) => k.topic.toLowerCase().includes(topicLower)); + } + + if (query.tags && Array.isArray(query.tags) && query.tags.length > 0) { + const queryTags = query.tags.map((t) => t.toLowerCase()); + results = results.filter((k) => + k.tags.some((tag) => queryTags.includes(tag.toLowerCase())) + ); + } + + if (query.sharedBy) { + results = results.filter((k) => k.sharedBy === query.sharedBy); + } + + const limit = query.limit ?? 10; + + // Increment citation count for returned results + const limited = results.slice(0, limit); + for (const entry of limited) { + const original = swarm.knowledgeBase.find((k) => k.id === entry.id); + if (original) { + original.citations++; + } + } + + // Persist citation bumps + this._persistAsync(); + + return limited; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // LEADER ELECTION + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Elect a leader for the swarm based on criterion + * @param {string} swarmId - Swarm identifier + * @param {string} [criterion='most-capable'] - Election criterion + * @returns {Object} Election result { leaderId, criterion, agentDetails } + */ + electLeader(swarmId, criterion = LEADER_CRITERIA.MOST_CAPABLE) { + const swarm = this._getActiveSwarm(swarmId); + + if (swarm.agents.size === 0) { + throw new Error(`Swarm ${swarmId} has no agents to elect a leader from`); + } + + const validCriteria = Object.values(LEADER_CRITERIA); + if (!validCriteria.includes(criterion)) { + throw new Error(`Invalid criterion: ${criterion}. Must be one of: ${validCriteria.join(', ')}`); + } + + let leaderId; + const agents = Array.from(swarm.agents.entries()); + + switch (criterion) { + case LEADER_CRITERIA.MOST_CAPABLE: { + // Agent with most capabilities wins + let maxCapabilities = -1; + for (const [id, agent] of agents) { + if (agent.capabilities.length > maxCapabilities) { + maxCapabilities = agent.capabilities.length; + leaderId = id; + } + } + break; + } + + case LEADER_CRITERIA.HIGHEST_REPUTATION: { + // Agent with highest reputation wins + let maxReputation = -1; + for (const [id, agent] of agents) { + if (agent.reputation > maxReputation) { + maxReputation = agent.reputation; + leaderId = id; + } + } + break; + } + + case LEADER_CRITERIA.ROUND_ROBIN: { + // Rotate through agents in insertion order + const agentIds = Array.from(swarm.agents.keys()); + const currentIndex = this._roundRobinIndex.get(swarmId) ?? 0; + leaderId = agentIds[currentIndex % agentIds.length]; + this._roundRobinIndex.set(swarmId, (currentIndex + 1) % agentIds.length); + break; + } + } + + swarm.leader = leaderId; + this._stats.leadersElected++; + + const leaderAgent = swarm.agents.get(leaderId); + const result = { + leaderId, + criterion, + agentDetails: { ...leaderAgent }, + }; + + this.emit('leader:elected', { swarmId, leaderId, criterion }); + this._log(`Leader elected in swarm ${swarmId}: ${leaderId} (criterion: ${criterion})`); + this._persistAsync(); + + return result; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // HEALTH & STATS + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Get health metrics for a swarm + * @param {string} swarmId - Swarm identifier + * @returns {Object} Health metrics + */ + getSwarmHealth(swarmId) { + const swarm = this._getSwarm(swarmId); + + const agentCount = swarm.agents.size; + const pendingProposals = swarm.proposals.filter((p) => p.status === PROPOSAL_STATUS.PENDING).length; + const resolvedProposals = swarm.proposals.filter( + (p) => p.status === PROPOSAL_STATUS.APPROVED || p.status === PROPOSAL_STATUS.REJECTED + ).length; + + const agents = Array.from(swarm.agents.values()); + const avgReputation = agents.length > 0 + ? Math.round((agents.reduce((sum, a) => sum + a.reputation, 0) / agents.length) * 100) / 100 + : 0; + + const hasLeader = swarm.leader !== null; + const meetsMinAgents = agentCount >= swarm.config.minAgents; + + // Health score: 0-100 + let healthScore = 0; + if (swarm.status === SWARM_STATUS.ACTIVE) { + healthScore += 30; // Base for being active + if (meetsMinAgents) healthScore += 25; + if (hasLeader) healthScore += 15; + if (avgReputation >= 1.0) healthScore += 15; + if (resolvedProposals > 0) healthScore += 15; + } + + return { + swarmId: swarm.id, + name: swarm.name, + status: swarm.status, + agentCount, + minAgents: swarm.config.minAgents, + maxAgents: swarm.config.maxAgents, + meetsMinAgents, + hasLeader, + leader: swarm.leader, + pendingProposals, + resolvedProposals, + knowledgeEntries: swarm.knowledgeBase.length, + avgReputation, + healthScore, + createdAt: swarm.createdAt, + }; + } + + /** + * Get global statistics across all swarms + * @returns {Object} Global stats + */ + getStats() { + const activeSwarms = Array.from(this.swarms.values()).filter( + (s) => s.status === SWARM_STATUS.ACTIVE + ).length; + + const totalAgents = Array.from(this.swarms.values()).reduce( + (sum, s) => sum + s.agents.size, 0 + ); + + return { + ...this._stats, + activeSwarms, + totalSwarms: this.swarms.size, + totalAgents, + }; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // PERSISTENCE + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Persist state to disk asynchronously (fire-and-forget) + * @private + */ + _persistAsync() { + if (!this.options.persist) return; + + // Serialize writes to prevent concurrent fs operations + this._pendingSave = (this._pendingSave || Promise.resolve()) + .then(() => this._saveToDisk()) + .catch((err) => { + this._log(`Persistence error: ${err.message}`); + }); + } + + /** + * Save current state to disk + * @returns {Promise} + * @private + */ + async _saveToDisk() { + const dir = path.dirname(this._persistPath); + await fs.mkdir(dir, { recursive: true }); + + const data = { + version: '1.0.0', + savedAt: new Date().toISOString(), + stats: this._stats, + swarms: this._serializeSwarms(), + }; + + await fs.writeFile(this._persistPath, JSON.stringify(data, null, 2), 'utf8'); + } + + /** + * Load state from disk + * @returns {Promise} Whether state was loaded + */ + async loadFromDisk() { + try { + const raw = await fs.readFile(this._persistPath, 'utf8'); + const data = JSON.parse(raw); + + if (data.stats) { + this._stats = { ...this._stats, ...data.stats }; + } + + if (data.swarms && Array.isArray(data.swarms)) { + for (const s of data.swarms) { + const swarm = { + ...s, + agents: new Map(Object.entries(s.agents ?? {})), + proposals: (s.proposals ?? []).map((p) => ({ + ...p, + votes: new Map(Object.entries(p.votes ?? {})), + })), + knowledgeBase: s.knowledgeBase ?? [], + }; + this.swarms.set(swarm.id, swarm); + } + } + + this._log('State loaded from disk'); + return true; + } catch (err) { + // Only treat ENOENT (file not found) as fresh start + if (err.code === 'ENOENT') { + return false; + } + this._log(`Failed to load state: ${err.message}`); + throw err; + } + } + + /** + * Serialize swarms for JSON persistence (Maps to plain objects) + * @returns {Object[]} + * @private + */ + _serializeSwarms() { + const result = []; + for (const [, swarm] of this.swarms) { + result.push({ + ...swarm, + agents: Object.fromEntries(swarm.agents), + proposals: swarm.proposals.map((p) => ({ + ...p, + votes: Object.fromEntries(p.votes), + })), + }); + } + return result; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // INTERNAL HELPERS + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Get a swarm by ID (any status) + * @param {string} swarmId + * @returns {Object} + * @private + */ + _getSwarm(swarmId) { + const swarm = this.swarms.get(swarmId); + if (!swarm) { + throw new Error(`Swarm not found: ${swarmId}`); + } + return swarm; + } + + /** + * Get an active swarm by ID + * @param {string} swarmId + * @returns {Object} + * @private + */ + _getActiveSwarm(swarmId) { + const swarm = this._getSwarm(swarmId); + if (swarm.status !== SWARM_STATUS.ACTIVE) { + throw new Error(`Swarm ${swarmId} is not active (status: ${swarm.status})`); + } + return swarm; + } + + /** + * Get a proposal from a swarm + * @param {Object} swarm + * @param {string} proposalId + * @returns {Object} + * @private + */ + _getProposal(swarm, proposalId) { + const proposal = swarm.proposals.find((p) => p.id === proposalId); + if (!proposal) { + throw new Error(`Proposal not found: ${proposalId}`); + } + return proposal; + } + + /** + * Get a pending proposal from a swarm + * @param {Object} swarm + * @param {string} proposalId + * @returns {Object} + * @private + */ + _getPendingProposal(swarm, proposalId) { + const proposal = this._getProposal(swarm, proposalId); + if (proposal.status !== PROPOSAL_STATUS.PENDING) { + throw new Error(`Proposal ${proposalId} is not pending (status: ${proposal.status})`); + } + return proposal; + } + + /** + * Debug logging + * @param {string} message + * @private + */ + _log(message) { + if (this.options.debug) { + console.log(`[SwarmIntelligence] ${message}`); + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// EXPORTS +// ═══════════════════════════════════════════════════════════════════════════════ + +module.exports = SwarmIntelligence; +module.exports.SwarmIntelligence = SwarmIntelligence; +module.exports.VOTING_STRATEGIES = VOTING_STRATEGIES; +module.exports.PROPOSAL_STATUS = PROPOSAL_STATUS; +module.exports.SWARM_STATUS = SWARM_STATUS; +module.exports.LEADER_CRITERIA = LEADER_CRITERIA; +module.exports.VOTE_OPTIONS = VOTE_OPTIONS; diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 2516e4c299..c68f948d97 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,9 +8,9 @@ # - File types for categorization # version: 5.0.3 -generated_at: "2026-03-10T17:08:07.160Z" +generated_at: "2026-03-11T02:24:08.632Z" generator: scripts/generate-install-manifest.js -file_count: 1089 +file_count: 1090 files: - path: cli/commands/config/index.js hash: sha256:25c4b9bf4e0241abf7754b55153f49f1a214f1fb5fe904a576675634cb7b3da9 @@ -932,6 +932,10 @@ files: hash: sha256:92e9d5bea78c3db4940c39f79e537821b36451cd524d69e6b738272aa63c08b6 type: core size: 12849 + - path: core/orchestration/swarm-intelligence.js + hash: sha256:960b90fbcafb68d5728a645e892891d9bdec053b4e9f36086f8e6d35259a08c1 + type: core + size: 35774 - path: core/orchestration/task-complexity-classifier.js hash: sha256:33b3b7c349352d607c156e0018c508f0869a1c7d233d107bed194a51bc608c93 type: core @@ -1221,9 +1225,9 @@ files: type: data size: 9575 - path: data/entity-registry.yaml - hash: sha256:151aca46769c614f0238e7a8e2968884c3888b438ff1c634f0eeb2505e325b83 + hash: sha256:6552c0779e63ea612587db5703a64db597241ab7899ea1677945889fad8f81e8 type: data - size: 521804 + size: 522285 - path: data/learned-patterns.yaml hash: sha256:24ac0b160615583a0ff783d3da8af80b7f94191575d6db2054ec8e10a3f945dc type: data @@ -2583,19 +2587,19 @@ files: - path: development/templates/service-template/__tests__/index.test.ts.hbs hash: sha256:4617c189e75ab362d4ef2cabcc3ccce3480f914fd915af550469c17d1b68a4fe type: template - size: 9810 + size: 9573 - path: development/templates/service-template/client.ts.hbs hash: sha256:f342c60695fe611192002bdb8c04b3a0dbce6345b7fa39834ea1898f71689198 type: template - size: 12213 + size: 11810 - path: development/templates/service-template/errors.ts.hbs hash: sha256:e0be40d8be19b71b26e35778eadffb20198e7ca88e9d140db9da1bfe12de01ec type: template - size: 5395 + size: 5213 - path: development/templates/service-template/index.ts.hbs hash: sha256:d44012d54b76ab98356c7163d257ca939f7fed122f10fecf896fe1e7e206d10a type: template - size: 3206 + size: 3086 - path: development/templates/service-template/jest.config.js hash: sha256:1681bfd7fbc0d330d3487d3427515847c4d57ef300833f573af59e0ad69ed159 type: template @@ -2603,11 +2607,11 @@ files: - path: development/templates/service-template/package.json.hbs hash: sha256:d89d35f56992ee95c2ceddf17fa1d455c18007a4d24af914ba83cf4abc38bca9 type: template - size: 2314 + size: 2227 - path: development/templates/service-template/README.md.hbs hash: sha256:2c3dd4c2bf6df56b9b6db439977be7e1cc35820438c0e023140eccf6ccd227a0 type: template - size: 3584 + size: 3426 - path: development/templates/service-template/tsconfig.json hash: sha256:8b465fcbdd45c4d6821ba99aea62f2bd7998b1bca8de80486a1525e77d43c9a1 type: template @@ -2615,7 +2619,7 @@ files: - path: development/templates/service-template/types.ts.hbs hash: sha256:3e52e0195003be8cd1225a3f27f4d040686c8b8c7762f71b41055f04cd1b841b type: template - size: 2661 + size: 2516 - path: development/templates/squad-template/agents/example-agent.yaml hash: sha256:824a1b349965e5d4ae85458c231b78260dc65497da75dada25b271f2cabbbe67 type: agent @@ -2623,7 +2627,7 @@ files: - path: development/templates/squad-template/LICENSE hash: sha256:ff7017aa403270cf2c440f5ccb4240d0b08e54d8bf8a0424d34166e8f3e10138 type: template - size: 1092 + size: 1071 - path: development/templates/squad-template/package.json hash: sha256:8f68627a0d74e49f94ae382d0c2b56ecb5889d00f3095966c742fb5afaf363db type: template @@ -3367,11 +3371,11 @@ files: - path: infrastructure/templates/aiox-sync.yaml.template hash: sha256:0040ad8a9e25716a28631b102c9448b72fd72e84f992c3926eb97e9e514744bb type: template - size: 8567 + size: 8385 - path: infrastructure/templates/coderabbit.yaml.template hash: sha256:91a4a76bbc40767a4072fb6a87c480902bb800cfb0a11e9fc1b3183d8f7f3a80 type: template - size: 8321 + size: 8042 - path: infrastructure/templates/core-config/core-config-brownfield.tmpl.yaml hash: sha256:9bdb0c0e09c765c991f9f142921f7f8e2c0d0ada717f41254161465dc0622d02 type: template @@ -3383,11 +3387,11 @@ files: - path: infrastructure/templates/github-workflows/ci.yml.template hash: sha256:acbfa2a8a84141fd6a6b205eac74719772f01c221c0afe22ce951356f06a605d type: template - size: 5089 + size: 4920 - path: infrastructure/templates/github-workflows/pr-automation.yml.template hash: sha256:c236077b4567965a917e48df9a91cc42153ff97b00a9021c41a7e28179be9d0f type: template - size: 10939 + size: 10609 - path: infrastructure/templates/github-workflows/README.md hash: sha256:6b7b5cb32c28b3e562c81a96e2573ea61849b138c93ccac6e93c3adac26cadb5 type: template @@ -3395,23 +3399,23 @@ files: - path: infrastructure/templates/github-workflows/release.yml.template hash: sha256:b771145e61a254a88dc6cca07869e4ece8229ce18be87132f59489cdf9a66ec6 type: template - size: 6791 + size: 6595 - path: infrastructure/templates/gitignore/gitignore-aiox-base.tmpl hash: sha256:9088975ee2bf4d88e23db6ac3ea5d27cccdc72b03db44450300e2f872b02e935 type: template - size: 851 + size: 788 - path: infrastructure/templates/gitignore/gitignore-brownfield-merge.tmpl hash: sha256:ce4291a3cf5677050c9dafa320809e6b0ca5db7e7f7da0382d2396e32016a989 type: template - size: 506 + size: 488 - path: infrastructure/templates/gitignore/gitignore-node.tmpl hash: sha256:5179f78de7483274f5d7182569229088c71934db1fd37a63a40b3c6b815c9c8e type: template - size: 1036 + size: 951 - path: infrastructure/templates/gitignore/gitignore-python.tmpl hash: sha256:d7aac0b1e6e340b774a372a9102b4379722588449ca82ac468cf77804bbc1e55 type: template - size: 1725 + size: 1580 - path: infrastructure/templates/project-docs/coding-standards-tmpl.md hash: sha256:377acf85463df8ac9923fc59d7cfeba68a82f8353b99948ea1d28688e88bc4a9 type: template @@ -3507,43 +3511,43 @@ files: - path: monitor/hooks/lib/__init__.py hash: sha256:bfab6ee249c52f412c02502479da649b69d044938acaa6ab0aa39dafe6dee9bf type: monitor - size: 30 + size: 29 - path: monitor/hooks/lib/enrich.py hash: sha256:20dfa73b4b20d7a767e52c3ec90919709c4447c6e230902ba797833fc6ddc22c type: monitor - size: 1702 + size: 1644 - path: monitor/hooks/lib/send_event.py hash: sha256:59d61311f718fb373a5cf85fd7a01c23a4fd727e8e022ad6930bba533ef4615d type: monitor - size: 1237 + size: 1190 - path: monitor/hooks/notification.py hash: sha256:8a1a6ce0ff2b542014de177006093b9caec9b594e938a343dc6bd62df2504f22 type: monitor - size: 528 + size: 499 - path: monitor/hooks/post_tool_use.py hash: sha256:47dbe37073d432c55657647fc5b907ddb56efa859d5c3205e8362aa916d55434 type: monitor - size: 1185 + size: 1140 - path: monitor/hooks/pre_compact.py hash: sha256:f287cf45e83deed6f1bc0e30bd9348dfa1bf08ad770c5e58bb34e3feb210b30b type: monitor - size: 529 + size: 500 - path: monitor/hooks/pre_tool_use.py hash: sha256:a4d1d3ffdae9349e26a383c67c9137effff7d164ac45b2c87eea9fa1ab0d6d98 type: monitor - size: 1021 + size: 981 - path: monitor/hooks/stop.py hash: sha256:edb382f0cf46281a11a8588bc20eafa7aa2b5cc3f4ad775d71b3d20a7cfab385 type: monitor - size: 519 + size: 490 - path: monitor/hooks/subagent_stop.py hash: sha256:fa5357309247c71551dba0a19f28dd09bebde749db033d6657203b50929c0a42 type: monitor - size: 541 + size: 512 - path: monitor/hooks/user_prompt_submit.py hash: sha256:af57dca79ef55cdf274432f4abb4c20a9778b95e107ca148f47ace14782c5828 type: monitor - size: 856 + size: 818 - path: package.json hash: sha256:9fdf0dcee2dcec6c0643634ee384ba181ad077dcff1267d8807434d4cb4809c7 type: other @@ -3691,7 +3695,7 @@ files: - path: product/templates/adr.hbs hash: sha256:d68653cae9e64414ad4f58ea941b6c6e337c5324c2c7247043eca1461a652d10 type: template - size: 2337 + size: 2212 - path: product/templates/agent-template.yaml hash: sha256:98676fcc493c0d5f09264dcc52fcc2cf1129f9a195824ecb4c2ec035c2515121 type: template @@ -3743,7 +3747,7 @@ files: - path: product/templates/dbdr.hbs hash: sha256:5a2781ffaa3da9fc663667b5a63a70b7edfc478ed14cad02fc6ed237ff216315 type: template - size: 4380 + size: 4139 - path: product/templates/design-story-tmpl.yaml hash: sha256:2bfefc11ae2bcfc679dbd924c58f8b764fa23538c14cb25344d6edef41968f29 type: template @@ -3807,7 +3811,7 @@ files: - path: product/templates/epic.hbs hash: sha256:dcbcc26f6dd8f3782b3ef17aee049b689f1d6d92931615c3df9513eca0de2ef7 type: template - size: 4080 + size: 3868 - path: product/templates/eslintrc-security.json hash: sha256:657d40117261d6a52083984d29f9f88e79040926a64aa4c2058a602bfe91e0d5 type: template @@ -3915,7 +3919,7 @@ files: - path: product/templates/pmdr.hbs hash: sha256:d529cebbb562faa82c70477ece70de7cda871eaa6896f2962b48b2a8b67b1cbe type: template - size: 3425 + size: 3239 - path: product/templates/prd-tmpl.yaml hash: sha256:25c239f40e05f24aee1986601a98865188dbe3ea00a705028efc3adad6d420f3 type: template @@ -3923,11 +3927,11 @@ files: - path: product/templates/prd-v2.0.hbs hash: sha256:21a20ef5333a85a11f5326d35714e7939b51bab22bd6e28d49bacab755763bea type: template - size: 4728 + size: 4512 - path: product/templates/prd.hbs hash: sha256:4a1a030a5388c6a8bf2ce6ea85e54cae6cf1fe64f1bb2af7f17d349d3c24bf1d type: template - size: 3626 + size: 3425 - path: product/templates/project-brief-tmpl.yaml hash: sha256:b8d388268c24dc5018f48a87036d591b11cb122fafe9b59c17809b06ea5d9d58 type: template @@ -3975,7 +3979,7 @@ files: - path: product/templates/story.hbs hash: sha256:3f0ac8b39907634a2b53f43079afc33663eee76f46e680d318ff253e0befc2c4 type: template - size: 5846 + size: 5583 - path: product/templates/task-execution-report.md hash: sha256:e0f08a3e199234f3d2207ba8f435786b7d8e1b36174f46cb82fc3666b9a9309e type: template @@ -3987,67 +3991,67 @@ files: - path: product/templates/task.hbs hash: sha256:621e987e142c455cd290dc85d990ab860faa0221f66cf1f57ac296b076889ea5 type: template - size: 2875 + size: 2705 - path: product/templates/tmpl-comment-on-examples.sql hash: sha256:254002c3fbc63cfcc5848b1d4b15822ce240bf5f57e6a1c8bb984e797edc2691 type: template - size: 6373 + size: 6215 - path: product/templates/tmpl-migration-script.sql hash: sha256:44ef63ea475526d21a11e3c667c9fdb78a9fddace80fdbaa2312b7f2724fbbb5 type: template - size: 3038 + size: 2947 - path: product/templates/tmpl-rls-granular-policies.sql hash: sha256:36c2fd8c6d9eebb5d164acb0fb0c87bc384d389264b4429ce21e77e06318f5f3 type: template - size: 3426 + size: 3322 - path: product/templates/tmpl-rls-kiss-policy.sql hash: sha256:5210d37fce62e5a9a00e8d5366f5f75653cd518be73fbf96333ed8a6712453c7 type: template - size: 309 + size: 299 - path: product/templates/tmpl-rls-roles.sql hash: sha256:2d032a608a8e87440c3a430c7d69ddf9393d8813d8d4129270f640dd847425c3 type: template - size: 4727 + size: 4592 - path: product/templates/tmpl-rls-simple.sql hash: sha256:f67af0fa1cdd2f2af9eab31575ac3656d82457421208fd9ccb8b57ca9785275e type: template - size: 2992 + size: 2915 - path: product/templates/tmpl-rls-tenant.sql hash: sha256:36629ed87a2c72311809cc3fb96298b6f38716bba35bc56c550ac39d3321757a type: template - size: 5130 + size: 4978 - path: product/templates/tmpl-rollback-script.sql hash: sha256:8b84046a98f1163faf7350322f43831447617c5a63a94c88c1a71b49804e022b type: template - size: 2734 + size: 2657 - path: product/templates/tmpl-seed-data.sql hash: sha256:a65e73298f46cd6a8e700f29b9d8d26e769e12a57751a943a63fd0fe15768615 type: template - size: 5716 + size: 5576 - path: product/templates/tmpl-smoke-test.sql hash: sha256:aee7e48bb6d9c093769dee215cacc9769939501914e20e5ea8435b25fad10f3c type: template - size: 739 + size: 723 - path: product/templates/tmpl-staging-copy-merge.sql hash: sha256:55988caeb47cc04261665ba7a37f4caa2aa5fac2e776fdbc5964e0587af24450 type: template - size: 4220 + size: 4081 - path: product/templates/tmpl-stored-proc.sql hash: sha256:2b205ff99dc0adfade6047a4d79f5b50109e50ceb45386e5c886437692c7a2a3 type: template - size: 3979 + size: 3839 - path: product/templates/tmpl-trigger.sql hash: sha256:93abdc92e1b475d1370094e69a9d1b18afd804da6acb768b878355c798bd8e0e type: template - size: 5424 + size: 5272 - path: product/templates/tmpl-view-materialized.sql hash: sha256:47935510f03d4ad9b2200748e65441ce6c2d6a7c74750395eca6831d77c48e91 type: template - size: 4496 + size: 4363 - path: product/templates/tmpl-view.sql hash: sha256:22557b076003a856b32397f05fa44245a126521de907058a95e14dd02da67aff type: template - size: 5093 + size: 4916 - path: product/templates/token-exports-css-tmpl.css hash: sha256:d937b8d61cdc9e5b10fdff871c6cb41c9f756004d060d671e0ae26624a047f62 type: template diff --git a/tests/core/orchestration/swarm-intelligence.test.js b/tests/core/orchestration/swarm-intelligence.test.js new file mode 100644 index 0000000000..a171ae6e8a --- /dev/null +++ b/tests/core/orchestration/swarm-intelligence.test.js @@ -0,0 +1,1030 @@ +/** + * Swarm Intelligence Tests + * Story ORCH-5 - Emergent intelligence from multi-agent collaboration + * + * 40+ test cases covering all methods, voting strategies, edge cases + */ + +const path = require('path'); +const fs = require('fs').promises; + +const SwarmIntelligence = require('../../../.aiox-core/core/orchestration/swarm-intelligence'); +const { + VOTING_STRATEGIES, + PROPOSAL_STATUS, + SWARM_STATUS, + LEADER_CRITERIA, + VOTE_OPTIONS, +} = SwarmIntelligence; + +// Test fixtures +const TEST_PROJECT_ROOT = path.join(__dirname, '../../fixtures/test-project-swarm'); + +describe('SwarmIntelligence', () => { + let si; + + beforeEach(async () => { + try { + await fs.rm(TEST_PROJECT_ROOT, { recursive: true, force: true }); + } catch { + // Ignore if doesn't exist + } + await fs.mkdir(TEST_PROJECT_ROOT, { recursive: true }); + + si = new SwarmIntelligence(TEST_PROJECT_ROOT, { persist: false, debug: false }); + }); + + afterEach(async () => { + si.removeAllListeners(); + try { + await fs.rm(TEST_PROJECT_ROOT, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // CONSTRUCTOR + // ═══════════════════════════════════════════════════════════════════════════ + + describe('constructor', () => { + it('should create instance with valid projectRoot', () => { + expect(si).toBeInstanceOf(SwarmIntelligence); + expect(si.projectRoot).toBe(TEST_PROJECT_ROOT); + expect(si.swarms.size).toBe(0); + }); + + it('should throw if projectRoot is missing', () => { + expect(() => new SwarmIntelligence()).toThrow('projectRoot is required'); + }); + + it('should throw if projectRoot is not a string', () => { + expect(() => new SwarmIntelligence(123)).toThrow('projectRoot is required'); + }); + + it('should use ?? for defaults (persist=true, debug=false)', () => { + const instance = new SwarmIntelligence(TEST_PROJECT_ROOT); + expect(instance.options.persist).toBe(true); + expect(instance.options.debug).toBe(false); + }); + + it('should be an EventEmitter', () => { + expect(typeof si.on).toBe('function'); + expect(typeof si.emit).toBe('function'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // SWARM MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════ + + describe('createSwarm', () => { + it('should create a swarm with default config', () => { + const swarm = si.createSwarm('alpha-team'); + expect(swarm.name).toBe('alpha-team'); + expect(swarm.id).toBeDefined(); + expect(swarm.status).toBe(SWARM_STATUS.ACTIVE); + expect(swarm.agents).toBeInstanceOf(Map); + expect(swarm.agents.size).toBe(0); + expect(swarm.config.votingStrategy).toBe(VOTING_STRATEGIES.MAJORITY); + expect(swarm.config.consensusThreshold).toBe(0.6); + expect(swarm.config.minAgents).toBe(2); + expect(swarm.config.maxAgents).toBe(50); + }); + + it('should create swarm with custom config', () => { + const swarm = si.createSwarm('beta-team', { + minAgents: 3, + maxAgents: 10, + consensusThreshold: 0.8, + votingStrategy: 'weighted', + }); + expect(swarm.config.minAgents).toBe(3); + expect(swarm.config.maxAgents).toBe(10); + expect(swarm.config.consensusThreshold).toBe(0.8); + expect(swarm.config.votingStrategy).toBe('weighted'); + }); + + it('should emit swarm:created event', () => { + const handler = jest.fn(); + si.on('swarm:created', handler); + const swarm = si.createSwarm('test-swarm'); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ swarmId: swarm.id, name: 'test-swarm' }) + ); + }); + + it('should throw for missing name', () => { + expect(() => si.createSwarm()).toThrow('Swarm name is required'); + }); + + it('should throw for invalid voting strategy', () => { + expect(() => si.createSwarm('bad', { votingStrategy: 'invalid' })) + .toThrow('Invalid voting strategy'); + }); + + it('should throw for invalid consensusThreshold', () => { + expect(() => si.createSwarm('bad', { consensusThreshold: 1.5 })) + .toThrow('consensusThreshold must be a number between 0 and 1'); + }); + + it('should throw if maxAgents < minAgents', () => { + expect(() => si.createSwarm('bad', { minAgents: 10, maxAgents: 5 })) + .toThrow('maxAgents must be >= minAgents'); + }); + + it('should throw if minAgents < 1', () => { + expect(() => si.createSwarm('bad', { minAgents: 0 })) + .toThrow('minAgents must be at least 1'); + }); + + it('should increment swarmsCreated stat', () => { + si.createSwarm('s1'); + si.createSwarm('s2'); + expect(si.getStats().swarmsCreated).toBe(2); + }); + }); + + describe('joinSwarm', () => { + let swarm; + + beforeEach(() => { + swarm = si.createSwarm('test-swarm', { maxAgents: 3 }); + }); + + it('should add an agent to the swarm', () => { + si.joinSwarm(swarm.id, 'agent-1', ['coding', 'testing']); + expect(swarm.agents.size).toBe(1); + const agent = swarm.agents.get('agent-1'); + expect(agent.capabilities).toEqual(['coding', 'testing']); + expect(agent.reputation).toBe(1.0); + expect(agent.votesCount).toBe(0); + }); + + it('should emit swarm:joined event', () => { + const handler = jest.fn(); + si.on('swarm:joined', handler); + si.joinSwarm(swarm.id, 'agent-1', ['coding']); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ swarmId: swarm.id, agentId: 'agent-1' }) + ); + }); + + it('should throw if agent already joined', () => { + si.joinSwarm(swarm.id, 'agent-1'); + expect(() => si.joinSwarm(swarm.id, 'agent-1')) + .toThrow('already a member'); + }); + + it('should throw if swarm is at max capacity', () => { + si.joinSwarm(swarm.id, 'agent-1'); + si.joinSwarm(swarm.id, 'agent-2'); + si.joinSwarm(swarm.id, 'agent-3'); + expect(() => si.joinSwarm(swarm.id, 'agent-4')) + .toThrow('maximum capacity'); + }); + + it('should throw for invalid agentId', () => { + expect(() => si.joinSwarm(swarm.id, '')) + .toThrow('agentId is required'); + }); + + it('should throw for non-existent swarm', () => { + expect(() => si.joinSwarm('fake-id', 'agent-1')) + .toThrow('Swarm not found'); + }); + }); + + describe('leaveSwarm', () => { + let swarm; + + beforeEach(() => { + swarm = si.createSwarm('test-swarm'); + si.joinSwarm(swarm.id, 'agent-1'); + si.joinSwarm(swarm.id, 'agent-2'); + }); + + it('should remove agent from swarm', () => { + si.leaveSwarm(swarm.id, 'agent-1'); + expect(swarm.agents.size).toBe(1); + expect(swarm.agents.has('agent-1')).toBe(false); + }); + + it('should clear leader if leader leaves', () => { + swarm.leader = 'agent-1'; + si.leaveSwarm(swarm.id, 'agent-1'); + expect(swarm.leader).toBeNull(); + }); + + it('should emit swarm:left event', () => { + const handler = jest.fn(); + si.on('swarm:left', handler); + si.leaveSwarm(swarm.id, 'agent-1'); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ swarmId: swarm.id, agentId: 'agent-1' }) + ); + }); + + it('should throw if agent is not a member', () => { + expect(() => si.leaveSwarm(swarm.id, 'unknown-agent')) + .toThrow('not a member'); + }); + }); + + describe('dissolveSwarm', () => { + it('should dissolve an active swarm', () => { + const swarm = si.createSwarm('ephemeral'); + si.joinSwarm(swarm.id, 'agent-1'); + + const summary = si.dissolveSwarm(swarm.id); + expect(summary.name).toBe('ephemeral'); + expect(summary.agentCount).toBe(1); + expect(swarm.status).toBe(SWARM_STATUS.DISSOLVED); + }); + + it('should emit swarm:dissolved event', () => { + const handler = jest.fn(); + si.on('swarm:dissolved', handler); + const swarm = si.createSwarm('temp'); + si.dissolveSwarm(swarm.id); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ swarmId: swarm.id }) + ); + }); + + it('should prevent operations on dissolved swarm', () => { + const swarm = si.createSwarm('temp'); + si.dissolveSwarm(swarm.id); + expect(() => si.joinSwarm(swarm.id, 'agent-1')) + .toThrow('not active'); + }); + + it('should increment swarmsDissolved stat', () => { + const swarm = si.createSwarm('temp'); + si.dissolveSwarm(swarm.id); + expect(si.getStats().swarmsDissolved).toBe(1); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // DECISION MAKING + // ═══════════════════════════════════════════════════════════════════════════ + + describe('proposeDecision', () => { + let swarm; + + beforeEach(() => { + swarm = si.createSwarm('decision-swarm'); + si.joinSwarm(swarm.id, 'agent-1'); + }); + + it('should create a proposal', () => { + const proposal = si.proposeDecision(swarm.id, { + description: 'Use TypeScript', + proposedBy: 'agent-1', + type: 'architecture', + }); + expect(proposal.id).toBeDefined(); + expect(proposal.description).toBe('Use TypeScript'); + expect(proposal.proposedBy).toBe('agent-1'); + expect(proposal.type).toBe('architecture'); + expect(proposal.status).toBe(PROPOSAL_STATUS.PENDING); + expect(proposal.votes).toBeInstanceOf(Map); + }); + + it('should emit proposal:created event', () => { + const handler = jest.fn(); + si.on('proposal:created', handler); + si.proposeDecision(swarm.id, { + description: 'Test proposal', + proposedBy: 'agent-1', + }); + expect(handler).toHaveBeenCalled(); + }); + + it('should throw if proposer is not a member', () => { + expect(() => si.proposeDecision(swarm.id, { + description: 'Bad proposal', + proposedBy: 'outsider', + })).toThrow('not a member'); + }); + + it('should throw if description is missing', () => { + expect(() => si.proposeDecision(swarm.id, { + proposedBy: 'agent-1', + })).toThrow('description is required'); + }); + + it('should default type to general', () => { + const proposal = si.proposeDecision(swarm.id, { + description: 'A thing', + proposedBy: 'agent-1', + }); + expect(proposal.type).toBe('general'); + }); + }); + + describe('vote', () => { + let swarm; + let proposal; + + beforeEach(() => { + swarm = si.createSwarm('vote-swarm'); + si.joinSwarm(swarm.id, 'agent-1'); + si.joinSwarm(swarm.id, 'agent-2'); + si.joinSwarm(swarm.id, 'agent-3'); + proposal = si.proposeDecision(swarm.id, { + description: 'Should we deploy?', + proposedBy: 'agent-1', + }); + }); + + it('should record a vote', () => { + si.vote(swarm.id, proposal.id, 'agent-1', 'approve', 0.9); + expect(proposal.votes.size).toBe(1); + const v = proposal.votes.get('agent-1'); + expect(v.vote).toBe('approve'); + expect(v.confidence).toBe(0.9); + }); + + it('should emit proposal:voted event', () => { + const handler = jest.fn(); + si.on('proposal:voted', handler); + si.vote(swarm.id, proposal.id, 'agent-1', 'reject', 0.5); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + swarmId: swarm.id, + proposalId: proposal.id, + agentId: 'agent-1', + vote: 'reject', + confidence: 0.5, + }) + ); + }); + + it('should throw if agent already voted', () => { + si.vote(swarm.id, proposal.id, 'agent-1', 'approve'); + expect(() => si.vote(swarm.id, proposal.id, 'agent-1', 'reject')) + .toThrow('already voted'); + }); + + it('should throw for invalid vote value', () => { + expect(() => si.vote(swarm.id, proposal.id, 'agent-1', 'maybe')) + .toThrow('Invalid vote'); + }); + + it('should throw for invalid confidence', () => { + expect(() => si.vote(swarm.id, proposal.id, 'agent-1', 'approve', 1.5)) + .toThrow('confidence must be a number between 0 and 1'); + }); + + it('should throw if agent is not a member', () => { + expect(() => si.vote(swarm.id, proposal.id, 'outsider', 'approve')) + .toThrow('not a member'); + }); + + it('should default confidence to 1.0', () => { + si.vote(swarm.id, proposal.id, 'agent-2', 'approve'); + const v = proposal.votes.get('agent-2'); + expect(v.confidence).toBe(1.0); + }); + + it('should increment agent votesCount', () => { + si.vote(swarm.id, proposal.id, 'agent-1', 'approve'); + expect(swarm.agents.get('agent-1').votesCount).toBe(1); + }); + + it('should reject vote on expired proposal', () => { + // Force deadline to past + proposal.deadline = new Date(Date.now() - 1000).toISOString(); + expect(() => si.vote(swarm.id, proposal.id, 'agent-1', 'approve')) + .toThrow('has expired'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // VOTING STRATEGIES + // ═══════════════════════════════════════════════════════════════════════════ + + describe('resolveProposal - majority strategy', () => { + it('should approve with majority approvals', () => { + const swarm = si.createSwarm('majority-swarm', { votingStrategy: 'majority' }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + si.joinSwarm(swarm.id, 'a3'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'approve'); + si.vote(swarm.id, p.id, 'a3', 'reject'); + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.status).toBe(PROPOSAL_STATUS.APPROVED); + expect(result.approved).toBe(true); + expect(result.approveCount).toBe(2); + expect(result.rejectCount).toBe(1); + }); + + it('should reject without majority', () => { + const swarm = si.createSwarm('majority-swarm', { votingStrategy: 'majority' }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + si.joinSwarm(swarm.id, 'a3'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'reject'); + si.vote(swarm.id, p.id, 'a3', 'reject'); + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.status).toBe(PROPOSAL_STATUS.REJECTED); + expect(result.approved).toBe(false); + }); + + it('should handle abstain votes (majority of non-abstain)', () => { + const swarm = si.createSwarm('majority-swarm', { votingStrategy: 'majority' }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + si.joinSwarm(swarm.id, 'a3'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'abstain'); + si.vote(swarm.id, p.id, 'a3', 'abstain'); + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.approved).toBe(true); // 1 approve, 0 reject = majority + }); + }); + + describe('resolveProposal - weighted strategy', () => { + it('should weight votes by confidence and reputation', () => { + const swarm = si.createSwarm('weighted-swarm', { votingStrategy: 'weighted' }); + si.joinSwarm(swarm.id, 'expert', ['ml', 'data-science', 'python']); + si.joinSwarm(swarm.id, 'junior', ['python']); + + // Boost expert reputation + swarm.agents.get('expert').reputation = 2.0; + swarm.agents.get('junior').reputation = 0.5; + + const p = si.proposeDecision(swarm.id, { description: 'use ML', proposedBy: 'expert' }); + si.vote(swarm.id, p.id, 'expert', 'approve', 1.0); // weight = 1.0 * 2.0 = 2.0 + si.vote(swarm.id, p.id, 'junior', 'reject', 0.8); // weight = 0.8 * 0.5 = 0.4 + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.approved).toBe(true); + expect(result.approveWeight).toBe(2.0); + expect(result.rejectWeight).toBe(0.4); + }); + + it('should reject when reject weight exceeds approve weight', () => { + const swarm = si.createSwarm('weighted-swarm', { votingStrategy: 'weighted' }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + + swarm.agents.get('a1').reputation = 0.5; + swarm.agents.get('a2').reputation = 2.0; + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve', 0.5); // weight = 0.25 + si.vote(swarm.id, p.id, 'a2', 'reject', 1.0); // weight = 2.0 + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.approved).toBe(false); + }); + }); + + describe('resolveProposal - unanimous strategy', () => { + it('should approve when all non-abstain votes approve', () => { + const swarm = si.createSwarm('unanimous-swarm', { votingStrategy: 'unanimous' }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + si.joinSwarm(swarm.id, 'a3'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'approve'); + si.vote(swarm.id, p.id, 'a3', 'abstain'); + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.approved).toBe(true); + }); + + it('should reject if any non-abstain vote is reject', () => { + const swarm = si.createSwarm('unanimous-swarm', { votingStrategy: 'unanimous' }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'reject'); + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.approved).toBe(false); + }); + }); + + describe('resolveProposal - quorum strategy', () => { + it('should approve when quorum met and approves > rejects', () => { + const swarm = si.createSwarm('quorum-swarm', { + votingStrategy: 'quorum', + consensusThreshold: 0.5, + }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + si.joinSwarm(swarm.id, 'a3'); + si.joinSwarm(swarm.id, 'a4'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'approve'); + // a3, a4 don't vote — quorum = ceil(4 * 0.5) = 2 — met + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.approved).toBe(true); + expect(result.hasQuorum).toBe(true); + }); + + it('should reject when quorum not met', () => { + const swarm = si.createSwarm('quorum-swarm', { + votingStrategy: 'quorum', + consensusThreshold: 0.8, + }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + si.joinSwarm(swarm.id, 'a3'); + si.joinSwarm(swarm.id, 'a4'); + si.joinSwarm(swarm.id, 'a5'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'approve'); + // quorum = ceil(5 * 0.8) = 4, only 2 voted + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.approved).toBe(false); + expect(result.hasQuorum).toBe(false); + }); + }); + + describe('resolveProposal - expired deadline', () => { + it('should reject expired proposals with deadline_expired reason', () => { + const swarm = si.createSwarm('expired-swarm'); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + + const p = si.proposeDecision(swarm.id, { + description: 'should expire', + proposedBy: 'a1', + deadlineMs: 1, // 1ms — will expire immediately + }); + + si.vote(swarm.id, p.id, 'a1', 'approve'); + + // Force deadline to the past + p.deadline = new Date(Date.now() - 10000).toISOString(); + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.status).toBe(PROPOSAL_STATUS.REJECTED); + expect(result.approved).toBe(false); + expect(result.reason).toBe('deadline_expired'); + expect(p.resolvedAt).toBeDefined(); + }); + }); + + describe('resolveProposal - quorum counts only approves', () => { + it('should not count abstains or rejects toward quorum', () => { + const swarm = si.createSwarm('quorum-approve-only', { + votingStrategy: 'quorum', + consensusThreshold: 0.6, + }); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + si.joinSwarm(swarm.id, 'a3'); + si.joinSwarm(swarm.id, 'a4'); + si.joinSwarm(swarm.id, 'a5'); + + // quorumRequired = ceil(5 * 0.6) = 3 approves needed + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'reject'); + si.vote(swarm.id, p.id, 'a3', 'abstain'); + // totalVotes = 3, but only 1 approve — quorum NOT met (needs 3) + + const result = si.resolveProposal(swarm.id, p.id); + expect(result.hasQuorum).toBe(false); + expect(result.approved).toBe(false); + }); + }); + + describe('resolveProposal - common', () => { + it('should emit proposal:resolved event', () => { + const handler = jest.fn(); + si.on('proposal:resolved', handler); + + const swarm = si.createSwarm('resolved-swarm'); + si.joinSwarm(swarm.id, 'a1'); + const p = si.proposeDecision(swarm.id, { description: 't', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.resolveProposal(swarm.id, p.id); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ swarmId: swarm.id, proposalId: p.id }) + ); + }); + + it('should throw if resolving already-resolved proposal', () => { + const swarm = si.createSwarm('resolved-swarm'); + si.joinSwarm(swarm.id, 'a1'); + const p = si.proposeDecision(swarm.id, { description: 't', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.resolveProposal(swarm.id, p.id); + + expect(() => si.resolveProposal(swarm.id, p.id)) + .toThrow('already resolved'); + }); + + it('should update reputations after resolution', () => { + const swarm = si.createSwarm('rep-swarm'); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + si.joinSwarm(swarm.id, 'a3'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'reject'); + si.vote(swarm.id, p.id, 'a3', 'approve'); + + const initRepA1 = swarm.agents.get('a1').reputation; + const initRepA2 = swarm.agents.get('a2').reputation; + + si.resolveProposal(swarm.id, p.id); + + // a1 voted approve (aligned with result = approved) => rep goes up + expect(swarm.agents.get('a1').reputation).toBeGreaterThan(initRepA1); + // a2 voted reject (not aligned) => rep goes down + expect(swarm.agents.get('a2').reputation).toBeLessThan(initRepA2); + }); + + it('should not change reputation for abstain votes', () => { + const swarm = si.createSwarm('abstain-rep'); + si.joinSwarm(swarm.id, 'a1'); + si.joinSwarm(swarm.id, 'a2'); + + const p = si.proposeDecision(swarm.id, { description: 'test', proposedBy: 'a1' }); + si.vote(swarm.id, p.id, 'a1', 'approve'); + si.vote(swarm.id, p.id, 'a2', 'abstain'); + + si.resolveProposal(swarm.id, p.id); + + expect(swarm.agents.get('a2').reputation).toBe(1.0); // unchanged + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // KNOWLEDGE MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════ + + describe('shareKnowledge', () => { + let swarm; + + beforeEach(() => { + swarm = si.createSwarm('knowledge-swarm'); + si.joinSwarm(swarm.id, 'agent-1'); + }); + + it('should add knowledge to the swarm', () => { + const entry = si.shareKnowledge(swarm.id, 'agent-1', { + topic: 'deployment', + content: 'Use blue-green deployments for zero downtime', + tags: ['devops', 'deployment'], + }); + expect(entry.id).toBeDefined(); + expect(entry.topic).toBe('deployment'); + expect(entry.sharedBy).toBe('agent-1'); + expect(entry.tags).toEqual(['devops', 'deployment']); + expect(entry.citations).toBe(0); + expect(swarm.knowledgeBase.length).toBe(1); + }); + + it('should emit knowledge:shared event', () => { + const handler = jest.fn(); + si.on('knowledge:shared', handler); + si.shareKnowledge(swarm.id, 'agent-1', { + topic: 'test', + content: 'some content', + }); + expect(handler).toHaveBeenCalled(); + }); + + it('should throw if agent is not a member', () => { + expect(() => si.shareKnowledge(swarm.id, 'outsider', { + topic: 'test', + content: 'data', + })).toThrow('not a member'); + }); + + it('should throw if topic is missing', () => { + expect(() => si.shareKnowledge(swarm.id, 'agent-1', { + content: 'data', + })).toThrow('topic is required'); + }); + + it('should throw if content is null', () => { + expect(() => si.shareKnowledge(swarm.id, 'agent-1', { + topic: 'test', + content: null, + })).toThrow('content is required'); + }); + }); + + describe('queryKnowledge', () => { + let swarm; + + beforeEach(() => { + swarm = si.createSwarm('knowledge-swarm'); + si.joinSwarm(swarm.id, 'agent-1'); + si.joinSwarm(swarm.id, 'agent-2'); + + si.shareKnowledge(swarm.id, 'agent-1', { + topic: 'deployment strategies', + content: 'blue-green', + tags: ['devops', 'ci-cd'], + }); + si.shareKnowledge(swarm.id, 'agent-1', { + topic: 'testing patterns', + content: 'TDD is great', + tags: ['testing', 'quality'], + }); + si.shareKnowledge(swarm.id, 'agent-2', { + topic: 'deployment monitoring', + content: 'prometheus + grafana', + tags: ['devops', 'monitoring'], + }); + }); + + it('should return all knowledge when no filter', () => { + const results = si.queryKnowledge(swarm.id); + expect(results.length).toBe(3); + }); + + it('should filter by topic substring', () => { + const results = si.queryKnowledge(swarm.id, { topic: 'deployment' }); + expect(results.length).toBe(2); + }); + + it('should filter by tags', () => { + const results = si.queryKnowledge(swarm.id, { tags: ['testing'] }); + expect(results.length).toBe(1); + expect(results[0].topic).toBe('testing patterns'); + }); + + it('should filter by sharedBy', () => { + const results = si.queryKnowledge(swarm.id, { sharedBy: 'agent-2' }); + expect(results.length).toBe(1); + }); + + it('should respect limit', () => { + const results = si.queryKnowledge(swarm.id, { limit: 1 }); + expect(results.length).toBe(1); + }); + + it('should increment citations on queried results', () => { + si.queryKnowledge(swarm.id, { topic: 'deployment' }); + const k = swarm.knowledgeBase.find((k) => k.topic === 'deployment strategies'); + expect(k.citations).toBe(1); + }); + + it('should be case-insensitive for topic search', () => { + const results = si.queryKnowledge(swarm.id, { topic: 'DEPLOYMENT' }); + expect(results.length).toBe(2); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // LEADER ELECTION + // ═══════════════════════════════════════════════════════════════════════════ + + describe('electLeader', () => { + let swarm; + + beforeEach(() => { + swarm = si.createSwarm('leader-swarm'); + si.joinSwarm(swarm.id, 'generalist', ['coding']); + si.joinSwarm(swarm.id, 'expert', ['coding', 'testing', 'devops', 'architecture']); + si.joinSwarm(swarm.id, 'mid', ['coding', 'testing']); + }); + + it('should elect most-capable leader (most capabilities)', () => { + const result = si.electLeader(swarm.id, 'most-capable'); + expect(result.leaderId).toBe('expert'); + expect(result.criterion).toBe('most-capable'); + expect(swarm.leader).toBe('expert'); + }); + + it('should elect highest-reputation leader', () => { + swarm.agents.get('generalist').reputation = 1.8; + swarm.agents.get('expert').reputation = 1.2; + swarm.agents.get('mid').reputation = 0.9; + + const result = si.electLeader(swarm.id, 'highest-reputation'); + expect(result.leaderId).toBe('generalist'); + }); + + it('should rotate leader with round-robin', () => { + const first = si.electLeader(swarm.id, 'round-robin'); + const second = si.electLeader(swarm.id, 'round-robin'); + const third = si.electLeader(swarm.id, 'round-robin'); + + // Should cycle through the agents + expect(first.leaderId).not.toBe(second.leaderId); + expect(second.leaderId).not.toBe(third.leaderId); + }); + + it('should emit leader:elected event', () => { + const handler = jest.fn(); + si.on('leader:elected', handler); + si.electLeader(swarm.id); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ swarmId: swarm.id }) + ); + }); + + it('should throw for empty swarm', () => { + const empty = si.createSwarm('empty'); + expect(() => si.electLeader(empty.id)) + .toThrow('no agents'); + }); + + it('should throw for invalid criterion', () => { + expect(() => si.electLeader(swarm.id, 'random')) + .toThrow('Invalid criterion'); + }); + + it('should increment leadersElected stat', () => { + si.electLeader(swarm.id); + expect(si.getStats().leadersElected).toBe(1); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // HEALTH & STATS + // ═══════════════════════════════════════════════════════════════════════════ + + describe('getSwarmHealth', () => { + it('should return health metrics for active swarm', () => { + const swarm = si.createSwarm('health-test', { minAgents: 2 }); + si.joinSwarm(swarm.id, 'a1', ['coding']); + si.joinSwarm(swarm.id, 'a2', ['testing']); + + const health = si.getSwarmHealth(swarm.id); + expect(health.status).toBe(SWARM_STATUS.ACTIVE); + expect(health.agentCount).toBe(2); + expect(health.meetsMinAgents).toBe(true); + expect(health.healthScore).toBeGreaterThan(0); + expect(health.avgReputation).toBe(1.0); + }); + + it('should report dissolved swarm health with score 0', () => { + const swarm = si.createSwarm('temp'); + si.dissolveSwarm(swarm.id); + + const health = si.getSwarmHealth(swarm.id); + expect(health.status).toBe(SWARM_STATUS.DISSOLVED); + expect(health.healthScore).toBe(0); + }); + + it('should reflect leader presence in health', () => { + const swarm = si.createSwarm('leader-health'); + si.joinSwarm(swarm.id, 'a1'); + + const noLeader = si.getSwarmHealth(swarm.id); + si.electLeader(swarm.id); + const withLeader = si.getSwarmHealth(swarm.id); + + expect(withLeader.healthScore).toBeGreaterThan(noLeader.healthScore); + expect(withLeader.hasLeader).toBe(true); + }); + }); + + describe('getStats', () => { + it('should return global statistics', () => { + const stats = si.getStats(); + expect(stats).toHaveProperty('swarmsCreated'); + expect(stats).toHaveProperty('swarmsDissolved'); + expect(stats).toHaveProperty('proposalsCreated'); + expect(stats).toHaveProperty('proposalsResolved'); + expect(stats).toHaveProperty('knowledgeShared'); + expect(stats).toHaveProperty('leadersElected'); + expect(stats).toHaveProperty('totalVotes'); + expect(stats).toHaveProperty('activeSwarms'); + expect(stats).toHaveProperty('totalSwarms'); + expect(stats).toHaveProperty('totalAgents'); + }); + + it('should track stats across multiple swarms', () => { + const s1 = si.createSwarm('s1'); + const s2 = si.createSwarm('s2'); + si.joinSwarm(s1.id, 'a1'); + si.joinSwarm(s2.id, 'a2'); + si.joinSwarm(s2.id, 'a3'); + + const stats = si.getStats(); + expect(stats.swarmsCreated).toBe(2); + expect(stats.activeSwarms).toBe(2); + expect(stats.totalAgents).toBe(3); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // PERSISTENCE + // ═══════════════════════════════════════════════════════════════════════════ + + describe('persistence', () => { + it('should save and load state from disk', async () => { + const persisted = new SwarmIntelligence(TEST_PROJECT_ROOT, { persist: true, debug: false }); + const swarm = persisted.createSwarm('persistent-swarm'); + persisted.joinSwarm(swarm.id, 'agent-1', ['coding']); + persisted.shareKnowledge(swarm.id, 'agent-1', { + topic: 'persistence', + content: 'works!', + }); + + // Wait for all pending async writes, then do a final save + if (persisted._pendingSave) await persisted._pendingSave; + await persisted._saveToDisk(); + + // Load into fresh instance + const loaded = new SwarmIntelligence(TEST_PROJECT_ROOT, { persist: true, debug: false }); + const success = await loaded.loadFromDisk(); + expect(success).toBe(true); + + const loadedSwarm = loaded.swarms.get(swarm.id); + expect(loadedSwarm).toBeDefined(); + expect(loadedSwarm.name).toBe('persistent-swarm'); + expect(loadedSwarm.agents.get('agent-1')).toBeDefined(); + expect(loadedSwarm.knowledgeBase.length).toBe(1); + }); + + it('should return false when no persisted file exists', async () => { + const fresh = new SwarmIntelligence(path.join(TEST_PROJECT_ROOT, 'nonexistent'), { + persist: true, + debug: false, + }); + const result = await fresh.loadFromDisk(); + expect(result).toBe(false); + }); + + it('should rethrow non-ENOENT errors in loadFromDisk', async () => { + const instance = new SwarmIntelligence(TEST_PROJECT_ROOT, { persist: true, debug: false }); + + // Write invalid JSON to the persistence file + const persistDir = path.join(TEST_PROJECT_ROOT, '.aiox'); + await fs.mkdir(persistDir, { recursive: true }); + await fs.writeFile(path.join(persistDir, 'swarms.json'), 'NOT_VALID_JSON', 'utf8'); + + // Should throw SyntaxError (JSON parse), NOT silently return false + await expect(instance.loadFromDisk()).rejects.toThrow(SyntaxError); + }); + + it('should serialize persistence writes (promise chain)', async () => { + const instance = new SwarmIntelligence(TEST_PROJECT_ROOT, { persist: true, debug: false }); + + // Create swarm to trigger multiple _persistAsync calls + instance.createSwarm('chain-test-1'); + instance.createSwarm('chain-test-2'); + instance.createSwarm('chain-test-3'); + + // _pendingSave should be a promise (chain established) + expect(instance._pendingSave).toBeInstanceOf(Promise); + + // Wait for all writes to complete + await instance._pendingSave; + + // Verify file was written (last write wins) + const raw = await fs.readFile(path.join(TEST_PROJECT_ROOT, '.aiox', 'swarms.json'), 'utf8'); + const data = JSON.parse(raw); + expect(data.swarms.length).toBe(3); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // EXPORTS + // ═══════════════════════════════════════════════════════════════════════════ + + describe('exports', () => { + it('should export SwarmIntelligence as default and named', () => { + const mod = require('../../../.aiox-core/core/orchestration/swarm-intelligence'); + expect(mod).toBe(SwarmIntelligence); + expect(mod.SwarmIntelligence).toBe(SwarmIntelligence); + }); + + it('should export constants', () => { + expect(VOTING_STRATEGIES).toBeDefined(); + expect(PROPOSAL_STATUS).toBeDefined(); + expect(SWARM_STATUS).toBeDefined(); + expect(LEADER_CRITERIA).toBeDefined(); + expect(VOTE_OPTIONS).toBeDefined(); + }); + }); +});