Skip to content
Merged
75 changes: 61 additions & 14 deletions .aiox-core/core/config/config-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
* - Agent config: const { AgentConfigLoader } = require('./agent-config-loader');
* const loader = new AgentConfigLoader(agentId);
* const config = await loader.load(coreConfig);
* - Single section: const config = await loadConfigSections([sectionName]);
* const section = config[sectionName];
*
* AIOX Config Loader with Lazy Loading
*
Expand Down Expand Up @@ -77,7 +79,9 @@ const performanceMetrics = {
};

/**
* Checks if cache is still valid
* Checks whether the in-memory configuration cache is still valid.
*
* @returns {boolean} True when a cache timestamp exists and is still within the TTL window.
*/
function isCacheValid() {
if (!configCache.lastLoad) return false;
Expand All @@ -89,7 +93,13 @@ function isCacheValid() {
}

/**
* Loads full config file (used for initial load or cache refresh)
* Loads the full core-config.yaml file from disk.
*
* Used for initial load or cache refresh. The parsed result is stored in the
* in-memory cache and performance metrics are updated for observability.
*
* @returns {Promise<Object>} Parsed configuration object.
* @throws {Error} If the config file cannot be read or parsed.
*/
async function loadFullConfig() {
const configPath = path.join('.aiox-core', 'core-config.yaml');
Expand Down Expand Up @@ -121,8 +131,12 @@ async function loadFullConfig() {
/**
* Loads specific config sections on demand
*
* Uses the cached full config when possible; otherwise reloads the full config
* and returns only the requested sections. Unknown sections are omitted.
*
* @param {string[]} sections - Array of section names to load
* @returns {Promise<Object>} Config object with requested sections
* @returns {Promise<Object>} Config object containing the requested sections
* @throws {Error} If config file cannot be read or parsed on cache miss.
*/
async function loadConfigSections(sections) {
const startTime = Date.now();
Expand Down Expand Up @@ -160,10 +174,18 @@ async function loadConfigSections(sections) {
}

/**
* Loads config for specific agent with lazy loading
* Loads config for a specific agent with lazy loading.
*
* Only sections listed in the agent requirements map are loaded. Unknown agent
* IDs fall back to the always-loaded baseline sections.
*
* @param {string} agentId - Agent ID (e.g., 'dev', 'qa', 'po')
* @returns {Promise<Object>} Config object with sections needed by agent
* @returns {Promise<Object>} Config object with sections needed by the agent
* @throws {Error} If config file cannot be read or parsed.
*
* @example
* const config = await loadAgentConfig('dev');
* console.log(config.frameworkDocsLocation);
*/
async function loadAgentConfig(agentId) {
const startTime = Date.now();
Expand All @@ -186,16 +208,26 @@ async function loadAgentConfig(agentId) {
}

/**
* Loads always-loaded sections (minimal config)
* Loads the always-loaded baseline configuration sections.
*
* This lightweight subset is enough for startup paths that do not need
* agent-specific or tool-specific configuration yet.
*
* @returns {Promise<Object>} Minimal config with always-loaded sections
* @throws {Error} If config file cannot be read or parsed.
*/
async function loadMinimalConfig() {
return await loadConfigSections(ALWAYS_LOADED);
}

/**
* Preloads config into cache (useful for startup optimization)
* Preloads the full configuration into the in-memory cache.
*
* Useful during startup when subsequent commands need config access without
* paying the initial disk-read cost.
*
* @returns {Promise<void>}
* @throws {Error} If config file cannot be read or parsed.
*/
async function preloadConfig() {
console.log('🔄 Preloading config into cache...');
Expand All @@ -204,7 +236,12 @@ async function preloadConfig() {
}

/**
* Clears config cache (useful for testing or forcing reload)
* Clears the full config cache and section cache.
*
* Useful for tests or for forcing the next config access to read fresh data
* from disk.
*
* @returns {void}
*/
function clearCache() {
configCache.full = null;
Expand All @@ -214,9 +251,9 @@ function clearCache() {
}

/**
* Gets performance metrics
* Gets configuration loader performance metrics.
*
* @returns {Object} Performance statistics
* @returns {Object} Performance statistics including load counts, cache hit rate, and average load time.
*/
function getPerformanceMetrics() {
return {
Expand All @@ -229,10 +266,15 @@ function getPerformanceMetrics() {
}

/**
* Validates that required sections exist in config
* Validates that required sections exist in config for a specific agent.
*
* Loads the full config and checks each section required by the agent
* requirements map. Unknown agent IDs validate against the always-loaded
* baseline sections.
*
* @param {string} agentId - Agent ID to validate
* @returns {Promise<Object>} Validation result
* @returns {Promise<{valid: boolean, agentId: string, requiredSections: string[], missingSections: string[], availableSections: string[]}>} Validation result
* @throws {Error} If config file cannot be read or parsed.
*/
async function validateAgentConfig(agentId) {
const requiredSections = agentRequirements[agentId] || ALWAYS_LOADED;
Expand All @@ -253,10 +295,15 @@ async function validateAgentConfig(agentId) {
}

/**
* Gets config section on demand (async lazy load)
* Gets a single config section on demand.
*
* Uses the same lazy loading and cache behavior as loadConfigSections.
*
* @param {string} sectionName - Section to load
* @returns {Promise<any>} Section content
* @returns {Promise<*>} Section content, or undefined if the section is not present
*
* @example
* const tools = await getConfigSection('toolConfigurations');
*/
async function getConfigSection(sectionName) {
const config = await loadConfigSections([sectionName]);
Expand Down
73 changes: 54 additions & 19 deletions .aiox-core/core/events/dashboard-emitter.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,27 @@ const MONITOR_SERVER_URL = process.env.AIOX_MONITOR_URL || 'http://localhost:400
const EMIT_TIMEOUT_MS = 500;

/**
* DashboardEmitter - Singleton for emitting events to monitor-server
* DashboardEmitter - Singleton for emitting events to monitor-server.
*
* Sends high-level CLI events such as agent activation, command execution,
* story status changes, session lifecycle, and Bob orchestration events via
* non-blocking HTTP POST requests. Falls back to JSONL file writes when the
* monitor server is unavailable.
*
* @example
* const emitter = DashboardEmitter.getInstance();
* emitter.setAgent('dev');
* await emitter.emitCommandStart('*develop', ['--story', '3.1']);
*/
class DashboardEmitter {
static instance = null;

/**
* Creates a dashboard emitter instance.
*
* Uses CLAUDE_CODE_SESSION_ID when available; otherwise generates a UUID for
* session tracking. The emitter disables itself automatically in test runs.
*/
constructor() {
this.sessionId = process.env.CLAUDE_CODE_SESSION_ID || randomUUID();
this.projectRoot = process.cwd();
Expand All @@ -42,8 +58,9 @@ class DashboardEmitter {
}

/**
* Get singleton instance
* @returns {DashboardEmitter}
* Gets the singleton DashboardEmitter instance.
*
* @returns {DashboardEmitter} Shared dashboard emitter instance.
*/
static getInstance() {
if (!DashboardEmitter.instance) {
Expand All @@ -53,42 +70,54 @@ class DashboardEmitter {
}

/**
* Set current agent context
* @param {string|null} agentId
* Sets the current agent context attached to future events.
*
* @param {string|null} agentId - Current agent ID, or null to clear agent context.
* @returns {void}
*/
setAgent(agentId) {
this.currentAgent = agentId;
}

/**
* Set current story context
* @param {string|null} storyId
* Sets the current story context attached to future events.
*
* @param {string|null} storyId - Current story ID, or null to clear story context.
* @returns {void}
*/
setStoryId(storyId) {
this.currentStoryId = storyId;
}

/**
* Set session ID
* @param {string} sessionId
* Sets the session ID attached to future events.
*
* @param {string} sessionId - Session identifier.
* @returns {void}
*/
setSessionId(sessionId) {
this.sessionId = sessionId;
}

/**
* Enable/disable emitter
* @param {boolean} enabled
* Enables or disables event emission.
*
* @param {boolean} enabled - Whether the emitter should send events.
* @returns {void}
*/
setEnabled(enabled) {
this.enabled = enabled;
}

/**
* Emit an event to monitor-server
* Non-blocking, silent failure
* Emits an event to the monitor server.
*
* Emission is non-blocking for the CLI caller. HTTP failures fall back to a
* local JSONL file and never throw to the command path.
*
* @param {string} type - Event type
* @param {Object} data - Event data
* @returns {Promise<void>}
*/
async emit(type, data = {}) {
if (!this.enabled) return;
Expand Down Expand Up @@ -303,9 +332,12 @@ class DashboardEmitter {
}

/**
* POST event to monitor-server with timeout
* Posts an event to the monitor server with a short timeout.
*
* @param {Object} event - Normalized dashboard event payload.
* @returns {Promise<void>}
* @throws {Error} If the HTTP request fails or returns a non-2xx status.
* @private
* @param {Object} event
*/
async _postEvent(event) {
const controller = new AbortController();
Expand Down Expand Up @@ -339,9 +371,11 @@ class DashboardEmitter {
}

/**
* Write event to fallback file
* Writes an event to the local JSONL fallback file.
*
* @param {Object} event - Normalized dashboard event payload.
* @returns {Promise<void>}
* @private
* @param {Object} event
*/
async _writeToFile(event) {
try {
Expand All @@ -355,8 +389,9 @@ class DashboardEmitter {
}

/**
* Get singleton emitter instance
* @returns {DashboardEmitter}
* Gets the singleton dashboard emitter instance.
*
* @returns {DashboardEmitter} Shared dashboard emitter instance.
*/
function getDashboardEmitter() {
return DashboardEmitter.getInstance();
Expand Down
32 changes: 25 additions & 7 deletions .aiox-core/core/orchestration/bob-orchestrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,24 @@ const ProjectState = {
*/

/**
* BobOrchestrator — Main decision tree and orchestration entry point
* BobOrchestrator - Main decision tree and orchestration entry point.
*
* Coordinates Bob's project-state detection, session recovery, educational
* mode handling, workflow routing, and observability bridge without relying on
* LLM reasoning for routing decisions.
*/
class BobOrchestrator {
/**
* Creates a new BobOrchestrator instance
* @param {string} projectRoot - Project root directory
* Creates a new BobOrchestrator instance.
*
* Initializes all orchestration collaborators used by the Bob decision tree,
* including workflow execution, session state, file locks, lifecycle cleanup,
* brownfield and greenfield handlers, dashboard status, and message formatting.
*
* @param {string} projectRoot - Project root directory.
* @param {Object} [options] - Orchestrator options
* @param {boolean} [options.debug=false] - Enable debug logging
* @throws {Error} If projectRoot is missing or not a string.
*/
constructor(projectRoot, options = {}) {
if (!projectRoot || typeof projectRoot !== 'string') {
Expand Down Expand Up @@ -140,6 +150,12 @@ class BobOrchestrator {

/**
* Sets up observability panel callbacks (Story 12.6 - AC1, AC2, AC6-11)
*
* Wires workflow and handler lifecycle events into the CLI panel, status
* writer, and dashboard emitter. Callback failures are logged but never block
* orchestration progress.
*
* @returns {void}
* @private
*/
_setupObservabilityCallbacks() {
Expand Down Expand Up @@ -294,9 +310,7 @@ class BobOrchestrator {
* - "desativa modo educativo", "desativa educativo", "modo educativo off", "educational mode off"
*
* @param {string} userInput - User input text
* @returns {Object|null} Toggle result or null if not a toggle command
* @returns {boolean} result.enable - Whether to enable educational mode
* @returns {string} result.command - Matched command
* @returns {{enable: boolean, command: string}|null} Toggle result, or null if not a toggle command.
*/
_detectEducationalModeToggle(userInput) {
if (!userInput || typeof userInput !== 'string') {
Expand Down Expand Up @@ -396,6 +410,7 @@ class BobOrchestrator {

/**
* Gets the persistence prompt for educational mode toggle (Story 12.7 - AC6)
*
* @returns {string} Prompt message
*/
getEducationalModePersistencePrompt() {
Expand Down Expand Up @@ -537,7 +552,10 @@ class BobOrchestrator {
/**
* Checks for existing session and builds formatted summary (Story 12.5 - AC1, AC2, AC4)
*
* @returns {Promise<Object>} Session check result
* Loads the current session state, detects crash risk, computes elapsed time,
* and returns both machine-readable resume data and a user-facing summary.
*
* @returns {Promise<{hasSession: boolean, state?: Object, crashInfo?: Object, elapsedString?: string, formattedMessage?: string, summary?: Object, epicTitle?: string, currentStory?: string, currentPhase?: string}>} Session check result.
* @private
*/
async _checkExistingSession() {
Expand Down
Loading
Loading