aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
921 lines (804 loc) • 27.1 kB
JavaScript
/**
* AIOS Plan Progress Tracker
*
* Story: 4.6 - Execution Engine
* Epic: Epic 4 - Execution Engine
*
* Tracks and displays implementation progress for stories.
* Generates visual progress bars and integrates with dashboard status.json.
*
* Features:
* - AC1: Located in `.aios-core/infrastructure/scripts/`
* - AC2: Calculates total, completed, in-progress, pending, failed
* - AC3: Global `*plan-status {story-id}` command support
* - AC4: Visual progress bars output
* - AC5: Generates `plan/build-progress.txt` snapshot
* - AC6: Auto-updates after each subtask
* - AC7: Integrates with `.aios/dashboard/status.json`
*
* @author @architect (Aria)
* @version 1.0.0
*/
const fs = require('fs');
const fsPromises = require('fs').promises;
const path = require('path');
const yaml = require('js-yaml');
// ═══════════════════════════════════════════════════════════════════════════════════
// CONFIGURATION
// ═══════════════════════════════════════════════════════════════════════════════════
const CONFIG = {
progressBarWidth: 10,
progressBarFilled: '▓',
progressBarEmpty: '░',
// AC7: Dashboard integration path
dashboardStatusPath: '.aios/dashboard/status.json',
// Legacy status path for backwards compatibility
legacyStatusPath: '.aios/status.json',
// AC5: Build progress snapshot file
buildProgressFile: 'build-progress.txt',
// Plan file name
implementationFile: 'implementation.yaml',
};
// ═══════════════════════════════════════════════════════════════════════════════════
// STATUS ENUM
// ═══════════════════════════════════════════════════════════════════════════════════
const Status = {
PENDING: 'pending',
IN_PROGRESS: 'in_progress',
COMPLETED: 'completed',
FAILED: 'failed',
BLOCKED: 'blocked',
SKIPPED: 'skipped',
};
const StatusEmoji = {
[Status.PENDING]: '⏳',
[Status.IN_PROGRESS]: '🔄',
[Status.COMPLETED]: '✅',
[Status.FAILED]: '❌',
[Status.BLOCKED]: '🚫',
[Status.SKIPPED]: '⏭️',
};
// ═══════════════════════════════════════════════════════════════════════════════════
// PLAN TRACKER CLASS
// ═══════════════════════════════════════════════════════════════════════════════════
class PlanTracker {
/**
* Create a new PlanTracker instance
*
* @param {Object|string} options - Options object or story ID string
* @param {string} [options.storyId] - Story ID (e.g., 'STORY-42')
* @param {string} [options.planPath] - Explicit path to implementation.yaml
* @param {string} [options.rootPath] - Project root path (defaults to cwd)
*/
constructor(options) {
// Support both object and string (legacy) constructor
if (typeof options === 'string') {
this.storyId = options;
this.rootPath = process.cwd();
this.planPath = null;
} else {
this.storyId = options?.storyId;
this.rootPath = options?.rootPath || process.cwd();
this.planPath = options?.planPath || null;
}
this.plan = null;
this._initPaths();
}
/**
* Initialize file paths based on story ID or explicit plan path
* @private
*/
_initPaths() {
// If explicit planPath provided, use it
if (this.planPath) {
if (!path.isAbsolute(this.planPath)) {
this.planPath = path.join(this.rootPath, this.planPath);
}
// Derive progress path from plan path
this.progressPath = path.join(path.dirname(this.planPath), CONFIG.buildProgressFile);
return;
}
// Otherwise, try to find plan file for story ID
if (this.storyId) {
const planPath = this._findPlanPath();
if (planPath) {
this.planPath = planPath;
this.progressPath = path.join(path.dirname(this.planPath), CONFIG.buildProgressFile);
} else {
// Default paths for new plans
this.planPath = path.join(
this.rootPath,
'docs/stories',
this.storyId,
'plan',
CONFIG.implementationFile
);
this.progressPath = path.join(
this.rootPath,
'docs/stories',
this.storyId,
'plan',
CONFIG.buildProgressFile
);
}
}
}
/**
* Find implementation plan file in common locations
* @private
* @returns {string|null} Path to plan file or null
*/
_findPlanPath() {
const searchPaths = [
// Standard story plan location
path.join(this.rootPath, 'docs/stories', this.storyId, 'plan', CONFIG.implementationFile),
// Lowercase story ID
path.join(
this.rootPath,
'docs/stories',
this.storyId.toLowerCase(),
'plan',
CONFIG.implementationFile
),
// Story ID with hyphens
path.join(
this.rootPath,
'docs/stories',
this.storyId.replace(/[_]/g, '-'),
'plan',
CONFIG.implementationFile
),
// .aios plans location
path.join(this.rootPath, '.aios/plans', this.storyId, CONFIG.implementationFile),
// Epic-based location (e.g., aios-core-ade/story-4.6)
...this._getEpicBasedPaths(),
];
for (const p of searchPaths) {
if (fs.existsSync(p)) {
return p;
}
}
return null;
}
/**
* Get epic-based search paths
* @private
* @returns {string[]} Array of potential paths
*/
_getEpicBasedPaths() {
const paths = [];
const storiesDir = path.join(this.rootPath, 'docs/stories');
if (fs.existsSync(storiesDir)) {
try {
const epicDirs = fs
.readdirSync(storiesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
for (const epicDir of epicDirs) {
paths.push(
path.join(storiesDir, epicDir, this.storyId, 'plan', CONFIG.implementationFile),
path.join(
storiesDir,
epicDir,
this.storyId.toLowerCase(),
'plan',
CONFIG.implementationFile
)
);
}
} catch {
// Ignore errors reading directories
}
}
return paths;
}
/**
* Load implementation plan
*/
load() {
if (!fs.existsSync(this.planPath)) {
throw new Error(`Implementation plan not found: ${this.planPath}`);
}
this.plan = yaml.load(fs.readFileSync(this.planPath, 'utf-8'));
return this;
}
/**
* Calculate progress statistics
*/
getStats() {
if (!this.plan) {
this.load();
}
const stats = {
total: 0,
completed: 0,
inProgress: 0,
pending: 0,
failed: 0,
blocked: 0,
skipped: 0,
phases: [],
current: null,
};
for (const phase of this.plan.phases || []) {
const phaseStats = {
id: phase.id,
name: phase.name,
total: 0,
completed: 0,
inProgress: 0,
pending: 0,
failed: 0,
subtasks: [],
};
for (const subtask of phase.subtasks || []) {
const status = subtask.status || Status.PENDING;
phaseStats.total++;
stats.total++;
switch (status) {
case Status.COMPLETED:
phaseStats.completed++;
stats.completed++;
break;
case Status.IN_PROGRESS:
phaseStats.inProgress++;
stats.inProgress++;
if (!stats.current) {
stats.current = {
phase: phase.name,
subtask: subtask.id,
description: subtask.description,
};
}
break;
case Status.PENDING:
phaseStats.pending++;
stats.pending++;
break;
case Status.FAILED:
phaseStats.failed++;
stats.failed++;
break;
case Status.BLOCKED:
stats.blocked++;
break;
case Status.SKIPPED:
stats.skipped++;
break;
}
phaseStats.subtasks.push({
id: subtask.id,
description: subtask.description,
status,
});
}
stats.phases.push(phaseStats);
}
// Calculate percentages
stats.percentComplete = stats.total > 0 ? Math.round((stats.completed / stats.total) * 100) : 0;
for (const phase of stats.phases) {
phase.percentComplete =
phase.total > 0 ? Math.round((phase.completed / phase.total) * 100) : 0;
}
// Determine overall status
if (stats.completed === stats.total) {
stats.status = Status.COMPLETED;
} else if (stats.failed > 0) {
stats.status = Status.FAILED;
} else if (stats.inProgress > 0) {
stats.status = Status.IN_PROGRESS;
} else if (stats.blocked > 0) {
stats.status = Status.BLOCKED;
} else {
stats.status = Status.PENDING;
}
return stats;
}
/**
* Generate progress bar
*/
progressBar(percent, width = CONFIG.progressBarWidth) {
const filled = Math.round((percent / 100) * width);
const empty = width - filled;
return CONFIG.progressBarFilled.repeat(filled) + CONFIG.progressBarEmpty.repeat(empty);
}
/**
* Generate visual progress report
*/
generateReport() {
const stats = this.getStats();
const lines = [];
// Header
lines.push('');
lines.push(`📊 Implementation Progress: ${this.storyId}`);
lines.push('━'.repeat(50));
// Phase progress
for (const phase of stats.phases) {
const bar = this.progressBar(phase.percentComplete);
const count = `(${phase.completed}/${phase.total})`;
const percent = `${phase.percentComplete}%`.padStart(4);
lines.push(`${phase.name.padEnd(18)} ${bar} ${percent} ${count}`);
}
lines.push('━'.repeat(50));
// Total progress
const totalBar = this.progressBar(stats.percentComplete);
lines.push(`Total: ${stats.percentComplete}% (${stats.completed}/${stats.total} subtasks)`);
lines.push(`Status: ${StatusEmoji[stats.status]} ${stats.status.toUpperCase()}`);
// Current task
if (stats.current) {
lines.push(`Current: ${stats.current.subtask} - ${stats.current.description}`);
}
// Failures
if (stats.failed > 0) {
lines.push('');
lines.push(`⚠️ ${stats.failed} subtask(s) failed - review required`);
}
lines.push('');
return lines.join('\n');
}
/**
* Generate detailed report with all subtasks
*/
generateDetailedReport() {
const stats = this.getStats();
const lines = [];
lines.push('');
lines.push('╔' + '═'.repeat(58) + '╗');
lines.push('║' + ` Implementation Progress: ${this.storyId}`.padEnd(58) + '║');
lines.push('╚' + '═'.repeat(58) + '╝');
lines.push('');
for (const phase of stats.phases) {
const bar = this.progressBar(phase.percentComplete);
lines.push(`📁 ${phase.name}`);
lines.push(` ${bar} ${phase.percentComplete}% (${phase.completed}/${phase.total})`);
lines.push('');
for (const subtask of phase.subtasks) {
const emoji = StatusEmoji[subtask.status];
const statusText = subtask.status.padEnd(12);
lines.push(` ${emoji} ${subtask.id.padEnd(5)} ${statusText} ${subtask.description}`);
}
lines.push('');
}
// Summary
lines.push('─'.repeat(60));
lines.push('');
lines.push(`📈 Overall Progress: ${stats.percentComplete}%`);
lines.push(
` Total: ${stats.total} | Completed: ${stats.completed} | In Progress: ${stats.inProgress} | Failed: ${stats.failed}`
);
lines.push(` Status: ${StatusEmoji[stats.status]} ${stats.status.toUpperCase()}`);
if (stats.current) {
lines.push('');
lines.push(`🎯 Current: ${stats.current.subtask} - ${stats.current.description}`);
}
// Next subtask
const nextPending = this.getNextPending(stats);
if (nextPending && stats.status !== Status.COMPLETED) {
lines.push(`📌 Next: ${nextPending.id} - ${nextPending.description}`);
}
lines.push('');
return lines.join('\n');
}
/**
* Get next pending subtask
*/
getNextPending(stats) {
for (const phase of stats.phases) {
for (const subtask of phase.subtasks) {
if (subtask.status === Status.PENDING) {
return subtask;
}
}
}
return null;
}
/**
* Save progress snapshot to file
*/
saveProgress() {
const report = this.generateDetailedReport();
const dir = path.dirname(this.progressPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(this.progressPath, report, 'utf-8');
return this.progressPath;
}
/**
* Update dashboard status.json with progress (AC7)
*
* Updates `.aios/dashboard/status.json` for dashboard integration.
* Also updates legacy `.aios/status.json` for backwards compatibility.
*
* @returns {string} Path to updated status file
*/
updateStatusJson() {
const stats = this.getStats();
// AC7: Update dashboard status.json
const dashboardPath = path.join(this.rootPath, CONFIG.dashboardStatusPath);
this._updateStatusFile(dashboardPath, stats);
// Also update legacy path for backwards compatibility
const legacyPath = path.join(this.rootPath, CONFIG.legacyStatusPath);
this._updateStatusFile(legacyPath, stats);
return dashboardPath;
}
/**
* Update a specific status file
* @private
* @param {string} statusPath - Path to status file
* @param {Object} stats - Progress statistics
*/
_updateStatusFile(statusPath, stats) {
let status = {};
if (fs.existsSync(statusPath)) {
try {
status = JSON.parse(fs.readFileSync(statusPath, 'utf-8'));
} catch {
// Invalid JSON, start fresh
status = {};
}
}
// Initialize structure if needed
if (!status.version) status.version = '1.0';
if (!status.stories) {
status.stories = { inProgress: [], completed: [] };
}
// AC7: Add/update planProgress section for this story
if (!status.planProgress) {
status.planProgress = {};
}
status.planProgress[this.storyId] = {
total: stats.total,
completed: stats.completed,
inProgress: stats.inProgress,
pending: stats.pending,
failed: stats.failed,
percentage: stats.percentComplete,
status: stats.status,
current: stats.current,
phases: stats.phases.map((p) => ({
id: p.id,
name: p.name,
total: p.total,
completed: p.completed,
percentage: p.percentComplete,
})),
updatedAt: new Date().toISOString(),
};
// Update stories lists based on status
const inProgressList = status.stories.inProgress || [];
const completedList = status.stories.completed || [];
const inProgressIdx = inProgressList.indexOf(this.storyId);
const completedIdx = completedList.indexOf(this.storyId);
if (stats.status === Status.COMPLETED) {
// Move to completed list
if (inProgressIdx !== -1) {
inProgressList.splice(inProgressIdx, 1);
}
if (completedIdx === -1) {
completedList.push(this.storyId);
}
} else if (stats.status === Status.IN_PROGRESS) {
// Ensure in inProgress list
if (completedIdx !== -1) {
completedList.splice(completedIdx, 1);
}
if (inProgressIdx === -1) {
inProgressList.push(this.storyId);
}
}
status.stories.inProgress = inProgressList;
status.stories.completed = completedList;
status.updatedAt = new Date().toISOString();
// Ensure directory exists
const dir = path.dirname(statusPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(statusPath, JSON.stringify(status, null, 2), 'utf-8');
}
/**
* Update dashboard status asynchronously
* @returns {Promise<string>} Path to updated status file
*/
async updateDashboardStatus() {
return this.updateStatusJson();
}
/**
* Mark subtask as started
*/
startSubtask(subtaskId) {
return this.updateSubtaskStatus(subtaskId, Status.IN_PROGRESS);
}
/**
* Mark subtask as completed
*/
completeSubtask(subtaskId) {
return this.updateSubtaskStatus(subtaskId, Status.COMPLETED, {
completedAt: new Date().toISOString(),
});
}
/**
* Mark subtask as failed
*/
failSubtask(subtaskId, error) {
return this.updateSubtaskStatus(subtaskId, Status.FAILED, {
failedAt: new Date().toISOString(),
error,
});
}
/**
* Update subtask status in implementation.yaml
*/
updateSubtaskStatus(subtaskId, status, extra = {}) {
if (!this.plan) {
this.load();
}
let found = false;
for (const phase of this.plan.phases) {
for (const subtask of phase.subtasks) {
if (subtask.id === subtaskId) {
subtask.status = status;
Object.assign(subtask, extra);
found = true;
break;
}
}
if (found) break;
}
if (!found) {
throw new Error(`Subtask ${subtaskId} not found`);
}
// Save updated plan
fs.writeFileSync(this.planPath, yaml.dump(this.plan), 'utf-8');
// Update status.json
this.updateStatusJson();
return this.getStats();
}
/**
* Get progress in the expected interface format (AC2)
*
* @returns {Object} Progress metrics
* { total, completed, inProgress, pending, failed, percentage }
*/
getProgress() {
const stats = this.getStats();
return {
total: stats.total,
completed: stats.completed,
inProgress: stats.inProgress,
pending: stats.pending,
failed: stats.failed,
percentage: stats.percentComplete,
status: stats.status,
current: stats.current,
phases: stats.phases,
};
}
/**
* Print progress to console (AC4)
*/
printProgress() {
console.log(this.generateReport());
}
/**
* Save snapshot to file (AC5)
*
* @param {string} [outputPath] - Custom output path
* @returns {Promise<string>} Path to saved file
*/
async saveSnapshot(outputPath) {
const savePath = outputPath
? path.isAbsolute(outputPath)
? outputPath
: path.join(this.rootPath, outputPath)
: this.progressPath;
return this.saveProgress();
}
/**
* Get JSON representation of progress
*/
toJSON() {
return this.getStats();
}
}
// ═══════════════════════════════════════════════════════════════════════════════════
// HELPER FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Quick helper to get progress for a story without creating tracker instance
*
* @param {string} storyId - Story ID
* @returns {Object} Progress metrics
*/
function getPlanProgress(storyId) {
const tracker = new PlanTracker(storyId);
try {
tracker.load();
return tracker.getProgress();
} catch {
return null;
}
}
/**
* Update progress after a subtask operation (AC6)
* Called automatically by subtask operations
*
* @param {string} storyId - Story ID
* @param {string} subtaskId - Subtask ID
* @param {string} status - New status
* @returns {Object} Updated stats
*/
function updateAfterSubtask(storyId, subtaskId, status) {
const tracker = new PlanTracker(storyId);
tracker.load();
tracker.updateSubtaskStatus(subtaskId, status);
return tracker.getStats();
}
// ═══════════════════════════════════════════════════════════════════════════════════
// CLI INTERFACE
// ═══════════════════════════════════════════════════════════════════════════════════
async function main() {
const args = process.argv.slice(2);
if (args.length < 1 || args.includes('--help') || args.includes('-h')) {
console.log(`
\u{1F4CA} Plan Progress Tracker - AIOS Execution Engine (Story 4.6)
Usage:
node plan-tracker.js <story-id> [command] [options]
*plan-status <story-id> [command] [options]
Commands:
status Show progress status (default)
detailed Show detailed progress with all subtasks
json Output progress as JSON
save Save progress snapshot to plan/build-progress.txt (AC5)
update Update dashboard status.json (AC7)
all Save snapshot AND update dashboard
start <id> Mark subtask as in_progress
complete <id> Mark subtask as completed (auto-updates dashboard - AC6)
fail <id> Mark subtask as failed (auto-updates dashboard - AC6)
Options:
--plan-path <path> Explicit path to implementation.yaml
--quiet, -q Suppress visual output
--help, -h Show this help message
Examples:
node plan-tracker.js STORY-42
node plan-tracker.js STORY-42 detailed
node plan-tracker.js STORY-42 json
node plan-tracker.js STORY-42 save
node plan-tracker.js STORY-42 all
node plan-tracker.js STORY-42 start 1.1
node plan-tracker.js STORY-42 complete 1.1
node plan-tracker.js --plan-path docs/stories/STORY-42/plan/implementation.yaml
Acceptance Criteria Coverage:
AC1: Located in .aios-core/infrastructure/scripts/
AC2: Calculates total, completed, in-progress, pending, failed
AC3: *plan-status {story-id} command globally available
AC4: Visual progress bars in output
AC5: Generates plan/build-progress.txt snapshot
AC6: Auto-updates after each subtask
AC7: Integrates with .aios/dashboard/status.json
`);
process.exit(args.includes('--help') || args.includes('-h') ? 0 : 1);
}
// Parse arguments
let storyId = null;
let planPath = null;
let command = 'status';
let subtaskId = null;
let errorMessage = null;
let quiet = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--plan-path' && args[i + 1]) {
planPath = args[++i];
} else if (arg === '--quiet' || arg === '-q') {
quiet = true;
} else if (!arg.startsWith('-')) {
if (!storyId) {
storyId = arg;
} else if (!command || command === 'status') {
command = arg;
} else if (!subtaskId) {
subtaskId = arg;
} else {
errorMessage = arg;
}
}
}
if (!storyId && !planPath) {
console.error('Error: Story ID or --plan-path required');
process.exit(1);
}
try {
const tracker = new PlanTracker({
storyId,
planPath,
});
switch (command) {
case 'status':
console.log(tracker.generateReport());
break;
case 'detailed':
console.log(tracker.generateDetailedReport());
break;
case 'json':
console.log(JSON.stringify(tracker.getProgress(), null, 2));
break;
case 'save':
const savePath = tracker.saveProgress();
if (!quiet) console.log(`\u2705 Progress snapshot saved to: ${savePath}`);
break;
case 'update':
const updatePath = tracker.updateStatusJson();
if (!quiet) console.log(`\u2705 Dashboard status.json updated: ${updatePath}`);
break;
case 'all':
const snapshotPath = tracker.saveProgress();
const dashboardPath = tracker.updateStatusJson();
if (!quiet) {
console.log(`\u2705 Progress snapshot saved to: ${snapshotPath}`);
console.log(`\u2705 Dashboard status.json updated: ${dashboardPath}`);
}
break;
case 'start':
if (!subtaskId) {
console.error('Error: subtask ID required for start command');
process.exit(1);
}
tracker.startSubtask(subtaskId);
if (!quiet) {
console.log(`\u{1F504} Subtask ${subtaskId} marked as in_progress`);
console.log(tracker.generateReport());
}
break;
case 'complete':
if (!subtaskId) {
console.error('Error: subtask ID required for complete command');
process.exit(1);
}
tracker.completeSubtask(subtaskId);
// AC6: Auto-update dashboard after subtask completion
tracker.updateStatusJson();
if (!quiet) {
console.log(`\u2705 Subtask ${subtaskId} marked as completed`);
console.log(tracker.generateReport());
}
break;
case 'fail':
if (!subtaskId) {
console.error('Error: subtask ID required for fail command');
process.exit(1);
}
tracker.failSubtask(subtaskId, errorMessage || 'Unknown error');
// AC6: Auto-update dashboard after subtask failure
tracker.updateStatusJson();
if (!quiet) {
console.log(`\u274C Subtask ${subtaskId} marked as failed`);
console.log(tracker.generateReport());
}
break;
default:
console.error(`Unknown command: ${command}`);
process.exit(1);
}
} catch (error) {
console.error(`\n\u274C Error: ${error.message}`);
process.exit(1);
}
}
// Export for programmatic use
module.exports = {
PlanTracker,
Status,
StatusEmoji,
// Helper functions
getPlanProgress,
updateAfterSubtask,
// Config for external use
CONFIG,
};
// Run CLI if executed directly
if (require.main === module) {
main();
}