From 69eada0632e9d2246ee749450f58c3db721b6b63 Mon Sep 17 00:00:00 2001 From: arkon Date: Tue, 20 Jan 2026 17:16:00 +0100 Subject: [PATCH] fix: improve Ralph Wiggum tracker completion detection - Add ALL_COMPLETE_PATTERN to detect "All 8 files created" messages - Add ALL_COUNT_PATTERN to extract count from completion messages - Update detectAllTasksComplete to mark all todos as complete - Update handleBareCompletionPhrase to mark todos complete - Update handleCompletionPhrase to mark todos complete on 2nd occurrence - Track bare phrase occurrences to avoid double-firing This fixes an issue where the tracker would detect todos but not mark them as complete when Claude outputs "All X files/tasks completed" instead of individually checking off each todo. Co-Authored-By: Claude Opus 4.5 --- src/inner-loop-tracker.ts | 238 +++++++++++++++++++++++++++++++++++++- 1 file changed, 233 insertions(+), 5 deletions(-) diff --git a/src/inner-loop-tracker.ts b/src/inner-loop-tracker.ts index b8fbcc08..16303096 100644 --- a/src/inner-loop-tracker.ts +++ b/src/inner-loop-tracker.ts @@ -56,6 +56,19 @@ const MAX_ITERATIONS_PATTERN = /max[_-]?iterations?\s*[=:]\s*(\d+)/i; // TodoWrite tool output - detect the tool being used const TODOWRITE_PATTERN = /TodoWrite|todo(?:s)?\s*(?:updated|written|saved)|Todos have been modified/i; +// All tasks complete patterns - detect when Claude says all work is done +// Includes patterns like "All 8 files have been created", "All tasks completed" +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; + +// Pattern to extract count from "All 8 files created" type messages +const ALL_COUNT_PATTERN = /all\s+(\d+)\s+(?:tasks?|files?|items?)/i; + +// Individual task completion patterns - detect when Claude marks a specific task done +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; + +// Generic completion signals (be careful with these - can be false positives) +const COMPLETION_SIGNAL_PATTERN = /^(?:done|completed?|finished|all\s+set)!?\s*$/i; + // ANSI escape code removal for cleaner parsing // Matches color codes (\x1b[...m), cursor movement (\x1b[...H, \x1b[...C, etc.), and other sequences const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g; @@ -119,6 +132,33 @@ export class InnerLoopTracker extends EventEmitter { } } + /** + * Reset the tracker to initial state (for when a new task/loop starts) + * Clears all todos, completion phrase, and loop state while keeping enabled status + */ + reset(): void { + const wasEnabled = this._loopState.enabled; + this._loopState = createInitialInnerLoopState(); + this._loopState.enabled = wasEnabled; // Keep enabled status + this._todos.clear(); + this._completionPhraseCount.clear(); + this._lineBuffer = ''; + this.emit('loopUpdate', this.loopState); + this.emit('todoUpdate', this.todos); + } + + /** + * Full reset including enabled state (for complete cleanup) + */ + fullReset(): void { + this._loopState = createInitialInnerLoopState(); + this._todos.clear(); + this._completionPhraseCount.clear(); + this._lineBuffer = ''; + this.emit('loopUpdate', this.loopState); + this.emit('todoUpdate', this.todos); + } + get loopState(): InnerLoopState { return { ...this._loopState }; } @@ -210,6 +250,16 @@ export class InnerLoopTracker extends EventEmitter { return true; } + // All tasks complete signals + if (ALL_COMPLETE_PATTERN.test(data)) { + return true; + } + + // Task completion signals + if (TASK_DONE_PATTERN.test(data)) { + return true; + } + return false; } @@ -223,6 +273,12 @@ export class InnerLoopTracker extends EventEmitter { // 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); @@ -230,6 +286,84 @@ export class InnerLoopTracker extends EventEmitter { this.detectTodoItems(trimmed); } + /** + * Detect "all tasks complete" messages + * When detected: marks all todos as complete AND emits completion event + */ + private detectAllTasksComplete(line: string): void { + // Only trigger if line is a clear standalone completion message + // Avoid matching commentary like "once all tasks are complete..." + if (!ALL_COMPLETE_PATTERN.test(line)) return; + + // Must be a reasonably short line (< 100 chars) to be a completion signal, not commentary + if (line.length > 100) return; + + // Skip if this looks like it's part of the original prompt (contains "output:") + if (line.toLowerCase().includes('output:') || line.includes('')) return; + + // Don't trigger if we haven't seen any todos yet + if (this._todos.size === 0) return; + + // Check if the count matches our todo count (e.g., "All 8 files created") + const countMatch = line.match(ALL_COUNT_PATTERN); + const mentionedCount = countMatch ? parseInt(countMatch[1]) : null; + const todoCount = this._todos.size; + + // If a count is mentioned, it should match our todo count (within reason) + if (mentionedCount !== null && Math.abs(mentionedCount - todoCount) > 2) { + // Count doesn't match our todos, might be unrelated + return; + } + + // Mark all todos as complete + 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); + } + + // Emit completion if we have an expected phrase + 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 + * e.g., "Task 8 is done", "marked as completed" + * + * NOTE: This is intentionally conservative to avoid jitter. + * Only marks a todo complete if we can match it by task number. + */ + private detectTaskCompletion(line: string): void { + if (!TASK_DONE_PATTERN.test(line)) return; + + // Only act on explicit task number references like "Task 8 is done" + const taskNumMatch = line.match(/task\s*#?(\d+)/i); + if (taskNumMatch) { + const taskNum = parseInt(taskNumMatch[1]); + // Find the nth todo (by order) and mark it complete + 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; + } + } + } + // Don't guess which todo to mark - let the checkbox detection handle it + } + /** * Check for multi-line patterns in the data chunk */ @@ -243,12 +377,67 @@ export class InnerLoopTracker extends EventEmitter { /** * Detect PHRASE completion phrases + * Also detects bare phrase (without tags) if we already know the expected phrase */ private detectCompletionPhrase(line: string): void { + // First check for tagged phrase: PHRASE const match = line.match(PROMISE_PATTERN); if (match) { this.handleCompletionPhrase(match[1]); + return; } + + // If we have an expected completion phrase, also check for bare phrase + // This handles cases where Claude outputs "ALL_TASKS_DONE" without the tags + const expectedPhrase = this._loopState.completionPhrase; + if (expectedPhrase && line.includes(expectedPhrase)) { + // Avoid false positives: don't trigger on the original prompt echo + // Only trigger if line looks like completion output (standalone or at end) + const isStandalone = line.trim() === expectedPhrase; + const isAtEnd = line.trim().endsWith(expectedPhrase); + const isNotInPromptContext = !line.includes('') && !line.includes('output:'); + + if ((isStandalone || isAtEnd) && isNotInPromptContext) { + this.handleBareCompletionPhrase(expectedPhrase); + } + } + } + + /** + * Handle a bare completion phrase (without tags) + * Emits completion if we've already seen the tagged version in the prompt + * and this appears to be actual completion output (not prompt echo) + */ + private handleBareCompletionPhrase(phrase: string): void { + // Only count if this phrase was already seen in tagged form (from the prompt) + const taggedCount = this._completionPhraseCount.get(phrase) || 0; + if (taggedCount === 0) return; + + // Track bare occurrences to avoid double-firing + const bareKey = `bare:${phrase}`; + const bareCount = (this._completionPhraseCount.get(bareKey) || 0) + 1; + this._completionPhraseCount.set(bareKey, bareCount); + + // Only fire once for bare phrase + if (bareCount > 1) return; + + // Mark all todos as complete (since we've reached the completion phrase) + 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); + } + + // Emit completion event + this._loopState.active = false; + this._loopState.lastActivity = Date.now(); + this.emit('completionDetected', phrase); + this.emit('loopUpdate', this.loopState); } /** @@ -272,6 +461,18 @@ export class InnerLoopTracker extends EventEmitter { // Emit completion if loop is active OR this is 2nd+ occurrence if (this._loopState.active || count >= 2) { + // Mark all todos as complete when completion phrase is detected + 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); + } + this._loopState.active = false; this._loopState.lastActivity = Date.now(); this.emit('completionDetected', phrase); @@ -457,8 +658,15 @@ export class InnerLoopTracker extends EventEmitter { // Skip empty or whitespace-only content if (!content || !content.trim()) return; - // Generate a stable ID from content - const id = this.generateTodoId(content); + // Clean content: remove ANSI codes, collapse whitespace, trim + const cleanContent = content + .replace(ANSI_ESCAPE_PATTERN, '') // Remove ANSI escape codes + .replace(/\s+/g, ' ') // Collapse whitespace + .trim(); + if (cleanContent.length < 5) return; // Skip very short content + + // Generate a stable ID from normalized content + const id = this.generateTodoId(cleanContent); const existing = this._todos.get(id); if (existing) { @@ -477,23 +685,43 @@ export class InnerLoopTracker extends EventEmitter { this._todos.set(id, { id, - content, + content: cleanContent, status, detectedAt: Date.now(), }); } } + /** + * Normalize todo content for consistent matching + * - Collapse multiple whitespace to single space + * - Remove trailing garbage characters + * - Trim whitespace + */ + private normalizeTodoContent(content: string): string { + if (!content) return ''; + return content + .replace(/\s+/g, ' ') // Collapse whitespace + .replace(/[^a-zA-Z0-9\s.,!?'"-]/g, '') // Remove special chars (keep punctuation) + .trim() + .toLowerCase(); + } + /** * Generate a stable ID from todo content using djb2 hash + * Content is normalized first to prevent duplicates from terminal artifacts */ private generateTodoId(content: string): string { if (!content) return 'todo-empty'; + // Normalize content for consistent hashing + const normalized = this.normalizeTodoContent(content); + if (!normalized) return 'todo-empty'; + // djb2 hash algorithm - good distribution for strings let hash = 5381; - for (let i = 0; i < content.length; i++) { - hash = ((hash << 5) + hash) ^ content.charCodeAt(i); + for (let i = 0; i < normalized.length; i++) { + hash = ((hash << 5) + hash) ^ normalized.charCodeAt(i); hash = hash | 0; // Convert to 32-bit integer } return `todo-${Math.abs(hash).toString(36)}`;