From 11d4666d19fcc2a8e8924fad09e913c69f035991 Mon Sep 17 00:00:00 2001 From: arkon Date: Tue, 27 Jan 2026 00:29:38 +0100 Subject: [PATCH] feat: add RALPH_STATUS block parsing and circuit breaker pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 Ralph enhancement implementation: - Parse RALPH_STATUS blocks from Claude output (status, tasks, files, tests) - Circuit breaker state machine (CLOSED → HALF_OPEN → OPEN) for stuck detection - Dual-condition exit gate (completion indicators >= 2 AND EXIT_SIGNAL) - Frontend: circuit breaker badge (yellow warning, red stuck), status block display - API: circuit breaker reset endpoint, ralph status endpoint - Notifications when circuit breaker opens or exit gate is met Co-Authored-By: Claude Opus 4.5 --- docs/ralph-improvement-plan.md | 251 ++++++++++++++++ docs/ralph-phase1-implementation.md | 434 +++++++++++++++++++++++++++ src/ralph-tracker.ts | 447 ++++++++++++++++++++++++++++ src/session.ts | 18 ++ src/types.ts | 133 +++++++++ src/web/public/app.js | 168 ++++++++++- src/web/public/styles.css | 111 +++++++ src/web/server.ts | 138 +++++++++ 8 files changed, 1699 insertions(+), 1 deletion(-) create mode 100644 docs/ralph-improvement-plan.md create mode 100644 docs/ralph-phase1-implementation.md diff --git a/docs/ralph-improvement-plan.md b/docs/ralph-improvement-plan.md new file mode 100644 index 00000000..dda795e5 --- /dev/null +++ b/docs/ralph-improvement-plan.md @@ -0,0 +1,251 @@ +# Ralph Loop Improvements Plan + +## Overview + +This plan details improvements to Claudeman's Ralph Loop system based on best practices from the Ralph Claude Code repository (https://github.com/frankbria/ralph-claude-code). + +## Key Concepts to Implement + +### RALPH_STATUS Block Format + +Claude outputs this structured block at the end of every response for better tracking: + +``` +---RALPH_STATUS--- +STATUS: IN_PROGRESS | COMPLETE | BLOCKED +TASKS_COMPLETED_THIS_LOOP: +FILES_MODIFIED: +TESTS_STATUS: PASSING | FAILING | NOT_RUN +WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING +EXIT_SIGNAL: false | true +RECOMMENDATION: +---END_RALPH_STATUS--- +``` + +### Dual-Condition Exit Gate + +Exit requires BOTH conditions: +1. `completion_indicators >= 2` (heuristic detection from natural language patterns) +2. Claude's explicit `EXIT_SIGNAL: true` in the RALPH_STATUS block + +### Circuit Breaker Pattern + +Three states: CLOSED → HALF_OPEN → OPEN + +| From State | Condition | To State | +|------------|-----------|----------| +| CLOSED | consecutive_no_progress >= 2 | HALF_OPEN | +| CLOSED | consecutive_no_progress >= 3 | OPEN | +| CLOSED | consecutive_same_error >= 5 | OPEN | +| HALF_OPEN | progress detected | CLOSED | +| HALF_OPEN | consecutive_no_progress >= 3 | OPEN | +| OPEN | Manual reset | CLOSED | + +### @fix_plan.md Structure + +```markdown +# Fix Plan + +## High Priority (P0) +- [ ] Critical: Fix authentication bug +- [ ] Blocker: Database connection timeout + +## Standard (P1) +- [ ] Feature: Add user profile page + +## Nice to Have (P2) +- [ ] Improvement: Add dark mode + +## Completed +- [x] Setup: Initialize project structure +``` + +--- + +## Phase 1: Quick Wins (1-2 days) + +### 1.1 RALPH_STATUS Block Parsing + +**What**: Add parsing support for the structured RALPH_STATUS block format in RalphTracker. + +**Implementation**: +- Add regex pattern to detect `---RALPH_STATUS---` blocks +- Parse fields: STATUS, TASKS_COMPLETED_THIS_LOOP, FILES_MODIFIED, TESTS_STATUS, WORK_TYPE, EXIT_SIGNAL, RECOMMENDATION +- Store in extended `RalphTrackerState` type +- Emit new events: `ralphStatusUpdate` + +**Files**: `ralph-tracker.ts`, `types.ts` + +### 1.2 Enhanced Status Display in UI + +**What**: Display RALPH_STATUS fields in the Ralph State Panel. + +**Implementation**: +- Add UI elements: WORK_TYPE indicator, TESTS_STATUS badge, FILES_MODIFIED count +- Show RECOMMENDATION text in expanded view +- Color-code status (IN_PROGRESS=blue, COMPLETE=green, BLOCKED=red) + +**Files**: `app.js`, `styles.css`, `index.html` + +### 1.3 Prompt Template Improvements + +**What**: Add specification-by-example exit scenarios to prompts. + +**Implementation**: +- Add "Exit Scenarios" section to case-template.md +- Document when to continue vs. when to output completion +- Include testing limits guidance (max 20% effort on tests) +- Add RALPH_STATUS block instructions + +**Files**: `case-template.md` + +### 1.4 Better Wizard Validation + +**What**: Add client-side validation and helpful warnings. + +**Implementation**: +- Warn if task description < 50 chars +- Warn if no success criteria mentioned +- Suggest adding test requirements if none detected +- Validate completion phrase is uppercase alphanumeric + +**Files**: `app.js` + +--- + +## Phase 2: Core Improvements (3-5 days) + +### 2.1 Circuit Breaker Pattern + +**What**: Implement three-state circuit breaker to detect stuck loops. + +**Implementation**: +- Create `CircuitBreaker` class with CLOSED, HALF_OPEN, OPEN states +- Track: files_modified, tasks_completed, error_patterns per iteration +- Triggers: N consecutive no-progress, same error M times, tests failing K iterations +- Emit events: `circuitBreakerStateChange` + +**Files**: New `circuit-breaker.ts`, integrate into `ralph-tracker.ts` + +### 2.2 Circuit Breaker UI + +**What**: Visual indicator in Ralph panel. + +**Implementation**: +- Badge: green (CLOSED), yellow (HALF_OPEN), red (OPEN) +- Warning before tripping +- Notification when circuit opens +- Manual reset button + +**Files**: `app.js`, `styles.css`, `index.html` + +### 2.3 @fix_plan.md Integration + +**What**: Generate and track structured task plan file. + +**Implementation**: +- Generate `@fix_plan.md` in working directory when loop starts +- Watch file for changes and sync with RalphTracker todos +- Parse priority levels (P0, P1, P2) +- Show priority in UI + +**Files**: New `fix-plan.ts`, `ralph-tracker.ts`, `server.ts` + +### 2.4 Wizard Plan Generation Step + +**What**: Add third wizard step for AI-assisted plan generation. + +**Implementation**: +- Step 2: "Plan Generation" between Task Setup and Launch +- Use Claude to break down task into fix plan items +- Allow edit/reorder before launch +- Generate @fix_plan.md with selected items + +**Files**: `app.js`, `index.html`, `server.ts` + +### 2.5 Smart Respawn Integration + +**What**: Use RALPH_STATUS for respawn decisions. + +**Implementation**: +- Use EXIT_SIGNAL field for respawn decisions +- If STATUS=BLOCKED, trigger circuit breaker instead of respawn +- Pass RECOMMENDATION to respawn update prompt + +**Files**: `respawn-controller.ts`, `ralph-tracker.ts` + +--- + +## Phase 3: Advanced Features (5+ days) + +### 3.1 Template Library +- Bug Fix, Feature, Refactoring, Test Coverage, Documentation templates +- Template selector in wizard +- Custom templates in `~/.claudeman/templates/` + +### 3.2 Tool Permissions +- Configure allowed Claude tools per loop +- Generate hook configuration +- Store in session config + +### 3.3 Per-Iteration Timeout +- Max time per iteration (5-60 min) +- Auto-continue on timeout +- Log timeout events + +### 3.4 Rate Limiting +- Max tokens per iteration +- Max API calls per minute +- Cooldown between iterations + +### 3.5 Metrics Dashboard +- Time-series charts (files modified, tasks completed, tokens) +- Aggregate statistics +- Export to JSON/CSV + +--- + +## Priority Matrix + +| Item | Effort | Impact | Priority | +|------|--------|--------|----------| +| 1.1 RALPH_STATUS Parsing | Low | High | P0 | +| 1.2 Status Display UI | Low | Medium | P0 | +| 1.3 Prompt Templates | Low | High | P0 | +| 1.4 Wizard Validation | Low | Medium | P1 | +| 2.1 Circuit Breaker | Medium | High | P1 | +| 2.2 Circuit Breaker UI | Medium | Medium | P1 | +| 2.3 Fix Plan Integration | Medium | High | P1 | +| 2.4 Plan Generation Step | Medium | Medium | P2 | +| 2.5 Respawn Integration | Medium | High | P1 | +| 3.1 Template Selection | High | Medium | P2 | +| 3.2 Tool Permissions | High | Medium | P3 | +| 3.3 Per-Iteration Timeout | High | Medium | P2 | +| 3.4 Rate Limiting | High | Low | P3 | +| 3.5 Metrics Dashboard | High | Medium | P3 | + +--- + +## Reference: Ralph Claude Code Best Practices + +### Testing Guidelines +- LIMIT testing to ~20% of total effort per loop +- PRIORITIZE: Implementation > Documentation > Tests +- Only write tests for NEW functionality +- Do NOT refactor existing tests unless broken + +### What NOT to Do +- Do NOT continue with busy work when EXIT_SIGNAL should be true +- Do NOT run tests repeatedly without implementing new features +- Do NOT refactor code that is already working +- Do NOT add features not in specifications +- Do NOT forget the status block + +### Exit Scenarios (Specification by Example) + +1. **Successful Completion**: All tasks done → EXIT_SIGNAL=true +2. **Test-Only Loop**: No implementation, only testing → continue but warn +3. **Stuck on Error**: Same error 5 times → circuit breaker opens +4. **No Work Remaining**: All specs done → EXIT_SIGNAL=true +5. **Making Progress**: Normal flow → continue +6. **Blocked**: Needs human intervention → STATUS=BLOCKED diff --git a/docs/ralph-phase1-implementation.md b/docs/ralph-phase1-implementation.md new file mode 100644 index 00000000..99fbd0b4 --- /dev/null +++ b/docs/ralph-phase1-implementation.md @@ -0,0 +1,434 @@ +# Ralph Tracker Phase 1 Implementation Plan + +## Overview + +This plan details how to enhance the existing RalphTracker with RALPH_STATUS block parsing, circuit breaker pattern, and dual-condition exit gate. + +--- + +## 1. Current State Analysis + +### What RalphTracker Already Does Well + +- **Todo Detection**: Supports 5 formats (checkboxes, indicators, status in parentheses, native TodoWrite, checkmark-based) +- **Completion Phrases**: Detects `PHRASE` with occurrence-based logic (1st = store, 2nd = complete) +- **Loop State Tracking**: Tracks active/inactive, iteration counts, max iterations, elapsed hours, cycle counts +- **Auto-Enable**: Disabled by default, auto-enables when Ralph patterns detected +- **Event System**: Emits `loopUpdate`, `todoUpdate`, `completionDetected`, `enabled` events +- **SSE Integration**: Events forwarded via `session:ralphLoopUpdate`, `session:ralphTodoUpdate`, `session:ralphCompletionDetected` +- **Debouncing**: EVENT_DEBOUNCE_MS (50ms) for rapid updates to prevent UI jitter +- **Cleanup**: MAX_TODO_ITEMS (50), TODO_EXPIRY_MS (1 hour), throttled cleanup + +### Current Limitations + +| Feature | Status | +|---------|--------| +| RALPH_STATUS block parsing | Missing | +| Circuit breaker pattern | Missing | +| Priority-based todos (P0/P1/P2) | Missing | +| Dual-condition exit gate | Missing | +| Files modified tracking | Missing | +| Tests status tracking | Missing | +| Work type classification | Missing | + +--- + +## 2. New Type Definitions (types.ts) + +```typescript +// ========== RALPH_STATUS Block Types ========== + +export type RalphStatusValue = 'IN_PROGRESS' | 'COMPLETE' | 'BLOCKED'; +export type RalphTestsStatus = 'PASSING' | 'FAILING' | 'NOT_RUN'; +export type RalphWorkType = 'IMPLEMENTATION' | 'TESTING' | 'DOCUMENTATION' | 'REFACTORING'; + +/** + * Parsed RALPH_STATUS block from Claude output. + */ +export interface RalphStatusBlock { + status: RalphStatusValue; + tasksCompletedThisLoop: number; + filesModified: number; + testsStatus: RalphTestsStatus; + workType: RalphWorkType; + exitSignal: boolean; + recommendation: string; + parsedAt: number; +} + +// ========== Circuit Breaker Types ========== + +export type CircuitBreakerState = 'CLOSED' | 'HALF_OPEN' | 'OPEN'; + +export type CircuitBreakerReason = + | 'normal_operation' + | 'no_progress_warning' + | 'no_progress_open' + | 'same_error_repeated' + | 'tests_failing_too_long' + | 'progress_detected' + | 'manual_reset'; + +export interface CircuitBreakerStatus { + state: CircuitBreakerState; + consecutiveNoProgress: number; + consecutiveSameError: number; + consecutiveTestsFailure: number; + lastProgressIteration: number; + reason: string; + reasonCode: CircuitBreakerReason; + lastTransitionAt: number; + lastErrorMessage: string | null; +} + +// ========== Priority Todo Types ========== + +export type RalphTodoPriority = 'P0' | 'P1' | 'P2' | null; + +// ========== Helper Functions ========== + +export function createInitialCircuitBreakerStatus(): CircuitBreakerStatus { + return { + state: 'CLOSED', + consecutiveNoProgress: 0, + consecutiveSameError: 0, + consecutiveTestsFailure: 0, + lastProgressIteration: 0, + reason: 'Initial state', + reasonCode: 'normal_operation', + lastTransitionAt: Date.now(), + lastErrorMessage: null, + }; +} +``` + +--- + +## 3. New Regex Patterns (ralph-tracker.ts) + +```typescript +// ---------- RALPH_STATUS Block Patterns ---------- + +const RALPH_STATUS_START_PATTERN = /^---RALPH_STATUS---\s*$/; +const RALPH_STATUS_END_PATTERN = /^---END_RALPH_STATUS---\s*$/; +const RALPH_STATUS_FIELD_PATTERN = /^STATUS:\s*(IN_PROGRESS|COMPLETE|BLOCKED)\s*$/i; +const RALPH_TASKS_COMPLETED_PATTERN = /^TASKS_COMPLETED_THIS_LOOP:\s*(\d+)\s*$/i; +const RALPH_FILES_MODIFIED_PATTERN = /^FILES_MODIFIED:\s*(\d+)\s*$/i; +const RALPH_TESTS_STATUS_PATTERN = /^TESTS_STATUS:\s*(PASSING|FAILING|NOT_RUN)\s*$/i; +const RALPH_WORK_TYPE_PATTERN = /^WORK_TYPE:\s*(IMPLEMENTATION|TESTING|DOCUMENTATION|REFACTORING)\s*$/i; +const RALPH_EXIT_SIGNAL_PATTERN = /^EXIT_SIGNAL:\s*(true|false)\s*$/i; +const RALPH_RECOMMENDATION_PATTERN = /^RECOMMENDATION:\s*(.+)$/i; + +// ---------- Completion Indicator Patterns ---------- + +const COMPLETION_INDICATOR_PATTERNS = [ + /all\s+(?:tasks?|items?|work)\s+(?:are\s+)?(?:completed?|done|finished)/i, + /(?:completed?|finished)\s+all\s+(?:tasks?|items?|work)/i, + /nothing\s+(?:left|remaining)\s+to\s+do/i, + /no\s+more\s+(?:tasks?|items?|work)/i, + /everything\s+(?:is\s+)?(?:completed?|done)/i, +]; + +// ---------- Priority Pattern ---------- + +const TODO_PRIORITY_PATTERN = /^\s*(?:\[.\])?\s*(?:Critical:|Blocker:|Feature:|Improvement:)?\s*\(?(P[012])\)?:?\s*/i; +``` + +--- + +## 4. New State Properties (ralph-tracker.ts) + +```typescript +// Add to RalphTracker class + +// Circuit breaker state tracking +private _circuitBreaker: CircuitBreakerStatus; + +// RALPH_STATUS block parsing state +private _statusBlockBuffer: string[] = []; +private _inStatusBlock: boolean = false; +private _lastStatusBlock: RalphStatusBlock | null = null; + +// Dual-condition exit tracking +private _completionIndicators: number = 0; +private _exitGateMet: boolean = false; + +// Cumulative tracking +private _totalFilesModified: number = 0; +private _totalTasksCompleted: number = 0; +``` + +--- + +## 5. New Methods to Implement + +### 5.1 RALPH_STATUS Block Parsing + +```typescript +private processStatusBlockLine(line: string): void { + const trimmed = line.trim(); + + if (RALPH_STATUS_START_PATTERN.test(trimmed)) { + this._inStatusBlock = true; + this._statusBlockBuffer = []; + return; + } + + if (this._inStatusBlock && RALPH_STATUS_END_PATTERN.test(trimmed)) { + this._inStatusBlock = false; + this.parseStatusBlock(this._statusBlockBuffer); + this._statusBlockBuffer = []; + return; + } + + if (this._inStatusBlock) { + this._statusBlockBuffer.push(trimmed); + } +} + +private parseStatusBlock(lines: string[]): void { + const block: Partial = { parsedAt: Date.now() }; + + for (const line of lines) { + // Parse each field... + } + + if (block.status !== undefined) { + this._lastStatusBlock = fullBlock; + this.handleStatusBlock(fullBlock); + } +} + +private handleStatusBlock(block: RalphStatusBlock): void { + this._totalFilesModified += block.filesModified; + this._totalTasksCompleted += block.tasksCompletedThisLoop; + + const hasProgress = block.filesModified > 0 || block.tasksCompletedThisLoop > 0; + this.updateCircuitBreaker(hasProgress, block.testsStatus, block.status); + + if (block.status === 'COMPLETE') { + this._completionIndicators++; + } + + if (block.exitSignal && this._completionIndicators >= 2) { + this._exitGateMet = true; + this.emit('exitGateMet', { completionIndicators: this._completionIndicators, exitSignal: true }); + } + + this.emit('statusBlockDetected', block); +} +``` + +### 5.2 Circuit Breaker Logic + +```typescript +private updateCircuitBreaker( + hasProgress: boolean, + testsStatus: RalphTestsStatus, + status: RalphStatusValue +): void { + const prevState = this._circuitBreaker.state; + + if (hasProgress) { + this._circuitBreaker.consecutiveNoProgress = 0; + this._circuitBreaker.lastProgressIteration = this._loopState.cycleCount; + + if (this._circuitBreaker.state === 'HALF_OPEN') { + this._circuitBreaker.state = 'CLOSED'; + this._circuitBreaker.reasonCode = 'progress_detected'; + } + } else { + this._circuitBreaker.consecutiveNoProgress++; + + if (this._circuitBreaker.state === 'CLOSED') { + if (this._circuitBreaker.consecutiveNoProgress >= 3) { + this._circuitBreaker.state = 'OPEN'; + this._circuitBreaker.reasonCode = 'no_progress_open'; + } else if (this._circuitBreaker.consecutiveNoProgress >= 2) { + this._circuitBreaker.state = 'HALF_OPEN'; + this._circuitBreaker.reasonCode = 'no_progress_warning'; + } + } + } + + if (prevState !== this._circuitBreaker.state) { + this._circuitBreaker.lastTransitionAt = Date.now(); + this.emit('circuitBreakerUpdate', { ...this._circuitBreaker }); + } +} + +resetCircuitBreaker(): void { + this._circuitBreaker = createInitialCircuitBreakerStatus(); + this._circuitBreaker.reasonCode = 'manual_reset'; + this.emit('circuitBreakerUpdate', { ...this._circuitBreaker }); +} +``` + +### 5.3 Update processLine Method + +```typescript +private processLine(line: string): void { + const trimmed = line.trim(); + if (!trimmed) return; + + // NEW: Check for RALPH_STATUS block + this.processStatusBlockLine(trimmed); + + // NEW: Check for completion indicators + this.detectCompletionIndicators(trimmed); + + // EXISTING: Rest of the detection methods... + this.detectCompletionPhrase(trimmed); + this.detectAllTasksComplete(trimmed); + this.detectTaskCompletion(trimmed); + this.detectLoopStatus(trimmed); + this.detectTodoItems(trimmed); +} +``` + +--- + +## 6. New Events to Add + +```typescript +export interface RalphTrackerEvents { + // Existing events + loopUpdate: (state: RalphTrackerState) => void; + todoUpdate: (todos: RalphTodoItem[]) => void; + completionDetected: (phrase: string) => void; + enabled: () => void; + + // New events + statusBlockDetected: (block: RalphStatusBlock) => void; + circuitBreakerUpdate: (status: CircuitBreakerStatus) => void; + exitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void; +} +``` + +--- + +## 7. Server Integration (server.ts) + +```typescript +// Add new SSE event handlers in setupSessionListeners() + +session.on('ralphStatusBlockDetected', (block: RalphStatusBlock) => { + this.broadcast('session:ralphStatusUpdate', { sessionId: session.id, block }); +}); + +session.on('ralphCircuitBreakerUpdate', (status: CircuitBreakerStatus) => { + this.broadcast('session:circuitBreakerUpdate', { sessionId: session.id, status }); +}); + +session.on('ralphExitGateMet', (data) => { + this.broadcast('session:exitGateMet', { sessionId: session.id, ...data }); +}); + +// Add API endpoint for circuit breaker reset +this.app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => { + const session = this.sessions.get(req.params.id); + if (!session) return { success: false, error: 'Session not found' }; + + session.ralphTracker?.resetCircuitBreaker(); + return { success: true }; +}); +``` + +--- + +## 8. Frontend Changes (app.js) + +### New SSE Event Listeners + +```javascript +this.eventSource.addEventListener('session:ralphStatusUpdate', (e) => { + const data = JSON.parse(e.data); + this.updateRalphStatusBlock(data.sessionId, data.block); +}); + +this.eventSource.addEventListener('session:circuitBreakerUpdate', (e) => { + const data = JSON.parse(e.data); + this.updateCircuitBreaker(data.sessionId, data.status); +}); +``` + +### New Rendering Methods + +```javascript +updateRalphStatusBlock(sessionId, block) { + // Store and render status block +} + +renderRalphStatusBlock(block) { + // Render STATUS, WORK_TYPE, TESTS_STATUS, RECOMMENDATION +} + +updateCircuitBreaker(sessionId, status) { + // Store and render circuit breaker state +} + +renderCircuitBreaker(status) { + // Render badge: green (CLOSED), yellow (HALF_OPEN), red (OPEN) +} +``` + +--- + +## 9. Implementation Order + +| Step | Task | Time | +|------|------|------| +| 1 | Add type definitions to `types.ts` | 30 min | +| 2 | Add regex patterns to `ralph-tracker.ts` | 30 min | +| 3 | Add state properties to RalphTracker class | 15 min | +| 4 | Implement RALPH_STATUS parsing methods | 1.5 hr | +| 5 | Implement circuit breaker logic | 1 hr | +| 6 | Implement completion indicators | 30 min | +| 7 | Update events interface | 15 min | +| 8 | Add server SSE handlers and API endpoint | 45 min | +| 9 | Add frontend event listeners and rendering | 1 hr | +| 10 | Add CSS styles | 30 min | +| 11 | Update HTML structure | 15 min | +| 12 | Write unit tests | 1.5 hr | + +**Total: ~8 hours** + +--- + +## 10. Files to Modify + +| File | Changes | +|------|---------| +| `src/types.ts` | Add RalphStatusBlock, CircuitBreakerStatus, helper functions | +| `src/ralph-tracker.ts` | Add patterns, state, parsing methods, circuit breaker | +| `src/web/server.ts` | Add SSE handlers, circuit breaker reset endpoint | +| `src/web/public/app.js` | Add event listeners, rendering methods | +| `src/web/public/styles.css` | Add status block and circuit breaker styles | +| `src/web/public/index.html` | Add UI elements to Ralph panel | +| `test/ralph-tracker.test.ts` | Add tests for new functionality | + +--- + +## 11. Test Cases to Add + +1. **RALPH_STATUS Parsing** + - Parse valid status block with all fields + - Parse block with missing optional fields + - Ignore malformed blocks + - Handle multiple blocks in sequence + +2. **Circuit Breaker State Transitions** + - CLOSED → HALF_OPEN on 2 no-progress + - HALF_OPEN → OPEN on 3 no-progress + - HALF_OPEN → CLOSED on progress + - Manual reset from OPEN + +3. **Dual-Condition Exit Gate** + - Exit when indicators >= 2 AND exitSignal = true + - No exit when indicators >= 2 but exitSignal = false + - No exit when exitSignal = true but indicators < 2 + +4. **Integration Tests** + - SSE events broadcast correctly + - UI updates on status block detection + - Circuit breaker badge updates diff --git a/src/ralph-tracker.ts b/src/ralph-tracker.ts index 4f47c7aa..d71689b6 100644 --- a/src/ralph-tracker.ts +++ b/src/ralph-tracker.ts @@ -18,7 +18,13 @@ import { RalphTrackerState, RalphTodoItem, RalphTodoStatus, + RalphStatusBlock, + RalphStatusValue, + RalphTestsStatus, + RalphWorkType, + CircuitBreakerStatus, createInitialRalphTrackerState, + createInitialCircuitBreakerStatus, } from './types.js'; // ========== Configuration Constants ========== @@ -220,6 +226,78 @@ const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g; /** Maximum number of task number to content mappings to track */ const MAX_TASK_MAPPINGS = 100; +// ---------- RALPH_STATUS Block Patterns ---------- +// Based on Ralph Claude Code structured status reporting + +/** + * Matches the start of a RALPH_STATUS block + * Pattern: ---RALPH_STATUS--- + */ +const RALPH_STATUS_START_PATTERN = /^---RALPH_STATUS---\s*$/; + +/** + * Matches the end of a RALPH_STATUS block + * Pattern: ---END_RALPH_STATUS--- + */ +const RALPH_STATUS_END_PATTERN = /^---END_RALPH_STATUS---\s*$/; + +/** + * Matches STATUS field in RALPH_STATUS block + * Captures: IN_PROGRESS | COMPLETE | BLOCKED + */ +const RALPH_STATUS_FIELD_PATTERN = /^STATUS:\s*(IN_PROGRESS|COMPLETE|BLOCKED)\s*$/i; + +/** + * Matches TASKS_COMPLETED_THIS_LOOP field + * Captures: number + */ +const RALPH_TASKS_COMPLETED_PATTERN = /^TASKS_COMPLETED_THIS_LOOP:\s*(\d+)\s*$/i; + +/** + * Matches FILES_MODIFIED field + * Captures: number + */ +const RALPH_FILES_MODIFIED_PATTERN = /^FILES_MODIFIED:\s*(\d+)\s*$/i; + +/** + * Matches TESTS_STATUS field + * Captures: PASSING | FAILING | NOT_RUN + */ +const RALPH_TESTS_STATUS_PATTERN = /^TESTS_STATUS:\s*(PASSING|FAILING|NOT_RUN)\s*$/i; + +/** + * Matches WORK_TYPE field + * Captures: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING + */ +const RALPH_WORK_TYPE_PATTERN = /^WORK_TYPE:\s*(IMPLEMENTATION|TESTING|DOCUMENTATION|REFACTORING)\s*$/i; + +/** + * Matches EXIT_SIGNAL field + * Captures: true | false + */ +const RALPH_EXIT_SIGNAL_PATTERN = /^EXIT_SIGNAL:\s*(true|false)\s*$/i; + +/** + * Matches RECOMMENDATION field + * Captures: any text + */ +const RALPH_RECOMMENDATION_PATTERN = /^RECOMMENDATION:\s*(.+)$/i; + +// ---------- Completion Indicator Patterns (for dual-condition exit) ---------- + +/** + * Patterns that indicate potential completion (natural language) + * Count >= 2 along with EXIT_SIGNAL: true triggers exit + */ +const COMPLETION_INDICATOR_PATTERNS = [ + /all\s+(?:tasks?|items?|work)\s+(?:are\s+)?(?:completed?|done|finished)/i, + /(?:completed?|finished)\s+all\s+(?:tasks?|items?|work)/i, + /nothing\s+(?:left|remaining)\s+to\s+do/i, + /no\s+more\s+(?:tasks?|items?|work)/i, + /everything\s+(?:is\s+)?(?:completed?|done)/i, + /project\s+(?:is\s+)?(?:completed?|done|finished)/i, +]; + // ========== Event Types ========== /** @@ -228,6 +306,9 @@ const MAX_TASK_MAPPINGS = 100; * @event todoUpdate - Fired when todo list changes (items added, status changed) * @event completionDetected - Fired when completion phrase is detected (task complete) * @event enabled - Fired when tracker auto-enables due to Ralph pattern detection + * @event statusBlockDetected - Fired when a RALPH_STATUS block is parsed + * @event circuitBreakerUpdate - Fired when circuit breaker state changes + * @event exitGateMet - Fired when dual-condition exit gate is met */ export interface RalphTrackerEvents { /** Emitted when loop state changes */ @@ -238,6 +319,12 @@ export interface RalphTrackerEvents { completionDetected: (phrase: string) => void; /** Emitted when tracker auto-enables from disabled state */ enabled: () => void; + /** Emitted when a RALPH_STATUS block is parsed */ + statusBlockDetected: (block: RalphStatusBlock) => void; + /** Emitted when circuit breaker state changes */ + circuitBreakerUpdate: (status: CircuitBreakerStatus) => void; + /** Emitted when dual-condition exit gate is met (completion indicators >= 2 AND EXIT_SIGNAL: true) */ + exitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void; } /** @@ -315,6 +402,32 @@ export class RalphTracker extends EventEmitter { /** Maps task numbers from "✔ Task #N" format to their content for status updates */ private _taskNumberToContent: Map = new Map(); + // ========== RALPH_STATUS Block State ========== + + /** Circuit breaker state tracking */ + private _circuitBreaker: CircuitBreakerStatus; + + /** Buffer for RALPH_STATUS block lines */ + private _statusBlockBuffer: string[] = []; + + /** Flag indicating we're inside a RALPH_STATUS block */ + private _inStatusBlock: boolean = false; + + /** Last parsed RALPH_STATUS block */ + private _lastStatusBlock: RalphStatusBlock | null = null; + + /** Count of completion indicators detected (for dual-condition exit) */ + private _completionIndicators: number = 0; + + /** Whether dual-condition exit gate has been met */ + private _exitGateMet: boolean = false; + + /** Cumulative files modified across all iterations */ + private _totalFilesModified: number = 0; + + /** Cumulative tasks completed across all iterations */ + private _totalTasksCompleted: number = 0; + /** * Creates a new RalphTracker instance. * Starts in disabled state until Ralph patterns are detected. @@ -322,6 +435,7 @@ export class RalphTracker extends EventEmitter { constructor() { super(); this._loopState = createInitialRalphTrackerState(); + this._circuitBreaker = createInitialCircuitBreakerStatus(); } /** @@ -411,6 +525,15 @@ export class RalphTracker extends EventEmitter { this._completionPhraseCount.clear(); this._taskNumberToContent.clear(); this._lineBuffer = ''; + // Reset RALPH_STATUS block state + this._statusBlockBuffer = []; + this._inStatusBlock = false; + this._lastStatusBlock = null; + this._completionIndicators = 0; + this._exitGateMet = false; + this._totalFilesModified = 0; + this._totalTasksCompleted = 0; + // Keep circuit breaker state on soft reset (it tracks across iterations) // Emit immediately on reset (no debounce) this.emit('loopUpdate', this.loopState); this.emit('todoUpdate', this.todos); @@ -432,6 +555,15 @@ export class RalphTracker extends EventEmitter { this._completionPhraseCount.clear(); this._taskNumberToContent.clear(); this._lineBuffer = ''; + // Reset all RALPH_STATUS block and circuit breaker state + this._statusBlockBuffer = []; + this._inStatusBlock = false; + this._lastStatusBlock = null; + this._completionIndicators = 0; + this._exitGateMet = false; + this._totalFilesModified = 0; + this._totalTasksCompleted = 0; + this._circuitBreaker = createInitialCircuitBreakerStatus(); // Emit immediately on reset (no debounce) this.emit('loopUpdate', this.loopState); this.emit('todoUpdate', this.todos); @@ -689,6 +821,12 @@ export class RalphTracker extends EventEmitter { const trimmed = line.trim(); if (!trimmed) return; + // Check for RALPH_STATUS block (structured status reporting) + this.processStatusBlockLine(trimmed); + + // Check for completion indicators (for dual-condition exit gate) + this.detectCompletionIndicators(trimmed); + // Check for completion phrase this.detectCompletionPhrase(trimmed); @@ -1493,6 +1631,15 @@ export class RalphTracker extends EventEmitter { this._taskNumberToContent.clear(); this._lineBuffer = ''; this._completionPhraseCount.clear(); + // Clear RALPH_STATUS block and circuit breaker state + this._statusBlockBuffer = []; + this._inStatusBlock = false; + this._lastStatusBlock = null; + this._completionIndicators = 0; + this._exitGateMet = false; + this._totalFilesModified = 0; + this._totalTasksCompleted = 0; + this._circuitBreaker = createInitialCircuitBreakerStatus(); this.emit('loopUpdate', this.loopState); this.emit('todoUpdate', this.todos); } @@ -1555,4 +1702,304 @@ export class RalphTracker extends EventEmitter { this._todos.set(todo.id, { ...todo }); } } + + // ========== RALPH_STATUS Block Detection ========== + + /** + * Process a line for RALPH_STATUS block detection. + * Buffers lines between ---RALPH_STATUS--- and ---END_RALPH_STATUS--- + * then parses the complete block. + * + * @param line - Single line to process (already trimmed) + * @fires statusBlockDetected - When a complete block is parsed + */ + private processStatusBlockLine(line: string): void { + // Check for block start + if (RALPH_STATUS_START_PATTERN.test(line)) { + this._inStatusBlock = true; + this._statusBlockBuffer = []; + return; + } + + // Check for block end + if (this._inStatusBlock && RALPH_STATUS_END_PATTERN.test(line)) { + this._inStatusBlock = false; + this.parseStatusBlock(this._statusBlockBuffer); + this._statusBlockBuffer = []; + return; + } + + // Buffer lines while in block + if (this._inStatusBlock) { + this._statusBlockBuffer.push(line); + } + } + + /** + * Parse buffered RALPH_STATUS block lines into structured data. + * + * @param lines - Array of lines between block markers + * @fires statusBlockDetected - When parsing succeeds + */ + private parseStatusBlock(lines: string[]): void { + const block: Partial = { + parsedAt: Date.now(), + }; + + for (const line of lines) { + // STATUS field + const statusMatch = line.match(RALPH_STATUS_FIELD_PATTERN); + if (statusMatch) { + block.status = statusMatch[1].toUpperCase() as RalphStatusValue; + continue; + } + + // TASKS_COMPLETED_THIS_LOOP field + const tasksMatch = line.match(RALPH_TASKS_COMPLETED_PATTERN); + if (tasksMatch) { + block.tasksCompletedThisLoop = parseInt(tasksMatch[1], 10); + continue; + } + + // FILES_MODIFIED field + const filesMatch = line.match(RALPH_FILES_MODIFIED_PATTERN); + if (filesMatch) { + block.filesModified = parseInt(filesMatch[1], 10); + continue; + } + + // TESTS_STATUS field + const testsMatch = line.match(RALPH_TESTS_STATUS_PATTERN); + if (testsMatch) { + block.testsStatus = testsMatch[1].toUpperCase() as RalphTestsStatus; + continue; + } + + // WORK_TYPE field + const workMatch = line.match(RALPH_WORK_TYPE_PATTERN); + if (workMatch) { + block.workType = workMatch[1].toUpperCase() as RalphWorkType; + continue; + } + + // EXIT_SIGNAL field + const exitMatch = line.match(RALPH_EXIT_SIGNAL_PATTERN); + if (exitMatch) { + block.exitSignal = exitMatch[1].toLowerCase() === 'true'; + continue; + } + + // RECOMMENDATION field + const recMatch = line.match(RALPH_RECOMMENDATION_PATTERN); + if (recMatch) { + block.recommendation = recMatch[1].trim(); + continue; + } + } + + // Only process if we have at least the STATUS field + if (block.status !== undefined) { + // Fill in defaults for missing fields + const fullBlock: RalphStatusBlock = { + status: block.status, + tasksCompletedThisLoop: block.tasksCompletedThisLoop ?? 0, + filesModified: block.filesModified ?? 0, + testsStatus: block.testsStatus ?? 'NOT_RUN', + workType: block.workType ?? 'IMPLEMENTATION', + exitSignal: block.exitSignal ?? false, + recommendation: block.recommendation ?? '', + parsedAt: block.parsedAt!, + }; + + this._lastStatusBlock = fullBlock; + this.handleStatusBlock(fullBlock); + } + } + + /** + * Handle a parsed RALPH_STATUS block. + * Updates circuit breaker, checks exit conditions. + * + * @param block - Parsed status block + * @fires statusBlockDetected - With the block data + * @fires circuitBreakerUpdate - If state changes + * @fires exitGateMet - If dual-condition exit triggered + */ + private handleStatusBlock(block: RalphStatusBlock): void { + // Auto-enable tracker when we see a status block + if (!this._loopState.enabled && !this._autoEnableDisabled) { + this.enable(); + } + + // Update cumulative counts + this._totalFilesModified += block.filesModified; + this._totalTasksCompleted += block.tasksCompletedThisLoop; + + // Check for progress (for circuit breaker) + const hasProgress = block.filesModified > 0 || block.tasksCompletedThisLoop > 0; + + // Update circuit breaker + this.updateCircuitBreaker(hasProgress, block.testsStatus, block.status); + + // Check completion indicators + if (block.status === 'COMPLETE') { + this._completionIndicators++; + } + + // Check dual-condition exit gate + if (block.exitSignal && this._completionIndicators >= 2 && !this._exitGateMet) { + this._exitGateMet = true; + this.emit('exitGateMet', { + completionIndicators: this._completionIndicators, + exitSignal: true, + }); + } + + // Update loop state + this._loopState.lastActivity = Date.now(); + + // Emit the status block + this.emit('statusBlockDetected', block); + this.emitLoopUpdateDebounced(); + } + + // ========== Circuit Breaker ========== + + /** + * Update circuit breaker state based on iteration results. + * + * @param hasProgress - Whether this iteration made progress + * @param testsStatus - Current test status + * @param status - Overall status from RALPH_STATUS + * @fires circuitBreakerUpdate - If state changes + */ + private updateCircuitBreaker( + hasProgress: boolean, + testsStatus: RalphTestsStatus, + status: RalphStatusValue + ): void { + const prevState = this._circuitBreaker.state; + + if (hasProgress) { + // Progress detected - reset counters, possibly close circuit + this._circuitBreaker.consecutiveNoProgress = 0; + this._circuitBreaker.consecutiveSameError = 0; + this._circuitBreaker.lastProgressIteration = this._loopState.cycleCount; + + if (this._circuitBreaker.state === 'HALF_OPEN') { + this._circuitBreaker.state = 'CLOSED'; + this._circuitBreaker.reason = 'Progress detected, circuit closed'; + this._circuitBreaker.reasonCode = 'progress_detected'; + } + } else { + // No progress + this._circuitBreaker.consecutiveNoProgress++; + + // State transitions based on consecutive no-progress + if (this._circuitBreaker.state === 'CLOSED') { + if (this._circuitBreaker.consecutiveNoProgress >= 3) { + this._circuitBreaker.state = 'OPEN'; + this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`; + this._circuitBreaker.reasonCode = 'no_progress_open'; + } else if (this._circuitBreaker.consecutiveNoProgress >= 2) { + this._circuitBreaker.state = 'HALF_OPEN'; + this._circuitBreaker.reason = 'Warning: no progress detected'; + this._circuitBreaker.reasonCode = 'no_progress_warning'; + } + } else if (this._circuitBreaker.state === 'HALF_OPEN') { + if (this._circuitBreaker.consecutiveNoProgress >= 3) { + this._circuitBreaker.state = 'OPEN'; + this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`; + this._circuitBreaker.reasonCode = 'no_progress_open'; + } + } + } + + // Track tests failure + if (testsStatus === 'FAILING') { + this._circuitBreaker.consecutiveTestsFailure++; + if (this._circuitBreaker.consecutiveTestsFailure >= 5 && this._circuitBreaker.state !== 'OPEN') { + this._circuitBreaker.state = 'OPEN'; + this._circuitBreaker.reason = `Tests failing for ${this._circuitBreaker.consecutiveTestsFailure} iterations`; + this._circuitBreaker.reasonCode = 'tests_failing_too_long'; + } + } else { + this._circuitBreaker.consecutiveTestsFailure = 0; + } + + // Track blocked status + if (status === 'BLOCKED' && this._circuitBreaker.state !== 'OPEN') { + this._circuitBreaker.state = 'OPEN'; + this._circuitBreaker.reason = 'Claude reported BLOCKED status'; + this._circuitBreaker.reasonCode = 'same_error_repeated'; + } + + // Emit if state changed + if (prevState !== this._circuitBreaker.state) { + this._circuitBreaker.lastTransitionAt = Date.now(); + this.emit('circuitBreakerUpdate', { ...this._circuitBreaker }); + } + } + + /** + * Manually reset circuit breaker to CLOSED state. + * Use when user acknowledges the issue is resolved. + * + * @fires circuitBreakerUpdate + */ + resetCircuitBreaker(): void { + this._circuitBreaker = createInitialCircuitBreakerStatus(); + this._circuitBreaker.reason = 'Manual reset'; + this._circuitBreaker.reasonCode = 'manual_reset'; + this.emit('circuitBreakerUpdate', { ...this._circuitBreaker }); + } + + /** + * Get current circuit breaker status. + */ + get circuitBreakerStatus(): CircuitBreakerStatus { + return { ...this._circuitBreaker }; + } + + /** + * Get last parsed RALPH_STATUS block. + */ + get lastStatusBlock(): RalphStatusBlock | null { + return this._lastStatusBlock ? { ...this._lastStatusBlock } : null; + } + + /** + * Get cumulative stats from status blocks. + */ + get cumulativeStats(): { filesModified: number; tasksCompleted: number; completionIndicators: number } { + return { + filesModified: this._totalFilesModified, + tasksCompleted: this._totalTasksCompleted, + completionIndicators: this._completionIndicators, + }; + } + + /** + * Whether dual-condition exit gate has been met. + */ + get exitGateMet(): boolean { + return this._exitGateMet; + } + + // ========== Completion Indicator Detection ========== + + /** + * Check line for completion indicators (natural language patterns). + * Used for dual-condition exit gate. + * + * @param line - Line to check + */ + private detectCompletionIndicators(line: string): void { + for (const pattern of COMPLETION_INDICATOR_PATTERNS) { + if (pattern.test(line)) { + this._completionIndicators++; + break; // Only count once per line + } + } + } } diff --git a/src/session.ts b/src/session.ts index 9340ea62..93ef6993 100644 --- a/src/session.ts +++ b/src/session.ts @@ -319,6 +319,12 @@ export interface SessionEvents { ralphTodoUpdate: (todos: RalphTodoItem[]) => void; /** Ralph completion phrase detected */ ralphCompletionDetected: (phrase: string) => void; + /** RALPH_STATUS block detected */ + ralphStatusBlockDetected: (block: import('./types.js').RalphStatusBlock) => void; + /** Circuit breaker state changed */ + ralphCircuitBreakerUpdate: (status: import('./types.js').CircuitBreakerStatus) => void; + /** Dual-condition exit gate met */ + ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void; /** Bash tool with file paths started */ bashToolStart: (tool: ActiveBashTool) => void; /** Bash tool completed */ @@ -441,6 +447,9 @@ export class Session extends EventEmitter { loopUpdate: (state: RalphTrackerState) => void; todoUpdate: (todos: RalphTodoItem[]) => void; completionDetected: (phrase: string) => void; + statusBlockDetected: (block: import('./types.js').RalphStatusBlock) => void; + circuitBreakerUpdate: (status: import('./types.js').CircuitBreakerStatus) => void; + exitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void; } | null = null; // Bash tool tracking (file paths for live log viewing) @@ -494,10 +503,16 @@ export class Session extends EventEmitter { loopUpdate: (state) => this.emit('ralphLoopUpdate', state), todoUpdate: (todos) => this.emit('ralphTodoUpdate', todos), completionDetected: (phrase) => this.emit('ralphCompletionDetected', phrase), + statusBlockDetected: (block) => this.emit('ralphStatusBlockDetected', block), + circuitBreakerUpdate: (status) => this.emit('ralphCircuitBreakerUpdate', status), + exitGateMet: (data) => this.emit('ralphExitGateMet', data), }; this._ralphTracker.on('loopUpdate', this._ralphHandlers.loopUpdate); this._ralphTracker.on('todoUpdate', this._ralphHandlers.todoUpdate); this._ralphTracker.on('completionDetected', this._ralphHandlers.completionDetected); + this._ralphTracker.on('statusBlockDetected', this._ralphHandlers.statusBlockDetected); + this._ralphTracker.on('circuitBreakerUpdate', this._ralphHandlers.circuitBreakerUpdate); + this._ralphTracker.on('exitGateMet', this._ralphHandlers.exitGateMet); // Initialize Bash tool parser and forward events (store handlers for cleanup) this._bashToolParser = new BashToolParser({ sessionId: this.id, workingDir: this.workingDir }); @@ -1739,6 +1754,9 @@ export class Session extends EventEmitter { this._ralphTracker.off('loopUpdate', this._ralphHandlers.loopUpdate); this._ralphTracker.off('todoUpdate', this._ralphHandlers.todoUpdate); this._ralphTracker.off('completionDetected', this._ralphHandlers.completionDetected); + this._ralphTracker.off('statusBlockDetected', this._ralphHandlers.statusBlockDetected); + this._ralphTracker.off('circuitBreakerUpdate', this._ralphHandlers.circuitBreakerUpdate); + this._ralphTracker.off('exitGateMet', this._ralphHandlers.exitGateMet); this._ralphHandlers = null; } diff --git a/src/types.ts b/src/types.ts index 27be1f8d..2ccc8ab3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -753,6 +753,139 @@ export interface RalphStateRecord { [sessionId: string]: RalphSessionState; } +// ========== RALPH_STATUS Block Types ========== + +/** + * Status values from RALPH_STATUS block. + * - IN_PROGRESS: Work is ongoing + * - COMPLETE: All tasks finished + * - BLOCKED: Needs human intervention + */ +export type RalphStatusValue = 'IN_PROGRESS' | 'COMPLETE' | 'BLOCKED'; + +/** + * Test status from RALPH_STATUS block. + */ +export type RalphTestsStatus = 'PASSING' | 'FAILING' | 'NOT_RUN'; + +/** + * Work type classification for current iteration. + */ +export type RalphWorkType = 'IMPLEMENTATION' | 'TESTING' | 'DOCUMENTATION' | 'REFACTORING'; + +/** + * Parsed RALPH_STATUS block from Claude output. + * + * Claude outputs this at the end of every response: + * ``` + * ---RALPH_STATUS--- + * STATUS: IN_PROGRESS + * TASKS_COMPLETED_THIS_LOOP: 3 + * FILES_MODIFIED: 5 + * TESTS_STATUS: PASSING + * WORK_TYPE: IMPLEMENTATION + * EXIT_SIGNAL: false + * RECOMMENDATION: Continue with database migration + * ---END_RALPH_STATUS--- + * ``` + */ +export interface RalphStatusBlock { + /** Overall loop status */ + status: RalphStatusValue; + /** Number of tasks completed in current iteration */ + tasksCompletedThisLoop: number; + /** Number of files modified in current iteration */ + filesModified: number; + /** Current state of tests */ + testsStatus: RalphTestsStatus; + /** Type of work being performed */ + workType: RalphWorkType; + /** Whether Claude is signaling completion */ + exitSignal: boolean; + /** Claude's recommendation for next steps */ + recommendation: string; + /** Timestamp when this block was parsed */ + parsedAt: number; +} + +// ========== Circuit Breaker Types ========== + +/** + * Circuit breaker states for detecting stuck loops. + * - CLOSED: Normal operation, all checks passing + * - HALF_OPEN: Warning state, some checks failing + * - OPEN: Loop is stuck, requires intervention + */ +export type CircuitBreakerState = 'CLOSED' | 'HALF_OPEN' | 'OPEN'; + +/** + * Reason codes for circuit breaker state transitions. + */ +export type CircuitBreakerReason = + | 'normal_operation' + | 'no_progress_warning' + | 'no_progress_open' + | 'same_error_repeated' + | 'tests_failing_too_long' + | 'progress_detected' + | 'manual_reset'; + +/** + * Circuit breaker status for tracking loop health. + * + * Transitions: + * - CLOSED -> HALF_OPEN: consecutive_no_progress >= 2 + * - CLOSED -> OPEN: consecutive_no_progress >= 3 OR consecutive_same_error >= 5 + * - HALF_OPEN -> CLOSED: progress detected + * - HALF_OPEN -> OPEN: consecutive_no_progress >= 3 + * - OPEN -> CLOSED: manual reset only + */ +export interface CircuitBreakerStatus { + /** Current state of the circuit breaker */ + state: CircuitBreakerState; + /** Number of consecutive iterations with no progress */ + consecutiveNoProgress: number; + /** Number of consecutive iterations with the same error */ + consecutiveSameError: number; + /** Number of consecutive iterations with failing tests */ + consecutiveTestsFailure: number; + /** Last iteration number that showed progress */ + lastProgressIteration: number; + /** Human-readable reason for current state */ + reason: string; + /** Reason code for programmatic handling */ + reasonCode: CircuitBreakerReason; + /** Timestamp of last state transition */ + lastTransitionAt: number; + /** Last error message seen (for same-error tracking) */ + lastErrorMessage: string | null; +} + +/** + * Creates initial circuit breaker status. + */ +export function createInitialCircuitBreakerStatus(): CircuitBreakerStatus { + return { + state: 'CLOSED', + consecutiveNoProgress: 0, + consecutiveSameError: 0, + consecutiveTestsFailure: 0, + lastProgressIteration: 0, + reason: 'Initial state', + reasonCode: 'normal_operation', + lastTransitionAt: Date.now(), + lastErrorMessage: null, + }; +} + +// ========== Priority Todo Types ========== + +/** + * Priority levels for todo items. + * Matches @fix_plan.md format. + */ +export type RalphTodoPriority = 'P0' | 'P1' | 'P2' | null; + /** * Creates initial Ralph tracker state * @returns Fresh Ralph tracker state with defaults diff --git a/src/web/public/app.js b/src/web/public/app.js index 7d537135..e773bf74 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1523,6 +1523,43 @@ class ClaudemanApp { }); }); + // RALPH_STATUS block and circuit breaker events + this.eventSource.addEventListener('session:ralphStatusUpdate', (e) => { + const data = JSON.parse(e.data); + this.updateRalphState(data.sessionId, { statusBlock: data.block }); + }); + + this.eventSource.addEventListener('session:circuitBreakerUpdate', (e) => { + const data = JSON.parse(e.data); + this.updateRalphState(data.sessionId, { circuitBreaker: data.status }); + // Notify if circuit breaker opens + if (data.status.state === 'OPEN') { + const session = this.sessions.get(data.sessionId); + this.notificationManager?.notify({ + urgency: 'critical', + category: 'circuit-breaker', + sessionId: data.sessionId, + sessionName: session?.name || data.sessionId?.slice(0, 8), + title: 'Circuit Breaker Open', + message: data.status.reason || 'Loop stuck - no progress detected', + }); + } + }); + + this.eventSource.addEventListener('session:exitGateMet', (e) => { + const data = JSON.parse(e.data); + // Notify when exit gate is met + const session = this.sessions.get(data.sessionId); + this.notificationManager?.notify({ + urgency: 'warning', + category: 'exit-gate', + sessionId: data.sessionId, + sessionName: session?.name || data.sessionId?.slice(0, 8), + title: 'Exit Gate Met', + message: `Loop ready to exit (indicators: ${data.completionIndicators})`, + }); + }); + // Active Bash tool events (for clickable file paths) this.eventSource.addEventListener('session:bashToolStart', (e) => { const data = JSON.parse(e.data); @@ -5185,8 +5222,10 @@ class ClaudemanApp { const isEnabled = state?.loop?.enabled === true; const hasLoop = state?.loop?.active || state?.loop?.completionPhrase; const hasTodos = state?.todos?.length > 0; + const hasCircuitBreaker = state?.circuitBreaker && state.circuitBreaker.state !== 'CLOSED'; + const hasStatusBlock = state?.statusBlock !== undefined; - if (!isEnabled && !hasLoop && !hasTodos) { + if (!isEnabled && !hasLoop && !hasTodos && !hasCircuitBreaker && !hasStatusBlock) { panel.style.display = 'none'; return; } @@ -5208,6 +5247,9 @@ class ClaudemanApp { // Update stats this.updateRalphStats(state?.loop, completed, total); + // Update circuit breaker badge + this.updateCircuitBreakerBadge(state?.circuitBreaker); + // Handle collapsed/expanded state if (this.ralphStatePanelCollapsed) { panel.classList.add('collapsed'); @@ -5269,6 +5311,67 @@ class ClaudemanApp { } } + updateCircuitBreakerBadge(circuitBreaker) { + // Find or create the circuit breaker badge container + let cbContainer = this.$('ralphCircuitBreakerBadge'); + if (!cbContainer) { + // Create container if it doesn't exist (we'll add it dynamically) + const summary = this.$('ralphSummary'); + if (!summary) return; + + // Check if it already exists + cbContainer = summary.querySelector('.ralph-circuit-breaker'); + if (!cbContainer) { + cbContainer = document.createElement('div'); + cbContainer.id = 'ralphCircuitBreakerBadge'; + cbContainer.className = 'ralph-circuit-breaker'; + // Insert after the status badge + const statusBadge = this.$('ralphStatusBadge'); + if (statusBadge && statusBadge.nextSibling) { + statusBadge.parentNode.insertBefore(cbContainer, statusBadge.nextSibling); + } else { + summary.appendChild(cbContainer); + } + } + } + + // Hide if no circuit breaker state or CLOSED + if (!circuitBreaker || circuitBreaker.state === 'CLOSED') { + cbContainer.style.display = 'none'; + return; + } + + cbContainer.style.display = ''; + cbContainer.classList.remove('half-open', 'open'); + + if (circuitBreaker.state === 'HALF_OPEN') { + cbContainer.classList.add('half-open'); + cbContainer.innerHTML = `⚠Warning`; + cbContainer.title = circuitBreaker.reason || 'Circuit breaker warning'; + } else if (circuitBreaker.state === 'OPEN') { + cbContainer.classList.add('open'); + cbContainer.innerHTML = `🛑Stuck`; + cbContainer.title = circuitBreaker.reason || 'Loop appears stuck'; + } + + // Add click handler to reset + cbContainer.onclick = () => this.resetCircuitBreaker(); + } + + async resetCircuitBreaker() { + if (!this.activeSessionId) return; + try { + const response = await fetch(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, { + method: 'POST', + }); + if (response.ok) { + console.log('Circuit breaker reset'); + } + } catch (err) { + console.error('Failed to reset circuit breaker:', err); + } + } + updateRalphStats(loop, completed, total) { // Time stat const timeEl = this.$('ralphStatTime'); @@ -5352,6 +5455,69 @@ class ClaudemanApp { // Render task cards this.renderRalphTasks(todos); + + // Render RALPH_STATUS block if present + this.renderRalphStatusBlock(state?.statusBlock); + } + + renderRalphStatusBlock(statusBlock) { + // Find or create the status block container + let container = this.$('ralphStatusBlockDisplay'); + const expandedContent = this.$('ralphExpandedContent'); + + if (!statusBlock) { + // Remove container if no status block + if (container) { + container.remove(); + } + return; + } + + if (!container && expandedContent) { + container = document.createElement('div'); + container.id = 'ralphStatusBlockDisplay'; + container.className = 'ralph-status-block'; + // Insert at the top of expanded content + expandedContent.insertBefore(container, expandedContent.firstChild); + } + + if (!container) return; + + // Build status class + const statusClass = statusBlock.status === 'IN_PROGRESS' ? 'in-progress' + : statusBlock.status === 'COMPLETE' ? 'complete' + : statusBlock.status === 'BLOCKED' ? 'blocked' : ''; + + // Build tests status icon + const testsIcon = statusBlock.testsStatus === 'PASSING' ? '✅' + : statusBlock.testsStatus === 'FAILING' ? '❌' + : '⏸'; + + // Build work type icon + const workIcon = statusBlock.workType === 'IMPLEMENTATION' ? '🔧' + : statusBlock.workType === 'TESTING' ? '🧪' + : statusBlock.workType === 'DOCUMENTATION' ? '📝' + : statusBlock.workType === 'REFACTORING' ? '♻️' : '📋'; + + let html = ` +
+ RALPH_STATUS + ${statusBlock.status} + ${statusBlock.exitSignal ? '🚪 EXIT' : ''} +
+
+ ${workIcon} ${statusBlock.workType} + 📁 ${statusBlock.filesModified} files + ✓ ${statusBlock.tasksCompletedThisLoop} tasks + ${testsIcon} Tests: ${statusBlock.testsStatus} +
+ `; + + if (statusBlock.recommendation) { + html += `
${statusBlock.recommendation}
`; + } + + container.innerHTML = html; } renderRalphTasks(todos) { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index c78078dc..2a794337 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -843,6 +843,117 @@ body { 50% { opacity: 0.6; transform: scale(1.2); } } +/* Circuit Breaker Badge */ +.ralph-circuit-breaker { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + margin-left: 0.5rem; +} + +.ralph-circuit-breaker.half-open { + background: rgba(255, 193, 7, 0.2); + color: #ffc107; + border: 1px solid rgba(255, 193, 7, 0.4); +} + +.ralph-circuit-breaker.half-open:hover { + background: rgba(255, 193, 7, 0.3); +} + +.ralph-circuit-breaker.open { + background: rgba(244, 67, 54, 0.2); + color: #f44336; + border: 1px solid rgba(244, 67, 54, 0.4); + animation: circuit-breaker-pulse 1s ease-in-out infinite; +} + +.ralph-circuit-breaker.open:hover { + background: rgba(244, 67, 54, 0.3); +} + +.ralph-circuit-breaker .cb-icon { + font-size: 0.8rem; +} + +.ralph-circuit-breaker .cb-text { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +@keyframes circuit-breaker-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +/* RALPH_STATUS Block Display */ +.ralph-status-block { + background: var(--bg-input); + border-radius: 4px; + padding: 0.5rem 0.75rem; + margin-top: 0.5rem; + font-size: 0.75rem; +} + +.ralph-status-block-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.4rem; + font-weight: 600; + color: var(--text-bright); +} + +.ralph-status-block-status { + padding: 0.1rem 0.4rem; + border-radius: 3px; + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; +} + +.ralph-status-block-status.in-progress { + background: rgba(33, 150, 243, 0.2); + color: #2196f3; +} + +.ralph-status-block-status.complete { + background: rgba(76, 175, 80, 0.2); + color: #4caf50; +} + +.ralph-status-block-status.blocked { + background: rgba(244, 67, 54, 0.2); + color: #f44336; +} + +.ralph-status-block-stats { + display: flex; + gap: 1rem; + color: var(--text-dim); +} + +.ralph-status-block-stats span { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.ralph-status-block-recommendation { + margin-top: 0.4rem; + padding-top: 0.4rem; + border-top: 1px solid var(--border); + color: var(--text-muted); + font-style: italic; +} + /* Summary Stats */ .ralph-summary-stats { display: flex; diff --git a/src/web/server.ts b/src/web/server.ts index c98409b7..f01eef4d 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -118,6 +118,24 @@ const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/; const CTRL_L_PATTERN = /\x0c/g; const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/; +/** + * Formats uptime in seconds to a human-readable string. + */ +function formatUptime(seconds: number): string { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (secs > 0 || parts.length === 0) parts.push(`${secs}s`); + + return parts.join(' '); +} + /** * Sanitizes hook event data before broadcasting via SSE. * Extracts only relevant fields and limits total size to prevent @@ -444,6 +462,50 @@ export class WebServer extends EventEmitter { return { success: true, config: this.store.getConfig() }; }); + // Debug/monitoring endpoint - lightweight, only runs when called + this.app.get('/api/debug/memory', async () => { + const mem = process.memoryUsage(); + const spawnState = this.spawnOrchestrator.getState(); + return { + memory: { + rss: mem.rss, + rssMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10, + heapUsed: mem.heapUsed, + heapUsedMB: Math.round(mem.heapUsed / 1024 / 1024 * 10) / 10, + heapTotal: mem.heapTotal, + heapTotalMB: Math.round(mem.heapTotal / 1024 / 1024 * 10) / 10, + external: mem.external, + externalMB: Math.round(mem.external / 1024 / 1024 * 10) / 10, + arrayBuffers: mem.arrayBuffers, + arrayBuffersMB: Math.round(mem.arrayBuffers / 1024 / 1024 * 10) / 10, + }, + counts: { + sessions: this.sessions.size, + sseClients: this.sseClients.size, + respawnControllers: this.respawnControllers.size, + runSummaryTrackers: this.runSummaryTrackers.size, + scheduledRuns: this.scheduledRuns.size, + terminalBatches: this.terminalBatches.size, + outputBatches: this.outputBatches.size, + pendingRespawnStarts: this.pendingRespawnStarts.size, + respawnTimers: this.respawnTimers.size, + subagents: subagentWatcher.getSubagents().length, + }, + spawn: { + activeAgents: spawnState.activeCount, + queuedAgents: spawnState.queuedCount, + totalSpawned: spawnState.totalSpawned, + totalCompleted: spawnState.totalCompleted, + totalFailed: spawnState.totalFailed, + }, + uptime: { + seconds: Math.round(process.uptime()), + formatted: formatUptime(process.uptime()), + }, + timestamp: Date.now(), + }; + }); + // Session management this.app.get('/api/sessions', async () => this.getSessionsState()); @@ -759,6 +821,39 @@ export class WebServer extends EventEmitter { return { success: true }; }); + // Reset circuit breaker for Ralph tracker + this.app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => { + const { id } = req.params as { id: string }; + const session = this.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + session.ralphTracker.resetCircuitBreaker(); + return { success: true }; + }); + + // Get Ralph status block and circuit breaker state + this.app.get('/api/sessions/:id/ralph-status', async (req) => { + const { id } = req.params as { id: string }; + const session = this.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + return { + success: true, + data: { + lastStatusBlock: session.ralphTracker.lastStatusBlock, + circuitBreaker: session.ralphTracker.circuitBreakerStatus, + cumulativeStats: session.ralphTracker.cumulativeStats, + exitGateMet: session.ralphTracker.exitGateMet, + } + }; + }); + // Run prompt in session this.app.post('/api/sessions/:id/run', async (req): Promise => { const { id } = req.params as { id: string }; @@ -2348,6 +2443,49 @@ export class WebServer extends EventEmitter { if (tracker) tracker.recordRalphCompletion(phrase); }); + // RALPH_STATUS block events + session.on('ralphStatusBlockDetected', (block: import('../types.js').RalphStatusBlock) => { + this.broadcast('session:ralphStatusUpdate', { sessionId: session.id, block }); + // Track in run summary + const tracker = this.runSummaryTrackers.get(session.id); + if (tracker) { + tracker.addEvent( + block.status === 'BLOCKED' ? 'warning' : 'idle_detected', + block.status === 'BLOCKED' ? 'warning' : 'info', + `Ralph Status: ${block.status}`, + `Tasks: ${block.tasksCompletedThisLoop}, Files: ${block.filesModified}, Tests: ${block.testsStatus}` + ); + } + }); + + session.on('ralphCircuitBreakerUpdate', (status: import('../types.js').CircuitBreakerStatus) => { + this.broadcast('session:circuitBreakerUpdate', { sessionId: session.id, status }); + // Track state changes in run summary + const tracker = this.runSummaryTrackers.get(session.id); + if (tracker && status.state === 'OPEN') { + tracker.addEvent( + 'warning', + 'warning', + 'Circuit Breaker Opened', + status.reason + ); + } + }); + + session.on('ralphExitGateMet', (data: { completionIndicators: number; exitSignal: boolean }) => { + this.broadcast('session:exitGateMet', { sessionId: session.id, ...data }); + // Track in run summary + const tracker = this.runSummaryTrackers.get(session.id); + if (tracker) { + tracker.addEvent( + 'ralph_completion', + 'success', + 'Exit Gate Met', + `Indicators: ${data.completionIndicators}, EXIT_SIGNAL: ${data.exitSignal}` + ); + } + }); + // Bash tool tracking events (for clickable file paths) session.on('bashToolStart', (tool: ActiveBashTool) => { this.broadcast('session:bashToolStart', { sessionId: session.id, tool });