sf-agent-framework
Version:
AI Agent Orchestration Framework for Salesforce Development - Two-phase architecture with 70% context reduction
602 lines (506 loc) ⢠16.6 kB
JavaScript
/**
* Story Queue Manager for SF-Agent Framework
* Manages the queue of implementation stories during development phase
* Tracks story status, dependencies, and progress
*/
const fs = require('fs-extra');
const path = require('path');
const chalk = require('chalk');
const yaml = require('js-yaml');
class StoryQueueManager {
constructor(options = {}) {
this.options = {
storyDir: options.storyDir || 'docs/stories',
queueFile: options.queueFile || 'docs/stories/story-queue.yaml',
statusFile: options.statusFile || 'docs/stories/story-status.yaml',
verbose: options.verbose || false,
...options,
};
this.queue = [];
this.status = {};
this.currentStory = null;
this.storyStates = {
PENDING: 'pending',
IN_PROGRESS: 'in_progress',
IN_REVIEW: 'in_review',
COMPLETED: 'completed',
BLOCKED: 'blocked',
SKIPPED: 'skipped',
};
}
/**
* Initialize story queue from sharded documents
*/
async initializeQueue() {
console.log(chalk.blue('šÆ Initializing story queue...'));
try {
// Load existing queue if exists
if (await fs.pathExists(this.options.queueFile)) {
await this.loadQueue();
console.log(chalk.yellow(` š Loaded existing queue with ${this.queue.length} stories`));
} else {
// Build new queue from story files
await this.buildQueueFromStories();
}
// Load or initialize status
await this.loadStatus();
return this.queue;
} catch (error) {
console.error(chalk.red(`ā Error initializing queue: ${error.message}`));
throw error;
}
}
/**
* Build queue from story files in directory
*/
async buildQueueFromStories() {
console.log(chalk.cyan(' š Scanning for story files...'));
const storyFiles = await fs.readdir(this.options.storyDir);
const stories = [];
for (const file of storyFiles) {
if (file.endsWith('.md') && file.startsWith('STORY-')) {
const filepath = path.join(this.options.storyDir, file);
const story = await this.parseStoryFile(filepath);
if (story) {
stories.push(story);
}
}
}
// Sort stories by priority and dependencies
this.queue = this.sortStories(stories);
// Save queue
await this.saveQueue();
console.log(chalk.green(` ā
Created queue with ${this.queue.length} stories`));
}
/**
* Parse story file to extract metadata
*/
async parseStoryFile(filepath) {
try {
const content = await fs.readFile(filepath, 'utf-8');
const lines = content.split('\n');
const story = {
id: path.basename(filepath, '.md'),
file: filepath,
title: '',
epic: '',
priority: 'P2',
sprint: null,
estimated_hours: 0,
dependencies: [],
status: this.storyStates.PENDING,
assigned_to: null,
created_date: new Date().toISOString(),
metadata: {},
};
// Parse story metadata from content
let inMetadata = false;
for (const line of lines) {
// Extract title
if (line.startsWith('# STORY-')) {
const match = line.match(/# (STORY-[\d-]+):\s*(.+)/);
if (match) {
story.id = match[1];
story.title = match[2];
}
}
// Parse metadata section
if (line.includes('## Story Metadata') || line.includes('## Metadata')) {
inMetadata = true;
continue;
}
if (inMetadata) {
if (line.startsWith('## ')) {
inMetadata = false;
continue;
}
// Extract metadata fields
if (line.includes('Epic:')) {
story.epic = line.split('Epic:')[1].trim();
}
if (line.includes('Priority:')) {
story.priority = line.split('Priority:')[1].trim();
}
if (line.includes('Sprint:')) {
story.sprint = line.split('Sprint:')[1].trim();
}
if (line.includes('Estimated:')) {
const estimate = line.split('Estimated:')[1].trim();
story.estimated_hours = parseFloat(estimate) || 0;
}
if (line.includes('Dependencies:')) {
const deps = line.split('Dependencies:')[1].trim();
story.dependencies = deps.split(',').map((d) => d.trim());
}
if (line.includes('Assigned:')) {
story.assigned_to = line.split('Assigned:')[1].trim();
}
}
}
return story;
} catch (error) {
console.error(chalk.red(` ā ļø Error parsing story ${filepath}: ${error.message}`));
return null;
}
}
/**
* Sort stories by priority and dependencies
*/
sortStories(stories) {
// Priority order
const priorityOrder = { P0: 0, P1: 1, P2: 2, P3: 3 };
// Sort by priority first
stories.sort((a, b) => {
const aPriority = priorityOrder[a.priority] || 99;
const bPriority = priorityOrder[b.priority] || 99;
return aPriority - bPriority;
});
// Resolve dependencies
const sorted = [];
const added = new Set();
const addStory = (story) => {
if (added.has(story.id)) return;
// Add dependencies first
for (const depId of story.dependencies) {
const dep = stories.find((s) => s.id === depId);
if (dep) {
addStory(dep);
}
}
// Add the story
sorted.push(story);
added.add(story.id);
};
// Add all stories respecting dependencies
for (const story of stories) {
addStory(story);
}
return sorted;
}
/**
* Get next story from queue
*/
async getNextStory() {
// Find next pending story
const nextStory = this.queue.find(
(story) => story.status === this.storyStates.PENDING && this.areDependenciesMet(story)
);
if (!nextStory) {
console.log(chalk.yellow('š No available stories in queue'));
// Check for blocked stories
const blocked = this.queue.filter((s) => s.status === this.storyStates.BLOCKED);
if (blocked.length > 0) {
console.log(chalk.red(` ā ļø ${blocked.length} stories are blocked`));
}
return null;
}
// Update status
nextStory.status = this.storyStates.IN_PROGRESS;
nextStory.started_date = new Date().toISOString();
this.currentStory = nextStory;
// Save status
await this.saveStatus();
console.log(chalk.green(`\nš Next Story: ${nextStory.id}`));
console.log(` Title: ${nextStory.title}`);
console.log(` Priority: ${nextStory.priority}`);
console.log(` Estimated: ${nextStory.estimated_hours} hours`);
return nextStory;
}
/**
* Check if story dependencies are met
*/
areDependenciesMet(story) {
if (!story.dependencies || story.dependencies.length === 0) {
return true;
}
for (const depId of story.dependencies) {
const dep = this.queue.find((s) => s.id === depId);
if (!dep || dep.status !== this.storyStates.COMPLETED) {
return false;
}
}
return true;
}
/**
* Update story status
*/
async updateStoryStatus(storyId, newStatus, notes = '') {
const story = this.queue.find((s) => s.id === storyId);
if (!story) {
throw new Error(`Story ${storyId} not found in queue`);
}
const oldStatus = story.status;
story.status = newStatus;
story.last_updated = new Date().toISOString();
// Add status transition
if (!story.status_history) {
story.status_history = [];
}
story.status_history.push({
from: oldStatus,
to: newStatus,
date: new Date().toISOString(),
notes: notes,
});
// Set completion date if completed
if (newStatus === this.storyStates.COMPLETED) {
story.completed_date = new Date().toISOString();
if (story.started_date) {
const started = new Date(story.started_date);
const completed = new Date(story.completed_date);
story.actual_hours = (completed - started) / (1000 * 60 * 60);
}
}
// Save status
await this.saveStatus();
console.log(chalk.cyan(` ā
Updated ${storyId}: ${oldStatus} ā ${newStatus}`));
// Check for newly unblocked stories
if (newStatus === this.storyStates.COMPLETED) {
this.checkUnblockedStories();
}
return story;
}
/**
* Check for stories that are now unblocked
*/
checkUnblockedStories() {
const unblocked = [];
for (const story of this.queue) {
if (story.status === this.storyStates.BLOCKED) {
if (this.areDependenciesMet(story)) {
story.status = this.storyStates.PENDING;
unblocked.push(story.id);
}
}
}
if (unblocked.length > 0) {
console.log(chalk.green(` š Unblocked stories: ${unblocked.join(', ')}`));
}
}
/**
* Get queue status summary
*/
getQueueStatus() {
const summary = {
total: this.queue.length,
pending: 0,
in_progress: 0,
in_review: 0,
completed: 0,
blocked: 0,
skipped: 0,
completion_percentage: 0,
estimated_remaining_hours: 0,
actual_completed_hours: 0,
};
for (const story of this.queue) {
summary[story.status] = (summary[story.status] || 0) + 1;
if (story.status === this.storyStates.COMPLETED) {
summary.actual_completed_hours += story.actual_hours || 0;
} else if (story.status !== this.storyStates.SKIPPED) {
summary.estimated_remaining_hours += story.estimated_hours || 0;
}
}
summary.completion_percentage = Math.round((summary.completed / summary.total) * 100);
return summary;
}
/**
* Display queue status
*/
displayQueueStatus() {
const status = this.getQueueStatus();
console.log(chalk.blue('\nš Story Queue Status'));
console.log(chalk.gray('ā'.repeat(40)));
console.log(`Total Stories: ${status.total}`);
console.log(` ${chalk.gray('ā')} Pending: ${status.pending}`);
console.log(` ${chalk.yellow('ā')} In Progress: ${status.in_progress}`);
console.log(` ${chalk.cyan('ā')} In Review: ${status.in_review}`);
console.log(` ${chalk.green('ā')} Completed: ${status.completed}`);
console.log(` ${chalk.red('ā')} Blocked: ${status.blocked}`);
console.log(` ${chalk.gray('ā')} Skipped: ${status.skipped}`);
console.log(chalk.gray('ā'.repeat(40)));
console.log(`Completion: ${status.completion_percentage}%`);
console.log(`Remaining Hours: ${status.estimated_remaining_hours.toFixed(1)}`);
console.log(`Completed Hours: ${status.actual_completed_hours.toFixed(1)}`);
// Show progress bar
const progressBar = this.createProgressBar(status.completion_percentage);
console.log(`Progress: ${progressBar}`);
// Show current story
if (this.currentStory) {
console.log(chalk.gray('ā'.repeat(40)));
console.log(chalk.yellow(`Current: ${this.currentStory.id}`));
console.log(` ${this.currentStory.title}`);
}
}
/**
* Create visual progress bar
*/
createProgressBar(percentage) {
const width = 30;
const filled = Math.round((percentage / 100) * width);
const empty = width - filled;
const bar = chalk.green('ā'.repeat(filled)) + chalk.gray('ā'.repeat(empty));
return `[${bar}] ${percentage}%`;
}
/**
* Get stories by status
*/
getStoriesByStatus(status) {
return this.queue.filter((story) => story.status === status);
}
/**
* Get blocked stories with reasons
*/
getBlockedStories() {
const blocked = [];
for (const story of this.queue) {
if (
story.status === this.storyStates.BLOCKED ||
(story.status === this.storyStates.PENDING && !this.areDependenciesMet(story))
) {
const blockingDeps = [];
for (const depId of story.dependencies || []) {
const dep = this.queue.find((s) => s.id === depId);
if (!dep || dep.status !== this.storyStates.COMPLETED) {
blockingDeps.push(depId);
}
}
blocked.push({
story: story,
blocking_dependencies: blockingDeps,
});
}
}
return blocked;
}
/**
* Load queue from file
*/
async loadQueue() {
if (await fs.pathExists(this.options.queueFile)) {
const content = await fs.readFile(this.options.queueFile, 'utf-8');
this.queue = yaml.load(content) || [];
}
}
/**
* Save queue to file
*/
async saveQueue() {
await fs.ensureDir(path.dirname(this.options.queueFile));
await fs.writeFile(this.options.queueFile, yaml.dump(this.queue, { indent: 2 }));
}
/**
* Load status from file
*/
async loadStatus() {
if (await fs.pathExists(this.options.statusFile)) {
const content = await fs.readFile(this.options.statusFile, 'utf-8');
this.status = yaml.load(content) || {};
// Sync status with queue
for (const story of this.queue) {
if (this.status[story.id]) {
Object.assign(story, this.status[story.id]);
}
}
}
}
/**
* Save status to file
*/
async saveStatus() {
// Build status object from queue
this.status = {};
for (const story of this.queue) {
this.status[story.id] = {
status: story.status,
assigned_to: story.assigned_to,
started_date: story.started_date,
completed_date: story.completed_date,
actual_hours: story.actual_hours,
status_history: story.status_history,
};
}
await fs.ensureDir(path.dirname(this.options.statusFile));
await fs.writeFile(this.options.statusFile, yaml.dump(this.status, { indent: 2 }));
}
/**
* Export queue report
*/
async exportReport(outputFile) {
const report = {
generated_date: new Date().toISOString(),
summary: this.getQueueStatus(),
stories: this.queue,
blocked_analysis: this.getBlockedStories(),
};
await fs.writeJson(outputFile, report, { spaces: 2 });
console.log(chalk.green(`š Report exported to ${outputFile}`));
}
}
// CLI Interface
if (require.main === module) {
const program = require('commander');
program.version('1.0.0').description('Manage story queue for Salesforce development');
program
.command('init')
.description('Initialize story queue from story files')
.option('-d, --dir <directory>', 'Story directory', 'docs/stories')
.action(async (options) => {
const manager = new StoryQueueManager({ storyDir: options.dir });
await manager.initializeQueue();
manager.displayQueueStatus();
});
program
.command('next')
.description('Get next story from queue')
.action(async () => {
const manager = new StoryQueueManager();
await manager.initializeQueue();
const story = await manager.getNextStory();
if (story) {
console.log(chalk.blue(`\nš Story file: ${story.file}`));
}
});
program
.command('update <storyId> <status>')
.description('Update story status')
.option('-n, --notes <notes>', 'Status change notes')
.action(async (storyId, status, options) => {
const manager = new StoryQueueManager();
await manager.initializeQueue();
await manager.updateStoryStatus(storyId, status, options.notes || '');
manager.displayQueueStatus();
});
program
.command('status')
.description('Display queue status')
.action(async () => {
const manager = new StoryQueueManager();
await manager.initializeQueue();
manager.displayQueueStatus();
});
program
.command('blocked')
.description('Show blocked stories')
.action(async () => {
const manager = new StoryQueueManager();
await manager.initializeQueue();
const blocked = manager.getBlockedStories();
console.log(chalk.red(`\nš« Blocked Stories: ${blocked.length}`));
for (const item of blocked) {
console.log(`\n${item.story.id}: ${item.story.title}`);
console.log(` Blocked by: ${item.blocking_dependencies.join(', ')}`);
}
});
program
.command('report <output>')
.description('Export queue report')
.action(async (output) => {
const manager = new StoryQueueManager();
await manager.initializeQueue();
await manager.exportReport(output);
});
program.parse();
}
module.exports = StoryQueueManager;