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
+3 -3
View File
@@ -99,11 +99,11 @@ const BUILTIN_RESPAWN_PRESETS = [
description: 'Unattended overnight runs with full context reset between cycles',
config: {
idleTimeoutMs: 10000,
updatePrompt: 'summarize what you accomplished so far and write key progress notes to CLAUDE.md, then continue working on the task.',
updatePrompt: 'summarize what you accomplished so far and write key progress notes to CLAUDE.md so the next cycle can pick up where you left off.',
interStepDelayMs: 3000,
sendClear: true,
sendInit: true,
kickstartPrompt: 'read CLAUDE.md for context on what was accomplished in previous cycles, then pick up where you left off.',
kickstartPrompt: 'continue working on the task. Pick up where you left off based on the context above.',
autoAcceptPrompts: true,
},
durationMinutes: 480,
@@ -7471,7 +7471,7 @@ class ClaudemanApp {
if (config.enableRespawn) {
const ralphUpdatePrompt = 'Before /clear: Update CLAUDE.md with discoveries and notes, mark completed tasks in @fix_plan.md, write a brief progress summary to a file so the next iteration can continue seamlessly.';
const ralphKickstartPrompt = `You are in a Ralph Wiggum loop. Read @fix_plan.md for task status, check CLAUDE.md for notes from previous iterations, continue on the next uncompleted task, output <promise>${config.completionPhrase}</promise> when ALL tasks are complete.`;
const ralphKickstartPrompt = `You are in a Ralph Wiggum loop. Read @fix_plan.md for task status, continue on the next uncompleted task, output <promise>${config.completionPhrase}</promise> when ALL tasks are complete.`;
await fetch(`/api/sessions/${sessionId}/respawn/enable`, {
method: 'POST',
+67
View File
@@ -32,6 +32,7 @@ import { writeHooksConfig, updateCaseEnvVars } from '../hooks-config.js';
import { subagentWatcher, type SubagentInfo, type SubagentToolCall, type SubagentProgress, type SubagentMessage, type SubagentToolResult } from '../subagent-watcher.js';
import { imageWatcher } from '../image-watcher.js';
import { TranscriptWatcher } from '../transcript-watcher.js';
import { TeamWatcher } from '../team-watcher.js';
import { v4 as uuidv4 } from 'uuid';
import { createRequire } from 'node:module';
import { RunSummaryTracker } from '../run-summary.js';
@@ -413,6 +414,13 @@ export class WebServer extends EventEmitter {
detected: (event: ImageDetectedEvent) => void;
error: (error: Error, sessionId?: string) => void;
} | null = null;
private teamWatcher: TeamWatcher = new TeamWatcher();
private teamWatcherHandlers: {
teamCreated: (config: unknown) => void;
teamUpdated: (config: unknown) => void;
teamRemoved: (config: unknown) => void;
taskUpdated: (data: unknown) => void;
} | null = null;
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) {
super();
this.setMaxListeners(0);
@@ -447,6 +455,9 @@ export class WebServer extends EventEmitter {
// Set up image watcher listeners
this.setupImageWatcherListeners();
// Set up team watcher listeners
this.setupTeamWatcherListeners();
}
/**
@@ -527,6 +538,37 @@ export class WebServer extends EventEmitter {
}
}
/**
* Set up event listeners for team watcher.
* Broadcasts team activity events to SSE clients.
*/
private setupTeamWatcherListeners(): void {
this.teamWatcherHandlers = {
teamCreated: (config: unknown) => this.broadcast('team:created', config),
teamUpdated: (config: unknown) => this.broadcast('team:updated', config),
teamRemoved: (config: unknown) => this.broadcast('team:removed', config),
taskUpdated: (data: unknown) => this.broadcast('team:taskUpdated', data),
};
this.teamWatcher.on('teamCreated', this.teamWatcherHandlers.teamCreated);
this.teamWatcher.on('teamUpdated', this.teamWatcherHandlers.teamUpdated);
this.teamWatcher.on('teamRemoved', this.teamWatcherHandlers.teamRemoved);
this.teamWatcher.on('taskUpdated', this.teamWatcherHandlers.taskUpdated);
}
/**
* Clean up team watcher listeners to prevent memory leaks.
*/
private cleanupTeamWatcherListeners(): void {
if (this.teamWatcherHandlers) {
this.teamWatcher.off('teamCreated', this.teamWatcherHandlers.teamCreated);
this.teamWatcher.off('teamUpdated', this.teamWatcherHandlers.teamUpdated);
this.teamWatcher.off('teamRemoved', this.teamWatcherHandlers.teamRemoved);
this.teamWatcher.off('taskUpdated', this.teamWatcherHandlers.taskUpdated);
this.teamWatcherHandlers = null;
}
}
private async setupRoutes(): Promise<void> {
// Allow multipart/form-data for screenshot uploads — skip Fastify's body parser
// so the route handler can read the raw stream directly.
@@ -3579,6 +3621,20 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
});
// ========== Agent Teams ==========
// List all discovered teams
this.app.get('/api/teams', async () => {
return { success: true, data: this.teamWatcher.getTeams() };
});
// Get tasks for a specific team
this.app.get('/api/teams/:name/tasks', async (req) => {
const { name } = req.params as { name: string };
return { success: true, data: this.teamWatcher.getTeamTasks(name) };
});
// ========== Hook Events ==========
this.app.post('/api/hook-event', async (req) => {
@@ -4324,6 +4380,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
// Wire team watcher for team-aware idle detection
controller.setTeamWatcher(this.teamWatcher);
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
const getTracker = () => this.runSummaryTrackers.get(sessionId);
@@ -5150,6 +5209,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
console.log('Image watcher disabled by user settings');
}
// Start team watcher for agent team awareness (always on — lightweight polling)
this.teamWatcher.start();
console.log('Team watcher started - monitoring ~/.claude/teams/ for agent team activity');
}
/**
@@ -5462,6 +5525,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Clean up watcher listeners to prevent memory leaks
this.cleanupSubagentWatcherListeners();
this.cleanupImageWatcherListeners();
this.cleanupTeamWatcherListeners();
// Stop subagent watcher
subagentWatcher.stop();
@@ -5469,6 +5533,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Stop image watcher
imageWatcher.stop();
// Stop team watcher
this.teamWatcher.stop();
// Destroy file stream manager (clears cleanup timer and kills remaining tail processes)
fileStreamManager.destroy();