/**
* @fileoverview Ralph Tracker - Detects Ralph Wiggum loops, todos, and completion phrases.
*
* Parses terminal output from Claude Code sessions to detect:
* - Ralph loop state (active, completion phrase, iteration count)
* - Todo items from the TodoWrite tool (with deduplication and expiry)
* - Completion phrases signaling loop completion
* - Circuit breaker state (CLOSED/HALF_OPEN/OPEN)
*
* DISABLED by default — auto-enables when Ralph-related patterns appear,
* reducing overhead for non-autonomous sessions.
*
* Composed of four sub-modules:
* - `RalphPlanTracker`: Plan task management, checkpoints, versioning
* - `RalphFixPlanWatcher`: @fix_plan.md file watching and parsing
* - `RalphStallDetector`: Iteration stall detection
* - `RalphStatusParser`: RALPH_STATUS block parsing, circuit breaker
*
* Key exports:
* - `RalphTracker` class — main tracker, extends EventEmitter
* - `RalphTrackerEvents` interface — typed event map
*
* Key methods: `processData(data)` — feed terminal output, `getState()`,
* `getTodos()`, `getCompletionHistory()`, `getPlanTasks()`, `reset()`
*
* @dependencies types (RalphTrackerState, RalphTodoItem, CircuitBreakerStatus),
* ralph-plan-tracker, ralph-fix-plan-watcher, ralph-stall-detector, ralph-status-parser,
* config/buffer-limits, config/map-limits
* @consumedby session (owns one RalphTracker per session), web/server (SSE events)
* @emits ralphStateChanged, todoUpdated, completionDetected, statusBlockParsed,
* circuitBreakerChanged, exitGateMet, planTaskUpdated, planCheckpoint
*
* @module ralph-tracker
*/
import { EventEmitter } from 'node:events';
import {
RalphTrackerState,
RalphTodoItem,
RalphTodoStatus,
RalphTodoPriority,
RalphStatusBlock,
CircuitBreakerStatus,
RalphTodoProgress,
CompletionConfidence,
createInitialRalphTrackerState,
PlanTaskStatus,
TddPhase,
} from './types.js';
import {
ANSI_ESCAPE_PATTERN_SIMPLE,
fuzzyPhraseMatch,
todoContentHash,
stringSimilarity,
Debouncer,
CleanupManager,
execPattern,
} from './utils/index.js';
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
import { RalphPlanTracker } from './ralph-plan-tracker.js';
import type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js';
import { RalphFixPlanWatcher, generateFixPlanMarkdown, importFixPlanMarkdown } from './ralph-fix-plan-watcher.js';
import { RalphStallDetector } from './ralph-stall-detector.js';
import { RalphStatusParser } from './ralph-status-parser.js';
import { STALE_DATA_MAX_AGE_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
// ========== Configuration Constants ==========
// Note: MAX_TODOS_PER_SESSION and MAX_LINE_BUFFER_SIZE are imported from config modules
/**
* Todo items older than this duration (in milliseconds) will be auto-expired.
* Default: 1 hour
*/
const TODO_EXPIRY_MS = STALE_DATA_MAX_AGE_MS;
/**
* Minimum interval between on-demand cleanup checks (in milliseconds).
* Prevents running cleanup on every data chunk.
* Default: 30 seconds
*/
const CLEANUP_THROTTLE_MS = 30 * 1000;
/**
* Interval for periodic todo expiry cleanup (in milliseconds).
* Actively purges expired todos even when no terminal data is flowing.
* Default: 5 minutes
*/
const TODO_CLEANUP_INTERVAL_MS = INACTIVITY_TIMEOUT_MS;
/**
* Similarity threshold for todo deduplication.
* Todos with similarity >= this value are considered duplicates.
* Range: 0.0 (no similarity) to 1.0 (identical)
* Default: 0.85 (85% similar)
*/
const TODO_SIMILARITY_THRESHOLD = 0.85;
/**
* Similarity threshold for short todo content (<30 chars).
* Higher threshold reduces false positive deduplication of short strings.
*/
const SIMILARITY_THRESHOLD_SHORT = 0.95;
/**
* Similarity threshold for medium-length todo content (30-60 chars).
* Slightly relaxed compared to short strings.
*/
const SIMILARITY_THRESHOLD_MEDIUM = 0.9;
/**
* Debounce interval for event emissions (milliseconds).
* Prevents UI jitter from rapid consecutive updates.
* Default: 50ms
*/
const EVENT_DEBOUNCE_MS = 50;
/**
* Maximum number of completion phrase entries to track.
* Prevents unbounded growth if many unique phrases are seen.
*/
const MAX_COMPLETION_PHRASE_ENTRIES = 50;
/**
* Common/generic completion phrases that may cause false positives.
* These phrases are likely to appear in Claude's natural output,
* making them unreliable as completion signals.
*
* P1-002: Configurable false positive prevention
*/
const COMMON_COMPLETION_PHRASES = new Set([
'DONE',
'COMPLETE',
'FINISHED',
'OK',
'YES',
'TRUE',
'SUCCESS',
'READY',
'COMPLETED',
'PASSED',
'END',
'STOP',
'EXIT',
]);
/**
* Minimum recommended phrase length for completion detection.
* Shorter phrases are more likely to cause false positives.
*/
const MIN_RECOMMENDED_PHRASE_LENGTH = 6;
// ========== Pre-compiled Regex Patterns ==========
// Pre-compiled for performance (avoid re-compilation on each call)
/**
* Matches completion phrase tags: `PHRASE`
* Used to detect when Claude signals task completion.
* Capture group 1: The completion phrase text
*
* Supports any characters between tags including:
* - Uppercase letters: COMPLETE, DONE
* - Numbers: TASK_123
* - Underscores: ALL_TASKS_DONE
* - Hyphens: TESTS-PASS, TIME-COMPLETE
*
* Now also tolerates:
* - Whitespace/newlines inside tags: COMPLETE
* - Case variations in tag names: ,
*/
const PROMISE_PATTERN = /\s*([^<]+?)\s*<\/promise>/i;
/**
* Pattern for detecting partial/incomplete promise tags at end of buffer.
* Used for cross-chunk promise detection when tags are split across PTY writes.
* Captures:
* - Group 1: Partial opening tag content after (may be incomplete)
*/
const PROMISE_PARTIAL_PATTERN = /\s*([^<]*)$/i;
// ---------- Todo Item Patterns ----------
// Claude Code outputs todos in multiple formats; we detect all of them
/**
* Format 1: Markdown checkbox format
* Matches: "- [ ] Task" or "- [x] Task" (also with * bullet)
* Capture group 1: Checkbox state ('x', 'X', or ' ')
* Capture group 2: Task content
*/
const TODO_CHECKBOX_PATTERN = /^[-*]\s*\[([xX ])\]\s+(.+)$/gm;
/**
* Format 2: Todo with indicator icons
* Matches: "Todo: ☐ Task", "Todo: ◐ Task", "Todo: ✓ Task"
* Capture group 1: Status icon
* Capture group 2: Task content
*/
const TODO_INDICATOR_PATTERN = /Todo:\s*(☐|◐|✓|⏳|✅|⌛|🔄)\s+(.+)/g;
/**
* Format 3: Status in parentheses
* Matches: "- Task (pending)", "- Task (in_progress)", "- Task (completed)"
* Capture group 1: Task content
* Capture group 2: Status string
*/
const TODO_STATUS_PATTERN = /[-*]\s*(.+?)\s+\((pending|in_progress|completed)\)/g;
/**
* Format 4: Claude Code native TodoWrite output
* Matches: "☐ Task", "☒ Task", "◐ Task", "✓ Task"
* These appear with optional leading whitespace/brackets like "⎿ ☐ Task"
* Capture group 1: Checkbox icon (☐=pending, ☒=completed, ◐=in_progress, ✓=completed)
* Capture group 2: Task content (min 3 chars, excludes checkbox icons)
*/
const TODO_NATIVE_PATTERN = /^[\s⎿]*(☐|☒|◐|✓)\s+([^☐☒◐✓\n]{3,})/gm;
/**
* Format 5: Claude Code checkmark-based TodoWrite output
* Matches task creation: "✔ Task #1 created: Fix the bug"
* Matches task summary: "✔ #1 Fix the bug"
* Matches status update: "✔ Task #1 updated: status → completed"
*
* These are the primary output format of Claude Code's TodoWrite tool.
*/
const TODO_TASK_CREATED_PATTERN = /✔\s*Task\s*#(\d+)\s*created:\s*(.+)/g;
const TODO_TASK_SUMMARY_PATTERN = /✔\s*#(\d+)\s+(.+)/g;
const TODO_TASK_STATUS_PATTERN = /✔\s*Task\s*#(\d+)\s*updated:\s*status\s*→\s*(in progress|completed|pending)/g;
/**
* Matches plain checkmark TodoWrite output without task numbers.
* Real Claude Code TodoWrite output: "✔ Create hello.txt with Hello World"
* This is the most common format in actual usage.
*/
const TODO_PLAIN_CHECKMARK_PATTERN = /✔\s+(.+)/g;
/**
* Patterns to exclude from todo detection
* Prevents false positives from tool invocations and Claude commentary
*/
const TODO_EXCLUDE_PATTERNS = [
/^(?:Bash|Search|Read|Write|Glob|Grep|Edit|Task)\s*\(/i, // Tool invocations
/^(?:I'll |Let me |Now I|First,|Task \d+:|Result:|Error:)/i, // Claude commentary
/^\S+\([^)]+\)$/, // Generic function call pattern
];
// ---------- Loop Status Patterns ----------
// Note: tags are handled separately by PROMISE_PATTERN
/**
* Matches generic loop start messages
* Examples: "Loop started at", "Starting main loop", "Ralph loop started"
*/
const LOOP_START_PATTERN = /Loop started at|Starting.*loop|Ralph loop started/i;
/**
* Matches elapsed time output
* Example: "Elapsed: 2.5 hours"
* Capture group 1: Hours as decimal number
*/
const ELAPSED_TIME_PATTERN = /Elapsed:\s*(\d+(?:\.\d+)?)\s*hours?/i;
/**
* Matches cycle count indicators (legacy format)
* Examples: "cycle #5", "respawn cycle #3"
* Capture groups 1 or 2: Cycle number
*/
const CYCLE_PATTERN = /cycle\s*#?(\d+)|respawn cycle #(\d+)/i;
// ---------- Ralph Wiggum Plugin Patterns ----------
// Based on the official Ralph Wiggum plugin output format
/**
* Matches iteration progress indicators
* Examples: "Iteration 5/50", "[5/50]", "iteration #5", "iter. 3 of 10"
* Capture groups: (1,2) for "Iteration X/Y" format, (3,4) for "[X/Y]" format
*/
const ITERATION_PATTERN = /(?:iteration|iter\.?)\s*#?(\d+)(?:\s*(?:\/|of)\s*(\d+))?|\[(\d+)\/(\d+)\]/i;
/**
* Matches Ralph loop start command or announcement
* Examples: "/ralph-loop:ralph-loop", "Starting Ralph Wiggum loop", "ralph loop beginning"
*/
const RALPH_START_PATTERN = /\/ralph-loop|starting ralph(?:\s+wiggum)?\s+loop|ralph loop (?:started|beginning)/i;
/**
* Matches max iterations configuration
* Examples: "max-iterations 50", "maxIterations: 50", "max_iterations=50"
* Capture group 1: Maximum iteration count
*/
const MAX_ITERATIONS_PATTERN = /max[_-]?iterations?\s*[=:]\s*(\d+)/i;
/**
* Matches TodoWrite tool usage indicators
* Examples: "TodoWrite", "todos updated", "Todos have been modified"
*/
const TODOWRITE_PATTERN = /TodoWrite|todo(?:s)?\s*(?:updated|written|saved)|Todos have been modified/i;
// ---------- Task Completion Detection Patterns ----------
/**
* Matches "all tasks complete" announcements
* Examples: "All 8 files have been created", "All tasks completed", "Everything is done"
* Used to mark all tracked todos as complete at once
*/
const ALL_COMPLETE_PATTERN =
/all\s+(?:\d+\s+)?(?:tasks?|files?|items?)\s+(?:have\s+been\s+|are\s+)?(?:completed?|done|finished|created)|completed?\s+all\s+(?:\d+\s+)?tasks?|all\s+done|everything\s+(?:is\s+)?(?:completed?|done)|finished\s+all\s+tasks?/i;
/**
* Extracts count from "all N items" messages
* Example: "All 8 files created" → captures "8"
* Capture group 1: The count
*/
const ALL_COUNT_PATTERN = /all\s+(\d+)\s+(?:tasks?|files?|items?)/i;
/**
* Matches individual task completion messages
* Examples: "Task #5 is done", "marked as completed", "todo 3 finished"
* Used to update specific todo items by number
*/
const TASK_DONE_PATTERN =
/(?:task|item|todo)\s*(?:#?\d+|"\s*[^"]+\s*")?\s*(?:is\s+)?(?:done|completed?|finished)|(?:completed?|done|finished)\s+(?:task|item)\s*(?:#?\d+)?|marking\s+(?:.*?\s+)?(?:as\s+)?completed?|marked\s+(?:.*?\s+)?(?:as\s+)?completed?/i;
// ---------- Utility Patterns ----------
/** Maximum number of task number to content mappings to track */
const MAX_TASK_MAPPINGS = 100;
// ---------- Priority Detection Patterns ----------
// Pre-compiled for performance; avoids repeated allocation in parsePriority()
/** P0 (Critical) priority patterns - highest severity issues */
const P0_PRIORITY_PATTERNS = [
/\bP0\b|\(P0\)|:?\s*P0\s*:/, // Explicit P0
/\bCRITICAL\b/, // Critical keyword
/\bBLOCKER\b/, // Blocker
/\bURGENT\b/, // Urgent
/\bSECURITY\b/, // Security issues
/\bCRASH(?:ES|ING)?\b/, // Crash, crashes, crashing
/\bBROKEN\b/, // Broken
/\bDATA\s*LOSS\b/, // Data loss
/\bPRODUCTION\s*(?:DOWN|ISSUE|BUG)\b/, // Production issues
/\bHOTFIX\b/, // Hotfix
/\bSEVERITY\s*1\b/, // Severity 1
];
/** P1 (High) priority patterns - important issues requiring attention */
const P1_PRIORITY_PATTERNS = [
/\bP1\b|\(P1\)|:?\s*P1\s*:/, // Explicit P1
/\bHIGH\s*PRIORITY\b/, // High priority
/\bIMPORTANT\b/, // Important
/\bBUG\b/, // Bug
/\bFIX\b/, // Fix (as task type)
/\bERROR\b/, // Error
/\bFAIL(?:S|ED|ING|URE)?\b/, // Fail variants
/\bREGRESSION\b/, // Regression
/\bMUST\s*(?:HAVE|FIX|DO)\b/, // Must have/fix/do
/\bSEVERITY\s*2\b/, // Severity 2
/\bREQUIRED\b/, // Required
];
/** P2 (Medium) priority patterns - lower priority improvements */
const P2_PRIORITY_PATTERNS = [
/\bP2\b|\(P2\)|:?\s*P2\s*:/, // Explicit P2
/\bNICE\s*TO\s*HAVE\b/, // Nice to have
/\bLOW\s*PRIORITY\b/, // Low priority
/\bREFACTOR\b/, // Refactor
/\bCLEANUP\b/, // Cleanup
/\bIMPROVE(?:MENT)?\b/, // Improve/Improvement
/\bOPTIMIZ(?:E|ATION)\b/, // Optimize/Optimization
/\bCONSIDER\b/, // Consider
/\bWOULD\s*BE\s*NICE\b/, // Would be nice
/\bENHANCE(?:MENT)?\b/, // Enhance/Enhancement
/\bTECH(?:NICAL)?\s*DEBT\b/, // Tech debt
/\bDOCUMENT(?:ATION)?\b/, // Documentation
];
// ========== Event Types ==========
/**
* Events emitted by RalphTracker
* @event loopUpdate - Fired when loop state changes (active, iteration, completion phrase)
* @event todoUpdate - Fired when todo list changes (items added, status changed)
* @event completionDetected - Fired when completion phrase is detected (task complete)
* @event enabled - Fired when tracker auto-enables due to Ralph pattern detection
* @event statusBlockDetected - Fired when a RALPH_STATUS block is parsed
* @event circuitBreakerUpdate - Fired when circuit breaker state changes
* @event exitGateMet - Fired when dual-condition exit gate is met
*/
/**
* RalphTracker - Parses terminal output to detect Ralph Wiggum loops and todos
*
* This class monitors Claude Code session output to detect:
* 1. **Ralph Wiggum loop state** - Active loops, completion phrases, iteration counts
* 2. **Todo list items** - From TodoWrite tool in various formats
* 3. **Completion signals** - `PHRASE` tags
*
* ## Lifecycle
*
* The tracker is **DISABLED by default** and auto-enables when Ralph-related
* patterns are detected (e.g., /ralph-loop:ralph-loop, , todos).
* This reduces overhead for sessions not using autonomous loops.
*
* ## Completion Detection
*
* Uses occurrence-based detection to distinguish prompt from actual completion:
* - 1st occurrence of `X`: Stored as expected phrase (likely in prompt)
* - 2nd occurrence: Emits `completionDetected` event (actual completion)
* - If loop already active: Emits immediately on first occurrence
*
* ## Sub-modules
*
* - `planTracker` - Plan task management, checkpoints, versioning
* - `fixPlanWatcher` - @fix_plan.md file watching and parsing
* - `stallDetector` - Iteration stall detection
* - `statusParser` - RALPH_STATUS block parsing, circuit breaker
*
* ## Events
*
* - `loopUpdate` - Loop state changed (status, iteration, phrase)
* - `todoUpdate` - Todo list modified (add, status change)
* - `completionDetected` - Loop completion phrase detected
* - `enabled` - Tracker auto-enabled from disabled state
*
* @extends EventEmitter
* @example
* ```typescript
* const tracker = new RalphTracker();
* tracker.on('completionDetected', (phrase) => {
* console.log('Loop completed with phrase:', phrase);
* });
* tracker.processTerminalData(ptyOutput);
* ```
*/
export class RalphTracker extends EventEmitter {
// ========== Sub-modules ==========
/** Plan task management sub-module */
readonly planTracker = new RalphPlanTracker();
/** @fix_plan.md file watcher sub-module */
readonly fixPlanWatcher: RalphFixPlanWatcher;
/** Iteration stall detector sub-module */
readonly stallDetector = new RalphStallDetector();
/** RALPH_STATUS block parser and circuit breaker sub-module */
readonly statusParser = new RalphStatusParser();
// ========== Core State ==========
/** Current state of the detected loop */
private _loopState: RalphTrackerState;
/** Map of todo items by ID for O(1) lookup */
private _todos: Map = new Map();
/** Buffer for incomplete lines from terminal data */
private _lineBuffer: string = '';
/**
* Tracks occurrences of completion phrases.
* Used to distinguish prompt echo (1st) from actual completion (2nd+).
*/
private _completionPhraseCount: Map = new Map();
/** Timestamp of last cleanup check for throttling */
private _lastCleanupTime: number = 0;
/** Maximum number of todos retained for this session (defaults to global cap) */
private _maxTodos: number = MAX_TODOS_PER_SESSION;
/** Todo auto-expiry duration in milliseconds (defaults to global constant) */
private _todoExpiryMs: number = TODO_EXPIRY_MS;
/** Debouncer for todoUpdate events */
private _todoDeb = new Debouncer(EVENT_DEBOUNCE_MS);
/** Debouncer for loopUpdate events */
private _loopDeb = new Debouncer(EVENT_DEBOUNCE_MS);
/** When true, prevents auto-enable on pattern detection */
private _autoEnableDisabled: boolean = true;
/** Maps task numbers from "✔ Task #N" format to their content for status updates */
private _taskNumberToContent: Map = new Map();
/**
* Buffer for partial promise tags split across PTY chunks.
* Holds content after '' when closing tag hasn't arrived yet.
* Max 256 chars to prevent unbounded growth from malformed tags.
*/
private _partialPromiseBuffer: string = '';
/** Maximum size of partial promise buffer */
private static readonly MAX_PARTIAL_PROMISE_SIZE = 256;
/** Alternate completion phrases (P1-003: multi-phrase support) - Set for O(1) lookup */
private _alternateCompletionPhrases: Set = new Set();
// ========== P1-009: Progress Estimation ==========
/** History of todo completion times (ms) for averaging */
private _completionTimes: number[] = [];
/** Maximum number of completion times to track */
private static readonly MAX_COMPLETION_TIMES = 50;
/** Timestamp when todos started being tracked for this session */
private _todosStartedAt: number = 0;
/** Map of todo ID to timestamp when it started (for duration tracking) */
private _todoStartTimes: Map = new Map();
/** Last calculated completion confidence */
private _lastCompletionConfidence: CompletionConfidence | undefined;
/** Manages periodic cleanup timers (todo expiry) */
private cleanup = new CleanupManager();
/** Confidence threshold for triggering completion (0-100) */
private static readonly COMPLETION_CONFIDENCE_THRESHOLD = 70;
/**
* Creates a new RalphTracker instance.
* Starts in disabled state until Ralph patterns are detected.
*/
constructor() {
super();
this._loopState = createInitialRalphTrackerState();
// Initialize fix plan watcher with callbacks to parent methods
this.fixPlanWatcher = new RalphFixPlanWatcher(
(content: string) => this.parsePriority(content),
(content: string) => this.generateTodoId(content)
);
// Wire sub-module events
this._wireSubModuleEvents();
// Periodic cleanup of expired todos — ensures stale entries are purged
// even when no terminal data is flowing (e.g., idle sessions)
this.cleanup.setInterval(() => this.cleanupExpiredTodos(), TODO_CLEANUP_INTERVAL_MS, {
description: 'ralph todo expiry cleanup',
});
}
/**
* Forward all sub-module events through RalphTracker
* so external consumers don't need to know about the split.
*/
private _wireSubModuleEvents(): void {
// Forward plan tracker events
for (const event of [
'planInitialized',
'planTaskUpdate',
'taskBlocked',
'taskUnblocked',
'planCheckpoint',
'planTaskAdded',
'planRollback',
] as const) {
this.planTracker.on(event, (...args: unknown[]) => this.emit(event, ...args));
}
// Forward status parser events
this.statusParser.on('statusBlockDetected', (block: RalphStatusBlock) => {
// Auto-enable tracker when we see a status block
if (!this._loopState.enabled && !this._autoEnableDisabled) {
this.enable();
}
this._loopState.lastActivity = Date.now();
this.emit('statusBlockDetected', block);
this.emitLoopUpdateDebounced();
});
this.statusParser.on('circuitBreakerUpdate', (status: CircuitBreakerStatus) => {
this.emit('circuitBreakerUpdate', status);
});
this.statusParser.on('exitGateMet', (data: { completionIndicators: number; exitSignal: boolean }) => {
this.emit('exitGateMet', data);
});
// Forward stall detector events
this.stallDetector.on('iterationStallWarning', (data: { iteration: number; stallDurationMs: number }) => {
this.emit('iterationStallWarning', data);
});
this.stallDetector.on('iterationStallCritical', (data: { iteration: number; stallDurationMs: number }) => {
this.emit('iterationStallCritical', data);
});
// Forward fix plan watcher events
this.fixPlanWatcher.on('todosLoaded', (items: RalphTodoItem[]) => {
// Replace _todos with file-based items
this._todos.clear();
for (const item of items) {
this._todos.set(item.id, item);
}
// Auto-enable tracker when we have todos from @fix_plan.md
if (!this._loopState.enabled) {
this.enable();
}
this.emit('todoUpdate', this.todos);
});
}
// ========== Delegated Plan Tracker Methods ==========
/**
* Initialize plan tasks from generated plan items.
*/
initializePlanTasks(
items: Array<{
id?: string;
content: string;
priority?: 'P0' | 'P1' | 'P2' | null;
verificationCriteria?: string;
testCommand?: string;
dependencies?: string[];
tddPhase?: TddPhase;
pairedWith?: string;
complexity?: 'low' | 'medium' | 'high';
}>
): void {
this.planTracker.initializePlanTasks(items);
}
/**
* Update a specific plan task's status, attempts, or error.
*/
updatePlanTask(
taskId: string,
update: {
status?: PlanTaskStatus;
error?: string;
incrementAttempts?: boolean;
}
): { success: boolean; task?: EnhancedPlanTask; error?: string } {
return this.planTracker.updatePlanTask(taskId, update);
}
/**
* Add a new task to the plan.
*/
addPlanTask(task: {
content: string;
priority?: 'P0' | 'P1' | 'P2';
verificationCriteria?: string;
dependencies?: string[];
insertAfter?: string;
}): { task: EnhancedPlanTask } {
return this.planTracker.addPlanTask(task);
}
/**
* Get all plan tasks.
*/
getPlanTasks(): EnhancedPlanTask[] {
return this.planTracker.getPlanTasks();
}
/**
* Generate a checkpoint review.
*/
generateCheckpointReview(): CheckpointReview {
return this.planTracker.generateCheckpointReview();
}
/**
* Get plan version history.
*/
getPlanHistory(): Array<{
version: number;
timestamp: number;
summary: string;
stats: { total: number; completed: number; failed: number };
}> {
return this.planTracker.getPlanHistory();
}
/**
* Rollback to a previous plan version.
*/
rollbackToVersion(version: number): {
success: boolean;
plan?: EnhancedPlanTask[];
error?: string;
} {
return this.planTracker.rollbackToVersion(version);
}
/**
* Check if checkpoint review is due.
*/
isCheckpointDue(): boolean {
return this.planTracker.isCheckpointDue();
}
/**
* Get current plan version.
*/
get planVersion(): number {
return this.planTracker.planVersion;
}
// ========== Delegated Fix Plan Watcher Methods ==========
/**
* Set the working directory and start watching @fix_plan.md.
* @param workingDir - The session's working directory
*/
setWorkingDir(workingDir: string): void {
this.fixPlanWatcher.setWorkingDir(workingDir);
}
/**
* Load @fix_plan.md from disk if it exists.
*/
async loadFixPlanFromDisk(): Promise {
return this.fixPlanWatcher.loadFixPlanFromDisk();
}
/**
* Stop watching @fix_plan.md.
*/
stopWatchingFixPlan(): void {
this.fixPlanWatcher.stopWatchingFixPlan();
}
/**
* When @fix_plan.md is active, treat it as the source of truth for todo status.
*/
get isFileAuthoritative(): boolean {
return this.fixPlanWatcher.isFileAuthoritative;
}
/**
* Generate @fix_plan.md content from current todos.
*/
generateFixPlanMarkdown(): string {
return generateFixPlanMarkdown(this.todos);
}
/**
* Parse @fix_plan.md content and import todos.
* Replaces current todos with imported ones.
*
* @param content - Markdown content from @fix_plan.md
* @returns Number of todos imported
*/
importFixPlanMarkdown(content: string): number {
const newTodos = importFixPlanMarkdown(
content,
(c: string) => this.parsePriority(c),
(c: string) => this.generateTodoId(c)
);
// Replace current todos with imported ones
this._todos.clear();
for (const todo of newTodos) {
this._todos.set(todo.id, todo);
}
// Emit update
this.emit('todoUpdate', this.todos);
return newTodos.length;
}
// ========== Delegated Stall Detector Methods ==========
/**
* Start iteration stall detection timer.
*/
startIterationStallDetection(): void {
this.stallDetector.startIterationStallDetection();
}
/**
* Stop iteration stall detection timer.
*/
stopIterationStallDetection(): void {
this.stallDetector.stopIterationStallDetection();
}
/**
* Get iteration stall metrics for monitoring.
*/
getIterationStallMetrics(): {
lastIterationChangeTime: number;
stallDurationMs: number;
warningThresholdMs: number;
criticalThresholdMs: number;
isWarned: boolean;
currentIteration: number;
} {
return this.stallDetector.getIterationStallMetrics();
}
/**
* Configure iteration stall thresholds.
*/
configureIterationStallThresholds(warningMs: number, criticalMs: number): void {
this.stallDetector.configureIterationStallThresholds(warningMs, criticalMs);
}
// ========== Delegated Status Parser Methods ==========
/**
* Manually reset circuit breaker to CLOSED state.
* @fires circuitBreakerUpdate
*/
resetCircuitBreaker(): void {
this.statusParser.resetCircuitBreaker();
}
/**
* Get current circuit breaker status.
*/
get circuitBreakerStatus(): CircuitBreakerStatus {
return this.statusParser.circuitBreakerStatus;
}
/**
* Get last parsed RALPH_STATUS block.
*/
get lastStatusBlock(): RalphStatusBlock | null {
return this.statusParser.lastStatusBlock;
}
/**
* Get cumulative stats from status blocks.
*/
get cumulativeStats(): {
filesModified: number;
tasksCompleted: number;
completionIndicators: number;
} {
return this.statusParser.cumulativeStats;
}
/**
* Whether dual-condition exit gate has been met.
*/
get exitGateMet(): boolean {
return this.statusParser.exitGateMet;
}
// ========== Core Methods ==========
/**
* Add an alternate completion phrase (P1-003: multi-phrase support).
*/
addAlternateCompletionPhrase(phrase: string): void {
if (!this._alternateCompletionPhrases.has(phrase)) {
this._alternateCompletionPhrases.add(phrase);
this._loopState.alternateCompletionPhrases = Array.from(this._alternateCompletionPhrases);
this.emit('loopUpdate', this.loopState);
}
}
/**
* Remove an alternate completion phrase.
*/
removeAlternateCompletionPhrase(phrase: string): void {
if (this._alternateCompletionPhrases.delete(phrase)) {
this._loopState.alternateCompletionPhrases = Array.from(this._alternateCompletionPhrases);
this.emit('loopUpdate', this.loopState);
}
}
/**
* Check if a phrase matches any valid completion phrase (primary or alternate).
*/
isValidCompletionPhrase(phrase: string): boolean {
return this.findMatchingCompletionPhrase(phrase) !== null;
}
/**
* Find which completion phrase (primary or alternate) matches the given phrase.
*/
private findMatchingCompletionPhrase(phrase: string): string | null {
const primary = this._loopState.completionPhrase;
if (primary && this.isFuzzyPhraseMatch(phrase, primary)) {
return primary;
}
for (const alt of this._alternateCompletionPhrases) {
if (this.isFuzzyPhraseMatch(phrase, alt)) {
return alt;
}
}
return null;
}
/**
* Prevent auto-enable from pattern detection.
*/
disableAutoEnable(): void {
this._autoEnableDisabled = true;
}
/**
* Allow auto-enable from pattern detection.
*/
enableAutoEnable(): void {
this._autoEnableDisabled = false;
}
/**
* Whether auto-enable is disabled.
*/
get autoEnableDisabled(): boolean {
return this._autoEnableDisabled;
}
/**
* Whether the tracker is enabled and actively monitoring output.
*/
get enabled(): boolean {
return this._loopState.enabled;
}
/**
* Enable the tracker to start monitoring terminal output.
* @fires enabled
* @fires loopUpdate
*/
enable(): void {
if (!this._loopState.enabled) {
this._loopState.enabled = true;
this._loopState.lastActivity = Date.now();
this.emit('enabled');
this.emit('loopUpdate', this.loopState);
}
}
/**
* Disable the tracker to stop monitoring terminal output.
* @fires loopUpdate
*/
disable(): void {
if (this._loopState.enabled) {
this._loopState.enabled = false;
this._loopState.lastActivity = Date.now();
this.emit('loopUpdate', this.loopState);
}
}
/**
* Soft reset - clears state but keeps enabled status.
* @fires loopUpdate
* @fires todoUpdate
*/
reset(): void {
// Clear debounce timers
this.clearDebounceTimers();
const wasEnabled = this._loopState.enabled;
this._loopState = createInitialRalphTrackerState();
this._loopState.enabled = wasEnabled; // Keep enabled status
this._todos.clear();
this._completionPhraseCount.clear();
this._taskNumberToContent.clear();
this._lineBuffer = '';
this._partialPromiseBuffer = '';
// Reset sub-modules
this.statusParser.reset();
this.planTracker.reset();
this.stallDetector.reset();
// Emit on next tick to prevent listeners from modifying state during reset (non-reentrant)
const loopState = this.loopState;
const todos = this.todos;
process.nextTick(() => {
this.emit('loopUpdate', loopState);
this.emit('todoUpdate', todos);
});
}
/**
* Full reset - clears all state including enabled status.
* @fires loopUpdate
* @fires todoUpdate
*/
fullReset(): void {
// Clear debounce timers
this.clearDebounceTimers();
this._loopState = createInitialRalphTrackerState();
this._todos.clear();
this._completionPhraseCount.clear();
this._taskNumberToContent.clear();
this._todoStartTimes.clear();
this._alternateCompletionPhrases.clear();
this._lineBuffer = '';
this._partialPromiseBuffer = '';
// Full reset sub-modules
this.statusParser.fullReset();
this.planTracker.fullReset();
this.stallDetector.reset();
// Emit on next tick to prevent listeners from modifying state during reset (non-reentrant)
const loopState = this.loopState;
const todos = this.todos;
process.nextTick(() => {
this.emit('loopUpdate', loopState);
this.emit('todoUpdate', todos);
});
}
/**
* Clear all debounce timers.
*/
private clearDebounceTimers(): void {
this._todoDeb.cancel();
this._loopDeb.cancel();
}
/**
* Emit todoUpdate event with debouncing.
*/
private emitTodoUpdateDebounced(): void {
this._todoDeb.schedule(() => this.emit('todoUpdate', this.todos));
}
/**
* Emit loopUpdate event with debouncing.
*/
private emitLoopUpdateDebounced(): void {
this._loopDeb.schedule(() => this.emit('loopUpdate', this.loopState));
}
/**
* Flush all pending debounced events immediately.
*/
flushPendingEvents(): void {
if (this._todoDeb.isPending) {
this._todoDeb.cancel();
this.emit('todoUpdate', this.todos);
}
if (this._loopDeb.isPending) {
this._loopDeb.cancel();
this.emit('loopUpdate', this.loopState);
}
}
/**
* Get a copy of the current loop state.
*/
get loopState(): RalphTrackerState {
return {
...this._loopState,
planVersion: this.planTracker.planVersion,
planHistoryLength: this.planTracker.getPlanHistory().length,
completionConfidence: this._lastCompletionConfidence,
// Surface the live todo-config so it persists (toState) and reads back into
// the Session Options modal (broadcast) — mirrors maxIterations round-trip.
maxTodos: this._maxTodos,
todoExpirationMinutes: this.todoExpirationMinutes,
};
}
/**
* Calculate confidence score for a potential completion signal.
*/
calculateCompletionConfidence(phrase: string, context?: string): CompletionConfidence {
let score = 0;
const signals = {
hasPromiseTag: false,
matchesExpected: false,
allTodosComplete: false,
hasExitSignal: false,
multipleIndicators: false,
contextAppropriate: true, // Default to true, deduct if inappropriate
};
// Check for promise tag format (adds 30 points)
if (context && PROMISE_PATTERN.test(context)) {
signals.hasPromiseTag = true;
score += 30;
}
// Check if phrase matches expected completion phrase (adds 25 points)
const expectedPhrase = this._loopState.completionPhrase;
if (expectedPhrase) {
const matchedPhrase = this.findMatchingCompletionPhrase(phrase);
if (matchedPhrase) {
signals.matchesExpected = true;
score += 25;
}
}
// Check if all todos are complete (adds 20 points)
const todoArray = Array.from(this._todos.values());
if (todoArray.length > 0 && todoArray.every((t) => t.status === 'completed')) {
signals.allTodosComplete = true;
score += 20;
}
// Check for EXIT_SIGNAL from RALPH_STATUS block (adds 15 points)
const lastBlock = this.statusParser.lastStatusBlock;
if (lastBlock?.exitSignal === true) {
signals.hasExitSignal = true;
score += 15;
}
// Check for multiple completion indicators (adds 10 points)
if (this.statusParser.cumulativeStats.completionIndicators >= 2) {
signals.multipleIndicators = true;
score += 10;
}
// Check context appropriateness (deduct if inappropriate)
if (context) {
const lowerContext = context.toLowerCase();
if (
lowerContext.includes('output:') ||
lowerContext.includes('completion phrase') ||
lowerContext.includes('output exactly') ||
lowerContext.includes('when done')
) {
signals.contextAppropriate = false;
score -= 20;
} else {
score += 10;
}
}
// Bonus for active loop state (adds 10 points)
if (this._loopState.active) {
score += 10;
}
// Bonus for 2nd+ occurrence (adds 15 points)
const count = this._completionPhraseCount.get(phrase) || 0;
if (count >= 2) {
score += 15;
}
// Clamp score to 0-100
score = Math.max(0, Math.min(100, score));
const confidence: CompletionConfidence = {
score,
isConfident: score >= RalphTracker.COMPLETION_CONFIDENCE_THRESHOLD,
signals,
calculatedAt: Date.now(),
};
this._lastCompletionConfidence = confidence;
return confidence;
}
/**
* Get all tracked todo items as an array.
*/
get todos(): RalphTodoItem[] {
return Array.from(this._todos.values());
}
/**
* Process raw terminal data to detect inner loop patterns.
*/
processTerminalData(data: string): void {
// Remove ANSI escape codes for cleaner parsing
const cleanData = data.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '');
this.processCleanData(cleanData);
}
/**
* Process pre-stripped terminal data (ANSI codes already removed).
*/
processCleanData(cleanData: string): void {
// If tracker is disabled, only check for patterns that should auto-enable it
if (!this._loopState.enabled) {
if (this._autoEnableDisabled) {
return;
}
if (this.shouldAutoEnable(cleanData)) {
this.enable();
} else {
return;
}
}
// Buffer data for line-based processing
this._lineBuffer += cleanData;
// Prevent unbounded line buffer growth from very long lines
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
this._lineBuffer = this._lineBuffer.slice(-Math.floor(MAX_LINE_BUFFER_SIZE / 2));
}
// Process complete lines
const lines = this._lineBuffer.split('\n');
this._lineBuffer = lines.pop() || '';
for (const line of lines) {
this.processLine(line);
}
// Also check the current buffer for multi-line patterns
this.checkMultiLinePatterns(cleanData);
// Cleanup expired todos (throttled to avoid running on every chunk)
this.maybeCleanupExpiredTodos();
}
/**
* Check if data contains patterns that should auto-enable the tracker.
*/
private shouldAutoEnable(data: string): boolean {
// Cheap pre-filter: skip the full regex battery if none of the key
// substrings that any pattern could match are present in the data.
if (
!data.includes('<') &&
!data.includes('ralph') &&
!data.includes('Ralph') &&
!data.includes('Todo') &&
!data.includes('todo') &&
!data.includes('Iteration') &&
!data.includes('[') &&
!data.includes('\u2610') &&
!data.includes('\u2612') &&
!data.includes('\u2714') &&
!data.includes('Loop') &&
!data.includes('complete') &&
!data.includes('COMPLETE') &&
!data.includes('Done') &&
!data.includes('DONE')
) {
return false;
}
if (RALPH_START_PATTERN.test(data)) return true;
if (PROMISE_PATTERN.test(data)) return true;
if (TODOWRITE_PATTERN.test(data)) return true;
if (ITERATION_PATTERN.test(data)) return true;
TODO_CHECKBOX_PATTERN.lastIndex = 0;
if (TODO_CHECKBOX_PATTERN.test(data)) return true;
TODO_INDICATOR_PATTERN.lastIndex = 0;
if (TODO_INDICATOR_PATTERN.test(data)) return true;
TODO_NATIVE_PATTERN.lastIndex = 0;
if (TODO_NATIVE_PATTERN.test(data)) return true;
TODO_TASK_CREATED_PATTERN.lastIndex = 0;
if (TODO_TASK_CREATED_PATTERN.test(data)) return true;
TODO_TASK_STATUS_PATTERN.lastIndex = 0;
if (TODO_TASK_STATUS_PATTERN.test(data)) return true;
if (LOOP_START_PATTERN.test(data)) return true;
if (ALL_COMPLETE_PATTERN.test(data)) return true;
if (TASK_DONE_PATTERN.test(data)) return true;
return false;
}
/**
* Process a single line of terminal output.
*/
private processLine(line: string): void {
const trimmed = line.trim();
if (!trimmed) return;
// Delegate RALPH_STATUS block and completion indicator detection to sub-module
this.statusParser.processLine(trimmed);
// Check for completion phrase
this.detectCompletionPhrase(trimmed);
// Check for "all tasks complete" signals
this.detectAllTasksComplete(trimmed);
// Check for individual task completion signals
this.detectTaskCompletion(trimmed);
// Check for loop start/status
this.detectLoopStatus(trimmed);
// Check for todo items
this.detectTodoItems(trimmed);
}
/**
* Mark all tracked todos as completed and emit todoUpdate if any changed.
* @returns true if any todo was updated
*/
private completeAllTodos(): boolean {
let updated = false;
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
return updated;
}
/**
* Detect "all tasks complete" messages.
*/
private detectAllTasksComplete(line: string): void {
if (this.isFileAuthoritative) return;
if (!ALL_COMPLETE_PATTERN.test(line)) return;
if (line.length > 100) return;
if (line.toLowerCase().includes('output:') || line.includes('')) return;
if (this._todos.size === 0) return;
const countMatch = line.match(ALL_COUNT_PATTERN);
const parsedCount = countMatch ? parseInt(countMatch[1], 10) : NaN;
const mentionedCount = Number.isNaN(parsedCount) ? null : parsedCount;
const todoCount = this._todos.size;
if (mentionedCount !== null && Math.abs(mentionedCount - todoCount) > 2) {
return;
}
this.completeAllTodos();
if (this._loopState.completionPhrase) {
this._loopState.active = false;
this._loopState.lastActivity = Date.now();
this.emit('completionDetected', this._loopState.completionPhrase);
this.emit('loopUpdate', this.loopState);
}
}
/**
* Detect individual task completion signals.
*/
private detectTaskCompletion(line: string): void {
if (this.isFileAuthoritative) return;
if (!TASK_DONE_PATTERN.test(line)) return;
const taskNumMatch = line.match(/task\s*#?(\d+)/i);
if (taskNumMatch) {
const taskNum = parseInt(taskNumMatch[1], 10);
if (Number.isNaN(taskNum)) return;
let count = 0;
for (const [_id, todo] of this._todos) {
count++;
if (count === taskNum && todo.status !== 'completed') {
todo.status = 'completed';
this.emit('todoUpdate', this.todos);
break;
}
}
}
}
/**
* Check for multi-line patterns that might span line boundaries.
*/
private checkMultiLinePatterns(data: string): void {
if (this._partialPromiseBuffer) {
const combinedData = this._partialPromiseBuffer + data;
const promiseMatch = combinedData.match(PROMISE_PATTERN);
if (promiseMatch) {
const phrase = promiseMatch[1].trim();
this._partialPromiseBuffer = '';
this.handleCompletionPhrase(phrase);
return;
}
}
const partialMatch = data.match(PROMISE_PARTIAL_PATTERN);
if (partialMatch) {
const partialContent = partialMatch[0];
if (partialContent.length <= RalphTracker.MAX_PARTIAL_PROMISE_SIZE) {
this._partialPromiseBuffer = partialContent;
} else {
this._partialPromiseBuffer = '';
}
} else {
this._partialPromiseBuffer = '';
}
}
/**
* Detect completion phrases in a line.
*/
private detectCompletionPhrase(line: string): void {
const match = line.match(PROMISE_PATTERN);
if (match) {
this.handleCompletionPhrase(match[1]);
return;
}
const expectedPhrase = this._loopState.completionPhrase;
if (expectedPhrase && line.toUpperCase().includes(expectedPhrase.toUpperCase())) {
const isNotInPromptContext = !line.includes('') && !line.includes('output:');
const isNotExplanation =
!line.toLowerCase().includes('completion phrase') && !line.toLowerCase().includes('output exactly');
if (isNotInPromptContext && isNotExplanation) {
this.handleBareCompletionPhrase(expectedPhrase);
}
}
}
/**
* Handle a bare completion phrase (without XML tags).
*/
private handleBareCompletionPhrase(phrase: string): void {
const taggedCount = this._completionPhraseCount.get(phrase) || 0;
const loopExplicitlyActive = this._loopState.active;
if (taggedCount === 0 && !loopExplicitlyActive) return;
const bareKey = `bare:${phrase}`;
const bareCount = (this._completionPhraseCount.get(bareKey) || 0) + 1;
this._completionPhraseCount.set(bareKey, bareCount);
if (bareCount > 1) return;
this.completeAllTodos();
this._loopState.active = false;
this._loopState.lastActivity = Date.now();
this.emit('completionDetected', phrase);
this.emit('loopUpdate', this.loopState);
}
/**
* Handle a detected completion phrase.
*/
private handleCompletionPhrase(phrase: string): void {
const count = (this._completionPhraseCount.get(phrase) || 0) + 1;
this._completionPhraseCount.set(phrase, count);
// Trim completion phrase map if it exceeds the limit
if (this._completionPhraseCount.size > MAX_COMPLETION_PHRASE_ENTRIES) {
const entries = Array.from(this._completionPhraseCount.entries());
entries.sort((a, b) => b[1] - a[1]);
this._completionPhraseCount.clear();
const keepCount = Math.floor(MAX_COMPLETION_PHRASE_ENTRIES / 2);
for (let i = 0; i < Math.min(keepCount, entries.length); i++) {
this._completionPhraseCount.set(entries[i][0], entries[i][1]);
}
if (this._loopState.completionPhrase && !this._completionPhraseCount.has(this._loopState.completionPhrase)) {
this._completionPhraseCount.set(this._loopState.completionPhrase, 1);
}
}
// Store phrase on first occurrence
if (!this._loopState.completionPhrase) {
this._loopState.completionPhrase = phrase;
this._loopState.lastActivity = Date.now();
this.validateCompletionPhrase(phrase);
this.emit('loopUpdate', this.loopState);
}
// Check for fuzzy match with primary phrase or any alternate phrase
const matchedPhrase = this.findMatchingCompletionPhrase(phrase);
if (matchedPhrase) {
const canonicalCount = this._completionPhraseCount.get(matchedPhrase) || 0;
if (canonicalCount >= 2 || this._loopState.active) {
this._loopState.active = false;
this._loopState.lastActivity = Date.now();
this.completeAllTodos();
this.emit('completionDetected', matchedPhrase);
this.emit('loopUpdate', this.loopState);
return;
}
}
if (this._loopState.active || count >= 2) {
this.completeAllTodos();
this._loopState.active = false;
this._loopState.lastActivity = Date.now();
this.emit('completionDetected', phrase);
this.emit('loopUpdate', this.loopState);
}
}
/**
* Check if two phrases match with fuzzy tolerance.
*/
private isFuzzyPhraseMatch(phrase1: string, phrase2: string, maxDistance = 2): boolean {
return fuzzyPhraseMatch(phrase1, phrase2, maxDistance);
}
/**
* Validate a completion phrase and emit warnings if it's risky.
*/
private validateCompletionPhrase(phrase: string): void {
const normalized = phrase.toUpperCase().replace(/[\s_\-.]+/g, '');
const uniqueSuffix = Date.now().toString(36).slice(-4).toUpperCase();
const suggestedPhrase = `${phrase}_${uniqueSuffix}`;
if (COMMON_COMPLETION_PHRASES.has(normalized)) {
this.emitValidationWarning(phrase, 'common', suggestedPhrase);
return;
}
if (normalized.length < MIN_RECOMMENDED_PHRASE_LENGTH) {
this.emitValidationWarning(phrase, 'short', suggestedPhrase);
return;
}
if (/^\d+$/.test(normalized)) {
this.emitValidationWarning(phrase, 'numeric', suggestedPhrase);
}
}
/**
* Emit a phrase validation warning with a console message and event.
*/
private emitValidationWarning(phrase: string, reason: 'common' | 'short' | 'numeric', suggestedPhrase: string): void {
const descriptions: Record<'common' | 'short' | 'numeric', string> = {
common: 'is very common and may cause false positives',
short: `is too short (${phrase.toUpperCase().replace(/[\s_\-.]+/g, '').length} chars)`,
numeric: 'is numeric-only and may cause false positives',
};
console.warn(
`[RalphTracker] Warning: Completion phrase "${phrase}" ${descriptions[reason]}. Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', {
phrase,
reason,
suggestedPhrase,
});
}
/**
* Activate the loop if not already active.
*/
private activateLoopIfNeeded(): boolean {
if (this._loopState.active) return false;
this._loopState.active = true;
this._loopState.startedAt = Date.now();
this._loopState.cycleCount = 0;
this._loopState.maxIterations = null;
this._loopState.elapsedHours = null;
this._loopState.lastActivity = Date.now();
this.emit('loopUpdate', this.loopState);
return true;
}
/**
* Detect loop start and status indicators.
*/
private detectLoopStatus(line: string): void {
if (RALPH_START_PATTERN.test(line) || LOOP_START_PATTERN.test(line)) {
this.activateLoopIfNeeded();
}
const maxIterMatch = line.match(MAX_ITERATIONS_PATTERN);
if (maxIterMatch) {
const maxIter = parseInt(maxIterMatch[1], 10);
if (!Number.isNaN(maxIter) && maxIter > 0) {
this._loopState.maxIterations = maxIter;
this._loopState.lastActivity = Date.now();
this.emitLoopUpdateDebounced();
}
}
const iterMatch = line.match(ITERATION_PATTERN);
if (iterMatch) {
const currentIter = parseInt(iterMatch[1] || iterMatch[3], 10);
const maxIterStr = iterMatch[2] || iterMatch[4];
const maxIter = maxIterStr ? parseInt(maxIterStr, 10) : null;
if (!Number.isNaN(currentIter)) {
this.activateLoopIfNeeded();
// Track iteration changes for stall detection and circuit breaker
if (currentIter !== this.stallDetector.getIterationStallMetrics().currentIteration) {
this.stallDetector.notifyIterationChanged(currentIter);
this.statusParser.notifyIterationProgress(currentIter);
}
this._loopState.cycleCount = currentIter;
// Notify sub-modules of cycle count
this.planTracker.notifyCycleCount(currentIter);
this.statusParser.setCycleCount(currentIter);
this.stallDetector.setLoopActive(true);
if (maxIter !== null && !Number.isNaN(maxIter)) {
this._loopState.maxIterations = maxIter;
}
this._loopState.lastActivity = Date.now();
this.emitLoopUpdateDebounced();
}
}
const elapsedMatch = line.match(ELAPSED_TIME_PATTERN);
if (elapsedMatch) {
this._loopState.elapsedHours = parseFloat(elapsedMatch[1]);
this._loopState.lastActivity = Date.now();
this.emitLoopUpdateDebounced();
}
const cycleMatch = line.match(CYCLE_PATTERN);
if (cycleMatch) {
const cycleNum = parseInt(cycleMatch[1] || cycleMatch[2], 10);
if (!Number.isNaN(cycleNum) && cycleNum > this._loopState.cycleCount) {
this._loopState.cycleCount = cycleNum;
this._loopState.lastActivity = Date.now();
this.emitLoopUpdateDebounced();
}
}
if (TODOWRITE_PATTERN.test(line)) {
this._loopState.lastActivity = Date.now();
}
}
/**
* Detect todo items in various formats from Claude Code output.
*/
private detectTodoItems(line: string): void {
const hasCheckbox = line.includes('[');
const hasTodoIndicator = line.includes('Todo:');
const hasNativeCheckbox = line.includes('☐') || line.includes('☒') || line.includes('◐') || line.includes('✓');
const hasStatus = line.includes('(pending)') || line.includes('(in_progress)') || line.includes('(completed)');
const hasCheckmark = line.includes('✔');
if (!hasCheckbox && !hasTodoIndicator && !hasNativeCheckbox && !hasStatus && !hasCheckmark) {
return;
}
let updated = false;
let match: RegExpExecArray | null;
if (hasCheckbox) {
execPattern(TODO_CHECKBOX_PATTERN, line, (match) => {
const checked = match[1].toLowerCase() === 'x';
const content = match[2].trim();
const status: RalphTodoStatus = checked ? 'completed' : 'pending';
this.upsertTodo(content, status);
updated = true;
});
}
if (hasTodoIndicator) {
execPattern(TODO_INDICATOR_PATTERN, line, (match) => {
const icon = match[1];
const content = match[2].trim();
const status = this.iconToStatus(icon);
this.upsertTodo(content, status);
updated = true;
});
}
if (hasStatus) {
execPattern(TODO_STATUS_PATTERN, line, (match) => {
const content = match[1].trim();
const status = match[2] as RalphTodoStatus;
this.upsertTodo(content, status);
updated = true;
});
}
if (hasNativeCheckbox) {
TODO_NATIVE_PATTERN.lastIndex = 0;
while ((match = TODO_NATIVE_PATTERN.exec(line)) !== null) {
const icon = match[1];
const content = match[2].trim();
const shouldExclude = TODO_EXCLUDE_PATTERNS.some((pattern) => pattern.test(content));
if (shouldExclude) continue;
if (content.length < 5) continue;
const status = this.iconToStatus(icon);
this.upsertTodo(content, status);
updated = true;
}
}
if (hasCheckmark) {
execPattern(TODO_TASK_CREATED_PATTERN, line, (match) => {
const taskNum = parseInt(match[1], 10);
const content = match[2].trim();
if (content.length >= 5) {
this._taskNumberToContent.set(taskNum, content);
this.enforceTaskMappingLimit();
this.upsertTodo(content, 'pending');
updated = true;
}
});
execPattern(TODO_TASK_SUMMARY_PATTERN, line, (match) => {
const taskNum = parseInt(match[1], 10);
const content = match[2].trim();
if (content.length >= 5) {
if (!this._taskNumberToContent.has(taskNum)) {
this._taskNumberToContent.set(taskNum, content);
this.enforceTaskMappingLimit();
}
this.upsertTodo(this._taskNumberToContent.get(taskNum) || content, 'pending');
updated = true;
}
});
execPattern(TODO_TASK_STATUS_PATTERN, line, (match) => {
const taskNum = parseInt(match[1], 10);
const statusStr = match[2].trim();
const status: RalphTodoStatus =
statusStr === 'completed' ? 'completed' : statusStr === 'in progress' ? 'in_progress' : 'pending';
const content = this._taskNumberToContent.get(taskNum);
if (content) {
this.upsertTodo(content, status);
updated = true;
}
});
if (!updated) {
TODO_PLAIN_CHECKMARK_PATTERN.lastIndex = 0;
while ((match = TODO_PLAIN_CHECKMARK_PATTERN.exec(line)) !== null) {
const content = match[1].trim();
const shouldExclude = TODO_EXCLUDE_PATTERNS.some((pattern) => pattern.test(content));
if (shouldExclude) continue;
if (content.length < 5) continue;
if (/^(Task\s*#\d+|#\d+)\s/.test(content)) continue;
this.upsertTodo(content, 'completed');
updated = true;
}
}
}
if (updated) {
this.emitTodoUpdateDebounced();
}
}
/**
* Convert a todo icon character to its corresponding status.
*/
private iconToStatus(icon: string): RalphTodoStatus {
switch (icon) {
case '✓':
case '✅':
case '☒':
case '◉':
case '●':
return 'completed';
case '◐':
case '⏳':
case '⌛':
case '🔄':
return 'in_progress';
case '☐':
case '○':
default:
return 'pending';
}
}
/**
* Parse priority from todo content.
*/
private parsePriority(content: string): RalphTodoPriority {
const upper = content.toUpperCase();
for (const pattern of P0_PRIORITY_PATTERNS) {
if (pattern.test(upper)) {
return 'P0';
}
}
for (const pattern of P1_PRIORITY_PATTERNS) {
if (pattern.test(upper)) {
return 'P1';
}
}
for (const pattern of P2_PRIORITY_PATTERNS) {
if (pattern.test(upper)) {
return 'P2';
}
}
return null;
}
/**
* Add a new todo item or update an existing one.
*/
private upsertTodo(content: string, status: RalphTodoStatus): void {
if (!content || !content.trim()) return;
const cleanContent = content.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '').replace(/\s+/g, ' ').trim();
if (cleanContent.length < 5) return;
const priority = this.parsePriority(cleanContent);
const estimatedComplexity = this.estimateComplexity(cleanContent);
const id = this.generateTodoId(cleanContent);
const existing = this._todos.get(id);
if (existing) {
const wasCompleted = existing.status === 'completed';
const isNowCompleted = status === 'completed';
const wasInProgress = existing.status === 'in_progress';
const isNowInProgress = status === 'in_progress';
existing.status = status;
existing.detectedAt = Date.now();
if (priority) existing.priority = priority;
if (!existing.estimatedComplexity) {
existing.estimatedComplexity = estimatedComplexity;
}
if (!wasCompleted && isNowCompleted) {
this.recordTodoCompletion(id);
}
if (!wasInProgress && isNowInProgress) {
this.startTrackingTodo(id);
}
} else {
const similar = this.findSimilarTodo(cleanContent);
if (similar) {
const wasCompleted = similar.status === 'completed';
const isNowCompleted = status === 'completed';
const wasInProgress = similar.status === 'in_progress';
const isNowInProgress = status === 'in_progress';
similar.status = status;
similar.detectedAt = Date.now();
if (priority && !similar.priority) {
similar.priority = priority;
}
if (cleanContent.length > similar.content.length) {
similar.content = cleanContent;
}
if (!wasCompleted && isNowCompleted) {
this.recordTodoCompletion(similar.id);
}
if (!wasInProgress && isNowInProgress) {
this.startTrackingTodo(similar.id);
}
return;
}
while (this._todos.size >= this._maxTodos) {
const oldest = this.findOldestTodo();
if (oldest) {
this._todos.delete(oldest.id);
} else {
const firstKey = this._todos.keys().next().value;
if (firstKey) this._todos.delete(firstKey);
else break;
}
}
const estimatedDurationMs = this.getEstimatedDuration(estimatedComplexity);
this._todos.set(id, {
id,
content: cleanContent,
status,
detectedAt: Date.now(),
priority,
estimatedComplexity,
estimatedDurationMs,
});
if (status === 'in_progress') {
this.startTrackingTodo(id);
}
}
}
/**
* Normalize todo content for consistent matching.
*/
private normalizeTodoContent(content: string): string {
if (!content) return '';
return content
.replace(/\s+/g, ' ')
.replace(/[^a-zA-Z0-9\s.,!?'"-]/g, '')
.trim()
.toLowerCase();
}
/**
* Calculate similarity between two strings.
*/
private calculateSimilarity(str1: string, str2: string): number {
const norm1 = this.normalizeTodoContent(str1);
const norm2 = this.normalizeTodoContent(str2);
if (norm1 === norm2) return 1.0;
if (!norm1 || !norm2) return 0.0;
const levenshteinSim = stringSimilarity(norm1, norm2);
const bigramSim = this.calculateBigramSimilarity(norm1, norm2);
return Math.max(levenshteinSim, bigramSim);
}
/**
* Calculate bigram (Dice coefficient) similarity.
*/
private calculateBigramSimilarity(norm1: string, norm2: string): number {
if (norm1.length < 3 || norm2.length < 3) {
const shorter = norm1.length <= norm2.length ? norm1 : norm2;
const longer = norm1.length > norm2.length ? norm1 : norm2;
return longer.includes(shorter) ? 0.9 : 0.0;
}
const getBigrams = (s: string): Set => {
const bigrams = new Set();
for (let i = 0; i < s.length - 1; i++) {
bigrams.add(s.substring(i, i + 2));
}
return bigrams;
};
const bigrams1 = getBigrams(norm1);
const bigrams2 = getBigrams(norm2);
let intersection = 0;
for (const bigram of bigrams1) {
if (bigrams2.has(bigram)) {
intersection++;
}
}
const totalBigrams = bigrams1.size + bigrams2.size;
if (totalBigrams === 0) return 0.0;
return (2 * intersection) / totalBigrams;
}
/**
* Find an existing todo that is similar to the given content.
*/
private findSimilarTodo(content: string): RalphTodoItem | undefined {
const normalized = this.normalizeTodoContent(content);
let threshold: number;
if (normalized.length < 30) {
threshold = SIMILARITY_THRESHOLD_SHORT;
} else if (normalized.length < 60) {
threshold = SIMILARITY_THRESHOLD_MEDIUM;
} else {
threshold = TODO_SIMILARITY_THRESHOLD;
}
let bestMatch: RalphTodoItem | undefined;
let bestSimilarity = 0;
for (const todo of this._todos.values()) {
const similarity = this.calculateSimilarity(content, todo.content);
if (similarity >= threshold && similarity > bestSimilarity) {
bestSimilarity = similarity;
bestMatch = todo;
}
}
return bestMatch;
}
// ========== P1-009: Progress Estimation Methods ==========
/**
* Estimate complexity of a todo based on content keywords.
*/
private estimateComplexity(content: string): 'trivial' | 'simple' | 'moderate' | 'complex' {
const lower = content.toLowerCase();
const trivialPatterns = [
/\btypo\b/,
/\bspelling\b/,
/\bcomment\b/,
/\bupdate\s+(?:version|readme)\b/,
/\brename\b/,
/\bformat(?:ting)?\b/,
];
const complexPatterns = [
/\barchitect(?:ure)?\b/,
/\brefactor\b/,
/\brewrite\b/,
/\bsecurity\b/,
/\bmigrat(?:e|ion)\b/,
/\btest(?:s|ing)?\b/,
/\bintegrat(?:e|ion)\b/,
/\bperformance\b/,
/\boptimiz(?:e|ation)\b/,
/\bmultiple\s+files?\b/,
];
const moderatePatterns = [/\bbug\b/, /\bfeature\b/, /\benhance(?:ment)?\b/, /\bimplement\b/, /\badd\b/, /\bfix\b/];
for (const pattern of complexPatterns) {
if (pattern.test(lower)) return 'complex';
}
for (const trivialPattern of trivialPatterns) {
if (trivialPattern.test(lower)) return 'trivial';
}
for (const moderatePattern of moderatePatterns) {
if (moderatePattern.test(lower)) return 'moderate';
}
return 'simple';
}
/**
* Get estimated duration for a complexity level (ms).
*/
private getEstimatedDuration(complexity: 'trivial' | 'simple' | 'moderate' | 'complex'): number {
const avgTime = this.getAverageCompletionTime();
if (avgTime !== null) {
const multipliers = {
trivial: 0.25,
simple: 0.5,
moderate: 1.0,
complex: 2.0,
};
return Math.round(avgTime * multipliers[complexity]);
}
const defaults = {
trivial: 1 * 60 * 1000,
simple: 3 * 60 * 1000,
moderate: 10 * 60 * 1000,
complex: 30 * 60 * 1000,
};
return defaults[complexity];
}
/**
* Get average completion time from historical data.
*/
private getAverageCompletionTime(): number | null {
if (this._completionTimes.length === 0) return null;
const sum = this._completionTimes.reduce((a, b) => a + b, 0);
return Math.round(sum / this._completionTimes.length);
}
/**
* Record a todo completion for progress tracking.
*/
private recordTodoCompletion(todoId: string): void {
const startTime = this._todoStartTimes.get(todoId);
if (startTime) {
const duration = Date.now() - startTime;
this._completionTimes.push(duration);
while (this._completionTimes.length > RalphTracker.MAX_COMPLETION_TIMES) {
this._completionTimes.shift();
}
this._todoStartTimes.delete(todoId);
}
}
/**
* Start tracking a todo for duration estimation.
*/
private startTrackingTodo(todoId: string): void {
if (!this._todoStartTimes.has(todoId)) {
this._todoStartTimes.set(todoId, Date.now());
}
if (this._todosStartedAt === 0) {
this._todosStartedAt = Date.now();
}
}
/**
* Get progress estimation for the todo list.
*/
public getTodoProgress(): RalphTodoProgress {
const todos = Array.from(this._todos.values());
const total = todos.length;
const completed = todos.filter((t) => t.status === 'completed').length;
const inProgress = todos.filter((t) => t.status === 'in_progress').length;
const pending = todos.filter((t) => t.status === 'pending').length;
const percentComplete = total > 0 ? Math.round((completed / total) * 100) : 0;
let estimatedRemainingMs: number | null = null;
let avgCompletionTimeMs: number | null = null;
let projectedCompletionAt: number | null = null;
avgCompletionTimeMs = this.getAverageCompletionTime();
if (total > 0 && completed > 0) {
if (avgCompletionTimeMs !== null) {
const remaining = total - completed;
estimatedRemainingMs = remaining * avgCompletionTimeMs;
} else {
const elapsed = Date.now() - this._todosStartedAt;
if (elapsed > 0 && completed > 0) {
const timePerTodo = elapsed / completed;
avgCompletionTimeMs = Math.round(timePerTodo);
const remaining = total - completed;
estimatedRemainingMs = Math.round(remaining * timePerTodo);
}
}
if (estimatedRemainingMs !== null) {
projectedCompletionAt = Date.now() + estimatedRemainingMs;
}
} else if (total > 0 && completed === 0) {
let totalEstimate = 0;
for (const todo of todos) {
if (todo.status !== 'completed') {
const complexity = todo.estimatedComplexity || this.estimateComplexity(todo.content);
totalEstimate += this.getEstimatedDuration(complexity);
}
}
estimatedRemainingMs = totalEstimate;
projectedCompletionAt = Date.now() + totalEstimate;
}
return {
total,
completed,
inProgress,
pending,
percentComplete,
estimatedRemainingMs,
avgCompletionTimeMs,
projectedCompletionAt,
};
}
/**
* Generate a stable ID from todo content using content hashing.
*/
private generateTodoId(content: string): string {
if (!content) return 'todo-empty';
const hash = todoContentHash(content);
return `todo-${hash}`;
}
/**
* Find the todo item with the oldest detectedAt timestamp.
*/
private findOldestTodo(): RalphTodoItem | undefined {
let oldest: RalphTodoItem | undefined;
for (const todo of this._todos.values()) {
if (!oldest || todo.detectedAt < oldest.detectedAt) {
oldest = todo;
}
}
return oldest;
}
/**
* Conditionally run cleanup, throttled to CLEANUP_THROTTLE_MS.
*/
private maybeCleanupExpiredTodos(): void {
const now = Date.now();
if (now - this._lastCleanupTime < CLEANUP_THROTTLE_MS) {
return;
}
this._lastCleanupTime = now;
this.cleanupExpiredTodos();
}
/**
* Remove todo items older than the configured expiry duration.
*/
private cleanupExpiredTodos(): void {
const now = Date.now();
const toDelete: string[] = [];
for (const [id, todo] of this._todos) {
if (now - todo.detectedAt > this._todoExpiryMs) {
toDelete.push(id);
}
}
if (toDelete.length > 0) {
for (const id of toDelete) {
this._todos.delete(id);
this._todoStartTimes.delete(id);
}
this.emit('todoUpdate', this.todos);
}
}
/**
* Programmatically start a loop (external API).
*/
startLoop(completionPhrase?: string, maxIterations?: number): void {
this.enable();
this._loopState.active = true;
this._loopState.startedAt = Date.now();
this._loopState.cycleCount = 0;
this._loopState.maxIterations = maxIterations ?? null;
this._loopState.elapsedHours = null;
this._loopState.lastActivity = Date.now();
if (completionPhrase) {
this._loopState.completionPhrase = completionPhrase;
}
this.emit('loopUpdate', this.loopState);
}
/**
* Update the maximum iteration count (external API).
*/
setMaxIterations(maxIterations: number | null): void {
this._loopState.maxIterations = maxIterations;
this._loopState.lastActivity = Date.now();
this.emit('loopUpdate', this.loopState);
}
/** Maximum number of todos retained for this session. */
get maxTodos(): number {
return this._maxTodos;
}
/** Todo auto-expiry duration in minutes for this session. */
get todoExpirationMinutes(): number {
return Math.round(this._todoExpiryMs / 60000);
}
/**
* Update the maximum number of retained todos (external API).
* Ignores non-positive values.
*/
setMaxTodos(maxTodos: number): void {
if (!Number.isFinite(maxTodos) || maxTodos <= 0) return;
this._maxTodos = Math.floor(maxTodos);
}
/**
* Update the todo auto-expiry duration (external API), specified in minutes.
* Converts to milliseconds internally. Ignores non-positive values.
*/
setTodoExpirationMinutes(minutes: number): void {
if (!Number.isFinite(minutes) || minutes <= 0) return;
this._todoExpiryMs = Math.floor(minutes) * 60000;
}
/**
* Configure the tracker from external state.
*/
configure(config: { enabled?: boolean; completionPhrase?: string; maxIterations?: number }): void {
if (config.enabled !== undefined) {
this._loopState.enabled = config.enabled;
}
if (config.completionPhrase !== undefined) {
this._loopState.completionPhrase = config.completionPhrase;
}
if (config.maxIterations !== undefined) {
this._loopState.maxIterations = config.maxIterations;
}
this._loopState.lastActivity = Date.now();
this.emit('loopUpdate', this.loopState);
}
/**
* Programmatically stop the loop (external API).
*/
stopLoop(): void {
this._loopState.active = false;
this._loopState.lastActivity = Date.now();
this.emit('loopUpdate', this.loopState);
}
/**
* Enforce size limit on _taskNumberToContent map.
*/
private enforceTaskMappingLimit(): void {
if (this._taskNumberToContent.size <= MAX_TASK_MAPPINGS) return;
const sortedKeys = Array.from(this._taskNumberToContent.keys()).sort((a, b) => a - b);
const keysToRemove = sortedKeys.slice(0, this._taskNumberToContent.size - MAX_TASK_MAPPINGS);
for (const key of keysToRemove) {
this._taskNumberToContent.delete(key);
}
}
/**
* Clear all state and disable the tracker.
*/
clear(): void {
this.clearDebounceTimers();
this.fixPlanWatcher.stop();
this.stallDetector.stopIterationStallDetection();
this._loopState = createInitialRalphTrackerState();
this._todos.clear();
this._taskNumberToContent.clear();
this._todoStartTimes.clear();
this._alternateCompletionPhrases.clear();
this._lineBuffer = '';
this._partialPromiseBuffer = '';
this._completionPhraseCount.clear();
// Clear sub-module state
this.statusParser.fullReset();
this.planTracker.fullReset();
this.stallDetector.reset();
this.emit('loopUpdate', this.loopState);
this.emit('todoUpdate', this.todos);
}
/**
* Get aggregated statistics about tracked todos.
*/
getTodoStats(): { total: number; pending: number; inProgress: number; completed: number } {
let pending = 0;
let inProgress = 0;
let completed = 0;
for (const todo of this._todos.values()) {
switch (todo.status) {
case 'pending':
pending++;
break;
case 'in_progress':
inProgress++;
break;
case 'completed':
completed++;
break;
}
}
return {
total: this._todos.size,
pending,
inProgress,
completed,
};
}
/**
* Restore tracker state from persisted data.
*/
restoreState(loopState: RalphTrackerState, todos: RalphTodoItem[]): void {
this._loopState = {
...loopState,
enabled: loopState.enabled ?? false,
};
// Restore the per-session todo-config into the live fields used by the hot
// paths (eviction cap + expiry). Setters ignore non-positive values.
if (typeof loopState.maxTodos === 'number') this.setMaxTodos(loopState.maxTodos);
if (typeof loopState.todoExpirationMinutes === 'number') {
this.setTodoExpirationMinutes(loopState.todoExpirationMinutes);
}
this._todos.clear();
for (const todo of todos) {
this._todos.set(todo.id, {
...todo,
priority: todo.priority ?? null,
});
}
}
/**
* Clean up all resources and release memory.
*/
destroy(): void {
this.cleanup.dispose();
this._todoDeb.dispose();
this._loopDeb.dispose();
this.fixPlanWatcher.destroy();
this.stallDetector.destroy();
this.statusParser.destroy();
this.planTracker.destroy();
this._todos.clear();
this._taskNumberToContent.clear();
this._todoStartTimes.clear();
this._alternateCompletionPhrases.clear();
this._completionPhraseCount.clear();
this._completionTimes.length = 0;
this._lineBuffer = '';
this._partialPromiseBuffer = '';
this.removeAllListeners();
}
}