feat: detect Claude Code checkmark-based TodoWrite output format

The RalphTracker now recognizes Format 5 - the checkmark-based output
that Claude Code's TodoWrite tool produces:
- "✔ Task #N created: <content>" → creates todo
- "✔ #N <content>" → registers task summary
- "✔ Task #N updated: status → in progress/completed" → updates status

Previously only detected markdown checkboxes, icon indicators, and
native ☐/☒/◐ format, missing the primary output format used by
Claude Code CLI sessions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-22 22:02:49 +01:00
co-authored by Claude Opus 4.5
parent d291dc0d05
commit bf44925ea4
+76 -1
View File
@@ -112,6 +112,18 @@ const TODO_STATUS_PATTERN = /[-*]\s*(.+?)\s+\((pending|in_progress|completed)\)/
*/
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;
/**
* Patterns to exclude from todo detection
* Prevents false positives from tool invocations and Claude commentary
@@ -300,6 +312,9 @@ export class RalphTracker extends EventEmitter {
/** When true, prevents auto-enable on pattern detection */
private _autoEnableDisabled: boolean = false;
/** Maps task numbers from "✔ Task #N" format to their content for status updates */
private _taskNumberToContent: Map<number, string> = new Map();
/**
* Creates a new RalphTracker instance.
* Starts in disabled state until Ralph patterns are detected.
@@ -394,6 +409,7 @@ export class RalphTracker extends EventEmitter {
this._loopState.enabled = wasEnabled; // Keep enabled status
this._todos.clear();
this._completionPhraseCount.clear();
this._taskNumberToContent.clear();
this._lineBuffer = '';
// Emit immediately on reset (no debounce)
this.emit('loopUpdate', this.loopState);
@@ -414,6 +430,7 @@ export class RalphTracker extends EventEmitter {
this._loopState = createInitialRalphTrackerState();
this._todos.clear();
this._completionPhraseCount.clear();
this._taskNumberToContent.clear();
this._lineBuffer = '';
// Emit immediately on reset (no debounce)
this.emit('loopUpdate', this.loopState);
@@ -635,6 +652,16 @@ export class RalphTracker extends EventEmitter {
return true;
}
// Claude Code checkmark-based TodoWrite: "✔ Task #N created:", "✔ Task #N updated:"
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;
}
// Loop start patterns (e.g., "Loop started at", "Starting Ralph loop")
if (LOOP_START_PATTERN.test(data)) {
return true;
@@ -1056,9 +1083,10 @@ export class RalphTracker extends EventEmitter {
const hasTodoIndicator = line.includes('Todo:');
const hasNativeCheckbox = line.includes('☐') || line.includes('☒') || line.includes('◐');
const hasStatus = line.includes('(pending)') || line.includes('(in_progress)') || line.includes('(completed)');
const hasCheckmark = line.includes('✔');
// Quick check: skip lines that can't possibly contain todos
if (!hasCheckbox && !hasTodoIndicator && !hasNativeCheckbox && !hasStatus) {
if (!hasCheckbox && !hasTodoIndicator && !hasNativeCheckbox && !hasStatus && !hasCheckmark) {
return;
}
@@ -1124,6 +1152,52 @@ export class RalphTracker extends EventEmitter {
}
}
// Format 5: Claude Code checkmark-based TodoWrite output (✔ Task #N)
// Handles: "✔ Task #N created: content", "✔ #N content", "✔ Task #N updated: status → X"
if (hasCheckmark) {
// Task creation: "✔ Task #1 created: Fix the bug"
TODO_TASK_CREATED_PATTERN.lastIndex = 0;
while ((match = TODO_TASK_CREATED_PATTERN.exec(line)) !== null) {
const taskNum = parseInt(match[1], 10);
const content = match[2].trim();
if (content.length >= 5) {
this._taskNumberToContent.set(taskNum, content);
this.upsertTodo(content, 'pending');
updated = true;
}
}
// Task summary: "✔ #1 Fix the bug"
TODO_TASK_SUMMARY_PATTERN.lastIndex = 0;
while ((match = TODO_TASK_SUMMARY_PATTERN.exec(line)) !== null) {
const taskNum = parseInt(match[1], 10);
const content = match[2].trim();
if (content.length >= 5) {
// Only register if not already known from a "created" line
if (!this._taskNumberToContent.has(taskNum)) {
this._taskNumberToContent.set(taskNum, content);
}
this.upsertTodo(this._taskNumberToContent.get(taskNum) || content, 'pending');
updated = true;
}
}
// Status update: "✔ Task #1 updated: status → completed"
TODO_TASK_STATUS_PATTERN.lastIndex = 0;
while ((match = TODO_TASK_STATUS_PATTERN.exec(line)) !== null) {
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) {
// Use debounced emit to batch rapid todo updates and reduce UI jitter
this.emitTodoUpdateDebounced();
@@ -1378,6 +1452,7 @@ export class RalphTracker extends EventEmitter {
this.clearDebounceTimers();
this._loopState = createInitialRalphTrackerState(); // This sets enabled: false
this._todos.clear();
this._taskNumberToContent.clear();
this._lineBuffer = '';
this._completionPhraseCount.clear();
this.emit('loopUpdate', this.loopState);