aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
964 lines (831 loc) • 31.3 kB
JavaScript
/**
* AIOS Recovery Tracker
*
* Story: 5.1 - Attempt Tracker
* Epic: Epic 5 - Recovery System
*
* Tracks implementation attempts for subtasks during story development.
* Records approach, changes, success/failure, and error information.
*
* Features:
* - AC1: Located in `.aios-core/infrastructure/scripts/`
* - AC2: Registra: attempt_number, timestamp, approach, success/fail, error
* - AC3: Output: `docs/stories/{story-id}/recovery/attempts.json`
* - AC4: Comando `*track-attempt {subtask-id}` no @dev
* - AC5: Schema de validacao para attempts.json
* - AC6: Historico mantido por subtask (nao por story)
* - AC7: Auto-increment de attempt_number
*
* @author @dev (Dex)
* @version 1.0.0
*/
const fs = require('fs');
const fsPromises = require('fs').promises;
const path = require('path');
// Optional dependencies with graceful fallback
let chalk;
try {
chalk = require('chalk');
} catch {
chalk = {
blue: (s) => s,
green: (s) => s,
red: (s) => s,
yellow: (s) => s,
cyan: (s) => s,
gray: (s) => s,
bold: (s) => s,
dim: (s) => s,
};
}
// ═══════════════════════════════════════════════════════════════════════════════════
// CONFIGURATION
// ═══════════════════════════════════════════════════════════════════════════════════
const CONFIG = {
// AC3: Default recovery directory within story
recoveryDir: 'recovery',
// AC3: Attempts file name
attemptsFile: 'attempts.json',
// Stories base directory
storiesBasePath: 'docs/stories',
// Schema version
schemaVersion: '1.0',
};
// ═══════════════════════════════════════════════════════════════════════════════════
// STATUS ENUM
// ═══════════════════════════════════════════════════════════════════════════════════
const AttemptStatus = {
IN_PROGRESS: 'in_progress',
SUCCESS: 'success',
FAILED: 'failed',
ABANDONED: 'abandoned',
};
const SubtaskStatus = {
PENDING: 'pending',
IN_PROGRESS: 'in_progress',
COMPLETED: 'completed',
FAILED: 'failed',
};
// ═══════════════════════════════════════════════════════════════════════════════════
// SCHEMA VALIDATION (AC5)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* JSON Schema for attempts.json
* AC5: Schema de validacao para attempts.json
*/
const ATTEMPTS_SCHEMA = {
type: 'object',
required: ['subtaskId', 'storyId', 'attempts', 'currentAttempt', 'status'],
properties: {
subtaskId: { type: 'string', pattern: '^\\d+\\.\\d+$' },
storyId: { type: 'string' },
attempts: {
type: 'array',
items: {
type: 'object',
required: ['number', 'timestamp', 'approach'],
properties: {
number: { type: 'integer', minimum: 1 },
timestamp: { type: 'string', format: 'date-time' },
approach: { type: 'string' },
changes: { type: 'array', items: { type: 'string' } },
success: { type: 'boolean' },
error: { type: 'string' },
duration: { type: 'string' },
completedAt: { type: 'string', format: 'date-time' },
notes: { type: 'string' },
},
},
},
currentAttempt: { type: 'integer', minimum: 0 },
status: { enum: ['pending', 'in_progress', 'completed', 'failed'] },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
};
/**
* Validate attempts data against schema
* @param {Object} data - Data to validate
* @returns {{ valid: boolean, errors: string[] }}
*/
function validateAttemptsSchema(data) {
const errors = [];
// Required fields
for (const field of ATTEMPTS_SCHEMA.required) {
if (data[field] === undefined) {
errors.push(`Missing required field: ${field}`);
}
}
// Type checks
if (typeof data.subtaskId !== 'string') {
errors.push('subtaskId must be a string');
} else if (!/^\d+\.\d+$/.test(data.subtaskId)) {
errors.push('subtaskId must match pattern X.Y (e.g., "2.1")');
}
if (typeof data.storyId !== 'string') {
errors.push('storyId must be a string');
}
if (!Array.isArray(data.attempts)) {
errors.push('attempts must be an array');
} else {
for (let i = 0; i < data.attempts.length; i++) {
const attempt = data.attempts[i];
if (typeof attempt.number !== 'number' || attempt.number < 1) {
errors.push(`attempts[${i}].number must be a positive integer`);
}
if (typeof attempt.timestamp !== 'string') {
errors.push(`attempts[${i}].timestamp must be a string`);
}
if (typeof attempt.approach !== 'string') {
errors.push(`attempts[${i}].approach must be a string`);
}
if (attempt.changes !== undefined && !Array.isArray(attempt.changes)) {
errors.push(`attempts[${i}].changes must be an array`);
}
}
}
if (typeof data.currentAttempt !== 'number' || data.currentAttempt < 0) {
errors.push('currentAttempt must be a non-negative integer');
}
const validStatuses = ['pending', 'in_progress', 'completed', 'failed'];
if (!validStatuses.includes(data.status)) {
errors.push(`status must be one of: ${validStatuses.join(', ')}`);
}
return { valid: errors.length === 0, errors };
}
// ═══════════════════════════════════════════════════════════════════════════════════
// RECOVERY TRACKER CLASS
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* RecoveryTracker - Tracks implementation attempts for subtasks
*
* AC6: Historico mantido por subtask (nao por story)
* Each subtask has its own attempts history within the story's recovery folder.
*/
class RecoveryTracker {
/**
* Create a new RecoveryTracker instance
*
* @param {Object} options - Configuration options
* @param {string} options.storyId - Story ID (e.g., 'STORY-42')
* @param {string} [options.recoveryPath] - Explicit path to recovery directory
* @param {string} [options.rootPath] - Project root path (defaults to cwd)
*/
constructor(options = {}) {
if (!options.storyId) {
throw new Error('storyId is required');
}
this.storyId = options.storyId;
this.rootPath = options.rootPath || process.cwd();
this.recoveryPath = options.recoveryPath || this._getDefaultRecoveryPath();
// Cache for loaded subtask attempts
this._cache = new Map();
}
/**
* Get default recovery path for story
* @private
* @returns {string}
*/
_getDefaultRecoveryPath() {
return path.join(this.rootPath, CONFIG.storiesBasePath, this.storyId, CONFIG.recoveryDir);
}
/**
* Get attempts file path for a subtask
* AC6: Each subtask has its own file
* @private
* @param {string} subtaskId - Subtask identifier (e.g., '2.1')
* @returns {string}
*/
_getAttemptsFilePath(subtaskId) {
// Sanitize subtask ID for filename (2.1 -> subtask-2.1.json)
const safeId = subtaskId.replace(/[^0-9.]/g, '');
return path.join(this.recoveryPath, `subtask-${safeId}.json`);
}
/**
* Ensure recovery directory exists
* @private
*/
_ensureRecoveryDir() {
if (!fs.existsSync(this.recoveryPath)) {
fs.mkdirSync(this.recoveryPath, { recursive: true });
}
}
/**
* Load attempts for a subtask
* @param {string} subtaskId - Subtask identifier
* @returns {Object} Attempts data
*/
loadAttempts(subtaskId) {
// Check cache first
if (this._cache.has(subtaskId)) {
return this._cache.get(subtaskId);
}
const filePath = this._getAttemptsFilePath(subtaskId);
if (fs.existsSync(filePath)) {
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
this._cache.set(subtaskId, data);
return data;
} catch (error) {
throw new Error(`Failed to load attempts for ${subtaskId}: ${error.message}`);
}
}
// Return default structure for new subtask
const defaultData = {
subtaskId,
storyId: this.storyId,
attempts: [],
currentAttempt: 0,
status: SubtaskStatus.PENDING,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
this._cache.set(subtaskId, defaultData);
return defaultData;
}
/**
* Save attempts for a subtask
* @private
* @param {string} subtaskId - Subtask identifier
* @param {Object} data - Attempts data
*/
_saveAttempts(subtaskId, data) {
this._ensureRecoveryDir();
// Update timestamp
data.updatedAt = new Date().toISOString();
// Validate before saving (AC5)
const validation = validateAttemptsSchema(data);
if (!validation.valid) {
throw new Error(`Invalid attempts data: ${validation.errors.join(', ')}`);
}
const filePath = this._getAttemptsFilePath(subtaskId);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
// Update cache
this._cache.set(subtaskId, data);
}
/**
* Start a new attempt for a subtask
* AC7: Auto-increment de attempt_number
*
* @param {string} subtaskId - Subtask identifier (e.g., '2.1')
* @param {Object} options - Attempt options
* @param {string} options.approach - Description of the approach being tried
* @param {string[]} [options.changes] - Files/paths that will be changed
* @param {string} [options.notes] - Additional notes
* @returns {Object} The new attempt object
*/
startAttempt(subtaskId, options = {}) {
if (!options.approach) {
throw new Error('approach is required for startAttempt');
}
const data = this.loadAttempts(subtaskId);
// AC7: Auto-increment attempt number
const attemptNumber = data.attempts.length + 1;
const newAttempt = {
number: attemptNumber,
timestamp: new Date().toISOString(),
approach: options.approach,
changes: options.changes || [],
success: null, // Will be set on complete
error: null,
duration: null,
notes: options.notes || null,
};
data.attempts.push(newAttempt);
data.currentAttempt = attemptNumber;
data.status = SubtaskStatus.IN_PROGRESS;
this._saveAttempts(subtaskId, data);
return newAttempt;
}
/**
* Complete the current attempt for a subtask
*
* @param {string} subtaskId - Subtask identifier
* @param {Object} options - Completion options
* @param {boolean} options.success - Whether the attempt succeeded
* @param {string} [options.error] - Error message if failed
* @param {string} [options.notes] - Additional notes
* @returns {Object} The completed attempt object
*/
completeAttempt(subtaskId, options = {}) {
if (options.success === undefined) {
throw new Error('success is required for completeAttempt');
}
const data = this.loadAttempts(subtaskId);
if (data.currentAttempt === 0 || data.attempts.length === 0) {
throw new Error(`No active attempt for subtask ${subtaskId}`);
}
// Get the current (last) attempt
const currentAttempt = data.attempts[data.attempts.length - 1];
// Calculate duration
const startTime = new Date(currentAttempt.timestamp);
const endTime = new Date();
const durationMs = endTime - startTime;
const durationStr = this._formatDuration(durationMs);
// Update attempt
currentAttempt.success = options.success;
currentAttempt.error = options.error || null;
currentAttempt.duration = durationStr;
currentAttempt.completedAt = endTime.toISOString();
if (options.notes) {
currentAttempt.notes = options.notes;
}
// Update subtask status
if (options.success) {
data.status = SubtaskStatus.COMPLETED;
} else {
// Keep in_progress to allow retries, or set to failed if explicitly marked
data.status = SubtaskStatus.IN_PROGRESS;
}
this._saveAttempts(subtaskId, data);
return currentAttempt;
}
/**
* Abandon the current attempt (mark as abandoned without completing)
*
* @param {string} subtaskId - Subtask identifier
* @param {string} [reason] - Reason for abandoning
* @returns {Object} The abandoned attempt object
*/
abandonAttempt(subtaskId, reason) {
const data = this.loadAttempts(subtaskId);
if (data.currentAttempt === 0 || data.attempts.length === 0) {
throw new Error(`No active attempt for subtask ${subtaskId}`);
}
const currentAttempt = data.attempts[data.attempts.length - 1];
// Calculate duration
const startTime = new Date(currentAttempt.timestamp);
const endTime = new Date();
const durationMs = endTime - startTime;
currentAttempt.success = false;
currentAttempt.error = reason || 'Attempt abandoned';
currentAttempt.duration = this._formatDuration(durationMs);
currentAttempt.completedAt = endTime.toISOString();
currentAttempt.abandoned = true;
this._saveAttempts(subtaskId, data);
return currentAttempt;
}
/**
* Get all attempts for a subtask
* AC6: Historico mantido por subtask
*
* @param {string} subtaskId - Subtask identifier
* @returns {Object[]} Array of attempt objects
*/
getAttempts(subtaskId) {
const data = this.loadAttempts(subtaskId);
return data.attempts;
}
/**
* Get the current attempt for a subtask
*
* @param {string} subtaskId - Subtask identifier
* @returns {Object|null} Current attempt or null if none
*/
getCurrentAttempt(subtaskId) {
const data = this.loadAttempts(subtaskId);
if (data.attempts.length === 0) {
return null;
}
return data.attempts[data.attempts.length - 1];
}
/**
* Get full subtask status including attempts
*
* @param {string} subtaskId - Subtask identifier
* @returns {Object} Full subtask data
*/
getSubtaskStatus(subtaskId) {
return this.loadAttempts(subtaskId);
}
/**
* Mark subtask as completed (sets final status)
*
* @param {string} subtaskId - Subtask identifier
*/
markCompleted(subtaskId) {
const data = this.loadAttempts(subtaskId);
data.status = SubtaskStatus.COMPLETED;
this._saveAttempts(subtaskId, data);
}
/**
* Mark subtask as failed (sets final status)
*
* @param {string} subtaskId - Subtask identifier
*/
markFailed(subtaskId) {
const data = this.loadAttempts(subtaskId);
data.status = SubtaskStatus.FAILED;
this._saveAttempts(subtaskId, data);
}
/**
* Get summary statistics for all tracked subtasks
*
* @returns {Object} Summary statistics
*/
getSummary() {
this._ensureRecoveryDir();
const files = fs
.readdirSync(this.recoveryPath)
.filter((f) => f.startsWith('subtask-') && f.endsWith('.json'));
const summary = {
storyId: this.storyId,
totalSubtasks: files.length,
totalAttempts: 0,
completed: 0,
inProgress: 0,
failed: 0,
pending: 0,
subtasks: [],
};
for (const file of files) {
const filePath = path.join(this.recoveryPath, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
summary.totalAttempts += data.attempts.length;
switch (data.status) {
case SubtaskStatus.COMPLETED:
summary.completed++;
break;
case SubtaskStatus.IN_PROGRESS:
summary.inProgress++;
break;
case SubtaskStatus.FAILED:
summary.failed++;
break;
default:
summary.pending++;
}
summary.subtasks.push({
subtaskId: data.subtaskId,
status: data.status,
attempts: data.attempts.length,
lastAttempt:
data.attempts.length > 0 ? data.attempts[data.attempts.length - 1].timestamp : null,
});
} catch {
// Skip invalid files
}
}
// Sort subtasks by ID
summary.subtasks.sort((a, b) => {
const [aMajor, aMinor] = a.subtaskId.split('.').map(Number);
const [bMajor, bMinor] = b.subtaskId.split('.').map(Number);
return aMajor - bMajor || aMinor - bMinor;
});
return summary;
}
/**
* Format duration from milliseconds to human-readable string
* @private
* @param {number} ms - Duration in milliseconds
* @returns {string} Formatted duration (e.g., "5m", "1h 30m")
*/
_formatDuration(ms) {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
const remainingMins = minutes % 60;
return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
}
if (minutes > 0) {
return `${minutes}m`;
}
return `${seconds}s`;
}
/**
* Generate a visual report of attempt history
*
* @param {string} subtaskId - Subtask identifier
* @returns {string} Formatted report
*/
generateReport(subtaskId) {
const data = this.loadAttempts(subtaskId);
const lines = [];
lines.push('');
lines.push(chalk.bold(`Recovery History: ${this.storyId} / Subtask ${subtaskId}`));
lines.push('━'.repeat(60));
lines.push('');
if (data.attempts.length === 0) {
lines.push(chalk.dim(' No attempts recorded yet.'));
} else {
for (const attempt of data.attempts) {
const statusIcon =
attempt.success === true
? chalk.green('✓')
: attempt.success === false
? chalk.red('✗')
: chalk.yellow('◌');
const duration = attempt.duration ? chalk.dim(` (${attempt.duration})`) : '';
const timestamp = new Date(attempt.timestamp).toLocaleString();
lines.push(`${statusIcon} Attempt #${attempt.number}${duration}`);
lines.push(chalk.dim(` Started: ${timestamp}`));
lines.push(` Approach: ${attempt.approach}`);
if (attempt.changes && attempt.changes.length > 0) {
lines.push(` Changes: ${attempt.changes.join(', ')}`);
}
if (attempt.error) {
lines.push(chalk.red(` Error: ${attempt.error}`));
}
if (attempt.notes) {
lines.push(chalk.cyan(` Notes: ${attempt.notes}`));
}
lines.push('');
}
}
// Status summary
lines.push('─'.repeat(60));
const statusColors = {
[SubtaskStatus.COMPLETED]: chalk.green,
[SubtaskStatus.IN_PROGRESS]: chalk.yellow,
[SubtaskStatus.FAILED]: chalk.red,
[SubtaskStatus.PENDING]: chalk.dim,
};
const statusColor = statusColors[data.status] || chalk.dim;
lines.push(`Status: ${statusColor(data.status.toUpperCase())}`);
lines.push(`Total Attempts: ${data.attempts.length}`);
lines.push('');
return lines.join('\n');
}
/**
* Generate summary report for all subtasks
*
* @returns {string} Formatted summary report
*/
generateSummaryReport() {
const summary = this.getSummary();
const lines = [];
lines.push('');
lines.push(chalk.bold(`Recovery Summary: ${this.storyId}`));
lines.push('━'.repeat(60));
lines.push('');
lines.push(`Total Subtasks: ${summary.totalSubtasks}`);
lines.push(`Total Attempts: ${summary.totalAttempts}`);
lines.push('');
lines.push(` ${chalk.green('✓')} Completed: ${summary.completed}`);
lines.push(` ${chalk.yellow('◌')} In Progress: ${summary.inProgress}`);
lines.push(` ${chalk.red('✗')} Failed: ${summary.failed}`);
lines.push(` ${chalk.dim('○')} Pending: ${summary.pending}`);
lines.push('');
if (summary.subtasks.length > 0) {
lines.push('─'.repeat(60));
lines.push('Subtasks:');
lines.push('');
for (const st of summary.subtasks) {
const statusIcon =
{
[SubtaskStatus.COMPLETED]: chalk.green('✓'),
[SubtaskStatus.IN_PROGRESS]: chalk.yellow('◌'),
[SubtaskStatus.FAILED]: chalk.red('✗'),
[SubtaskStatus.PENDING]: chalk.dim('○'),
}[st.status] || chalk.dim('?');
lines.push(` ${statusIcon} ${st.subtaskId} - ${st.attempts} attempt(s)`);
}
}
lines.push('');
return lines.join('\n');
}
/**
* Export schema for external validation
* AC5: Schema de validacao
*
* @returns {Object} JSON Schema
*/
static getSchema() {
return ATTEMPTS_SCHEMA;
}
}
// ═══════════════════════════════════════════════════════════════════════════════════
// HELPER FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Quick helper to start tracking an attempt
*
* @param {string} storyId - Story ID
* @param {string} subtaskId - Subtask ID
* @param {string} approach - Approach description
* @returns {Object} The new attempt
*/
function trackAttempt(storyId, subtaskId, approach) {
const tracker = new RecoveryTracker({ storyId });
return tracker.startAttempt(subtaskId, { approach });
}
/**
* Quick helper to complete an attempt
*
* @param {string} storyId - Story ID
* @param {string} subtaskId - Subtask ID
* @param {boolean} success - Whether succeeded
* @param {string} [error] - Error message if failed
* @returns {Object} The completed attempt
*/
function completeAttempt(storyId, subtaskId, success, error) {
const tracker = new RecoveryTracker({ storyId });
return tracker.completeAttempt(subtaskId, { success, error });
}
/**
* Quick helper to get attempt history
*
* @param {string} storyId - Story ID
* @param {string} subtaskId - Subtask ID
* @returns {Object[]} Array of attempts
*/
function getAttemptHistory(storyId, subtaskId) {
const tracker = new RecoveryTracker({ storyId });
return tracker.getAttempts(subtaskId);
}
// ═══════════════════════════════════════════════════════════════════════════════════
// CLI INTERFACE
// ═══════════════════════════════════════════════════════════════════════════════════
async function main() {
const args = process.argv.slice(2);
if (args.length < 1 || args.includes('--help') || args.includes('-h')) {
console.log(`
${chalk.bold('Recovery Tracker')} - AIOS Attempt Tracking System (Story 5.1)
${chalk.cyan('Usage:')}
node recovery-tracker.js <command> <story-id> <subtask-id> [options]
*track-attempt <subtask-id> --approach "..." [options]
${chalk.cyan('Commands:')}
start <story-id> <subtask-id> Start a new attempt
complete <story-id> <subtask-id> Complete current attempt
abandon <story-id> <subtask-id> Abandon current attempt
history <story-id> <subtask-id> Show attempt history
summary <story-id> Show summary for all subtasks
json <story-id> <subtask-id> Output attempts as JSON
schema Output JSON schema for validation
${chalk.cyan('Options:')}
--approach, -a <text> Approach description (required for start)
--changes, -c <files> Comma-separated list of changed files
--success Mark attempt as successful (for complete)
--fail Mark attempt as failed (for complete)
--error, -e <text> Error message (for complete --fail)
--notes, -n <text> Additional notes
--quiet, -q Suppress visual output
--help, -h Show this help message
${chalk.cyan('Examples:')}
${chalk.dim('# Start a new attempt')}
node recovery-tracker.js start STORY-42 2.1 --approach "Using zustand with middleware"
${chalk.dim('# Complete with success')}
node recovery-tracker.js complete STORY-42 2.1 --success
${chalk.dim('# Complete with failure')}
node recovery-tracker.js complete STORY-42 2.1 --fail --error "Type error: Property persist does not exist"
${chalk.dim('# View attempt history')}
node recovery-tracker.js history STORY-42 2.1
${chalk.dim('# View summary for story')}
node recovery-tracker.js summary STORY-42
${chalk.cyan('Acceptance Criteria Coverage:')}
AC1: Located in .aios-core/infrastructure/scripts/
AC2: Registra: attempt_number, timestamp, approach, success/fail, error
AC3: Output: docs/stories/{story-id}/recovery/attempts.json
AC4: Comando *track-attempt {subtask-id} no @dev
AC5: Schema de validacao para attempts.json
AC6: Historico mantido por subtask (nao por story)
AC7: Auto-increment de attempt_number
`);
process.exit(args.includes('--help') || args.includes('-h') ? 0 : 1);
}
// Parse arguments
const command = args[0];
let storyId = null;
let subtaskId = null;
let approach = null;
let changes = [];
let success = null;
let error = null;
let notes = null;
let quiet = false;
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (arg === '--approach' || arg === '-a') {
approach = args[++i];
} else if (arg === '--changes' || arg === '-c') {
changes = args[++i].split(',').map((s) => s.trim());
} else if (arg === '--success') {
success = true;
} else if (arg === '--fail') {
success = false;
} else if (arg === '--error' || arg === '-e') {
error = args[++i];
} else if (arg === '--notes' || arg === '-n') {
notes = args[++i];
} else if (arg === '--quiet' || arg === '-q') {
quiet = true;
} else if (!arg.startsWith('-')) {
if (!storyId) {
storyId = arg;
} else if (!subtaskId) {
subtaskId = arg;
}
}
}
try {
// Handle schema command (no story needed)
if (command === 'schema') {
console.log(JSON.stringify(ATTEMPTS_SCHEMA, null, 2));
process.exit(0);
}
// Validate required arguments
if (!storyId && command !== 'schema') {
console.error(chalk.red('Error: story-id is required'));
process.exit(1);
}
if (!subtaskId && !['summary', 'schema'].includes(command)) {
console.error(chalk.red('Error: subtask-id is required'));
process.exit(1);
}
const tracker = new RecoveryTracker({ storyId });
switch (command) {
case 'start': {
if (!approach) {
console.error(chalk.red('Error: --approach is required for start command'));
process.exit(1);
}
const attempt = tracker.startAttempt(subtaskId, { approach, changes, notes });
if (!quiet) {
console.log(
chalk.green(`\n✓ Started attempt #${attempt.number} for ${storyId}/${subtaskId}`)
);
console.log(chalk.dim(` Approach: ${approach}`));
if (changes.length > 0) {
console.log(chalk.dim(` Changes: ${changes.join(', ')}`));
}
console.log('');
}
break;
}
case 'complete': {
if (success === null) {
console.error(chalk.red('Error: --success or --fail is required for complete command'));
process.exit(1);
}
const attempt = tracker.completeAttempt(subtaskId, { success, error, notes });
if (!quiet) {
if (success) {
console.log(chalk.green(`\n✓ Attempt #${attempt.number} completed successfully`));
} else {
console.log(chalk.red(`\n✗ Attempt #${attempt.number} failed`));
if (error) {
console.log(chalk.dim(` Error: ${error}`));
}
}
console.log(chalk.dim(` Duration: ${attempt.duration}`));
console.log('');
}
break;
}
case 'abandon': {
const attempt = tracker.abandonAttempt(subtaskId, error || notes);
if (!quiet) {
console.log(chalk.yellow(`\n⚠ Attempt #${attempt.number} abandoned`));
if (attempt.error) {
console.log(chalk.dim(` Reason: ${attempt.error}`));
}
console.log('');
}
break;
}
case 'history': {
console.log(tracker.generateReport(subtaskId));
break;
}
case 'summary': {
console.log(tracker.generateSummaryReport());
break;
}
case 'json': {
const data = tracker.getSubtaskStatus(subtaskId);
console.log(JSON.stringify(data, null, 2));
break;
}
default:
console.error(chalk.red(`Unknown command: ${command}`));
process.exit(1);
}
} catch (err) {
console.error(chalk.red(`\n✗ Error: ${err.message}`));
process.exit(1);
}
}
// ═══════════════════════════════════════════════════════════════════════════════════
// EXPORTS
// ═══════════════════════════════════════════════════════════════════════════════════
module.exports = {
RecoveryTracker,
AttemptStatus,
SubtaskStatus,
// Schema (AC5)
ATTEMPTS_SCHEMA,
validateAttemptsSchema,
// Helper functions
trackAttempt,
completeAttempt,
getAttemptHistory,
// Config for external use
CONFIG,
};
// Run CLI if executed directly
if (require.main === module) {
main();
}