fix: ralph tracker — 3 bugs: first-occurrence completion, double-counting, TodoWrite pattern

Bug 1 (CRITICAL): canonicalCount >= 1 always fired on first <promise> tag
(prompt echo). Changed to >= 2 so only 2nd+ occurrence triggers completion.

Bug 2: checkMultiLinePatterns() re-detected complete tags already handled
by processLine(), double-counting. Now only tries completion when partial
buffer is non-empty (cross-chunk scenario).

Bug 3: TodoWrite ✔ patterns required "Task #N" but real Claude Code output
is plain "✔ content". Added TODO_PLAIN_CHECKMARK_PATTERN fallback.

Includes 71 new deep tests + real-life verification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-19 14:00:10 +01:00
co-authored by Claude Opus 4.6
parent 926e77b1c2
commit fe1f665531
9 changed files with 1535 additions and 30 deletions
+41 -18
View File
@@ -237,6 +237,13 @@ 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
@@ -1613,32 +1620,30 @@ export class RalphTracker extends EventEmitter {
* @param data - The full data chunk (may contain multiple lines)
*/
private checkMultiLinePatterns(data: string): void {
// If we have a partial promise buffer, prepend it to the new data
const combinedData = this._partialPromiseBuffer + data;
// Try to find a complete promise tag in combined data
const promiseMatch = combinedData.match(PROMISE_PATTERN);
if (promiseMatch) {
// Found complete tag - extract phrase and clear buffer
const phrase = promiseMatch[1].trim();
this._partialPromiseBuffer = '';
this.handleCompletionPhrase(phrase);
return;
// Only try to complete a cross-chunk promise if we have a partial buffer.
// Without a partial buffer, complete tags are already handled by processLine
// via detectCompletionPhrase — re-detecting here would double-count.
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;
}
}
// Check for partial promise tag at end of combined data
const partialMatch = combinedData.match(PROMISE_PARTIAL_PATTERN);
// Check for partial promise tag at end of data (for next chunk)
const partialMatch = data.match(PROMISE_PARTIAL_PATTERN);
if (partialMatch) {
// Buffer the partial content (with size limit)
const partialContent = partialMatch[0];
if (partialContent.length <= RalphTracker.MAX_PARTIAL_PROMISE_SIZE) {
this._partialPromiseBuffer = partialContent;
} else {
// Partial is too long, likely malformed - discard
this._partialPromiseBuffer = '';
}
} else {
// No partial tag - clear buffer
this._partialPromiseBuffer = '';
}
}
@@ -1782,8 +1787,9 @@ export class RalphTracker extends EventEmitter {
if (matchedPhrase) {
// Use the matched phrase (canonical) for tracking
const canonicalCount = (this._completionPhraseCount.get(matchedPhrase) || 0);
// If this is a match of an expected phrase, treat as if we saw it
if (canonicalCount >= 1 || this._loopState.active) {
// Require 2nd+ occurrence of canonical phrase OR explicitly active loop.
// First occurrence (count=1) is the prompt echo — not actual completion.
if (canonicalCount >= 2 || this._loopState.active) {
// Mark as completion
this._loopState.active = false;
this._loopState.lastActivity = Date.now();
@@ -2158,6 +2164,23 @@ export class RalphTracker extends EventEmitter {
updated = true;
}
}
// Plain checkmark: "✔ Create hello.txt" (no task number)
// Only match if numbered patterns didn't already match on this line
if (!updated) {
TODO_PLAIN_CHECKMARK_PATTERN.lastIndex = 0;
while ((match = TODO_PLAIN_CHECKMARK_PATTERN.exec(line)) !== null) {
const content = match[1].trim();
// Skip if content matches exclude patterns
const shouldExclude = TODO_EXCLUDE_PATTERNS.some(pattern => pattern.test(content));
if (shouldExclude) continue;
if (content.length < 5) continue;
// Skip status/created/updated prefixed content (already handled above)
if (/^(Task\s*#\d+|#\d+)\s/.test(content)) continue;
this.upsertTodo(content, 'completed');
updated = true;
}
}
}
if (updated) {