diff --git a/src/inner-loop-tracker.ts b/src/inner-loop-tracker.ts index e45890d7..89f857e3 100644 --- a/src/inner-loop-tracker.ts +++ b/src/inner-loop-tracker.ts @@ -23,6 +23,17 @@ const TODO_CHECKBOX_PATTERN = /^[-*]\s*\[([xX ])\]\s+(.+)$/gm; const TODO_INDICATOR_PATTERN = /Todo:\s*(☐|◐|✓|⏳|✅|⌛|🔄)\s+(.+)/g; // Format 3: Status in parentheses: "(pending)", "(in_progress)", "(completed)" const TODO_STATUS_PATTERN = /[-*]\s*(.+?)\s+\((pending|in_progress|completed)\)/g; +// Format 4: Claude Code native TodoWrite output: "☐ Task", "☒ Task", "◐ Task" +// These appear in terminal with optional leading whitespace/brackets like "⎿ ☐ Task" +// Matches: start of line with optional whitespace/bracket, then checkbox, then task text +const TODO_NATIVE_PATTERN = /^[\s⎿]*(☐|☒|◐)\s+([^☐☒◐\n]{3,})/gm; + +// Patterns to exclude from todo detection (tool invocations, etc.) +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 (with context) + /^\S+\([^)]+\)$/, // Generic function call pattern +]; // Loop status patterns const LOOP_START_PATTERN = /Loop started at|Starting.*loop|Ralph loop started|([^<]+)<\/promise>/i; @@ -33,7 +44,8 @@ const CYCLE_PATTERN = /cycle\s*#?(\d+)|respawn cycle #(\d+)/i; // Iteration patterns: "Iteration 5/50", "[5/50]", "iteration #5" const ITERATION_PATTERN = /(?:iteration|iter\.?)\s*#?(\d+)(?:\s*[\/of]\s*(\d+))?|\[(\d+)\/(\d+)\]/i; -// Ralph loop start: "/ralph-loop" command or "Starting Ralph loop" +// Ralph loop start: "/ralph-loop:ralph-loop" command or "Starting Ralph loop" +// Pattern matches /ralph-loop anywhere to catch both skill invocations and output const RALPH_START_PATTERN = /\/ralph-loop|starting ralph(?:\s+wiggum)?\s+loop|ralph loop (?:started|beginning)/i; // Max iterations: "max-iterations 50" or "maxIterations: 50" or "max_iterations=50" @@ -59,12 +71,14 @@ export interface InnerLoopTrackerEvents { * 2. Todo list items from the TodoWrite tool * * The tracker is DISABLED by default and auto-enables when Ralph-related - * patterns are detected (e.g., /ralph-loop, , todos). + * patterns are detected (e.g., /ralph-loop:ralph-loop, , todos). */ export class InnerLoopTracker extends EventEmitter { private _loopState: InnerLoopState; private _todos: Map = new Map(); private _lineBuffer: string = ''; + // Track occurrences of completion phrases to distinguish prompt from actual completion + private _completionPhraseCount: Map = new Map(); constructor() { super(); @@ -148,7 +162,7 @@ export class InnerLoopTracker extends EventEmitter { * Check if the data contains patterns that should auto-enable the tracker */ private shouldAutoEnable(data: string): boolean { - // Ralph loop command: /ralph-loop + // Ralph loop command: /ralph-loop:ralph-loop if (RALPH_START_PATTERN.test(data)) { return true; } @@ -181,6 +195,12 @@ export class InnerLoopTracker extends EventEmitter { return true; } + // Claude Code native todo format: "☐ Task", "☒ Task" + if (TODO_NATIVE_PATTERN.test(data)) { + TODO_NATIVE_PATTERN.lastIndex = 0; + return true; + } + // Loop start patterns if (LOOP_START_PATTERN.test(data) && !PROMISE_PATTERN.test(data)) { return true; @@ -229,34 +249,37 @@ export class InnerLoopTracker extends EventEmitter { /** * Handle a detected completion phrase + * + * Uses occurrence-based detection to distinguish prompt from actual completion: + * - 1st occurrence: Store as expected phrase (likely in prompt) + * - 2nd occurrence: Emit completionDetected (actual completion) + * - If loop already active: Emit immediately (explicit loop start) */ private handleCompletionPhrase(phrase: string): void { - // Only mark as completed if the loop was actually active - // This prevents false positives when the completion phrase appears in the prompt itself - if (!this._loopState.active) { - // Just record the expected completion phrase without marking as complete - if (!this._loopState.completionPhrase) { - this._loopState.completionPhrase = phrase; - this._loopState.lastActivity = Date.now(); - this.emit('loopUpdate', this.loopState); - } - return; + const count = (this._completionPhraseCount.get(phrase) || 0) + 1; + this._completionPhraseCount.set(phrase, count); + + // Store phrase on first occurrence + if (!this._loopState.completionPhrase) { + this._loopState.completionPhrase = phrase; + this._loopState.lastActivity = Date.now(); + this.emit('loopUpdate', this.loopState); } - // Loop was active, this is a real completion - this._loopState.completionPhrase = phrase; - this._loopState.active = false; - this._loopState.lastActivity = Date.now(); - - this.emit('completionDetected', phrase); - this.emit('loopUpdate', this.loopState); + // Emit completion if loop is active OR this is 2nd+ occurrence + if (this._loopState.active || count >= 2) { + this._loopState.active = false; + this._loopState.lastActivity = Date.now(); + this.emit('completionDetected', phrase); + this.emit('loopUpdate', this.loopState); + } } /** * Detect loop start and status indicators */ private detectLoopStatus(line: string): void { - // Check for Ralph loop start command (/ralph-loop) + // Check for Ralph loop start command (/ralph-loop:ralph-loop) if (RALPH_START_PATTERN.test(line)) { if (!this._loopState.active) { this._loopState.active = true; @@ -382,6 +405,24 @@ export class InnerLoopTracker extends EventEmitter { updated = true; } + // Format 4: Claude Code native TodoWrite output (☐, ☒, ◐) + TODO_NATIVE_PATTERN.lastIndex = 0; + while ((match = TODO_NATIVE_PATTERN.exec(line)) !== null) { + const icon = match[1]; + const content = match[2].trim(); + + // Skip if content matches exclude patterns (tool invocations, commentary) + const shouldExclude = TODO_EXCLUDE_PATTERNS.some(pattern => pattern.test(content)); + if (shouldExclude) continue; + + // Skip if content is too short or looks like partial garbage + if (content.length < 5) continue; + + const status = this.iconToStatus(icon); + this.upsertTodo(content, status); + updated = true; + } + if (updated) { this.emit('todoUpdate', this.todos); } @@ -394,13 +435,17 @@ export class InnerLoopTracker extends EventEmitter { switch (icon) { case '✓': case '✅': + case '☒': // Claude Code checked checkbox + case '◉': // Filled circle (completed) + case '●': // Solid circle (completed) return 'completed'; - case '◐': + case '◐': // Half-filled circle (in progress) case '⏳': case '⌛': case '🔄': return 'in_progress'; - case '☐': + case '☐': // Claude Code empty checkbox + case '○': // Empty circle default: return 'pending'; } @@ -533,6 +578,7 @@ export class InnerLoopTracker extends EventEmitter { this._loopState = createInitialInnerLoopState(); // This sets enabled: false this._todos.clear(); this._lineBuffer = ''; + this._completionPhraseCount.clear(); this.emit('loopUpdate', this.loopState); this.emit('todoUpdate', this.todos); } diff --git a/test/inner-loop-tracker.test.ts b/test/inner-loop-tracker.test.ts index b8b729d5..9b961d4e 100644 --- a/test/inner-loop-tracker.test.ts +++ b/test/inner-loop-tracker.test.ts @@ -48,12 +48,12 @@ describe('InnerLoopTracker', () => { expect(freshTracker.loopState.elapsedHours).toBeNull(); }); - it('should auto-enable on /ralph-loop command', () => { + it('should auto-enable on /ralph-loop:ralph-loop command', () => { const freshTracker = new InnerLoopTracker(); const enableHandler = vi.fn(); freshTracker.on('enabled', enableHandler); - freshTracker.processTerminalData('/ralph-loop\n'); + freshTracker.processTerminalData('/ralph-loop:ralph-loop\n'); expect(freshTracker.enabled).toBe(true); expect(enableHandler).toHaveBeenCalled(); @@ -107,7 +107,7 @@ describe('InnerLoopTracker', () => { }); it('should reset to disabled on clear', () => { - tracker.processTerminalData('/ralph-loop\n'); + tracker.processTerminalData('/ralph-loop:ralph-loop\n'); expect(tracker.enabled).toBe(true); tracker.clear(); @@ -120,6 +120,8 @@ describe('InnerLoopTracker', () => { const completionHandler = vi.fn(); tracker.on('completionDetected', completionHandler); + // Start loop first (realistic workflow) + tracker.startLoop(); tracker.processTerminalData('COMPLETE\n'); expect(completionHandler).toHaveBeenCalledWith('COMPLETE'); @@ -130,6 +132,8 @@ describe('InnerLoopTracker', () => { const completionHandler = vi.fn(); tracker.on('completionDetected', completionHandler); + // Start loop first (realistic workflow) + tracker.startLoop(); tracker.processTerminalData('Output: TIME_COMPLETE\n'); expect(completionHandler).toHaveBeenCalledWith('TIME_COMPLETE'); @@ -139,6 +143,8 @@ describe('InnerLoopTracker', () => { const completionHandler = vi.fn(); tracker.on('completionDetected', completionHandler); + // Start loop first (realistic workflow) + tracker.startLoop(); tracker.processTerminalData('MY_CUSTOM_PHRASE_123\n'); expect(completionHandler).toHaveBeenCalledWith('MY_CUSTOM_PHRASE_123'); @@ -296,6 +302,67 @@ describe('InnerLoopTracker', () => { }); }); + describe('Todo Detection - Claude Code Native Format', () => { + it('should detect pending native checkbox (☐)', () => { + tracker.processTerminalData('☐ List files in current directory\n'); + + const todos = tracker.todos; + expect(todos).toHaveLength(1); + expect(todos[0].content).toBe('List files in current directory'); + expect(todos[0].status).toBe('pending'); + }); + + it('should detect completed native checkbox (☒)', () => { + tracker.processTerminalData('☒ Completed task\n'); + + const todos = tracker.todos; + expect(todos).toHaveLength(1); + expect(todos[0].status).toBe('completed'); + }); + + it('should detect todos with leading bracket (⎿)', () => { + tracker.processTerminalData('⎿ ☐ Task with bracket\n'); + + const todos = tracker.todos; + expect(todos).toHaveLength(1); + expect(todos[0].content).toBe('Task with bracket'); + expect(todos[0].status).toBe('pending'); + }); + + it('should detect todos with leading whitespace', () => { + tracker.processTerminalData(' ☐ Indented task\n'); + + const todos = tracker.todos; + expect(todos).toHaveLength(1); + expect(todos[0].content).toBe('Indented task'); + }); + + it('should detect in-progress native (◐)', () => { + tracker.processTerminalData('◐ Working on this\n'); + + const todos = tracker.todos; + expect(todos[0].status).toBe('in_progress'); + }); + + it('should handle multiple native todos in sequence', () => { + tracker.processTerminalData('⎿ ☐ First task\n'); + tracker.processTerminalData(' ☐ Second task\n'); + tracker.processTerminalData(' ☒ Third task\n'); + + const todos = tracker.todos; + expect(todos).toHaveLength(3); + expect(todos.filter(t => t.status === 'pending')).toHaveLength(2); + expect(todos.filter(t => t.status === 'completed')).toHaveLength(1); + }); + + it('should auto-enable on native todo pattern', () => { + const freshTracker = new InnerLoopTracker(); + freshTracker.processTerminalData('☐ New task\n'); + + expect(freshTracker.enabled).toBe(true); + }); + }); + describe('Todo Updates', () => { it('should update existing todos by content', () => { // Add pending todo @@ -419,11 +486,11 @@ describe('InnerLoopTracker', () => { }); describe('Enhanced Ralph Detection Patterns', () => { - it('should detect /ralph-loop command', () => { + it('should detect /ralph-loop:ralph-loop command', () => { const loopHandler = vi.fn(); tracker.on('loopUpdate', loopHandler); - tracker.processTerminalData('/ralph-loop\n'); + tracker.processTerminalData('/ralph-loop:ralph-loop\n'); expect(loopHandler).toHaveBeenCalled(); expect(tracker.loopState.active).toBe(true);