refactor: rename inner-loop-tracker to ralph-tracker with API improvements

- Rename inner-loop-tracker.ts → ralph-tracker.ts throughout codebase
- Add ralph-config.ts for parsing .claude/ralph-loop.local.md config
- Standardize API error responses using createErrorResponse()
- Add input validation for auto-compact/auto-clear thresholds
- Update UI labels to "Ralph / Todo Tracker" consistently
- Add 46 integration tests for Ralph tracking functionality
- Update test badge to 438 total tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-21 22:36:45 +01:00
co-authored by Claude Opus 4.5
parent 5be0babf01
commit 0d1ca37b6e
21 changed files with 2151 additions and 451 deletions
+420
View File
@@ -0,0 +1,420 @@
/**
* @fileoverview Tests for Ralph Wiggum configuration parser
*
* Tests parsing of .claude/ralph-loop.local.md and CLAUDE.md
* for Ralph loop configuration.
*/
import { describe, it, expect } from 'vitest';
import {
parseRalphLoopConfigFromContent,
extractCompletionPhraseFromContent,
} from '../src/ralph-config.js';
describe('parseRalphLoopConfigFromContent', () => {
describe('valid YAML frontmatter', () => {
it('should parse complete config', () => {
const content = `---
enabled: true
iteration: 5
max-iterations: 50
completion-promise: "COMPLETE"
---
# Original Prompt
Build a REST API...
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.enabled).toBe(true);
expect(config!.iteration).toBe(5);
expect(config!.maxIterations).toBe(50);
expect(config!.completionPromise).toBe('COMPLETE');
});
it('should parse config without quotes around values', () => {
const content = `---
enabled: true
iteration: 10
max-iterations: 100
completion-promise: TESTS_PASS
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.completionPromise).toBe('TESTS_PASS');
});
it('should parse config with hyphenated completion promise', () => {
const content = `---
enabled: true
completion-promise: "TESTS-PASS"
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.completionPromise).toBe('TESTS-PASS');
});
it('should handle partial config (only enabled)', () => {
const content = `---
enabled: true
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.enabled).toBe(true);
expect(config!.iteration).toBe(0);
expect(config!.maxIterations).toBeNull();
expect(config!.completionPromise).toBeNull();
});
it('should handle disabled state', () => {
const content = `---
enabled: false
iteration: 25
max-iterations: 50
completion-promise: "DONE"
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.enabled).toBe(false);
expect(config!.iteration).toBe(25);
});
it('should convert completion promise to uppercase', () => {
const content = `---
completion-promise: "lower_case_phrase"
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.completionPromise).toBe('LOWER_CASE_PHRASE');
});
it('should handle whitespace in values', () => {
const content = `---
enabled: true
completion-promise: "COMPLETE"
iteration: 5
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.completionPromise).toBe('COMPLETE');
expect(config!.iteration).toBe(5);
});
});
describe('invalid content', () => {
it('should return null for content without frontmatter', () => {
const content = `# Just a markdown file
No YAML frontmatter here.
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).toBeNull();
});
it('should return null for malformed frontmatter (missing closing)', () => {
const content = `---
enabled: true
# No closing ---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).toBeNull();
});
it('should return null for empty content', () => {
const config = parseRalphLoopConfigFromContent('');
expect(config).toBeNull();
});
it('should return null for empty frontmatter', () => {
const content = `---
---
Content here.
`;
const config = parseRalphLoopConfigFromContent(content);
// Empty frontmatter (nothing between ---) returns null
// because there's nothing useful to extract
expect(config).toBeNull();
});
});
describe('edge cases', () => {
it('should handle zero iteration', () => {
const content = `---
iteration: 0
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.iteration).toBe(0);
});
it('should handle invalid iteration as zero', () => {
const content = `---
iteration: not-a-number
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.iteration).toBe(0);
});
it('should handle invalid max-iterations as null', () => {
const content = `---
max-iterations: infinite
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.maxIterations).toBeNull();
});
it('should handle mixed valid and invalid values', () => {
const content = `---
enabled: true
iteration: abc
max-iterations: 50
completion-promise: "VALID-PHRASE"
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.enabled).toBe(true);
expect(config!.iteration).toBe(0); // Invalid falls back to 0
expect(config!.maxIterations).toBe(50);
expect(config!.completionPromise).toBe('VALID-PHRASE');
});
});
});
describe('extractCompletionPhraseFromContent', () => {
describe('standard patterns', () => {
it('should extract simple completion phrase', () => {
const content = `Output <promise>COMPLETE</promise> when done.`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('COMPLETE');
});
it('should extract phrase with underscores', () => {
const content = `<promise>ALL_TASKS_DONE</promise>`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('ALL_TASKS_DONE');
});
it('should extract phrase with hyphens', () => {
const content = `Output <promise>TESTS-PASS</promise> when all tests green.`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('TESTS-PASS');
});
it('should extract phrase with numbers', () => {
const content = `<promise>TASK_123</promise>`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('TASK_123');
});
it('should extract phrase with mixed characters', () => {
const content = `<promise>TASK-123_COMPLETE</promise>`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('TASK-123_COMPLETE');
});
});
describe('whitespace handling', () => {
it('should handle whitespace inside tags', () => {
const content = `<promise> COMPLETE </promise>`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('COMPLETE');
});
it('should handle newlines around phrase', () => {
const content = `<promise>
DONE
</promise>`;
// The pattern does match across lines since \s* includes newlines
// This is acceptable - multi-line promises are unusual but still valid
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('DONE');
});
});
describe('case handling', () => {
it('should convert lowercase to uppercase', () => {
const content = `<promise>complete</promise>`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('COMPLETE');
});
it('should convert mixed case to uppercase', () => {
const content = `<promise>TestsPass</promise>`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('TESTSPASS');
});
});
describe('multiple occurrences', () => {
it('should return first occurrence', () => {
const content = `
First: <promise>FIRST_PHRASE</promise>
Second: <promise>SECOND_PHRASE</promise>
Third: <promise>THIRD_PHRASE</promise>
`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('FIRST_PHRASE');
});
});
describe('in backticks', () => {
it('should extract phrase from within backticks', () => {
const content = 'Output `<promise>COMPLETE</promise>` when done.';
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('COMPLETE');
});
it('should extract phrase from code block', () => {
const content = `
\`\`\`
<promise>DONE</promise>
\`\`\`
`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('DONE');
});
});
describe('no match cases', () => {
it('should return null when no pattern found', () => {
const content = `Just some regular text without any promise tags.`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBeNull();
});
it('should return null for empty content', () => {
const phrase = extractCompletionPhraseFromContent('');
expect(phrase).toBeNull();
});
it('should return null for malformed tags', () => {
const content = `<promise>INCOMPLETE`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBeNull();
});
it('should return null for empty promise', () => {
const content = `<promise></promise>`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBeNull();
});
it('should return null for promise with only whitespace', () => {
const content = `<promise> </promise>`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBeNull();
});
});
describe('real-world CLAUDE.md examples', () => {
it('should extract from typical CLAUDE.md content', () => {
const content = `
# CLAUDE.md
## Task
Build a REST API for user management.
## Completion Criteria
- All endpoints working
- Tests passing
- Documentation complete
Output <promise>COMPLETE</promise> when all criteria met.
`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('COMPLETE');
});
it('should extract from Ralph Loop section', () => {
const content = `
## Ralph Wiggum Loop
Completion phrase: <promise>TIME_COMPLETE</promise>
Keep working until minimum duration reached.
`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('TIME_COMPLETE');
});
});
});
File diff suppressed because it is too large Load Diff
@@ -1,26 +1,26 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { InnerLoopTracker } from '../src/inner-loop-tracker.js';
import { InnerLoopState, InnerTodoItem } from '../src/types.js';
import { RalphTracker } from '../src/ralph-tracker.js';
import { RalphTrackerState, RalphTodoItem } from '../src/types.js';
/**
* InnerLoopTracker Tests
* RalphTracker Tests
*
* Tests the detection of Ralph Wiggum loops and todo lists from terminal output
* running inside Claude Code sessions.
*/
describe('InnerLoopTracker', () => {
let tracker: InnerLoopTracker;
describe('RalphTracker', () => {
let tracker: RalphTracker;
beforeEach(() => {
tracker = new InnerLoopTracker();
tracker = new RalphTracker();
// Enable tracker by default for most tests (testing detection logic)
tracker.enable();
});
describe('Initialization', () => {
it('should start with inactive loop state', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
const state = freshTracker.loopState;
expect(state.active).toBe(false);
expect(state.completionPhrase).toBeNull();
@@ -33,7 +33,7 @@ describe('InnerLoopTracker', () => {
});
it('should start disabled by default', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
expect(freshTracker.enabled).toBe(false);
expect(freshTracker.loopState.enabled).toBe(false);
});
@@ -41,7 +41,7 @@ describe('InnerLoopTracker', () => {
describe('Auto-Enable Behavior', () => {
it('should not process data when disabled', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
// This pattern doesn't trigger auto-enable
freshTracker.processTerminalData('Elapsed: 2.5 hours\n');
@@ -49,7 +49,7 @@ describe('InnerLoopTracker', () => {
});
it('should auto-enable on /ralph-loop:ralph-loop command', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
const enableHandler = vi.fn();
freshTracker.on('enabled', enableHandler);
@@ -60,21 +60,21 @@ describe('InnerLoopTracker', () => {
});
it('should auto-enable on completion phrase', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
freshTracker.processTerminalData('<promise>COMPLETE</promise>\n');
expect(freshTracker.enabled).toBe(true);
});
it('should auto-enable on TodoWrite detection', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
freshTracker.processTerminalData('TodoWrite: Todos have been modified\n');
expect(freshTracker.enabled).toBe(true);
});
it('should auto-enable on todo checkboxes', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
freshTracker.processTerminalData('- [ ] New task\n');
expect(freshTracker.enabled).toBe(true);
@@ -82,21 +82,21 @@ describe('InnerLoopTracker', () => {
});
it('should auto-enable on iteration patterns', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
freshTracker.processTerminalData('Iteration 5/50\n');
expect(freshTracker.enabled).toBe(true);
});
it('should auto-enable on loop start patterns', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
freshTracker.processTerminalData('Loop started at 2024-01-15\n');
expect(freshTracker.enabled).toBe(true);
});
it('should allow manual enable/disable', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
expect(freshTracker.enabled).toBe(false);
freshTracker.enable();
@@ -151,6 +151,28 @@ describe('InnerLoopTracker', () => {
expect(tracker.loopState.completionPhrase).toBe('MY_CUSTOM_PHRASE_123');
});
it('should detect completion phrases with hyphens', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.startLoop();
tracker.processTerminalData('<promise>TESTS-PASS</promise>\n');
expect(completionHandler).toHaveBeenCalledWith('TESTS-PASS');
expect(tracker.loopState.completionPhrase).toBe('TESTS-PASS');
});
it('should detect completion phrases with mixed characters', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.startLoop();
tracker.processTerminalData('<promise>TASK-123_COMPLETE</promise>\n');
expect(completionHandler).toHaveBeenCalledWith('TASK-123_COMPLETE');
expect(tracker.loopState.completionPhrase).toBe('TASK-123_COMPLETE');
});
it('should mark loop as inactive when completion detected', () => {
// Start a loop first
tracker.startLoop('TEST_PHRASE');
@@ -357,7 +379,7 @@ describe('InnerLoopTracker', () => {
});
it('should auto-enable on native todo pattern', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
freshTracker.processTerminalData('☐ New task\n');
expect(freshTracker.enabled).toBe(true);
@@ -427,8 +449,10 @@ describe('InnerLoopTracker', () => {
});
it('should clear all state', () => {
tracker.startLoop('TEST');
tracker.processTerminalData('- [ ] Test task to clear\n');
// Use unique phrase that won't appear in the todo content
// (bare phrase detection would trigger on common words like 'TEST')
tracker.startLoop('XYZZY_COMPLETE');
tracker.processTerminalData('- [ ] Sample task to clear\n');
expect(tracker.loopState.active).toBe(true);
expect(tracker.todos).toHaveLength(1);
@@ -443,7 +467,7 @@ describe('InnerLoopTracker', () => {
describe('State Restoration', () => {
it('should restore state from persisted data', () => {
const loopState: InnerLoopState = {
const loopState: RalphTrackerState = {
enabled: true,
active: true,
completionPhrase: 'RESTORED',
@@ -454,7 +478,7 @@ describe('InnerLoopTracker', () => {
elapsedHours: 1.5,
};
const todos: InnerTodoItem[] = [
const todos: RalphTodoItem[] = [
{ id: 'todo-1', content: 'Task 1', status: 'completed', detectedAt: Date.now() },
{ id: 'todo-2', content: 'Task 2', status: 'in_progress', detectedAt: Date.now() },
];
@@ -479,7 +503,7 @@ describe('InnerLoopTracker', () => {
maxIterations: null,
lastActivity: Date.now(),
elapsedHours: null,
} as InnerLoopState;
} as RalphTrackerState;
tracker.restoreState(loopState, []);
+4 -3
View File
@@ -125,9 +125,10 @@ describe('Interactive Session Lifecycle', () => {
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/output`);
const data = await response.json();
expect(data).toHaveProperty('textOutput');
expect(data).toHaveProperty('messages');
expect(data).toHaveProperty('errorBuffer');
expect(data.success).toBe(true);
expect(data.data).toHaveProperty('textOutput');
expect(data.data).toHaveProperty('messages');
expect(data.data).toHaveProperty('errorBuffer');
});
});
+27 -27
View File
@@ -2,7 +2,7 @@
* @fileoverview Tests for StateStore
*
* Tests the persistent JSON state storage including
* debounced saves, state CRUD operations, and inner state management.
* debounced saves, state CRUD operations, and Ralph state management.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@@ -11,7 +11,7 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
// Import types before mocking
import type { AppState, SessionState, TaskState, InnerSessionState } from '../src/types.js';
import type { AppState, SessionState, TaskState, RalphSessionState } from '../src/types.js';
// We need to import without mocking to test the actual implementation
import { StateStore, getStore } from '../src/state-store.js';
@@ -231,7 +231,7 @@ describe('StateStore', () => {
expect(store.getSession('session-1')).toBeNull();
expect(store.getTask('task-1')).toBeNull();
expect(store.getAllInnerStates().size).toBe(0);
expect(store.getAllRalphStates().size).toBe(0);
});
it('should preserve state file path in config', () => {
@@ -243,27 +243,27 @@ describe('StateStore', () => {
});
});
describe('inner state operations', () => {
it('should get and set inner state', () => {
describe('ralph state operations', () => {
it('should get and set ralph state', () => {
const store = new StateStore(testFilePath);
const innerState = createMockInnerState('session-1');
const ralphState = createMockRalphState('session-1');
store.setInnerState('session-1', innerState);
store.setRalphState('session-1', ralphState);
expect(store.getInnerState('session-1')).toEqual(innerState);
expect(store.getRalphState('session-1')).toEqual(ralphState);
});
it('should return null for non-existent inner state', () => {
it('should return null for non-existent ralph state', () => {
const store = new StateStore(testFilePath);
expect(store.getInnerState('non-existent')).toBeNull();
expect(store.getRalphState('non-existent')).toBeNull();
});
it('should update inner state with partial merge', () => {
it('should update ralph state with partial merge', () => {
const store = new StateStore(testFilePath);
store.setInnerState('session-1', createMockInnerState('session-1'));
const updated = store.updateInnerState('session-1', { totalTodos: 10 });
store.setRalphState('session-1', createMockRalphState('session-1'));
const updated = store.updateRalphState('session-1', { totalTodos: 10 });
expect(updated.totalTodos).toBe(10);
expect(updated.sessionId).toBe('session-1');
@@ -272,27 +272,27 @@ describe('StateStore', () => {
it('should create initial state on update if none exists', () => {
const store = new StateStore(testFilePath);
const state = store.updateInnerState('new-session', { totalTodos: 5 });
const state = store.updateRalphState('new-session', { totalTodos: 5 });
expect(state.sessionId).toBe('new-session');
expect(state.totalTodos).toBe(5);
});
it('should remove inner state', () => {
it('should remove ralph state', () => {
const store = new StateStore(testFilePath);
store.setInnerState('session-1', createMockInnerState('session-1'));
store.setRalphState('session-1', createMockRalphState('session-1'));
store.removeInnerState('session-1');
store.removeRalphState('session-1');
expect(store.getInnerState('session-1')).toBeNull();
expect(store.getRalphState('session-1')).toBeNull();
});
it('should get all inner states', () => {
it('should get all ralph states', () => {
const store = new StateStore(testFilePath);
store.setInnerState('session-1', createMockInnerState('session-1'));
store.setInnerState('session-2', createMockInnerState('session-2'));
store.setRalphState('session-1', createMockRalphState('session-1'));
store.setRalphState('session-2', createMockRalphState('session-2'));
const allStates = store.getAllInnerStates();
const allStates = store.getAllRalphStates();
expect(allStates.size).toBe(2);
expect(allStates.has('session-1')).toBe(true);
@@ -301,11 +301,11 @@ describe('StateStore', () => {
});
describe('flushAll', () => {
it('should flush both main and inner state', () => {
it('should flush both main and ralph state', () => {
const store = new StateStore(testFilePath);
store.setSession('session-1', createMockSessionState('session-1'));
store.setInnerState('session-1', createMockInnerState('session-1'));
store.setRalphState('session-1', createMockRalphState('session-1'));
store.flushAll();
@@ -320,7 +320,7 @@ describe('StateStore', () => {
const store1 = new StateStore(testFilePath);
store1.setSession('session-1', createMockSessionState('session-1'));
store1.setTask('task-1', createMockTaskState('task-1'));
store1.setInnerState('session-1', createMockInnerState('session-1'));
store1.setRalphState('session-1', createMockRalphState('session-1'));
store1.flushAll();
// Create new instance and verify state is loaded
@@ -328,7 +328,7 @@ describe('StateStore', () => {
expect(store2.getSession('session-1')).toBeDefined();
expect(store2.getTask('task-1')).toBeDefined();
expect(store2.getInnerState('session-1')).toBeDefined();
expect(store2.getRalphState('session-1')).toBeDefined();
});
});
});
@@ -363,7 +363,7 @@ function createMockTaskState(id: string): TaskState {
};
}
function createMockInnerState(sessionId: string): InnerSessionState {
function createMockRalphState(sessionId: string): RalphSessionState {
return {
sessionId,
enabled: true,
+8 -8
View File
@@ -9,8 +9,8 @@ import { describe, it, expect } from 'vitest';
import {
createErrorResponse,
createSuccessResponse,
createInitialInnerLoopState,
createInitialInnerSessionState,
createInitialRalphTrackerState,
createInitialRalphSessionState,
createInitialState,
ErrorMessages,
} from '../src/types.js';
@@ -69,9 +69,9 @@ describe('types utility functions', () => {
});
});
describe('createInitialInnerLoopState', () => {
it('should create initial inner loop state', () => {
const state = createInitialInnerLoopState();
describe('createInitialRalphTrackerState', () => {
it('should create initial Ralph tracker state', () => {
const state = createInitialRalphTrackerState();
expect(state.enabled).toBe(false);
expect(state.active).toBe(false);
@@ -84,9 +84,9 @@ describe('types utility functions', () => {
});
});
describe('createInitialInnerSessionState', () => {
it('should create initial inner session state with session ID', () => {
const state = createInitialInnerSessionState('session-123');
describe('createInitialRalphSessionState', () => {
it('should create initial Ralph session state with session ID', () => {
const state = createInitialRalphSessionState('session-123');
expect(state.sessionId).toBe('session-123');
expect(state.loop).toBeDefined();