diff --git a/.aios-core/core/memory/strategy-optimizer.js b/.aios-core/core/memory/strategy-optimizer.js new file mode 100644 index 0000000000..97873034e8 --- /dev/null +++ b/.aios-core/core/memory/strategy-optimizer.js @@ -0,0 +1,424 @@ +#!/usr/bin/env node + +/** + * AIOX Strategy Optimizer + * + * Story: 9.7 - Strategy Optimizer + * Epic: Epic 9 - Persistent Memory Layer + * + * Combines insights from Decision Memory and Reflection Engine to + * automatically optimize agent strategies. Implements A/B testing + * of strategies and promotes winners based on statistical evidence. + * + * Features: + * - AC1: strategy-optimizer.js in .aios-core/core/memory/ + * - AC2: Persists experiments in .aiox/strategy-experiments.json + * - AC3: Defines strategy experiments with variants and metrics + * - AC4: Assigns variants to tasks using weighted random selection + * - AC5: Collects results and computes statistical significance + * - AC6: Auto-promotes winning strategies when confidence threshold met + * - AC7: Maintains a strategy registry of current best strategies + * - AC8: Emits events for experiment lifecycle + * + * @author @dev (Dex) + * @version 1.0.0 + */ + +const fs = require('fs'); +const path = require('path'); +const EventEmitter = require('events'); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// CONFIGURATION +// ═══════════════════════════════════════════════════════════════════════════════════ + +const CONFIG = { + experimentsPath: '.aiox/strategy-experiments.json', + maxExperiments: 50, + minSampleSize: 10, + confidenceThreshold: 0.75, + maxConcurrentExperiments: 5, + version: '1.0.0', + schemaVersion: 'aiox-strategy-optimizer-v1', +}; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// ENUMS +// ═══════════════════════════════════════════════════════════════════════════════════ + +const ExperimentStatus = { + RUNNING: 'running', + CONCLUDED: 'concluded', + CANCELLED: 'cancelled', +}; + +const Events = { + EXPERIMENT_CREATED: 'experiment:created', + EXPERIMENT_CONCLUDED: 'experiment:concluded', + EXPERIMENT_CANCELLED: 'experiment:cancelled', + RESULT_RECORDED: 'result:recorded', + STRATEGY_PROMOTED: 'strategy:promoted', +}; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// STRATEGY OPTIMIZER +// ═══════════════════════════════════════════════════════════════════════════════════ + +class StrategyOptimizer extends EventEmitter { + constructor(options = {}) { + super(); + this.projectRoot = options.projectRoot || process.cwd(); + this.config = { ...CONFIG, ...options.config }; + this.experiments = []; + this.bestStrategies = new Map(); // taskType → strategy + this._loaded = false; + } + + /** + * Get the experiments file path + */ + _getFilePath() { + return path.join(this.projectRoot, this.config.experimentsPath); + } + + /** + * Load experiments from disk + */ + async load() { + const filePath = this._getFilePath(); + try { + if (fs.existsSync(filePath)) { + const raw = fs.readFileSync(filePath, 'utf8'); + const data = JSON.parse(raw); + + if (data.schemaVersion !== this.config.schemaVersion) { + this.experiments = []; + this.bestStrategies = new Map(); + this._loaded = true; + return; + } + + this.experiments = Array.isArray(data.experiments) ? data.experiments : []; + this.bestStrategies = new Map(Object.entries(data.bestStrategies || {})); + } + } catch { + this.experiments = []; + this.bestStrategies = new Map(); + } + this._loaded = true; + } + + /** + * Save experiments to disk + */ + async save() { + const filePath = this._getFilePath(); + const dir = path.dirname(filePath); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const data = { + schemaVersion: this.config.schemaVersion, + version: this.config.version, + savedAt: new Date().toISOString(), + experiments: this.experiments, + bestStrategies: Object.fromEntries(this.bestStrategies), + }; + + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8'); + } + + /** + * Create a new A/B experiment + * + * @param {Object} experiment + * @param {string} experiment.name - Experiment name + * @param {string} experiment.taskType - Task type being optimized + * @param {string[]} experiment.variants - Strategy variant names + * @param {number[]} [experiment.weights] - Assignment weights (default: equal) + * @param {string} [experiment.description] - Experiment description + * @returns {Object} Created experiment + */ + createExperiment(experiment) { + if (!experiment.name || !experiment.taskType || !experiment.variants) { + throw new Error('Required fields: name, taskType, variants'); + } + if (!Array.isArray(experiment.variants) || experiment.variants.length < 2) { + throw new Error('variants must be an array with at least 2 entries'); + } + + // Check concurrent limit + const running = this.experiments.filter((e) => e.status === ExperimentStatus.RUNNING).length; + if (running >= this.config.maxConcurrentExperiments) { + throw new Error( + `Max concurrent experiments (${this.config.maxConcurrentExperiments}) reached`, + ); + } + + const weights = experiment.weights || experiment.variants.map(() => 1); + + const entry = { + id: `exp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + name: experiment.name, + taskType: experiment.taskType, + description: experiment.description || '', + variants: experiment.variants.map((name, i) => ({ + name, + weight: weights[i] || 1, + results: [], + successCount: 0, + failureCount: 0, + totalDurationMs: 0, + })), + status: ExperimentStatus.RUNNING, + createdAt: new Date().toISOString(), + concludedAt: null, + winner: null, + }; + + this.experiments.push(entry); + this.emit(Events.EXPERIMENT_CREATED, { id: entry.id, name: entry.name }); + return entry; + } + + /** + * Get the variant to use for a task (weighted random assignment) + * + * @param {string} experimentId - Experiment ID + * @returns {string} Selected variant name + */ + assignVariant(experimentId) { + const exp = this.experiments.find((e) => e.id === experimentId); + if (!exp) throw new Error(`Unknown experiment: ${experimentId}`); + if (exp.status !== ExperimentStatus.RUNNING) { + return exp.winner || exp.variants[0].name; + } + + const totalWeight = exp.variants.reduce((sum, v) => sum + v.weight, 0); + let random = Math.random() * totalWeight; + + for (const variant of exp.variants) { + random -= variant.weight; + if (random <= 0) return variant.name; + } + + return exp.variants[exp.variants.length - 1].name; + } + + /** + * Record a result for a variant + * + * @param {Object} result + * @param {string} result.experimentId - Experiment ID + * @param {string} result.variant - Variant name + * @param {boolean} result.success - Whether it succeeded + * @param {number} [result.durationMs] - Execution duration + * @param {Object} [result.metadata] - Additional data + * @returns {Object} Updated experiment + */ + recordResult(result) { + if (!result.experimentId || !result.variant || result.success === undefined) { + throw new Error('Required fields: experimentId, variant, success'); + } + + const exp = this.experiments.find((e) => e.id === result.experimentId); + if (!exp) throw new Error(`Unknown experiment: ${result.experimentId}`); + if (exp.status !== ExperimentStatus.RUNNING) { + throw new Error(`Experiment ${result.experimentId} is not running`); + } + + const variant = exp.variants.find((v) => v.name === result.variant); + if (!variant) throw new Error(`Unknown variant: ${result.variant}`); + + variant.results.push({ + success: result.success, + durationMs: result.durationMs || null, + metadata: result.metadata || null, + recordedAt: new Date().toISOString(), + }); + + if (result.success) variant.successCount++; + else variant.failureCount++; + + if (result.durationMs) variant.totalDurationMs += result.durationMs; + + this.emit(Events.RESULT_RECORDED, { + experimentId: exp.id, + variant: result.variant, + success: result.success, + }); + + // Check if we should conclude + this._checkConclusion(exp); + + return exp; + } + + /** + * Get the best known strategy for a task type + * + * @param {string} taskType - Task type + * @returns {string|null} Best strategy name or null + */ + getBestStrategy(taskType) { + return this.bestStrategies.get(taskType) || null; + } + + /** + * Manually set the best strategy for a task type + * + * @param {string} taskType + * @param {string} strategy + */ + setBestStrategy(taskType, strategy) { + this.bestStrategies.set(taskType, strategy); + } + + /** + * Cancel a running experiment + * + * @param {string} experimentId + * @returns {Object} Cancelled experiment + */ + cancelExperiment(experimentId) { + const exp = this.experiments.find((e) => e.id === experimentId); + if (!exp) throw new Error(`Unknown experiment: ${experimentId}`); + + exp.status = ExperimentStatus.CANCELLED; + exp.concludedAt = new Date().toISOString(); + this.emit(Events.EXPERIMENT_CANCELLED, { id: exp.id, name: exp.name }); + return exp; + } + + /** + * Get experiment by ID + */ + getExperiment(experimentId) { + return this.experiments.find((e) => e.id === experimentId) || null; + } + + /** + * List experiments with optional filter + */ + listExperiments(filter = {}) { + let results = [...this.experiments]; + if (filter.status) results = results.filter((e) => e.status === filter.status); + if (filter.taskType) results = results.filter((e) => e.taskType === filter.taskType); + if (filter.limit) results = results.slice(-filter.limit); + return results; + } + + /** + * Get statistics + */ + getStats() { + const byStatus = {}; + for (const exp of this.experiments) { + byStatus[exp.status] = (byStatus[exp.status] || 0) + 1; + } + + return { + totalExperiments: this.experiments.length, + totalBestStrategies: this.bestStrategies.size, + byStatus, + bestStrategies: Object.fromEntries(this.bestStrategies), + }; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // INTERNAL METHODS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Check if experiment should conclude based on data + * @private + */ + _checkConclusion(experiment) { + // Need minimum samples for each variant + const allMeetMinimum = experiment.variants.every( + (v) => v.results.length >= this.config.minSampleSize, + ); + if (!allMeetMinimum) return; + + // Calculate success rates + const rates = experiment.variants.map((v) => ({ + name: v.name, + rate: v.results.length > 0 ? v.successCount / v.results.length : 0, + count: v.results.length, + avgDuration: + v.results.filter((r) => r.durationMs).length > 0 + ? v.totalDurationMs / v.results.filter((r) => r.durationMs).length + : null, + })); + + // Sort by success rate descending + rates.sort((a, b) => b.rate - a.rate); + + const best = rates[0]; + const secondBest = rates[1]; + + // Compute confidence (simplified z-score approximation) + const confidence = this._computeConfidence(best, secondBest); + + if (confidence >= this.config.confidenceThreshold) { + experiment.status = ExperimentStatus.CONCLUDED; + experiment.concludedAt = new Date().toISOString(); + experiment.winner = best.name; + experiment.confidence = confidence; + + // Auto-promote winner + this.bestStrategies.set(experiment.taskType, best.name); + + this.emit(Events.EXPERIMENT_CONCLUDED, { + id: experiment.id, + winner: best.name, + confidence, + rates, + }); + + this.emit(Events.STRATEGY_PROMOTED, { + taskType: experiment.taskType, + strategy: best.name, + confidence, + experimentId: experiment.id, + }); + } + } + + /** + * Compute confidence that variant A is better than B + * Uses simplified proportion test + * @private + */ + _computeConfidence(a, b) { + if (a.count === 0 || b.count === 0) return 0; + + const diff = a.rate - b.rate; + if (diff <= 0) return 0; + + // Pooled standard error + const pooledP = (a.rate * a.count + b.rate * b.count) / (a.count + b.count); + const se = Math.sqrt(pooledP * (1 - pooledP) * (1 / a.count + 1 / b.count)); + + if (se === 0) return diff > 0 ? 1.0 : 0; + + // z-score to approximate confidence + const z = diff / se; + + // Simple sigmoid-like mapping: z=1.65→90%, z=1.96→95%, z=2.58→99% + const confidence = 1 / (1 + Math.exp(-1.7 * (z - 1))); + return Math.min(1.0, Math.max(0, confidence)); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// EXPORTS +// ═══════════════════════════════════════════════════════════════════════════════════ + +module.exports = StrategyOptimizer; +module.exports.StrategyOptimizer = StrategyOptimizer; +module.exports.ExperimentStatus = ExperimentStatus; +module.exports.Events = Events; +module.exports.CONFIG = CONFIG; diff --git a/.aiox-core/core/orchestration/skill-dispatcher.js b/.aiox-core/core/orchestration/skill-dispatcher.js index bc8fc60f80..0c97a04d51 100644 --- a/.aiox-core/core/orchestration/skill-dispatcher.js +++ b/.aiox-core/core/orchestration/skill-dispatcher.js @@ -356,6 +356,7 @@ class SkillDispatcher { * @returns {boolean} */ isValidAgent(agentId) { + if (!agentId || typeof agentId !== 'string') return false; return agentId in this.skillMapping || agentId.startsWith('AIOX:'); } } diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 2516e4c299..50465aed2a 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,7 +8,7 @@ # - File types for categorization # version: 5.0.3 -generated_at: "2026-03-10T17:08:07.160Z" +generated_at: "2026-03-11T02:25:02.232Z" generator: scripts/generate-install-manifest.js file_count: 1089 files: @@ -921,9 +921,9 @@ files: type: core size: 24781 - path: core/orchestration/skill-dispatcher.js - hash: sha256:5ebee66973a1df5d9dfed195ac6d83765a92023ba504e1814674345094fb8117 + hash: sha256:39223a859e7c7e402503be95ae99d5b84a685b15588fb5787cbe8932e0481a64 type: core - size: 10490 + size: 10553 - path: core/orchestration/subagent-prompt-builder.js hash: sha256:c014eaae229e6c84742273701a6ef21a19343e34ef8be38d5d6a9bc59ecd8b02 type: core @@ -2583,19 +2583,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 +2603,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 +2615,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 +2623,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 +3367,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 +3383,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 +3395,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 +3507,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 +3691,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 +3743,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 +3807,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 +3915,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 +3923,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 +3975,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 +3987,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/memory/strategy-optimizer.test.js b/tests/core/memory/strategy-optimizer.test.js new file mode 100644 index 0000000000..7c9d956622 --- /dev/null +++ b/tests/core/memory/strategy-optimizer.test.js @@ -0,0 +1,553 @@ +/** + * Tests for Strategy Optimizer + * + * Story: 9.7 - Strategy Optimizer + * Epic: Epic 9 - Persistent Memory Layer + */ + +const fs = require('fs'); +const path = require('path'); +const StrategyOptimizer = require('../../../.aios-core/core/memory/strategy-optimizer'); +const { ExperimentStatus, Events, CONFIG } = StrategyOptimizer; + +const TEST_DIR = path.join(__dirname, '__test-strategy-optimizer__'); + +function createOptimizer(overrides = {}) { + return new StrategyOptimizer({ + projectRoot: TEST_DIR, + config: { + experimentsPath: '.aiox/strategy-experiments.json', + minSampleSize: 3, + confidenceThreshold: 0.5, + ...overrides, + }, + }); +} + +beforeEach(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } + fs.mkdirSync(TEST_DIR, { recursive: true }); +}); + +afterAll(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// CONSTRUCTOR +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('StrategyOptimizer - constructor', () => { + test('initializes with defaults', () => { + const opt = createOptimizer(); + expect(opt.experiments).toEqual([]); + expect(opt.bestStrategies.size).toBe(0); + expect(opt._loaded).toBe(false); + }); + + test('accepts custom config', () => { + const opt = createOptimizer({ minSampleSize: 20 }); + expect(opt.config.minSampleSize).toBe(20); + }); + + test('is an EventEmitter', () => { + const opt = createOptimizer(); + expect(typeof opt.on).toBe('function'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// LOAD / SAVE +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('StrategyOptimizer - load/save', () => { + test('loads from empty state', async () => { + const opt = createOptimizer(); + await opt.load(); + expect(opt._loaded).toBe(true); + expect(opt.experiments).toEqual([]); + }); + + test('save and load round-trip', async () => { + const opt1 = createOptimizer(); + await opt1.load(); + opt1.createExperiment({ + name: 'Test', + taskType: 'implementation', + variants: ['a', 'b'], + }); + opt1.setBestStrategy('debugging', 'log-analysis'); + await opt1.save(); + + const opt2 = createOptimizer(); + await opt2.load(); + expect(opt2.experiments).toHaveLength(1); + expect(opt2.bestStrategies.get('debugging')).toBe('log-analysis'); + }); + + test('handles corrupted file', async () => { + const dir = path.join(TEST_DIR, '.aiox'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'strategy-experiments.json'), 'INVALID'); + + const opt = createOptimizer(); + await opt.load(); + expect(opt.experiments).toEqual([]); + }); + + test('resets on schema mismatch', async () => { + const dir = path.join(TEST_DIR, '.aiox'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'strategy-experiments.json'), + JSON.stringify({ schemaVersion: 'old', experiments: [{ id: 'x' }] }), + ); + + const opt = createOptimizer(); + await opt.load(); + expect(opt.experiments).toEqual([]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// CREATE EXPERIMENT +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('StrategyOptimizer - createExperiment', () => { + test('creates experiment with variants', () => { + const opt = createOptimizer(); + const exp = opt.createExperiment({ + name: 'TDD vs Hack', + taskType: 'implementation', + variants: ['tdd', 'hack-first'], + description: 'Testing which approach works better', + }); + + expect(exp.id).toMatch(/^exp_/); + expect(exp.name).toBe('TDD vs Hack'); + expect(exp.variants).toHaveLength(2); + expect(exp.status).toBe(ExperimentStatus.RUNNING); + expect(exp.variants[0].name).toBe('tdd'); + expect(exp.variants[0].results).toEqual([]); + }); + + test('throws on missing required fields', () => { + const opt = createOptimizer(); + expect(() => opt.createExperiment({})).toThrow('Required fields'); + expect(() => + opt.createExperiment({ name: 'X', taskType: 'Y' }), + ).toThrow('Required fields'); + }); + + test('throws on less than 2 variants', () => { + const opt = createOptimizer(); + expect(() => + opt.createExperiment({ name: 'X', taskType: 'Y', variants: ['only-one'] }), + ).toThrow('at least 2'); + }); + + test('throws on max concurrent experiments', () => { + const opt = createOptimizer({ maxConcurrentExperiments: 2 }); + + opt.createExperiment({ name: 'E1', taskType: 't1', variants: ['a', 'b'] }); + opt.createExperiment({ name: 'E2', taskType: 't2', variants: ['a', 'b'] }); + + expect(() => + opt.createExperiment({ name: 'E3', taskType: 't3', variants: ['a', 'b'] }), + ).toThrow('Max concurrent'); + }); + + test('emits EXPERIMENT_CREATED event', () => { + const opt = createOptimizer(); + const handler = jest.fn(); + opt.on(Events.EXPERIMENT_CREATED, handler); + + opt.createExperiment({ name: 'Test', taskType: 'impl', variants: ['a', 'b'] }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + test('assigns equal weights by default', () => { + const opt = createOptimizer(); + const exp = opt.createExperiment({ + name: 'Test', + taskType: 'impl', + variants: ['a', 'b', 'c'], + }); + + expect(exp.variants.every((v) => v.weight === 1)).toBe(true); + }); + + test('accepts custom weights', () => { + const opt = createOptimizer(); + const exp = opt.createExperiment({ + name: 'Test', + taskType: 'impl', + variants: ['a', 'b'], + weights: [3, 1], + }); + + expect(exp.variants[0].weight).toBe(3); + expect(exp.variants[1].weight).toBe(1); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// ASSIGN VARIANT +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('StrategyOptimizer - assignVariant', () => { + test('assigns a variant from running experiment', () => { + const opt = createOptimizer(); + const exp = opt.createExperiment({ + name: 'Test', + taskType: 'impl', + variants: ['a', 'b'], + }); + + const assigned = opt.assignVariant(exp.id); + expect(['a', 'b']).toContain(assigned); + }); + + test('throws for unknown experiment', () => { + const opt = createOptimizer(); + expect(() => opt.assignVariant('fake-id')).toThrow('Unknown experiment'); + }); + + test('returns winner for concluded experiment', () => { + const opt = createOptimizer(); + const exp = opt.createExperiment({ + name: 'Test', + taskType: 'impl', + variants: ['winner', 'loser'], + }); + exp.status = ExperimentStatus.CONCLUDED; + exp.winner = 'winner'; + + expect(opt.assignVariant(exp.id)).toBe('winner'); + }); + + test('respects weights in assignment distribution', () => { + const opt = createOptimizer(); + const exp = opt.createExperiment({ + name: 'Test', + taskType: 'impl', + variants: ['heavy', 'light'], + weights: [99, 1], + }); + + // With 99:1 weight, most assignments should be 'heavy' + const counts = { heavy: 0, light: 0 }; + for (let i = 0; i < 100; i++) { + counts[opt.assignVariant(exp.id)]++; + } + expect(counts.heavy).toBeGreaterThan(80); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// RECORD RESULT +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('StrategyOptimizer - recordResult', () => { + test('records success result', () => { + const opt = createOptimizer(); + const exp = opt.createExperiment({ + name: 'Test', + taskType: 'impl', + variants: ['a', 'b'], + }); + + opt.recordResult({ experimentId: exp.id, variant: 'a', success: true, durationMs: 5000 }); + + const variant = exp.variants.find((v) => v.name === 'a'); + expect(variant.results).toHaveLength(1); + expect(variant.successCount).toBe(1); + expect(variant.failureCount).toBe(0); + }); + + test('records failure result', () => { + const opt = createOptimizer(); + const exp = opt.createExperiment({ + name: 'Test', + taskType: 'impl', + variants: ['a', 'b'], + }); + + opt.recordResult({ experimentId: exp.id, variant: 'b', success: false }); + const variant = exp.variants.find((v) => v.name === 'b'); + expect(variant.failureCount).toBe(1); + }); + + test('throws on missing fields', () => { + const opt = createOptimizer(); + expect(() => opt.recordResult({})).toThrow('Required fields'); + }); + + test('throws for unknown experiment', () => { + const opt = createOptimizer(); + expect(() => + opt.recordResult({ experimentId: 'fake', variant: 'a', success: true }), + ).toThrow('Unknown experiment'); + }); + + test('throws for concluded experiment', () => { + const opt = createOptimizer(); + const exp = opt.createExperiment({ + name: 'Test', + taskType: 'impl', + variants: ['a', 'b'], + }); + exp.status = ExperimentStatus.CONCLUDED; + + expect(() => + opt.recordResult({ experimentId: exp.id, variant: 'a', success: true }), + ).toThrow('not running'); + }); + + test('throws for unknown variant', () => { + const opt = createOptimizer(); + const exp = opt.createExperiment({ + name: 'Test', + taskType: 'impl', + variants: ['a', 'b'], + }); + + expect(() => + opt.recordResult({ experimentId: exp.id, variant: 'c', success: true }), + ).toThrow('Unknown variant'); + }); + + test('emits RESULT_RECORDED event', () => { + const opt = createOptimizer(); + const handler = jest.fn(); + opt.on(Events.RESULT_RECORDED, handler); + + const exp = opt.createExperiment({ + name: 'Test', + taskType: 'impl', + variants: ['a', 'b'], + }); + + opt.recordResult({ experimentId: exp.id, variant: 'a', success: true }); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'a', success: true }), + ); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// AUTO-CONCLUSION +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('StrategyOptimizer - auto-conclusion', () => { + test('concludes when clear winner with enough samples', () => { + const opt = createOptimizer({ minSampleSize: 3, confidenceThreshold: 0.5 }); + const concludeHandler = jest.fn(); + const promoteHandler = jest.fn(); + opt.on(Events.EXPERIMENT_CONCLUDED, concludeHandler); + opt.on(Events.STRATEGY_PROMOTED, promoteHandler); + + const exp = opt.createExperiment({ + name: 'TDD vs Hack', + taskType: 'implementation', + variants: ['tdd', 'hack'], + }); + + // TDD: 3 successes + for (let i = 0; i < 3; i++) { + opt.recordResult({ experimentId: exp.id, variant: 'tdd', success: true }); + } + // Hack: 3 failures + for (let i = 0; i < 3; i++) { + opt.recordResult({ experimentId: exp.id, variant: 'hack', success: false }); + } + + expect(exp.status).toBe(ExperimentStatus.CONCLUDED); + expect(exp.winner).toBe('tdd'); + expect(concludeHandler).toHaveBeenCalled(); + expect(promoteHandler).toHaveBeenCalledWith( + expect.objectContaining({ taskType: 'implementation', strategy: 'tdd' }), + ); + }); + + test('promotes winner to bestStrategies', () => { + const opt = createOptimizer({ minSampleSize: 3, confidenceThreshold: 0.5 }); + const exp = opt.createExperiment({ + name: 'Test', + taskType: 'debugging', + variants: ['logs', 'debugger'], + }); + + for (let i = 0; i < 3; i++) { + opt.recordResult({ experimentId: exp.id, variant: 'logs', success: true }); + } + for (let i = 0; i < 3; i++) { + opt.recordResult({ experimentId: exp.id, variant: 'debugger', success: false }); + } + + expect(opt.getBestStrategy('debugging')).toBe('logs'); + }); + + test('does not conclude before minSampleSize', () => { + const opt = createOptimizer({ minSampleSize: 5 }); + const exp = opt.createExperiment({ + name: 'Test', + taskType: 'impl', + variants: ['a', 'b'], + }); + + for (let i = 0; i < 3; i++) { + opt.recordResult({ experimentId: exp.id, variant: 'a', success: true }); + opt.recordResult({ experimentId: exp.id, variant: 'b', success: false }); + } + + expect(exp.status).toBe(ExperimentStatus.RUNNING); // Not enough samples + }); + + test('does not conclude when results are too close', () => { + const opt = createOptimizer({ minSampleSize: 3, confidenceThreshold: 0.9 }); + const exp = opt.createExperiment({ + name: 'Close Race', + taskType: 'impl', + variants: ['a', 'b'], + }); + + // Nearly equal: 2/3 vs 1/3 + opt.recordResult({ experimentId: exp.id, variant: 'a', success: true }); + opt.recordResult({ experimentId: exp.id, variant: 'a', success: true }); + opt.recordResult({ experimentId: exp.id, variant: 'a', success: false }); + + opt.recordResult({ experimentId: exp.id, variant: 'b', success: true }); + opt.recordResult({ experimentId: exp.id, variant: 'b', success: false }); + opt.recordResult({ experimentId: exp.id, variant: 'b', success: false }); + + // With high confidence threshold, this should not conclude + expect(exp.status).toBe(ExperimentStatus.RUNNING); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// BEST STRATEGY +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('StrategyOptimizer - bestStrategies', () => { + test('getBestStrategy returns null for unknown type', () => { + const opt = createOptimizer(); + expect(opt.getBestStrategy('unknown')).toBeNull(); + }); + + test('setBestStrategy works', () => { + const opt = createOptimizer(); + opt.setBestStrategy('testing', 'property-based'); + expect(opt.getBestStrategy('testing')).toBe('property-based'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// CANCEL +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('StrategyOptimizer - cancelExperiment', () => { + test('cancels a running experiment', () => { + const opt = createOptimizer(); + const exp = opt.createExperiment({ + name: 'Test', + taskType: 'impl', + variants: ['a', 'b'], + }); + + opt.cancelExperiment(exp.id); + expect(exp.status).toBe(ExperimentStatus.CANCELLED); + expect(exp.concludedAt).not.toBeNull(); + }); + + test('emits EXPERIMENT_CANCELLED event', () => { + const opt = createOptimizer(); + const handler = jest.fn(); + opt.on(Events.EXPERIMENT_CANCELLED, handler); + + const exp = opt.createExperiment({ + name: 'Test', + taskType: 'impl', + variants: ['a', 'b'], + }); + + opt.cancelExperiment(exp.id); + expect(handler).toHaveBeenCalledTimes(1); + }); + + test('throws for unknown experiment', () => { + const opt = createOptimizer(); + expect(() => opt.cancelExperiment('fake')).toThrow('Unknown experiment'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// LIST & STATS +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('StrategyOptimizer - listExperiments', () => { + test('lists all experiments', () => { + const opt = createOptimizer(); + opt.createExperiment({ name: 'E1', taskType: 't1', variants: ['a', 'b'] }); + opt.createExperiment({ name: 'E2', taskType: 't2', variants: ['a', 'b'] }); + + expect(opt.listExperiments()).toHaveLength(2); + }); + + test('filters by status', () => { + const opt = createOptimizer(); + const exp = opt.createExperiment({ name: 'E1', taskType: 't1', variants: ['a', 'b'] }); + opt.createExperiment({ name: 'E2', taskType: 't2', variants: ['a', 'b'] }); + opt.cancelExperiment(exp.id); + + expect(opt.listExperiments({ status: ExperimentStatus.RUNNING })).toHaveLength(1); + expect(opt.listExperiments({ status: ExperimentStatus.CANCELLED })).toHaveLength(1); + }); + + test('filters by taskType', () => { + const opt = createOptimizer(); + opt.createExperiment({ name: 'E1', taskType: 'impl', variants: ['a', 'b'] }); + opt.createExperiment({ name: 'E2', taskType: 'debug', variants: ['a', 'b'] }); + + expect(opt.listExperiments({ taskType: 'impl' })).toHaveLength(1); + }); + + test('applies limit', () => { + const opt = createOptimizer(); + for (let i = 0; i < 5; i++) { + opt.createExperiment({ name: `E${i}`, taskType: `t${i}`, variants: ['a', 'b'] }); + } + expect(opt.listExperiments({ limit: 2 })).toHaveLength(2); + }); +}); + +describe('StrategyOptimizer - getStats', () => { + test('returns stats', () => { + const opt = createOptimizer(); + opt.createExperiment({ name: 'E1', taskType: 't1', variants: ['a', 'b'] }); + opt.setBestStrategy('impl', 'tdd'); + + const stats = opt.getStats(); + expect(stats.totalExperiments).toBe(1); + expect(stats.totalBestStrategies).toBe(1); + expect(stats.byStatus[ExperimentStatus.RUNNING]).toBe(1); + }); +}); + +describe('StrategyOptimizer - getExperiment', () => { + test('returns experiment by id', () => { + const opt = createOptimizer(); + const exp = opt.createExperiment({ name: 'E1', taskType: 't1', variants: ['a', 'b'] }); + expect(opt.getExperiment(exp.id)).toBe(exp); + }); + + test('returns null for unknown id', () => { + const opt = createOptimizer(); + expect(opt.getExperiment('fake')).toBeNull(); + }); +}); diff --git a/tests/core/orchestration/skill-dispatcher.test.js b/tests/core/orchestration/skill-dispatcher.test.js new file mode 100644 index 0000000000..725edefc25 --- /dev/null +++ b/tests/core/orchestration/skill-dispatcher.test.js @@ -0,0 +1,463 @@ +/** + * Unit tests for skill-dispatcher module + * + * Tests the SkillDispatcher class that maps agent IDs to AIOS Skill + * invocations and handles dispatch payloads and result parsing. + */ + +const SkillDispatcher = require('../../../.aiox-core/core/orchestration/skill-dispatcher'); + +describe('SkillDispatcher', () => { + let dispatcher; + + beforeEach(() => { + dispatcher = new SkillDispatcher(); + }); + + // ============================================================ + // Constructor + // ============================================================ + describe('constructor', () => { + test('initializes with default options', () => { + expect(dispatcher.options).toEqual({}); + expect(dispatcher.skillMapping).toBeDefined(); + expect(dispatcher.agentPersonas).toBeDefined(); + }); + + test('stores custom options', () => { + const d = new SkillDispatcher({ debug: true }); + expect(d.options).toEqual({ debug: true }); + }); + + test('has all primary agents in skill mapping', () => { + const primary = [ + 'architect', 'data-engineer', 'dev', 'qa', + 'pm', 'po', 'sm', 'analyst', + 'ux-design-expert', 'devops', 'aios-master', + ]; + for (const agent of primary) { + expect(dispatcher.skillMapping[agent]).toBeDefined(); + } + }); + + test('has aliases in skill mapping', () => { + expect(dispatcher.skillMapping['ux-expert']).toBe('AIOX:agents:ux-design-expert'); + expect(dispatcher.skillMapping['github-devops']).toBe('AIOX:agents:devops'); + }); + }); + + // ============================================================ + // getSkillName + // ============================================================ + describe('getSkillName', () => { + test('returns mapped skill name for known agents', () => { + expect(dispatcher.getSkillName('architect')).toBe('AIOX:agents:architect'); + expect(dispatcher.getSkillName('dev')).toBe('AIOX:agents:dev'); + expect(dispatcher.getSkillName('qa')).toBe('AIOX:agents:qa'); + }); + + test('resolves aliases to canonical skill', () => { + expect(dispatcher.getSkillName('ux-expert')).toBe('AIOX:agents:ux-design-expert'); + expect(dispatcher.getSkillName('github-devops')).toBe('AIOX:agents:devops'); + }); + + test('generates fallback skill name for unknown agents', () => { + expect(dispatcher.getSkillName('custom-agent')).toBe('AIOX:agents:custom-agent'); + }); + }); + + // ============================================================ + // getAgentPersona + // ============================================================ + describe('getAgentPersona', () => { + test('returns persona for known agents', () => { + expect(dispatcher.getAgentPersona('architect')).toEqual({ + name: 'Aria', title: 'System Architect', + }); + expect(dispatcher.getAgentPersona('dev')).toEqual({ + name: 'Dex', title: 'Senior Developer', + }); + expect(dispatcher.getAgentPersona('qa')).toEqual({ + name: 'Quinn', title: 'QA Guardian', + }); + }); + + test('returns default persona for unknown agents', () => { + expect(dispatcher.getAgentPersona('unknown')).toEqual({ + name: 'unknown', title: 'AIOS Agent', + }); + }); + + test('returns persona for aliases', () => { + expect(dispatcher.getAgentPersona('ux-expert')).toEqual({ + name: 'Brad', title: 'UX Design Expert', + }); + }); + }); + + // ============================================================ + // buildDispatchPayload + // ============================================================ + describe('buildDispatchPayload', () => { + const baseParams = { + agentId: 'architect', + prompt: 'Design the system architecture', + phase: { + phase: 1, + phase_name: 'Architecture', + step: 'design', + action: 'create-architecture', + task: 'design-system.md', + creates: 'docs/architecture.md', + checklist: 'arch-review', + template: 'arch-template', + }, + context: { + workflowId: 'wf-123', + yoloMode: true, + previousPhases: { 0: { agent: 'pm' } }, + executionProfile: 'fast', + executionPolicy: { risk: 'low' }, + }, + }; + + test('builds complete payload', () => { + const payload = dispatcher.buildDispatchPayload(baseParams); + + expect(payload.skill).toBe('AIOX:agents:architect'); + expect(payload.context.phase).toBe(1); + expect(payload.context.phaseName).toBe('Architecture'); + expect(payload.context.step).toBe('design'); + expect(payload.context.action).toBe('create-architecture'); + expect(payload.context.task).toBe('design-system.md'); + expect(payload.context.creates).toBe('docs/architecture.md'); + expect(payload.context.prompt).toBe('Design the system architecture'); + expect(payload.context.workflowId).toBe('wf-123'); + expect(payload.context.yoloMode).toBe(true); + expect(payload.context.executionProfile).toBe('fast'); + }); + + test('builds args string with task and output', () => { + const payload = dispatcher.buildDispatchPayload(baseParams); + + expect(payload.args).toContain('--task="design-system.md"'); + expect(payload.args).toContain('--output="docs/architecture.md"'); + expect(payload.args).toContain('--phase=1'); + expect(payload.args).toContain('--yolo'); + }); + + test('handles array creates (uses first element)', () => { + const params = { + ...baseParams, + phase: { ...baseParams.phase, creates: ['docs/a.md', 'docs/b.md'] }, + }; + + const payload = dispatcher.buildDispatchPayload(params); + expect(payload.args).toContain('--output="docs/a.md"'); + }); + + test('omits optional args when not present', () => { + const params = { + agentId: 'dev', + prompt: 'Implement', + phase: { phase: 2 }, + context: { workflowId: 'wf-1' }, + }; + + const payload = dispatcher.buildDispatchPayload(params); + expect(payload.args).not.toContain('--task'); + expect(payload.args).not.toContain('--output'); + expect(payload.args).not.toContain('--yolo'); + expect(payload.args).toContain('--phase=2'); + }); + + test('includes tech stack flags', () => { + const params = { + ...baseParams, + techStackProfile: { + hasDatabase: true, + database: { type: 'postgresql' }, + hasFrontend: true, + frontend: { framework: 'react' }, + hasTypeScript: true, + }, + }; + + const payload = dispatcher.buildDispatchPayload(params); + expect(payload.args).toContain('--has-database'); + expect(payload.args).toContain('--db-type="postgresql"'); + expect(payload.args).toContain('--has-frontend'); + expect(payload.args).toContain('--frontend="react"'); + expect(payload.args).toContain('--typescript'); + expect(payload.context.techStack).toEqual({ + hasDatabase: true, + database: { type: 'postgresql' }, + hasFrontend: true, + frontend: { framework: 'react' }, + hasTypeScript: true, + }); + }); + + test('omits tech stack flags when no profile', () => { + const payload = dispatcher.buildDispatchPayload(baseParams); + expect(payload.args).not.toContain('--has-database'); + expect(payload.args).not.toContain('--typescript'); + }); + + test('handles partial tech stack (db only)', () => { + const params = { + ...baseParams, + techStackProfile: { + hasDatabase: true, + database: { type: 'mysql' }, + hasFrontend: false, + hasTypeScript: false, + }, + }; + + const payload = dispatcher.buildDispatchPayload(params); + expect(payload.args).toContain('--has-database'); + expect(payload.args).toContain('--db-type="mysql"'); + expect(payload.args).not.toContain('--has-frontend'); + expect(payload.args).not.toContain('--typescript'); + }); + + test('defaults for missing context fields', () => { + const params = { + agentId: 'dev', + prompt: 'Build', + phase: { phase: 1 }, + context: {}, + }; + + const payload = dispatcher.buildDispatchPayload(params); + expect(payload.context.yoloMode).toBe(false); + expect(payload.context.previousPhases).toEqual({}); + expect(payload.context.executionProfile).toBeNull(); + expect(payload.context.executionPolicy).toBeNull(); + }); + }); + + // ============================================================ + // parseSkillOutput + // ============================================================ + describe('parseSkillOutput', () => { + test('handles null result', () => { + const result = dispatcher.parseSkillOutput(null); + expect(result.status).toBe('failed'); + expect(result.summary).toContain('No result'); + expect(result.timestamp).toBeDefined(); + }); + + test('handles undefined result', () => { + const result = dispatcher.parseSkillOutput(undefined); + expect(result.status).toBe('failed'); + }); + + test('passes through structured object with status', () => { + const input = { + status: 'success', + output_path: '/out.md', + summary: 'Done', + timestamp: '2025-01-01T00:00:00Z', + }; + + const result = dispatcher.parseSkillOutput(input); + expect(result.status).toBe('success'); + expect(result.output_path).toBe('/out.md'); + expect(result.timestamp).toBe('2025-01-01T00:00:00Z'); + }); + + test('adds valid ISO timestamp to structured object if missing', () => { + const input = { status: 'success', summary: 'Done' }; + const result = dispatcher.parseSkillOutput(input); + expect(result.timestamp).toBeDefined(); + expect(Number.isNaN(Date.parse(result.timestamp))).toBe(false); + }); + + test('extracts JSON from markdown code block', () => { + const input = 'Some text\n```json\n{"status":"success","summary":"Extracted","output_path":"/out.md"}\n```\nMore text'; + const result = dispatcher.parseSkillOutput(input, { creates: '/default.md' }); + + expect(result.status).toBe('success'); + expect(result.summary).toBe('Extracted'); + expect(result.output_path).toBe('/out.md'); + }); + + test('uses phase.creates when JSON has no output_path', () => { + const input = '```json\n{"summary":"Done"}\n```'; + const result = dispatcher.parseSkillOutput(input, { creates: '/phase-out.md' }); + + expect(result.output_path).toBe('/phase-out.md'); + }); + + test('parses plain JSON string', () => { + const input = '{"status":"success","summary":"Plain JSON"}'; + const result = dispatcher.parseSkillOutput(input); + + expect(result.status).toBe('success'); + expect(result.summary).toBe('Plain JSON'); + }); + + test('handles plain text as success with summary', () => { + const input = 'The architecture has been designed successfully.'; + const result = dispatcher.parseSkillOutput(input, { creates: '/arch.md' }); + + expect(result.status).toBe('success'); + expect(result.summary).toBe(input); + expect(result.output_path).toBe('/arch.md'); + }); + + test('truncates long text summaries to 500 chars', () => { + const input = 'A'.repeat(1000); + const result = dispatcher.parseSkillOutput(input); + + expect(result.summary.length).toBe(500); + }); + + test('handles invalid JSON in markdown block gracefully', () => { + const input = '```json\n{invalid json}\n```'; + const result = dispatcher.parseSkillOutput(input); + + // Falls through to plain JSON parse, fails, then plain text + expect(result.status).toBe('success'); + expect(result.summary).toContain('```json'); + }); + + test('wraps unknown types in default structure', () => { + const input = 42; + const result = dispatcher.parseSkillOutput(input, { creates: '/out.md' }); + + expect(result.status).toBe('success'); + expect(result.output).toBe(42); + expect(result.output_path).toBe('/out.md'); + }); + + test('wraps boolean in default structure', () => { + const result = dispatcher.parseSkillOutput(true); + expect(result.status).toBe('success'); + expect(result.output).toBe(true); + }); + }); + + // ============================================================ + // createSkipResult + // ============================================================ + describe('createSkipResult', () => { + test('creates skip result with all fields', () => { + const phase = { + phase: 3, + phase_name: 'QA Review', + agent: 'qa', + }; + + const result = dispatcher.createSkipResult(phase, 'No tests configured'); + + expect(result.status).toBe('skipped'); + expect(result.reason).toBe('No tests configured'); + expect(result.phase).toBe(3); + expect(result.phaseName).toBe('QA Review'); + expect(result.agent).toBe('qa'); + expect(result.timestamp).toBeDefined(); + }); + }); + + // ============================================================ + // formatDispatchLog + // ============================================================ + describe('formatDispatchLog', () => { + test('formats log with persona and details', () => { + const payload = { + skill: 'AIOX:agents:architect', + args: '--task="design.md"', + context: { + phase: 1, + phaseName: 'Architecture', + task: 'design-system.md', + creates: 'docs/arch.md', + }, + }; + + const log = dispatcher.formatDispatchLog(payload); + + expect(log).toContain('Aria'); + expect(log).toContain('@architect'); + expect(log).toContain('AIOX:agents:architect'); + expect(log).toContain('1 - Architecture'); + expect(log).toContain('design-system.md'); + expect(log).toContain('docs/arch.md'); + }); + + test('shows N/A for missing task and output', () => { + const payload = { + skill: 'AIOX:agents:dev', + args: '', + context: { phase: 2, phaseName: 'Dev' }, + }; + + const log = dispatcher.formatDispatchLog(payload); + expect(log).toContain('Task: N/A'); + expect(log).toContain('Output: N/A'); + }); + + test('uses agent ID as name for unknown agents', () => { + const payload = { + skill: 'AIOX:agents:custom', + args: '', + context: { phase: 1, phaseName: 'Custom' }, + }; + + const log = dispatcher.formatDispatchLog(payload); + expect(log).toContain('custom'); + }); + }); + + // ============================================================ + // getAvailableAgents + // ============================================================ + describe('getAvailableAgents', () => { + test('returns only primary agents (no aliases)', () => { + const agents = dispatcher.getAvailableAgents(); + + expect(agents).toContain('architect'); + expect(agents).toContain('dev'); + expect(agents).toContain('qa'); + expect(agents).toContain('devops'); + expect(agents).toContain('aios-master'); + + // Aliases should NOT be included + expect(agents).not.toContain('ux-expert'); + expect(agents).not.toContain('github-devops'); + }); + + test('returns expected count of primary agents', () => { + const agents = dispatcher.getAvailableAgents(); + expect(agents).toHaveLength(11); + }); + }); + + // ============================================================ + // isValidAgent + // ============================================================ + describe('isValidAgent', () => { + test('returns true for known agents', () => { + expect(dispatcher.isValidAgent('architect')).toBe(true); + expect(dispatcher.isValidAgent('dev')).toBe(true); + expect(dispatcher.isValidAgent('ux-expert')).toBe(true); + }); + + test('returns true for AIOX: prefixed agents', () => { + expect(dispatcher.isValidAgent('AIOX:custom:agent')).toBe(true); + }); + + test('returns false for unknown non-AIOX agents', () => { + expect(dispatcher.isValidAgent('random-agent')).toBe(false); + expect(dispatcher.isValidAgent('')).toBe(false); + }); + + test('handles null and undefined without throwing', () => { + expect(dispatcher.isValidAgent(null)).toBe(false); + expect(dispatcher.isValidAgent(undefined)).toBe(false); + }); + }); +});