diff --git a/CLAUDE.md b/CLAUDE.md index ec2ac8e1..76c6483a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,7 +110,7 @@ journalctl --user -u codeman-web -f ## Import Conventions - **Utilities**: Import from `./utils` (re-exports all): `import { LRUMap, stripAnsi } from './utils'` -- **Types**: Use type imports: `import type { SessionState } from './types'` +- **Types**: Use type imports from barrel: `import type { SessionState } from './types'` (re-exports from `src/types/` domain files) - **Config**: Import from specific files: `import { MAX_TERMINAL_BUFFER_SIZE } from './config/buffer-limits'` ## Architecture @@ -146,16 +146,18 @@ journalctl --user -u codeman-web -f | `src/session-lifecycle-log.ts` | Append-only JSONL audit log at `~/.codeman/session-lifecycle.jsonl` | | `src/image-watcher.ts` | Watches for image file creation (screenshots, etc.) | | `src/file-stream-manager.ts` | Manages `tail -f` processes for live log viewing | -| `src/plan-orchestrator.ts` | Multi-agent plan generation with research and planning phases | +| `src/plan-orchestrator.ts` | 2-agent plan generation: optional research agent → planner agent | | `src/prompts/index.ts` | Barrel export for all agent prompts | | `src/prompts/*.ts` | Agent prompts (research-agent, planner) | | `src/templates/claude-md.ts` | CLAUDE.md generation for new cases | | `src/tunnel-manager.ts` | Manages cloudflared child process for Cloudflare tunnel remote access | | `src/cli.ts` | Command-line interface handlers | -| `src/web/server.ts` | Fastify REST API + SSE at `/api/events` (~280 routes) | +| `src/web/server.ts` | Fastify server setup, SSE at `/api/events`, delegates to route modules | +| `src/web/routes/*.ts` | 13 domain route modules (session, respawn, ralph, plan, etc.) — each exports `register*Routes()` | +| `src/web/route-helpers.ts` | Shared helper utilities for route modules | | `src/web/schemas.ts` | Zod v4 validation schemas with path/env security allowlists | | `src/web/public/app.js` | Frontend: xterm.js, tab management, subagent windows, mobile support (~15K lines) | -| `src/types.ts` | All TypeScript interfaces (~70 type/interface/enum defs, ~1450 lines) | +| `src/types.ts` | Barrel re-export from `src/types/` — 14 domain files (session, task, respawn, ralph, api, etc.) | **Large files** (>50KB): `app.js`, `ralph-tracker.ts`, `respawn-controller.ts`, `session.ts`, `subagent-watcher.ts` — these contain complex state machines; read `docs/respawn-state-machine.md` before modifying. @@ -284,7 +286,7 @@ The frontend is a single ~15K-line vanilla JS file with these key systems: ### API Route Categories -~280 route handlers in `server.ts:buildServer()`. Key groups: +~280 route handlers split across `src/web/routes/` domain modules. Key groups: | Group | Prefix | Count | Key endpoints | |-------|--------|-------|---------------| @@ -302,7 +304,7 @@ The frontend is a single ~15K-line vanilla JS file with these key systems: ## Adding Features -- **API endpoint**: Types in `types.ts`, route in `server.ts:buildServer()`, use `createErrorResponse()`. Validate request bodies with Zod schemas in `schemas.ts`. +- **API endpoint**: Types in `src/types/` (domain file), route in the appropriate `src/web/routes/*-routes.ts` module, use `createErrorResponse()`. Validate request bodies with Zod schemas in `schemas.ts`. - **SSE event**: Emit via `broadcast()`, handle in `app.js` SSE listener section (search `addListener(`) - **Session setting**: Add to `SessionState` in `types.ts`, include in `session.toState()`, call `persistSessionState()` - **Hook event**: Add to `HookEventType` in `types.ts`, add hook command in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema` in `schemas.ts` @@ -442,10 +444,11 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base | **Claude Code hooks** | `docs/claude-code-hooks-reference.md` | | **Terminal anti-flicker** | `docs/terminal-anti-flicker.md` | | **Agent Teams (experimental)** | `agent-teams/README.md`, `agent-teams/design.md` | -| **API routes** | `src/web/server.ts:buildServer()` or README.md | -| **SSE events** | Search `broadcast(` in `server.ts` | -| **Session statuses** | `SessionStatus` type in `src/types.ts` | -| **Error codes** | `createErrorResponse()` in `src/types.ts` | +| **API routes** | `src/web/routes/` domain modules, or README.md | +| **SSE events** | Search `broadcast(` in `server.ts` and route modules | +| **Session statuses** | `SessionStatus` in `src/types/session.ts` | +| **Error codes** | `createErrorResponse()` in `src/types/api.ts` | +| **Refactoring phases** | `docs/phase1-implementation-plan.md` through `docs/phase-4-domain-splitting-plan.md` | | **Test utilities** | `test/respawn-test-utils.ts` | | **Mobile test suite** | `mobile-test/README.md` | | **OpenCode integration** | `docs/opencode-integration.md` | @@ -498,7 +501,7 @@ Run `npx vitest run test/memory-leak-prevention.test.ts` to verify patterns. **Investigating a bug**: Start dev server (`npx tsx src/index.ts web`), reproduce in browser, check terminal output and `~/.codeman/state.json` for clues. -**Adding a new API endpoint**: Define types in `types.ts`, add route in `server.ts:buildServer()`, broadcast SSE events if needed, handle in `app.js:handleSSEEvent()`. +**Adding a new API endpoint**: Define types in the appropriate `src/types/*.ts` domain file, add route in the matching `src/web/routes/*-routes.ts` module, broadcast SSE events if needed, handle in `app.js:handleSSEEvent()`. **Modifying respawn behavior**: Study `docs/respawn-state-machine.md` first. The state machine is in `respawn-controller.ts`. Use MockSession from `test/respawn-test-utils.ts` for testing. diff --git a/docs/phase-4-domain-splitting-plan.md b/docs/phase-4-domain-splitting-plan.md new file mode 100644 index 00000000..e3021c8a --- /dev/null +++ b/docs/phase-4-domain-splitting-plan.md @@ -0,0 +1,788 @@ +# Phase 4: Domain File Splitting — Implementation Plan + +**Date**: 2026-03-01 +**Prerequisites**: Phase 1-3 complete (utils cleanup, CleanupManager/Debouncer migration, route extraction) +**Goal**: Split 4 god files into focused modules with barrel exports for transparent migration. + +--- + +## Table of Contents + +1. [Split types.ts into types/ directory](#1-split-typests-into-types-directory) +2. [Split ralph-tracker.ts into focused modules](#2-split-ralph-trackerts-into-focused-modules) +3. [Split respawn-controller.ts into focused modules](#3-split-respawn-controllerts-into-focused-modules) +4. [Split session.ts into focused modules](#4-split-sessionts-into-focused-modules) +5. [Execution Order & Dependencies](#5-execution-order--dependencies) +6. [Validation Checklist](#6-validation-checklist) + +--- + +## 1. Split types.ts into types/ directory + +**Current**: 1,443 lines, 71 exports, imported by 36 files. +**Risk**: LOW — pure type refactor, no runtime behavior change. + +### Target Structure + +``` +src/types/ +├── index.ts (barrel re-export — transparent migration) +├── common.ts (Disposable, BufferConfig, CleanupResourceType, CleanupRegistration) +├── session.ts (SessionStatus, SessionMode, ClaudeMode, SessionConfig, SessionColor, +│ SessionState, OpenCodeConfig, SessionOutput) +├── task.ts (TaskStatus, TaskDefinition, TaskState) +├── app-state.ts (AppState, AppConfig, GlobalStats, TokenUsageEntry, TokenStats, +│ DEFAULT_CONFIG, createInitialState, createInitialGlobalStats) +├── respawn.ts (RespawnConfig, PersistedRespawnConfig, CycleOutcome, +│ RespawnCycleMetrics, RespawnAggregateMetrics, HealthStatus, +│ RalphLoopHealthScore, TimingHistory, RespawnPreset) +├── ralph.ts (RalphLoopStatus, RalphLoopState, RalphTodoStatus, RalphTodoPriority, +│ RalphTodoItem, RalphTodoProgress, RalphSessionState, +│ RalphStatusValue, RalphTestsStatus, RalphWorkType, RalphStatusBlock, +│ CompletionConfidence, RalphTrackerState, +│ CircuitBreakerState, CircuitBreakerReason, CircuitBreakerStatus, +│ createInitialCircuitBreakerStatus, createInitialRalphTrackerState, +│ createInitialRalphSessionState) +├── api.ts (ApiErrorCode, ApiResponse, HookEventType, QuickStartResponse, +│ CaseInfo, createErrorResponse, isError, getErrorMessage) +├── lifecycle.ts (LifecycleEventType, LifecycleEntry) +├── run-summary.ts (RunSummaryEventType, RunSummaryEventSeverity, RunSummaryEvent, +│ RunSummaryStats, RunSummary, createInitialRunSummaryStats) +├── tools.ts (ActiveBashToolStatus, ActiveBashTool, ImageDetectedEvent) +├── teams.ts (TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo) +├── push.ts (PushSubscriptionRecord, VapidKeys) +└── plan.ts (PlanTaskStatus, TddPhase, PlanItem re-export, NiceConfig, + DEFAULT_NICE_CONFIG, ProcessStats) +``` + +### Steps + +1. **Create `src/types/` directory** and each domain file above. + +2. **Move types** from `src/types.ts` into their domain files. Preserve all JSDoc comments. Each file should import from siblings as needed (e.g., `ralph.ts` imports `CircuitBreakerState` within itself — no cross-file deps needed since they're in the same file). + +3. **Create barrel `src/types/index.ts`** that re-exports everything: + ```typescript + export * from './common.js'; + export * from './session.js'; + export * from './task.js'; + export * from './app-state.js'; + export * from './respawn.js'; + export * from './ralph.js'; + export * from './api.js'; + export * from './lifecycle.js'; + export * from './run-summary.js'; + export * from './tools.js'; + export * from './teams.js'; + export * from './push.js'; + export * from './plan.js'; + ``` + +4. **Delete old `src/types.ts`** and replace with a single-line re-export barrel: + ```typescript + export * from './types/index.js'; + ``` + This ensures `import { ... } from './types.js'` continues to work everywhere — zero changes to 36 import sites. + +5. **Verify**: `tsc --noEmit` and `npm run lint` must pass. No runtime changes. + +### Internal Dependencies Between Domain Files + +Some types reference others across domains. Handle with imports: + +| File | Imports From | +|------|-------------| +| `app-state.ts` | `session.ts` (SessionState), `task.ts` (TaskState), `ralph.ts` (RalphLoopState, RalphSessionState) | +| `respawn.ts` | None (self-contained) | +| `ralph.ts` | None (self-contained) | +| `run-summary.ts` | None (self-contained) | +| `api.ts` | None (self-contained) | +| `session.ts` | `respawn.ts` (RespawnConfig), `ralph.ts` (RalphTrackerState, RalphTodoItem, CircuitBreakerStatus, RalphSessionState, RunSummaryEvent) | + +Wait — `SessionState` references `RespawnConfig`, `RalphTrackerState`, `CircuitBreakerStatus`, and `RunSummaryEvent`. This creates imports from `session.ts` → `respawn.ts`, `ralph.ts`, `run-summary.ts`. This is fine (one-way deps, no cycles). + +--- + +## 2. Split ralph-tracker.ts into focused modules + +**Current**: 3,868 lines, single `RalphTracker` class with 5 responsibilities. +**Risk**: MEDIUM — class has shared mutable state, but extractable modules are well-isolated. + +### Coupling Analysis Summary + +| Module | Coupling | Extractability | +|--------|----------|----------------| +| Plan task tracking | LOW | HIGH — only reads `cycleCount` | +| Fix-plan file watching | LOW | HIGH — callback-based todo replacement | +| Iteration stall detection | LOW | HIGH — notification-based | +| RALPH_STATUS block parsing + circuit breaker | MEDIUM | MEDIUM — callback for circuit breaker updates | +| Todo parsing, loop detection, completion | HIGH | LOW — deeply entangled shared state | + +### Target Structure + +``` +src/ +├── ralph-tracker.ts (~1,800 LOC — core: output parsing, loop state, +│ todo management, completion detection) +├── ralph-plan-tracker.ts (~600 LOC — plan tasks, checkpoints, history, rollback) +├── ralph-status-parser.ts (~300 LOC — RALPH_STATUS block parsing, circuit breaker) +├── ralph-fix-plan-watcher.ts (~150 LOC — @fix_plan.md file watching) +└── ralph-stall-detector.ts (~80 LOC — iteration stall detection) +``` + +### Step 2a: Extract `RalphPlanTracker` (~600 LOC) + +**Why first**: Lowest coupling. Only dependency is `cycleCount` for checkpoint detection. + +**Extract these from `RalphTracker`**: + +Types to export: +- `EnhancedPlanTask` (interface, currently lines 56-87) +- `CheckpointReview` (interface, currently lines 90-139) + +Properties to move: +- `_planVersion: number` +- `_planHistory: Array<{version, timestamp, tasks, summary}>` +- `_planTasks: Map` +- `_checkpointIterations: number[]` +- `_lastCheckpointIteration: number` + +Methods to move: +- `initializePlanTasks(items)` +- `updatePlanTask(taskId, update)` +- `addPlanTask(params)` +- `getPlanTasks()` +- `generateCheckpointReview()` +- `getPlanHistory()` +- `rollbackToVersion(version)` +- `isCheckpointDue()` +- `planVersion` getter +- `_savePlanToHistory()` (private) +- `_unblockDependentTasks()` (private) +- `_checkForCheckpoint()` (private) + +Events emitted (define in new class): +- `planInitialized` +- `planTaskUpdate` +- `taskBlocked` +- `taskUnblocked` +- `planCheckpoint` + +**Interface with parent**: +```typescript +export class RalphPlanTracker extends EventEmitter { + constructor() { ... } + + // Parent calls this when iteration changes (for checkpoint detection) + notifyCycleCount(cycleCount: number): void { ... } + + // Full public API moves here unchanged + initializePlanTasks(items: PlanItem[]): void { ... } + updatePlanTask(taskId: string, update: { ... }): { ... } | null { ... } + // ...etc +} +``` + +**In `RalphTracker`**: Replace plan methods with delegation: +```typescript +readonly planTracker = new RalphPlanTracker(); + +// Forward plan events +this.planTracker.on('planInitialized', (...args) => this.emit('planInitialized', ...args)); +// ...etc + +// In detectLoopStatus(), when cycleCount changes: +this.planTracker.notifyCycleCount(this._loopState.cycleCount); +``` + +### Step 2b: Extract `RalphFixPlanWatcher` (~150 LOC) + +**Extract these**: + +Properties: +- `_workingDir: string | null` +- `_fixPlanPath: string | null` +- `_fixPlanWatcher: FSWatcher | null` +- `_fixPlanWatcherErrorHandler` +- `_fixPlanReloadDeb` + +Methods: +- `setWorkingDir(workingDir)` +- `loadFixPlanFromDisk()` +- `startWatchingFixPlan()` +- `stopWatchingFixPlan()` +- `handleFixPlanChange()` +- `isFileAuthoritative` getter + +**Interface with parent**: +```typescript +export class RalphFixPlanWatcher extends EventEmitter { + get isFileAuthoritative(): boolean { ... } + + setWorkingDir(workingDir: string): void { ... } + stop(): void { ... } +} + +// Events: +// 'todosLoaded' → (todos: Array<{id, content, status, priority}>) — parent replaces _todos +``` + +**In `RalphTracker`**: +```typescript +readonly fixPlanWatcher = new RalphFixPlanWatcher(); + +constructor() { + this.fixPlanWatcher.on('todosLoaded', (items) => { + // Replace _todos with file-based items + this._todos.clear(); + for (const item of items) { + this.addOrUpdateTodo(item.id, item.content, item.status, item.priority); + } + }); +} + +// Delegate isFileAuthoritative +get isFileAuthoritative(): boolean { + return this.fixPlanWatcher.isFileAuthoritative; +} +``` + +### Step 2c: Extract `RalphStallDetector` (~80 LOC) + +**Extract these**: + +Properties: +- `_lastIterationChangeTime` +- `_lastObservedIteration` +- `_iterationStallTimerId` +- `_iterationStallWarningMs` +- `_iterationStallCriticalMs` +- `_iterationStallWarned` + +Methods: +- `startIterationStallDetection()` +- `stopIterationStallDetection()` +- `checkIterationStall()` +- `getIterationStallMetrics()` +- `configureIterationStallThresholds(warningMs, criticalMs)` + +**Interface with parent**: +```typescript +export class RalphStallDetector extends EventEmitter { + constructor(private cleanup: CleanupManager) { ... } + + start(): void { ... } + stop(): void { ... } + + // Parent calls when iteration changes + notifyIterationChanged(iteration: number): void { + this._lastIterationChangeTime = Date.now(); + this._lastObservedIteration = iteration; + this._iterationStallWarned = false; + } + + // Parent calls to check if loop is active + setLoopActive(active: boolean): void { ... } + + getIterationStallMetrics(): { ... } { ... } +} + +// Events: 'iterationStallWarning', 'iterationStallCritical' +``` + +### Step 2d: Extract `RalphStatusParser` (~300 LOC) + +**Extract these**: + +Properties: +- `_circuitBreaker: CircuitBreakerStatus` +- `_statusBlockBuffer: string[]` +- `_inStatusBlock: boolean` +- `_lastStatusBlock: RalphStatusBlock | null` +- `_completionIndicators: number` +- `_exitGateMet: boolean` +- `_totalFilesModified: number` +- `_totalTasksCompleted: number` + +Methods: +- `processStatusBlockLine(line)` +- `parseStatusBlock(lines)` +- `detectCompletionIndicators(line)` +- `updateCircuitBreaker(hasProgress, testsStatus, status)` +- `resetCircuitBreaker()` +- `circuitBreakerStatus` getter +- `lastStatusBlock` getter +- `cumulativeStats` getter +- `exitGateMet` getter + +Regex patterns to move: +- `RALPH_STATUS_START_PATTERN` through `RALPH_RECOMMENDATION_PATTERN` +- `COMPLETION_INDICATOR_PATTERNS` + +**Interface with parent**: +```typescript +export class RalphStatusParser extends EventEmitter { + processLine(line: string): void { ... } // calls processStatusBlockLine + detectCompletionIndicators + + get circuitBreakerStatus(): CircuitBreakerStatus { ... } + get lastStatusBlock(): RalphStatusBlock | null { ... } + get exitGateMet(): boolean { ... } + get cumulativeStats(): { ... } { ... } + + resetCircuitBreaker(): void { ... } + reset(): void { ... } +} + +// Events: 'statusBlockDetected', 'circuitBreakerUpdate', 'exitGateMet' +``` + +**In `RalphTracker.processLine()`**: +```typescript +// Replace inline status block handling with delegation +this.statusParser.processLine(line); +``` + +### Step 2e: Keep in `ralph-tracker.ts` (~1,800 LOC) + +The core remains tightly coupled and stays together: +- Output parsing pipeline (`processTerminalData`, `processCleanData`, `processLine`) +- Loop state management (`_loopState`, `detectLoopStatus`, `enable/disable/startLoop/stopLoop`) +- Todo management (`_todos`, `detectTodoItems`, `addOrUpdateTodo`, `updateTodoStatus`, `getTodoStats`) +- Completion detection (`detectCompletionPhrase`, `handleCompletionPhrase`, `calculateCompletionConfidence`) +- All-tasks-complete detection (`detectAllTasksComplete`) +- Auto-enable logic (`shouldAutoEnable`) +- Lifecycle (`reset`, `fullReset`, `clear`, `restoreState`, `destroy`) +- Event debouncing and buffering + +The class coordinates the extracted modules via composition: +```typescript +export class RalphTracker extends EventEmitter { + readonly planTracker = new RalphPlanTracker(); + readonly fixPlanWatcher = new RalphFixPlanWatcher(); + readonly stallDetector: RalphStallDetector; + readonly statusParser = new RalphStatusParser(); + + constructor() { + super(); + this.stallDetector = new RalphStallDetector(this.cleanup); + this._wireSubModuleEvents(); + } + + private _wireSubModuleEvents(): void { + // Forward all sub-module events through RalphTracker + // so external consumers don't need to know about the split + for (const event of ['planInitialized', 'planTaskUpdate', ...]) { + this.planTracker.on(event, (...args) => this.emit(event, ...args)); + } + // ...same for statusParser, stallDetector, fixPlanWatcher + } +} +``` + +### Migration Safety + +- All events continue to be emitted from `RalphTracker` (forwarded from sub-modules) +- All public methods stay on `RalphTracker` (delegated to sub-modules) +- External consumers (`session.ts`, `case-routes.ts`) see zero API changes +- New sub-modules are exposed as `readonly` properties for direct access where needed + +--- + +## 3. Split respawn-controller.ts into focused modules + +**Current**: 3,611 lines, single `RespawnController` class with 6 responsibilities. +**Risk**: MEDIUM — health scoring and metrics are cleanly decoupled; detection is tightly coupled. + +### Coupling Analysis Summary + +| Module | Coupling | Extractability | +|--------|----------|----------------| +| Health scoring | NONE | HIGH — pure calculations from metrics | +| Cycle metrics | LOW | HIGH — standalone tracking | +| Adaptive timing | LOW | HIGH — standalone timing adjustments | +| Stuck-state detection | LOW | MEDIUM — needs state + config refs | +| Pattern detection utilities | NONE | HIGH — pure functions | +| State machine + idle detection + AI checkers | HIGH | LOW — deeply entangled | + +### Target Structure + +``` +src/ +├── respawn-controller.ts (~2,200 LOC — state machine, idle detection, +│ AI checkers, terminal handling, hook signals, +│ auto-accept, step execution) +├── respawn-health.ts (~250 LOC — health scoring + recommendations) +├── respawn-metrics.ts (~200 LOC — cycle metrics + aggregate stats) +├── respawn-adaptive-timing.ts (~100 LOC — adaptive timing with percentile calc) +└── respawn-patterns.ts (~50 LOC — terminal pattern detection utilities) +``` + +### Step 3a: Extract `RespawnPatterns` (~50 LOC) + +**Pure utility functions, zero coupling**. + +Move: +- `isCompletionMessage(data): boolean` +- `hasWorkingPattern(data, window): boolean` +- `extractTokenCount(data): number | null` +- `PROMPT_PATTERNS` array +- `WORKING_PATTERNS` array + +```typescript +// src/respawn-patterns.ts +import { TOKEN_PATTERN, SPINNER_PATTERN } from './utils/index.js'; + +export const PROMPT_PATTERNS = ['❯', '>', '$', '%', '#']; + +export const WORKING_PATTERNS = [/* 70+ patterns */]; + +export function isCompletionMessage(data: string): boolean { ... } +export function hasWorkingPattern(data: string, window: string): boolean { ... } +export function extractTokenCount(data: string): number | null { ... } +``` + +**In `RespawnController`**: Import and call: +```typescript +import { isCompletionMessage, hasWorkingPattern, extractTokenCount } from './respawn-patterns.js'; +``` + +### Step 3b: Extract `RespawnAdaptiveTiming` (~100 LOC) + +**Self-contained timing controller**. + +Move properties: +- `timingHistory: TimingHistory` + +Move methods: +- `recordTimingData(idleDetectionMs, cycleDurationMs)` +- `updateAdaptiveTiming()` +- `getTimingHistory()` +- `getAdaptiveCompletionConfirmMs()` + +```typescript +export class RespawnAdaptiveTiming { + private timingHistory: TimingHistory; + + constructor(private config: { adaptiveMinConfirmMs: number; adaptiveMaxConfirmMs: number }) { + this.timingHistory = { recentIdleDetectionMs: [], recentCycleDurationMs: [], ... }; + } + + recordTimingData(idleDetectionMs: number, cycleDurationMs: number): void { ... } + getAdaptiveCompletionConfirmMs(): number { ... } + getTimingHistory(): TimingHistory { ... } + reset(): void { ... } +} +``` + +### Step 3c: Extract `RespawnCycleMetrics` (~200 LOC) + +**Standalone metrics tracker**. + +Move properties: +- `currentCycleMetrics` +- `recentCycleMetrics[]` +- `aggregateMetrics` +- `MAX_CYCLE_METRICS_IN_MEMORY` + +Move methods: +- `startCycleMetrics(idleReason)` +- `recordCycleStep(step)` +- `completeCycleMetrics(outcome, errorMessage?)` +- `updateAggregateMetrics(metrics)` +- `getAggregateMetrics()` +- `getRecentCycleMetrics(limit?)` + +```typescript +export class RespawnCycleMetricsTracker { + private currentCycleMetrics: Partial | null = null; + private recentCycleMetrics: RespawnCycleMetrics[] = []; + private aggregateMetrics: RespawnAggregateMetrics; + + startCycle(sessionId: string, cycleNumber: number, idleReason: string): void { ... } + recordStep(step: string): void { ... } + completeCycle(outcome: CycleOutcome, errorMessage?: string): RespawnCycleMetrics | null { ... } + getAggregate(): RespawnAggregateMetrics { ... } + getRecent(limit?: number): RespawnCycleMetrics[] { ... } + reset(): void { ... } +} +``` + +**Callback**: `completeCycle()` returns the completed metrics so the controller can pass them to `adaptiveTiming.recordTimingData()`. + +### Step 3d: Extract `RespawnHealthCalculator` (~250 LOC) + +**Pure calculation — no state of its own**. + +Move methods: +- `calculateHealthScore()` +- `calculateCycleSuccessScore()` +- `calculateCircuitBreakerScore()` +- `calculateIterationProgressScore()` +- `calculateAiCheckerScore()` +- `calculateStuckRecoveryScore()` +- `generateHealthRecommendations(components)` +- `generateHealthSummary(score, status, components)` +- `shouldSkipClear()` (belongs here since it's a pure calculation on token/config) + +```typescript +export interface HealthInputs { + aggregateMetrics: RespawnAggregateMetrics; + circuitBreakerStatus: CircuitBreakerStatus; + iterationStallMetrics: { stallDurationMs: number; warningMs: number; criticalMs: number } | null; + aiCheckerState: { disabled: boolean; inCooldown: boolean; hasErrors: boolean }; + stuckRecoveryCount: number; + maxStuckRecoveries: number; +} + +export function calculateHealthScore(inputs: HealthInputs): RalphLoopHealthScore { ... } + +export function shouldSkipClear( + lastTokenCount: number, + skipClearThresholdPercent: number, + maxContextTokens: number +): boolean { ... } +``` + +**Made as pure functions** (not a class) since they hold no state. + +### Step 3e: Keep in `respawn-controller.ts` (~2,200 LOC) + +The core state machine, idle detection, and AI checker integration stays: +- State machine transitions (`setState`, `start`, `stop`, `pause`, `resume`) +- Terminal data handling (`handleTerminalData`) +- All 5 idle detection layers + hook signals +- AI checker integration (`tryStartAiCheck`, `startAiCheck`, `startPlanCheck`) +- Auto-accept logic +- Step execution (`sendUpdateDocs`, `sendClear`, `sendInit`, `sendKickstart`) +- Timer management (`startTrackedTimer`, `cancelTrackedTimer`) +- Stuck-state detection and recovery +- Action logging + +The class composes extracted modules: +```typescript +import { RespawnAdaptiveTiming } from './respawn-adaptive-timing.js'; +import { RespawnCycleMetricsTracker } from './respawn-metrics.js'; +import { calculateHealthScore, shouldSkipClear } from './respawn-health.js'; +import { isCompletionMessage, hasWorkingPattern, extractTokenCount } from './respawn-patterns.js'; + +export class RespawnController extends EventEmitter { + private adaptiveTiming: RespawnAdaptiveTiming; + private cycleMetrics: RespawnCycleMetricsTracker; + + calculateHealthScore(): RalphLoopHealthScore { + return calculateHealthScore({ + aggregateMetrics: this.cycleMetrics.getAggregate(), + circuitBreakerStatus: this.session.ralphTracker.circuitBreakerStatus, + iterationStallMetrics: this.session.ralphTracker.getIterationStallMetrics(), + aiCheckerState: { ... }, + stuckRecoveryCount: this.stuckRecoveryCount, + maxStuckRecoveries: this.config.maxStuckRecoveries ?? 3, + }); + } +} +``` + +--- + +## 4. Split session.ts into focused modules + +**Current**: 2,418 lines, single `Session` class. +**Risk**: LOW-MEDIUM — extractable pieces are utility-like with clear boundaries. + +### Coupling Analysis Summary + +| Module | Coupling | Extractability | +|--------|----------|----------------| +| CLI arg builder | NONE | HIGH — pure functions used at spawn time | +| Auto-compact/clear | LOW | HIGH — self-contained automation with config | +| Token tracking | LOW | MEDIUM — reads PTY output, writes state | +| Task description cache | LOW | HIGH — separate LRU cache | +| PTY + mux lifecycle | HIGH | KEEP — core of the class | +| Tracker integration | HIGH | KEEP — event forwarding plumbing | + +### Target Structure + +``` +src/ +├── session.ts (~1,600 LOC — PTY lifecycle, terminal I/O, +│ tracker integration, output processing, +│ token tracking, state management) +├── session-cli-builder.ts (~250 LOC — Claude/OpenCode CLI arg construction) +├── session-auto-ops.ts (~300 LOC — auto-compact, auto-clear automation) +└── session-task-cache.ts (~100 LOC — task description LRU cache) +``` + +### Step 4a: Extract `SessionCliBuilder` (~250 LOC) + +**Pure functions — zero coupling to Session instance**. + +Move: +- `buildClaudeArgs()` logic (currently inlined in `startInteractive` and `runPrompt`) +- `buildOpenCodeArgs()` logic +- Model mapping constants +- Claude mode to flag mapping +- Environment variable construction + +```typescript +// src/session-cli-builder.ts +export interface CliBuilderConfig { + claudeMode: ClaudeMode; + model?: string; + workingDir: string; + sessionId: string; + niceConfig?: NiceConfig; + isOpenCode?: boolean; + openCodeConfig?: OpenCodeConfig; +} + +export function buildInteractiveArgs(config: CliBuilderConfig): string[] { ... } +export function buildPromptArgs(config: CliBuilderConfig, prompt: string): string[] { ... } +export function buildShellArgs(shell?: string): string[] { ... } +export function buildClaudeEnv(config: CliBuilderConfig): Record { ... } +``` + +### Step 4b: Extract `SessionAutoOps` (~300 LOC) + +**Self-contained automation with config-based thresholds**. + +Move properties: +- `_autoCompactThreshold` +- `_autoClearThreshold` +- `_isAutoCompacting` +- `_isAutoClearing` +- `_autoCompactCount` +- `_autoClearCount` +- `_lastAutoCompactTime` +- `_lastAutoClearTime` + +Move methods: +- `checkAutoCompact(tokenCount)` +- `performAutoCompact()` +- `checkAutoClear(tokenCount)` +- `performAutoClear()` +- Auto-compact/clear threshold configuration + +```typescript +export class SessionAutoOps extends EventEmitter { + constructor( + private writeCommand: (command: string) => Promise, + private getTokenCount: () => number, + config: { compactThreshold: number; clearThreshold: number } + ) { ... } + + /** Called after token count updates. Checks thresholds and triggers if needed. */ + checkThresholds(tokenCount: number): void { ... } + + updateConfig(config: { compactThreshold?: number; clearThreshold?: number }): void { ... } + getStats(): { autoCompactCount: number; autoClearCount: number; ... } { ... } +} + +// Events: 'autoCompact', 'autoClear' +``` + +**In `Session`**: Compose and wire: +```typescript +private autoOps = new SessionAutoOps( + (cmd) => this.writeViaMux(cmd), + () => this._state.tokenCount, + { compactThreshold: 110_000, clearThreshold: 140_000 } +); +``` + +### Step 4c: Extract `SessionTaskCache` (~100 LOC) + +**Isolated LRU cache for task descriptions**. + +Move: +- `_taskDescriptionCache: LRUMap` +- `_taskDescriptionMaxAge` +- `findTaskDescriptionNear(lineNumber)` +- `cacheTaskDescription(lineNumber, description)` + +```typescript +export class SessionTaskCache { + private cache: LRUMap; + private maxAgeMs: number; + + constructor(maxSize: number = 50, maxAgeMs: number = 30_000) { ... } + + find(lineNumber: number, searchRadius: number = 50): string | null { ... } + add(lineNumber: number, description: string): void { ... } + clear(): void { ... } +} +``` + +### Step 4d: Keep in `session.ts` (~1,600 LOC) + +The core stays together: +- PTY process management (`spawn`, `kill`, `resize`, `writeViaMux`) +- Data streaming pipeline (PTY → buffer → ANSI strip → JSON parse → events) +- Tracker initialization and event forwarding (RalphTracker, BashToolParser, TaskTracker) +- Output processing (message extraction, completion detection) +- Token tracking (status line parsing) +- State management (`toState()`, `updateState()`) +- Session lifecycle (`startInteractive`, `startShell`, `runPrompt`) +- CLI info detection (version, model, account) + +--- + +## 5. Execution Order & Dependencies + +Execute in this order to minimize risk. Each step is independently deployable. + +``` +Step 1: types.ts split + ↓ (no runtime change, just file reorganization) +Step 2a: RalphPlanTracker extraction + ↓ (independent of types split) +Step 2b: RalphFixPlanWatcher extraction +Step 2c: RalphStallDetector extraction +Step 2d: RalphStatusParser extraction + ↓ (ralph-tracker.ts now ~1,800 LOC) +Step 3a: RespawnPatterns extraction +Step 3b: RespawnAdaptiveTiming extraction +Step 3c: RespawnCycleMetrics extraction +Step 3d: RespawnHealthCalculator extraction + ↓ (respawn-controller.ts now ~2,200 LOC) +Step 4a: SessionCliBuilder extraction +Step 4b: SessionAutoOps extraction +Step 4c: SessionTaskCache extraction + ↓ (session.ts now ~1,600 LOC) +``` + +**Parallelization**: Steps 1, 2a-2d, 3a-3d, and 4a-4c can be done by separate agents in parallel since they touch different files. However, within each group, sequential execution is safer. + +### Risk Mitigation + +- **Barrel exports**: Every split uses delegation + barrel re-export so external consumers see zero API changes +- **Event forwarding**: Sub-modules emit events, parent class forwards them — no event contract changes +- **Incremental**: Each step can be verified independently with `tsc --noEmit` + `npm run lint` +- **No test changes needed**: External API stays identical; existing tests continue to pass + +--- + +## 6. Validation Checklist + +After each step, verify: + +- [ ] `tsc --noEmit` passes (no type errors) +- [ ] `npm run lint` passes (no unused imports, etc.) +- [ ] `npm run format:check` passes +- [ ] `npx vitest run test/respawn-controller.test.ts` passes (for respawn splits) +- [ ] `npx vitest run test/ralph-tracker.test.ts` passes (for ralph splits) +- [ ] `npx vitest run test/session-manager.test.ts` passes (for session splits) +- [ ] Dev server starts: `npx tsx src/index.ts web` +- [ ] Existing sessions work (create, interact, delete) +- [ ] Respawn cycle works (enable respawn, verify idle detection fires) +- [ ] No new circular dependencies: `npx madge --circular src/` + +### Size Targets + +| File | Before | After | +|------|--------|-------| +| `src/types.ts` | 1,443 LOC | 1 LOC (re-export barrel) | +| `src/ralph-tracker.ts` | 3,868 LOC | ~1,800 LOC | +| `src/respawn-controller.ts` | 3,611 LOC | ~2,200 LOC | +| `src/session.ts` | 2,418 LOC | ~1,600 LOC | +| **Total new files** | — | 12 files | +| **Net LOC change** | — | ~0 (refactor only) | diff --git a/src/ralph-fix-plan-watcher.ts b/src/ralph-fix-plan-watcher.ts new file mode 100644 index 00000000..17396fde --- /dev/null +++ b/src/ralph-fix-plan-watcher.ts @@ -0,0 +1,366 @@ +/** + * @fileoverview RalphFixPlanWatcher - Watches @fix_plan.md for changes + * + * Monitors the @fix_plan.md file in the session's working directory + * for changes, parsing todo items from the markdown format. + * + * Extracted from ralph-tracker.ts as part of domain splitting. + * + * @module ralph-fix-plan-watcher + */ + +import { EventEmitter } from 'node:events'; +import { readFile } from 'node:fs/promises'; +import { existsSync, FSWatcher, watch as fsWatch } from 'node:fs'; +import { join } from 'node:path'; +import type { RalphTodoStatus, RalphTodoPriority, RalphTodoItem } from './types.js'; + +// ========== @fix_plan.md Generation & Import Utility Functions ========== + +/** + * Generate @fix_plan.md content from todo items. + * Groups todos by priority and status. + * + * @param todos - Array of todo items + * @returns Markdown content for @fix_plan.md + */ +export function generateFixPlanMarkdown(todos: RalphTodoItem[]): string { + const lines: string[] = ['# Fix Plan', '']; + + // Group by priority + const p0: RalphTodoItem[] = []; + const p1: RalphTodoItem[] = []; + const p2: RalphTodoItem[] = []; + const noPriority: RalphTodoItem[] = []; + const completed: RalphTodoItem[] = []; + + for (const todo of todos) { + if (todo.status === 'completed') { + completed.push(todo); + } else if (todo.priority === 'P0') { + p0.push(todo); + } else if (todo.priority === 'P1') { + p1.push(todo); + } else if (todo.priority === 'P2') { + p2.push(todo); + } else { + noPriority.push(todo); + } + } + + // High Priority (P0) + if (p0.length > 0) { + lines.push('## High Priority (P0)'); + for (const todo of p0) { + const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]'; + lines.push(`- ${checkbox} ${todo.content}`); + } + lines.push(''); + } + + // Standard (P1) + if (p1.length > 0) { + lines.push('## Standard (P1)'); + for (const todo of p1) { + const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]'; + lines.push(`- ${checkbox} ${todo.content}`); + } + lines.push(''); + } + + // Nice to Have (P2) + if (p2.length > 0) { + lines.push('## Nice to Have (P2)'); + for (const todo of p2) { + const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]'; + lines.push(`- ${checkbox} ${todo.content}`); + } + lines.push(''); + } + + // Tasks (no priority) + if (noPriority.length > 0) { + lines.push('## Tasks'); + for (const todo of noPriority) { + const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]'; + lines.push(`- ${checkbox} ${todo.content}`); + } + lines.push(''); + } + + // Completed + if (completed.length > 0) { + lines.push('## Completed'); + for (const todo of completed) { + lines.push(`- [x] ${todo.content}`); + } + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * Parse @fix_plan.md content and return parsed todo items. + * + * @param content - Markdown content from @fix_plan.md + * @param parsePriority - Function to parse priority from content text + * @param generateTodoId - Function to generate stable todo ID from content + * @returns Array of parsed todo items + */ +export function importFixPlanMarkdown( + content: string, + parsePriority: (content: string) => RalphTodoPriority, + generateTodoId: (content: string) => string +): RalphTodoItem[] { + const lines = content.split('\n'); + const newTodos: RalphTodoItem[] = []; + let currentPriority: RalphTodoPriority = null; + + // Patterns for section headers + const p0HeaderPattern = /^##\s*(High Priority|Critical|P0)/i; + const p1HeaderPattern = /^##\s*(Standard|P1|Medium Priority)/i; + const p2HeaderPattern = /^##\s*(Nice to Have|P2|Low Priority)/i; + const completedHeaderPattern = /^##\s*Completed/i; + const tasksHeaderPattern = /^##\s*Tasks/i; + + // Pattern for todo items + const todoPattern = /^-\s*\[([ x-])\]\s*(.+)$/; + + let inCompletedSection = false; + + for (const line of lines) { + const trimmed = line.trim(); + + // Check for section headers + if (p0HeaderPattern.test(trimmed)) { + currentPriority = 'P0'; + inCompletedSection = false; + continue; + } + if (p1HeaderPattern.test(trimmed)) { + currentPriority = 'P1'; + inCompletedSection = false; + continue; + } + if (p2HeaderPattern.test(trimmed)) { + currentPriority = 'P2'; + inCompletedSection = false; + continue; + } + if (completedHeaderPattern.test(trimmed)) { + inCompletedSection = true; + continue; + } + if (tasksHeaderPattern.test(trimmed)) { + currentPriority = null; + inCompletedSection = false; + continue; + } + + // Parse todo item + const match = trimmed.match(todoPattern); + if (match) { + const [, checkboxState, todoContent] = match; + let status: RalphTodoStatus; + + if (inCompletedSection || checkboxState === 'x' || checkboxState === 'X') { + status = 'completed'; + } else if (checkboxState === '-') { + status = 'in_progress'; + } else { + status = 'pending'; + } + + // Parse priority from content if not in a priority section + const parsedPriority = inCompletedSection ? null : currentPriority || parsePriority(todoContent); + + const id = generateTodoId(todoContent); + newTodos.push({ + id, + content: todoContent.trim(), + status, + detectedAt: Date.now(), + priority: parsedPriority, + }); + } + } + + return newTodos; +} + +/** + * RalphFixPlanWatcher - Watches @fix_plan.md for changes. + * + * Events emitted: + * - `todosLoaded` - Emits parsed todo items when @fix_plan.md is loaded/changed + * - `enabled` - Emits when tracker should be auto-enabled (todos loaded from file) + */ +export class RalphFixPlanWatcher extends EventEmitter { + /** Working directory for @fix_plan.md watching */ + private _workingDir: string | null = null; + + /** Path to the @fix_plan.md file being watched */ + private _fixPlanPath: string | null = null; + + /** File watcher for @fix_plan.md */ + private _fixPlanWatcher: FSWatcher | null = null; + + /** Error handler for FSWatcher (stored for cleanup to prevent memory leak) */ + private _fixPlanWatcherErrorHandler: ((err: Error) => void) | null = null; + + /** Debounce timer for file change events */ + private _fixPlanReloadTimer: NodeJS.Timeout | null = null; + + /** Priority parser injected from parent (for importFixPlanMarkdown) */ + private _parsePriority: (content: string) => RalphTodoPriority; + + /** Todo ID generator injected from parent */ + private _generateTodoId: (content: string) => string; + + constructor(parsePriority: (content: string) => RalphTodoPriority, generateTodoId: (content: string) => string) { + super(); + this._parsePriority = parsePriority; + this._generateTodoId = generateTodoId; + } + + /** + * When @fix_plan.md is active, treat it as the source of truth for todo status. + * This prevents output-based detection from overriding file-based status. + */ + get isFileAuthoritative(): boolean { + return this._fixPlanPath !== null; + } + + /** + * Set the working directory and start watching @fix_plan.md. + * Automatically loads existing @fix_plan.md if present. + * @param workingDir - The session's working directory + */ + setWorkingDir(workingDir: string): void { + this._workingDir = workingDir; + this._fixPlanPath = join(workingDir, '@fix_plan.md'); + + // Try to load existing @fix_plan.md + this.loadFixPlanFromDisk(); + + // Start watching for changes + this.startWatchingFixPlan(); + } + + /** + * Load @fix_plan.md from disk if it exists. + * Called on initialization and when file changes are detected. + */ + async loadFixPlanFromDisk(): Promise { + if (!this._fixPlanPath) return 0; + + try { + if (!existsSync(this._fixPlanPath)) { + return 0; + } + + const content = await readFile(this._fixPlanPath, 'utf-8'); + const todos = importFixPlanMarkdown(content, this._parsePriority, this._generateTodoId); + + if (todos.length > 0) { + this.emit('todosLoaded', todos); + console.log(`[RalphFixPlanWatcher] Loaded ${todos.length} todos from @fix_plan.md`); + } + + return todos.length; + } catch (err) { + // File doesn't exist or can't be read - that's OK + console.log(`[RalphFixPlanWatcher] Could not load @fix_plan.md: ${err}`); + return 0; + } + } + + /** + * Start watching @fix_plan.md for changes. + * Reloads todos when the file is modified. + */ + private startWatchingFixPlan(): void { + if (!this._fixPlanPath || !this._workingDir) return; + + // Stop existing watcher if any + this.stopWatchingFixPlan(); + + try { + // Only watch if the file exists + if (!existsSync(this._fixPlanPath)) { + // Watch the directory instead for file creation + this._fixPlanWatcher = fsWatch(this._workingDir, (_eventType, filename) => { + if (filename === '@fix_plan.md') { + this.handleFixPlanChange(); + } + }); + } else { + // Watch the file directly + this._fixPlanWatcher = fsWatch(this._fixPlanPath, () => { + this.handleFixPlanChange(); + }); + } + // Add error handler to prevent unhandled errors and clean up on failure + // Store handler reference for proper cleanup in stopWatchingFixPlan() + if (this._fixPlanWatcher) { + this._fixPlanWatcherErrorHandler = (err: Error) => { + console.log(`[RalphFixPlanWatcher] FSWatcher error for @fix_plan.md: ${err.message}`); + this.stopWatchingFixPlan(); + }; + this._fixPlanWatcher.on('error', this._fixPlanWatcherErrorHandler); + } + } catch (err) { + console.log(`[RalphFixPlanWatcher] Could not watch @fix_plan.md: ${err}`); + } + } + + /** + * Handle @fix_plan.md file change with debouncing. + */ + private handleFixPlanChange(): void { + // Debounce rapid changes (e.g., multiple writes) + if (this._fixPlanReloadTimer) { + clearTimeout(this._fixPlanReloadTimer); + } + + this._fixPlanReloadTimer = setTimeout(() => { + this._fixPlanReloadTimer = null; + this.loadFixPlanFromDisk(); + }, 500); // 500ms debounce + } + + /** + * Stop watching @fix_plan.md. + */ + stopWatchingFixPlan(): void { + if (this._fixPlanWatcher) { + // Remove error handler before closing to prevent memory leak + if (this._fixPlanWatcherErrorHandler) { + this._fixPlanWatcher.off('error', this._fixPlanWatcherErrorHandler); + this._fixPlanWatcherErrorHandler = null; + } + this._fixPlanWatcher.close(); + this._fixPlanWatcher = null; + } + if (this._fixPlanReloadTimer) { + clearTimeout(this._fixPlanReloadTimer); + this._fixPlanReloadTimer = null; + } + } + + /** + * Stop watching and clean up all resources. + */ + stop(): void { + this.stopWatchingFixPlan(); + } + + /** + * Clean up all resources. + */ + destroy(): void { + this.stop(); + this.removeAllListeners(); + } +} diff --git a/src/ralph-plan-tracker.ts b/src/ralph-plan-tracker.ts new file mode 100644 index 00000000..d4cf2591 --- /dev/null +++ b/src/ralph-plan-tracker.ts @@ -0,0 +1,477 @@ +/** + * @fileoverview RalphPlanTracker - Enhanced plan task management + * + * Manages plan tasks with verification criteria, dependencies, + * execution tracking, TDD workflow support, and plan versioning. + * + * Extracted from ralph-tracker.ts as part of domain splitting. + * + * @module ralph-plan-tracker + */ + +import { EventEmitter } from 'node:events'; +import type { PlanTaskStatus, TddPhase } from './types.js'; + +// ========== Enhanced Plan Task Interface ========== + +/** + * Enhanced plan task with verification criteria, dependencies, and execution tracking. + * Supports TDD workflow, failure tracking, and plan versioning. + */ +export interface EnhancedPlanTask { + /** Unique identifier (e.g., "P0-001") */ + id: string; + /** Task description */ + content: string; + /** Criticality level */ + priority: 'P0' | 'P1' | 'P2' | null; + /** How to verify completion */ + verificationCriteria?: string; + /** Command to run for verification */ + testCommand?: string; + /** IDs of tasks that must complete first */ + dependencies: string[]; + /** Current execution status */ + status: PlanTaskStatus; + /** How many times attempted */ + attempts: number; + /** Most recent failure reason */ + lastError?: string; + /** Timestamp of completion */ + completedAt?: number; + /** Plan version this belongs to */ + version: number; + /** TDD phase category */ + tddPhase?: TddPhase; + /** ID of paired test/impl task */ + pairedWith?: string; + /** Estimated complexity */ + complexity?: 'low' | 'medium' | 'high'; + /** Checklist items for review tasks (tddPhase: 'review') */ + reviewChecklist?: string[]; +} + +/** Checkpoint review data */ +export interface CheckpointReview { + iteration: number; + timestamp: number; + summary: { + total: number; + completed: number; + failed: number; + blocked: number; + pending: number; + inProgress: number; + }; + stuckTasks: Array<{ + id: string; + content: string; + attempts: number; + lastError?: string; + }>; + recommendations: string[]; +} + +const MAX_PLAN_HISTORY = 10; + +/** + * RalphPlanTracker - Manages enhanced plan tasks with versioning and checkpoints. + * + * Events emitted: + * - `planInitialized` - When a new plan is initialized + * - `planTaskUpdate` - When a plan task is updated + * - `taskBlocked` - When a task becomes blocked after too many failures + * - `taskUnblocked` - When a task's dependencies are all met + * - `planCheckpoint` - When a checkpoint review is triggered + * - `planTaskAdded` - When a new task is added to the plan + * - `planRollback` - When the plan is rolled back to a previous version + */ +export class RalphPlanTracker extends EventEmitter { + /** Current version of the plan (incremented on changes) */ + private _planVersion: number = 1; + + /** History of plan versions for rollback support */ + private _planHistory: Array<{ + version: number; + timestamp: number; + tasks: Map; + summary: string; + }> = []; + + /** Enhanced plan tasks with execution tracking */ + private _planTasks: Map = new Map(); + + /** Checkpoint intervals (iterations at which to trigger review) */ + private _checkpointIterations: number[] = [5, 10, 20, 30, 50, 75, 100]; + + /** Last checkpoint iteration */ + private _lastCheckpointIteration: number = 0; + + /** Current cycle count (fed by parent via notifyCycleCount) */ + private _cycleCount: number = 0; + + constructor() { + super(); + } + + /** + * Notify the plan tracker of the current cycle count. + * Called by parent when iteration changes (for checkpoint detection). + */ + notifyCycleCount(cycleCount: number): void { + this._cycleCount = cycleCount; + } + + /** + * Initialize plan tasks from generated plan items. + * Called when wizard generates a new plan. + */ + initializePlanTasks( + items: Array<{ + id?: string; + content: string; + priority?: 'P0' | 'P1' | 'P2' | null; + verificationCriteria?: string; + testCommand?: string; + dependencies?: string[]; + tddPhase?: TddPhase; + pairedWith?: string; + complexity?: 'low' | 'medium' | 'high'; + }> + ): void { + // Save current plan to history before replacing + if (this._planTasks.size > 0) { + this._savePlanToHistory('Plan replaced with new generation'); + } + + // Clear and rebuild + this._planTasks.clear(); + this._planVersion++; + + items.forEach((item, idx) => { + const id = item.id || `task-${idx}`; + const task: EnhancedPlanTask = { + id, + content: item.content, + priority: item.priority || null, + verificationCriteria: item.verificationCriteria, + testCommand: item.testCommand, + dependencies: item.dependencies || [], + status: 'pending', + attempts: 0, + version: this._planVersion, + tddPhase: item.tddPhase, + pairedWith: item.pairedWith, + complexity: item.complexity, + }; + this._planTasks.set(id, task); + }); + + this.emit('planInitialized', { version: this._planVersion, taskCount: this._planTasks.size }); + } + + /** + * Update a specific plan task's status, attempts, or error. + */ + updatePlanTask( + taskId: string, + update: { + status?: PlanTaskStatus; + error?: string; + incrementAttempts?: boolean; + } + ): { success: boolean; task?: EnhancedPlanTask; error?: string } { + const task = this._planTasks.get(taskId); + if (!task) { + return { success: false, error: 'Task not found' }; + } + + if (update.status) { + task.status = update.status; + if (update.status === 'completed') { + task.completedAt = Date.now(); + } + } + + if (update.error) { + task.lastError = update.error; + } + + if (update.incrementAttempts) { + task.attempts++; + + // After 3 failed attempts, mark as blocked and emit warning + if (task.attempts >= 3 && task.status === 'failed') { + task.status = 'blocked'; + this.emit('taskBlocked', { + taskId, + content: task.content, + attempts: task.attempts, + lastError: task.lastError, + }); + } + } + + // Update blocked tasks when a dependency completes + if (update.status === 'completed') { + this._unblockDependentTasks(taskId); + } + + // Check for checkpoint + this._checkForCheckpoint(); + + this.emit('planTaskUpdate', { taskId, task }); + return { success: true, task }; + } + + /** + * Add a new task to the plan (for runtime adaptation). + */ + addPlanTask(task: { + content: string; + priority?: 'P0' | 'P1' | 'P2'; + verificationCriteria?: string; + dependencies?: string[]; + insertAfter?: string; + }): { task: EnhancedPlanTask } { + // Generate unique ID + const existingIds = Array.from(this._planTasks.keys()); + const prefix = task.priority || 'P1'; + let counter = existingIds.filter((id) => id.startsWith(prefix)).length + 1; + let id = `${prefix}-${String(counter).padStart(3, '0')}`; + while (this._planTasks.has(id)) { + counter++; + id = `${prefix}-${String(counter).padStart(3, '0')}`; + } + + const newTask: EnhancedPlanTask = { + id, + content: task.content, + priority: task.priority || null, + verificationCriteria: task.verificationCriteria || 'Task completed successfully', + dependencies: task.dependencies || [], + status: 'pending', + attempts: 0, + version: this._planVersion, + }; + + this._planTasks.set(id, newTask); + this.emit('planTaskAdded', { task: newTask }); + + return { task: newTask }; + } + + /** + * Get all plan tasks. + */ + getPlanTasks(): EnhancedPlanTask[] { + return Array.from(this._planTasks.values()); + } + + /** + * Generate a checkpoint review summarizing plan progress and stuck tasks. + */ + generateCheckpointReview(): CheckpointReview { + const tasks = Array.from(this._planTasks.values()); + + const summary = { + total: tasks.length, + completed: tasks.filter((t) => t.status === 'completed').length, + failed: tasks.filter((t) => t.status === 'failed').length, + blocked: tasks.filter((t) => t.status === 'blocked').length, + pending: tasks.filter((t) => t.status === 'pending').length, + inProgress: tasks.filter((t) => t.status === 'in_progress').length, + }; + + // Find stuck tasks (3+ attempts or blocked) + const stuckTasks = tasks + .filter((t) => t.attempts >= 3 || t.status === 'blocked') + .map((t) => ({ + id: t.id, + content: t.content, + attempts: t.attempts, + lastError: t.lastError, + })); + + // Generate recommendations + const recommendations: string[] = []; + + if (stuckTasks.length > 0) { + recommendations.push(`${stuckTasks.length} task(s) are stuck. Consider breaking them into smaller steps.`); + } + + if (summary.failed > summary.completed && summary.total > 5) { + recommendations.push('More tasks have failed than completed. Review approach and consider plan adjustment.'); + } + + const progressPercent = summary.total > 0 ? Math.round((summary.completed / summary.total) * 100) : 0; + if (progressPercent < 20 && this._cycleCount > 10) { + recommendations.push('Progress is slow. Consider simplifying tasks or reviewing dependencies.'); + } + + if (summary.total > 0 && summary.blocked > summary.total / 3) { + recommendations.push('Many tasks are blocked. Review dependency chain for bottlenecks.'); + } + + return { + iteration: this._cycleCount, + timestamp: Date.now(), + summary, + stuckTasks, + recommendations, + }; + } + + /** + * Get plan version history. + */ + getPlanHistory(): Array<{ + version: number; + timestamp: number; + summary: string; + stats: { total: number; completed: number; failed: number }; + }> { + return this._planHistory.map((h) => { + const tasks = Array.from(h.tasks.values()); + return { + version: h.version, + timestamp: h.timestamp, + summary: h.summary, + stats: { + total: tasks.length, + completed: tasks.filter((t) => t.status === 'completed').length, + failed: tasks.filter((t) => t.status === 'failed').length, + }, + }; + }); + } + + /** + * Rollback to a previous plan version. + */ + rollbackToVersion(version: number): { + success: boolean; + plan?: EnhancedPlanTask[]; + error?: string; + } { + const historyEntry = this._planHistory.find((h) => h.version === version); + if (!historyEntry) { + return { success: false, error: `Version ${version} not found in history` }; + } + + // Save current state first + this._savePlanToHistory(`Rolled back from v${this._planVersion} to v${version}`); + + // Restore the historical version + this._planTasks.clear(); + for (const [id, task] of historyEntry.tasks) { + // Reset execution state for retry + this._planTasks.set(id, { + ...task, + status: task.status === 'completed' ? 'completed' : 'pending', + attempts: task.status === 'completed' ? task.attempts : 0, + lastError: undefined, + }); + } + + this._planVersion++; + this.emit('planRollback', { version, newVersion: this._planVersion }); + + return { success: true, plan: Array.from(this._planTasks.values()) }; + } + + /** + * Check if checkpoint review is due for current iteration. + */ + isCheckpointDue(): boolean { + return this._checkpointIterations.includes(this._cycleCount) && this._cycleCount > this._lastCheckpointIteration; + } + + /** + * Get current plan version. + */ + get planVersion(): number { + return this._planVersion; + } + + /** + * Reset plan state (soft reset - keeps version history). + */ + reset(): void { + // Don't clear history or version on soft reset + } + + /** + * Full reset - clears all plan state. + */ + fullReset(): void { + this._planTasks.clear(); + this._planHistory.length = 0; + this._planVersion = 1; + this._lastCheckpointIteration = 0; + this._cycleCount = 0; + } + + /** + * Clean up all resources. + */ + destroy(): void { + this._planTasks.clear(); + this._planHistory.length = 0; + this.removeAllListeners(); + } + + /** + * Unblock tasks that were waiting on a completed dependency. + */ + private _unblockDependentTasks(completedTaskId: string): void { + for (const [_, task] of this._planTasks) { + if (task.dependencies.includes(completedTaskId)) { + // Check if all dependencies are now complete + const allDepsComplete = task.dependencies.every((depId) => { + const dep = this._planTasks.get(depId); + return dep && dep.status === 'completed'; + }); + + if (allDepsComplete && task.status === 'blocked') { + task.status = 'pending'; + this.emit('taskUnblocked', { taskId: task.id }); + } + } + } + } + + /** + * Check if current iteration is a checkpoint and emit review if so. + */ + private _checkForCheckpoint(): void { + if (this._checkpointIterations.includes(this._cycleCount) && this._cycleCount > this._lastCheckpointIteration) { + this._lastCheckpointIteration = this._cycleCount; + const checkpoint = this.generateCheckpointReview(); + this.emit('planCheckpoint', checkpoint); + } + } + + /** + * Save current plan state to history. + */ + private _savePlanToHistory(summary: string): void { + // Clone current tasks + const tasksCopy = new Map(); + for (const [id, task] of this._planTasks) { + tasksCopy.set(id, { ...task }); + } + + this._planHistory.push({ + version: this._planVersion, + timestamp: Date.now(), + tasks: tasksCopy, + summary, + }); + + // Limit history size + if (this._planHistory.length > MAX_PLAN_HISTORY) { + this._planHistory.shift(); + } + } +} diff --git a/src/ralph-stall-detector.ts b/src/ralph-stall-detector.ts new file mode 100644 index 00000000..cc1f0963 --- /dev/null +++ b/src/ralph-stall-detector.ts @@ -0,0 +1,166 @@ +/** + * @fileoverview RalphStallDetector - Iteration stall detection + * + * Monitors iteration progress and emits warnings when the loop + * appears to be stalled (no iteration changes for extended periods). + * + * Extracted from ralph-tracker.ts as part of domain splitting. + * + * @module ralph-stall-detector + */ + +import { EventEmitter } from 'node:events'; + +/** + * RalphStallDetector - Detects iteration stalls in the Ralph loop. + * + * Events emitted: + * - `iterationStallWarning` - When iteration hasn't changed for warning threshold + * - `iterationStallCritical` - When iteration hasn't changed for critical threshold + */ +export class RalphStallDetector extends EventEmitter { + /** Timestamp when iteration count last changed */ + private _lastIterationChangeTime: number = 0; + + /** Last observed iteration count for stall detection */ + private _lastObservedIteration: number = 0; + + /** Timer for iteration stall detection */ + private _iterationStallTimer: NodeJS.Timeout | null = null; + + /** Iteration stall warning threshold (ms) - default 10 minutes */ + private _iterationStallWarningMs: number = 10 * 60 * 1000; + + /** Iteration stall critical threshold (ms) - default 20 minutes */ + private _iterationStallCriticalMs: number = 20 * 60 * 1000; + + /** Whether stall warning has been emitted */ + private _iterationStallWarned: boolean = false; + + /** Whether the loop is currently active */ + private _loopActive: boolean = false; + + constructor() { + super(); + this._lastIterationChangeTime = Date.now(); + } + + /** + * Start iteration stall detection timer. + * Should be called when the loop becomes active. + */ + startIterationStallDetection(): void { + this.stopIterationStallDetection(); + this._lastIterationChangeTime = Date.now(); + this._iterationStallWarned = false; + + // Check every minute + this._iterationStallTimer = setInterval(() => { + this.checkIterationStall(); + }, 60 * 1000); + } + + /** + * Stop iteration stall detection timer. + */ + stopIterationStallDetection(): void { + if (this._iterationStallTimer) { + clearInterval(this._iterationStallTimer); + this._iterationStallTimer = null; + } + } + + /** + * Notify the detector that the iteration has changed. + * Resets stall tracking state. + */ + notifyIterationChanged(iteration: number): void { + this._lastIterationChangeTime = Date.now(); + this._lastObservedIteration = iteration; + this._iterationStallWarned = false; + } + + /** + * Set whether the loop is currently active. + * Stall detection only fires when loop is active. + */ + setLoopActive(active: boolean): void { + this._loopActive = active; + } + + /** + * Check for iteration stall and emit appropriate events. + */ + private checkIterationStall(): void { + if (!this._loopActive) return; + + const stallDurationMs = Date.now() - this._lastIterationChangeTime; + + // Critical stall (longer duration) + if (stallDurationMs >= this._iterationStallCriticalMs) { + this.emit('iterationStallCritical', { + iteration: this._lastObservedIteration, + stallDurationMs, + }); + return; + } + + // Warning stall + if (stallDurationMs >= this._iterationStallWarningMs && !this._iterationStallWarned) { + this._iterationStallWarned = true; + this.emit('iterationStallWarning', { + iteration: this._lastObservedIteration, + stallDurationMs, + }); + } + } + + /** + * Get iteration stall metrics for monitoring. + */ + getIterationStallMetrics(): { + lastIterationChangeTime: number; + stallDurationMs: number; + warningThresholdMs: number; + criticalThresholdMs: number; + isWarned: boolean; + currentIteration: number; + } { + return { + lastIterationChangeTime: this._lastIterationChangeTime, + stallDurationMs: Date.now() - this._lastIterationChangeTime, + warningThresholdMs: this._iterationStallWarningMs, + criticalThresholdMs: this._iterationStallCriticalMs, + isWarned: this._iterationStallWarned, + currentIteration: this._lastObservedIteration, + }; + } + + /** + * Configure iteration stall thresholds. + * @param warningMs - Warning threshold in milliseconds + * @param criticalMs - Critical threshold in milliseconds + */ + configureIterationStallThresholds(warningMs: number, criticalMs: number): void { + this._iterationStallWarningMs = warningMs; + this._iterationStallCriticalMs = criticalMs; + } + + /** + * Reset stall detector state. + */ + reset(): void { + this._lastIterationChangeTime = Date.now(); + this._lastObservedIteration = 0; + this._iterationStallWarned = false; + this._loopActive = false; + } + + /** + * Clean up all resources. + */ + destroy(): void { + this.stopIterationStallDetection(); + this.removeAllListeners(); + } +} diff --git a/src/ralph-status-parser.ts b/src/ralph-status-parser.ts new file mode 100644 index 00000000..d4993fd9 --- /dev/null +++ b/src/ralph-status-parser.ts @@ -0,0 +1,552 @@ +/** + * @fileoverview RalphStatusParser - RALPH_STATUS block parsing and circuit breaker + * + * Parses structured RALPH_STATUS blocks from Claude Code output + * and manages the circuit breaker state machine. + * + * Extracted from ralph-tracker.ts as part of domain splitting. + * + * @module ralph-status-parser + */ + +import { EventEmitter } from 'node:events'; +import type { + RalphStatusBlock, + RalphStatusValue, + RalphTestsStatus, + RalphWorkType, + CircuitBreakerStatus, +} from './types.js'; +import { createInitialCircuitBreakerStatus } from './types.js'; + +// ---------- 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, +]; + +/** + * RalphStatusParser - Parses RALPH_STATUS blocks and manages circuit breaker. + * + * Events emitted: + * - `statusBlockDetected` - When a complete RALPH_STATUS block is parsed + * - `circuitBreakerUpdate` - When circuit breaker state changes + * - `exitGateMet` - When dual-condition exit gate is met + */ +export class RalphStatusParser extends EventEmitter { + /** 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; + + /** Current cycle count (fed by parent) */ + private _cycleCount: number = 0; + + constructor() { + super(); + this._circuitBreaker = createInitialCircuitBreakerStatus(); + } + + /** + * Process a line for status block detection and completion indicators. + * Main entry point - call this for each trimmed line. + */ + processLine(line: string): void { + this.processStatusBlockLine(line); + this.detectCompletionIndicators(line); + } + + /** + * Set the current cycle count (fed by parent for circuit breaker tracking). + */ + setCycleCount(cycleCount: number): void { + this._cycleCount = cycleCount; + } + + /** + * Notify of iteration progress (for circuit breaker reset on progress). + * Called by parent when iteration count changes. + */ + notifyIterationProgress(currentIteration: number): void { + if ( + this._circuitBreaker.state === 'HALF_OPEN' || + this._circuitBreaker.consecutiveNoProgress > 0 || + this._circuitBreaker.consecutiveSameError > 0 || + this._circuitBreaker.consecutiveTestsFailure > 0 + ) { + this._circuitBreaker.consecutiveNoProgress = 0; + this._circuitBreaker.consecutiveSameError = 0; + this._circuitBreaker.lastProgressIteration = currentIteration; + if (this._circuitBreaker.state === 'HALF_OPEN') { + this._circuitBreaker.state = 'CLOSED'; + this._circuitBreaker.reason = 'Iteration progress detected'; + this._circuitBreaker.reasonCode = 'progress_detected'; + 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; + } + + /** + * 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 }); + } + + /** + * Reset status parser state (soft reset). + * Clears status block buffer and completion indicators. + * Keeps circuit breaker state (it tracks across iterations). + */ + reset(): void { + 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) + } + + /** + * Full reset - clears all state including circuit breaker. + */ + fullReset(): void { + this.reset(); + this._circuitBreaker = createInitialCircuitBreakerStatus(); + } + + /** + * Clean up all resources. + */ + destroy(): void { + this._statusBlockBuffer.length = 0; + this.removeAllListeners(); + } + + // ========== Private Methods ========== + + /** + * 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. + * + * P1-004: Enhanced with schema validation and error recovery + * + * @param lines - Array of lines between block markers + * @fires statusBlockDetected - When parsing succeeds + */ + private parseStatusBlock(lines: string[]): void { + const block: Partial = { + parsedAt: Date.now(), + }; + const parseErrors: string[] = []; + const unknownFields: string[] = []; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine) continue; + + // Track whether this line matched any known field + let matched = false; + + // STATUS field (required) + const statusMatch = trimmedLine.match(RALPH_STATUS_FIELD_PATTERN); + if (statusMatch) { + const value = statusMatch[1].toUpperCase(); + if (['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(value)) { + block.status = value as RalphStatusValue; + } else { + parseErrors.push(`Invalid STATUS value: "${value}". Expected: IN_PROGRESS, COMPLETE, or BLOCKED`); + } + matched = true; + } + + // TASKS_COMPLETED_THIS_LOOP field + const tasksMatch = trimmedLine.match(RALPH_TASKS_COMPLETED_PATTERN); + if (tasksMatch) { + const value = parseInt(tasksMatch[1], 10); + if (!Number.isNaN(value) && value >= 0) { + block.tasksCompletedThisLoop = value; + } else { + parseErrors.push( + `Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer` + ); + } + matched = true; + } + + // FILES_MODIFIED field + const filesMatch = trimmedLine.match(RALPH_FILES_MODIFIED_PATTERN); + if (filesMatch) { + const value = parseInt(filesMatch[1], 10); + if (!Number.isNaN(value) && value >= 0) { + block.filesModified = value; + } else { + parseErrors.push(`Invalid FILES_MODIFIED value: "${filesMatch[1]}". Expected: non-negative integer`); + } + matched = true; + } + + // TESTS_STATUS field + const testsMatch = trimmedLine.match(RALPH_TESTS_STATUS_PATTERN); + if (testsMatch) { + const value = testsMatch[1].toUpperCase(); + if (['PASSING', 'FAILING', 'NOT_RUN'].includes(value)) { + block.testsStatus = value as RalphTestsStatus; + } else { + parseErrors.push(`Invalid TESTS_STATUS value: "${value}". Expected: PASSING, FAILING, or NOT_RUN`); + } + matched = true; + } + + // WORK_TYPE field + const workMatch = trimmedLine.match(RALPH_WORK_TYPE_PATTERN); + if (workMatch) { + const value = workMatch[1].toUpperCase(); + if (['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(value)) { + block.workType = value as RalphWorkType; + } else { + parseErrors.push( + `Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING` + ); + } + matched = true; + } + + // EXIT_SIGNAL field + const exitMatch = trimmedLine.match(RALPH_EXIT_SIGNAL_PATTERN); + if (exitMatch) { + block.exitSignal = exitMatch[1].toLowerCase() === 'true'; + matched = true; + } + + // RECOMMENDATION field + const recMatch = trimmedLine.match(RALPH_RECOMMENDATION_PATTERN); + if (recMatch) { + block.recommendation = recMatch[1].trim(); + matched = true; + } + + // Track unknown fields for debugging (only if looks like a field) + if (!matched && trimmedLine.includes(':')) { + const fieldName = trimmedLine.split(':')[0].trim().toUpperCase(); + if (fieldName && !['#', '//'].some((c) => fieldName.startsWith(c))) { + unknownFields.push(fieldName); + } + } + } + + // Log parse errors if any + if (parseErrors.length > 0) { + console.warn(`[RalphStatusParser] RALPH_STATUS parse errors:\n - ${parseErrors.join('\n - ')}`); + } + + // Log unknown fields if any + if (unknownFields.length > 0) { + console.warn(`[RalphStatusParser] RALPH_STATUS unknown fields: ${unknownFields.join(', ')}`); + } + + // Validate required field: STATUS + if (block.status === undefined) { + console.warn('[RalphStatusParser] RALPH_STATUS block missing required STATUS field, skipping'); + return; + } + + // Fill in defaults for missing optional 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 { + // 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, + }); + } + + // Emit the status block + this.emit('statusBlockDetected', block); + } + + /** + * 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._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 }); + } + } + + /** + * 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/ralph-tracker.ts b/src/ralph-tracker.ts index 40142ea9..4b49f26c 100644 --- a/src/ralph-tracker.ts +++ b/src/ralph-tracker.ts @@ -10,102 +10,40 @@ * patterns are detected in the output stream, reducing overhead for * sessions not using autonomous loops. * + * Composed of four sub-modules: + * - RalphPlanTracker: Plan task management, checkpoints, versioning + * - RalphFixPlanWatcher: @fix_plan.md file watching and parsing + * - RalphStallDetector: Iteration stall detection + * - RalphStatusParser: RALPH_STATUS block parsing, circuit breaker + * * @module ralph-tracker */ import { EventEmitter } from 'node:events'; -import { readFile } from 'node:fs/promises'; -import { existsSync, FSWatcher, watch as fsWatch } from 'node:fs'; -import { join } from 'node:path'; import { RalphTrackerState, RalphTodoItem, RalphTodoStatus, RalphTodoPriority, RalphStatusBlock, - RalphStatusValue, - RalphTestsStatus, - RalphWorkType, CircuitBreakerStatus, RalphTodoProgress, CompletionConfidence, createInitialRalphTrackerState, - createInitialCircuitBreakerStatus, PlanTaskStatus, TddPhase, } from './types.js'; -import { - ANSI_ESCAPE_PATTERN_SIMPLE, - CleanupManager, - Debouncer, - fuzzyPhraseMatch, - todoContentHash, - stringSimilarity, -} from './utils/index.js'; +import { ANSI_ESCAPE_PATTERN_SIMPLE, fuzzyPhraseMatch, todoContentHash, stringSimilarity } from './utils/index.js'; import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js'; import { MAX_TODOS_PER_SESSION } from './config/map-limits.js'; +import { RalphPlanTracker } from './ralph-plan-tracker.js'; +import type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js'; +import { RalphFixPlanWatcher, generateFixPlanMarkdown, importFixPlanMarkdown } from './ralph-fix-plan-watcher.js'; +import { RalphStallDetector } from './ralph-stall-detector.js'; +import { RalphStatusParser } from './ralph-status-parser.js'; -// ========== Enhanced Plan Task Interface ========== - -// Note: PlanTaskStatus and TddPhase are imported from types.ts - -/** - * Enhanced plan task with verification criteria, dependencies, and execution tracking. - * Supports TDD workflow, failure tracking, and plan versioning. - */ -export interface EnhancedPlanTask { - /** Unique identifier (e.g., "P0-001") */ - id: string; - /** Task description */ - content: string; - /** Criticality level */ - priority: 'P0' | 'P1' | 'P2' | null; - /** How to verify completion */ - verificationCriteria?: string; - /** Command to run for verification */ - testCommand?: string; - /** IDs of tasks that must complete first */ - dependencies: string[]; - /** Current execution status */ - status: PlanTaskStatus; - /** How many times attempted */ - attempts: number; - /** Most recent failure reason */ - lastError?: string; - /** Timestamp of completion */ - completedAt?: number; - /** Plan version this belongs to */ - version: number; - /** TDD phase category */ - tddPhase?: TddPhase; - /** ID of paired test/impl task */ - pairedWith?: string; - /** Estimated complexity */ - complexity?: 'low' | 'medium' | 'high'; - /** Checklist items for review tasks (tddPhase: 'review') */ - reviewChecklist?: string[]; -} - -/** Checkpoint review data */ -export interface CheckpointReview { - iteration: number; - timestamp: number; - summary: { - total: number; - completed: number; - failed: number; - blocked: number; - pending: number; - inProgress: number; - }; - stuckTasks: Array<{ - id: string; - content: string; - attempts: number; - lastError?: string; - }>; - recommendations: string[]; -} +// Re-export sub-module types for backward compatibility +export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js'; // ========== Configuration Constants ========== // Note: MAX_TODOS_PER_SESSION and MAX_LINE_BUFFER_SIZE are imported from config modules @@ -143,7 +81,6 @@ const EVENT_DEBOUNCE_MS = 50; * Prevents unbounded growth if many unique phrases are seen. */ const MAX_COMPLETION_PHRASE_ENTRIES = 50; -const MAX_PLAN_HISTORY = 10; /** * Common/generic completion phrases that may cause false positives. @@ -349,78 +286,6 @@ const TASK_DONE_PATTERN = /** 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, -]; - // ---------- Priority Detection Patterns ---------- // Pre-compiled for performance; avoids repeated allocation in parsePriority() @@ -530,6 +395,13 @@ export interface RalphTrackerEvents { * - 2nd occurrence: Emits `completionDetected` event (actual completion) * - If loop already active: Emits immediately on first occurrence * + * ## Sub-modules + * + * - `planTracker` - Plan task management, checkpoints, versioning + * - `fixPlanWatcher` - @fix_plan.md file watching and parsing + * - `stallDetector` - Iteration stall detection + * - `statusParser` - RALPH_STATUS block parsing, circuit breaker + * * ## Events * * - `loopUpdate` - Loop state changed (status, iteration, phrase) @@ -548,6 +420,22 @@ export interface RalphTrackerEvents { * ``` */ export class RalphTracker extends EventEmitter { + // ========== Sub-modules ========== + + /** Plan task management sub-module */ + readonly planTracker = new RalphPlanTracker(); + + /** @fix_plan.md file watcher sub-module */ + readonly fixPlanWatcher: RalphFixPlanWatcher; + + /** Iteration stall detector sub-module */ + readonly stallDetector = new RalphStallDetector(); + + /** RALPH_STATUS block parser and circuit breaker sub-module */ + readonly statusParser = new RalphStatusParser(); + + // ========== Core State ========== + /** Current state of the detected loop */ private _loopState: RalphTrackerState; @@ -566,14 +454,17 @@ export class RalphTracker extends EventEmitter { /** Timestamp of last cleanup check for throttling */ private _lastCleanupTime: number = 0; - /** Centralized resource cleanup for timers/intervals */ - private cleanup = new CleanupManager(); + /** Debounce timer for todoUpdate events */ + private _todoUpdateTimer: NodeJS.Timeout | null = null; - /** Debouncer for todoUpdate events */ - private _todoUpdateDeb = new Debouncer(EVENT_DEBOUNCE_MS); + /** Debounce timer for loopUpdate events */ + private _loopUpdateTimer: NodeJS.Timeout | null = null; - /** Debouncer for loopUpdate events */ - private _loopUpdateDeb = new Debouncer(EVENT_DEBOUNCE_MS); + /** Flag indicating pending todoUpdate emission */ + private _todoUpdatePending: boolean = false; + + /** Flag indicating pending loopUpdate emission */ + private _loopUpdatePending: boolean = false; /** When true, prevents auto-enable on pattern detection */ private _autoEnableDisabled: boolean = true; @@ -591,96 +482,6 @@ export class RalphTracker extends EventEmitter { /** Maximum size of partial promise buffer */ private static readonly MAX_PARTIAL_PROMISE_SIZE = 256; - // ========== 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; - - /** Working directory for @fix_plan.md watching */ - private _workingDir: string | null = null; - - /** File watcher for @fix_plan.md */ - private _fixPlanWatcher: FSWatcher | null = null; - /** Error handler for FSWatcher (stored for cleanup to prevent memory leak) */ - private _fixPlanWatcherErrorHandler: ((err: Error) => void) | null = null; - - /** Debouncer for file change events */ - private _fixPlanReloadDeb = new Debouncer(500); - - /** Path to the @fix_plan.md file being watched */ - private _fixPlanPath: string | null = null; - - /** - * When @fix_plan.md is active, treat it as the source of truth for todo status. - * This prevents output-based detection from overriding file-based status. - */ - private get isFileAuthoritative(): boolean { - return this._fixPlanPath !== null; - } - - // ========== Enhanced Plan Management ========== - - /** Current version of the plan (incremented on changes) */ - private _planVersion: number = 1; - - /** History of plan versions for rollback support */ - private _planHistory: Array<{ - version: number; - timestamp: number; - tasks: Map; - summary: string; - }> = []; - - /** Enhanced plan tasks with execution tracking */ - private _planTasks: Map = new Map(); - - /** Checkpoint intervals (iterations at which to trigger review) */ - private _checkpointIterations: number[] = [5, 10, 20, 30, 50, 75, 100]; - - /** Last checkpoint iteration */ - private _lastCheckpointIteration: number = 0; - - // ========== Iteration Stall Detection ========== - - /** Timestamp when iteration count last changed */ - private _lastIterationChangeTime: number = 0; - - /** Last observed iteration count for stall detection */ - private _lastObservedIteration: number = 0; - - /** CleanupManager registration ID for iteration stall detection interval */ - private _iterationStallTimerId: string | null = null; - - /** Iteration stall warning threshold (ms) - default 10 minutes */ - private _iterationStallWarningMs: number = 10 * 60 * 1000; - - /** Iteration stall critical threshold (ms) - default 20 minutes */ - private _iterationStallCriticalMs: number = 20 * 60 * 1000; - - /** Whether stall warning has been emitted */ - private _iterationStallWarned: boolean = false; - /** Alternate completion phrases (P1-003: multi-phrase support) - Set for O(1) lookup */ private _alternateCompletionPhrases: Set = new Set(); @@ -698,6 +499,12 @@ export class RalphTracker extends EventEmitter { /** Map of todo ID to timestamp when it started (for duration tracking) */ private _todoStartTimes: Map = new Map(); + /** Last calculated completion confidence */ + private _lastCompletionConfidence: CompletionConfidence | undefined; + + /** Confidence threshold for triggering completion (0-100) */ + private static readonly COMPLETION_CONFIDENCE_THRESHOLD = 70; + /** * Creates a new RalphTracker instance. * Starts in disabled state until Ralph patterns are detected. @@ -705,14 +512,326 @@ export class RalphTracker extends EventEmitter { constructor() { super(); this._loopState = createInitialRalphTrackerState(); - this._circuitBreaker = createInitialCircuitBreakerStatus(); - this._lastIterationChangeTime = Date.now(); + + // Initialize fix plan watcher with callbacks to parent methods + this.fixPlanWatcher = new RalphFixPlanWatcher( + (content: string) => this.parsePriority(content), + (content: string) => this.generateTodoId(content) + ); + + // Wire sub-module events + this._wireSubModuleEvents(); } + /** + * Forward all sub-module events through RalphTracker + * so external consumers don't need to know about the split. + */ + private _wireSubModuleEvents(): void { + // Forward plan tracker events + for (const event of [ + 'planInitialized', + 'planTaskUpdate', + 'taskBlocked', + 'taskUnblocked', + 'planCheckpoint', + 'planTaskAdded', + 'planRollback', + ] as const) { + this.planTracker.on(event, (...args: unknown[]) => this.emit(event, ...args)); + } + + // Forward status parser events + this.statusParser.on('statusBlockDetected', (block: RalphStatusBlock) => { + // Auto-enable tracker when we see a status block + if (!this._loopState.enabled && !this._autoEnableDisabled) { + this.enable(); + } + this._loopState.lastActivity = Date.now(); + this.emit('statusBlockDetected', block); + this.emitLoopUpdateDebounced(); + }); + + this.statusParser.on('circuitBreakerUpdate', (status: CircuitBreakerStatus) => { + this.emit('circuitBreakerUpdate', status); + }); + + this.statusParser.on('exitGateMet', (data: { completionIndicators: number; exitSignal: boolean }) => { + this.emit('exitGateMet', data); + }); + + // Forward stall detector events + this.stallDetector.on('iterationStallWarning', (data: { iteration: number; stallDurationMs: number }) => { + this.emit('iterationStallWarning', data); + }); + + this.stallDetector.on('iterationStallCritical', (data: { iteration: number; stallDurationMs: number }) => { + this.emit('iterationStallCritical', data); + }); + + // Forward fix plan watcher events + this.fixPlanWatcher.on('todosLoaded', (items: RalphTodoItem[]) => { + // Replace _todos with file-based items + this._todos.clear(); + for (const item of items) { + this._todos.set(item.id, item); + } + + // Auto-enable tracker when we have todos from @fix_plan.md + if (!this._loopState.enabled) { + this.enable(); + } + + this.emit('todoUpdate', this.todos); + }); + } + + // ========== Delegated Plan Tracker Methods ========== + + /** + * Initialize plan tasks from generated plan items. + */ + initializePlanTasks( + items: Array<{ + id?: string; + content: string; + priority?: 'P0' | 'P1' | 'P2' | null; + verificationCriteria?: string; + testCommand?: string; + dependencies?: string[]; + tddPhase?: TddPhase; + pairedWith?: string; + complexity?: 'low' | 'medium' | 'high'; + }> + ): void { + this.planTracker.initializePlanTasks(items); + } + + /** + * Update a specific plan task's status, attempts, or error. + */ + updatePlanTask( + taskId: string, + update: { + status?: PlanTaskStatus; + error?: string; + incrementAttempts?: boolean; + } + ): { success: boolean; task?: EnhancedPlanTask; error?: string } { + return this.planTracker.updatePlanTask(taskId, update); + } + + /** + * Add a new task to the plan. + */ + addPlanTask(task: { + content: string; + priority?: 'P0' | 'P1' | 'P2'; + verificationCriteria?: string; + dependencies?: string[]; + insertAfter?: string; + }): { task: EnhancedPlanTask } { + return this.planTracker.addPlanTask(task); + } + + /** + * Get all plan tasks. + */ + getPlanTasks(): EnhancedPlanTask[] { + return this.planTracker.getPlanTasks(); + } + + /** + * Generate a checkpoint review. + */ + generateCheckpointReview(): CheckpointReview { + return this.planTracker.generateCheckpointReview(); + } + + /** + * Get plan version history. + */ + getPlanHistory(): Array<{ + version: number; + timestamp: number; + summary: string; + stats: { total: number; completed: number; failed: number }; + }> { + return this.planTracker.getPlanHistory(); + } + + /** + * Rollback to a previous plan version. + */ + rollbackToVersion(version: number): { + success: boolean; + plan?: EnhancedPlanTask[]; + error?: string; + } { + return this.planTracker.rollbackToVersion(version); + } + + /** + * Check if checkpoint review is due. + */ + isCheckpointDue(): boolean { + return this.planTracker.isCheckpointDue(); + } + + /** + * Get current plan version. + */ + get planVersion(): number { + return this.planTracker.planVersion; + } + + // ========== Delegated Fix Plan Watcher Methods ========== + + /** + * Set the working directory and start watching @fix_plan.md. + * @param workingDir - The session's working directory + */ + setWorkingDir(workingDir: string): void { + this.fixPlanWatcher.setWorkingDir(workingDir); + } + + /** + * Load @fix_plan.md from disk if it exists. + */ + async loadFixPlanFromDisk(): Promise { + return this.fixPlanWatcher.loadFixPlanFromDisk(); + } + + /** + * Stop watching @fix_plan.md. + */ + stopWatchingFixPlan(): void { + this.fixPlanWatcher.stopWatchingFixPlan(); + } + + /** + * When @fix_plan.md is active, treat it as the source of truth for todo status. + */ + get isFileAuthoritative(): boolean { + return this.fixPlanWatcher.isFileAuthoritative; + } + + /** + * Generate @fix_plan.md content from current todos. + */ + generateFixPlanMarkdown(): string { + return generateFixPlanMarkdown(this.todos); + } + + /** + * Parse @fix_plan.md content and import todos. + * Replaces current todos with imported ones. + * + * @param content - Markdown content from @fix_plan.md + * @returns Number of todos imported + */ + importFixPlanMarkdown(content: string): number { + const newTodos = importFixPlanMarkdown( + content, + (c: string) => this.parsePriority(c), + (c: string) => this.generateTodoId(c) + ); + + // Replace current todos with imported ones + this._todos.clear(); + for (const todo of newTodos) { + this._todos.set(todo.id, todo); + } + + // Emit update + this.emit('todoUpdate', this.todos); + + return newTodos.length; + } + + // ========== Delegated Stall Detector Methods ========== + + /** + * Start iteration stall detection timer. + */ + startIterationStallDetection(): void { + this.stallDetector.startIterationStallDetection(); + } + + /** + * Stop iteration stall detection timer. + */ + stopIterationStallDetection(): void { + this.stallDetector.stopIterationStallDetection(); + } + + /** + * Get iteration stall metrics for monitoring. + */ + getIterationStallMetrics(): { + lastIterationChangeTime: number; + stallDurationMs: number; + warningThresholdMs: number; + criticalThresholdMs: number; + isWarned: boolean; + currentIteration: number; + } { + return this.stallDetector.getIterationStallMetrics(); + } + + /** + * Configure iteration stall thresholds. + */ + configureIterationStallThresholds(warningMs: number, criticalMs: number): void { + this.stallDetector.configureIterationStallThresholds(warningMs, criticalMs); + } + + // ========== Delegated Status Parser Methods ========== + + /** + * Manually reset circuit breaker to CLOSED state. + * @fires circuitBreakerUpdate + */ + resetCircuitBreaker(): void { + this.statusParser.resetCircuitBreaker(); + } + + /** + * Get current circuit breaker status. + */ + get circuitBreakerStatus(): CircuitBreakerStatus { + return this.statusParser.circuitBreakerStatus; + } + + /** + * Get last parsed RALPH_STATUS block. + */ + get lastStatusBlock(): RalphStatusBlock | null { + return this.statusParser.lastStatusBlock; + } + + /** + * Get cumulative stats from status blocks. + */ + get cumulativeStats(): { + filesModified: number; + tasksCompleted: number; + completionIndicators: number; + } { + return this.statusParser.cumulativeStats; + } + + /** + * Whether dual-condition exit gate has been met. + */ + get exitGateMet(): boolean { + return this.statusParser.exitGateMet; + } + + // ========== Core Methods ========== + /** * Add an alternate completion phrase (P1-003: multi-phrase support). - * Multiple phrases can trigger completion (useful for complex workflows). - * @param phrase - Additional phrase that can trigger completion */ addAlternateCompletionPhrase(phrase: string): void { if (!this._alternateCompletionPhrases.has(phrase)) { @@ -724,7 +843,6 @@ export class RalphTracker extends EventEmitter { /** * Remove an alternate completion phrase. - * @param phrase - Phrase to remove */ removeAlternateCompletionPhrase(phrase: string): void { if (this._alternateCompletionPhrases.delete(phrase)) { @@ -735,8 +853,6 @@ export class RalphTracker extends EventEmitter { /** * Check if a phrase matches any valid completion phrase (primary or alternate). - * @param phrase - Phrase to check - * @returns True if phrase matches any valid completion phrase */ isValidCompletionPhrase(phrase: string): boolean { return this.findMatchingCompletionPhrase(phrase) !== null; @@ -744,8 +860,6 @@ export class RalphTracker extends EventEmitter { /** * Find which completion phrase (primary or alternate) matches the given phrase. - * @param phrase - Phrase to check - * @returns The matched canonical phrase, or null if no match */ private findMatchingCompletionPhrase(phrase: string): string | null { const primary = this._loopState.completionPhrase; @@ -762,7 +876,6 @@ export class RalphTracker extends EventEmitter { /** * Prevent auto-enable from pattern detection. - * Use this when the user has explicitly disabled the Ralph tracker. */ disableAutoEnable(): void { this._autoEnableDisabled = true; @@ -782,122 +895,8 @@ export class RalphTracker extends EventEmitter { return this._autoEnableDisabled; } - /** - * Set the working directory and start watching @fix_plan.md. - * Automatically loads existing @fix_plan.md if present. - * @param workingDir - The session's working directory - */ - setWorkingDir(workingDir: string): void { - this._workingDir = workingDir; - this._fixPlanPath = join(workingDir, '@fix_plan.md'); - - // Try to load existing @fix_plan.md - this.loadFixPlanFromDisk(); - - // Start watching for changes - this.startWatchingFixPlan(); - } - - /** - * Load @fix_plan.md from disk if it exists. - * Called on initialization and when file changes are detected. - */ - async loadFixPlanFromDisk(): Promise { - if (!this._fixPlanPath) return 0; - - try { - if (!existsSync(this._fixPlanPath)) { - return 0; - } - - const content = await readFile(this._fixPlanPath, 'utf-8'); - const count = this.importFixPlanMarkdown(content); - - if (count > 0) { - // Auto-enable tracker when we have todos from @fix_plan.md - if (!this._loopState.enabled) { - this.enable(); - } - console.log(`[RalphTracker] Loaded ${count} todos from @fix_plan.md`); - } - - return count; - } catch (err) { - // File doesn't exist or can't be read - that's OK - console.log(`[RalphTracker] Could not load @fix_plan.md: ${err}`); - return 0; - } - } - - /** - * Start watching @fix_plan.md for changes. - * Reloads todos when the file is modified. - */ - private startWatchingFixPlan(): void { - if (!this._fixPlanPath || !this._workingDir) return; - - // Stop existing watcher if any - this.stopWatchingFixPlan(); - - try { - // Only watch if the file exists - if (!existsSync(this._fixPlanPath)) { - // Watch the directory instead for file creation - this._fixPlanWatcher = fsWatch(this._workingDir, (_eventType, filename) => { - if (filename === '@fix_plan.md') { - this.handleFixPlanChange(); - } - }); - } else { - // Watch the file directly - this._fixPlanWatcher = fsWatch(this._fixPlanPath, () => { - this.handleFixPlanChange(); - }); - } - // Add error handler to prevent unhandled errors and clean up on failure - // Store handler reference for proper cleanup in stopWatchingFixPlan() - if (this._fixPlanWatcher) { - this._fixPlanWatcherErrorHandler = (err: Error) => { - console.log(`[RalphTracker] FSWatcher error for @fix_plan.md: ${err.message}`); - this.stopWatchingFixPlan(); - }; - this._fixPlanWatcher.on('error', this._fixPlanWatcherErrorHandler); - } - } catch (err) { - console.log(`[RalphTracker] Could not watch @fix_plan.md: ${err}`); - } - } - - /** - * Handle @fix_plan.md file change with debouncing. - */ - private handleFixPlanChange(): void { - // Debounce rapid changes (e.g., multiple writes) - this._fixPlanReloadDeb.schedule(() => { - this.loadFixPlanFromDisk(); - }); - } - - /** - * Stop watching @fix_plan.md. - */ - stopWatchingFixPlan(): void { - if (this._fixPlanWatcher) { - // Remove error handler before closing to prevent memory leak - if (this._fixPlanWatcherErrorHandler) { - this._fixPlanWatcher.off('error', this._fixPlanWatcherErrorHandler); - this._fixPlanWatcherErrorHandler = null; - } - this._fixPlanWatcher.close(); - this._fixPlanWatcher = null; - } - this._fixPlanReloadDeb.cancel(); - } - /** * Whether the tracker is enabled and actively monitoring output. - * Disabled by default; auto-enables when Ralph patterns detected. - * @returns True if tracker is processing terminal data */ get enabled(): boolean { return this._loopState.enabled; @@ -905,8 +904,6 @@ export class RalphTracker extends EventEmitter { /** * Enable the tracker to start monitoring terminal output. - * Called automatically when Ralph patterns are detected. - * Emits 'enabled' event when transitioning from disabled state. * @fires enabled * @fires loopUpdate */ @@ -921,7 +918,6 @@ export class RalphTracker extends EventEmitter { /** * Disable the tracker to stop monitoring terminal output. - * Terminal data will be ignored until re-enabled. * @fires loopUpdate */ disable(): void { @@ -934,17 +930,6 @@ export class RalphTracker extends EventEmitter { /** * Soft reset - clears state but keeps enabled status. - * Use when a new task/loop starts within the same session. - * - * Clears: - * - All todo items - * - Completion phrase tracking - * - Loop state (active, iterations) - * - Line buffer - * - * Preserves: - * - Enabled status - * * @fires loopUpdate * @fires todoUpdate */ @@ -960,15 +945,12 @@ export class RalphTracker extends EventEmitter { this._taskNumberToContent.clear(); this._lineBuffer = ''; this._partialPromiseBuffer = ''; - // 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) + + // Reset sub-modules + this.statusParser.reset(); + this.planTracker.reset(); + this.stallDetector.reset(); + // Emit on next tick to prevent listeners from modifying state during reset (non-reentrant) const loopState = this.loopState; const todos = this.todos; @@ -980,8 +962,6 @@ export class RalphTracker extends EventEmitter { /** * Full reset - clears all state including enabled status. - * Use when session is closed or completely cleared. - * Returns tracker to initial disabled state. * @fires loopUpdate * @fires todoUpdate */ @@ -996,15 +976,13 @@ export class RalphTracker extends EventEmitter { this._todoStartTimes.clear(); this._alternateCompletionPhrases.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(); + this._partialPromiseBuffer = ''; + + // Full reset sub-modules + this.statusParser.fullReset(); + this.planTracker.fullReset(); + this.stallDetector.reset(); + // Emit on next tick to prevent listeners from modifying state during reset (non-reentrant) const loopState = this.loopState; const todos = this.todos; @@ -1016,171 +994,94 @@ export class RalphTracker extends EventEmitter { /** * Clear all debounce timers. - * Called during reset/fullReset to prevent stale emissions. */ private clearDebounceTimers(): void { - this._todoUpdateDeb.cancel(); - this._loopUpdateDeb.cancel(); + if (this._todoUpdateTimer) { + clearTimeout(this._todoUpdateTimer); + this._todoUpdateTimer = null; + } + if (this._loopUpdateTimer) { + clearTimeout(this._loopUpdateTimer); + this._loopUpdateTimer = null; + } + this._todoUpdatePending = false; + this._loopUpdatePending = false; } /** * Emit todoUpdate event with debouncing. - * Batches rapid consecutive calls to reduce UI jitter. - * The event fires after EVENT_DEBOUNCE_MS of inactivity. */ private emitTodoUpdateDebounced(): void { - this._todoUpdateDeb.schedule(() => { - this.emit('todoUpdate', this.todos); - }); + this._todoUpdatePending = true; + + if (this._todoUpdateTimer) { + clearTimeout(this._todoUpdateTimer); + } + + this._todoUpdateTimer = setTimeout(() => { + if (this._todoUpdatePending) { + this._todoUpdatePending = false; + this._todoUpdateTimer = null; + this.emit('todoUpdate', this.todos); + } + }, EVENT_DEBOUNCE_MS); } /** * Emit loopUpdate event with debouncing. - * Batches rapid consecutive calls to reduce UI jitter. - * The event fires after EVENT_DEBOUNCE_MS of inactivity. */ private emitLoopUpdateDebounced(): void { - this._loopUpdateDeb.schedule(() => { - this.emit('loopUpdate', this.loopState); - }); + this._loopUpdatePending = true; + + if (this._loopUpdateTimer) { + clearTimeout(this._loopUpdateTimer); + } + + this._loopUpdateTimer = setTimeout(() => { + if (this._loopUpdatePending) { + this._loopUpdatePending = false; + this._loopUpdateTimer = null; + this.emit('loopUpdate', this.loopState); + } + }, EVENT_DEBOUNCE_MS); } /** * Flush all pending debounced events immediately. - * Useful for testing or when immediate state sync is needed. */ flushPendingEvents(): void { - if (this._todoUpdateDeb.isPending) { - this._todoUpdateDeb.flush(() => this.emit('todoUpdate', this.todos)); + if (this._todoUpdatePending) { + this._todoUpdatePending = false; + if (this._todoUpdateTimer) { + clearTimeout(this._todoUpdateTimer); + this._todoUpdateTimer = null; + } + this.emit('todoUpdate', this.todos); } - if (this._loopUpdateDeb.isPending) { - this._loopUpdateDeb.flush(() => this.emit('loopUpdate', this.loopState)); + if (this._loopUpdatePending) { + this._loopUpdatePending = false; + if (this._loopUpdateTimer) { + clearTimeout(this._loopUpdateTimer); + this._loopUpdateTimer = null; + } + this.emit('loopUpdate', this.loopState); } } /** * Get a copy of the current loop state. - * @returns Shallow copy of loop state (safe to modify) */ - // ========== Iteration Stall Detection Methods ========== - - /** - * Start iteration stall detection timer. - * Should be called when the loop becomes active. - */ - startIterationStallDetection(): void { - this.stopIterationStallDetection(); - this._lastIterationChangeTime = Date.now(); - this._iterationStallWarned = false; - - // Check every minute - this._iterationStallTimerId = this.cleanup.setInterval( - () => { - this.checkIterationStall(); - }, - 60 * 1000, - { description: 'iteration stall detection' } - ); - } - - /** - * Stop iteration stall detection timer. - */ - stopIterationStallDetection(): void { - if (this._iterationStallTimerId) { - this.cleanup.unregister(this._iterationStallTimerId); - this._iterationStallTimerId = null; - } - } - - /** - * Check for iteration stall and emit appropriate events. - */ - private checkIterationStall(): void { - if (!this._loopState.active) return; - - const stallDurationMs = Date.now() - this._lastIterationChangeTime; - - // Critical stall (longer duration) - if (stallDurationMs >= this._iterationStallCriticalMs) { - this.emit('iterationStallCritical', { - iteration: this._loopState.cycleCount, - stallDurationMs, - }); - return; - } - - // Warning stall - if (stallDurationMs >= this._iterationStallWarningMs && !this._iterationStallWarned) { - this._iterationStallWarned = true; - this.emit('iterationStallWarning', { - iteration: this._loopState.cycleCount, - stallDurationMs, - }); - } - } - - /** - * Get iteration stall metrics for monitoring. - */ - getIterationStallMetrics(): { - lastIterationChangeTime: number; - stallDurationMs: number; - warningThresholdMs: number; - criticalThresholdMs: number; - isWarned: boolean; - currentIteration: number; - } { - return { - lastIterationChangeTime: this._lastIterationChangeTime, - stallDurationMs: Date.now() - this._lastIterationChangeTime, - warningThresholdMs: this._iterationStallWarningMs, - criticalThresholdMs: this._iterationStallCriticalMs, - isWarned: this._iterationStallWarned, - currentIteration: this._loopState.cycleCount, - }; - } - - /** - * Configure iteration stall thresholds. - * @param warningMs - Warning threshold in milliseconds - * @param criticalMs - Critical threshold in milliseconds - */ - configureIterationStallThresholds(warningMs: number, criticalMs: number): void { - this._iterationStallWarningMs = warningMs; - this._iterationStallCriticalMs = criticalMs; - } - get loopState(): RalphTrackerState { return { ...this._loopState, - planVersion: this._planVersion, - planHistoryLength: this._planHistory.length, + planVersion: this.planTracker.planVersion, + planHistoryLength: this.planTracker.getPlanHistory().length, completionConfidence: this._lastCompletionConfidence, }; } - /** Last calculated completion confidence */ - private _lastCompletionConfidence: CompletionConfidence | undefined; - - /** Confidence threshold for triggering completion (0-100) */ - private static readonly COMPLETION_CONFIDENCE_THRESHOLD = 70; - /** * Calculate confidence score for a potential completion signal. - * - * Scoring weights: - * - Promise tag with proper format: +30 - * - Matches expected phrase: +25 - * - All todos complete: +20 - * - EXIT_SIGNAL: true: +15 - * - Multiple completion indicators (>=2): +10 - * - Context appropriate (not in prompt/explanation): +10 - * - Loop was explicitly active: +10 - * - * @param phrase - The detected phrase to evaluate - * @param context - Optional surrounding context for the phrase - * @returns CompletionConfidence assessment */ calculateCompletionConfidence(phrase: string, context?: string): CompletionConfidence { let score = 0; @@ -1217,13 +1118,14 @@ export class RalphTracker extends EventEmitter { } // Check for EXIT_SIGNAL from RALPH_STATUS block (adds 15 points) - if (this._lastStatusBlock?.exitSignal === true) { + const lastBlock = this.statusParser.lastStatusBlock; + if (lastBlock?.exitSignal === true) { signals.hasExitSignal = true; score += 15; } // Check for multiple completion indicators (adds 10 points) - if (this._completionIndicators >= 2) { + if (this.statusParser.cumulativeStats.completionIndicators >= 2) { signals.multipleIndicators = true; score += 10; } @@ -1231,7 +1133,6 @@ export class RalphTracker extends EventEmitter { // Check context appropriateness (deduct if inappropriate) if (context) { const lowerContext = context.toLowerCase(); - // Deduct points if phrase appears in prompt-like context if ( lowerContext.includes('output:') || lowerContext.includes('completion phrase') || @@ -1272,7 +1173,6 @@ export class RalphTracker extends EventEmitter { /** * Get all tracked todo items as an array. - * @returns Array of todo items (copy, safe to modify) */ get todos(): RalphTodoItem[] { return Array.from(this._todos.values()); @@ -1280,21 +1180,6 @@ export class RalphTracker extends EventEmitter { /** * Process raw terminal data to detect inner loop patterns. - * - * This is the main entry point for parsing output. Call this with each - * chunk of data from the PTY. The tracker will: - * - * 1. Strip ANSI escape codes - * 2. Auto-enable if disabled and Ralph patterns detected - * 3. Buffer data and process complete lines - * 4. Detect loop status, todos, and completion phrases - * 5. Periodically clean up expired todos - * - * @param data - Raw terminal data (may include ANSI codes) - * @fires loopUpdate - When loop state changes - * @fires todoUpdate - When todos are detected or updated - * @fires completionDetected - When completion phrase found - * @fires enabled - When tracker auto-enables */ processTerminalData(data: string): void { // Remove ANSI escape codes for cleaner parsing @@ -1304,20 +1189,17 @@ export class RalphTracker extends EventEmitter { /** * Process pre-stripped terminal data (ANSI codes already removed). - * Use this when the caller has already stripped ANSI to avoid redundant regex work. */ processCleanData(cleanData: string): void { // If tracker is disabled, only check for patterns that should auto-enable it if (!this._loopState.enabled) { - // Don't auto-enable if explicitly disabled by user setting if (this._autoEnableDisabled) { return; } if (this.shouldAutoEnable(cleanData)) { this.enable(); - // Continue processing now that we're enabled } else { - return; // Don't process further when disabled + return; } } @@ -1326,13 +1208,12 @@ export class RalphTracker extends EventEmitter { // Prevent unbounded line buffer growth from very long lines if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) { - // Truncate to last portion to preserve recent data this._lineBuffer = this._lineBuffer.slice(-Math.floor(MAX_LINE_BUFFER_SIZE / 2)); } // Process complete lines const lines = this._lineBuffer.split('\n'); - this._lineBuffer = lines.pop() || ''; // Keep incomplete line in buffer + this._lineBuffer = lines.pop() || ''; for (const line of lines) { this.processLine(line); @@ -1347,27 +1228,12 @@ export class RalphTracker extends EventEmitter { /** * Check if data contains patterns that should auto-enable the tracker. - * - * The tracker auto-enables when any of these patterns are detected: - * - `/ralph-loop:ralph-loop` command - * - `PHRASE` completion tags - * - TodoWrite tool usage indicators - * - Iteration patterns (`Iteration 5/50`, `[5/50]`) - * - Todo checkboxes (`- [ ]`, `- [x]`) - * - Todo indicator icons (`☐`, `◐`, `☒`) - * - Loop start messages (`Loop started at`) - * - All tasks complete announcements - * - Task completion signals - * - * @param data - ANSI-cleaned terminal data - * @returns True if any Ralph-related pattern is detected */ private shouldAutoEnable(data: string): boolean { // Cheap pre-filter: skip the full regex battery if none of the key // substrings that any pattern could match are present in the data. - // This avoids 12 regex tests on every PTY chunk (the common case). if ( - !data.includes('<') && // , TodoWrite + !data.includes('<') && !data.includes('ralph') && !data.includes('Ralph') && !data.includes('Todo') && @@ -1375,8 +1241,8 @@ export class RalphTracker extends EventEmitter { !data.includes('Iteration') && !data.includes('[') && !data.includes('\u2610') && - !data.includes('\u2612') && // ☐ ☒ - !data.includes('\u2714') && // ✔ + !data.includes('\u2612') && + !data.includes('\u2714') && !data.includes('Loop') && !data.includes('complete') && !data.includes('COMPLETE') && @@ -1386,87 +1252,42 @@ export class RalphTracker extends EventEmitter { return false; } - // Ralph loop command: /ralph-loop:ralph-loop - if (RALPH_START_PATTERN.test(data)) { - return true; - } + if (RALPH_START_PATTERN.test(data)) return true; + if (PROMISE_PATTERN.test(data)) return true; + if (TODOWRITE_PATTERN.test(data)) return true; + if (ITERATION_PATTERN.test(data)) return true; - // Completion phrase: ... - if (PROMISE_PATTERN.test(data)) { - return true; - } - - // TodoWrite tool usage - if (TODOWRITE_PATTERN.test(data)) { - return true; - } - - // Iteration patterns from Ralph loop: "Iteration 5/50", "[5/50]" - if (ITERATION_PATTERN.test(data)) { - return true; - } - - // Todo checkboxes: "- [ ] Task" or "- [x] Task" - // Reset lastIndex BEFORE test to ensure consistent matching with /g flag patterns TODO_CHECKBOX_PATTERN.lastIndex = 0; - if (TODO_CHECKBOX_PATTERN.test(data)) { - return true; - } + if (TODO_CHECKBOX_PATTERN.test(data)) return true; - // Todo indicator icons: "Todo: ☐", "Todo: ◐", etc. TODO_INDICATOR_PATTERN.lastIndex = 0; - if (TODO_INDICATOR_PATTERN.test(data)) { - return true; - } + if (TODO_INDICATOR_PATTERN.test(data)) return true; - // Claude Code native todo format: "☐ Task", "☒ Task" TODO_NATIVE_PATTERN.lastIndex = 0; - if (TODO_NATIVE_PATTERN.test(data)) { - return true; - } + if (TODO_NATIVE_PATTERN.test(data)) 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; - } + if (TODO_TASK_CREATED_PATTERN.test(data)) return true; + TODO_TASK_STATUS_PATTERN.lastIndex = 0; - if (TODO_TASK_STATUS_PATTERN.test(data)) { - return true; - } + 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; - } - - // All tasks complete signals - if (ALL_COMPLETE_PATTERN.test(data)) { - return true; - } - - // Task completion signals - if (TASK_DONE_PATTERN.test(data)) { - return true; - } + if (LOOP_START_PATTERN.test(data)) return true; + if (ALL_COMPLETE_PATTERN.test(data)) return true; + if (TASK_DONE_PATTERN.test(data)) return true; return false; } /** * Process a single line of terminal output. - * Runs all detection methods in sequence. - * @param line - Single line of ANSI-cleaned terminal output */ private processLine(line: string): void { 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); + // Delegate RALPH_STATUS block and completion indicator detection to sub-module + this.statusParser.processLine(trimmed); // Check for completion phrase this.detectCompletionPhrase(trimmed); @@ -1486,54 +1307,23 @@ export class RalphTracker extends EventEmitter { /** * Detect "all tasks complete" messages. - * - * When a valid "all complete" message is detected: - * 1. Marks all tracked todos as completed - * 2. Emits completion event if a completion phrase is set - * - * Validation criteria: - * - Line must match ALL_COMPLETE_PATTERN - * - Line must be reasonably short (<100 chars) to avoid matching commentary - * - Must not look like prompt text (no "output:" or ``) - * - Must have at least one tracked todo - * - If count is mentioned, should roughly match tracked todo count - * - * @param line - Single line to check - * @fires todoUpdate - If any todos marked complete - * @fires completionDetected - If completion phrase was set - * @fires loopUpdate - If loop state changes */ private detectAllTasksComplete(line: string): void { - // When @fix_plan.md is active, only trust the file for todo status - // This prevents false positives from Claude saying "all done" in conversation if (this.isFileAuthoritative) return; - - // 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 parsedCount = countMatch ? parseInt(countMatch[1], 10) : NaN; const mentionedCount = Number.isNaN(parsedCount) ? null : parsedCount; 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') { @@ -1545,7 +1335,6 @@ export class RalphTracker extends EventEmitter { 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(); @@ -1555,24 +1344,16 @@ export class RalphTracker extends EventEmitter { } /** - * 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. + * Detect individual task completion signals. */ private detectTaskCompletion(line: string): void { - // When @fix_plan.md is active, only trust the file for todo status if (this.isFileAuthoritative) return; - 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], 10); if (Number.isNaN(taskNum)) return; - // Find the nth todo (by order) and mark it complete let count = 0; for (const [_id, todo] of this._todos) { count++; @@ -1583,24 +1364,12 @@ export class RalphTracker extends EventEmitter { } } } - // Don't guess which todo to mark - let the checkbox detection handle it } /** * Check for multi-line patterns that might span line boundaries. - * Completion phrases can be split across PTY chunks. - * - * Handles cross-chunk promise tags by: - * 1. Checking combined buffer + new data for complete tags - * 2. Detecting partial tags at end of chunk and buffering - * 3. Clearing buffer when complete tag found or buffer gets stale - * - * @param data - The full data chunk (may contain multiple lines) */ private checkMultiLinePatterns(data: string): void { - // 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); @@ -1612,7 +1381,6 @@ export class RalphTracker extends EventEmitter { } } - // Check for partial promise tag at end of data (for next chunk) const partialMatch = data.match(PROMISE_PARTIAL_PATTERN); if (partialMatch) { const partialContent = partialMatch[0]; @@ -1628,33 +1396,17 @@ export class RalphTracker extends EventEmitter { /** * Detect completion phrases in a line. - * - * Handles two formats: - * 1. Tagged: `PHRASE` - Processed via handleCompletionPhrase - * 2. Bare: Just `PHRASE` - Only if we already know the expected phrase - * - * Bare phrase detection avoids false positives by requiring: - * - The phrase was previously seen in tagged form - * - Line is standalone or ends with the phrase - * - Line doesn't look like prompt context - * - * @param line - Single line to check */ 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.toUpperCase().includes(expectedPhrase.toUpperCase())) { - // Avoid false positives: don't trigger on prompt context const isNotInPromptContext = !line.includes('') && !line.includes('output:'); - // Also avoid triggering on "completion phrase is X" explanatory text const isNotExplanation = !line.toLowerCase().includes('completion phrase') && !line.toLowerCase().includes('output exactly'); @@ -1666,39 +1418,19 @@ export class RalphTracker extends EventEmitter { /** * Handle a bare completion phrase (without XML tags). - * - * Only fires completion if: - * 1. The phrase was previously seen in tagged form (from prompt) - * 2. This is the first bare occurrence (prevents double-firing) - * - * When triggered: - * - Marks all todos as complete - * - Emits completionDetected event - * - Sets loop to inactive - * - * @param phrase - The completion phrase text - * @fires todoUpdate - If any todos marked complete - * @fires completionDetected - When completion triggered - * @fires loopUpdate - When loop state changes */ private handleBareCompletionPhrase(phrase: string): void { - // Allow bare phrase detection if: - // 1. Loop is explicitly active (via startLoop()) - phrase was set programmatically - // 2. OR phrase was seen in tagged form (from terminal output) const taggedCount = this._completionPhraseCount.get(phrase) || 0; const loopExplicitlyActive = this._loopState.active; if (taggedCount === 0 && !loopExplicitlyActive) 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') { @@ -1710,7 +1442,6 @@ export class RalphTracker extends EventEmitter { this.emit('todoUpdate', this.todos); } - // Emit completion event this._loopState.active = false; this._loopState.lastActivity = Date.now(); this.emit('completionDetected', phrase); @@ -1718,13 +1449,7 @@ export class RalphTracker extends EventEmitter { } /** - * Handle a detected completion phrase - * - * Uses occurrence-based detection combined with confidence scoring - * to distinguish prompt from actual completion: - * - 1st occurrence: Store as expected phrase (likely in prompt) - * - 2nd occurrence OR high confidence: Emit completionDetected (actual completion) - * - If loop already active: Emit immediately (explicit loop start) + * Handle a detected completion phrase. */ private handleCompletionPhrase(phrase: string): void { const count = (this._completionPhraseCount.get(phrase) || 0) + 1; @@ -1732,16 +1457,13 @@ export class RalphTracker extends EventEmitter { // Trim completion phrase map if it exceeds the limit if (this._completionPhraseCount.size > MAX_COMPLETION_PHRASE_ENTRIES) { - // Keep only the most important entries (current expected phrase and highest counts) const entries = Array.from(this._completionPhraseCount.entries()); - entries.sort((a, b) => b[1] - a[1]); // Sort by count descending + entries.sort((a, b) => b[1] - a[1]); this._completionPhraseCount.clear(); - // Keep top half of entries const keepCount = Math.floor(MAX_COMPLETION_PHRASE_ENTRIES / 2); for (let i = 0; i < Math.min(keepCount, entries.length); i++) { this._completionPhraseCount.set(entries[i][0], entries[i][1]); } - // Always keep the expected phrase if set if (this._loopState.completionPhrase && !this._completionPhraseCount.has(this._loopState.completionPhrase)) { this._completionPhraseCount.set(this._loopState.completionPhrase, 1); } @@ -1752,26 +1474,18 @@ export class RalphTracker extends EventEmitter { this._loopState.completionPhrase = phrase; this._loopState.lastActivity = Date.now(); - // P1-002: Validate phrase and emit warning if risky this.validateCompletionPhrase(phrase); - this.emit('loopUpdate', this.loopState); } - // Check for fuzzy match with primary phrase or any alternate phrase (P1-003) - // This handles minor variations like whitespace, case, underscores vs hyphens + // Check for fuzzy match with primary phrase or any alternate phrase const matchedPhrase = this.findMatchingCompletionPhrase(phrase); if (matchedPhrase) { - // Use the matched phrase (canonical) for tracking const canonicalCount = this._completionPhraseCount.get(matchedPhrase) || 0; - // 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(); - // Mark all todos as complete let updated = false; for (const todo of this._todos.values()) { if (todo.status !== 'completed') { @@ -1788,9 +1502,7 @@ export class RalphTracker 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') { @@ -1811,16 +1523,6 @@ export class RalphTracker extends EventEmitter { /** * Check if two phrases match with fuzzy tolerance. - * Handles variations in: - * - Case (COMPLETE vs Complete) - * - Whitespace (TASK_DONE vs TASK DONE) - * - Separators (TASK_DONE vs TASK-DONE) - * - Minor typos with Levenshtein distance (COMPLET vs COMPLETE) - * - * @param phrase1 - First phrase to compare - * @param phrase2 - Second phrase to compare - * @param maxDistance - Maximum edit distance for fuzzy match (default: 2) - * @returns True if phrases are fuzzy-equal */ private isFuzzyPhraseMatch(phrase1: string, phrase2: string, maxDistance = 2): boolean { return fuzzyPhraseMatch(phrase1, phrase2, maxDistance); @@ -1828,25 +1530,13 @@ export class RalphTracker extends EventEmitter { /** * Validate a completion phrase and emit warnings if it's risky. - * - * P1-002: Configurable false positive prevention - * - * Checks for: - * - Common/generic phrases (DONE, COMPLETE, etc.) - * - Short phrases (< MIN_RECOMMENDED_PHRASE_LENGTH) - * - Numeric-only phrases - * - * @param phrase - The completion phrase to validate - * @fires phraseValidationWarning - When a risky phrase is detected */ private validateCompletionPhrase(phrase: string): void { const normalized = phrase.toUpperCase().replace(/[\s_\-.]+/g, ''); - // Generate a suggested unique phrase const uniqueSuffix = Date.now().toString(36).slice(-4).toUpperCase(); const suggestedPhrase = `${phrase}_${uniqueSuffix}`; - // Check for common phrases if (COMMON_COMPLETION_PHRASES.has(normalized)) { console.warn( `[RalphTracker] Warning: Completion phrase "${phrase}" is very common and may cause false positives. Consider using: "${suggestedPhrase}"` @@ -1859,7 +1549,6 @@ export class RalphTracker extends EventEmitter { return; } - // Check for short phrases if (normalized.length < MIN_RECOMMENDED_PHRASE_LENGTH) { console.warn( `[RalphTracker] Warning: Completion phrase "${phrase}" is too short (${normalized.length} chars). Consider using: "${suggestedPhrase}"` @@ -1872,7 +1561,6 @@ export class RalphTracker extends EventEmitter { return; } - // Check for numeric-only phrases if (/^\d+$/.test(normalized)) { console.warn( `[RalphTracker] Warning: Completion phrase "${phrase}" is numeric-only and may cause false positives. Consider using: "${suggestedPhrase}"` @@ -1887,12 +1575,6 @@ export class RalphTracker extends EventEmitter { /** * Activate the loop if not already active. - * - * Sets loop state to active and initializes counters. - * No-op if loop is already active. - * - * @returns True if loop was activated, false if already active - * @fires loopUpdate - When loop state changes */ private activateLoopIfNeeded(): boolean { if (this._loopState.active) return false; @@ -1909,135 +1591,81 @@ export class RalphTracker extends EventEmitter { /** * Detect loop start and status indicators. - * - * Patterns detected: - * - Ralph loop start commands (`/ralph-loop:ralph-loop`) - * - Loop start messages (`Loop started at`, `Starting Ralph loop`) - * - Max iterations setting (`max-iterations 50`) - * - Iteration progress (`Iteration 5/50`, `[5/50]`) - * - Elapsed time (`Elapsed: 2.5 hours`) - * - Cycle count (`cycle #5`, `respawn cycle #3`) - * - TodoWrite tool usage - * - * @param line - Single line to check - * @fires loopUpdate - When any loop state changes */ private detectLoopStatus(line: string): void { - // Check for Ralph loop start command (/ralph-loop:ralph-loop) - // or generic loop start patterns ("Loop started at", "Starting Ralph loop") if (RALPH_START_PATTERN.test(line) || LOOP_START_PATTERN.test(line)) { this.activateLoopIfNeeded(); } - // Check for max iterations setting const maxIterMatch = line.match(MAX_ITERATIONS_PATTERN); if (maxIterMatch) { const maxIter = parseInt(maxIterMatch[1], 10); if (!Number.isNaN(maxIter) && maxIter > 0) { this._loopState.maxIterations = maxIter; this._loopState.lastActivity = Date.now(); - // Use debounced emit for settings changes this.emitLoopUpdateDebounced(); } } - // Check for iteration patterns: "Iteration 5/50", "[5/50]" const iterMatch = line.match(ITERATION_PATTERN); if (iterMatch) { - // Pattern captures: group 1&2 for "Iteration X/Y", group 3&4 for "[X/Y]" const currentIter = parseInt(iterMatch[1] || iterMatch[3], 10); const maxIterStr = iterMatch[2] || iterMatch[4]; const maxIter = maxIterStr ? parseInt(maxIterStr, 10) : null; if (!Number.isNaN(currentIter)) { this.activateLoopIfNeeded(); - // Track iteration changes for stall detection - if (currentIter !== this._lastObservedIteration) { - this._lastIterationChangeTime = Date.now(); - this._lastObservedIteration = currentIter; - this._iterationStallWarned = false; // Reset warning on iteration change - - // P1-004: Reset circuit breaker on successful iteration progress - // If we're making progress, the loop is healthy - if ( - this._circuitBreaker.state === 'HALF_OPEN' || - this._circuitBreaker.consecutiveNoProgress > 0 || - this._circuitBreaker.consecutiveSameError > 0 || - this._circuitBreaker.consecutiveTestsFailure > 0 - ) { - this._circuitBreaker.consecutiveNoProgress = 0; - this._circuitBreaker.consecutiveSameError = 0; - this._circuitBreaker.lastProgressIteration = currentIter; - if (this._circuitBreaker.state === 'HALF_OPEN') { - this._circuitBreaker.state = 'CLOSED'; - this._circuitBreaker.reason = 'Iteration progress detected'; - this._circuitBreaker.reasonCode = 'progress_detected'; - this.emit('circuitBreakerUpdate', { ...this._circuitBreaker }); - } - } + // Track iteration changes for stall detection and circuit breaker + if (currentIter !== this.stallDetector.getIterationStallMetrics().currentIteration) { + this.stallDetector.notifyIterationChanged(currentIter); + this.statusParser.notifyIterationProgress(currentIter); } this._loopState.cycleCount = currentIter; + // Notify sub-modules of cycle count + this.planTracker.notifyCycleCount(currentIter); + this.statusParser.setCycleCount(currentIter); + this.stallDetector.setLoopActive(true); + if (maxIter !== null && !Number.isNaN(maxIter)) { this._loopState.maxIterations = maxIter; } this._loopState.lastActivity = Date.now(); - // Use debounced emit for rapid iteration updates this.emitLoopUpdateDebounced(); } } - // Check for elapsed time const elapsedMatch = line.match(ELAPSED_TIME_PATTERN); if (elapsedMatch) { this._loopState.elapsedHours = parseFloat(elapsedMatch[1]); this._loopState.lastActivity = Date.now(); - // Use debounced emit for elapsed time updates this.emitLoopUpdateDebounced(); } - // Check for cycle count (legacy pattern) const cycleMatch = line.match(CYCLE_PATTERN); if (cycleMatch) { const cycleNum = parseInt(cycleMatch[1] || cycleMatch[2], 10); if (!Number.isNaN(cycleNum) && cycleNum > this._loopState.cycleCount) { this._loopState.cycleCount = cycleNum; this._loopState.lastActivity = Date.now(); - // Use debounced emit for cycle updates this.emitLoopUpdateDebounced(); } } - // Check for TodoWrite tool usage - indicates active task tracking if (TODOWRITE_PATTERN.test(line)) { this._loopState.lastActivity = Date.now(); - // Don't emit update just for activity, let todo detection handle it } } /** * Detect todo items in various formats from Claude Code output. - * - * Supported formats: - * - Format 1: Checkbox markdown (`- [ ] Task`, `- [x] Task`) - * - Format 2: Indicator icons (`Todo: ☐ Task`, `Todo: ✓ Task`) - * - Format 3: Status in parentheses (`- Task (pending)`) - * - Format 4: Native TodoWrite (`☐ Task`, `☒ Task`, `◐ Task`) - * - * Uses quick pre-check to skip lines that can't contain todos. - * Excludes tool invocations and Claude commentary patterns. - * - * @param line - Single line to check - * @fires todoUpdate - When any todos are detected or updated */ private detectTodoItems(line: string): void { - // Pre-compute which pattern categories might match (60-75% faster) const hasCheckbox = line.includes('['); const hasTodoIndicator = line.includes('Todo:'); const hasNativeCheckbox = line.includes('☐') || 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 && !hasCheckmark) { return; } @@ -2045,8 +1673,6 @@ export class RalphTracker extends EventEmitter { let updated = false; let match: RegExpExecArray | null; - // Format 1: Checkbox format "- [ ] Task" or "- [x] Task" - // Only scan if line contains '[' character if (hasCheckbox) { TODO_CHECKBOX_PATTERN.lastIndex = 0; while ((match = TODO_CHECKBOX_PATTERN.exec(line)) !== null) { @@ -2058,8 +1684,6 @@ export class RalphTracker extends EventEmitter { } } - // Format 2: Todo with indicator icons - // Only scan if line contains 'Todo:' prefix if (hasTodoIndicator) { TODO_INDICATOR_PATTERN.lastIndex = 0; while ((match = TODO_INDICATOR_PATTERN.exec(line)) !== null) { @@ -2071,8 +1695,6 @@ export class RalphTracker extends EventEmitter { } } - // Format 3: Status in parentheses - // Only scan if line contains status in parentheses if (hasStatus) { TODO_STATUS_PATTERN.lastIndex = 0; while ((match = TODO_STATUS_PATTERN.exec(line)) !== null) { @@ -2083,19 +1705,15 @@ export class RalphTracker extends EventEmitter { } } - // Format 4: Claude Code native TodoWrite output (☐, ☒, ◐) - // Only scan if line contains native checkbox icons if (hasNativeCheckbox) { TODO_NATIVE_PATTERN.lastIndex = 0; while ((match = TODO_NATIVE_PATTERN.exec(line)) !== null) { const icon = match[1]; const content = match[2].trim(); - // Skip if content matches exclude patterns (tool invocations, commentary) const shouldExclude = TODO_EXCLUDE_PATTERNS.some((pattern) => pattern.test(content)); if (shouldExclude) continue; - // Skip if content is too short or looks like partial garbage if (content.length < 5) continue; const status = this.iconToStatus(icon); @@ -2104,10 +1722,7 @@ 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); @@ -2120,13 +1735,11 @@ export class RalphTracker extends EventEmitter { } } - // 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.enforceTaskMappingLimit(); @@ -2136,7 +1749,6 @@ export class RalphTracker extends EventEmitter { } } - // 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); @@ -2150,17 +1762,13 @@ export class RalphTracker extends EventEmitter { } } - // 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; @@ -2169,37 +1777,28 @@ export class RalphTracker extends EventEmitter { } if (updated) { - // Use debounced emit to batch rapid todo updates and reduce UI jitter this.emitTodoUpdateDebounced(); } } /** * Convert a todo icon character to its corresponding status. - * - * Icon mappings: - * - Completed: `✓`, `✅`, `☒`, `◉`, `●` - * - In Progress: `◐`, `⏳`, `⌛`, `🔄` - * - Pending: `☐`, `○`, and anything else (default) - * - * @param icon - Single character icon - * @returns Corresponding RalphTodoStatus */ private iconToStatus(icon: string): RalphTodoStatus { switch (icon) { case '✓': case '✅': - case '☒': // Claude Code checked checkbox - case '◉': // Filled circle (completed) - case '●': // Solid circle (completed) + case '☒': + case '◉': + case '●': return 'completed'; - case '◐': // Half-filled circle (in progress) + case '◐': case '⏳': case '⌛': case '🔄': return 'in_progress'; - case '☐': // Claude Code empty checkbox - case '○': // Empty circle + case '☐': + case '○': default: return 'pending'; } @@ -2207,35 +1806,22 @@ export class RalphTracker extends EventEmitter { /** * Parse priority from todo content. - * P1-008: Enhanced keyword-based priority inference. - * - * Priority levels: - * - P0 (Critical): Explicit P0, "critical", "blocker", "urgent", "security", "crash", "broken" - * - P1 (High): Explicit P1, "important", "high priority", "bug", "fix", "error", "fail" - * - P2 (Medium): Explicit P2, "nice to have", "low priority", "refactor", "cleanup", "improve" - * - * @param content - Todo content text - * @returns Parsed priority level or null */ private parsePriority(content: string): RalphTodoPriority { const upper = content.toUpperCase(); - // Check P0 first (highest priority wins) - // Uses pre-compiled module-level patterns for performance for (const pattern of P0_PRIORITY_PATTERNS) { if (pattern.test(upper)) { return 'P0'; } } - // Check P1 for (const pattern of P1_PRIORITY_PATTERNS) { if (pattern.test(upper)) { return 'P1'; } } - // Check P2 for (const pattern of P2_PRIORITY_PATTERNS) { if (pattern.test(upper)) { return 'P2'; @@ -2247,87 +1833,54 @@ export class RalphTracker extends EventEmitter { /** * Add a new todo item or update an existing one. - * - * Behavior: - * - Content is cleaned (ANSI removed, whitespace collapsed) - * - Content under 5 chars is skipped - * - ID is generated from normalized content (stable hash) - * - Priority is parsed from content (P0/P1/P2, Critical, High Priority, etc.) - * - Existing item: Updates status and timestamp - * - New item: Adds to map, evicts oldest if at MAX_TODOS_PER_SESSION - * - * @param content - Raw todo content text - * @param status - Status to set */ private upsertTodo(content: string, status: RalphTodoStatus): void { - // Skip empty or whitespace-only content if (!content || !content.trim()) return; - // Clean content: remove ANSI codes, collapse whitespace, trim - const cleanContent = content - .replace(ANSI_ESCAPE_PATTERN_SIMPLE, '') // Remove ANSI escape codes - .replace(/\s+/g, ' ') // Collapse whitespace - .trim(); - if (cleanContent.length < 5) return; // Skip very short content + const cleanContent = content.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '').replace(/\s+/g, ' ').trim(); + if (cleanContent.length < 5) return; - // Parse priority from content const priority = this.parsePriority(cleanContent); - - // P1-009: Estimate complexity for duration tracking const estimatedComplexity = this.estimateComplexity(cleanContent); - - // Generate a stable ID from normalized content const id = this.generateTodoId(cleanContent); const existing = this._todos.get(id); if (existing) { - // P1-009: Track status transitions for progress estimation const wasCompleted = existing.status === 'completed'; const isNowCompleted = status === 'completed'; const wasInProgress = existing.status === 'in_progress'; const isNowInProgress = status === 'in_progress'; - // Update existing todo (exact match by ID) existing.status = status; existing.detectedAt = Date.now(); - // Update priority if parsed (don't overwrite with null) if (priority) existing.priority = priority; - // Update complexity estimate if not already set if (!existing.estimatedComplexity) { existing.estimatedComplexity = estimatedComplexity; } - // P1-009: Track completion time if (!wasCompleted && isNowCompleted) { this.recordTodoCompletion(id); } - // P1-009: Start tracking when status changes to in_progress if (!wasInProgress && isNowInProgress) { this.startTrackingTodo(id); } } else { - // P1-007: Check for similar existing todo (deduplication) const similar = this.findSimilarTodo(cleanContent); if (similar) { - // P1-009: Track status transitions on similar todo const wasCompleted = similar.status === 'completed'; const isNowCompleted = status === 'completed'; const wasInProgress = similar.status === 'in_progress'; const isNowInProgress = status === 'in_progress'; - // Update similar todo instead of creating duplicate similar.status = status; similar.detectedAt = Date.now(); - // Update priority if new content has priority and existing doesn't if (priority && !similar.priority) { similar.priority = priority; } - // Keep the longer/more descriptive content if (cleanContent.length > similar.content.length) { similar.content = cleanContent; } - // P1-009: Track completion time if (!wasCompleted && isNowCompleted) { this.recordTodoCompletion(similar.id); } @@ -2337,17 +1890,14 @@ export class RalphTracker extends EventEmitter { return; } - // Add new todo with guaranteed eviction if at capacity - // Use while loop to ensure we always have room (handles edge case where findOldestTodo returns undefined) while (this._todos.size >= MAX_TODOS_PER_SESSION) { const oldest = this.findOldestTodo(); if (oldest) { this._todos.delete(oldest.id); } else { - // Safety valve: if somehow no oldest found, clear a random entry const firstKey = this._todos.keys().next().value; if (firstKey) this._todos.delete(firstKey); - else break; // Map is empty somehow, exit loop + else break; } } @@ -2363,7 +1913,6 @@ export class RalphTracker extends EventEmitter { estimatedDurationMs, }); - // P1-009: Start tracking if already in_progress if (status === 'in_progress') { this.startTrackingTodo(id); } @@ -2372,76 +1921,42 @@ export class RalphTracker extends EventEmitter { /** * Normalize todo content for consistent matching. - * - * Normalization steps: - * 1. Collapse multiple whitespace to single space - * 2. Remove special characters (keep alphanumeric + basic punctuation) - * 3. Trim whitespace - * 4. Convert to lowercase - * - * This prevents duplicate todos from terminal rendering artifacts. - * - * @param content - Raw todo content - * @returns Normalized lowercase string */ 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) + .replace(/\s+/g, ' ') + .replace(/[^a-zA-Z0-9\s.,!?'"-]/g, '') .trim() .toLowerCase(); } /** * Calculate similarity between two strings. - * - * P1-007: Uses a hybrid approach combining: - * 1. Levenshtein-based similarity for edit-distance tolerance - * 2. Bigram (Dice coefficient) for reordering tolerance - * Returns the maximum of both methods. - * - * @param str1 - First string (will be normalized) - * @param str2 - Second string (will be normalized) - * @returns Similarity score from 0.0 (no similarity) to 1.0 (identical) */ private calculateSimilarity(str1: string, str2: string): number { const norm1 = this.normalizeTodoContent(str1); const norm2 = this.normalizeTodoContent(str2); - // Identical after normalization if (norm1 === norm2) return 1.0; - - // If either is empty, no similarity if (!norm1 || !norm2) return 0.0; - // Method 1: Levenshtein-based similarity (good for typos/minor edits) const levenshteinSim = stringSimilarity(norm1, norm2); - - // Method 2: Bigram/Dice similarity (good for word reordering) const bigramSim = this.calculateBigramSimilarity(norm1, norm2); - // Return the higher of the two scores return Math.max(levenshteinSim, bigramSim); } /** * Calculate bigram (Dice coefficient) similarity. - * Good for detecting near-duplicates with word reordering. - * - * @param norm1 - First normalized string - * @param norm2 - Second normalized string - * @returns Similarity score from 0.0 to 1.0 */ private calculateBigramSimilarity(norm1: string, norm2: string): number { - // Short strings: use simple character overlap if (norm1.length < 3 || norm2.length < 3) { const shorter = norm1.length <= norm2.length ? norm1 : norm2; const longer = norm1.length > norm2.length ? norm1 : norm2; return longer.includes(shorter) ? 0.9 : 0.0; } - // Extract bigrams (pairs of consecutive characters) const getBigrams = (s: string): Set => { const bigrams = new Set(); for (let i = 0; i < s.length - 1; i++) { @@ -2453,7 +1968,6 @@ export class RalphTracker extends EventEmitter { const bigrams1 = getBigrams(norm1); const bigrams2 = getBigrams(norm2); - // Count intersection let intersection = 0; for (const bigram of bigrams1) { if (bigrams2.has(bigram)) { @@ -2461,7 +1975,6 @@ export class RalphTracker extends EventEmitter { } } - // Dice coefficient: 2 * intersection / (total bigrams) const totalBigrams = bigrams1.size + bigrams2.size; if (totalBigrams === 0) return 0.0; @@ -2470,31 +1983,17 @@ export class RalphTracker extends EventEmitter { /** * Find an existing todo that is similar to the given content. - * Returns the most similar todo if similarity >= threshold. - * - * Deduplication is intentionally conservative: - * - Short strings (< 30 chars): require 95% similarity (nearly identical) - * - Medium strings (30-60 chars): require 90% similarity - * - Longer strings: use default 85% threshold - * - * This prevents over-aggressive deduplication of brief, numbered items - * like "Task 1", "Task 2" while still catching true duplicates. - * - * @param content - New todo content to check against existing todos - * @returns Similar todo item if found, undefined otherwise */ private findSimilarTodo(content: string): RalphTodoItem | undefined { const normalized = this.normalizeTodoContent(content); - // Determine appropriate threshold based on string length - // Shorter strings need higher threshold to avoid false positives let threshold: number; if (normalized.length < 30) { - threshold = 0.95; // Very strict for short strings + threshold = 0.95; } else if (normalized.length < 60) { - threshold = 0.9; // Strict for medium strings + threshold = 0.9; } else { - threshold = TODO_SIMILARITY_THRESHOLD; // 0.85 for longer strings + threshold = TODO_SIMILARITY_THRESHOLD; } let bestMatch: RalphTodoItem | undefined; @@ -2515,15 +2014,10 @@ export class RalphTracker extends EventEmitter { /** * Estimate complexity of a todo based on content keywords. - * Used for duration estimation. - * - * @param content - Todo content text - * @returns Complexity category */ private estimateComplexity(content: string): 'trivial' | 'simple' | 'moderate' | 'complex' { const lower = content.toLowerCase(); - // Trivial: Simple fixes, typos, documentation const trivialPatterns = [ /\btypo\b/, /\bspelling\b/, @@ -2533,7 +2027,6 @@ export class RalphTracker extends EventEmitter { /\bformat(?:ting)?\b/, ]; - // Complex: Architecture, refactoring, security, testing const complexPatterns = [ /\barchitect(?:ure)?\b/, /\brefactor\b/, @@ -2547,7 +2040,6 @@ export class RalphTracker extends EventEmitter { /\bmultiple\s+files?\b/, ]; - // Moderate: Bugs, features, enhancements const moderatePatterns = [/\bbug\b/, /\bfeature\b/, /\benhance(?:ment)?\b/, /\bimplement\b/, /\badd\b/, /\bfix\b/]; for (const pattern of complexPatterns) { @@ -2567,13 +2059,8 @@ export class RalphTracker extends EventEmitter { /** * Get estimated duration for a complexity level (ms). - * Based on historical patterns from similar tasks. - * - * @param complexity - Complexity category - * @returns Estimated duration in milliseconds */ private getEstimatedDuration(complexity: 'trivial' | 'simple' | 'moderate' | 'complex'): number { - // If we have historical data, use average adjusted by complexity const avgTime = this.getAverageCompletionTime(); if (avgTime !== null) { const multipliers = { @@ -2585,19 +2072,17 @@ export class RalphTracker extends EventEmitter { return Math.round(avgTime * multipliers[complexity]); } - // Default estimates (in ms) based on typical task durations const defaults = { - trivial: 1 * 60 * 1000, // 1 minute - simple: 3 * 60 * 1000, // 3 minutes - moderate: 10 * 60 * 1000, // 10 minutes - complex: 30 * 60 * 1000, // 30 minutes + trivial: 1 * 60 * 1000, + simple: 3 * 60 * 1000, + moderate: 10 * 60 * 1000, + complex: 30 * 60 * 1000, }; return defaults[complexity]; } /** * Get average completion time from historical data. - * @returns Average time in ms, or null if no data */ private getAverageCompletionTime(): number | null { if (this._completionTimes.length === 0) return null; @@ -2607,7 +2092,6 @@ export class RalphTracker extends EventEmitter { /** * Record a todo completion for progress tracking. - * @param todoId - ID of the completed todo */ private recordTodoCompletion(todoId: string): void { const startTime = this._todoStartTimes.get(todoId); @@ -2615,7 +2099,6 @@ export class RalphTracker extends EventEmitter { const duration = Date.now() - startTime; this._completionTimes.push(duration); - // Keep only recent completion times while (this._completionTimes.length > RalphTracker.MAX_COMPLETION_TIMES) { this._completionTimes.shift(); } @@ -2626,14 +2109,12 @@ export class RalphTracker extends EventEmitter { /** * Start tracking a todo for duration estimation. - * @param todoId - ID of the todo being started */ private startTrackingTodo(todoId: string): void { if (!this._todoStartTimes.has(todoId)) { this._todoStartTimes.set(todoId, Date.now()); } - // Initialize session tracking if needed if (this._todosStartedAt === 0) { this._todosStartedAt = Date.now(); } @@ -2641,10 +2122,6 @@ export class RalphTracker extends EventEmitter { /** * Get progress estimation for the todo list. - * P1-009: Provides completion percentage, estimated remaining time, - * and projected completion timestamp. - * - * @returns Progress estimation object */ public getTodoProgress(): RalphTodoProgress { const todos = Array.from(this._todos.values()); @@ -2655,7 +2132,6 @@ export class RalphTracker extends EventEmitter { const percentComplete = total > 0 ? Math.round((completed / total) * 100) : 0; - // Calculate estimated remaining time let estimatedRemainingMs: number | null = null; let avgCompletionTimeMs: number | null = null; let projectedCompletionAt: number | null = null; @@ -2663,12 +2139,10 @@ export class RalphTracker extends EventEmitter { avgCompletionTimeMs = this.getAverageCompletionTime(); if (total > 0 && completed > 0) { - // Method 1: Use historical average if available if (avgCompletionTimeMs !== null) { const remaining = total - completed; estimatedRemainingMs = remaining * avgCompletionTimeMs; } else { - // Method 2: Calculate based on elapsed time and progress const elapsed = Date.now() - this._todosStartedAt; if (elapsed > 0 && completed > 0) { const timePerTodo = elapsed / completed; @@ -2678,12 +2152,10 @@ export class RalphTracker extends EventEmitter { } } - // Calculate projected completion timestamp if (estimatedRemainingMs !== null) { projectedCompletionAt = Date.now() + estimatedRemainingMs; } } else if (total > 0 && completed === 0) { - // No completions yet - use complexity-based estimates let totalEstimate = 0; for (const todo of todos) { if (todo.status !== 'completed') { @@ -2707,36 +2179,17 @@ export class RalphTracker extends EventEmitter { }; } - /** - * Generate a stable ID from todo content using djb2 hash. - * - * Uses the djb2 hash algorithm for good distribution across strings. - * Content is normalized first to prevent duplicates from terminal artifacts. - * - * @param content - Todo content text - * @returns Stable ID in format `todo-{hash}` (base36 encoded) - */ /** * Generate a stable ID from todo content using content hashing. - * - * P1-007: Uses centralized todoContentHash utility for consistency - * with deduplication logic. - * - * @param content - Todo content text - * @returns Unique ID string prefixed with "todo-" */ private generateTodoId(content: string): string { if (!content) return 'todo-empty'; - - // Use centralized hashing utility const hash = todoContentHash(content); return `todo-${hash}`; } /** * Find the todo item with the oldest detectedAt timestamp. - * Used for LRU eviction when at MAX_TODOS_PER_SESSION limit. - * @returns Oldest todo item, or undefined if map is empty */ private findOldestTodo(): RalphTodoItem | undefined { let oldest: RalphTodoItem | undefined; @@ -2750,7 +2203,6 @@ export class RalphTracker extends EventEmitter { /** * Conditionally run cleanup, throttled to CLEANUP_THROTTLE_MS. - * Prevents cleanup from running on every data chunk (performance). */ private maybeCleanupExpiredTodos(): void { const now = Date.now(); @@ -2763,8 +2215,6 @@ export class RalphTracker extends EventEmitter { /** * Remove todo items older than TODO_EXPIRY_MS. - * Emits todoUpdate if any items were removed. - * @fires todoUpdate - When expired items are removed */ private cleanupExpiredTodos(): void { const now = Date.now(); @@ -2786,19 +2236,9 @@ export class RalphTracker extends EventEmitter { /** * Programmatically start a loop (external API). - * - * Use when starting a loop from outside terminal detection, - * such as from a user action or API call. - * - * Automatically enables the tracker if not already enabled. - * - * @param completionPhrase - Optional phrase that signals completion - * @param maxIterations - Optional maximum iteration count - * @fires enabled - If tracker was disabled - * @fires loopUpdate - When loop state changes */ startLoop(completionPhrase?: string, maxIterations?: number): void { - this.enable(); // Ensure tracker is enabled + this.enable(); this._loopState.active = true; this._loopState.startedAt = Date.now(); this._loopState.cycleCount = 0; @@ -2813,9 +2253,6 @@ export class RalphTracker extends EventEmitter { /** * Update the maximum iteration count (external API). - * - * @param maxIterations - New max iterations, or null to remove limit - * @fires loopUpdate - When loop state changes */ setMaxIterations(maxIterations: number | null): void { this._loopState.maxIterations = maxIterations; @@ -2824,11 +2261,7 @@ export class RalphTracker extends EventEmitter { } /** - * Configure the tracker from external state (e.g. ralph plugin config). - * Only updates fields that are provided, leaving others unchanged. - * - * @param config - Partial configuration to apply - * @fires loopUpdate - When loop state changes + * Configure the tracker from external state. */ configure(config: { enabled?: boolean; completionPhrase?: string; maxIterations?: number }): void { if (config.enabled !== undefined) { @@ -2846,11 +2279,6 @@ export class RalphTracker extends EventEmitter { /** * Programmatically stop the loop (external API). - * - * Sets loop to inactive. Does not disable the tracker - * or clear todos - use reset() or clear() for that. - * - * @fires loopUpdate - When loop state changes */ stopLoop(): void { this._loopState.active = false; @@ -2860,12 +2288,10 @@ export class RalphTracker extends EventEmitter { /** * Enforce size limit on _taskNumberToContent map. - * Removes lowest task numbers (oldest tasks) when limit exceeded. */ private enforceTaskMappingLimit(): void { if (this._taskNumberToContent.size <= MAX_TASK_MAPPINGS) return; - // Sort keys and remove lowest (oldest) task numbers const sortedKeys = Array.from(this._taskNumberToContent.keys()).sort((a, b) => a - b); const keysToRemove = sortedKeys.slice(0, this._taskNumberToContent.size - MAX_TASK_MAPPINGS); for (const key of keysToRemove) { @@ -2875,21 +2301,12 @@ export class RalphTracker extends EventEmitter { /** * Clear all state and disable the tracker. - * - * Use when the session is cleared or closed. - * Resets everything to initial disabled state. - * - * @fires loopUpdate - With initial state - * @fires todoUpdate - With empty array */ clear(): void { - // Clear debounce timers to prevent stale emissions after clear this.clearDebounceTimers(); - // Stop fix plan file watcher to prevent memory leak - this.stopWatchingFixPlan(); - // Stop iteration stall detection timer to prevent leak - this.stopIterationStallDetection(); - this._loopState = createInitialRalphTrackerState(); // This sets enabled: false + this.fixPlanWatcher.stop(); + this.stallDetector.stopIterationStallDetection(); + this._loopState = createInitialRalphTrackerState(); this._todos.clear(); this._taskNumberToContent.clear(); this._todoStartTimes.clear(); @@ -2897,27 +2314,16 @@ export class RalphTracker extends EventEmitter { this._lineBuffer = ''; this._partialPromiseBuffer = ''; 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(); + // Clear sub-module state + this.statusParser.fullReset(); + this.planTracker.fullReset(); + this.stallDetector.reset(); this.emit('loopUpdate', this.loopState); this.emit('todoUpdate', this.todos); } /** * Get aggregated statistics about tracked todos. - * - * @returns Object with counts by status: - * - total: Total number of tracked todos - * - pending: Todos not yet started - * - inProgress: Todos currently in progress - * - completed: Finished todos */ getTodoStats(): { total: number; pending: number; inProgress: number; completed: number } { let pending = 0; @@ -2948,24 +2354,14 @@ export class RalphTracker extends EventEmitter { /** * Restore tracker state from persisted data. - * - * Use after loading state from StateStore. Handles backwards - * compatibility by defaulting missing `enabled` flag to false. - * - * Note: Does not emit events (caller should handle if needed). - * - * @param loopState - Persisted loop state object - * @param todos - Persisted todo items array */ restoreState(loopState: RalphTrackerState, todos: RalphTodoItem[]): void { - // Ensure enabled flag exists (backwards compatibility) this._loopState = { ...loopState, - enabled: loopState.enabled ?? false, // Override after spread for backwards compat + enabled: loopState.enabled ?? false, }; this._todos.clear(); for (const todo of todos) { - // Backwards compatibility: ensure priority field exists this._todos.set(todo.id, { ...todo, priority: todo.priority ?? null, @@ -2973,896 +2369,23 @@ export class RalphTracker extends EventEmitter { } } - // ========== 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. - * - * P1-004: Enhanced with schema validation and error recovery - * - * @param lines - Array of lines between block markers - * @fires statusBlockDetected - When parsing succeeds - */ - private parseStatusBlock(lines: string[]): void { - const block: Partial = { - parsedAt: Date.now(), - }; - const parseErrors: string[] = []; - const unknownFields: string[] = []; - - for (const line of lines) { - const trimmedLine = line.trim(); - if (!trimmedLine) continue; - - // Track whether this line matched any known field - let matched = false; - - // STATUS field (required) - const statusMatch = trimmedLine.match(RALPH_STATUS_FIELD_PATTERN); - if (statusMatch) { - const value = statusMatch[1].toUpperCase(); - if (['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(value)) { - block.status = value as RalphStatusValue; - } else { - parseErrors.push(`Invalid STATUS value: "${value}". Expected: IN_PROGRESS, COMPLETE, or BLOCKED`); - } - matched = true; - } - - // TASKS_COMPLETED_THIS_LOOP field - const tasksMatch = trimmedLine.match(RALPH_TASKS_COMPLETED_PATTERN); - if (tasksMatch) { - const value = parseInt(tasksMatch[1], 10); - if (!Number.isNaN(value) && value >= 0) { - block.tasksCompletedThisLoop = value; - } else { - parseErrors.push( - `Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer` - ); - } - matched = true; - } - - // FILES_MODIFIED field - const filesMatch = trimmedLine.match(RALPH_FILES_MODIFIED_PATTERN); - if (filesMatch) { - const value = parseInt(filesMatch[1], 10); - if (!Number.isNaN(value) && value >= 0) { - block.filesModified = value; - } else { - parseErrors.push(`Invalid FILES_MODIFIED value: "${filesMatch[1]}". Expected: non-negative integer`); - } - matched = true; - } - - // TESTS_STATUS field - const testsMatch = trimmedLine.match(RALPH_TESTS_STATUS_PATTERN); - if (testsMatch) { - const value = testsMatch[1].toUpperCase(); - if (['PASSING', 'FAILING', 'NOT_RUN'].includes(value)) { - block.testsStatus = value as RalphTestsStatus; - } else { - parseErrors.push(`Invalid TESTS_STATUS value: "${value}". Expected: PASSING, FAILING, or NOT_RUN`); - } - matched = true; - } - - // WORK_TYPE field - const workMatch = trimmedLine.match(RALPH_WORK_TYPE_PATTERN); - if (workMatch) { - const value = workMatch[1].toUpperCase(); - if (['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(value)) { - block.workType = value as RalphWorkType; - } else { - parseErrors.push( - `Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING` - ); - } - matched = true; - } - - // EXIT_SIGNAL field - const exitMatch = trimmedLine.match(RALPH_EXIT_SIGNAL_PATTERN); - if (exitMatch) { - block.exitSignal = exitMatch[1].toLowerCase() === 'true'; - matched = true; - } - - // RECOMMENDATION field - const recMatch = trimmedLine.match(RALPH_RECOMMENDATION_PATTERN); - if (recMatch) { - block.recommendation = recMatch[1].trim(); - matched = true; - } - - // Track unknown fields for debugging (only if looks like a field) - if (!matched && trimmedLine.includes(':')) { - const fieldName = trimmedLine.split(':')[0].trim().toUpperCase(); - if (fieldName && !['#', '//'].some((c) => fieldName.startsWith(c))) { - unknownFields.push(fieldName); - } - } - } - - // Log parse errors if any - if (parseErrors.length > 0) { - console.warn(`[RalphTracker] RALPH_STATUS parse errors:\n - ${parseErrors.join('\n - ')}`); - } - - // Log unknown fields if any - if (unknownFields.length > 0) { - console.warn(`[RalphTracker] RALPH_STATUS unknown fields: ${unknownFields.join(', ')}`); - } - - // Validate required field: STATUS - if (block.status === undefined) { - console.warn('[RalphTracker] RALPH_STATUS block missing required STATUS field, skipping'); - return; - } - - // Fill in defaults for missing optional 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 - } - } - } - - // ========== @fix_plan.md Generation & Import ========== - - /** - * Generate @fix_plan.md content from current todos. - * Groups todos by priority and status. - * - * @returns Markdown content for @fix_plan.md - */ - generateFixPlanMarkdown(): string { - const todos = this.todos; - const lines: string[] = ['# Fix Plan', '']; - - // Group by priority - const p0: RalphTodoItem[] = []; - const p1: RalphTodoItem[] = []; - const p2: RalphTodoItem[] = []; - const noPriority: RalphTodoItem[] = []; - const completed: RalphTodoItem[] = []; - - for (const todo of todos) { - if (todo.status === 'completed') { - completed.push(todo); - } else if (todo.priority === 'P0') { - p0.push(todo); - } else if (todo.priority === 'P1') { - p1.push(todo); - } else if (todo.priority === 'P2') { - p2.push(todo); - } else { - noPriority.push(todo); - } - } - - // High Priority (P0) - if (p0.length > 0) { - lines.push('## High Priority (P0)'); - for (const todo of p0) { - const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]'; - lines.push(`- ${checkbox} ${todo.content}`); - } - lines.push(''); - } - - // Standard (P1) - if (p1.length > 0) { - lines.push('## Standard (P1)'); - for (const todo of p1) { - const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]'; - lines.push(`- ${checkbox} ${todo.content}`); - } - lines.push(''); - } - - // Nice to Have (P2) - if (p2.length > 0) { - lines.push('## Nice to Have (P2)'); - for (const todo of p2) { - const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]'; - lines.push(`- ${checkbox} ${todo.content}`); - } - lines.push(''); - } - - // Tasks (no priority) - if (noPriority.length > 0) { - lines.push('## Tasks'); - for (const todo of noPriority) { - const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]'; - lines.push(`- ${checkbox} ${todo.content}`); - } - lines.push(''); - } - - // Completed - if (completed.length > 0) { - lines.push('## Completed'); - for (const todo of completed) { - lines.push(`- [x] ${todo.content}`); - } - lines.push(''); - } - - return lines.join('\n'); - } - - /** - * Parse @fix_plan.md content and import todos. - * Replaces current todos with imported ones. - * - * @param content - Markdown content from @fix_plan.md - * @returns Number of todos imported - */ - importFixPlanMarkdown(content: string): number { - const lines = content.split('\n'); - const newTodos: RalphTodoItem[] = []; - let currentPriority: RalphTodoPriority = null; - - // Patterns for section headers - const p0HeaderPattern = /^##\s*(High Priority|Critical|P0)/i; - const p1HeaderPattern = /^##\s*(Standard|P1|Medium Priority)/i; - const p2HeaderPattern = /^##\s*(Nice to Have|P2|Low Priority)/i; - const completedHeaderPattern = /^##\s*Completed/i; - const tasksHeaderPattern = /^##\s*Tasks/i; - - // Pattern for todo items - const todoPattern = /^-\s*\[([ x-])\]\s*(.+)$/; - - let inCompletedSection = false; - - for (const line of lines) { - const trimmed = line.trim(); - - // Check for section headers - if (p0HeaderPattern.test(trimmed)) { - currentPriority = 'P0'; - inCompletedSection = false; - continue; - } - if (p1HeaderPattern.test(trimmed)) { - currentPriority = 'P1'; - inCompletedSection = false; - continue; - } - if (p2HeaderPattern.test(trimmed)) { - currentPriority = 'P2'; - inCompletedSection = false; - continue; - } - if (completedHeaderPattern.test(trimmed)) { - inCompletedSection = true; - continue; - } - if (tasksHeaderPattern.test(trimmed)) { - currentPriority = null; - inCompletedSection = false; - continue; - } - - // Parse todo item - const match = trimmed.match(todoPattern); - if (match) { - const [, checkboxState, content] = match; - let status: RalphTodoStatus; - - if (inCompletedSection || checkboxState === 'x' || checkboxState === 'X') { - status = 'completed'; - } else if (checkboxState === '-') { - status = 'in_progress'; - } else { - status = 'pending'; - } - - // Parse priority from content if not in a priority section - const parsedPriority = inCompletedSection ? null : currentPriority || this.parsePriority(content); - - const id = this.generateTodoId(content); - newTodos.push({ - id, - content: content.trim(), - status, - detectedAt: Date.now(), - priority: parsedPriority, - }); - } - } - - // Replace current todos with imported ones - this._todos.clear(); - for (const todo of newTodos) { - this._todos.set(todo.id, todo); - } - - // Emit update - this.emit('todoUpdate', this.todos); - - return newTodos.length; - } - - // ========== Enhanced Plan Management Methods ========== - - /** - * Initialize plan tasks from generated plan items. - * Called when wizard generates a new plan. - */ - initializePlanTasks( - items: Array<{ - id?: string; - content: string; - priority?: 'P0' | 'P1' | 'P2' | null; - verificationCriteria?: string; - testCommand?: string; - dependencies?: string[]; - tddPhase?: TddPhase; - pairedWith?: string; - complexity?: 'low' | 'medium' | 'high'; - }> - ): void { - // Save current plan to history before replacing - if (this._planTasks.size > 0) { - this._savePlanToHistory('Plan replaced with new generation'); - } - - // Clear and rebuild - this._planTasks.clear(); - this._planVersion++; - - items.forEach((item, idx) => { - const id = item.id || `task-${idx}`; - const task: EnhancedPlanTask = { - id, - content: item.content, - priority: item.priority || null, - verificationCriteria: item.verificationCriteria, - testCommand: item.testCommand, - dependencies: item.dependencies || [], - status: 'pending', - attempts: 0, - version: this._planVersion, - tddPhase: item.tddPhase, - pairedWith: item.pairedWith, - complexity: item.complexity, - }; - this._planTasks.set(id, task); - }); - - this.emit('planInitialized', { version: this._planVersion, taskCount: this._planTasks.size }); - } - - /** - * Update a specific plan task's status, attempts, or error. - */ - updatePlanTask( - taskId: string, - update: { - status?: PlanTaskStatus; - error?: string; - incrementAttempts?: boolean; - } - ): { success: boolean; task?: EnhancedPlanTask; error?: string } { - const task = this._planTasks.get(taskId); - if (!task) { - return { success: false, error: 'Task not found' }; - } - - if (update.status) { - task.status = update.status; - if (update.status === 'completed') { - task.completedAt = Date.now(); - } - } - - if (update.error) { - task.lastError = update.error; - } - - if (update.incrementAttempts) { - task.attempts++; - - // After 3 failed attempts, mark as blocked and emit warning - if (task.attempts >= 3 && task.status === 'failed') { - task.status = 'blocked'; - this.emit('taskBlocked', { - taskId, - content: task.content, - attempts: task.attempts, - lastError: task.lastError, - }); - } - } - - // Update blocked tasks when a dependency completes - if (update.status === 'completed') { - this._unblockDependentTasks(taskId); - } - - // Check for checkpoint - this._checkForCheckpoint(); - - this.emit('planTaskUpdate', { taskId, task }); - return { success: true, task }; - } - - /** - * Unblock tasks that were waiting on a completed dependency. - */ - private _unblockDependentTasks(completedTaskId: string): void { - for (const [_, task] of this._planTasks) { - if (task.dependencies.includes(completedTaskId)) { - // Check if all dependencies are now complete - const allDepsComplete = task.dependencies.every((depId) => { - const dep = this._planTasks.get(depId); - return dep && dep.status === 'completed'; - }); - - if (allDepsComplete && task.status === 'blocked') { - task.status = 'pending'; - this.emit('taskUnblocked', { taskId: task.id }); - } - } - } - } - - /** - * Check if current iteration is a checkpoint and emit review if so. - */ - private _checkForCheckpoint(): void { - const currentIteration = this._loopState.cycleCount; - if (this._checkpointIterations.includes(currentIteration) && currentIteration > this._lastCheckpointIteration) { - this._lastCheckpointIteration = currentIteration; - const checkpoint = this.generateCheckpointReview(); - this.emit('planCheckpoint', checkpoint); - } - } - - /** - * Generate a checkpoint review summarizing plan progress and stuck tasks. - */ - generateCheckpointReview(): CheckpointReview { - const tasks = Array.from(this._planTasks.values()); - - const summary = { - total: tasks.length, - completed: tasks.filter((t) => t.status === 'completed').length, - failed: tasks.filter((t) => t.status === 'failed').length, - blocked: tasks.filter((t) => t.status === 'blocked').length, - pending: tasks.filter((t) => t.status === 'pending').length, - inProgress: tasks.filter((t) => t.status === 'in_progress').length, - }; - - // Find stuck tasks (3+ attempts or blocked) - const stuckTasks = tasks - .filter((t) => t.attempts >= 3 || t.status === 'blocked') - .map((t) => ({ - id: t.id, - content: t.content, - attempts: t.attempts, - lastError: t.lastError, - })); - - // Generate recommendations - const recommendations: string[] = []; - - if (stuckTasks.length > 0) { - recommendations.push(`${stuckTasks.length} task(s) are stuck. Consider breaking them into smaller steps.`); - } - - if (summary.failed > summary.completed && summary.total > 5) { - recommendations.push('More tasks have failed than completed. Review approach and consider plan adjustment.'); - } - - const progressPercent = summary.total > 0 ? Math.round((summary.completed / summary.total) * 100) : 0; - if (progressPercent < 20 && this._loopState.cycleCount > 10) { - recommendations.push('Progress is slow. Consider simplifying tasks or reviewing dependencies.'); - } - - if (summary.total > 0 && summary.blocked > summary.total / 3) { - recommendations.push('Many tasks are blocked. Review dependency chain for bottlenecks.'); - } - - return { - iteration: this._loopState.cycleCount, - timestamp: Date.now(), - summary, - stuckTasks, - recommendations, - }; - } - - /** - * Save current plan state to history. - */ - private _savePlanToHistory(summary: string): void { - // Clone current tasks - const tasksCopy = new Map(); - for (const [id, task] of this._planTasks) { - tasksCopy.set(id, { ...task }); - } - - this._planHistory.push({ - version: this._planVersion, - timestamp: Date.now(), - tasks: tasksCopy, - summary, - }); - - // Limit history size - if (this._planHistory.length > MAX_PLAN_HISTORY) { - this._planHistory.shift(); - } - } - - /** - * Get plan version history. - */ - getPlanHistory(): Array<{ - version: number; - timestamp: number; - summary: string; - stats: { total: number; completed: number; failed: number }; - }> { - return this._planHistory.map((h) => { - const tasks = Array.from(h.tasks.values()); - return { - version: h.version, - timestamp: h.timestamp, - summary: h.summary, - stats: { - total: tasks.length, - completed: tasks.filter((t) => t.status === 'completed').length, - failed: tasks.filter((t) => t.status === 'failed').length, - }, - }; - }); - } - - /** - * Rollback to a previous plan version. - */ - rollbackToVersion(version: number): { - success: boolean; - plan?: EnhancedPlanTask[]; - error?: string; - } { - const historyEntry = this._planHistory.find((h) => h.version === version); - if (!historyEntry) { - return { success: false, error: `Version ${version} not found in history` }; - } - - // Save current state first - this._savePlanToHistory(`Rolled back from v${this._planVersion} to v${version}`); - - // Restore the historical version - this._planTasks.clear(); - for (const [id, task] of historyEntry.tasks) { - // Reset execution state for retry - this._planTasks.set(id, { - ...task, - status: task.status === 'completed' ? 'completed' : 'pending', - attempts: task.status === 'completed' ? task.attempts : 0, - lastError: undefined, - }); - } - - this._planVersion++; - this.emit('planRollback', { version, newVersion: this._planVersion }); - - return { success: true, plan: Array.from(this._planTasks.values()) }; - } - - /** - * Add a new task to the plan (for runtime adaptation). - */ - addPlanTask(task: { - content: string; - priority?: 'P0' | 'P1' | 'P2'; - verificationCriteria?: string; - dependencies?: string[]; - insertAfter?: string; - }): { task: EnhancedPlanTask } { - // Generate unique ID - const existingIds = Array.from(this._planTasks.keys()); - const prefix = task.priority || 'P1'; - let counter = existingIds.filter((id) => id.startsWith(prefix)).length + 1; - let id = `${prefix}-${String(counter).padStart(3, '0')}`; - while (this._planTasks.has(id)) { - counter++; - id = `${prefix}-${String(counter).padStart(3, '0')}`; - } - - const newTask: EnhancedPlanTask = { - id, - content: task.content, - priority: task.priority || null, - verificationCriteria: task.verificationCriteria || 'Task completed successfully', - dependencies: task.dependencies || [], - status: 'pending', - attempts: 0, - version: this._planVersion, - }; - - this._planTasks.set(id, newTask); - this.emit('planTaskAdded', { task: newTask }); - - return { task: newTask }; - } - - /** - * Get all plan tasks. - */ - getPlanTasks(): EnhancedPlanTask[] { - return Array.from(this._planTasks.values()); - } - - /** - * Get current plan version. - */ - get planVersion(): number { - return this._planVersion; - } - - /** - * Check if checkpoint review is due for current iteration. - */ - isCheckpointDue(): boolean { - const currentIteration = this._loopState.cycleCount; - return this._checkpointIterations.includes(currentIteration) && currentIteration > this._lastCheckpointIteration; - } - /** * Clean up all resources and release memory. - * - * Call this when the session is being destroyed to prevent memory leaks. - * Stops file watchers, clears all timers, data, and removes event listeners. */ destroy(): void { this.clearDebounceTimers(); - this.stopWatchingFixPlan(); - this._fixPlanReloadDeb.dispose(); - this.cleanup.dispose(); + this.fixPlanWatcher.destroy(); + this.stallDetector.destroy(); + this.statusParser.destroy(); + this.planTracker.destroy(); this._todos.clear(); this._taskNumberToContent.clear(); this._todoStartTimes.clear(); this._alternateCompletionPhrases.clear(); this._completionPhraseCount.clear(); - this._planTasks.clear(); this._completionTimes.length = 0; this._lineBuffer = ''; this._partialPromiseBuffer = ''; - this._statusBlockBuffer.length = 0; - this._planHistory.length = 0; this.removeAllListeners(); } } diff --git a/src/respawn-adaptive-timing.ts b/src/respawn-adaptive-timing.ts new file mode 100644 index 00000000..1c1eef1e --- /dev/null +++ b/src/respawn-adaptive-timing.ts @@ -0,0 +1,134 @@ +/** + * @fileoverview Adaptive timing controller for respawn idle detection. + * + * Extracted from respawn-controller.ts for modularity. Tracks historical timing + * data and adjusts the completion confirm timeout dynamically based on the 75th + * percentile of recent idle detection durations. + * + * @module respawn-adaptive-timing + */ + +import type { TimingHistory } from './types.js'; + +/** + * Configuration for adaptive timing bounds. + */ +export interface AdaptiveTimingConfig { + /** Minimum adaptive completion confirm timeout (ms) */ + adaptiveMinConfirmMs: number; + /** Maximum adaptive completion confirm timeout (ms) */ + adaptiveMaxConfirmMs: number; +} + +/** + * Manages adaptive timing for respawn idle detection. + * + * Uses historical idle detection durations to calculate an optimal completion + * confirm timeout. The timeout is based on the 75th percentile of recent + * durations with a 20% safety buffer, clamped to configured bounds. + */ +export class RespawnAdaptiveTiming { + private timingHistory: TimingHistory; + + constructor(private config: AdaptiveTimingConfig) { + this.timingHistory = { + recentIdleDetectionMs: [], + recentCycleDurationMs: [], + adaptiveCompletionConfirmMs: 10000, // Start with default + sampleCount: 0, + maxSamples: 20, // Keep last 20 samples for rolling average + lastUpdatedAt: Date.now(), + }; + } + + /** + * Record timing data from a completed cycle for adaptive adjustments. + * + * @param idleDetectionMs - Time spent detecting idle + * @param cycleDurationMs - Total cycle duration + */ + recordTimingData(idleDetectionMs: number, cycleDurationMs: number): void { + const history = this.timingHistory; + + // Add to rolling windows + history.recentIdleDetectionMs.push(idleDetectionMs); + history.recentCycleDurationMs.push(cycleDurationMs); + + // Trim to max samples + if (history.recentIdleDetectionMs.length > history.maxSamples) { + history.recentIdleDetectionMs.shift(); + } + if (history.recentCycleDurationMs.length > history.maxSamples) { + history.recentCycleDurationMs.shift(); + } + + history.sampleCount = history.recentIdleDetectionMs.length; + history.lastUpdatedAt = Date.now(); + + // Recalculate adaptive timing + this.updateAdaptiveTiming(); + } + + /** + * Get the current adaptive completion confirm timeout. + * Returns the calculated value, or the default if not enough samples. + * + * @returns Completion confirm timeout in milliseconds + */ + getAdaptiveCompletionConfirmMs(): number { + // Need at least 5 samples before adjusting + if (this.timingHistory.sampleCount < 5) { + return this.timingHistory.adaptiveCompletionConfirmMs; + } + + return this.timingHistory.adaptiveCompletionConfirmMs; + } + + /** + * Get the current timing history for monitoring. + * @returns Copy of timing history + */ + getTimingHistory(): TimingHistory { + return { ...this.timingHistory }; + } + + /** + * Reset all timing history. + */ + reset(): void { + this.timingHistory = { + recentIdleDetectionMs: [], + recentCycleDurationMs: [], + adaptiveCompletionConfirmMs: 10000, + sampleCount: 0, + maxSamples: 20, + lastUpdatedAt: Date.now(), + }; + } + + /** + * Recalculate the adaptive completion confirm timeout based on historical data. + * Uses the 75th percentile of recent idle detection times as the new timeout, + * with a 20% buffer for safety. + */ + private updateAdaptiveTiming(): void { + const history = this.timingHistory; + const minMs = this.config.adaptiveMinConfirmMs; + const maxMs = this.config.adaptiveMaxConfirmMs; + + if (history.recentIdleDetectionMs.length < 5) return; + + // Sort for percentile calculation + const sorted = [...history.recentIdleDetectionMs].sort((a, b) => a - b); + + // Use 75th percentile with 20% buffer + const p75Index = Math.floor(sorted.length * 0.75); + const p75Value = sorted[p75Index]; + const withBuffer = Math.round(p75Value * 1.2); + + // Clamp to configured bounds + const clamped = Math.max(minMs, Math.min(maxMs, withBuffer)); + + history.adaptiveCompletionConfirmMs = clamped; + } +} diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index ce5a611a..525fb322 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -41,28 +41,29 @@ import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle- import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js'; import type { TeamWatcher } from './team-watcher.js'; import { BufferAccumulator } from './utils/buffer-accumulator.js'; -import { ANSI_ESCAPE_PATTERN_SIMPLE, TOKEN_PATTERN, assertNever, CleanupManager } from './utils/index.js'; +import { ANSI_ESCAPE_PATTERN_SIMPLE, assertNever } from './utils/index.js'; import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js'; +import { + isCompletionMessage, + hasWorkingPattern, + extractTokenCount, + PROMPT_PATTERNS, + WORKING_PATTERNS, +} from './respawn-patterns.js'; +import { RespawnAdaptiveTiming } from './respawn-adaptive-timing.js'; +import { RespawnCycleMetricsTracker } from './respawn-metrics.js'; +import { calculateHealthScore, shouldSkipClear, type HealthInputs } from './respawn-health.js'; import type { RespawnCycleMetrics, RespawnAggregateMetrics, RalphLoopHealthScore, TimingHistory, CycleOutcome, - HealthStatus, } from './types.js'; // ========== Constants ========== -/** - * Pattern to detect completion messages from Claude Code. - * Requires "Worked for" prefix to avoid false positives from bare time durations - * in regular text (e.g., "wait for 5s", "run for 2m"). - * - * Matches: "✻ Worked for 2m 46s", "Worked for 46s", "Worked for 1h 2m 3s" - * Does NOT match: "wait for 5s", "run for 2m", "for 3s the system..." - */ -const COMPLETION_TIME_PATTERN = /\bWorked\s+for\s+\d+[hms](\s*\d+[hms])*/i; +// COMPLETION_TIME_PATTERN moved to ./respawn-patterns.ts /** Pre-filter: numbered option pattern for plan mode detection */ const PLAN_MODE_OPTION_PATTERN = /\d+\.\s+(Yes|No|Type|Cancel|Skip|Proceed|Approve|Reject)/i; @@ -641,29 +642,26 @@ export class RespawnController extends EventEmitter { /** Current state machine state */ private _state: RespawnState = 'stopped'; - /** Centralized resource cleanup manager for timers */ - private cleanup = new CleanupManager(); + /** Timer for step delays */ + private stepTimer: NodeJS.Timeout | null = null; - /** Timer ID for step delays */ - private stepTimerId: string | null = null; + /** Timer for completion confirmation (Layer 2) */ + private completionConfirmTimer: NodeJS.Timeout | null = null; - /** Timer ID for completion confirmation (Layer 2) */ - private completionConfirmTimerId: string | null = null; + /** Timer for no-output fallback (Layer 5) */ + private noOutputTimer: NodeJS.Timeout | null = null; - /** Timer ID for no-output fallback (Layer 5) */ - private noOutputTimerId: string | null = null; - - /** Timer ID for periodic detection status updates */ - private detectionUpdateTimerId: string | null = null; + /** Timer for periodic detection status updates */ + private detectionUpdateTimer: NodeJS.Timeout | null = null; /** Cached key fields from last emitted detection status (for dedup) */ private lastEmittedDetectionKey: string = ''; - /** Timer ID for auto-accepting plan mode prompts */ - private autoAcceptTimerId: string | null = null; + /** Timer for auto-accepting plan mode prompts */ + private autoAcceptTimer: NodeJS.Timeout | null = null; - /** Timer ID for pre-filter silence detection (triggers AI check) */ - private preFilterTimerId: string | null = null; + /** Timer for pre-filter silence detection (triggers AI check) */ + private preFilterTimer: NodeJS.Timeout | null = null; /** Whether any terminal output has been received since start/last-auto-accept */ private hasReceivedOutput: boolean = false; @@ -685,8 +683,8 @@ export class RespawnController extends EventEmitter { /** Timestamp when idle_prompt was received */ private idlePromptTime: number | null = null; - /** Timer ID for short confirmation after hook signal (handles race conditions) */ - private hookConfirmTimerId: string | null = null; + /** Timer for short confirmation after hook signal (handles race conditions) */ + private hookConfirmTimer: NodeJS.Timeout | null = null; /** Confirmation delay after hook signal before confirming idle (ms) */ private static readonly HOOK_CONFIRM_DELAY_MS = 3000; @@ -721,11 +719,11 @@ export class RespawnController extends EventEmitter { /** Unique ID for current AI check request (to detect stale results) */ private _currentAiCheckId: string | null = null; - /** Timer ID for /clear step fallback (sends /init if no prompt detected) */ - private clearFallbackTimerId: string | null = null; + /** Timer for /clear step fallback (sends /init if no prompt detected) */ + private clearFallbackTimer: NodeJS.Timeout | null = null; - /** Timer ID for step completion confirmation (waits for silence after completion) */ - private stepConfirmTimerId: string | null = null; + /** Timer for step completion confirmation (waits for silence after completion) */ + private stepConfirmTimer: NodeJS.Timeout | null = null; /** Fallback timeout for /clear step (ms) - sends /init without waiting for prompt */ private static readonly CLEAR_FALLBACK_TIMEOUT_MS = 10000; @@ -744,8 +742,8 @@ export class RespawnController extends EventEmitter { /** Timestamp when the current state was entered */ private stateEnteredAt: number = 0; - /** Timer ID for stuck-state detection */ - private stuckStateTimerId: string | null = null; + /** Timer for stuck-state detection */ + private stuckStateTimer: NodeJS.Timeout | null = null; /** Whether a stuck-state warning has been emitted for current state */ private stuckStateWarned: boolean = false; @@ -753,46 +751,19 @@ export class RespawnController extends EventEmitter { /** Number of stuck-state recovery attempts */ private stuckRecoveryCount: number = 0; - // ========== P2-001: Adaptive Timing State ========== + // ========== P2-001: Adaptive Timing (delegated to RespawnAdaptiveTiming) ========== - /** Historical timing data for adaptive adjustments */ - private timingHistory: TimingHistory = { - recentIdleDetectionMs: [], - recentCycleDurationMs: [], - adaptiveCompletionConfirmMs: 10000, // Start with default - sampleCount: 0, - maxSamples: 20, // Keep last 20 samples for rolling average - lastUpdatedAt: Date.now(), - }; + /** Adaptive timing controller */ + private adaptiveTiming: RespawnAdaptiveTiming; - // ========== P2-004: Cycle Metrics State ========== + // ========== P2-004: Cycle Metrics (delegated to RespawnCycleMetricsTracker) ========== - /** Current cycle being tracked */ - private currentCycleMetrics: Partial | null = null; + /** Cycle metrics tracker */ + private cycleMetrics: RespawnCycleMetricsTracker = new RespawnCycleMetricsTracker(); /** Timestamp when idle detection started for current cycle */ private idleDetectionStartTime: number = 0; - /** Recent cycle metrics (rolling window for aggregate calculation) */ - private recentCycleMetrics: RespawnCycleMetrics[] = []; - - /** Maximum number of cycle metrics to keep in memory */ - private static readonly MAX_CYCLE_METRICS_IN_MEMORY = 100; - - /** Aggregate metrics across all tracked cycles */ - private aggregateMetrics: RespawnAggregateMetrics = { - totalCycles: 0, - successfulCycles: 0, - stuckRecoveryCycles: 0, - blockedCycles: 0, - errorCycles: 0, - avgCycleDurationMs: 0, - avgIdleDetectionMs: 0, - p90CycleDurationMs: 0, - successRate: 100, - lastUpdatedAt: Date.now(), - }; - // ========== Multi-Layer Detection State ========== /** Layer 1: Timestamp when completion message was detected */ @@ -810,72 +781,7 @@ export class RespawnController extends EventEmitter { /** Layer 4: Timestamp when last working pattern was seen */ private lastWorkingPatternTime: number = 0; - /** - * Patterns indicating Claude is ready for input (legacy fallback). - * Used as secondary signals, not primary detection. - */ - private readonly PROMPT_PATTERNS = [ - '❯', // Standard prompt - '\u276f', // Unicode variant - '⏵', // Claude Code prompt variant - ]; - - /** - * Patterns indicating Claude is actively working. - * When detected, resets all idle detection timers. - * Note: ✻ and ✽ removed - they appear in completion messages too. - */ - private readonly WORKING_PATTERNS = [ - 'Thinking', - 'Writing', - 'Reading', - 'Running', - 'Searching', - 'Editing', - 'Creating', - 'Deleting', - 'Analyzing', - 'Executing', - 'Synthesizing', - 'Brewing', // Claude's processing indicators - 'Compiling', - 'Building', - 'Installing', - 'Fetching', - 'Downloading', - 'Processing', - 'Generating', - 'Loading', - 'Starting', - 'Updating', - 'Checking', - 'Validating', - 'Testing', - 'Formatting', - 'Linting', - '⠋', - '⠙', - '⠹', - '⠸', - '⠼', - '⠴', - '⠦', - '⠧', - '⠇', - '⠏', // Spinner chars - '◐', - '◓', - '◑', - '◒', // Alternative spinners - '⣾', - '⣽', - '⣻', - '⢿', - '⡿', - '⣟', - '⣯', - '⣷', // Braille spinners - ]; + // PROMPT_PATTERNS and WORKING_PATTERNS are now imported from ./respawn-patterns.js /** * Rolling window buffer for working pattern detection. @@ -909,6 +815,12 @@ export class RespawnController extends EventEmitter { // Validate configuration values this.validateConfig(); + // Initialize sub-modules + this.adaptiveTiming = new RespawnAdaptiveTiming({ + adaptiveMinConfirmMs: this.config.adaptiveMinConfirmMs ?? 5000, + adaptiveMaxConfirmMs: this.config.adaptiveMaxConfirmMs ?? 30000, + }); + this.aiChecker = new AiIdleChecker(session.id, { enabled: this.config.aiIdleCheckEnabled, model: this.config.aiIdleCheckModel, @@ -1203,35 +1115,31 @@ export class RespawnController extends EventEmitter { this.stopDetectionUpdates(); if (this._state === 'stopped') return; this.lastEmittedDetectionKey = ''; - this.detectionUpdateTimerId = this.cleanup.setInterval( - () => { - try { - if (this._state !== 'stopped') { - const status = this.getDetectionStatus(); - // Only emit when status meaningfully changed (confidence, state text, or timer values) - // to avoid broadcasting identical data every 2s for stable/idle sessions. - const key = `${status.confidenceLevel}|${status.statusText}|${this._state}`; - if (key !== this.lastEmittedDetectionKey) { - this.lastEmittedDetectionKey = key; - this.emit('detectionUpdate', status); - } + this.detectionUpdateTimer = setInterval(() => { + try { + if (this._state !== 'stopped') { + const status = this.getDetectionStatus(); + // Only emit when status meaningfully changed (confidence, state text, or timer values) + // to avoid broadcasting identical data every 2s for stable/idle sessions. + const key = `${status.confidenceLevel}|${status.statusText}|${this._state}`; + if (key !== this.lastEmittedDetectionKey) { + this.lastEmittedDetectionKey = key; + this.emit('detectionUpdate', status); } - } catch (err) { - console.error(`[RespawnController] Error in detectionUpdateTimer:`, err); } - }, - 2000, - { description: 'detection status updates' } - ); + } catch (err) { + console.error(`[RespawnController] Error in detectionUpdateTimer:`, err); + } + }, 2000); } /** * Stop periodic detection status updates. */ private stopDetectionUpdates(): void { - if (this.detectionUpdateTimerId) { - this.cleanup.unregister(this.detectionUpdateTimerId); - this.detectionUpdateTimerId = null; + if (this.detectionUpdateTimer) { + clearInterval(this.detectionUpdateTimer); + this.detectionUpdateTimer = null; } } @@ -1456,7 +1364,7 @@ export class RespawnController extends EventEmitter { } // Track token count (Layer 3) - const tokenCount = this.extractTokenCount(data); + const tokenCount = extractTokenCount(data); if (tokenCount !== null && tokenCount !== this.lastTokenCount) { this.lastTokenCount = tokenCount; this.lastTokenChangeTime = now; @@ -1465,7 +1373,7 @@ export class RespawnController extends EventEmitter { // Detect completion message FIRST (Layer 1) - PRIMARY DETECTION // Check this before working patterns because completion message indicates // the work is done, even if working patterns are still in the rolling window - if (this.isCompletionMessage(data)) { + if (isCompletionMessage(data)) { // Clear the rolling window - completion marks a transition point this.clearWorkingPatternWindow(); this.workingDetected = false; @@ -1513,7 +1421,7 @@ export class RespawnController extends EventEmitter { } // Detect working patterns (Layer 4) - const isWorking = this.hasWorkingPattern(data); + const isWorking = this.checkWorkingPattern(data); if (isWorking) { this.workingDetected = true; this.promptDetected = false; @@ -1522,8 +1430,8 @@ export class RespawnController extends EventEmitter { this.lastWorkingPatternTime = now; // Cancel hook confirmation timer if running - this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimerId, 'working patterns detected'); - this.hookConfirmTimerId = null; + this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimer, 'working patterns detected'); + this.hookConfirmTimer = null; // Cancel any pending completion confirmation this.cancelCompletionConfirm(); @@ -1576,7 +1484,7 @@ export class RespawnController extends EventEmitter { } // Legacy fallback: detect prompt characters (still useful for waiting_* states) - const hasPrompt = this.PROMPT_PATTERNS.some((pattern) => data.includes(pattern)); + const hasPrompt = PROMPT_PATTERNS.some((pattern) => data.includes(pattern)); if (hasPrompt) { this.promptDetected = true; this.workingDetected = false; @@ -1630,10 +1538,8 @@ export class RespawnController extends EventEmitter { if (this.config.sendClear) { // P2-002: Check if we should skip /clear - if (this.shouldSkipClear()) { - if (this.currentCycleMetrics) { - this.currentCycleMetrics.clearSkipped = true; - } + if (this.checkShouldSkipClear()) { + this.cycleMetrics.markClearSkipped(); // Skip /clear, go directly to /init or complete if (this.config.sendInit) { this.sendInit(); @@ -1657,8 +1563,8 @@ export class RespawnController extends EventEmitter { */ private checkClearComplete(): void { // Clear the fallback timer since we got prompt detection - this.cancelTrackedTimer('clear-fallback', this.clearFallbackTimerId, 'prompt detected'); - this.clearFallbackTimerId = null; + this.cancelTrackedTimer('clear-fallback', this.clearFallbackTimer, 'prompt detected'); + this.clearFallbackTimer = null; this.logAction('step', '/clear completed'); this.emit('stepCompleted', 'clear'); @@ -1706,11 +1612,11 @@ export class RespawnController extends EventEmitter { this.logAction('step', 'Monitoring if /init triggered work...'); // Give Claude a moment to start working before checking for idle - this.stepTimerId = this.startTrackedTimer( + this.stepTimer = this.startTrackedTimer( 'init-monitor', 3000, () => { - this.stepTimerId = null; + this.stepTimer = null; // If still in monitoring state and no work detected, consider it idle if (this._state === 'monitoring_init' && !this.workingDetected) { this.checkMonitoringInitIdle(); @@ -1726,9 +1632,9 @@ export class RespawnController extends EventEmitter { * @fires stepCompleted - With step 'init' */ private checkMonitoringInitIdle(): void { - if (this.stepTimerId) { - this.cleanup.unregister(this.stepTimerId); - this.stepTimerId = null; + if (this.stepTimer) { + clearTimeout(this.stepTimer); + this.stepTimer = null; } this.log('/init did not trigger work, sending kickstart prompt'); this.emit('stepCompleted', 'init'); @@ -1744,11 +1650,11 @@ export class RespawnController extends EventEmitter { this.terminalBuffer.clear(); this.clearWorkingPatternWindow(); - this.stepTimerId = this.startTrackedTimer( + this.stepTimer = this.startTrackedTimer( 'step-delay', this.config.interStepDelayMs, async () => { - this.stepTimerId = null; + this.stepTimer = null; if (this._state === 'stopped') return; const prompt = this.config.kickstartPrompt!; this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`); @@ -1780,20 +1686,46 @@ export class RespawnController extends EventEmitter { private clearTimers(): void { // Clear tracked timers map first to avoid stale entries during individual cleanup this.activeTimers.clear(); - this.cleanup.dispose(); - // Reinitialize for reuse (controller can be stopped and restarted) - this.cleanup = new CleanupManager(); - // Null out IDs - this.stepTimerId = null; - this.completionConfirmTimerId = null; - this.noOutputTimerId = null; - this.detectionUpdateTimerId = null; - this.autoAcceptTimerId = null; - this.preFilterTimerId = null; - this.hookConfirmTimerId = null; - this.clearFallbackTimerId = null; - this.stepConfirmTimerId = null; - this.stuckStateTimerId = null; + if (this.stepTimer) { + clearTimeout(this.stepTimer); + this.stepTimer = null; + } + if (this.clearFallbackTimer) { + clearTimeout(this.clearFallbackTimer); + this.clearFallbackTimer = null; + } + if (this.completionConfirmTimer) { + clearTimeout(this.completionConfirmTimer); + this.completionConfirmTimer = null; + } + if (this.stepConfirmTimer) { + clearTimeout(this.stepConfirmTimer); + this.stepConfirmTimer = null; + } + if (this.autoAcceptTimer) { + clearTimeout(this.autoAcceptTimer); + this.autoAcceptTimer = null; + } + if (this.preFilterTimer) { + clearTimeout(this.preFilterTimer); + this.preFilterTimer = null; + } + if (this.noOutputTimer) { + clearTimeout(this.noOutputTimer); + this.noOutputTimer = null; + } + if (this.hookConfirmTimer) { + clearTimeout(this.hookConfirmTimer); + this.hookConfirmTimer = null; + } + if (this.stuckStateTimer) { + clearInterval(this.stuckStateTimer); + this.stuckStateTimer = null; + } + if (this.detectionUpdateTimer) { + clearInterval(this.detectionUpdateTimer); + this.detectionUpdateTimer = null; + } } // ========== Stuck-State Detection Methods ========== @@ -1807,25 +1739,21 @@ export class RespawnController extends EventEmitter { if (this._state === 'stopped') return; // Clear existing timer - if (this.stuckStateTimerId) { - this.cleanup.unregister(this.stuckStateTimerId); - this.stuckStateTimerId = null; + if (this.stuckStateTimer) { + clearInterval(this.stuckStateTimer); + this.stuckStateTimer = null; } // Check interval for stuck state const checkIntervalMs = Math.min(this.config.stuckStateWarningMs, 60000); // Check every minute max - this.stuckStateTimerId = this.cleanup.setInterval( - () => { - try { - this.checkStuckState(); - } catch (err) { - console.error(`[RespawnController] Error in stuckStateTimer:`, err); - } - }, - checkIntervalMs, - { description: 'stuck-state detection' } - ); + this.stuckStateTimer = setInterval(() => { + try { + this.checkStuckState(); + } catch (err) { + console.error(`[RespawnController] Error in stuckStateTimer:`, err); + } + }, checkIntervalMs); } /** @@ -1874,7 +1802,7 @@ export class RespawnController extends EventEmitter { const currentState = this._state; // P2-004: Complete current cycle metrics with stuck_recovery outcome - if (this.currentCycleMetrics) { + if (this.cycleMetrics.getCurrentCycle()) { this.completeCycleMetrics('stuck_recovery', `Stuck in state: ${currentState}`); } @@ -1966,7 +1894,7 @@ export class RespawnController extends EventEmitter { * Start a tracked timer with UI countdown support. * Emits timerStarted event and tracks the timer for UI display. */ - private startTrackedTimer(name: string, durationMs: number, callback: () => void, reason?: string): string { + private startTrackedTimer(name: string, durationMs: number, callback: () => void, reason?: string): NodeJS.Timeout { const now = Date.now(); const endsAt = now + durationMs; @@ -1974,23 +1902,19 @@ export class RespawnController extends EventEmitter { this.emit('timerStarted', { name, durationMs, endsAt, reason }); this.logAction('timer', `Started ${name}: ${Math.round(durationMs / 1000)}s${reason ? ` (${reason})` : ''}`); - return this.cleanup.setTimeout( - () => { - this.activeTimers.delete(name); - this.emit('timerCompleted', name); - callback(); - }, - durationMs, - { description: name } - ); + return setTimeout(() => { + this.activeTimers.delete(name); + this.emit('timerCompleted', name); + callback(); + }, durationMs); } /** * Cancel a tracked timer and emit cancellation event. */ - private cancelTrackedTimer(name: string, timerId: string | null, reason?: string): void { - if (timerId) { - this.cleanup.unregister(timerId); + private cancelTrackedTimer(name: string, timerRef: NodeJS.Timeout | null, reason?: string): void { + if (timerRef) { + clearTimeout(timerRef); if (this.activeTimers.has(name)) { this.activeTimers.delete(name); this.emit('timerCancelled', name, reason); @@ -2032,28 +1956,21 @@ export class RespawnController extends EventEmitter { } // ========== Multi-Layer Detection Methods ========== + // Pattern detection delegated to ./respawn-patterns.js (isCompletionMessage, hasWorkingPattern, extractTokenCount) /** - * Check if data contains a completion message pattern. - * Matches "for Xh Xm Xs" time duration patterns. + * Check if data contains working patterns using the rolling window. + * Updates the window and delegates to the pure function from respawn-patterns. */ - private isCompletionMessage(data: string): boolean { - return COMPLETION_TIME_PATTERN.test(data); - } - - /** - * Check if data contains working patterns. - * Uses rolling window to catch patterns split across chunks (e.g., "Thin" + "king"). - */ - private hasWorkingPattern(data: string): boolean { + private checkWorkingPattern(data: string): boolean { // Always update the rolling window first to maintain continuity this.workingPatternWindow += data; if (this.workingPatternWindow.length > RespawnController.WORKING_PATTERN_WINDOW_SIZE) { this.workingPatternWindow = this.workingPatternWindow.slice(-RespawnController.WORKING_PATTERN_WINDOW_SIZE); } - // Check the rolling window (includes current data, catches both complete and split patterns) - return this.WORKING_PATTERNS.some((pattern) => this.workingPatternWindow.includes(pattern)); + // Delegate to pure function + return hasWorkingPattern(this.workingPatternWindow); } /** @@ -2064,36 +1981,20 @@ export class RespawnController extends EventEmitter { this.workingPatternWindow = ''; } - /** - * Extract token count from data if present. - * Returns null if no token pattern found. - */ - private extractTokenCount(data: string): number | null { - const match = data.match(TOKEN_PATTERN); - if (!match) return null; - - let count = parseFloat(match[1]); - const suffix = match[2]?.toLowerCase(); - if (suffix === 'k') count *= 1000; - else if (suffix === 'm') count *= 1000000; - - return Math.round(count); - } - /** * Start the no-output fallback timer. * If no output for noOutputTimeoutMs, triggers idle detection as safety net * (used when AI check is disabled or has too many errors). */ private startNoOutputTimer(): void { - this.cancelTrackedTimer('no-output-fallback', this.noOutputTimerId, 'restarting'); - this.noOutputTimerId = null; + this.cancelTrackedTimer('no-output-fallback', this.noOutputTimer, 'restarting'); + this.noOutputTimer = null; - this.noOutputTimerId = this.startTrackedTimer( + this.noOutputTimer = this.startTrackedTimer( 'no-output-fallback', this.config.noOutputTimeoutMs, () => { - this.noOutputTimerId = null; + this.noOutputTimer = null; if (this._state === 'watching' || this._state === 'confirming_idle') { const msSinceOutput = Date.now() - this.lastOutputTime; this.logAction('detection', `No-output fallback: ${Math.round(msSinceOutput / 1000)}s silence`); @@ -2126,17 +2027,17 @@ export class RespawnController extends EventEmitter { * This provides an additional path to AI check even without a completion message. */ private startPreFilterTimer(): void { - this.cancelTrackedTimer('pre-filter', this.preFilterTimerId, 'restarting'); - this.preFilterTimerId = null; + this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'restarting'); + this.preFilterTimer = null; // Only set up pre-filter when AI check is enabled if (!this.config.aiIdleCheckEnabled) return; - this.preFilterTimerId = this.startTrackedTimer( + this.preFilterTimer = this.startTrackedTimer( 'pre-filter', this.config.completionConfirmMs, () => { - this.preFilterTimerId = null; + this.preFilterTimer = null; if (this._state === 'watching') { const now = Date.now(); const msSinceOutput = now - this.lastOutputTime; @@ -2241,18 +2142,18 @@ export class RespawnController extends EventEmitter { if (result.verdict === 'IDLE') { // Cancel any pending confirmation timers - AI has spoken - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'AI verdict: IDLE'); - this.completionConfirmTimerId = null; - this.cancelTrackedTimer('pre-filter', this.preFilterTimerId, 'AI verdict: IDLE'); - this.preFilterTimerId = null; + this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE'); + this.completionConfirmTimer = null; + this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE'); + this.preFilterTimer = null; this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`); this.emit('aiCheckCompleted', result); this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`); } else if (result.verdict === 'WORKING') { // Cancel timers and go to cooldown - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'AI verdict: WORKING'); - this.completionConfirmTimerId = null; + this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING'); + this.completionConfirmTimer = null; this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`); this.emit('aiCheckCompleted', result); @@ -2308,14 +2209,14 @@ export class RespawnController extends EventEmitter { * and no elicitation dialog was detected. Only handles plan mode approvals. */ private startAutoAcceptTimer(): void { - this.cancelTrackedTimer('auto-accept', this.autoAcceptTimerId, 'restarting'); - this.autoAcceptTimerId = null; + this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'restarting'); + this.autoAcceptTimer = null; - this.autoAcceptTimerId = this.startTrackedTimer( + this.autoAcceptTimer = this.startTrackedTimer( 'auto-accept', this.config.autoAcceptDelayMs, () => { - this.autoAcceptTimerId = null; + this.autoAcceptTimer = null; this.tryAutoAccept(); }, 'plan mode detection' @@ -2327,8 +2228,8 @@ export class RespawnController extends EventEmitter { * Called when a completion message is detected (normal idle flow handles it). */ private cancelAutoAcceptTimer(): void { - this.cancelTrackedTimer('auto-accept', this.autoAcceptTimerId, 'cancelled'); - this.autoAcceptTimerId = null; + this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'cancelled'); + this.autoAcceptTimer = null; } /** @@ -2418,7 +2319,7 @@ export class RespawnController extends EventEmitter { // Working patterns before the selector are from earlier work and don't matter. const selectorIndex = stripped.lastIndexOf(selectorMatch[0]); const afterSelector = stripped.slice(selectorIndex + selectorMatch[0].length); - const hasWorking = this.WORKING_PATTERNS.some((pattern) => afterSelector.includes(pattern)); + const hasWorking = WORKING_PATTERNS.some((pattern) => afterSelector.includes(pattern)); if (hasWorking) return false; return true; @@ -2489,8 +2390,8 @@ export class RespawnController extends EventEmitter { } // Cancel completion confirmation - auto-accept takes precedence - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'auto-accept'); - this.completionConfirmTimerId = null; + this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'auto-accept'); + this.completionConfirmTimer = null; this.completionMessageTime = null; // Ensure we're in watching state (not confirming_idle or ai_checking) @@ -2544,12 +2445,12 @@ export class RespawnController extends EventEmitter { } // Cancel completion confirm timer - hook takes precedence - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'Stop hook received'); - this.completionConfirmTimerId = null; + this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'Stop hook received'); + this.completionConfirmTimer = null; // Cancel pre-filter timer - hook takes precedence - this.cancelTrackedTimer('pre-filter', this.preFilterTimerId, 'Stop hook received'); - this.preFilterTimerId = null; + this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'Stop hook received'); + this.preFilterTimer = null; // Start short confirmation timer to handle race conditions // (e.g., Stop hook arrives but Claude immediately starts new work) @@ -2583,12 +2484,12 @@ export class RespawnController extends EventEmitter { } // Cancel all other detection timers - this is definitive - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'idle_prompt received'); - this.completionConfirmTimerId = null; - this.cancelTrackedTimer('pre-filter', this.preFilterTimerId, 'idle_prompt received'); - this.preFilterTimerId = null; - this.cancelTrackedTimer('no-output-fallback', this.noOutputTimerId, 'idle_prompt received'); - this.noOutputTimerId = null; + this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'idle_prompt received'); + this.completionConfirmTimer = null; + this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'idle_prompt received'); + this.preFilterTimer = null; + this.cancelTrackedTimer('no-output-fallback', this.noOutputTimer, 'idle_prompt received'); + this.noOutputTimer = null; // idle_prompt is an even stronger signal than Stop hook (60s+ idle) // Skip confirmation and go directly to idle @@ -2602,14 +2503,14 @@ export class RespawnController extends EventEmitter { * @param hookType - Which hook triggered this ('stop' or 'idle_prompt') */ private startHookConfirmTimer(hookType: 'stop' | 'idle_prompt'): void { - this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimerId, 'restarting'); - this.hookConfirmTimerId = null; + this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimer, 'restarting'); + this.hookConfirmTimer = null; - this.hookConfirmTimerId = this.startTrackedTimer( + this.hookConfirmTimer = this.startTrackedTimer( 'hook-confirm', RespawnController.HOOK_CONFIRM_DELAY_MS, () => { - this.hookConfirmTimerId = null; + this.hookConfirmTimer = null; // Verify we haven't received new output since the hook arrived const hookTime = hookType === 'stop' ? this.stopHookTime : this.idlePromptTime; @@ -2683,17 +2584,17 @@ export class RespawnController extends EventEmitter { * After completion message, waits for output silence then triggers AI check. */ private startCompletionConfirmTimer(): void { - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'restarting'); - this.completionConfirmTimerId = null; + this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'restarting'); + this.completionConfirmTimer = null; this.setState('confirming_idle'); this.logAction('detection', 'Completion message found in output'); - this.completionConfirmTimerId = this.startTrackedTimer( + this.completionConfirmTimer = this.startTrackedTimer( 'completion-confirm', this.config.completionConfirmMs, () => { - this.completionConfirmTimerId = null; + this.completionConfirmTimer = null; if (this._state === 'stopped') return; const msSinceOutput = Date.now() - this.lastOutputTime; if (msSinceOutput >= this.config.completionConfirmMs) { @@ -2714,8 +2615,8 @@ export class RespawnController extends EventEmitter { * Cancel completion confirmation if new activity detected. */ private cancelCompletionConfirm(): void { - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'activity detected'); - this.completionConfirmTimerId = null; + this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'activity detected'); + this.completionConfirmTimer = null; if (this._state === 'confirming_idle') { this.setState('watching'); this.completionMessageTime = null; @@ -2728,14 +2629,14 @@ export class RespawnController extends EventEmitter { * This ensures Claude has finished processing before we send the next command. */ private startStepConfirmTimer(step: 'update' | 'init' | 'kickstart'): void { - this.cancelTrackedTimer('step-confirm', this.stepConfirmTimerId, 'restarting'); - this.stepConfirmTimerId = null; + this.cancelTrackedTimer('step-confirm', this.stepConfirmTimer, 'restarting'); + this.stepConfirmTimer = null; - this.stepConfirmTimerId = this.startTrackedTimer( + this.stepConfirmTimer = this.startTrackedTimer( 'step-confirm', this.config.completionConfirmMs, () => { - this.stepConfirmTimerId = null; + this.stepConfirmTimer = null; if (this._state === 'stopped') return; const msSinceOutput = Date.now() - this.lastOutputTime; @@ -2768,8 +2669,8 @@ export class RespawnController extends EventEmitter { * Cancel step confirmation if working patterns detected. */ private cancelStepConfirm(): void { - this.cancelTrackedTimer('step-confirm', this.stepConfirmTimerId, 'working detected'); - this.stepConfirmTimerId = null; + this.cancelTrackedTimer('step-confirm', this.stepConfirmTimer, 'working detected'); + this.stepConfirmTimer = null; } /** @@ -2931,11 +2832,11 @@ export class RespawnController extends EventEmitter { this.terminalBuffer.clear(); // Clear buffer for fresh detection this.clearWorkingPatternWindow(); // Clear rolling window - this.stepTimerId = this.startTrackedTimer( + this.stepTimer = this.startTrackedTimer( 'step-delay', this.config.interStepDelayMs, async () => { - this.stepTimerId = null; + this.stepTimer = null; if (this._state === 'stopped') return; // Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config @@ -2972,11 +2873,11 @@ export class RespawnController extends EventEmitter { this.terminalBuffer.clear(); this.clearWorkingPatternWindow(); - this.stepTimerId = this.startTrackedTimer( + this.stepTimer = this.startTrackedTimer( 'step-delay', this.config.interStepDelayMs, async () => { - this.stepTimerId = null; + this.stepTimer = null; if (this._state === 'stopped') return; this.logAction('command', 'Sending: /clear'); await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI @@ -2985,11 +2886,11 @@ export class RespawnController extends EventEmitter { this.promptDetected = false; // Start fallback timer - if no prompt detected after 10s, proceed to /init anyway - this.clearFallbackTimerId = this.startTrackedTimer( + this.clearFallbackTimer = this.startTrackedTimer( 'clear-fallback', RespawnController.CLEAR_FALLBACK_TIMEOUT_MS, () => { - this.clearFallbackTimerId = null; + this.clearFallbackTimer = null; if (this._state === 'waiting_clear') { this.logAction('step', '/clear fallback: proceeding to /init'); this.emit('stepCompleted', 'clear'); @@ -3016,11 +2917,11 @@ export class RespawnController extends EventEmitter { this.terminalBuffer.clear(); this.clearWorkingPatternWindow(); - this.stepTimerId = this.startTrackedTimer( + this.stepTimer = this.startTrackedTimer( 'step-delay', this.config.interStepDelayMs, async () => { - this.stepTimerId = null; + this.stepTimer = null; if (this._state === 'stopped') return; this.logAction('command', 'Sending: /init'); await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI @@ -3166,7 +3067,7 @@ export class RespawnController extends EventEmitter { }; } - // ========== P2-001: Adaptive Timing Methods ========== + // ========== P2-001: Adaptive Timing (delegated to RespawnAdaptiveTiming) ========== /** * Get the current completion confirm timeout, potentially adjusted by adaptive timing. @@ -3180,68 +3081,12 @@ export class RespawnController extends EventEmitter { } // Need at least 5 samples before adjusting - if (this.timingHistory.sampleCount < 5) { + const history = this.adaptiveTiming.getTimingHistory(); + if (history.sampleCount < 5) { return this.config.completionConfirmMs ?? 10000; } - return this.timingHistory.adaptiveCompletionConfirmMs; - } - - /** - * Record timing data from a completed cycle for adaptive adjustments. - * - * @param idleDetectionMs - Time spent detecting idle - * @param cycleDurationMs - Total cycle duration - */ - private recordTimingData(idleDetectionMs: number, cycleDurationMs: number): void { - if (!this.config.adaptiveTimingEnabled) return; - - const history = this.timingHistory; - - // Add to rolling windows - history.recentIdleDetectionMs.push(idleDetectionMs); - history.recentCycleDurationMs.push(cycleDurationMs); - - // Trim to max samples - if (history.recentIdleDetectionMs.length > history.maxSamples) { - history.recentIdleDetectionMs.shift(); - } - if (history.recentCycleDurationMs.length > history.maxSamples) { - history.recentCycleDurationMs.shift(); - } - - history.sampleCount = history.recentIdleDetectionMs.length; - history.lastUpdatedAt = Date.now(); - - // Recalculate adaptive timing - this.updateAdaptiveTiming(); - } - - /** - * Recalculate the adaptive completion confirm timeout based on historical data. - * Uses the 75th percentile of recent idle detection times as the new timeout, - * with a 20% buffer for safety. - */ - private updateAdaptiveTiming(): void { - const history = this.timingHistory; - const minMs = this.config.adaptiveMinConfirmMs ?? 5000; - const maxMs = this.config.adaptiveMaxConfirmMs ?? 30000; - - if (history.recentIdleDetectionMs.length < 5) return; - - // Sort for percentile calculation - const sorted = [...history.recentIdleDetectionMs].sort((a, b) => a - b); - - // Use 75th percentile with 20% buffer - const p75Index = Math.floor(sorted.length * 0.75); - const p75Value = sorted[p75Index]; - const withBuffer = Math.round(p75Value * 1.2); - - // Clamp to configured bounds - const clamped = Math.max(minMs, Math.min(maxMs, withBuffer)); - - history.adaptiveCompletionConfirmMs = clamped; - this.log(`Adaptive timing updated: ${clamped}ms (p75=${p75Value}ms, samples=${sorted.length})`); + return this.adaptiveTiming.getAdaptiveCompletionConfirmMs(); } /** @@ -3249,10 +3094,10 @@ export class RespawnController extends EventEmitter { * @returns Copy of timing history */ getTimingHistory(): TimingHistory { - return { ...this.timingHistory }; + return this.adaptiveTiming.getTimingHistory(); } - // ========== P2-002: Skip-Clear Optimization Methods ========== + // ========== P2-002: Skip-Clear Optimization (delegated to respawn-health.ts) ========== /** * Determine whether to skip the /clear step based on current context usage. @@ -3260,28 +3105,24 @@ export class RespawnController extends EventEmitter { * * @returns True if /clear should be skipped */ - private shouldSkipClear(): boolean { + private checkShouldSkipClear(): boolean { if (!this.config.skipClearWhenLowContext) return false; const thresholdPercent = this.config.skipClearThresholdPercent ?? 30; const maxContext = 200000; // Approximate max context for Claude - // Use the session's token count if available - const currentTokens = this.lastTokenCount; - if (currentTokens === 0) return false; // Can't determine, don't skip + const skip = shouldSkipClear(this.lastTokenCount, thresholdPercent, maxContext); - const usagePercent = (currentTokens / maxContext) * 100; - - if (usagePercent < thresholdPercent) { - this.log(`Skip-clear optimization: ${usagePercent.toFixed(1)}% < ${thresholdPercent}% threshold`); - this.logAction('optimization', `Skipping /clear (${usagePercent.toFixed(1)}% context used)`); - return true; + if (skip) { + const usagePercent = ((this.lastTokenCount / maxContext) * 100).toFixed(1); + this.log(`Skip-clear optimization: ${usagePercent}% < ${thresholdPercent}% threshold`); + this.logAction('optimization', `Skipping /clear (${usagePercent}% context used)`); } - return false; + return skip; } - // ========== P2-004: Cycle Metrics Methods ========== + // ========== P2-004: Cycle Metrics (delegated to RespawnCycleMetricsTracker) ========== /** * Start tracking metrics for a new cycle. @@ -3290,19 +3131,14 @@ export class RespawnController extends EventEmitter { private startCycleMetrics(idleReason: string): void { if (!this.config.trackCycleMetrics) return; - const now = Date.now(); - this.currentCycleMetrics = { - cycleId: `${this.session.id}:${this.cycleCount}`, - sessionId: this.session.id, - cycleNumber: this.cycleCount, - startedAt: now, + this.cycleMetrics.startCycle( + this.session.id, + this.cycleCount, idleReason, - idleDetectionMs: now - this.idleDetectionStartTime, - stepsCompleted: [], - clearSkipped: false, - tokenCountAtStart: this.lastTokenCount, - completionConfirmMsUsed: this.getAdaptiveCompletionConfirmMs(), - }; + this.idleDetectionStartTime, + this.lastTokenCount, + this.getAdaptiveCompletionConfirmMs() + ); } /** @@ -3310,8 +3146,8 @@ export class RespawnController extends EventEmitter { * @param step - Name of the step (e.g., 'update', 'clear', 'init') */ private recordCycleStep(step: string): void { - if (!this.config.trackCycleMetrics || !this.currentCycleMetrics) return; - this.currentCycleMetrics.stepsCompleted?.push(step); + if (!this.config.trackCycleMetrics) return; + this.cycleMetrics.recordStep(step); } /** @@ -3322,86 +3158,20 @@ export class RespawnController extends EventEmitter { * @param errorMessage - Optional error message if outcome is 'error' */ private completeCycleMetrics(outcome: CycleOutcome, errorMessage?: string): void { - if (!this.config.trackCycleMetrics || !this.currentCycleMetrics) return; + if (!this.config.trackCycleMetrics) return; - const now = Date.now(); - const metrics: RespawnCycleMetrics = { - ...(this.currentCycleMetrics as RespawnCycleMetrics), - completedAt: now, - durationMs: now - (this.currentCycleMetrics.startedAt ?? now), - outcome, - errorMessage, - tokenCountAtEnd: this.lastTokenCount, - }; + const metrics = this.cycleMetrics.completeCycle(outcome, this.lastTokenCount, errorMessage); - // Add to recent metrics - this.recentCycleMetrics.push(metrics); - if (this.recentCycleMetrics.length > RespawnController.MAX_CYCLE_METRICS_IN_MEMORY) { - this.recentCycleMetrics.shift(); + if (metrics) { + // Record timing data for adaptive timing + if (this.config.adaptiveTimingEnabled) { + this.adaptiveTiming.recordTimingData(metrics.idleDetectionMs, metrics.durationMs); + } + + this.log( + `Cycle #${metrics.cycleNumber} metrics: ${outcome}, duration=${metrics.durationMs}ms, idle_detection=${metrics.idleDetectionMs}ms` + ); } - - // Record timing data for adaptive timing - this.recordTimingData(metrics.idleDetectionMs, metrics.durationMs); - - // Update aggregate metrics - this.updateAggregateMetrics(metrics); - - // Clear current cycle - this.currentCycleMetrics = null; - - this.log( - `Cycle #${metrics.cycleNumber} metrics: ${outcome}, duration=${metrics.durationMs}ms, idle_detection=${metrics.idleDetectionMs}ms` - ); - } - - /** - * Update aggregate metrics with a new cycle's data. - * @param metrics - The completed cycle metrics - */ - private updateAggregateMetrics(metrics: RespawnCycleMetrics): void { - const agg = this.aggregateMetrics; - - agg.totalCycles++; - - switch (metrics.outcome) { - case 'success': - agg.successfulCycles++; - break; - case 'stuck_recovery': - agg.stuckRecoveryCycles++; - break; - case 'blocked': - agg.blockedCycles++; - break; - case 'error': - agg.errorCycles++; - break; - case 'cancelled': - // Cancelled cycles don't count towards any specific category - // but are still counted in totalCycles - break; - default: - assertNever(metrics.outcome, `Unhandled CycleOutcome: ${metrics.outcome}`); - } - - // Recalculate averages using all recent metrics - const durations = this.recentCycleMetrics.map((m) => m.durationMs); - const idleTimes = this.recentCycleMetrics.map((m) => m.idleDetectionMs); - - if (durations.length > 0) { - agg.avgCycleDurationMs = Math.round(durations.reduce((a, b) => a + b, 0) / durations.length); - agg.avgIdleDetectionMs = Math.round(idleTimes.reduce((a, b) => a + b, 0) / idleTimes.length); - - // Calculate P90 - const sortedDurations = [...durations].sort((a, b) => a - b); - const p90Index = Math.floor(sortedDurations.length * 0.9); - agg.p90CycleDurationMs = sortedDurations[p90Index]; - } - - // Calculate success rate - agg.successRate = agg.totalCycles > 0 ? Math.round((agg.successfulCycles / agg.totalCycles) * 100) : 100; - - agg.lastUpdatedAt = Date.now(); } /** @@ -3409,7 +3179,7 @@ export class RespawnController extends EventEmitter { * @returns Copy of aggregate metrics */ getAggregateMetrics(): RespawnAggregateMetrics { - return { ...this.aggregateMetrics }; + return this.cycleMetrics.getAggregate(); } /** @@ -3418,10 +3188,10 @@ export class RespawnController extends EventEmitter { * @returns Recent cycle metrics, newest first */ getRecentCycleMetrics(limit: number = 20): RespawnCycleMetrics[] { - return this.recentCycleMetrics.slice(-limit).reverse(); + return this.cycleMetrics.getRecent(limit); } - // ========== P2-005: Health Score Methods ========== + // ========== P2-005: Health Score (delegated to respawn-health.ts) ========== /** * Calculate a comprehensive health score for the Ralph Loop system. @@ -3430,171 +3200,28 @@ export class RespawnController extends EventEmitter { * @returns Health score with component breakdown */ calculateHealthScore(): RalphLoopHealthScore { - const now = Date.now(); - const components = { - cycleSuccess: this.calculateCycleSuccessScore(), - circuitBreaker: this.calculateCircuitBreakerScore(), - iterationProgress: this.calculateIterationProgressScore(), - aiChecker: this.calculateAiCheckerScore(), - stuckRecovery: this.calculateStuckRecoveryScore(), - }; - - // Weighted average (cycle success is most important) - const weights = { - cycleSuccess: 0.35, - circuitBreaker: 0.2, - iterationProgress: 0.2, - aiChecker: 0.15, - stuckRecovery: 0.1, - }; - - const score = Math.round( - components.cycleSuccess * weights.cycleSuccess + - components.circuitBreaker * weights.circuitBreaker + - components.iterationProgress * weights.iterationProgress + - components.aiChecker * weights.aiChecker + - components.stuckRecovery * weights.stuckRecovery - ); - - // Determine status - let status: HealthStatus; - if (score >= 90) status = 'excellent'; - else if (score >= 70) status = 'good'; - else if (score >= 50) status = 'degraded'; - else status = 'critical'; - - // Generate recommendations - const recommendations = this.generateHealthRecommendations(components); - - // Generate summary - const summary = this.generateHealthSummary(score, status, components); - - return { - score, - status, - components, - summary, - recommendations, - calculatedAt: now, - }; - } - - /** - * Calculate score based on recent cycle success rate. - */ - private calculateCycleSuccessScore(): number { - if (this.aggregateMetrics.totalCycles === 0) return 100; // No data = assume healthy - return this.aggregateMetrics.successRate; - } - - /** - * Calculate score based on circuit breaker state. - */ - private calculateCircuitBreakerScore(): number { const tracker = this.session.ralphTracker; - if (!tracker) return 100; + const stallMetrics = tracker?.getIterationStallMetrics(); + const aiState = this.aiChecker.getState(); - const cb = tracker.circuitBreakerStatus; - switch (cb.state) { - case 'CLOSED': - return 100; - case 'HALF_OPEN': - return 50; - case 'OPEN': - return 0; - default: - return 100; - } - } + const inputs: HealthInputs = { + aggregateMetrics: this.cycleMetrics.getAggregate(), + circuitBreakerStatus: tracker?.circuitBreakerStatus ?? null, + iterationStallMetrics: stallMetrics + ? { + stallDurationMs: stallMetrics.stallDurationMs, + warningThresholdMs: stallMetrics.warningThresholdMs, + criticalThresholdMs: stallMetrics.criticalThresholdMs, + } + : null, + aiCheckerState: { + status: aiState.status, + consecutiveErrors: aiState.consecutiveErrors, + }, + stuckRecoveryCount: this.stuckRecoveryCount, + maxStuckRecoveries: this.config.maxStuckRecoveries ?? 3, + }; - /** - * Calculate score based on iteration progress. - */ - private calculateIterationProgressScore(): number { - const tracker = this.session.ralphTracker; - if (!tracker) return 100; - - const stallMetrics = tracker.getIterationStallMetrics(); - const { stallDurationMs, warningThresholdMs, criticalThresholdMs } = stallMetrics; - - if (stallDurationMs >= criticalThresholdMs) return 0; - if (stallDurationMs >= warningThresholdMs) return 30; - if (stallDurationMs >= warningThresholdMs / 2) return 70; - return 100; - } - - /** - * Calculate score based on AI checker health. - */ - private calculateAiCheckerScore(): number { - const state = this.aiChecker.getState(); - if (state.status === 'disabled') return 30; - if (state.status === 'cooldown') return 70; - if (state.consecutiveErrors > 0) return 50; - return 100; - } - - /** - * Calculate score based on stuck-state recovery count. - */ - private calculateStuckRecoveryScore(): number { - const maxRecoveries = this.config.maxStuckRecoveries ?? 3; - if (this.stuckRecoveryCount === 0) return 100; - if (this.stuckRecoveryCount >= maxRecoveries) return 0; - return Math.round(100 - (this.stuckRecoveryCount / maxRecoveries) * 100); - } - - /** - * Generate health recommendations based on component scores. - */ - private generateHealthRecommendations(components: RalphLoopHealthScore['components']): string[] { - const recommendations: string[] = []; - - if (components.cycleSuccess < 70) { - recommendations.push('Cycle success rate is low. Check for recurring errors or stuck states.'); - } - if (components.circuitBreaker < 50) { - recommendations.push('Circuit breaker is open or half-open. Review recent errors and consider manual reset.'); - } - if (components.iterationProgress < 50) { - recommendations.push('Iteration progress has stalled. Check if Claude is stuck on a task.'); - } - if (components.aiChecker < 50) { - recommendations.push('AI idle checker has errors. May need to check Claude CLI availability.'); - } - if (components.stuckRecovery < 50) { - recommendations.push('Multiple stuck-state recoveries occurred. Consider increasing timeouts.'); - } - - if (recommendations.length === 0) { - recommendations.push('System is healthy. No action needed.'); - } - - return recommendations; - } - - /** - * Generate a human-readable health summary. - */ - private generateHealthSummary( - score: number, - status: HealthStatus, - components: RalphLoopHealthScore['components'] - ): string { - const lowest = Object.entries(components).reduce((min, [key, val]) => (val < min.val ? { key, val } : min), { - key: '', - val: 100, - }); - - if (status === 'excellent') { - return `Ralph Loop is operating excellently (${score}/100). All systems healthy.`; - } - if (status === 'good') { - return `Ralph Loop is operating well (${score}/100). Minor issues in ${lowest.key}.`; - } - if (status === 'degraded') { - return `Ralph Loop is degraded (${score}/100). Primary issue: ${lowest.key} (${lowest.val}/100).`; - } - return `Ralph Loop is in critical state (${score}/100). Immediate attention needed: ${lowest.key}.`; + return calculateHealthScore(inputs); } } diff --git a/src/respawn-health.ts b/src/respawn-health.ts new file mode 100644 index 00000000..05dbda16 --- /dev/null +++ b/src/respawn-health.ts @@ -0,0 +1,229 @@ +/** + * @fileoverview Pure health scoring functions for respawn controller. + * + * Extracted from respawn-controller.ts for modularity. All functions are pure + * (no side effects, no state) and take a HealthInputs interface that decouples + * them from direct access to Session, RalphTracker, or AiChecker instances. + * + * @module respawn-health + */ + +import type { RespawnAggregateMetrics, RalphLoopHealthScore, HealthStatus, CircuitBreakerStatus } from './types.js'; + +/** + * Input data for health score calculation. + * Decouples the health calculation from direct access to controller internals. + */ +export interface HealthInputs { + /** Aggregate cycle metrics */ + aggregateMetrics: RespawnAggregateMetrics; + /** Current circuit breaker status */ + circuitBreakerStatus: CircuitBreakerStatus | null; + /** Iteration stall metrics, or null if tracker unavailable */ + iterationStallMetrics: { + stallDurationMs: number; + warningThresholdMs: number; + criticalThresholdMs: number; + } | null; + /** AI checker state summary */ + aiCheckerState: { + status: string; + consecutiveErrors: number; + }; + /** Number of stuck-state recovery attempts */ + stuckRecoveryCount: number; + /** Maximum allowed stuck recoveries */ + maxStuckRecoveries: number; +} + +/** + * Calculate a comprehensive health score for the Ralph Loop system. + * Aggregates multiple health signals into a single score (0-100). + * + * @param inputs - Health calculation inputs + * @returns Health score with component breakdown + */ +export function calculateHealthScore(inputs: HealthInputs): RalphLoopHealthScore { + const now = Date.now(); + const components = { + cycleSuccess: calculateCycleSuccessScore(inputs.aggregateMetrics), + circuitBreaker: calculateCircuitBreakerScore(inputs.circuitBreakerStatus), + iterationProgress: calculateIterationProgressScore(inputs.iterationStallMetrics), + aiChecker: calculateAiCheckerScore(inputs.aiCheckerState), + stuckRecovery: calculateStuckRecoveryScore(inputs.stuckRecoveryCount, inputs.maxStuckRecoveries), + }; + + // Weighted average (cycle success is most important) + const weights = { + cycleSuccess: 0.35, + circuitBreaker: 0.2, + iterationProgress: 0.2, + aiChecker: 0.15, + stuckRecovery: 0.1, + }; + + const score = Math.round( + components.cycleSuccess * weights.cycleSuccess + + components.circuitBreaker * weights.circuitBreaker + + components.iterationProgress * weights.iterationProgress + + components.aiChecker * weights.aiChecker + + components.stuckRecovery * weights.stuckRecovery + ); + + // Determine status + let status: HealthStatus; + if (score >= 90) status = 'excellent'; + else if (score >= 70) status = 'good'; + else if (score >= 50) status = 'degraded'; + else status = 'critical'; + + // Generate recommendations + const recommendations = generateHealthRecommendations(components); + + // Generate summary + const summary = generateHealthSummary(score, status, components); + + return { + score, + status, + components, + summary, + recommendations, + calculatedAt: now, + }; +} + +/** + * Determine whether to skip the /clear step based on current context usage. + * Skips if token count is below the configured threshold percentage. + * + * @param lastTokenCount - Current token count from the session + * @param skipClearThresholdPercent - Threshold percentage below which to skip /clear + * @param maxContextTokens - Approximate max context window size + * @returns True if /clear should be skipped + */ +export function shouldSkipClear( + lastTokenCount: number, + skipClearThresholdPercent: number, + maxContextTokens: number +): boolean { + if (lastTokenCount === 0) return false; // Can't determine, don't skip + + const usagePercent = (lastTokenCount / maxContextTokens) * 100; + return usagePercent < skipClearThresholdPercent; +} + +/** + * Calculate score based on recent cycle success rate. + */ +function calculateCycleSuccessScore(aggregateMetrics: RespawnAggregateMetrics): number { + if (aggregateMetrics.totalCycles === 0) return 100; // No data = assume healthy + return aggregateMetrics.successRate; +} + +/** + * Calculate score based on circuit breaker state. + */ +function calculateCircuitBreakerScore(circuitBreakerStatus: CircuitBreakerStatus | null): number { + if (!circuitBreakerStatus) return 100; + + switch (circuitBreakerStatus.state) { + case 'CLOSED': + return 100; + case 'HALF_OPEN': + return 50; + case 'OPEN': + return 0; + default: + return 100; + } +} + +/** + * Calculate score based on iteration progress. + */ +function calculateIterationProgressScore( + stallMetrics: { stallDurationMs: number; warningThresholdMs: number; criticalThresholdMs: number } | null +): number { + if (!stallMetrics) return 100; + + const { stallDurationMs, warningThresholdMs, criticalThresholdMs } = stallMetrics; + + if (stallDurationMs >= criticalThresholdMs) return 0; + if (stallDurationMs >= warningThresholdMs) return 30; + if (stallDurationMs >= warningThresholdMs / 2) return 70; + return 100; +} + +/** + * Calculate score based on AI checker health. + */ +function calculateAiCheckerScore(aiCheckerState: { status: string; consecutiveErrors: number }): number { + if (aiCheckerState.status === 'disabled') return 30; + if (aiCheckerState.status === 'cooldown') return 70; + if (aiCheckerState.consecutiveErrors > 0) return 50; + return 100; +} + +/** + * Calculate score based on stuck-state recovery count. + */ +function calculateStuckRecoveryScore(stuckRecoveryCount: number, maxStuckRecoveries: number): number { + if (stuckRecoveryCount === 0) return 100; + if (stuckRecoveryCount >= maxStuckRecoveries) return 0; + return Math.round(100 - (stuckRecoveryCount / maxStuckRecoveries) * 100); +} + +/** + * Generate health recommendations based on component scores. + */ +function generateHealthRecommendations(components: RalphLoopHealthScore['components']): string[] { + const recommendations: string[] = []; + + if (components.cycleSuccess < 70) { + recommendations.push('Cycle success rate is low. Check for recurring errors or stuck states.'); + } + if (components.circuitBreaker < 50) { + recommendations.push('Circuit breaker is open or half-open. Review recent errors and consider manual reset.'); + } + if (components.iterationProgress < 50) { + recommendations.push('Iteration progress has stalled. Check if Claude is stuck on a task.'); + } + if (components.aiChecker < 50) { + recommendations.push('AI idle checker has errors. May need to check Claude CLI availability.'); + } + if (components.stuckRecovery < 50) { + recommendations.push('Multiple stuck-state recoveries occurred. Consider increasing timeouts.'); + } + + if (recommendations.length === 0) { + recommendations.push('System is healthy. No action needed.'); + } + + return recommendations; +} + +/** + * Generate a human-readable health summary. + */ +function generateHealthSummary( + score: number, + status: HealthStatus, + components: RalphLoopHealthScore['components'] +): string { + const lowest = Object.entries(components).reduce((min, [key, val]) => (val < min.val ? { key, val } : min), { + key: '', + val: 100, + }); + + if (status === 'excellent') { + return `Ralph Loop is operating excellently (${score}/100). All systems healthy.`; + } + if (status === 'good') { + return `Ralph Loop is operating well (${score}/100). Minor issues in ${lowest.key}.`; + } + if (status === 'degraded') { + return `Ralph Loop is degraded (${score}/100). Primary issue: ${lowest.key} (${lowest.val}/100).`; + } + return `Ralph Loop is in critical state (${score}/100). Immediate attention needed: ${lowest.key}.`; +} diff --git a/src/respawn-metrics.ts b/src/respawn-metrics.ts new file mode 100644 index 00000000..ec6c1b15 --- /dev/null +++ b/src/respawn-metrics.ts @@ -0,0 +1,229 @@ +/** + * @fileoverview Cycle metrics tracker for respawn controller. + * + * Extracted from respawn-controller.ts for modularity. Tracks per-cycle metrics + * and maintains aggregate statistics across all tracked cycles. + * + * @module respawn-metrics + */ + +import { assertNever } from './utils/index.js'; +import type { RespawnCycleMetrics, RespawnAggregateMetrics, CycleOutcome } from './types.js'; + +/** + * Maximum number of cycle metrics to keep in memory. + */ +const MAX_CYCLE_METRICS_IN_MEMORY = 100; + +/** + * Tracks respawn cycle metrics and maintains aggregate statistics. + * + * Each respawn cycle is tracked from start to completion, recording timing, + * steps completed, and outcome. Aggregate metrics provide a rolling view + * of system health across recent cycles. + */ +export class RespawnCycleMetricsTracker { + /** Current cycle being tracked */ + private currentCycleMetrics: Partial | null = null; + + /** Recent cycle metrics (rolling window for aggregate calculation) */ + private recentCycleMetrics: RespawnCycleMetrics[] = []; + + /** Aggregate metrics across all tracked cycles */ + private aggregateMetrics: RespawnAggregateMetrics = { + totalCycles: 0, + successfulCycles: 0, + stuckRecoveryCycles: 0, + blockedCycles: 0, + errorCycles: 0, + avgCycleDurationMs: 0, + avgIdleDetectionMs: 0, + p90CycleDurationMs: 0, + successRate: 100, + lastUpdatedAt: Date.now(), + }; + + /** + * Start tracking metrics for a new cycle. + * Called when a respawn cycle begins. + * + * @param sessionId - The session this cycle belongs to + * @param cycleNumber - The cycle number within the session + * @param idleReason - What triggered idle detection + * @param idleDetectionStartTime - Timestamp when idle detection started + * @param lastTokenCount - Token count at start of cycle + * @param adaptiveCompletionConfirmMs - Completion confirm timeout used + */ + startCycle( + sessionId: string, + cycleNumber: number, + idleReason: string, + idleDetectionStartTime: number, + lastTokenCount: number, + adaptiveCompletionConfirmMs: number + ): void { + const now = Date.now(); + this.currentCycleMetrics = { + cycleId: `${sessionId}:${cycleNumber}`, + sessionId, + cycleNumber, + startedAt: now, + idleReason, + idleDetectionMs: now - idleDetectionStartTime, + stepsCompleted: [], + clearSkipped: false, + tokenCountAtStart: lastTokenCount, + completionConfirmMsUsed: adaptiveCompletionConfirmMs, + }; + } + + /** + * Record a completed step in the current cycle. + * @param step - Name of the step (e.g., 'update', 'clear', 'init') + */ + recordStep(step: string): void { + if (!this.currentCycleMetrics) return; + this.currentCycleMetrics.stepsCompleted?.push(step); + } + + /** + * Mark that /clear was skipped in the current cycle. + */ + markClearSkipped(): void { + if (this.currentCycleMetrics) { + this.currentCycleMetrics.clearSkipped = true; + } + } + + /** + * Get the current in-progress cycle metrics (for external inspection). + * @returns The current cycle metrics, or null if no cycle is in progress + */ + getCurrentCycle(): Partial | null { + return this.currentCycleMetrics; + } + + /** + * Complete the current cycle metrics with outcome. + * Adds to recent metrics and updates aggregates. + * + * @param outcome - Outcome of the cycle + * @param lastTokenCount - Token count at end of cycle + * @param errorMessage - Optional error message if outcome is 'error' + * @returns The completed cycle metrics, or null if no cycle was in progress + */ + completeCycle(outcome: CycleOutcome, lastTokenCount: number, errorMessage?: string): RespawnCycleMetrics | null { + if (!this.currentCycleMetrics) return null; + + const now = Date.now(); + const metrics: RespawnCycleMetrics = { + ...(this.currentCycleMetrics as RespawnCycleMetrics), + completedAt: now, + durationMs: now - (this.currentCycleMetrics.startedAt ?? now), + outcome, + errorMessage, + tokenCountAtEnd: lastTokenCount, + }; + + // Add to recent metrics + this.recentCycleMetrics.push(metrics); + if (this.recentCycleMetrics.length > MAX_CYCLE_METRICS_IN_MEMORY) { + this.recentCycleMetrics.shift(); + } + + // Update aggregate metrics + this.updateAggregateMetrics(metrics); + + // Clear current cycle + this.currentCycleMetrics = null; + + return metrics; + } + + /** + * Get aggregate metrics for monitoring. + * @returns Copy of aggregate metrics + */ + getAggregate(): RespawnAggregateMetrics { + return { ...this.aggregateMetrics }; + } + + /** + * Get recent cycle metrics for analysis. + * @param limit - Maximum number of metrics to return (default: 20) + * @returns Recent cycle metrics, newest first + */ + getRecent(limit: number = 20): RespawnCycleMetrics[] { + return this.recentCycleMetrics.slice(-limit).reverse(); + } + + /** + * Reset all metrics state. + */ + reset(): void { + this.currentCycleMetrics = null; + this.recentCycleMetrics = []; + this.aggregateMetrics = { + totalCycles: 0, + successfulCycles: 0, + stuckRecoveryCycles: 0, + blockedCycles: 0, + errorCycles: 0, + avgCycleDurationMs: 0, + avgIdleDetectionMs: 0, + p90CycleDurationMs: 0, + successRate: 100, + lastUpdatedAt: Date.now(), + }; + } + + /** + * Update aggregate metrics with a new cycle's data. + * @param metrics - The completed cycle metrics + */ + private updateAggregateMetrics(metrics: RespawnCycleMetrics): void { + const agg = this.aggregateMetrics; + + agg.totalCycles++; + + switch (metrics.outcome) { + case 'success': + agg.successfulCycles++; + break; + case 'stuck_recovery': + agg.stuckRecoveryCycles++; + break; + case 'blocked': + agg.blockedCycles++; + break; + case 'error': + agg.errorCycles++; + break; + case 'cancelled': + // Cancelled cycles don't count towards any specific category + // but are still counted in totalCycles + break; + default: + assertNever(metrics.outcome, `Unhandled CycleOutcome: ${metrics.outcome}`); + } + + // Recalculate averages using all recent metrics + const durations = this.recentCycleMetrics.map((m) => m.durationMs); + const idleTimes = this.recentCycleMetrics.map((m) => m.idleDetectionMs); + + if (durations.length > 0) { + agg.avgCycleDurationMs = Math.round(durations.reduce((a, b) => a + b, 0) / durations.length); + agg.avgIdleDetectionMs = Math.round(idleTimes.reduce((a, b) => a + b, 0) / idleTimes.length); + + // Calculate P90 + const sortedDurations = [...durations].sort((a, b) => a - b); + const p90Index = Math.floor(sortedDurations.length * 0.9); + agg.p90CycleDurationMs = sortedDurations[p90Index]; + } + + // Calculate success rate + agg.successRate = agg.totalCycles > 0 ? Math.round((agg.successfulCycles / agg.totalCycles) * 100) : 100; + + agg.lastUpdatedAt = Date.now(); + } +} diff --git a/src/respawn-patterns.ts b/src/respawn-patterns.ts new file mode 100644 index 00000000..cad91e49 --- /dev/null +++ b/src/respawn-patterns.ts @@ -0,0 +1,131 @@ +/** + * @fileoverview Pure utility functions for terminal pattern detection in respawn controller. + * + * Extracted from respawn-controller.ts for modularity. These are stateless functions + * and constants used to detect completion messages, working patterns, and token counts + * in terminal output. + * + * @module respawn-patterns + */ + +import { TOKEN_PATTERN } from './utils/index.js'; + +// ========== Constants ========== + +/** + * Pattern to detect completion messages from Claude Code. + * Requires "Worked for" prefix to avoid false positives from bare time durations + * in regular text (e.g., "wait for 5s", "run for 2m"). + * + * Matches: "Worked for 2m 46s", "Worked for 46s", "Worked for 1h 2m 3s" + * Does NOT match: "wait for 5s", "run for 2m", "for 3s the system..." + */ +const COMPLETION_TIME_PATTERN = /\bWorked\s+for\s+\d+[hms](\s*\d+[hms])*/i; + +/** + * Patterns indicating Claude is ready for input (legacy fallback). + * Used as secondary signals, not primary detection. + */ +export const PROMPT_PATTERNS = [ + '❯', // Standard prompt + '\u276f', // Unicode variant + '⏵', // Claude Code prompt variant +]; + +/** + * Patterns indicating Claude is actively working. + * When detected, resets all idle detection timers. + * Note: ✻ and ✽ removed - they appear in completion messages too. + */ +export const WORKING_PATTERNS = [ + 'Thinking', + 'Writing', + 'Reading', + 'Running', + 'Searching', + 'Editing', + 'Creating', + 'Deleting', + 'Analyzing', + 'Executing', + 'Synthesizing', + 'Brewing', // Claude's processing indicators + 'Compiling', + 'Building', + 'Installing', + 'Fetching', + 'Downloading', + 'Processing', + 'Generating', + 'Loading', + 'Starting', + 'Updating', + 'Checking', + 'Validating', + 'Testing', + 'Formatting', + 'Linting', + '⠋', + '⠙', + '⠹', + '⠸', + '⠼', + '⠴', + '⠦', + '⠧', + '⠇', + '⠏', // Spinner chars + '◐', + '◓', + '◑', + '◒', // Alternative spinners + '⣾', + '⣽', + '⣻', + '⢿', + '⡿', + '⣟', + '⣯', + '⣷', // Braille spinners +]; + +/** + * Check if data contains a completion message pattern. + * Matches "Worked for Xh Xm Xs" time duration patterns. + * + * @param data - Raw terminal output data + * @returns True if completion message pattern is found + */ +export function isCompletionMessage(data: string): boolean { + return COMPLETION_TIME_PATTERN.test(data); +} + +/** + * Check if a rolling window of terminal output contains working patterns. + * The rolling window catches patterns split across chunks (e.g., "Thin" + "king"). + * + * @param window - Rolling window of recent terminal output (already includes current data) + * @returns True if any working pattern is found in the window + */ +export function hasWorkingPattern(window: string): boolean { + return WORKING_PATTERNS.some((pattern) => window.includes(pattern)); +} + +/** + * Extract token count from data if present. + * Parses patterns like "123.4k tokens" or "1.5M tokens". + * + * @param data - Raw terminal output data + * @returns Parsed token count, or null if no token pattern found + */ +export function extractTokenCount(data: string): number | null { + const match = data.match(TOKEN_PATTERN); + if (!match) return null; + + let count = parseFloat(match[1]); + const suffix = match[2]?.toLowerCase(); + if (suffix === 'k') count *= 1000; + else if (suffix === 'm') count *= 1000000; + + return Math.round(count); +} diff --git a/src/session-auto-ops.ts b/src/session-auto-ops.ts new file mode 100644 index 00000000..e1330ef8 --- /dev/null +++ b/src/session-auto-ops.ts @@ -0,0 +1,284 @@ +/** + * @fileoverview Auto-compact and auto-clear automation for Session. + * + * Monitors token counts and triggers /compact or /clear commands when + * configurable thresholds are reached. Waits for Claude to be idle + * before sending commands, with retry logic and mutual exclusion + * (compact and clear never run simultaneously). + * + * @module session-auto-ops + */ + +import { EventEmitter } from 'node:events'; + +// ============================================================================ +// Timing Constants +// ============================================================================ + +/** Delay for auto-compact/clear retry attempts (2 seconds) */ +const AUTO_RETRY_DELAY_MS = 2000; + +/** Delay for auto-compact/clear initial check (1 second) */ +const AUTO_INITIAL_DELAY_MS = 1000; + +/** Cooldown after compact completes before re-enabling (10 seconds) */ +const COMPACT_COOLDOWN_MS = 10000; + +/** Cooldown after clear completes before re-enabling (5 seconds) */ +const CLEAR_COOLDOWN_MS = 5000; + +/** Minimum valid threshold for auto-clear/compact (1000 tokens) */ +const MIN_AUTO_THRESHOLD = 1000; + +/** Maximum valid threshold for auto-clear/compact (500k tokens) */ +const MAX_AUTO_THRESHOLD = 500_000; + +/** Default auto-clear threshold when invalid value provided */ +const DEFAULT_AUTO_CLEAR_THRESHOLD = 140_000; + +/** Default auto-compact threshold when invalid value provided */ +const DEFAULT_AUTO_COMPACT_THRESHOLD = 110_000; + +/** + * Callbacks required by SessionAutoOps to interact with the parent Session. + */ +export interface AutoOpsCallbacks { + /** Send a command via the terminal multiplexer */ + writeCommand: (command: string) => Promise; + /** Check if Claude is currently working */ + isWorking: () => boolean; + /** Check if the session has been stopped */ + isStopped: () => boolean; + /** Get current total token count (input + output) */ + getTotalTokens: () => number; + /** Get session ID for logging */ + getSessionId: () => string; +} + +/** + * Events emitted by SessionAutoOps. + */ +export interface SessionAutoOpsEvents { + /** Auto-compact was triggered and the /compact command was sent */ + autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void; + /** Auto-clear was triggered and the /clear command was sent */ + autoClear: (data: { tokens: number; threshold: number }) => void; +} + +/** + * Manages auto-compact and auto-clear automation for a Session. + * + * When enabled, monitors token counts after each update and triggers + * /compact or /clear commands when thresholds are exceeded. Ensures + * mutual exclusion between compact and clear operations. + */ +export class SessionAutoOps extends EventEmitter { + // Auto-compact state + private _autoCompactThreshold: number; + private _autoCompactEnabled: boolean = false; + private _autoCompactPrompt: string = ''; + private _isCompacting: boolean = false; + private _autoCompactTimer: NodeJS.Timeout | null = null; + + // Auto-clear state + private _autoClearThreshold: number; + private _autoClearEnabled: boolean = false; + private _isClearing: boolean = false; + private _autoClearTimer: NodeJS.Timeout | null = null; + + private readonly callbacks: AutoOpsCallbacks; + + constructor(callbacks: AutoOpsCallbacks, config?: { compactThreshold?: number; clearThreshold?: number }) { + super(); + this.callbacks = callbacks; + this._autoCompactThreshold = config?.compactThreshold ?? DEFAULT_AUTO_COMPACT_THRESHOLD; + this._autoClearThreshold = config?.clearThreshold ?? DEFAULT_AUTO_CLEAR_THRESHOLD; + } + + // ============================================================================ + // Auto-compact getters/setters + // ============================================================================ + + get autoCompactThreshold(): number { + return this._autoCompactThreshold; + } + + get autoCompactEnabled(): boolean { + return this._autoCompactEnabled; + } + + get autoCompactPrompt(): string { + return this._autoCompactPrompt; + } + + get isCompacting(): boolean { + return this._isCompacting; + } + + setAutoCompact(enabled: boolean, threshold?: number, prompt?: string): void { + this._autoCompactEnabled = enabled; + if (threshold !== undefined) { + if (threshold < MIN_AUTO_THRESHOLD || threshold > MAX_AUTO_THRESHOLD) { + console.warn( + `[SessionAutoOps ${this.callbacks.getSessionId()}] Invalid autoCompact threshold ${threshold}, must be between ${MIN_AUTO_THRESHOLD} and ${MAX_AUTO_THRESHOLD}. Using default ${DEFAULT_AUTO_COMPACT_THRESHOLD}.` + ); + this._autoCompactThreshold = DEFAULT_AUTO_COMPACT_THRESHOLD; + } else { + this._autoCompactThreshold = threshold; + } + } + if (prompt !== undefined) { + this._autoCompactPrompt = prompt; + } + } + + // ============================================================================ + // Auto-clear getters/setters + // ============================================================================ + + get autoClearThreshold(): number { + return this._autoClearThreshold; + } + + get autoClearEnabled(): boolean { + return this._autoClearEnabled; + } + + get isClearing(): boolean { + return this._isClearing; + } + + setAutoClear(enabled: boolean, threshold?: number): void { + this._autoClearEnabled = enabled; + if (threshold !== undefined) { + if (threshold < MIN_AUTO_THRESHOLD || threshold > MAX_AUTO_THRESHOLD) { + console.warn( + `[SessionAutoOps ${this.callbacks.getSessionId()}] Invalid autoClear threshold ${threshold}, must be between ${MIN_AUTO_THRESHOLD} and ${MAX_AUTO_THRESHOLD}. Using default ${DEFAULT_AUTO_CLEAR_THRESHOLD}.` + ); + this._autoClearThreshold = DEFAULT_AUTO_CLEAR_THRESHOLD; + } else { + this._autoClearThreshold = threshold; + } + } + } + + // ============================================================================ + // Threshold checks + // ============================================================================ + + /** + * Check if auto-compact should be triggered based on current token count. + * Called after token count updates. + */ + checkAutoCompact(): void { + if (this.callbacks.isStopped()) return; + if (!this._autoCompactEnabled || this._isCompacting || this._isClearing) return; + + const totalTokens = this.callbacks.getTotalTokens(); + if (totalTokens >= this._autoCompactThreshold) { + this._isCompacting = true; + console.log( + `[SessionAutoOps] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold` + ); + + const checkAndCompact = async () => { + if (this.callbacks.isStopped()) return; + if (!this._isCompacting) return; + + if (!this.callbacks.isWorking()) { + if (this.callbacks.isStopped()) return; + + const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r'; + await this.callbacks.writeCommand(compactCmd); + this.emit('autoCompact', { + tokens: totalTokens, + threshold: this._autoCompactThreshold, + prompt: this._autoCompactPrompt || undefined, + }); + + if (!this.callbacks.isStopped()) { + this._autoCompactTimer = setTimeout(() => { + if (this.callbacks.isStopped()) return; + this._autoCompactTimer = null; + this._isCompacting = false; + }, COMPACT_COOLDOWN_MS); + } + } else { + if (!this.callbacks.isStopped()) { + this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_RETRY_DELAY_MS); + } + } + }; + + if (!this.callbacks.isStopped()) { + this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_INITIAL_DELAY_MS); + } + } + } + + /** + * Check if auto-clear should be triggered based on current token count. + * Called after token count updates. + */ + checkAutoClear(): void { + if (this.callbacks.isStopped()) return; + if (!this._autoClearEnabled || this._isClearing || this._isCompacting) return; + + const totalTokens = this.callbacks.getTotalTokens(); + if (totalTokens >= this._autoClearThreshold) { + this._isClearing = true; + console.log( + `[SessionAutoOps] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold` + ); + + const checkAndClear = async () => { + if (this.callbacks.isStopped()) return; + if (!this._isClearing) return; + + if (!this.callbacks.isWorking()) { + if (this.callbacks.isStopped()) return; + + await this.callbacks.writeCommand('/clear\r'); + this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold }); + + if (!this.callbacks.isStopped()) { + this._autoClearTimer = setTimeout(() => { + if (this.callbacks.isStopped()) return; + this._autoClearTimer = null; + this._isClearing = false; + }, CLEAR_COOLDOWN_MS); + } + } else { + if (!this.callbacks.isStopped()) { + this._autoClearTimer = setTimeout(checkAndClear, AUTO_RETRY_DELAY_MS); + } + } + }; + + if (!this.callbacks.isStopped()) { + this._autoClearTimer = setTimeout(checkAndClear, AUTO_INITIAL_DELAY_MS); + } + } + } + + // ============================================================================ + // Cleanup + // ============================================================================ + + /** + * Clear all timers and reset state. Called when the session stops. + */ + destroy(): void { + if (this._autoCompactTimer) { + clearTimeout(this._autoCompactTimer); + this._autoCompactTimer = null; + } + this._isCompacting = false; + + if (this._autoClearTimer) { + clearTimeout(this._autoClearTimer); + this._autoClearTimer = null; + } + this._isClearing = false; + } +} diff --git a/src/session-cli-builder.ts b/src/session-cli-builder.ts new file mode 100644 index 00000000..94a75a85 --- /dev/null +++ b/src/session-cli-builder.ts @@ -0,0 +1,132 @@ +/** + * @fileoverview Pure functions for building CLI arguments and environment variables + * for Claude and OpenCode CLI spawning. + * + * Extracted from Session to keep argument construction logic testable and + * separate from PTY lifecycle management. + * + * @module session-cli-builder + */ + +import type { ClaudeMode } from './types.js'; +import { getAugmentedPath } from './utils/claude-cli-resolver.js'; + +/** + * Build Claude CLI permission flags based on the configured mode. + * Returns an array of args to pass to the CLI. + */ +export function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): string[] { + switch (claudeMode) { + case 'dangerously-skip-permissions': + return ['--dangerously-skip-permissions']; + case 'allowedTools': + if (allowedTools) { + return ['--allowedTools', allowedTools]; + } + // Fall back to normal mode if no tools specified + return []; + case 'normal': + default: + return []; + } +} + +/** + * Build args for an interactive Claude CLI session (direct PTY, non-mux fallback). + * + * @param sessionId - The Codeman session ID (passed as --session-id to Claude) + * @param claudeMode - Permission mode for the CLI + * @param model - Optional model override (e.g., 'opus', 'sonnet') + * @param allowedTools - Optional comma-separated allowed tools list + * @returns Array of CLI arguments + */ +export function buildInteractiveArgs( + sessionId: string, + claudeMode: ClaudeMode, + model?: string, + allowedTools?: string +): string[] { + const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId]; + if (model) args.push('--model', model); + return args; +} + +/** + * Build args for a one-shot Claude CLI prompt (runPrompt mode). + * + * @param prompt - The prompt text to send + * @param model - Optional model override + * @returns Array of CLI arguments + */ +export function buildPromptArgs(prompt: string, model?: string): string[] { + const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json']; + if (model) { + args.push('--model', model); + } + args.push(prompt); + return args; +} + +/** + * Build environment variables for Claude CLI processes (direct PTY, non-mux). + * + * Augments process.env with: + * - UTF-8 locale settings + * - Augmented PATH (includes Claude CLI directory) + * - xterm-256color terminal type + * - Codeman session identification vars + * + * @param sessionId - The Codeman session ID + * @returns Environment variables object for pty.spawn + */ +export function buildClaudeEnv(sessionId: string): Record { + return { + ...process.env, + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US.UTF-8', + PATH: getAugmentedPath(), + TERM: 'xterm-256color', + COLORTERM: undefined, + CLAUDECODE: undefined, + // Inform Claude it's running within Codeman (helps prevent self-termination) + CODEMAN_MUX: '1', + CODEMAN_SESSION_ID: sessionId, + CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000', + }; +} + +/** + * Build environment variables for mux-attached PTY sessions (tmux attach). + * Lighter than buildClaudeEnv — no PATH augmentation or Codeman vars needed + * since the mux session already has those set. + * + * @returns Environment variables object for pty.spawn + */ +export function buildMuxAttachEnv(): Record { + return { + ...process.env, + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US.UTF-8', + TERM: 'xterm-256color', + COLORTERM: undefined, + CLAUDECODE: undefined, + }; +} + +/** + * Build environment variables for a direct shell session (non-mux fallback). + * + * @param sessionId - The Codeman session ID + * @returns Environment variables object for pty.spawn + */ +export function buildShellEnv(sessionId: string): Record { + return { + ...process.env, + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US.UTF-8', + TERM: 'xterm-256color', + CODEMAN_MUX: '1', + CODEMAN_SESSION_ID: sessionId, + CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000', + }; +} diff --git a/src/session-task-cache.ts b/src/session-task-cache.ts new file mode 100644 index 00000000..f08252e7 --- /dev/null +++ b/src/session-task-cache.ts @@ -0,0 +1,101 @@ +/** + * @fileoverview LRU cache for task descriptions parsed from terminal output. + * + * Stores descriptions extracted from Claude Code's Task tool invocations + * (e.g., "Explore(Check files)") keyed by timestamp. Used to correlate + * with SubagentWatcher discoveries for better window titles. + * + * @module session-task-cache + */ + +import { LRUMap } from './utils/lru-map.js'; + +/** Default maximum number of task descriptions to keep */ +const DEFAULT_MAX_SIZE = 100; + +/** Default maximum age for task descriptions (30 seconds) */ +const DEFAULT_MAX_AGE_MS = 30_000; + +/** + * LRU cache for task descriptions parsed from terminal output. + * + * Descriptions are keyed by the timestamp when they were parsed. + * Old entries are automatically cleaned up based on maxAgeMs. + * Size is bounded by the underlying LRUMap. + */ +export class SessionTaskCache { + private readonly cache: LRUMap; + private readonly maxAgeMs: number; + + constructor(maxSize: number = DEFAULT_MAX_SIZE, maxAgeMs: number = DEFAULT_MAX_AGE_MS) { + this.cache = new LRUMap({ maxSize }); + this.maxAgeMs = maxAgeMs; + } + + /** + * Add a task description at the given timestamp. + */ + add(timestamp: number, description: string): void { + this.cache.set(timestamp, description); + } + + /** + * Remove task descriptions older than maxAgeMs. + * LRUMap maintains insertion order, so we can break early + * once we find a non-expired entry. + */ + private cleanupOld(): void { + const cutoff = Date.now() - this.maxAgeMs; + for (const timestamp of this.cache.keysInOrder()) { + if (timestamp < cutoff) { + this.cache.delete(timestamp); + } else { + break; + } + } + } + + /** + * Get all recent task descriptions sorted by timestamp (most recent first). + */ + getAll(): Array<{ timestamp: number; description: string }> { + this.cleanupOld(); + const results: Array<{ timestamp: number; description: string }> = []; + for (const [timestamp, description] of this.cache) { + results.push({ timestamp, description }); + } + return results.sort((a, b) => b.timestamp - a.timestamp); + } + + /** + * Find a task description that was parsed close to a given timestamp. + * Used to correlate with SubagentWatcher discoveries. + * + * @param subagentStartTime - The timestamp when the subagent was discovered + * @param maxAgeMs - Maximum age difference to consider (default 10 seconds) + * @returns The matching description or undefined + */ + findNear(subagentStartTime: number, maxAgeMs: number = 10000): string | undefined { + this.cleanupOld(); + + let bestMatch: { timestamp: number; description: string } | undefined; + let bestDiff = Infinity; + + for (const [timestamp, description] of this.cache) { + const diff = Math.abs(subagentStartTime - timestamp); + if (diff < maxAgeMs && diff < bestDiff) { + bestMatch = { timestamp, description }; + bestDiff = diff; + } + } + + return bestMatch?.description; + } + + /** + * Clear all cached descriptions. + */ + clear(): void { + this.cache.clear(); + } +} diff --git a/src/session.ts b/src/session.ts index afd5d42b..f8aec22e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -36,7 +36,6 @@ import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { RalphTracker } from './ralph-tracker.js'; import { BashToolParser } from './bash-tool-parser.js'; import { BufferAccumulator } from './utils/buffer-accumulator.js'; -import { LRUMap } from './utils/lru-map.js'; import { ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, SPINNER_PATTERN, MAX_SESSION_TOKENS } from './utils/index.js'; import { MAX_TERMINAL_BUFFER_SIZE, @@ -46,6 +45,15 @@ import { MAX_MESSAGES, MAX_LINE_BUFFER_SIZE, } from './config/buffer-limits.js'; +import { + buildInteractiveArgs, + buildPromptArgs, + buildClaudeEnv, + buildMuxAttachEnv, + buildShellEnv, +} from './session-cli-builder.js'; +import { SessionAutoOps } from './session-auto-ops.js'; +import { SessionTaskCache } from './session-task-cache.js'; export type { BackgroundTask } from './task-tracker.js'; export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js'; @@ -63,11 +71,7 @@ const MUX_STARTUP_DELAY_MS = 300; /** Delay before declaring session idle after last output (2 seconds) */ const IDLE_DETECTION_DELAY_MS = 2000; -/** Delay for auto-compact/clear retry attempts (2 seconds) */ -const AUTO_RETRY_DELAY_MS = 2000; - -/** Delay for auto-compact/clear initial check (1 second) */ -const AUTO_INITIAL_DELAY_MS = 1000; +// Note: Auto-compact/clear timing constants moved to session-auto-ops.ts /** Graceful shutdown delay when stopping session (100ms) */ const GRACEFUL_SHUTDOWN_DELAY_MS = 100; @@ -93,8 +97,7 @@ const CTRL_L_PATTERN = /\x0c/g; /** Pattern to split by newlines (CR or LF) */ const NEWLINE_SPLIT_PATTERN = /\r?\n/; -// Claude CLI PATH resolution — shared utility -import { getAugmentedPath } from './utils/claude-cli-resolver.js'; +// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv) /** * Represents a JSON message from Claude CLI's stream-json output format. @@ -224,9 +227,8 @@ export class Session extends EventEmitter { readonly createdAt: number; readonly mode: SessionMode; - /** Maximum number of task descriptions to keep (LRUMap handles size limit automatically) */ - private static readonly MAX_TASK_DESCRIPTIONS = 100; - private static readonly TASK_DESCRIPTION_MAX_AGE_MS = 30000; // Keep descriptions for 30 seconds + // Task description cache (extracted to SessionTaskCache) + private _taskCache = new SessionTaskCache(); private _name: string; private ptyProcess: pty.IPty | null = null; @@ -255,15 +257,9 @@ export class Session extends EventEmitter { // Token tracking for auto-clear private _totalInputTokens: number = 0; private _totalOutputTokens: number = 0; - private _autoClearThreshold: number = 140000; // Default 140k tokens - private _autoClearEnabled: boolean = false; - private _isClearing: boolean = false; // Prevent recursive clearing - // Auto-compact settings - private _autoCompactThreshold: number = 110000; // Default 110k tokens (lower than clear) - private _autoCompactEnabled: boolean = false; - private _autoCompactPrompt: string = ''; // Optional prompt for compact - private _isCompacting: boolean = false; // Prevent recursive compacting + // Auto-compact/auto-clear automation (extracted to SessionAutoOps) + private _autoOps!: SessionAutoOps; // Image watcher setting (per-session toggle) private _imageWatcherEnabled: boolean = false; @@ -279,8 +275,6 @@ export class Session extends EventEmitter { private _cliInfoParsed: boolean = false; // Only parse once per session // Timer tracking for cleanup (prevents memory leaks) - private _autoCompactTimer: NodeJS.Timeout | null = null; - private _autoClearTimer: NodeJS.Timeout | null = null; private _promptCheckInterval: NodeJS.Timeout | null = null; private _promptCheckTimeout: NodeJS.Timeout | null = null; private _shellIdleTimer: NodeJS.Timeout | null = null; @@ -340,12 +334,7 @@ export class Session extends EventEmitter { toolsUpdate: (tools: ActiveBashTool[]) => void; } | null = null; - // Task descriptions parsed from terminal output (e.g., "Explore(Description)") - // Used to correlate with SubagentWatcher discoveries for better window titles - // Uses LRUMap for automatic eviction at MAX_TASK_DESCRIPTIONS limit - private _recentTaskDescriptions: LRUMap = new LRUMap({ - maxSize: Session.MAX_TASK_DESCRIPTIONS, - }); + // Task descriptions parsed from terminal output — delegated to SessionTaskCache // Throttle expensive PTY processing (Ralph, bash parser, task descriptions) // Accumulates clean data between processing windows to avoid running regex on every chunk @@ -463,6 +452,22 @@ export class Session extends EventEmitter { this._bashToolParser.on('toolStart', this._bashToolHandlers.toolStart); this._bashToolParser.on('toolEnd', this._bashToolHandlers.toolEnd); this._bashToolParser.on('toolsUpdate', this._bashToolHandlers.toolsUpdate); + + // Initialize auto-compact/auto-clear automation and forward events + this._autoOps = new SessionAutoOps({ + writeCommand: (cmd) => this.writeViaMux(cmd), + isWorking: () => this._isWorking, + isStopped: () => this._isStopped, + getTotalTokens: () => this._totalInputTokens + this._totalOutputTokens, + getSessionId: () => this.id, + }); + this._autoOps.on('autoCompact', (data) => this.emit('autoCompact', data)); + this._autoOps.on('autoClear', (data) => { + // Reset token counts on clear + this._totalInputTokens = 0; + this._totalOutputTokens = 0; + this.emit('autoClear', data); + }); } get status(): SessionStatus { @@ -597,25 +602,7 @@ export class Session extends EventEmitter { return this._allowedTools; } - /** - * Build Claude CLI permission flags based on the configured mode. - * Returns an array of args to pass to the CLI. - */ - private _buildPermissionArgs(): string[] { - switch (this._claudeMode) { - case 'dangerously-skip-permissions': - return ['--dangerously-skip-permissions']; - case 'allowedTools': - if (this._allowedTools) { - return ['--allowedTools', this._allowedTools]; - } - // Fall back to normal mode if no tools specified - return []; - case 'normal': - default: - return []; - } - } + // Note: _buildPermissionArgs removed — now using buildInteractiveArgs from session-cli-builder.ts /** * Set CPU priority configuration. @@ -689,11 +676,11 @@ export class Session extends EventEmitter { } get autoClearThreshold(): number { - return this._autoClearThreshold; + return this._autoOps.autoClearThreshold; } get autoClearEnabled(): boolean { - return this._autoClearEnabled; + return this._autoOps.autoClearEnabled; } get name(): string { @@ -704,58 +691,24 @@ export class Session extends EventEmitter { this._name = value; } - /** Minimum valid threshold for auto-clear/compact (1000 tokens) */ - private static readonly MIN_AUTO_THRESHOLD = 1000; - /** Maximum valid threshold for auto-clear/compact (500k tokens) */ - private static readonly MAX_AUTO_THRESHOLD = 500_000; - /** Default auto-clear threshold when invalid value provided */ - private static readonly DEFAULT_AUTO_CLEAR_THRESHOLD = 140_000; - /** Default auto-compact threshold when invalid value provided */ - private static readonly DEFAULT_AUTO_COMPACT_THRESHOLD = 110_000; - setAutoClear(enabled: boolean, threshold?: number): void { - this._autoClearEnabled = enabled; - if (threshold !== undefined) { - // Validate threshold bounds - if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) { - console.warn( - `[Session ${this.id}] Invalid autoClear threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_CLEAR_THRESHOLD}.` - ); - this._autoClearThreshold = Session.DEFAULT_AUTO_CLEAR_THRESHOLD; - } else { - this._autoClearThreshold = threshold; - } - } + this._autoOps.setAutoClear(enabled, threshold); } get autoCompactThreshold(): number { - return this._autoCompactThreshold; + return this._autoOps.autoCompactThreshold; } get autoCompactEnabled(): boolean { - return this._autoCompactEnabled; + return this._autoOps.autoCompactEnabled; } get autoCompactPrompt(): string { - return this._autoCompactPrompt; + return this._autoOps.autoCompactPrompt; } setAutoCompact(enabled: boolean, threshold?: number, prompt?: string): void { - this._autoCompactEnabled = enabled; - if (threshold !== undefined) { - // Validate threshold bounds - if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) { - console.warn( - `[Session ${this.id}] Invalid autoCompact threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_COMPACT_THRESHOLD}.` - ); - this._autoCompactThreshold = Session.DEFAULT_AUTO_COMPACT_THRESHOLD; - } else { - this._autoCompactThreshold = threshold; - } - } - if (prompt !== undefined) { - this._autoCompactPrompt = prompt; - } + this._autoOps.setAutoCompact(enabled, threshold, prompt); } get imageWatcherEnabled(): boolean { @@ -797,11 +750,11 @@ export class Session extends EventEmitter { lastActivityAt: this._lastActivityAt, name: this._name, mode: this.mode, - autoClearEnabled: this._autoClearEnabled, - autoClearThreshold: this._autoClearThreshold, - autoCompactEnabled: this._autoCompactEnabled, - autoCompactThreshold: this._autoCompactThreshold, - autoCompactPrompt: this._autoCompactPrompt, + autoClearEnabled: this._autoOps.autoClearEnabled, + autoClearThreshold: this._autoOps.autoClearThreshold, + autoCompactEnabled: this._autoOps.autoCompactEnabled, + autoCompactThreshold: this._autoOps.autoCompactThreshold, + autoCompactPrompt: this._autoOps.autoCompactPrompt, imageWatcherEnabled: this._imageWatcherEnabled, totalCost: this._totalCost, inputTokens: this._totalInputTokens, @@ -865,8 +818,8 @@ export class Session extends EventEmitter { total: this._totalInputTokens + this._totalOutputTokens, }, autoClear: { - enabled: this._autoClearEnabled, - threshold: this._autoClearThreshold, + enabled: this._autoOps.autoClearEnabled, + threshold: this._autoOps.autoClearThreshold, }, // CPU priority configuration nice: { @@ -979,14 +932,7 @@ export class Session extends EventEmitter { cols: 120, rows: 40, cwd: this.workingDir, - env: { - ...process.env, - LANG: 'en_US.UTF-8', - LC_ALL: 'en_US.UTF-8', - TERM: 'xterm-256color', - COLORTERM: undefined, - CLAUDECODE: undefined, - }, + env: buildMuxAttachEnv(), } ); @@ -1061,26 +1007,13 @@ export class Session extends EventEmitter { try { // Pass --session-id to use the SAME ID as the Codeman session // This ensures subagents can be directly matched to the correct tab - const args = [...this._buildPermissionArgs(), '--session-id', this.id]; - if (this._model) args.push('--model', this._model); + const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools); this.ptyProcess = pty.spawn('claude', args, { name: 'xterm-256color', cols: 120, rows: 40, cwd: this.workingDir, - env: { - ...process.env, - LANG: 'en_US.UTF-8', - LC_ALL: 'en_US.UTF-8', - PATH: getAugmentedPath(), - TERM: 'xterm-256color', - COLORTERM: undefined, - CLAUDECODE: undefined, - // Inform Claude it's running within Codeman (helps prevent self-termination) - CODEMAN_MUX: '1', - CODEMAN_SESSION_ID: this.id, - CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000', - }, + env: buildClaudeEnv(this.id), }); } catch (spawnErr) { console.error('[Session] Failed to spawn Claude PTY:', spawnErr); @@ -1372,14 +1305,7 @@ export class Session extends EventEmitter { cols: 120, rows: 40, cwd: this.workingDir, - env: { - ...process.env, - LANG: 'en_US.UTF-8', - LC_ALL: 'en_US.UTF-8', - TERM: 'xterm-256color', - COLORTERM: undefined, - CLAUDECODE: undefined, - }, + env: buildMuxAttachEnv(), } ); } catch (spawnErr) { @@ -1413,15 +1339,7 @@ export class Session extends EventEmitter { cols: 120, rows: 40, cwd: this.workingDir, - env: { - ...process.env, - LANG: 'en_US.UTF-8', - LC_ALL: 'en_US.UTF-8', - TERM: 'xterm-256color', - CODEMAN_MUX: '1', - CODEMAN_SESSION_ID: this.id, - CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000', - }, + env: buildShellEnv(this.id), }); } catch (spawnErr) { console.error('[Session] Failed to spawn shell PTY:', spawnErr); @@ -1530,11 +1448,7 @@ export class Session extends EventEmitter { model ? `(model: ${model})` : '' ); - const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json']; - if (model) { - args.push('--model', model); - } - args.push(prompt); + const args = buildPromptArgs(prompt, model); try { this.ptyProcess = pty.spawn('claude', args, { @@ -1542,19 +1456,7 @@ export class Session extends EventEmitter { cols: 120, rows: 40, cwd: this.workingDir, - env: { - ...process.env, - LANG: 'en_US.UTF-8', - LC_ALL: 'en_US.UTF-8', - PATH: getAugmentedPath(), - TERM: 'xterm-256color', - COLORTERM: undefined, - CLAUDECODE: undefined, - // Inform Claude it's running within Codeman - CODEMAN_MUX: '1', - CODEMAN_SESSION_ID: this.id, - CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000', - }, + env: buildClaudeEnv(this.id), }); } catch (spawnErr) { console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr); @@ -1725,8 +1627,8 @@ export class Session extends EventEmitter { } // Check if we should auto-compact or auto-clear - this.checkAutoCompact(); - this.checkAutoClear(); + this._autoOps.checkAutoCompact(); + this._autoOps.checkAutoClear(); } } @@ -1784,30 +1686,7 @@ export class Session extends EventEmitter { while ((match = TASK_TOOL_PATTERN.exec(cleanLine)) !== null) { const description = match[2].trim(); if (description && description.length > 0) { - const now = Date.now(); - this._recentTaskDescriptions.set(now, description); - - // Cleanup old entries - this.cleanupOldTaskDescriptions(); - } - } - } - - /** - * Remove task descriptions older than TASK_DESCRIPTION_MAX_AGE_MS. - * Size limit is handled automatically by LRUMap eviction on set(). - */ - private cleanupOldTaskDescriptions(): void { - const cutoff = Date.now() - Session.TASK_DESCRIPTION_MAX_AGE_MS; - // Keys are timestamps - iterate and delete expired entries - // LRUMap maintains insertion order, so we can break early once we find a non-expired entry - for (const timestamp of this._recentTaskDescriptions.keysInOrder()) { - if (timestamp < cutoff) { - this._recentTaskDescriptions.delete(timestamp); - } else { - // Keys are ordered by insertion time (which is the timestamp) - // Once we find a non-expired one, all subsequent are also non-expired - break; + this._taskCache.add(Date.now(), description); } } } @@ -1817,12 +1696,7 @@ export class Session extends EventEmitter { * Returns descriptions sorted by timestamp (most recent first). */ getRecentTaskDescriptions(): Array<{ timestamp: number; description: string }> { - this.cleanupOldTaskDescriptions(); - const results: Array<{ timestamp: number; description: string }> = []; - for (const [timestamp, description] of this._recentTaskDescriptions) { - results.push({ timestamp, description }); - } - return results.sort((a, b) => b.timestamp - a.timestamp); + return this._taskCache.getAll(); } /** @@ -1834,21 +1708,7 @@ export class Session extends EventEmitter { * @returns The matching description or undefined */ findTaskDescriptionNear(subagentStartTime: number, maxAgeMs: number = 10000): string | undefined { - this.cleanupOldTaskDescriptions(); - - // Find the most recent description that was parsed before or around the subagent start time - let bestMatch: { timestamp: number; description: string } | undefined; - let bestDiff = Infinity; - - for (const [timestamp, description] of this._recentTaskDescriptions) { - const diff = Math.abs(subagentStartTime - timestamp); - if (diff < maxAgeMs && diff < bestDiff) { - bestMatch = { timestamp, description }; - bestDiff = diff; - } - } - - return bestMatch?.description; + return this._taskCache.findNear(subagentStartTime, maxAgeMs); } // Parse token count from Claude's status line in interactive mode @@ -1913,8 +1773,8 @@ export class Session extends EventEmitter { this._totalOutputTokens += Math.round(delta * 0.4); // Check if we should auto-compact or auto-clear - this.checkAutoCompact(); - this.checkAutoClear(); + this._autoOps.checkAutoCompact(); + this._autoOps.checkAutoClear(); } } } @@ -1997,107 +1857,7 @@ export class Session extends EventEmitter { } } - // Check if we should auto-compact based on token threshold - private checkAutoCompact(): void { - if (this._isStopped) return; // Early exit check - if (!this._autoCompactEnabled || this._isCompacting || this._isClearing) return; - - const totalTokens = this._totalInputTokens + this._totalOutputTokens; - if (totalTokens >= this._autoCompactThreshold) { - this._isCompacting = true; - console.log(`[Session] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`); - - // Wait for Claude to be idle before compacting - const checkAndCompact = async () => { - // Check if session is still valid (not stopped) - must be first check - if (this._isStopped) return; - if (!this._isCompacting) return; - - if (!this._isWorking) { - // Re-check stopped state after async operation might have completed - if (this._isStopped) return; - - // Send /compact command with optional prompt - const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r'; - await this.writeViaMux(compactCmd); - this.emit('autoCompact', { - tokens: totalTokens, - threshold: this._autoCompactThreshold, - prompt: this._autoCompactPrompt || undefined, - }); - - // Wait a moment then re-enable (longer than clear since compact takes time) - if (!this._isStopped) { - this._autoCompactTimer = setTimeout(() => { - if (this._isStopped) return; // Check at callback start - this._autoCompactTimer = null; - this._isCompacting = false; - }, 10000); - } - } else { - // Check again after delay - if (!this._isStopped) { - this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_RETRY_DELAY_MS); - } - } - }; - - // Start checking after a short delay - if (!this._isStopped) { - this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_INITIAL_DELAY_MS); - } - } - } - - // Check if we should auto-clear based on token threshold - private checkAutoClear(): void { - if (this._isStopped) return; // Early exit check - if (!this._autoClearEnabled || this._isClearing || this._isCompacting) return; - - const totalTokens = this._totalInputTokens + this._totalOutputTokens; - if (totalTokens >= this._autoClearThreshold) { - this._isClearing = true; - console.log(`[Session] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`); - - // Wait for Claude to be idle before clearing - const checkAndClear = async () => { - // Check if session is still valid (not stopped) - must be first check - if (this._isStopped) return; - if (!this._isClearing) return; - - if (!this._isWorking) { - // Re-check stopped state after async operation might have completed - if (this._isStopped) return; - - // Send /clear command - await this.writeViaMux('/clear\r'); - // Reset token counts - this._totalInputTokens = 0; - this._totalOutputTokens = 0; - this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold }); - - // Wait a moment then re-enable - if (!this._isStopped) { - this._autoClearTimer = setTimeout(() => { - if (this._isStopped) return; // Check at callback start - this._autoClearTimer = null; - this._isClearing = false; - }, 5000); - } - } else { - // Check again after delay - if (!this._isStopped) { - this._autoClearTimer = setTimeout(checkAndClear, AUTO_RETRY_DELAY_MS); - } - } - }; - - // Start checking after a short delay - if (!this._isStopped) { - this._autoClearTimer = setTimeout(checkAndClear, AUTO_INITIAL_DELAY_MS); - } - } - } + // Note: checkAutoCompact/checkAutoClear moved to SessionAutoOps (this._autoOps) /** * Sends input directly to the PTY process. @@ -2265,18 +2025,8 @@ export class Session extends EventEmitter { this._lineBufferFlushTimer = null; } - // Clear auto-compact/auto-clear timers to prevent memory leaks - if (this._autoCompactTimer) { - clearTimeout(this._autoCompactTimer); - this._autoCompactTimer = null; - } - this._isCompacting = false; - - if (this._autoClearTimer) { - clearTimeout(this._autoClearTimer); - this._autoClearTimer = null; - } - this._isClearing = false; + // Destroy auto-compact/auto-clear automation (clears its timers) + this._autoOps.destroy(); // Clear prompt check timers if (this._promptCheckInterval) { @@ -2357,7 +2107,7 @@ export class Session extends EventEmitter { this._currentTaskId = null; // Clear task description cache and agent tree to prevent memory leak - this._recentTaskDescriptions.clear(); + this._taskCache.clear(); this._childAgentIds = []; // Kill the associated mux session if requested @@ -2413,6 +2163,6 @@ export class Session extends EventEmitter { this._messages = []; this._taskTracker.clear(); this._ralphTracker.clear(); - this._recentTaskDescriptions.clear(); + this._taskCache.clear(); } }