aicodeman 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +403 -0
- package/dist/ai-checker-base.d.ts +175 -0
- package/dist/ai-checker-base.d.ts.map +1 -0
- package/dist/ai-checker-base.js +424 -0
- package/dist/ai-checker-base.js.map +1 -0
- package/dist/ai-idle-checker.d.ts +53 -0
- package/dist/ai-idle-checker.d.ts.map +1 -0
- package/dist/ai-idle-checker.js +141 -0
- package/dist/ai-idle-checker.js.map +1 -0
- package/dist/ai-plan-checker.d.ts +52 -0
- package/dist/ai-plan-checker.d.ts.map +1 -0
- package/dist/ai-plan-checker.js +103 -0
- package/dist/ai-plan-checker.js.map +1 -0
- package/dist/bash-tool-parser.d.ts +191 -0
- package/dist/bash-tool-parser.d.ts.map +1 -0
- package/dist/bash-tool-parser.js +598 -0
- package/dist/bash-tool-parser.js.map +1 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +460 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/buffer-limits.d.ts +59 -0
- package/dist/config/buffer-limits.d.ts.map +1 -0
- package/dist/config/buffer-limits.js +74 -0
- package/dist/config/buffer-limits.js.map +1 -0
- package/dist/config/map-limits.d.ts +40 -0
- package/dist/config/map-limits.d.ts.map +1 -0
- package/dist/config/map-limits.js +52 -0
- package/dist/config/map-limits.js.map +1 -0
- package/dist/file-stream-manager.d.ts +148 -0
- package/dist/file-stream-manager.d.ts.map +1 -0
- package/dist/file-stream-manager.js +351 -0
- package/dist/file-stream-manager.js.map +1 -0
- package/dist/hooks-config.d.ts +31 -0
- package/dist/hooks-config.d.ts.map +1 -0
- package/dist/hooks-config.js +115 -0
- package/dist/hooks-config.js.map +1 -0
- package/dist/image-watcher.d.ts +86 -0
- package/dist/image-watcher.d.ts.map +1 -0
- package/dist/image-watcher.js +275 -0
- package/dist/image-watcher.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +54 -0
- package/dist/index.js.map +1 -0
- package/dist/mux-factory.d.ts +13 -0
- package/dist/mux-factory.d.ts.map +1 -0
- package/dist/mux-factory.js +19 -0
- package/dist/mux-factory.js.map +1 -0
- package/dist/mux-interface.d.ts +145 -0
- package/dist/mux-interface.d.ts.map +1 -0
- package/dist/mux-interface.js +9 -0
- package/dist/mux-interface.js.map +1 -0
- package/dist/plan-orchestrator.d.ts +123 -0
- package/dist/plan-orchestrator.d.ts.map +1 -0
- package/dist/plan-orchestrator.js +500 -0
- package/dist/plan-orchestrator.js.map +1 -0
- package/dist/prompts/index.d.ts +9 -0
- package/dist/prompts/index.d.ts.map +1 -0
- package/dist/prompts/index.js +9 -0
- package/dist/prompts/index.js.map +1 -0
- package/dist/prompts/planner.d.ts +14 -0
- package/dist/prompts/planner.d.ts.map +1 -0
- package/dist/prompts/planner.js +83 -0
- package/dist/prompts/planner.js.map +1 -0
- package/dist/prompts/research-agent.d.ts +10 -0
- package/dist/prompts/research-agent.d.ts.map +1 -0
- package/dist/prompts/research-agent.js +143 -0
- package/dist/prompts/research-agent.js.map +1 -0
- package/dist/push-store.d.ts +41 -0
- package/dist/push-store.d.ts.map +1 -0
- package/dist/push-store.js +168 -0
- package/dist/push-store.js.map +1 -0
- package/dist/ralph-config.d.ts +67 -0
- package/dist/ralph-config.d.ts.map +1 -0
- package/dist/ralph-config.js +134 -0
- package/dist/ralph-config.js.map +1 -0
- package/dist/ralph-loop.d.ts +124 -0
- package/dist/ralph-loop.d.ts.map +1 -0
- package/dist/ralph-loop.js +418 -0
- package/dist/ralph-loop.js.map +1 -0
- package/dist/ralph-tracker.d.ts +1081 -0
- package/dist/ralph-tracker.d.ts.map +1 -0
- package/dist/ralph-tracker.js +3343 -0
- package/dist/ralph-tracker.js.map +1 -0
- package/dist/respawn-controller.d.ts +1182 -0
- package/dist/respawn-controller.d.ts.map +1 -0
- package/dist/respawn-controller.js +2754 -0
- package/dist/respawn-controller.js.map +1 -0
- package/dist/run-summary.d.ts +123 -0
- package/dist/run-summary.d.ts.map +1 -0
- package/dist/run-summary.js +325 -0
- package/dist/run-summary.js.map +1 -0
- package/dist/session-lifecycle-log.d.ts +36 -0
- package/dist/session-lifecycle-log.d.ts.map +1 -0
- package/dist/session-lifecycle-log.js +101 -0
- package/dist/session-lifecycle-log.js.map +1 -0
- package/dist/session-manager.d.ts +97 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +224 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/session.d.ts +686 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +2025 -0
- package/dist/session.js.map +1 -0
- package/dist/state-store.d.ts +189 -0
- package/dist/state-store.d.ts.map +1 -0
- package/dist/state-store.js +730 -0
- package/dist/state-store.js.map +1 -0
- package/dist/subagent-watcher.d.ts +345 -0
- package/dist/subagent-watcher.d.ts.map +1 -0
- package/dist/subagent-watcher.js +1469 -0
- package/dist/subagent-watcher.js.map +1 -0
- package/dist/task-queue.d.ts +108 -0
- package/dist/task-queue.d.ts.map +1 -0
- package/dist/task-queue.js +235 -0
- package/dist/task-queue.js.map +1 -0
- package/dist/task-tracker.d.ts +306 -0
- package/dist/task-tracker.d.ts.map +1 -0
- package/dist/task-tracker.js +488 -0
- package/dist/task-tracker.js.map +1 -0
- package/dist/task.d.ts +73 -0
- package/dist/task.d.ts.map +1 -0
- package/dist/task.js +177 -0
- package/dist/task.js.map +1 -0
- package/dist/team-watcher.d.ts +53 -0
- package/dist/team-watcher.d.ts.map +1 -0
- package/dist/team-watcher.js +313 -0
- package/dist/team-watcher.js.map +1 -0
- package/dist/templates/case-template.md +461 -0
- package/dist/templates/claude-md.d.ts +26 -0
- package/dist/templates/claude-md.d.ts.map +1 -0
- package/dist/templates/claude-md.js +74 -0
- package/dist/templates/claude-md.js.map +1 -0
- package/dist/tmux-manager.d.ts +181 -0
- package/dist/tmux-manager.d.ts.map +1 -0
- package/dist/tmux-manager.js +1405 -0
- package/dist/tmux-manager.js.map +1 -0
- package/dist/transcript-watcher.d.ts +110 -0
- package/dist/transcript-watcher.d.ts.map +1 -0
- package/dist/transcript-watcher.js +338 -0
- package/dist/transcript-watcher.js.map +1 -0
- package/dist/tunnel-manager.d.ts +54 -0
- package/dist/tunnel-manager.d.ts.map +1 -0
- package/dist/tunnel-manager.js +251 -0
- package/dist/tunnel-manager.js.map +1 -0
- package/dist/types.d.ts +1139 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +215 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/buffer-accumulator.d.ts +111 -0
- package/dist/utils/buffer-accumulator.d.ts.map +1 -0
- package/dist/utils/buffer-accumulator.js +172 -0
- package/dist/utils/buffer-accumulator.js.map +1 -0
- package/dist/utils/claude-cli-resolver.d.ts +26 -0
- package/dist/utils/claude-cli-resolver.d.ts.map +1 -0
- package/dist/utils/claude-cli-resolver.js +78 -0
- package/dist/utils/claude-cli-resolver.js.map +1 -0
- package/dist/utils/cleanup-manager.d.ts +165 -0
- package/dist/utils/cleanup-manager.d.ts.map +1 -0
- package/dist/utils/cleanup-manager.js +274 -0
- package/dist/utils/cleanup-manager.js.map +1 -0
- package/dist/utils/index.d.ts +19 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +19 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/lru-map.d.ts +140 -0
- package/dist/utils/lru-map.d.ts.map +1 -0
- package/dist/utils/lru-map.js +234 -0
- package/dist/utils/lru-map.js.map +1 -0
- package/dist/utils/nice-wrapper.d.ts +13 -0
- package/dist/utils/nice-wrapper.d.ts.map +1 -0
- package/dist/utils/nice-wrapper.js +17 -0
- package/dist/utils/nice-wrapper.js.map +1 -0
- package/dist/utils/opencode-cli-resolver.d.ts +21 -0
- package/dist/utils/opencode-cli-resolver.d.ts.map +1 -0
- package/dist/utils/opencode-cli-resolver.js +67 -0
- package/dist/utils/opencode-cli-resolver.js.map +1 -0
- package/dist/utils/regex-patterns.d.ts +64 -0
- package/dist/utils/regex-patterns.d.ts.map +1 -0
- package/dist/utils/regex-patterns.js +74 -0
- package/dist/utils/regex-patterns.js.map +1 -0
- package/dist/utils/stale-expiration-map.d.ts +159 -0
- package/dist/utils/stale-expiration-map.d.ts.map +1 -0
- package/dist/utils/stale-expiration-map.js +277 -0
- package/dist/utils/stale-expiration-map.js.map +1 -0
- package/dist/utils/string-similarity.d.ts +108 -0
- package/dist/utils/string-similarity.d.ts.map +1 -0
- package/dist/utils/string-similarity.js +189 -0
- package/dist/utils/string-similarity.js.map +1 -0
- package/dist/utils/token-validation.d.ts +39 -0
- package/dist/utils/token-validation.d.ts.map +1 -0
- package/dist/utils/token-validation.js +59 -0
- package/dist/utils/token-validation.js.map +1 -0
- package/dist/utils/type-safety.d.ts +33 -0
- package/dist/utils/type-safety.d.ts.map +1 -0
- package/dist/utils/type-safety.js +35 -0
- package/dist/utils/type-safety.js.map +1 -0
- package/dist/web/public/app.js +491 -0
- package/dist/web/public/app.js.br +0 -0
- package/dist/web/public/app.js.gz +0 -0
- package/dist/web/public/index.html +1675 -0
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.json +8 -0
- package/dist/web/public/mobile.css +1 -0
- package/dist/web/public/mobile.css.br +0 -0
- package/dist/web/public/mobile.css.gz +0 -0
- package/dist/web/public/ralph-wizard.js +1037 -0
- package/dist/web/public/ralph-wizard.js.br +0 -0
- package/dist/web/public/ralph-wizard.js.gz +0 -0
- package/dist/web/public/styles.css +1 -0
- package/dist/web/public/styles.css.br +0 -0
- package/dist/web/public/styles.css.gz +0 -0
- package/dist/web/public/sw.js +67 -0
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/upload.html +155 -0
- package/dist/web/public/upload.html.br +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js +1 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.br +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js +1 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.br +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js +2 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.br +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css +209 -0
- package/dist/web/public/vendor/xterm.css.br +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js +9 -0
- package/dist/web/public/vendor/xterm.min.js.br +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/schemas.d.ts +479 -0
- package/dist/web/schemas.d.ts.map +1 -0
- package/dist/web/schemas.js +448 -0
- package/dist/web/schemas.js.map +1 -0
- package/dist/web/server.d.ts +207 -0
- package/dist/web/server.d.ts.map +1 -0
- package/dist/web/server.js +5784 -0
- package/dist/web/server.js.map +1 -0
- package/package.json +110 -0
- package/scripts/postinstall.js +390 -0
|
@@ -0,0 +1,3343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Ralph Tracker - Detects Ralph Wiggum loops, todos, and completion phrases
|
|
3
|
+
*
|
|
4
|
+
* This module parses terminal output from Claude Code sessions to detect:
|
|
5
|
+
* - Ralph Wiggum loop state (active, completion phrase, iteration count)
|
|
6
|
+
* - Todo list items from the TodoWrite tool
|
|
7
|
+
* - Completion phrases signaling loop completion
|
|
8
|
+
*
|
|
9
|
+
* The tracker is DISABLED by default and auto-enables when Ralph-related
|
|
10
|
+
* patterns are detected in the output stream, reducing overhead for
|
|
11
|
+
* sessions not using autonomous loops.
|
|
12
|
+
*
|
|
13
|
+
* @module ralph-tracker
|
|
14
|
+
*/
|
|
15
|
+
import { EventEmitter } from 'node:events';
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import { existsSync, watch as fsWatch } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { createInitialRalphTrackerState, createInitialCircuitBreakerStatus, } from './types.js';
|
|
20
|
+
import { ANSI_ESCAPE_PATTERN_SIMPLE, fuzzyPhraseMatch, todoContentHash, stringSimilarity } from './utils/index.js';
|
|
21
|
+
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
|
|
22
|
+
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
|
|
23
|
+
// ========== Configuration Constants ==========
|
|
24
|
+
// Note: MAX_TODOS_PER_SESSION and MAX_LINE_BUFFER_SIZE are imported from config modules
|
|
25
|
+
/**
|
|
26
|
+
* Todo items older than this duration (in milliseconds) will be auto-expired.
|
|
27
|
+
* Default: 1 hour (60 * 60 * 1000)
|
|
28
|
+
*/
|
|
29
|
+
const TODO_EXPIRY_MS = 60 * 60 * 1000;
|
|
30
|
+
/**
|
|
31
|
+
* Minimum interval between cleanup checks (in milliseconds).
|
|
32
|
+
* Prevents running cleanup on every data chunk.
|
|
33
|
+
* Default: 30 seconds
|
|
34
|
+
*/
|
|
35
|
+
const CLEANUP_THROTTLE_MS = 30 * 1000;
|
|
36
|
+
/**
|
|
37
|
+
* Similarity threshold for todo deduplication.
|
|
38
|
+
* Todos with similarity >= this value are considered duplicates.
|
|
39
|
+
* Range: 0.0 (no similarity) to 1.0 (identical)
|
|
40
|
+
* Default: 0.85 (85% similar)
|
|
41
|
+
*/
|
|
42
|
+
const TODO_SIMILARITY_THRESHOLD = 0.85;
|
|
43
|
+
/**
|
|
44
|
+
* Debounce interval for event emissions (milliseconds).
|
|
45
|
+
* Prevents UI jitter from rapid consecutive updates.
|
|
46
|
+
* Default: 50ms
|
|
47
|
+
*/
|
|
48
|
+
const EVENT_DEBOUNCE_MS = 50;
|
|
49
|
+
/**
|
|
50
|
+
* Maximum number of completion phrase entries to track.
|
|
51
|
+
* Prevents unbounded growth if many unique phrases are seen.
|
|
52
|
+
*/
|
|
53
|
+
const MAX_COMPLETION_PHRASE_ENTRIES = 50;
|
|
54
|
+
const MAX_PLAN_HISTORY = 10;
|
|
55
|
+
/**
|
|
56
|
+
* Common/generic completion phrases that may cause false positives.
|
|
57
|
+
* These phrases are likely to appear in Claude's natural output,
|
|
58
|
+
* making them unreliable as completion signals.
|
|
59
|
+
*
|
|
60
|
+
* P1-002: Configurable false positive prevention
|
|
61
|
+
*/
|
|
62
|
+
const COMMON_COMPLETION_PHRASES = new Set([
|
|
63
|
+
'DONE',
|
|
64
|
+
'COMPLETE',
|
|
65
|
+
'FINISHED',
|
|
66
|
+
'OK',
|
|
67
|
+
'YES',
|
|
68
|
+
'TRUE',
|
|
69
|
+
'SUCCESS',
|
|
70
|
+
'READY',
|
|
71
|
+
'COMPLETED',
|
|
72
|
+
'PASSED',
|
|
73
|
+
'END',
|
|
74
|
+
'STOP',
|
|
75
|
+
'EXIT',
|
|
76
|
+
]);
|
|
77
|
+
/**
|
|
78
|
+
* Minimum recommended phrase length for completion detection.
|
|
79
|
+
* Shorter phrases are more likely to cause false positives.
|
|
80
|
+
*/
|
|
81
|
+
const MIN_RECOMMENDED_PHRASE_LENGTH = 6;
|
|
82
|
+
// ========== Pre-compiled Regex Patterns ==========
|
|
83
|
+
// Pre-compiled for performance (avoid re-compilation on each call)
|
|
84
|
+
/**
|
|
85
|
+
* Matches completion phrase tags: `<promise>PHRASE</promise>`
|
|
86
|
+
* Used to detect when Claude signals task completion.
|
|
87
|
+
* Capture group 1: The completion phrase text
|
|
88
|
+
*
|
|
89
|
+
* Supports any characters between tags including:
|
|
90
|
+
* - Uppercase letters: COMPLETE, DONE
|
|
91
|
+
* - Numbers: TASK_123
|
|
92
|
+
* - Underscores: ALL_TASKS_DONE
|
|
93
|
+
* - Hyphens: TESTS-PASS, TIME-COMPLETE
|
|
94
|
+
*
|
|
95
|
+
* Now also tolerates:
|
|
96
|
+
* - Whitespace/newlines inside tags: <promise> COMPLETE </promise>
|
|
97
|
+
* - Case variations in tag names: <Promise>, <PROMISE>
|
|
98
|
+
*/
|
|
99
|
+
const PROMISE_PATTERN = /<promise>\s*([^<]+?)\s*<\/promise>/i;
|
|
100
|
+
/**
|
|
101
|
+
* Pattern for detecting partial/incomplete promise tags at end of buffer.
|
|
102
|
+
* Used for cross-chunk promise detection when tags are split across PTY writes.
|
|
103
|
+
* Captures:
|
|
104
|
+
* - Group 1: Partial opening tag content after <promise> (may be incomplete)
|
|
105
|
+
*/
|
|
106
|
+
const PROMISE_PARTIAL_PATTERN = /<promise>\s*([^<]*)$/i;
|
|
107
|
+
// ---------- Todo Item Patterns ----------
|
|
108
|
+
// Claude Code outputs todos in multiple formats; we detect all of them
|
|
109
|
+
/**
|
|
110
|
+
* Format 1: Markdown checkbox format
|
|
111
|
+
* Matches: "- [ ] Task" or "- [x] Task" (also with * bullet)
|
|
112
|
+
* Capture group 1: Checkbox state ('x', 'X', or ' ')
|
|
113
|
+
* Capture group 2: Task content
|
|
114
|
+
*/
|
|
115
|
+
const TODO_CHECKBOX_PATTERN = /^[-*]\s*\[([xX ])\]\s+(.+)$/gm;
|
|
116
|
+
/**
|
|
117
|
+
* Format 2: Todo with indicator icons
|
|
118
|
+
* Matches: "Todo: ☐ Task", "Todo: ◐ Task", "Todo: ✓ Task"
|
|
119
|
+
* Capture group 1: Status icon
|
|
120
|
+
* Capture group 2: Task content
|
|
121
|
+
*/
|
|
122
|
+
const TODO_INDICATOR_PATTERN = /Todo:\s*(☐|◐|✓|⏳|✅|⌛|🔄)\s+(.+)/g;
|
|
123
|
+
/**
|
|
124
|
+
* Format 3: Status in parentheses
|
|
125
|
+
* Matches: "- Task (pending)", "- Task (in_progress)", "- Task (completed)"
|
|
126
|
+
* Capture group 1: Task content
|
|
127
|
+
* Capture group 2: Status string
|
|
128
|
+
*/
|
|
129
|
+
const TODO_STATUS_PATTERN = /[-*]\s*(.+?)\s+\((pending|in_progress|completed)\)/g;
|
|
130
|
+
/**
|
|
131
|
+
* Format 4: Claude Code native TodoWrite output
|
|
132
|
+
* Matches: "☐ Task", "☒ Task", "◐ Task", "✓ Task"
|
|
133
|
+
* These appear with optional leading whitespace/brackets like "⎿ ☐ Task"
|
|
134
|
+
* Capture group 1: Checkbox icon (☐=pending, ☒=completed, ◐=in_progress, ✓=completed)
|
|
135
|
+
* Capture group 2: Task content (min 3 chars, excludes checkbox icons)
|
|
136
|
+
*/
|
|
137
|
+
const TODO_NATIVE_PATTERN = /^[\s⎿]*(☐|☒|◐|✓)\s+([^☐☒◐✓\n]{3,})/gm;
|
|
138
|
+
/**
|
|
139
|
+
* Format 5: Claude Code checkmark-based TodoWrite output
|
|
140
|
+
* Matches task creation: "✔ Task #1 created: Fix the bug"
|
|
141
|
+
* Matches task summary: "✔ #1 Fix the bug"
|
|
142
|
+
* Matches status update: "✔ Task #1 updated: status → completed"
|
|
143
|
+
*
|
|
144
|
+
* These are the primary output format of Claude Code's TodoWrite tool.
|
|
145
|
+
*/
|
|
146
|
+
const TODO_TASK_CREATED_PATTERN = /✔\s*Task\s*#(\d+)\s*created:\s*(.+)/g;
|
|
147
|
+
const TODO_TASK_SUMMARY_PATTERN = /✔\s*#(\d+)\s+(.+)/g;
|
|
148
|
+
const TODO_TASK_STATUS_PATTERN = /✔\s*Task\s*#(\d+)\s*updated:\s*status\s*→\s*(in progress|completed|pending)/g;
|
|
149
|
+
/**
|
|
150
|
+
* Matches plain checkmark TodoWrite output without task numbers.
|
|
151
|
+
* Real Claude Code TodoWrite output: "✔ Create hello.txt with Hello World"
|
|
152
|
+
* This is the most common format in actual usage.
|
|
153
|
+
*/
|
|
154
|
+
const TODO_PLAIN_CHECKMARK_PATTERN = /✔\s+(.+)/g;
|
|
155
|
+
/**
|
|
156
|
+
* Patterns to exclude from todo detection
|
|
157
|
+
* Prevents false positives from tool invocations and Claude commentary
|
|
158
|
+
*/
|
|
159
|
+
const TODO_EXCLUDE_PATTERNS = [
|
|
160
|
+
/^(?:Bash|Search|Read|Write|Glob|Grep|Edit|Task)\s*\(/i, // Tool invocations
|
|
161
|
+
/^(?:I'll |Let me |Now I|First,|Task \d+:|Result:|Error:)/i, // Claude commentary
|
|
162
|
+
/^\S+\([^)]+\)$/, // Generic function call pattern
|
|
163
|
+
];
|
|
164
|
+
// ---------- Loop Status Patterns ----------
|
|
165
|
+
// Note: <promise> tags are handled separately by PROMISE_PATTERN
|
|
166
|
+
/**
|
|
167
|
+
* Matches generic loop start messages
|
|
168
|
+
* Examples: "Loop started at", "Starting main loop", "Ralph loop started"
|
|
169
|
+
*/
|
|
170
|
+
const LOOP_START_PATTERN = /Loop started at|Starting.*loop|Ralph loop started/i;
|
|
171
|
+
/**
|
|
172
|
+
* Matches elapsed time output
|
|
173
|
+
* Example: "Elapsed: 2.5 hours"
|
|
174
|
+
* Capture group 1: Hours as decimal number
|
|
175
|
+
*/
|
|
176
|
+
const ELAPSED_TIME_PATTERN = /Elapsed:\s*(\d+(?:\.\d+)?)\s*hours?/i;
|
|
177
|
+
/**
|
|
178
|
+
* Matches cycle count indicators (legacy format)
|
|
179
|
+
* Examples: "cycle #5", "respawn cycle #3"
|
|
180
|
+
* Capture groups 1 or 2: Cycle number
|
|
181
|
+
*/
|
|
182
|
+
const CYCLE_PATTERN = /cycle\s*#?(\d+)|respawn cycle #(\d+)/i;
|
|
183
|
+
// ---------- Ralph Wiggum Plugin Patterns ----------
|
|
184
|
+
// Based on the official Ralph Wiggum plugin output format
|
|
185
|
+
/**
|
|
186
|
+
* Matches iteration progress indicators
|
|
187
|
+
* Examples: "Iteration 5/50", "[5/50]", "iteration #5", "iter. 3 of 10"
|
|
188
|
+
* Capture groups: (1,2) for "Iteration X/Y" format, (3,4) for "[X/Y]" format
|
|
189
|
+
*/
|
|
190
|
+
const ITERATION_PATTERN = /(?:iteration|iter\.?)\s*#?(\d+)(?:\s*(?:\/|of)\s*(\d+))?|\[(\d+)\/(\d+)\]/i;
|
|
191
|
+
/**
|
|
192
|
+
* Matches Ralph loop start command or announcement
|
|
193
|
+
* Examples: "/ralph-loop:ralph-loop", "Starting Ralph Wiggum loop", "ralph loop beginning"
|
|
194
|
+
*/
|
|
195
|
+
const RALPH_START_PATTERN = /\/ralph-loop|starting ralph(?:\s+wiggum)?\s+loop|ralph loop (?:started|beginning)/i;
|
|
196
|
+
/**
|
|
197
|
+
* Matches max iterations configuration
|
|
198
|
+
* Examples: "max-iterations 50", "maxIterations: 50", "max_iterations=50"
|
|
199
|
+
* Capture group 1: Maximum iteration count
|
|
200
|
+
*/
|
|
201
|
+
const MAX_ITERATIONS_PATTERN = /max[_-]?iterations?\s*[=:]\s*(\d+)/i;
|
|
202
|
+
/**
|
|
203
|
+
* Matches TodoWrite tool usage indicators
|
|
204
|
+
* Examples: "TodoWrite", "todos updated", "Todos have been modified"
|
|
205
|
+
*/
|
|
206
|
+
const TODOWRITE_PATTERN = /TodoWrite|todo(?:s)?\s*(?:updated|written|saved)|Todos have been modified/i;
|
|
207
|
+
// ---------- Task Completion Detection Patterns ----------
|
|
208
|
+
/**
|
|
209
|
+
* Matches "all tasks complete" announcements
|
|
210
|
+
* Examples: "All 8 files have been created", "All tasks completed", "Everything is done"
|
|
211
|
+
* Used to mark all tracked todos as complete at once
|
|
212
|
+
*/
|
|
213
|
+
const ALL_COMPLETE_PATTERN = /all\s+(?:\d+\s+)?(?:tasks?|files?|items?)\s+(?:have\s+been\s+|are\s+)?(?:completed?|done|finished|created)|completed?\s+all\s+(?:\d+\s+)?tasks?|all\s+done|everything\s+(?:is\s+)?(?:completed?|done)|finished\s+all\s+tasks?/i;
|
|
214
|
+
/**
|
|
215
|
+
* Extracts count from "all N items" messages
|
|
216
|
+
* Example: "All 8 files created" → captures "8"
|
|
217
|
+
* Capture group 1: The count
|
|
218
|
+
*/
|
|
219
|
+
const ALL_COUNT_PATTERN = /all\s+(\d+)\s+(?:tasks?|files?|items?)/i;
|
|
220
|
+
/**
|
|
221
|
+
* Matches individual task completion messages
|
|
222
|
+
* Examples: "Task #5 is done", "marked as completed", "todo 3 finished"
|
|
223
|
+
* Used to update specific todo items by number
|
|
224
|
+
*/
|
|
225
|
+
const TASK_DONE_PATTERN = /(?:task|item|todo)\s*(?:#?\d+|"\s*[^"]+\s*")?\s*(?:is\s+)?(?:done|completed?|finished)|(?:completed?|done|finished)\s+(?:task|item)\s*(?:#?\d+)?|marking\s+(?:.*?\s+)?(?:as\s+)?completed?|marked\s+(?:.*?\s+)?(?:as\s+)?completed?/i;
|
|
226
|
+
// ---------- Utility Patterns ----------
|
|
227
|
+
/** Maximum number of task number to content mappings to track */
|
|
228
|
+
const MAX_TASK_MAPPINGS = 100;
|
|
229
|
+
// ---------- RALPH_STATUS Block Patterns ----------
|
|
230
|
+
// Based on Ralph Claude Code structured status reporting
|
|
231
|
+
/**
|
|
232
|
+
* Matches the start of a RALPH_STATUS block
|
|
233
|
+
* Pattern: ---RALPH_STATUS---
|
|
234
|
+
*/
|
|
235
|
+
const RALPH_STATUS_START_PATTERN = /^---RALPH_STATUS---\s*$/;
|
|
236
|
+
/**
|
|
237
|
+
* Matches the end of a RALPH_STATUS block
|
|
238
|
+
* Pattern: ---END_RALPH_STATUS---
|
|
239
|
+
*/
|
|
240
|
+
const RALPH_STATUS_END_PATTERN = /^---END_RALPH_STATUS---\s*$/;
|
|
241
|
+
/**
|
|
242
|
+
* Matches STATUS field in RALPH_STATUS block
|
|
243
|
+
* Captures: IN_PROGRESS | COMPLETE | BLOCKED
|
|
244
|
+
*/
|
|
245
|
+
const RALPH_STATUS_FIELD_PATTERN = /^STATUS:\s*(IN_PROGRESS|COMPLETE|BLOCKED)\s*$/i;
|
|
246
|
+
/**
|
|
247
|
+
* Matches TASKS_COMPLETED_THIS_LOOP field
|
|
248
|
+
* Captures: number
|
|
249
|
+
*/
|
|
250
|
+
const RALPH_TASKS_COMPLETED_PATTERN = /^TASKS_COMPLETED_THIS_LOOP:\s*(\d+)\s*$/i;
|
|
251
|
+
/**
|
|
252
|
+
* Matches FILES_MODIFIED field
|
|
253
|
+
* Captures: number
|
|
254
|
+
*/
|
|
255
|
+
const RALPH_FILES_MODIFIED_PATTERN = /^FILES_MODIFIED:\s*(\d+)\s*$/i;
|
|
256
|
+
/**
|
|
257
|
+
* Matches TESTS_STATUS field
|
|
258
|
+
* Captures: PASSING | FAILING | NOT_RUN
|
|
259
|
+
*/
|
|
260
|
+
const RALPH_TESTS_STATUS_PATTERN = /^TESTS_STATUS:\s*(PASSING|FAILING|NOT_RUN)\s*$/i;
|
|
261
|
+
/**
|
|
262
|
+
* Matches WORK_TYPE field
|
|
263
|
+
* Captures: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING
|
|
264
|
+
*/
|
|
265
|
+
const RALPH_WORK_TYPE_PATTERN = /^WORK_TYPE:\s*(IMPLEMENTATION|TESTING|DOCUMENTATION|REFACTORING)\s*$/i;
|
|
266
|
+
/**
|
|
267
|
+
* Matches EXIT_SIGNAL field
|
|
268
|
+
* Captures: true | false
|
|
269
|
+
*/
|
|
270
|
+
const RALPH_EXIT_SIGNAL_PATTERN = /^EXIT_SIGNAL:\s*(true|false)\s*$/i;
|
|
271
|
+
/**
|
|
272
|
+
* Matches RECOMMENDATION field
|
|
273
|
+
* Captures: any text
|
|
274
|
+
*/
|
|
275
|
+
const RALPH_RECOMMENDATION_PATTERN = /^RECOMMENDATION:\s*(.+)$/i;
|
|
276
|
+
// ---------- Completion Indicator Patterns (for dual-condition exit) ----------
|
|
277
|
+
/**
|
|
278
|
+
* Patterns that indicate potential completion (natural language)
|
|
279
|
+
* Count >= 2 along with EXIT_SIGNAL: true triggers exit
|
|
280
|
+
*/
|
|
281
|
+
const COMPLETION_INDICATOR_PATTERNS = [
|
|
282
|
+
/all\s+(?:tasks?|items?|work)\s+(?:are\s+)?(?:completed?|done|finished)/i,
|
|
283
|
+
/(?:completed?|finished)\s+all\s+(?:tasks?|items?|work)/i,
|
|
284
|
+
/nothing\s+(?:left|remaining)\s+to\s+do/i,
|
|
285
|
+
/no\s+more\s+(?:tasks?|items?|work)/i,
|
|
286
|
+
/everything\s+(?:is\s+)?(?:completed?|done)/i,
|
|
287
|
+
/project\s+(?:is\s+)?(?:completed?|done|finished)/i,
|
|
288
|
+
];
|
|
289
|
+
// ---------- Priority Detection Patterns ----------
|
|
290
|
+
// Pre-compiled for performance; avoids repeated allocation in parsePriority()
|
|
291
|
+
/** P0 (Critical) priority patterns - highest severity issues */
|
|
292
|
+
const P0_PRIORITY_PATTERNS = [
|
|
293
|
+
/\bP0\b|\(P0\)|:?\s*P0\s*:/, // Explicit P0
|
|
294
|
+
/\bCRITICAL\b/, // Critical keyword
|
|
295
|
+
/\bBLOCKER\b/, // Blocker
|
|
296
|
+
/\bURGENT\b/, // Urgent
|
|
297
|
+
/\bSECURITY\b/, // Security issues
|
|
298
|
+
/\bCRASH(?:ES|ING)?\b/, // Crash, crashes, crashing
|
|
299
|
+
/\bBROKEN\b/, // Broken
|
|
300
|
+
/\bDATA\s*LOSS\b/, // Data loss
|
|
301
|
+
/\bPRODUCTION\s*(?:DOWN|ISSUE|BUG)\b/, // Production issues
|
|
302
|
+
/\bHOTFIX\b/, // Hotfix
|
|
303
|
+
/\bSEVERITY\s*1\b/, // Severity 1
|
|
304
|
+
];
|
|
305
|
+
/** P1 (High) priority patterns - important issues requiring attention */
|
|
306
|
+
const P1_PRIORITY_PATTERNS = [
|
|
307
|
+
/\bP1\b|\(P1\)|:?\s*P1\s*:/, // Explicit P1
|
|
308
|
+
/\bHIGH\s*PRIORITY\b/, // High priority
|
|
309
|
+
/\bIMPORTANT\b/, // Important
|
|
310
|
+
/\bBUG\b/, // Bug
|
|
311
|
+
/\bFIX\b/, // Fix (as task type)
|
|
312
|
+
/\bERROR\b/, // Error
|
|
313
|
+
/\bFAIL(?:S|ED|ING|URE)?\b/, // Fail variants
|
|
314
|
+
/\bREGRESSION\b/, // Regression
|
|
315
|
+
/\bMUST\s*(?:HAVE|FIX|DO)\b/, // Must have/fix/do
|
|
316
|
+
/\bSEVERITY\s*2\b/, // Severity 2
|
|
317
|
+
/\bREQUIRED\b/, // Required
|
|
318
|
+
];
|
|
319
|
+
/** P2 (Medium) priority patterns - lower priority improvements */
|
|
320
|
+
const P2_PRIORITY_PATTERNS = [
|
|
321
|
+
/\bP2\b|\(P2\)|:?\s*P2\s*:/, // Explicit P2
|
|
322
|
+
/\bNICE\s*TO\s*HAVE\b/, // Nice to have
|
|
323
|
+
/\bLOW\s*PRIORITY\b/, // Low priority
|
|
324
|
+
/\bREFACTOR\b/, // Refactor
|
|
325
|
+
/\bCLEANUP\b/, // Cleanup
|
|
326
|
+
/\bIMPROVE(?:MENT)?\b/, // Improve/Improvement
|
|
327
|
+
/\bOPTIMIZ(?:E|ATION)\b/, // Optimize/Optimization
|
|
328
|
+
/\bCONSIDER\b/, // Consider
|
|
329
|
+
/\bWOULD\s*BE\s*NICE\b/, // Would be nice
|
|
330
|
+
/\bENHANCE(?:MENT)?\b/, // Enhance/Enhancement
|
|
331
|
+
/\bTECH(?:NICAL)?\s*DEBT\b/, // Tech debt
|
|
332
|
+
/\bDOCUMENT(?:ATION)?\b/, // Documentation
|
|
333
|
+
];
|
|
334
|
+
/**
|
|
335
|
+
* RalphTracker - Parses terminal output to detect Ralph Wiggum loops and todos
|
|
336
|
+
*
|
|
337
|
+
* This class monitors Claude Code session output to detect:
|
|
338
|
+
* 1. **Ralph Wiggum loop state** - Active loops, completion phrases, iteration counts
|
|
339
|
+
* 2. **Todo list items** - From TodoWrite tool in various formats
|
|
340
|
+
* 3. **Completion signals** - `<promise>PHRASE</promise>` tags
|
|
341
|
+
*
|
|
342
|
+
* ## Lifecycle
|
|
343
|
+
*
|
|
344
|
+
* The tracker is **DISABLED by default** and auto-enables when Ralph-related
|
|
345
|
+
* patterns are detected (e.g., /ralph-loop:ralph-loop, <promise>, todos).
|
|
346
|
+
* This reduces overhead for sessions not using autonomous loops.
|
|
347
|
+
*
|
|
348
|
+
* ## Completion Detection
|
|
349
|
+
*
|
|
350
|
+
* Uses occurrence-based detection to distinguish prompt from actual completion:
|
|
351
|
+
* - 1st occurrence of `<promise>X</promise>`: Stored as expected phrase (likely in prompt)
|
|
352
|
+
* - 2nd occurrence: Emits `completionDetected` event (actual completion)
|
|
353
|
+
* - If loop already active: Emits immediately on first occurrence
|
|
354
|
+
*
|
|
355
|
+
* ## Events
|
|
356
|
+
*
|
|
357
|
+
* - `loopUpdate` - Loop state changed (status, iteration, phrase)
|
|
358
|
+
* - `todoUpdate` - Todo list modified (add, status change)
|
|
359
|
+
* - `completionDetected` - Loop completion phrase detected
|
|
360
|
+
* - `enabled` - Tracker auto-enabled from disabled state
|
|
361
|
+
*
|
|
362
|
+
* @extends EventEmitter
|
|
363
|
+
* @example
|
|
364
|
+
* ```typescript
|
|
365
|
+
* const tracker = new RalphTracker();
|
|
366
|
+
* tracker.on('completionDetected', (phrase) => {
|
|
367
|
+
* console.log('Loop completed with phrase:', phrase);
|
|
368
|
+
* });
|
|
369
|
+
* tracker.processTerminalData(ptyOutput);
|
|
370
|
+
* ```
|
|
371
|
+
*/
|
|
372
|
+
export class RalphTracker extends EventEmitter {
|
|
373
|
+
/** Current state of the detected loop */
|
|
374
|
+
_loopState;
|
|
375
|
+
/** Map of todo items by ID for O(1) lookup */
|
|
376
|
+
_todos = new Map();
|
|
377
|
+
/** Buffer for incomplete lines from terminal data */
|
|
378
|
+
_lineBuffer = '';
|
|
379
|
+
/**
|
|
380
|
+
* Tracks occurrences of completion phrases.
|
|
381
|
+
* Used to distinguish prompt echo (1st) from actual completion (2nd+).
|
|
382
|
+
*/
|
|
383
|
+
_completionPhraseCount = new Map();
|
|
384
|
+
/** Timestamp of last cleanup check for throttling */
|
|
385
|
+
_lastCleanupTime = 0;
|
|
386
|
+
/** Debounce timer for todoUpdate events */
|
|
387
|
+
_todoUpdateTimer = null;
|
|
388
|
+
/** Debounce timer for loopUpdate events */
|
|
389
|
+
_loopUpdateTimer = null;
|
|
390
|
+
/** Flag indicating pending todoUpdate emission */
|
|
391
|
+
_todoUpdatePending = false;
|
|
392
|
+
/** Flag indicating pending loopUpdate emission */
|
|
393
|
+
_loopUpdatePending = false;
|
|
394
|
+
/** When true, prevents auto-enable on pattern detection */
|
|
395
|
+
_autoEnableDisabled = true;
|
|
396
|
+
/** Maps task numbers from "✔ Task #N" format to their content for status updates */
|
|
397
|
+
_taskNumberToContent = new Map();
|
|
398
|
+
/**
|
|
399
|
+
* Buffer for partial promise tags split across PTY chunks.
|
|
400
|
+
* Holds content after '<promise>' when closing tag hasn't arrived yet.
|
|
401
|
+
* Max 256 chars to prevent unbounded growth from malformed tags.
|
|
402
|
+
*/
|
|
403
|
+
_partialPromiseBuffer = '';
|
|
404
|
+
/** Maximum size of partial promise buffer */
|
|
405
|
+
static MAX_PARTIAL_PROMISE_SIZE = 256;
|
|
406
|
+
// ========== RALPH_STATUS Block State ==========
|
|
407
|
+
/** Circuit breaker state tracking */
|
|
408
|
+
_circuitBreaker;
|
|
409
|
+
/** Buffer for RALPH_STATUS block lines */
|
|
410
|
+
_statusBlockBuffer = [];
|
|
411
|
+
/** Flag indicating we're inside a RALPH_STATUS block */
|
|
412
|
+
_inStatusBlock = false;
|
|
413
|
+
/** Last parsed RALPH_STATUS block */
|
|
414
|
+
_lastStatusBlock = null;
|
|
415
|
+
/** Count of completion indicators detected (for dual-condition exit) */
|
|
416
|
+
_completionIndicators = 0;
|
|
417
|
+
/** Whether dual-condition exit gate has been met */
|
|
418
|
+
_exitGateMet = false;
|
|
419
|
+
/** Cumulative files modified across all iterations */
|
|
420
|
+
_totalFilesModified = 0;
|
|
421
|
+
/** Cumulative tasks completed across all iterations */
|
|
422
|
+
_totalTasksCompleted = 0;
|
|
423
|
+
/** Working directory for @fix_plan.md watching */
|
|
424
|
+
_workingDir = null;
|
|
425
|
+
/** File watcher for @fix_plan.md */
|
|
426
|
+
_fixPlanWatcher = null;
|
|
427
|
+
/** Error handler for FSWatcher (stored for cleanup to prevent memory leak) */
|
|
428
|
+
_fixPlanWatcherErrorHandler = null;
|
|
429
|
+
/** Debounce timer for file change events */
|
|
430
|
+
_fixPlanReloadTimer = null;
|
|
431
|
+
/** Path to the @fix_plan.md file being watched */
|
|
432
|
+
_fixPlanPath = null;
|
|
433
|
+
/**
|
|
434
|
+
* When @fix_plan.md is active, treat it as the source of truth for todo status.
|
|
435
|
+
* This prevents output-based detection from overriding file-based status.
|
|
436
|
+
*/
|
|
437
|
+
get isFileAuthoritative() {
|
|
438
|
+
return this._fixPlanPath !== null;
|
|
439
|
+
}
|
|
440
|
+
// ========== Enhanced Plan Management ==========
|
|
441
|
+
/** Current version of the plan (incremented on changes) */
|
|
442
|
+
_planVersion = 1;
|
|
443
|
+
/** History of plan versions for rollback support */
|
|
444
|
+
_planHistory = [];
|
|
445
|
+
/** Enhanced plan tasks with execution tracking */
|
|
446
|
+
_planTasks = new Map();
|
|
447
|
+
/** Checkpoint intervals (iterations at which to trigger review) */
|
|
448
|
+
_checkpointIterations = [5, 10, 20, 30, 50, 75, 100];
|
|
449
|
+
/** Last checkpoint iteration */
|
|
450
|
+
_lastCheckpointIteration = 0;
|
|
451
|
+
// ========== Iteration Stall Detection ==========
|
|
452
|
+
/** Timestamp when iteration count last changed */
|
|
453
|
+
_lastIterationChangeTime = 0;
|
|
454
|
+
/** Last observed iteration count for stall detection */
|
|
455
|
+
_lastObservedIteration = 0;
|
|
456
|
+
/** Timer for iteration stall detection */
|
|
457
|
+
_iterationStallTimer = null;
|
|
458
|
+
/** Iteration stall warning threshold (ms) - default 10 minutes */
|
|
459
|
+
_iterationStallWarningMs = 10 * 60 * 1000;
|
|
460
|
+
/** Iteration stall critical threshold (ms) - default 20 minutes */
|
|
461
|
+
_iterationStallCriticalMs = 20 * 60 * 1000;
|
|
462
|
+
/** Whether stall warning has been emitted */
|
|
463
|
+
_iterationStallWarned = false;
|
|
464
|
+
/** Alternate completion phrases (P1-003: multi-phrase support) - Set for O(1) lookup */
|
|
465
|
+
_alternateCompletionPhrases = new Set();
|
|
466
|
+
// ========== P1-009: Progress Estimation ==========
|
|
467
|
+
/** History of todo completion times (ms) for averaging */
|
|
468
|
+
_completionTimes = [];
|
|
469
|
+
/** Maximum number of completion times to track */
|
|
470
|
+
static MAX_COMPLETION_TIMES = 50;
|
|
471
|
+
/** Timestamp when todos started being tracked for this session */
|
|
472
|
+
_todosStartedAt = 0;
|
|
473
|
+
/** Map of todo ID to timestamp when it started (for duration tracking) */
|
|
474
|
+
_todoStartTimes = new Map();
|
|
475
|
+
/**
|
|
476
|
+
* Creates a new RalphTracker instance.
|
|
477
|
+
* Starts in disabled state until Ralph patterns are detected.
|
|
478
|
+
*/
|
|
479
|
+
constructor() {
|
|
480
|
+
super();
|
|
481
|
+
this._loopState = createInitialRalphTrackerState();
|
|
482
|
+
this._circuitBreaker = createInitialCircuitBreakerStatus();
|
|
483
|
+
this._lastIterationChangeTime = Date.now();
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Add an alternate completion phrase (P1-003: multi-phrase support).
|
|
487
|
+
* Multiple phrases can trigger completion (useful for complex workflows).
|
|
488
|
+
* @param phrase - Additional phrase that can trigger completion
|
|
489
|
+
*/
|
|
490
|
+
addAlternateCompletionPhrase(phrase) {
|
|
491
|
+
if (!this._alternateCompletionPhrases.has(phrase)) {
|
|
492
|
+
this._alternateCompletionPhrases.add(phrase);
|
|
493
|
+
this._loopState.alternateCompletionPhrases = Array.from(this._alternateCompletionPhrases);
|
|
494
|
+
this.emit('loopUpdate', this.loopState);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Remove an alternate completion phrase.
|
|
499
|
+
* @param phrase - Phrase to remove
|
|
500
|
+
*/
|
|
501
|
+
removeAlternateCompletionPhrase(phrase) {
|
|
502
|
+
if (this._alternateCompletionPhrases.delete(phrase)) {
|
|
503
|
+
this._loopState.alternateCompletionPhrases = Array.from(this._alternateCompletionPhrases);
|
|
504
|
+
this.emit('loopUpdate', this.loopState);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Check if a phrase matches any valid completion phrase (primary or alternate).
|
|
509
|
+
* @param phrase - Phrase to check
|
|
510
|
+
* @returns True if phrase matches any valid completion phrase
|
|
511
|
+
*/
|
|
512
|
+
isValidCompletionPhrase(phrase) {
|
|
513
|
+
return this.findMatchingCompletionPhrase(phrase) !== null;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Find which completion phrase (primary or alternate) matches the given phrase.
|
|
517
|
+
* @param phrase - Phrase to check
|
|
518
|
+
* @returns The matched canonical phrase, or null if no match
|
|
519
|
+
*/
|
|
520
|
+
findMatchingCompletionPhrase(phrase) {
|
|
521
|
+
const primary = this._loopState.completionPhrase;
|
|
522
|
+
if (primary && this.isFuzzyPhraseMatch(phrase, primary)) {
|
|
523
|
+
return primary;
|
|
524
|
+
}
|
|
525
|
+
for (const alt of this._alternateCompletionPhrases) {
|
|
526
|
+
if (this.isFuzzyPhraseMatch(phrase, alt)) {
|
|
527
|
+
return alt;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Prevent auto-enable from pattern detection.
|
|
534
|
+
* Use this when the user has explicitly disabled the Ralph tracker.
|
|
535
|
+
*/
|
|
536
|
+
disableAutoEnable() {
|
|
537
|
+
this._autoEnableDisabled = true;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Allow auto-enable from pattern detection.
|
|
541
|
+
*/
|
|
542
|
+
enableAutoEnable() {
|
|
543
|
+
this._autoEnableDisabled = false;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Whether auto-enable is disabled.
|
|
547
|
+
*/
|
|
548
|
+
get autoEnableDisabled() {
|
|
549
|
+
return this._autoEnableDisabled;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Set the working directory and start watching @fix_plan.md.
|
|
553
|
+
* Automatically loads existing @fix_plan.md if present.
|
|
554
|
+
* @param workingDir - The session's working directory
|
|
555
|
+
*/
|
|
556
|
+
setWorkingDir(workingDir) {
|
|
557
|
+
this._workingDir = workingDir;
|
|
558
|
+
this._fixPlanPath = join(workingDir, '@fix_plan.md');
|
|
559
|
+
// Try to load existing @fix_plan.md
|
|
560
|
+
this.loadFixPlanFromDisk();
|
|
561
|
+
// Start watching for changes
|
|
562
|
+
this.startWatchingFixPlan();
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Load @fix_plan.md from disk if it exists.
|
|
566
|
+
* Called on initialization and when file changes are detected.
|
|
567
|
+
*/
|
|
568
|
+
async loadFixPlanFromDisk() {
|
|
569
|
+
if (!this._fixPlanPath)
|
|
570
|
+
return 0;
|
|
571
|
+
try {
|
|
572
|
+
if (!existsSync(this._fixPlanPath)) {
|
|
573
|
+
return 0;
|
|
574
|
+
}
|
|
575
|
+
const content = await readFile(this._fixPlanPath, 'utf-8');
|
|
576
|
+
const count = this.importFixPlanMarkdown(content);
|
|
577
|
+
if (count > 0) {
|
|
578
|
+
// Auto-enable tracker when we have todos from @fix_plan.md
|
|
579
|
+
if (!this._loopState.enabled) {
|
|
580
|
+
this.enable();
|
|
581
|
+
}
|
|
582
|
+
console.log(`[RalphTracker] Loaded ${count} todos from @fix_plan.md`);
|
|
583
|
+
}
|
|
584
|
+
return count;
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
// File doesn't exist or can't be read - that's OK
|
|
588
|
+
console.log(`[RalphTracker] Could not load @fix_plan.md: ${err}`);
|
|
589
|
+
return 0;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Start watching @fix_plan.md for changes.
|
|
594
|
+
* Reloads todos when the file is modified.
|
|
595
|
+
*/
|
|
596
|
+
startWatchingFixPlan() {
|
|
597
|
+
if (!this._fixPlanPath || !this._workingDir)
|
|
598
|
+
return;
|
|
599
|
+
// Stop existing watcher if any
|
|
600
|
+
this.stopWatchingFixPlan();
|
|
601
|
+
try {
|
|
602
|
+
// Only watch if the file exists
|
|
603
|
+
if (!existsSync(this._fixPlanPath)) {
|
|
604
|
+
// Watch the directory instead for file creation
|
|
605
|
+
this._fixPlanWatcher = fsWatch(this._workingDir, (_eventType, filename) => {
|
|
606
|
+
if (filename === '@fix_plan.md') {
|
|
607
|
+
this.handleFixPlanChange();
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
// Watch the file directly
|
|
613
|
+
this._fixPlanWatcher = fsWatch(this._fixPlanPath, () => {
|
|
614
|
+
this.handleFixPlanChange();
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
// Add error handler to prevent unhandled errors and clean up on failure
|
|
618
|
+
// Store handler reference for proper cleanup in stopWatchingFixPlan()
|
|
619
|
+
if (this._fixPlanWatcher) {
|
|
620
|
+
this._fixPlanWatcherErrorHandler = (err) => {
|
|
621
|
+
console.log(`[RalphTracker] FSWatcher error for @fix_plan.md: ${err.message}`);
|
|
622
|
+
this.stopWatchingFixPlan();
|
|
623
|
+
};
|
|
624
|
+
this._fixPlanWatcher.on('error', this._fixPlanWatcherErrorHandler);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
catch (err) {
|
|
628
|
+
console.log(`[RalphTracker] Could not watch @fix_plan.md: ${err}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Handle @fix_plan.md file change with debouncing.
|
|
633
|
+
*/
|
|
634
|
+
handleFixPlanChange() {
|
|
635
|
+
// Debounce rapid changes (e.g., multiple writes)
|
|
636
|
+
if (this._fixPlanReloadTimer) {
|
|
637
|
+
clearTimeout(this._fixPlanReloadTimer);
|
|
638
|
+
}
|
|
639
|
+
this._fixPlanReloadTimer = setTimeout(() => {
|
|
640
|
+
this._fixPlanReloadTimer = null;
|
|
641
|
+
this.loadFixPlanFromDisk();
|
|
642
|
+
}, 500); // 500ms debounce
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Stop watching @fix_plan.md.
|
|
646
|
+
*/
|
|
647
|
+
stopWatchingFixPlan() {
|
|
648
|
+
if (this._fixPlanWatcher) {
|
|
649
|
+
// Remove error handler before closing to prevent memory leak
|
|
650
|
+
if (this._fixPlanWatcherErrorHandler) {
|
|
651
|
+
this._fixPlanWatcher.off('error', this._fixPlanWatcherErrorHandler);
|
|
652
|
+
this._fixPlanWatcherErrorHandler = null;
|
|
653
|
+
}
|
|
654
|
+
this._fixPlanWatcher.close();
|
|
655
|
+
this._fixPlanWatcher = null;
|
|
656
|
+
}
|
|
657
|
+
if (this._fixPlanReloadTimer) {
|
|
658
|
+
clearTimeout(this._fixPlanReloadTimer);
|
|
659
|
+
this._fixPlanReloadTimer = null;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Whether the tracker is enabled and actively monitoring output.
|
|
664
|
+
* Disabled by default; auto-enables when Ralph patterns detected.
|
|
665
|
+
* @returns True if tracker is processing terminal data
|
|
666
|
+
*/
|
|
667
|
+
get enabled() {
|
|
668
|
+
return this._loopState.enabled;
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Enable the tracker to start monitoring terminal output.
|
|
672
|
+
* Called automatically when Ralph patterns are detected.
|
|
673
|
+
* Emits 'enabled' event when transitioning from disabled state.
|
|
674
|
+
* @fires enabled
|
|
675
|
+
* @fires loopUpdate
|
|
676
|
+
*/
|
|
677
|
+
enable() {
|
|
678
|
+
if (!this._loopState.enabled) {
|
|
679
|
+
this._loopState.enabled = true;
|
|
680
|
+
this._loopState.lastActivity = Date.now();
|
|
681
|
+
this.emit('enabled');
|
|
682
|
+
this.emit('loopUpdate', this.loopState);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Disable the tracker to stop monitoring terminal output.
|
|
687
|
+
* Terminal data will be ignored until re-enabled.
|
|
688
|
+
* @fires loopUpdate
|
|
689
|
+
*/
|
|
690
|
+
disable() {
|
|
691
|
+
if (this._loopState.enabled) {
|
|
692
|
+
this._loopState.enabled = false;
|
|
693
|
+
this._loopState.lastActivity = Date.now();
|
|
694
|
+
this.emit('loopUpdate', this.loopState);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Soft reset - clears state but keeps enabled status.
|
|
699
|
+
* Use when a new task/loop starts within the same session.
|
|
700
|
+
*
|
|
701
|
+
* Clears:
|
|
702
|
+
* - All todo items
|
|
703
|
+
* - Completion phrase tracking
|
|
704
|
+
* - Loop state (active, iterations)
|
|
705
|
+
* - Line buffer
|
|
706
|
+
*
|
|
707
|
+
* Preserves:
|
|
708
|
+
* - Enabled status
|
|
709
|
+
*
|
|
710
|
+
* @fires loopUpdate
|
|
711
|
+
* @fires todoUpdate
|
|
712
|
+
*/
|
|
713
|
+
reset() {
|
|
714
|
+
// Clear debounce timers
|
|
715
|
+
this.clearDebounceTimers();
|
|
716
|
+
const wasEnabled = this._loopState.enabled;
|
|
717
|
+
this._loopState = createInitialRalphTrackerState();
|
|
718
|
+
this._loopState.enabled = wasEnabled; // Keep enabled status
|
|
719
|
+
this._todos.clear();
|
|
720
|
+
this._completionPhraseCount.clear();
|
|
721
|
+
this._taskNumberToContent.clear();
|
|
722
|
+
this._lineBuffer = '';
|
|
723
|
+
this._partialPromiseBuffer = '';
|
|
724
|
+
// Reset RALPH_STATUS block state
|
|
725
|
+
this._statusBlockBuffer = [];
|
|
726
|
+
this._inStatusBlock = false;
|
|
727
|
+
this._lastStatusBlock = null;
|
|
728
|
+
this._completionIndicators = 0;
|
|
729
|
+
this._exitGateMet = false;
|
|
730
|
+
this._totalFilesModified = 0;
|
|
731
|
+
this._totalTasksCompleted = 0;
|
|
732
|
+
// Keep circuit breaker state on soft reset (it tracks across iterations)
|
|
733
|
+
// Emit on next tick to prevent listeners from modifying state during reset (non-reentrant)
|
|
734
|
+
const loopState = this.loopState;
|
|
735
|
+
const todos = this.todos;
|
|
736
|
+
process.nextTick(() => {
|
|
737
|
+
this.emit('loopUpdate', loopState);
|
|
738
|
+
this.emit('todoUpdate', todos);
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Full reset - clears all state including enabled status.
|
|
743
|
+
* Use when session is closed or completely cleared.
|
|
744
|
+
* Returns tracker to initial disabled state.
|
|
745
|
+
* @fires loopUpdate
|
|
746
|
+
* @fires todoUpdate
|
|
747
|
+
*/
|
|
748
|
+
fullReset() {
|
|
749
|
+
// Clear debounce timers
|
|
750
|
+
this.clearDebounceTimers();
|
|
751
|
+
this._loopState = createInitialRalphTrackerState();
|
|
752
|
+
this._todos.clear();
|
|
753
|
+
this._completionPhraseCount.clear();
|
|
754
|
+
this._taskNumberToContent.clear();
|
|
755
|
+
this._todoStartTimes.clear();
|
|
756
|
+
this._alternateCompletionPhrases.clear();
|
|
757
|
+
this._lineBuffer = '';
|
|
758
|
+
// Reset all RALPH_STATUS block and circuit breaker state
|
|
759
|
+
this._statusBlockBuffer = [];
|
|
760
|
+
this._inStatusBlock = false;
|
|
761
|
+
this._lastStatusBlock = null;
|
|
762
|
+
this._completionIndicators = 0;
|
|
763
|
+
this._exitGateMet = false;
|
|
764
|
+
this._totalFilesModified = 0;
|
|
765
|
+
this._totalTasksCompleted = 0;
|
|
766
|
+
this._circuitBreaker = createInitialCircuitBreakerStatus();
|
|
767
|
+
// Emit on next tick to prevent listeners from modifying state during reset (non-reentrant)
|
|
768
|
+
const loopState = this.loopState;
|
|
769
|
+
const todos = this.todos;
|
|
770
|
+
process.nextTick(() => {
|
|
771
|
+
this.emit('loopUpdate', loopState);
|
|
772
|
+
this.emit('todoUpdate', todos);
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Clear all debounce timers.
|
|
777
|
+
* Called during reset/fullReset to prevent stale emissions.
|
|
778
|
+
*/
|
|
779
|
+
clearDebounceTimers() {
|
|
780
|
+
if (this._todoUpdateTimer) {
|
|
781
|
+
clearTimeout(this._todoUpdateTimer);
|
|
782
|
+
this._todoUpdateTimer = null;
|
|
783
|
+
}
|
|
784
|
+
if (this._loopUpdateTimer) {
|
|
785
|
+
clearTimeout(this._loopUpdateTimer);
|
|
786
|
+
this._loopUpdateTimer = null;
|
|
787
|
+
}
|
|
788
|
+
this._todoUpdatePending = false;
|
|
789
|
+
this._loopUpdatePending = false;
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Emit todoUpdate event with debouncing.
|
|
793
|
+
* Batches rapid consecutive calls to reduce UI jitter.
|
|
794
|
+
* The event fires after EVENT_DEBOUNCE_MS of inactivity.
|
|
795
|
+
*/
|
|
796
|
+
emitTodoUpdateDebounced() {
|
|
797
|
+
this._todoUpdatePending = true;
|
|
798
|
+
if (this._todoUpdateTimer) {
|
|
799
|
+
clearTimeout(this._todoUpdateTimer);
|
|
800
|
+
}
|
|
801
|
+
this._todoUpdateTimer = setTimeout(() => {
|
|
802
|
+
if (this._todoUpdatePending) {
|
|
803
|
+
this._todoUpdatePending = false;
|
|
804
|
+
this._todoUpdateTimer = null;
|
|
805
|
+
this.emit('todoUpdate', this.todos);
|
|
806
|
+
}
|
|
807
|
+
}, EVENT_DEBOUNCE_MS);
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Emit loopUpdate event with debouncing.
|
|
811
|
+
* Batches rapid consecutive calls to reduce UI jitter.
|
|
812
|
+
* The event fires after EVENT_DEBOUNCE_MS of inactivity.
|
|
813
|
+
*/
|
|
814
|
+
emitLoopUpdateDebounced() {
|
|
815
|
+
this._loopUpdatePending = true;
|
|
816
|
+
if (this._loopUpdateTimer) {
|
|
817
|
+
clearTimeout(this._loopUpdateTimer);
|
|
818
|
+
}
|
|
819
|
+
this._loopUpdateTimer = setTimeout(() => {
|
|
820
|
+
if (this._loopUpdatePending) {
|
|
821
|
+
this._loopUpdatePending = false;
|
|
822
|
+
this._loopUpdateTimer = null;
|
|
823
|
+
this.emit('loopUpdate', this.loopState);
|
|
824
|
+
}
|
|
825
|
+
}, EVENT_DEBOUNCE_MS);
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Flush all pending debounced events immediately.
|
|
829
|
+
* Useful for testing or when immediate state sync is needed.
|
|
830
|
+
*/
|
|
831
|
+
flushPendingEvents() {
|
|
832
|
+
if (this._todoUpdatePending) {
|
|
833
|
+
this._todoUpdatePending = false;
|
|
834
|
+
if (this._todoUpdateTimer) {
|
|
835
|
+
clearTimeout(this._todoUpdateTimer);
|
|
836
|
+
this._todoUpdateTimer = null;
|
|
837
|
+
}
|
|
838
|
+
this.emit('todoUpdate', this.todos);
|
|
839
|
+
}
|
|
840
|
+
if (this._loopUpdatePending) {
|
|
841
|
+
this._loopUpdatePending = false;
|
|
842
|
+
if (this._loopUpdateTimer) {
|
|
843
|
+
clearTimeout(this._loopUpdateTimer);
|
|
844
|
+
this._loopUpdateTimer = null;
|
|
845
|
+
}
|
|
846
|
+
this.emit('loopUpdate', this.loopState);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Get a copy of the current loop state.
|
|
851
|
+
* @returns Shallow copy of loop state (safe to modify)
|
|
852
|
+
*/
|
|
853
|
+
// ========== Iteration Stall Detection Methods ==========
|
|
854
|
+
/**
|
|
855
|
+
* Start iteration stall detection timer.
|
|
856
|
+
* Should be called when the loop becomes active.
|
|
857
|
+
*/
|
|
858
|
+
startIterationStallDetection() {
|
|
859
|
+
this.stopIterationStallDetection();
|
|
860
|
+
this._lastIterationChangeTime = Date.now();
|
|
861
|
+
this._iterationStallWarned = false;
|
|
862
|
+
// Check every minute
|
|
863
|
+
this._iterationStallTimer = setInterval(() => {
|
|
864
|
+
this.checkIterationStall();
|
|
865
|
+
}, 60 * 1000);
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Stop iteration stall detection timer.
|
|
869
|
+
*/
|
|
870
|
+
stopIterationStallDetection() {
|
|
871
|
+
if (this._iterationStallTimer) {
|
|
872
|
+
clearInterval(this._iterationStallTimer);
|
|
873
|
+
this._iterationStallTimer = null;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Check for iteration stall and emit appropriate events.
|
|
878
|
+
*/
|
|
879
|
+
checkIterationStall() {
|
|
880
|
+
if (!this._loopState.active)
|
|
881
|
+
return;
|
|
882
|
+
const stallDurationMs = Date.now() - this._lastIterationChangeTime;
|
|
883
|
+
// Critical stall (longer duration)
|
|
884
|
+
if (stallDurationMs >= this._iterationStallCriticalMs) {
|
|
885
|
+
this.emit('iterationStallCritical', {
|
|
886
|
+
iteration: this._loopState.cycleCount,
|
|
887
|
+
stallDurationMs,
|
|
888
|
+
});
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
// Warning stall
|
|
892
|
+
if (stallDurationMs >= this._iterationStallWarningMs && !this._iterationStallWarned) {
|
|
893
|
+
this._iterationStallWarned = true;
|
|
894
|
+
this.emit('iterationStallWarning', {
|
|
895
|
+
iteration: this._loopState.cycleCount,
|
|
896
|
+
stallDurationMs,
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Get iteration stall metrics for monitoring.
|
|
902
|
+
*/
|
|
903
|
+
getIterationStallMetrics() {
|
|
904
|
+
return {
|
|
905
|
+
lastIterationChangeTime: this._lastIterationChangeTime,
|
|
906
|
+
stallDurationMs: Date.now() - this._lastIterationChangeTime,
|
|
907
|
+
warningThresholdMs: this._iterationStallWarningMs,
|
|
908
|
+
criticalThresholdMs: this._iterationStallCriticalMs,
|
|
909
|
+
isWarned: this._iterationStallWarned,
|
|
910
|
+
currentIteration: this._loopState.cycleCount,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Configure iteration stall thresholds.
|
|
915
|
+
* @param warningMs - Warning threshold in milliseconds
|
|
916
|
+
* @param criticalMs - Critical threshold in milliseconds
|
|
917
|
+
*/
|
|
918
|
+
configureIterationStallThresholds(warningMs, criticalMs) {
|
|
919
|
+
this._iterationStallWarningMs = warningMs;
|
|
920
|
+
this._iterationStallCriticalMs = criticalMs;
|
|
921
|
+
}
|
|
922
|
+
get loopState() {
|
|
923
|
+
return {
|
|
924
|
+
...this._loopState,
|
|
925
|
+
planVersion: this._planVersion,
|
|
926
|
+
planHistoryLength: this._planHistory.length,
|
|
927
|
+
completionConfidence: this._lastCompletionConfidence,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
/** Last calculated completion confidence */
|
|
931
|
+
_lastCompletionConfidence;
|
|
932
|
+
/** Confidence threshold for triggering completion (0-100) */
|
|
933
|
+
static COMPLETION_CONFIDENCE_THRESHOLD = 70;
|
|
934
|
+
/**
|
|
935
|
+
* Calculate confidence score for a potential completion signal.
|
|
936
|
+
*
|
|
937
|
+
* Scoring weights:
|
|
938
|
+
* - Promise tag with proper format: +30
|
|
939
|
+
* - Matches expected phrase: +25
|
|
940
|
+
* - All todos complete: +20
|
|
941
|
+
* - EXIT_SIGNAL: true: +15
|
|
942
|
+
* - Multiple completion indicators (>=2): +10
|
|
943
|
+
* - Context appropriate (not in prompt/explanation): +10
|
|
944
|
+
* - Loop was explicitly active: +10
|
|
945
|
+
*
|
|
946
|
+
* @param phrase - The detected phrase to evaluate
|
|
947
|
+
* @param context - Optional surrounding context for the phrase
|
|
948
|
+
* @returns CompletionConfidence assessment
|
|
949
|
+
*/
|
|
950
|
+
calculateCompletionConfidence(phrase, context) {
|
|
951
|
+
let score = 0;
|
|
952
|
+
const signals = {
|
|
953
|
+
hasPromiseTag: false,
|
|
954
|
+
matchesExpected: false,
|
|
955
|
+
allTodosComplete: false,
|
|
956
|
+
hasExitSignal: false,
|
|
957
|
+
multipleIndicators: false,
|
|
958
|
+
contextAppropriate: true, // Default to true, deduct if inappropriate
|
|
959
|
+
};
|
|
960
|
+
// Check for promise tag format (adds 30 points)
|
|
961
|
+
if (context && PROMISE_PATTERN.test(context)) {
|
|
962
|
+
signals.hasPromiseTag = true;
|
|
963
|
+
score += 30;
|
|
964
|
+
}
|
|
965
|
+
// Check if phrase matches expected completion phrase (adds 25 points)
|
|
966
|
+
const expectedPhrase = this._loopState.completionPhrase;
|
|
967
|
+
if (expectedPhrase) {
|
|
968
|
+
const matchedPhrase = this.findMatchingCompletionPhrase(phrase);
|
|
969
|
+
if (matchedPhrase) {
|
|
970
|
+
signals.matchesExpected = true;
|
|
971
|
+
score += 25;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
// Check if all todos are complete (adds 20 points)
|
|
975
|
+
const todoArray = Array.from(this._todos.values());
|
|
976
|
+
if (todoArray.length > 0 && todoArray.every((t) => t.status === 'completed')) {
|
|
977
|
+
signals.allTodosComplete = true;
|
|
978
|
+
score += 20;
|
|
979
|
+
}
|
|
980
|
+
// Check for EXIT_SIGNAL from RALPH_STATUS block (adds 15 points)
|
|
981
|
+
if (this._lastStatusBlock?.exitSignal === true) {
|
|
982
|
+
signals.hasExitSignal = true;
|
|
983
|
+
score += 15;
|
|
984
|
+
}
|
|
985
|
+
// Check for multiple completion indicators (adds 10 points)
|
|
986
|
+
if (this._completionIndicators >= 2) {
|
|
987
|
+
signals.multipleIndicators = true;
|
|
988
|
+
score += 10;
|
|
989
|
+
}
|
|
990
|
+
// Check context appropriateness (deduct if inappropriate)
|
|
991
|
+
if (context) {
|
|
992
|
+
const lowerContext = context.toLowerCase();
|
|
993
|
+
// Deduct points if phrase appears in prompt-like context
|
|
994
|
+
if (lowerContext.includes('output:') ||
|
|
995
|
+
lowerContext.includes('completion phrase') ||
|
|
996
|
+
lowerContext.includes('output exactly') ||
|
|
997
|
+
lowerContext.includes('when done')) {
|
|
998
|
+
signals.contextAppropriate = false;
|
|
999
|
+
score -= 20;
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
score += 10;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
// Bonus for active loop state (adds 10 points)
|
|
1006
|
+
if (this._loopState.active) {
|
|
1007
|
+
score += 10;
|
|
1008
|
+
}
|
|
1009
|
+
// Bonus for 2nd+ occurrence (adds 15 points)
|
|
1010
|
+
const count = this._completionPhraseCount.get(phrase) || 0;
|
|
1011
|
+
if (count >= 2) {
|
|
1012
|
+
score += 15;
|
|
1013
|
+
}
|
|
1014
|
+
// Clamp score to 0-100
|
|
1015
|
+
score = Math.max(0, Math.min(100, score));
|
|
1016
|
+
const confidence = {
|
|
1017
|
+
score,
|
|
1018
|
+
isConfident: score >= RalphTracker.COMPLETION_CONFIDENCE_THRESHOLD,
|
|
1019
|
+
signals,
|
|
1020
|
+
calculatedAt: Date.now(),
|
|
1021
|
+
};
|
|
1022
|
+
this._lastCompletionConfidence = confidence;
|
|
1023
|
+
return confidence;
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Get all tracked todo items as an array.
|
|
1027
|
+
* @returns Array of todo items (copy, safe to modify)
|
|
1028
|
+
*/
|
|
1029
|
+
get todos() {
|
|
1030
|
+
return Array.from(this._todos.values());
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Process raw terminal data to detect inner loop patterns.
|
|
1034
|
+
*
|
|
1035
|
+
* This is the main entry point for parsing output. Call this with each
|
|
1036
|
+
* chunk of data from the PTY. The tracker will:
|
|
1037
|
+
*
|
|
1038
|
+
* 1. Strip ANSI escape codes
|
|
1039
|
+
* 2. Auto-enable if disabled and Ralph patterns detected
|
|
1040
|
+
* 3. Buffer data and process complete lines
|
|
1041
|
+
* 4. Detect loop status, todos, and completion phrases
|
|
1042
|
+
* 5. Periodically clean up expired todos
|
|
1043
|
+
*
|
|
1044
|
+
* @param data - Raw terminal data (may include ANSI codes)
|
|
1045
|
+
* @fires loopUpdate - When loop state changes
|
|
1046
|
+
* @fires todoUpdate - When todos are detected or updated
|
|
1047
|
+
* @fires completionDetected - When completion phrase found
|
|
1048
|
+
* @fires enabled - When tracker auto-enables
|
|
1049
|
+
*/
|
|
1050
|
+
processTerminalData(data) {
|
|
1051
|
+
// Remove ANSI escape codes for cleaner parsing
|
|
1052
|
+
const cleanData = data.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '');
|
|
1053
|
+
this.processCleanData(cleanData);
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Process pre-stripped terminal data (ANSI codes already removed).
|
|
1057
|
+
* Use this when the caller has already stripped ANSI to avoid redundant regex work.
|
|
1058
|
+
*/
|
|
1059
|
+
processCleanData(cleanData) {
|
|
1060
|
+
// If tracker is disabled, only check for patterns that should auto-enable it
|
|
1061
|
+
if (!this._loopState.enabled) {
|
|
1062
|
+
// Don't auto-enable if explicitly disabled by user setting
|
|
1063
|
+
if (this._autoEnableDisabled) {
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
if (this.shouldAutoEnable(cleanData)) {
|
|
1067
|
+
this.enable();
|
|
1068
|
+
// Continue processing now that we're enabled
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
return; // Don't process further when disabled
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
// Buffer data for line-based processing
|
|
1075
|
+
this._lineBuffer += cleanData;
|
|
1076
|
+
// Prevent unbounded line buffer growth from very long lines
|
|
1077
|
+
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
|
|
1078
|
+
// Truncate to last portion to preserve recent data
|
|
1079
|
+
this._lineBuffer = this._lineBuffer.slice(-Math.floor(MAX_LINE_BUFFER_SIZE / 2));
|
|
1080
|
+
}
|
|
1081
|
+
// Process complete lines
|
|
1082
|
+
const lines = this._lineBuffer.split('\n');
|
|
1083
|
+
this._lineBuffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
1084
|
+
for (const line of lines) {
|
|
1085
|
+
this.processLine(line);
|
|
1086
|
+
}
|
|
1087
|
+
// Also check the current buffer for multi-line patterns
|
|
1088
|
+
this.checkMultiLinePatterns(cleanData);
|
|
1089
|
+
// Cleanup expired todos (throttled to avoid running on every chunk)
|
|
1090
|
+
this.maybeCleanupExpiredTodos();
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Check if data contains patterns that should auto-enable the tracker.
|
|
1094
|
+
*
|
|
1095
|
+
* The tracker auto-enables when any of these patterns are detected:
|
|
1096
|
+
* - `/ralph-loop:ralph-loop` command
|
|
1097
|
+
* - `<promise>PHRASE</promise>` completion tags
|
|
1098
|
+
* - TodoWrite tool usage indicators
|
|
1099
|
+
* - Iteration patterns (`Iteration 5/50`, `[5/50]`)
|
|
1100
|
+
* - Todo checkboxes (`- [ ]`, `- [x]`)
|
|
1101
|
+
* - Todo indicator icons (`☐`, `◐`, `☒`)
|
|
1102
|
+
* - Loop start messages (`Loop started at`)
|
|
1103
|
+
* - All tasks complete announcements
|
|
1104
|
+
* - Task completion signals
|
|
1105
|
+
*
|
|
1106
|
+
* @param data - ANSI-cleaned terminal data
|
|
1107
|
+
* @returns True if any Ralph-related pattern is detected
|
|
1108
|
+
*/
|
|
1109
|
+
shouldAutoEnable(data) {
|
|
1110
|
+
// Cheap pre-filter: skip the full regex battery if none of the key
|
|
1111
|
+
// substrings that any pattern could match are present in the data.
|
|
1112
|
+
// This avoids 12 regex tests on every PTY chunk (the common case).
|
|
1113
|
+
if (!data.includes('<') && // <promise>, TodoWrite
|
|
1114
|
+
!data.includes('ralph') &&
|
|
1115
|
+
!data.includes('Ralph') &&
|
|
1116
|
+
!data.includes('Todo') &&
|
|
1117
|
+
!data.includes('todo') &&
|
|
1118
|
+
!data.includes('Iteration') &&
|
|
1119
|
+
!data.includes('[') &&
|
|
1120
|
+
!data.includes('\u2610') &&
|
|
1121
|
+
!data.includes('\u2612') && // ☐ ☒
|
|
1122
|
+
!data.includes('\u2714') && // ✔
|
|
1123
|
+
!data.includes('Loop') &&
|
|
1124
|
+
!data.includes('complete') &&
|
|
1125
|
+
!data.includes('COMPLETE') &&
|
|
1126
|
+
!data.includes('Done') &&
|
|
1127
|
+
!data.includes('DONE')) {
|
|
1128
|
+
return false;
|
|
1129
|
+
}
|
|
1130
|
+
// Ralph loop command: /ralph-loop:ralph-loop
|
|
1131
|
+
if (RALPH_START_PATTERN.test(data)) {
|
|
1132
|
+
return true;
|
|
1133
|
+
}
|
|
1134
|
+
// Completion phrase: <promise>...</promise>
|
|
1135
|
+
if (PROMISE_PATTERN.test(data)) {
|
|
1136
|
+
return true;
|
|
1137
|
+
}
|
|
1138
|
+
// TodoWrite tool usage
|
|
1139
|
+
if (TODOWRITE_PATTERN.test(data)) {
|
|
1140
|
+
return true;
|
|
1141
|
+
}
|
|
1142
|
+
// Iteration patterns from Ralph loop: "Iteration 5/50", "[5/50]"
|
|
1143
|
+
if (ITERATION_PATTERN.test(data)) {
|
|
1144
|
+
return true;
|
|
1145
|
+
}
|
|
1146
|
+
// Todo checkboxes: "- [ ] Task" or "- [x] Task"
|
|
1147
|
+
// Reset lastIndex BEFORE test to ensure consistent matching with /g flag patterns
|
|
1148
|
+
TODO_CHECKBOX_PATTERN.lastIndex = 0;
|
|
1149
|
+
if (TODO_CHECKBOX_PATTERN.test(data)) {
|
|
1150
|
+
return true;
|
|
1151
|
+
}
|
|
1152
|
+
// Todo indicator icons: "Todo: ☐", "Todo: ◐", etc.
|
|
1153
|
+
TODO_INDICATOR_PATTERN.lastIndex = 0;
|
|
1154
|
+
if (TODO_INDICATOR_PATTERN.test(data)) {
|
|
1155
|
+
return true;
|
|
1156
|
+
}
|
|
1157
|
+
// Claude Code native todo format: "☐ Task", "☒ Task"
|
|
1158
|
+
TODO_NATIVE_PATTERN.lastIndex = 0;
|
|
1159
|
+
if (TODO_NATIVE_PATTERN.test(data)) {
|
|
1160
|
+
return true;
|
|
1161
|
+
}
|
|
1162
|
+
// Claude Code checkmark-based TodoWrite: "✔ Task #N created:", "✔ Task #N updated:"
|
|
1163
|
+
TODO_TASK_CREATED_PATTERN.lastIndex = 0;
|
|
1164
|
+
if (TODO_TASK_CREATED_PATTERN.test(data)) {
|
|
1165
|
+
return true;
|
|
1166
|
+
}
|
|
1167
|
+
TODO_TASK_STATUS_PATTERN.lastIndex = 0;
|
|
1168
|
+
if (TODO_TASK_STATUS_PATTERN.test(data)) {
|
|
1169
|
+
return true;
|
|
1170
|
+
}
|
|
1171
|
+
// Loop start patterns (e.g., "Loop started at", "Starting Ralph loop")
|
|
1172
|
+
if (LOOP_START_PATTERN.test(data)) {
|
|
1173
|
+
return true;
|
|
1174
|
+
}
|
|
1175
|
+
// All tasks complete signals
|
|
1176
|
+
if (ALL_COMPLETE_PATTERN.test(data)) {
|
|
1177
|
+
return true;
|
|
1178
|
+
}
|
|
1179
|
+
// Task completion signals
|
|
1180
|
+
if (TASK_DONE_PATTERN.test(data)) {
|
|
1181
|
+
return true;
|
|
1182
|
+
}
|
|
1183
|
+
return false;
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Process a single line of terminal output.
|
|
1187
|
+
* Runs all detection methods in sequence.
|
|
1188
|
+
* @param line - Single line of ANSI-cleaned terminal output
|
|
1189
|
+
*/
|
|
1190
|
+
processLine(line) {
|
|
1191
|
+
const trimmed = line.trim();
|
|
1192
|
+
if (!trimmed)
|
|
1193
|
+
return;
|
|
1194
|
+
// Check for RALPH_STATUS block (structured status reporting)
|
|
1195
|
+
this.processStatusBlockLine(trimmed);
|
|
1196
|
+
// Check for completion indicators (for dual-condition exit gate)
|
|
1197
|
+
this.detectCompletionIndicators(trimmed);
|
|
1198
|
+
// Check for completion phrase
|
|
1199
|
+
this.detectCompletionPhrase(trimmed);
|
|
1200
|
+
// Check for "all tasks complete" signals
|
|
1201
|
+
this.detectAllTasksComplete(trimmed);
|
|
1202
|
+
// Check for individual task completion signals
|
|
1203
|
+
this.detectTaskCompletion(trimmed);
|
|
1204
|
+
// Check for loop start/status
|
|
1205
|
+
this.detectLoopStatus(trimmed);
|
|
1206
|
+
// Check for todo items
|
|
1207
|
+
this.detectTodoItems(trimmed);
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Detect "all tasks complete" messages.
|
|
1211
|
+
*
|
|
1212
|
+
* When a valid "all complete" message is detected:
|
|
1213
|
+
* 1. Marks all tracked todos as completed
|
|
1214
|
+
* 2. Emits completion event if a completion phrase is set
|
|
1215
|
+
*
|
|
1216
|
+
* Validation criteria:
|
|
1217
|
+
* - Line must match ALL_COMPLETE_PATTERN
|
|
1218
|
+
* - Line must be reasonably short (<100 chars) to avoid matching commentary
|
|
1219
|
+
* - Must not look like prompt text (no "output:" or `<promise>`)
|
|
1220
|
+
* - Must have at least one tracked todo
|
|
1221
|
+
* - If count is mentioned, should roughly match tracked todo count
|
|
1222
|
+
*
|
|
1223
|
+
* @param line - Single line to check
|
|
1224
|
+
* @fires todoUpdate - If any todos marked complete
|
|
1225
|
+
* @fires completionDetected - If completion phrase was set
|
|
1226
|
+
* @fires loopUpdate - If loop state changes
|
|
1227
|
+
*/
|
|
1228
|
+
detectAllTasksComplete(line) {
|
|
1229
|
+
// When @fix_plan.md is active, only trust the file for todo status
|
|
1230
|
+
// This prevents false positives from Claude saying "all done" in conversation
|
|
1231
|
+
if (this.isFileAuthoritative)
|
|
1232
|
+
return;
|
|
1233
|
+
// Only trigger if line is a clear standalone completion message
|
|
1234
|
+
// Avoid matching commentary like "once all tasks are complete..."
|
|
1235
|
+
if (!ALL_COMPLETE_PATTERN.test(line))
|
|
1236
|
+
return;
|
|
1237
|
+
// Must be a reasonably short line (< 100 chars) to be a completion signal, not commentary
|
|
1238
|
+
if (line.length > 100)
|
|
1239
|
+
return;
|
|
1240
|
+
// Skip if this looks like it's part of the original prompt (contains "output:")
|
|
1241
|
+
if (line.toLowerCase().includes('output:') || line.includes('<promise>'))
|
|
1242
|
+
return;
|
|
1243
|
+
// Don't trigger if we haven't seen any todos yet
|
|
1244
|
+
if (this._todos.size === 0)
|
|
1245
|
+
return;
|
|
1246
|
+
// Check if the count matches our todo count (e.g., "All 8 files created")
|
|
1247
|
+
const countMatch = line.match(ALL_COUNT_PATTERN);
|
|
1248
|
+
const parsedCount = countMatch ? parseInt(countMatch[1], 10) : NaN;
|
|
1249
|
+
const mentionedCount = Number.isNaN(parsedCount) ? null : parsedCount;
|
|
1250
|
+
const todoCount = this._todos.size;
|
|
1251
|
+
// If a count is mentioned, it should match our todo count (within reason)
|
|
1252
|
+
if (mentionedCount !== null && Math.abs(mentionedCount - todoCount) > 2) {
|
|
1253
|
+
// Count doesn't match our todos, might be unrelated
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
// Mark all todos as complete
|
|
1257
|
+
let updated = false;
|
|
1258
|
+
for (const todo of this._todos.values()) {
|
|
1259
|
+
if (todo.status !== 'completed') {
|
|
1260
|
+
todo.status = 'completed';
|
|
1261
|
+
updated = true;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (updated) {
|
|
1265
|
+
this.emit('todoUpdate', this.todos);
|
|
1266
|
+
}
|
|
1267
|
+
// Emit completion if we have an expected phrase
|
|
1268
|
+
if (this._loopState.completionPhrase) {
|
|
1269
|
+
this._loopState.active = false;
|
|
1270
|
+
this._loopState.lastActivity = Date.now();
|
|
1271
|
+
this.emit('completionDetected', this._loopState.completionPhrase);
|
|
1272
|
+
this.emit('loopUpdate', this.loopState);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Detect individual task completion signals
|
|
1277
|
+
* e.g., "Task 8 is done", "marked as completed"
|
|
1278
|
+
*
|
|
1279
|
+
* NOTE: This is intentionally conservative to avoid jitter.
|
|
1280
|
+
* Only marks a todo complete if we can match it by task number.
|
|
1281
|
+
*/
|
|
1282
|
+
detectTaskCompletion(line) {
|
|
1283
|
+
// When @fix_plan.md is active, only trust the file for todo status
|
|
1284
|
+
if (this.isFileAuthoritative)
|
|
1285
|
+
return;
|
|
1286
|
+
if (!TASK_DONE_PATTERN.test(line))
|
|
1287
|
+
return;
|
|
1288
|
+
// Only act on explicit task number references like "Task 8 is done"
|
|
1289
|
+
const taskNumMatch = line.match(/task\s*#?(\d+)/i);
|
|
1290
|
+
if (taskNumMatch) {
|
|
1291
|
+
const taskNum = parseInt(taskNumMatch[1], 10);
|
|
1292
|
+
if (Number.isNaN(taskNum))
|
|
1293
|
+
return;
|
|
1294
|
+
// Find the nth todo (by order) and mark it complete
|
|
1295
|
+
let count = 0;
|
|
1296
|
+
for (const [_id, todo] of this._todos) {
|
|
1297
|
+
count++;
|
|
1298
|
+
if (count === taskNum && todo.status !== 'completed') {
|
|
1299
|
+
todo.status = 'completed';
|
|
1300
|
+
this.emit('todoUpdate', this.todos);
|
|
1301
|
+
break;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
// Don't guess which todo to mark - let the checkbox detection handle it
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Check for multi-line patterns that might span line boundaries.
|
|
1309
|
+
* Completion phrases can be split across PTY chunks.
|
|
1310
|
+
*
|
|
1311
|
+
* Handles cross-chunk promise tags by:
|
|
1312
|
+
* 1. Checking combined buffer + new data for complete tags
|
|
1313
|
+
* 2. Detecting partial tags at end of chunk and buffering
|
|
1314
|
+
* 3. Clearing buffer when complete tag found or buffer gets stale
|
|
1315
|
+
*
|
|
1316
|
+
* @param data - The full data chunk (may contain multiple lines)
|
|
1317
|
+
*/
|
|
1318
|
+
checkMultiLinePatterns(data) {
|
|
1319
|
+
// Only try to complete a cross-chunk promise if we have a partial buffer.
|
|
1320
|
+
// Without a partial buffer, complete tags are already handled by processLine
|
|
1321
|
+
// via detectCompletionPhrase — re-detecting here would double-count.
|
|
1322
|
+
if (this._partialPromiseBuffer) {
|
|
1323
|
+
const combinedData = this._partialPromiseBuffer + data;
|
|
1324
|
+
const promiseMatch = combinedData.match(PROMISE_PATTERN);
|
|
1325
|
+
if (promiseMatch) {
|
|
1326
|
+
const phrase = promiseMatch[1].trim();
|
|
1327
|
+
this._partialPromiseBuffer = '';
|
|
1328
|
+
this.handleCompletionPhrase(phrase);
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
// Check for partial promise tag at end of data (for next chunk)
|
|
1333
|
+
const partialMatch = data.match(PROMISE_PARTIAL_PATTERN);
|
|
1334
|
+
if (partialMatch) {
|
|
1335
|
+
const partialContent = partialMatch[0];
|
|
1336
|
+
if (partialContent.length <= RalphTracker.MAX_PARTIAL_PROMISE_SIZE) {
|
|
1337
|
+
this._partialPromiseBuffer = partialContent;
|
|
1338
|
+
}
|
|
1339
|
+
else {
|
|
1340
|
+
this._partialPromiseBuffer = '';
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
else {
|
|
1344
|
+
this._partialPromiseBuffer = '';
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Detect completion phrases in a line.
|
|
1349
|
+
*
|
|
1350
|
+
* Handles two formats:
|
|
1351
|
+
* 1. Tagged: `<promise>PHRASE</promise>` - Processed via handleCompletionPhrase
|
|
1352
|
+
* 2. Bare: Just `PHRASE` - Only if we already know the expected phrase
|
|
1353
|
+
*
|
|
1354
|
+
* Bare phrase detection avoids false positives by requiring:
|
|
1355
|
+
* - The phrase was previously seen in tagged form
|
|
1356
|
+
* - Line is standalone or ends with the phrase
|
|
1357
|
+
* - Line doesn't look like prompt context
|
|
1358
|
+
*
|
|
1359
|
+
* @param line - Single line to check
|
|
1360
|
+
*/
|
|
1361
|
+
detectCompletionPhrase(line) {
|
|
1362
|
+
// First check for tagged phrase: <promise>PHRASE</promise>
|
|
1363
|
+
const match = line.match(PROMISE_PATTERN);
|
|
1364
|
+
if (match) {
|
|
1365
|
+
this.handleCompletionPhrase(match[1]);
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
// If we have an expected completion phrase, also check for bare phrase
|
|
1369
|
+
// This handles cases where Claude outputs "ALL_TASKS_DONE" without the tags
|
|
1370
|
+
const expectedPhrase = this._loopState.completionPhrase;
|
|
1371
|
+
if (expectedPhrase && line.toUpperCase().includes(expectedPhrase.toUpperCase())) {
|
|
1372
|
+
// Avoid false positives: don't trigger on prompt context
|
|
1373
|
+
const isNotInPromptContext = !line.includes('<promise>') && !line.includes('output:');
|
|
1374
|
+
// Also avoid triggering on "completion phrase is X" explanatory text
|
|
1375
|
+
const isNotExplanation = !line.toLowerCase().includes('completion phrase') && !line.toLowerCase().includes('output exactly');
|
|
1376
|
+
if (isNotInPromptContext && isNotExplanation) {
|
|
1377
|
+
this.handleBareCompletionPhrase(expectedPhrase);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Handle a bare completion phrase (without XML tags).
|
|
1383
|
+
*
|
|
1384
|
+
* Only fires completion if:
|
|
1385
|
+
* 1. The phrase was previously seen in tagged form (from prompt)
|
|
1386
|
+
* 2. This is the first bare occurrence (prevents double-firing)
|
|
1387
|
+
*
|
|
1388
|
+
* When triggered:
|
|
1389
|
+
* - Marks all todos as complete
|
|
1390
|
+
* - Emits completionDetected event
|
|
1391
|
+
* - Sets loop to inactive
|
|
1392
|
+
*
|
|
1393
|
+
* @param phrase - The completion phrase text
|
|
1394
|
+
* @fires todoUpdate - If any todos marked complete
|
|
1395
|
+
* @fires completionDetected - When completion triggered
|
|
1396
|
+
* @fires loopUpdate - When loop state changes
|
|
1397
|
+
*/
|
|
1398
|
+
handleBareCompletionPhrase(phrase) {
|
|
1399
|
+
// Allow bare phrase detection if:
|
|
1400
|
+
// 1. Loop is explicitly active (via startLoop()) - phrase was set programmatically
|
|
1401
|
+
// 2. OR phrase was seen in tagged form (from terminal output)
|
|
1402
|
+
const taggedCount = this._completionPhraseCount.get(phrase) || 0;
|
|
1403
|
+
const loopExplicitlyActive = this._loopState.active;
|
|
1404
|
+
if (taggedCount === 0 && !loopExplicitlyActive)
|
|
1405
|
+
return;
|
|
1406
|
+
// Track bare occurrences to avoid double-firing
|
|
1407
|
+
const bareKey = `bare:${phrase}`;
|
|
1408
|
+
const bareCount = (this._completionPhraseCount.get(bareKey) || 0) + 1;
|
|
1409
|
+
this._completionPhraseCount.set(bareKey, bareCount);
|
|
1410
|
+
// Only fire once for bare phrase
|
|
1411
|
+
if (bareCount > 1)
|
|
1412
|
+
return;
|
|
1413
|
+
// Mark all todos as complete (since we've reached the completion phrase)
|
|
1414
|
+
let updated = false;
|
|
1415
|
+
for (const todo of this._todos.values()) {
|
|
1416
|
+
if (todo.status !== 'completed') {
|
|
1417
|
+
todo.status = 'completed';
|
|
1418
|
+
updated = true;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
if (updated) {
|
|
1422
|
+
this.emit('todoUpdate', this.todos);
|
|
1423
|
+
}
|
|
1424
|
+
// Emit completion event
|
|
1425
|
+
this._loopState.active = false;
|
|
1426
|
+
this._loopState.lastActivity = Date.now();
|
|
1427
|
+
this.emit('completionDetected', phrase);
|
|
1428
|
+
this.emit('loopUpdate', this.loopState);
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Handle a detected completion phrase
|
|
1432
|
+
*
|
|
1433
|
+
* Uses occurrence-based detection combined with confidence scoring
|
|
1434
|
+
* to distinguish prompt from actual completion:
|
|
1435
|
+
* - 1st occurrence: Store as expected phrase (likely in prompt)
|
|
1436
|
+
* - 2nd occurrence OR high confidence: Emit completionDetected (actual completion)
|
|
1437
|
+
* - If loop already active: Emit immediately (explicit loop start)
|
|
1438
|
+
*/
|
|
1439
|
+
handleCompletionPhrase(phrase) {
|
|
1440
|
+
const count = (this._completionPhraseCount.get(phrase) || 0) + 1;
|
|
1441
|
+
this._completionPhraseCount.set(phrase, count);
|
|
1442
|
+
// Trim completion phrase map if it exceeds the limit
|
|
1443
|
+
if (this._completionPhraseCount.size > MAX_COMPLETION_PHRASE_ENTRIES) {
|
|
1444
|
+
// Keep only the most important entries (current expected phrase and highest counts)
|
|
1445
|
+
const entries = Array.from(this._completionPhraseCount.entries());
|
|
1446
|
+
entries.sort((a, b) => b[1] - a[1]); // Sort by count descending
|
|
1447
|
+
this._completionPhraseCount.clear();
|
|
1448
|
+
// Keep top half of entries
|
|
1449
|
+
const keepCount = Math.floor(MAX_COMPLETION_PHRASE_ENTRIES / 2);
|
|
1450
|
+
for (let i = 0; i < Math.min(keepCount, entries.length); i++) {
|
|
1451
|
+
this._completionPhraseCount.set(entries[i][0], entries[i][1]);
|
|
1452
|
+
}
|
|
1453
|
+
// Always keep the expected phrase if set
|
|
1454
|
+
if (this._loopState.completionPhrase && !this._completionPhraseCount.has(this._loopState.completionPhrase)) {
|
|
1455
|
+
this._completionPhraseCount.set(this._loopState.completionPhrase, 1);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
// Store phrase on first occurrence
|
|
1459
|
+
if (!this._loopState.completionPhrase) {
|
|
1460
|
+
this._loopState.completionPhrase = phrase;
|
|
1461
|
+
this._loopState.lastActivity = Date.now();
|
|
1462
|
+
// P1-002: Validate phrase and emit warning if risky
|
|
1463
|
+
this.validateCompletionPhrase(phrase);
|
|
1464
|
+
this.emit('loopUpdate', this.loopState);
|
|
1465
|
+
}
|
|
1466
|
+
// Check for fuzzy match with primary phrase or any alternate phrase (P1-003)
|
|
1467
|
+
// This handles minor variations like whitespace, case, underscores vs hyphens
|
|
1468
|
+
const matchedPhrase = this.findMatchingCompletionPhrase(phrase);
|
|
1469
|
+
if (matchedPhrase) {
|
|
1470
|
+
// Use the matched phrase (canonical) for tracking
|
|
1471
|
+
const canonicalCount = this._completionPhraseCount.get(matchedPhrase) || 0;
|
|
1472
|
+
// Require 2nd+ occurrence of canonical phrase OR explicitly active loop.
|
|
1473
|
+
// First occurrence (count=1) is the prompt echo — not actual completion.
|
|
1474
|
+
if (canonicalCount >= 2 || this._loopState.active) {
|
|
1475
|
+
// Mark as completion
|
|
1476
|
+
this._loopState.active = false;
|
|
1477
|
+
this._loopState.lastActivity = Date.now();
|
|
1478
|
+
// Mark all todos as complete
|
|
1479
|
+
let updated = false;
|
|
1480
|
+
for (const todo of this._todos.values()) {
|
|
1481
|
+
if (todo.status !== 'completed') {
|
|
1482
|
+
todo.status = 'completed';
|
|
1483
|
+
updated = true;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
if (updated) {
|
|
1487
|
+
this.emit('todoUpdate', this.todos);
|
|
1488
|
+
}
|
|
1489
|
+
this.emit('completionDetected', matchedPhrase);
|
|
1490
|
+
this.emit('loopUpdate', this.loopState);
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
// Emit completion if loop is active OR this is 2nd+ occurrence
|
|
1495
|
+
if (this._loopState.active || count >= 2) {
|
|
1496
|
+
// Mark all todos as complete when completion phrase is detected
|
|
1497
|
+
let updated = false;
|
|
1498
|
+
for (const todo of this._todos.values()) {
|
|
1499
|
+
if (todo.status !== 'completed') {
|
|
1500
|
+
todo.status = 'completed';
|
|
1501
|
+
updated = true;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
if (updated) {
|
|
1505
|
+
this.emit('todoUpdate', this.todos);
|
|
1506
|
+
}
|
|
1507
|
+
this._loopState.active = false;
|
|
1508
|
+
this._loopState.lastActivity = Date.now();
|
|
1509
|
+
this.emit('completionDetected', phrase);
|
|
1510
|
+
this.emit('loopUpdate', this.loopState);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Check if two phrases match with fuzzy tolerance.
|
|
1515
|
+
* Handles variations in:
|
|
1516
|
+
* - Case (COMPLETE vs Complete)
|
|
1517
|
+
* - Whitespace (TASK_DONE vs TASK DONE)
|
|
1518
|
+
* - Separators (TASK_DONE vs TASK-DONE)
|
|
1519
|
+
* - Minor typos with Levenshtein distance (COMPLET vs COMPLETE)
|
|
1520
|
+
*
|
|
1521
|
+
* @param phrase1 - First phrase to compare
|
|
1522
|
+
* @param phrase2 - Second phrase to compare
|
|
1523
|
+
* @param maxDistance - Maximum edit distance for fuzzy match (default: 2)
|
|
1524
|
+
* @returns True if phrases are fuzzy-equal
|
|
1525
|
+
*/
|
|
1526
|
+
isFuzzyPhraseMatch(phrase1, phrase2, maxDistance = 2) {
|
|
1527
|
+
return fuzzyPhraseMatch(phrase1, phrase2, maxDistance);
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Validate a completion phrase and emit warnings if it's risky.
|
|
1531
|
+
*
|
|
1532
|
+
* P1-002: Configurable false positive prevention
|
|
1533
|
+
*
|
|
1534
|
+
* Checks for:
|
|
1535
|
+
* - Common/generic phrases (DONE, COMPLETE, etc.)
|
|
1536
|
+
* - Short phrases (< MIN_RECOMMENDED_PHRASE_LENGTH)
|
|
1537
|
+
* - Numeric-only phrases
|
|
1538
|
+
*
|
|
1539
|
+
* @param phrase - The completion phrase to validate
|
|
1540
|
+
* @fires phraseValidationWarning - When a risky phrase is detected
|
|
1541
|
+
*/
|
|
1542
|
+
validateCompletionPhrase(phrase) {
|
|
1543
|
+
const normalized = phrase.toUpperCase().replace(/[\s_\-.]+/g, '');
|
|
1544
|
+
// Generate a suggested unique phrase
|
|
1545
|
+
const uniqueSuffix = Date.now().toString(36).slice(-4).toUpperCase();
|
|
1546
|
+
const suggestedPhrase = `${phrase}_${uniqueSuffix}`;
|
|
1547
|
+
// Check for common phrases
|
|
1548
|
+
if (COMMON_COMPLETION_PHRASES.has(normalized)) {
|
|
1549
|
+
console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is very common and may cause false positives. Consider using: "${suggestedPhrase}"`);
|
|
1550
|
+
this.emit('phraseValidationWarning', {
|
|
1551
|
+
phrase,
|
|
1552
|
+
reason: 'common',
|
|
1553
|
+
suggestedPhrase,
|
|
1554
|
+
});
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
// Check for short phrases
|
|
1558
|
+
if (normalized.length < MIN_RECOMMENDED_PHRASE_LENGTH) {
|
|
1559
|
+
console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is too short (${normalized.length} chars). Consider using: "${suggestedPhrase}"`);
|
|
1560
|
+
this.emit('phraseValidationWarning', {
|
|
1561
|
+
phrase,
|
|
1562
|
+
reason: 'short',
|
|
1563
|
+
suggestedPhrase,
|
|
1564
|
+
});
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
// Check for numeric-only phrases
|
|
1568
|
+
if (/^\d+$/.test(normalized)) {
|
|
1569
|
+
console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is numeric-only and may cause false positives. Consider using: "${suggestedPhrase}"`);
|
|
1570
|
+
this.emit('phraseValidationWarning', {
|
|
1571
|
+
phrase,
|
|
1572
|
+
reason: 'numeric',
|
|
1573
|
+
suggestedPhrase,
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Activate the loop if not already active.
|
|
1579
|
+
*
|
|
1580
|
+
* Sets loop state to active and initializes counters.
|
|
1581
|
+
* No-op if loop is already active.
|
|
1582
|
+
*
|
|
1583
|
+
* @returns True if loop was activated, false if already active
|
|
1584
|
+
* @fires loopUpdate - When loop state changes
|
|
1585
|
+
*/
|
|
1586
|
+
activateLoopIfNeeded() {
|
|
1587
|
+
if (this._loopState.active)
|
|
1588
|
+
return false;
|
|
1589
|
+
this._loopState.active = true;
|
|
1590
|
+
this._loopState.startedAt = Date.now();
|
|
1591
|
+
this._loopState.cycleCount = 0;
|
|
1592
|
+
this._loopState.maxIterations = null;
|
|
1593
|
+
this._loopState.elapsedHours = null;
|
|
1594
|
+
this._loopState.lastActivity = Date.now();
|
|
1595
|
+
this.emit('loopUpdate', this.loopState);
|
|
1596
|
+
return true;
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* Detect loop start and status indicators.
|
|
1600
|
+
*
|
|
1601
|
+
* Patterns detected:
|
|
1602
|
+
* - Ralph loop start commands (`/ralph-loop:ralph-loop`)
|
|
1603
|
+
* - Loop start messages (`Loop started at`, `Starting Ralph loop`)
|
|
1604
|
+
* - Max iterations setting (`max-iterations 50`)
|
|
1605
|
+
* - Iteration progress (`Iteration 5/50`, `[5/50]`)
|
|
1606
|
+
* - Elapsed time (`Elapsed: 2.5 hours`)
|
|
1607
|
+
* - Cycle count (`cycle #5`, `respawn cycle #3`)
|
|
1608
|
+
* - TodoWrite tool usage
|
|
1609
|
+
*
|
|
1610
|
+
* @param line - Single line to check
|
|
1611
|
+
* @fires loopUpdate - When any loop state changes
|
|
1612
|
+
*/
|
|
1613
|
+
detectLoopStatus(line) {
|
|
1614
|
+
// Check for Ralph loop start command (/ralph-loop:ralph-loop)
|
|
1615
|
+
// or generic loop start patterns ("Loop started at", "Starting Ralph loop")
|
|
1616
|
+
if (RALPH_START_PATTERN.test(line) || LOOP_START_PATTERN.test(line)) {
|
|
1617
|
+
this.activateLoopIfNeeded();
|
|
1618
|
+
}
|
|
1619
|
+
// Check for max iterations setting
|
|
1620
|
+
const maxIterMatch = line.match(MAX_ITERATIONS_PATTERN);
|
|
1621
|
+
if (maxIterMatch) {
|
|
1622
|
+
const maxIter = parseInt(maxIterMatch[1], 10);
|
|
1623
|
+
if (!Number.isNaN(maxIter) && maxIter > 0) {
|
|
1624
|
+
this._loopState.maxIterations = maxIter;
|
|
1625
|
+
this._loopState.lastActivity = Date.now();
|
|
1626
|
+
// Use debounced emit for settings changes
|
|
1627
|
+
this.emitLoopUpdateDebounced();
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
// Check for iteration patterns: "Iteration 5/50", "[5/50]"
|
|
1631
|
+
const iterMatch = line.match(ITERATION_PATTERN);
|
|
1632
|
+
if (iterMatch) {
|
|
1633
|
+
// Pattern captures: group 1&2 for "Iteration X/Y", group 3&4 for "[X/Y]"
|
|
1634
|
+
const currentIter = parseInt(iterMatch[1] || iterMatch[3], 10);
|
|
1635
|
+
const maxIterStr = iterMatch[2] || iterMatch[4];
|
|
1636
|
+
const maxIter = maxIterStr ? parseInt(maxIterStr, 10) : null;
|
|
1637
|
+
if (!Number.isNaN(currentIter)) {
|
|
1638
|
+
this.activateLoopIfNeeded();
|
|
1639
|
+
// Track iteration changes for stall detection
|
|
1640
|
+
if (currentIter !== this._lastObservedIteration) {
|
|
1641
|
+
this._lastIterationChangeTime = Date.now();
|
|
1642
|
+
this._lastObservedIteration = currentIter;
|
|
1643
|
+
this._iterationStallWarned = false; // Reset warning on iteration change
|
|
1644
|
+
// P1-004: Reset circuit breaker on successful iteration progress
|
|
1645
|
+
// If we're making progress, the loop is healthy
|
|
1646
|
+
if (this._circuitBreaker.state === 'HALF_OPEN' ||
|
|
1647
|
+
this._circuitBreaker.consecutiveNoProgress > 0 ||
|
|
1648
|
+
this._circuitBreaker.consecutiveSameError > 0 ||
|
|
1649
|
+
this._circuitBreaker.consecutiveTestsFailure > 0) {
|
|
1650
|
+
this._circuitBreaker.consecutiveNoProgress = 0;
|
|
1651
|
+
this._circuitBreaker.consecutiveSameError = 0;
|
|
1652
|
+
this._circuitBreaker.lastProgressIteration = currentIter;
|
|
1653
|
+
if (this._circuitBreaker.state === 'HALF_OPEN') {
|
|
1654
|
+
this._circuitBreaker.state = 'CLOSED';
|
|
1655
|
+
this._circuitBreaker.reason = 'Iteration progress detected';
|
|
1656
|
+
this._circuitBreaker.reasonCode = 'progress_detected';
|
|
1657
|
+
this.emit('circuitBreakerUpdate', { ...this._circuitBreaker });
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
this._loopState.cycleCount = currentIter;
|
|
1662
|
+
if (maxIter !== null && !Number.isNaN(maxIter)) {
|
|
1663
|
+
this._loopState.maxIterations = maxIter;
|
|
1664
|
+
}
|
|
1665
|
+
this._loopState.lastActivity = Date.now();
|
|
1666
|
+
// Use debounced emit for rapid iteration updates
|
|
1667
|
+
this.emitLoopUpdateDebounced();
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
// Check for elapsed time
|
|
1671
|
+
const elapsedMatch = line.match(ELAPSED_TIME_PATTERN);
|
|
1672
|
+
if (elapsedMatch) {
|
|
1673
|
+
this._loopState.elapsedHours = parseFloat(elapsedMatch[1]);
|
|
1674
|
+
this._loopState.lastActivity = Date.now();
|
|
1675
|
+
// Use debounced emit for elapsed time updates
|
|
1676
|
+
this.emitLoopUpdateDebounced();
|
|
1677
|
+
}
|
|
1678
|
+
// Check for cycle count (legacy pattern)
|
|
1679
|
+
const cycleMatch = line.match(CYCLE_PATTERN);
|
|
1680
|
+
if (cycleMatch) {
|
|
1681
|
+
const cycleNum = parseInt(cycleMatch[1] || cycleMatch[2], 10);
|
|
1682
|
+
if (!Number.isNaN(cycleNum) && cycleNum > this._loopState.cycleCount) {
|
|
1683
|
+
this._loopState.cycleCount = cycleNum;
|
|
1684
|
+
this._loopState.lastActivity = Date.now();
|
|
1685
|
+
// Use debounced emit for cycle updates
|
|
1686
|
+
this.emitLoopUpdateDebounced();
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
// Check for TodoWrite tool usage - indicates active task tracking
|
|
1690
|
+
if (TODOWRITE_PATTERN.test(line)) {
|
|
1691
|
+
this._loopState.lastActivity = Date.now();
|
|
1692
|
+
// Don't emit update just for activity, let todo detection handle it
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1696
|
+
* Detect todo items in various formats from Claude Code output.
|
|
1697
|
+
*
|
|
1698
|
+
* Supported formats:
|
|
1699
|
+
* - Format 1: Checkbox markdown (`- [ ] Task`, `- [x] Task`)
|
|
1700
|
+
* - Format 2: Indicator icons (`Todo: ☐ Task`, `Todo: ✓ Task`)
|
|
1701
|
+
* - Format 3: Status in parentheses (`- Task (pending)`)
|
|
1702
|
+
* - Format 4: Native TodoWrite (`☐ Task`, `☒ Task`, `◐ Task`)
|
|
1703
|
+
*
|
|
1704
|
+
* Uses quick pre-check to skip lines that can't contain todos.
|
|
1705
|
+
* Excludes tool invocations and Claude commentary patterns.
|
|
1706
|
+
*
|
|
1707
|
+
* @param line - Single line to check
|
|
1708
|
+
* @fires todoUpdate - When any todos are detected or updated
|
|
1709
|
+
*/
|
|
1710
|
+
detectTodoItems(line) {
|
|
1711
|
+
// Pre-compute which pattern categories might match (60-75% faster)
|
|
1712
|
+
const hasCheckbox = line.includes('[');
|
|
1713
|
+
const hasTodoIndicator = line.includes('Todo:');
|
|
1714
|
+
const hasNativeCheckbox = line.includes('☐') || line.includes('☒') || line.includes('◐') || line.includes('✓');
|
|
1715
|
+
const hasStatus = line.includes('(pending)') || line.includes('(in_progress)') || line.includes('(completed)');
|
|
1716
|
+
const hasCheckmark = line.includes('✔');
|
|
1717
|
+
// Quick check: skip lines that can't possibly contain todos
|
|
1718
|
+
if (!hasCheckbox && !hasTodoIndicator && !hasNativeCheckbox && !hasStatus && !hasCheckmark) {
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
let updated = false;
|
|
1722
|
+
let match;
|
|
1723
|
+
// Format 1: Checkbox format "- [ ] Task" or "- [x] Task"
|
|
1724
|
+
// Only scan if line contains '[' character
|
|
1725
|
+
if (hasCheckbox) {
|
|
1726
|
+
TODO_CHECKBOX_PATTERN.lastIndex = 0;
|
|
1727
|
+
while ((match = TODO_CHECKBOX_PATTERN.exec(line)) !== null) {
|
|
1728
|
+
const checked = match[1].toLowerCase() === 'x';
|
|
1729
|
+
const content = match[2].trim();
|
|
1730
|
+
const status = checked ? 'completed' : 'pending';
|
|
1731
|
+
this.upsertTodo(content, status);
|
|
1732
|
+
updated = true;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
// Format 2: Todo with indicator icons
|
|
1736
|
+
// Only scan if line contains 'Todo:' prefix
|
|
1737
|
+
if (hasTodoIndicator) {
|
|
1738
|
+
TODO_INDICATOR_PATTERN.lastIndex = 0;
|
|
1739
|
+
while ((match = TODO_INDICATOR_PATTERN.exec(line)) !== null) {
|
|
1740
|
+
const icon = match[1];
|
|
1741
|
+
const content = match[2].trim();
|
|
1742
|
+
const status = this.iconToStatus(icon);
|
|
1743
|
+
this.upsertTodo(content, status);
|
|
1744
|
+
updated = true;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
// Format 3: Status in parentheses
|
|
1748
|
+
// Only scan if line contains status in parentheses
|
|
1749
|
+
if (hasStatus) {
|
|
1750
|
+
TODO_STATUS_PATTERN.lastIndex = 0;
|
|
1751
|
+
while ((match = TODO_STATUS_PATTERN.exec(line)) !== null) {
|
|
1752
|
+
const content = match[1].trim();
|
|
1753
|
+
const status = match[2];
|
|
1754
|
+
this.upsertTodo(content, status);
|
|
1755
|
+
updated = true;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
// Format 4: Claude Code native TodoWrite output (☐, ☒, ◐)
|
|
1759
|
+
// Only scan if line contains native checkbox icons
|
|
1760
|
+
if (hasNativeCheckbox) {
|
|
1761
|
+
TODO_NATIVE_PATTERN.lastIndex = 0;
|
|
1762
|
+
while ((match = TODO_NATIVE_PATTERN.exec(line)) !== null) {
|
|
1763
|
+
const icon = match[1];
|
|
1764
|
+
const content = match[2].trim();
|
|
1765
|
+
// Skip if content matches exclude patterns (tool invocations, commentary)
|
|
1766
|
+
const shouldExclude = TODO_EXCLUDE_PATTERNS.some((pattern) => pattern.test(content));
|
|
1767
|
+
if (shouldExclude)
|
|
1768
|
+
continue;
|
|
1769
|
+
// Skip if content is too short or looks like partial garbage
|
|
1770
|
+
if (content.length < 5)
|
|
1771
|
+
continue;
|
|
1772
|
+
const status = this.iconToStatus(icon);
|
|
1773
|
+
this.upsertTodo(content, status);
|
|
1774
|
+
updated = true;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
// Format 5: Claude Code checkmark-based TodoWrite output (✔ Task #N)
|
|
1778
|
+
// Handles: "✔ Task #N created: content", "✔ #N content", "✔ Task #N updated: status → X"
|
|
1779
|
+
if (hasCheckmark) {
|
|
1780
|
+
// Task creation: "✔ Task #1 created: Fix the bug"
|
|
1781
|
+
TODO_TASK_CREATED_PATTERN.lastIndex = 0;
|
|
1782
|
+
while ((match = TODO_TASK_CREATED_PATTERN.exec(line)) !== null) {
|
|
1783
|
+
const taskNum = parseInt(match[1], 10);
|
|
1784
|
+
const content = match[2].trim();
|
|
1785
|
+
if (content.length >= 5) {
|
|
1786
|
+
this._taskNumberToContent.set(taskNum, content);
|
|
1787
|
+
this.enforceTaskMappingLimit();
|
|
1788
|
+
this.upsertTodo(content, 'pending');
|
|
1789
|
+
updated = true;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
// Task summary: "✔ #1 Fix the bug"
|
|
1793
|
+
TODO_TASK_SUMMARY_PATTERN.lastIndex = 0;
|
|
1794
|
+
while ((match = TODO_TASK_SUMMARY_PATTERN.exec(line)) !== null) {
|
|
1795
|
+
const taskNum = parseInt(match[1], 10);
|
|
1796
|
+
const content = match[2].trim();
|
|
1797
|
+
if (content.length >= 5) {
|
|
1798
|
+
// Only register if not already known from a "created" line
|
|
1799
|
+
if (!this._taskNumberToContent.has(taskNum)) {
|
|
1800
|
+
this._taskNumberToContent.set(taskNum, content);
|
|
1801
|
+
this.enforceTaskMappingLimit();
|
|
1802
|
+
}
|
|
1803
|
+
this.upsertTodo(this._taskNumberToContent.get(taskNum) || content, 'pending');
|
|
1804
|
+
updated = true;
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
// Status update: "✔ Task #1 updated: status → completed"
|
|
1808
|
+
TODO_TASK_STATUS_PATTERN.lastIndex = 0;
|
|
1809
|
+
while ((match = TODO_TASK_STATUS_PATTERN.exec(line)) !== null) {
|
|
1810
|
+
const taskNum = parseInt(match[1], 10);
|
|
1811
|
+
const statusStr = match[2].trim();
|
|
1812
|
+
const status = statusStr === 'completed' ? 'completed' : statusStr === 'in progress' ? 'in_progress' : 'pending';
|
|
1813
|
+
const content = this._taskNumberToContent.get(taskNum);
|
|
1814
|
+
if (content) {
|
|
1815
|
+
this.upsertTodo(content, status);
|
|
1816
|
+
updated = true;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
// Plain checkmark: "✔ Create hello.txt" (no task number)
|
|
1820
|
+
// Only match if numbered patterns didn't already match on this line
|
|
1821
|
+
if (!updated) {
|
|
1822
|
+
TODO_PLAIN_CHECKMARK_PATTERN.lastIndex = 0;
|
|
1823
|
+
while ((match = TODO_PLAIN_CHECKMARK_PATTERN.exec(line)) !== null) {
|
|
1824
|
+
const content = match[1].trim();
|
|
1825
|
+
// Skip if content matches exclude patterns
|
|
1826
|
+
const shouldExclude = TODO_EXCLUDE_PATTERNS.some((pattern) => pattern.test(content));
|
|
1827
|
+
if (shouldExclude)
|
|
1828
|
+
continue;
|
|
1829
|
+
if (content.length < 5)
|
|
1830
|
+
continue;
|
|
1831
|
+
// Skip status/created/updated prefixed content (already handled above)
|
|
1832
|
+
if (/^(Task\s*#\d+|#\d+)\s/.test(content))
|
|
1833
|
+
continue;
|
|
1834
|
+
this.upsertTodo(content, 'completed');
|
|
1835
|
+
updated = true;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
if (updated) {
|
|
1840
|
+
// Use debounced emit to batch rapid todo updates and reduce UI jitter
|
|
1841
|
+
this.emitTodoUpdateDebounced();
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
/**
|
|
1845
|
+
* Convert a todo icon character to its corresponding status.
|
|
1846
|
+
*
|
|
1847
|
+
* Icon mappings:
|
|
1848
|
+
* - Completed: `✓`, `✅`, `☒`, `◉`, `●`
|
|
1849
|
+
* - In Progress: `◐`, `⏳`, `⌛`, `🔄`
|
|
1850
|
+
* - Pending: `☐`, `○`, and anything else (default)
|
|
1851
|
+
*
|
|
1852
|
+
* @param icon - Single character icon
|
|
1853
|
+
* @returns Corresponding RalphTodoStatus
|
|
1854
|
+
*/
|
|
1855
|
+
iconToStatus(icon) {
|
|
1856
|
+
switch (icon) {
|
|
1857
|
+
case '✓':
|
|
1858
|
+
case '✅':
|
|
1859
|
+
case '☒': // Claude Code checked checkbox
|
|
1860
|
+
case '◉': // Filled circle (completed)
|
|
1861
|
+
case '●': // Solid circle (completed)
|
|
1862
|
+
return 'completed';
|
|
1863
|
+
case '◐': // Half-filled circle (in progress)
|
|
1864
|
+
case '⏳':
|
|
1865
|
+
case '⌛':
|
|
1866
|
+
case '🔄':
|
|
1867
|
+
return 'in_progress';
|
|
1868
|
+
case '☐': // Claude Code empty checkbox
|
|
1869
|
+
case '○': // Empty circle
|
|
1870
|
+
default:
|
|
1871
|
+
return 'pending';
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* Parse priority from todo content.
|
|
1876
|
+
* P1-008: Enhanced keyword-based priority inference.
|
|
1877
|
+
*
|
|
1878
|
+
* Priority levels:
|
|
1879
|
+
* - P0 (Critical): Explicit P0, "critical", "blocker", "urgent", "security", "crash", "broken"
|
|
1880
|
+
* - P1 (High): Explicit P1, "important", "high priority", "bug", "fix", "error", "fail"
|
|
1881
|
+
* - P2 (Medium): Explicit P2, "nice to have", "low priority", "refactor", "cleanup", "improve"
|
|
1882
|
+
*
|
|
1883
|
+
* @param content - Todo content text
|
|
1884
|
+
* @returns Parsed priority level or null
|
|
1885
|
+
*/
|
|
1886
|
+
parsePriority(content) {
|
|
1887
|
+
const upper = content.toUpperCase();
|
|
1888
|
+
// Check P0 first (highest priority wins)
|
|
1889
|
+
// Uses pre-compiled module-level patterns for performance
|
|
1890
|
+
for (const pattern of P0_PRIORITY_PATTERNS) {
|
|
1891
|
+
if (pattern.test(upper)) {
|
|
1892
|
+
return 'P0';
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
// Check P1
|
|
1896
|
+
for (const pattern of P1_PRIORITY_PATTERNS) {
|
|
1897
|
+
if (pattern.test(upper)) {
|
|
1898
|
+
return 'P1';
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
// Check P2
|
|
1902
|
+
for (const pattern of P2_PRIORITY_PATTERNS) {
|
|
1903
|
+
if (pattern.test(upper)) {
|
|
1904
|
+
return 'P2';
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
return null;
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* Add a new todo item or update an existing one.
|
|
1911
|
+
*
|
|
1912
|
+
* Behavior:
|
|
1913
|
+
* - Content is cleaned (ANSI removed, whitespace collapsed)
|
|
1914
|
+
* - Content under 5 chars is skipped
|
|
1915
|
+
* - ID is generated from normalized content (stable hash)
|
|
1916
|
+
* - Priority is parsed from content (P0/P1/P2, Critical, High Priority, etc.)
|
|
1917
|
+
* - Existing item: Updates status and timestamp
|
|
1918
|
+
* - New item: Adds to map, evicts oldest if at MAX_TODOS_PER_SESSION
|
|
1919
|
+
*
|
|
1920
|
+
* @param content - Raw todo content text
|
|
1921
|
+
* @param status - Status to set
|
|
1922
|
+
*/
|
|
1923
|
+
upsertTodo(content, status) {
|
|
1924
|
+
// Skip empty or whitespace-only content
|
|
1925
|
+
if (!content || !content.trim())
|
|
1926
|
+
return;
|
|
1927
|
+
// Clean content: remove ANSI codes, collapse whitespace, trim
|
|
1928
|
+
const cleanContent = content
|
|
1929
|
+
.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '') // Remove ANSI escape codes
|
|
1930
|
+
.replace(/\s+/g, ' ') // Collapse whitespace
|
|
1931
|
+
.trim();
|
|
1932
|
+
if (cleanContent.length < 5)
|
|
1933
|
+
return; // Skip very short content
|
|
1934
|
+
// Parse priority from content
|
|
1935
|
+
const priority = this.parsePriority(cleanContent);
|
|
1936
|
+
// P1-009: Estimate complexity for duration tracking
|
|
1937
|
+
const estimatedComplexity = this.estimateComplexity(cleanContent);
|
|
1938
|
+
// Generate a stable ID from normalized content
|
|
1939
|
+
const id = this.generateTodoId(cleanContent);
|
|
1940
|
+
const existing = this._todos.get(id);
|
|
1941
|
+
if (existing) {
|
|
1942
|
+
// P1-009: Track status transitions for progress estimation
|
|
1943
|
+
const wasCompleted = existing.status === 'completed';
|
|
1944
|
+
const isNowCompleted = status === 'completed';
|
|
1945
|
+
const wasInProgress = existing.status === 'in_progress';
|
|
1946
|
+
const isNowInProgress = status === 'in_progress';
|
|
1947
|
+
// Update existing todo (exact match by ID)
|
|
1948
|
+
existing.status = status;
|
|
1949
|
+
existing.detectedAt = Date.now();
|
|
1950
|
+
// Update priority if parsed (don't overwrite with null)
|
|
1951
|
+
if (priority)
|
|
1952
|
+
existing.priority = priority;
|
|
1953
|
+
// Update complexity estimate if not already set
|
|
1954
|
+
if (!existing.estimatedComplexity) {
|
|
1955
|
+
existing.estimatedComplexity = estimatedComplexity;
|
|
1956
|
+
}
|
|
1957
|
+
// P1-009: Track completion time
|
|
1958
|
+
if (!wasCompleted && isNowCompleted) {
|
|
1959
|
+
this.recordTodoCompletion(id);
|
|
1960
|
+
}
|
|
1961
|
+
// P1-009: Start tracking when status changes to in_progress
|
|
1962
|
+
if (!wasInProgress && isNowInProgress) {
|
|
1963
|
+
this.startTrackingTodo(id);
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
else {
|
|
1967
|
+
// P1-007: Check for similar existing todo (deduplication)
|
|
1968
|
+
const similar = this.findSimilarTodo(cleanContent);
|
|
1969
|
+
if (similar) {
|
|
1970
|
+
// P1-009: Track status transitions on similar todo
|
|
1971
|
+
const wasCompleted = similar.status === 'completed';
|
|
1972
|
+
const isNowCompleted = status === 'completed';
|
|
1973
|
+
const wasInProgress = similar.status === 'in_progress';
|
|
1974
|
+
const isNowInProgress = status === 'in_progress';
|
|
1975
|
+
// Update similar todo instead of creating duplicate
|
|
1976
|
+
similar.status = status;
|
|
1977
|
+
similar.detectedAt = Date.now();
|
|
1978
|
+
// Update priority if new content has priority and existing doesn't
|
|
1979
|
+
if (priority && !similar.priority) {
|
|
1980
|
+
similar.priority = priority;
|
|
1981
|
+
}
|
|
1982
|
+
// Keep the longer/more descriptive content
|
|
1983
|
+
if (cleanContent.length > similar.content.length) {
|
|
1984
|
+
similar.content = cleanContent;
|
|
1985
|
+
}
|
|
1986
|
+
// P1-009: Track completion time
|
|
1987
|
+
if (!wasCompleted && isNowCompleted) {
|
|
1988
|
+
this.recordTodoCompletion(similar.id);
|
|
1989
|
+
}
|
|
1990
|
+
if (!wasInProgress && isNowInProgress) {
|
|
1991
|
+
this.startTrackingTodo(similar.id);
|
|
1992
|
+
}
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
// Add new todo with guaranteed eviction if at capacity
|
|
1996
|
+
// Use while loop to ensure we always have room (handles edge case where findOldestTodo returns undefined)
|
|
1997
|
+
while (this._todos.size >= MAX_TODOS_PER_SESSION) {
|
|
1998
|
+
const oldest = this.findOldestTodo();
|
|
1999
|
+
if (oldest) {
|
|
2000
|
+
this._todos.delete(oldest.id);
|
|
2001
|
+
}
|
|
2002
|
+
else {
|
|
2003
|
+
// Safety valve: if somehow no oldest found, clear a random entry
|
|
2004
|
+
const firstKey = this._todos.keys().next().value;
|
|
2005
|
+
if (firstKey)
|
|
2006
|
+
this._todos.delete(firstKey);
|
|
2007
|
+
else
|
|
2008
|
+
break; // Map is empty somehow, exit loop
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
const estimatedDurationMs = this.getEstimatedDuration(estimatedComplexity);
|
|
2012
|
+
this._todos.set(id, {
|
|
2013
|
+
id,
|
|
2014
|
+
content: cleanContent,
|
|
2015
|
+
status,
|
|
2016
|
+
detectedAt: Date.now(),
|
|
2017
|
+
priority,
|
|
2018
|
+
estimatedComplexity,
|
|
2019
|
+
estimatedDurationMs,
|
|
2020
|
+
});
|
|
2021
|
+
// P1-009: Start tracking if already in_progress
|
|
2022
|
+
if (status === 'in_progress') {
|
|
2023
|
+
this.startTrackingTodo(id);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* Normalize todo content for consistent matching.
|
|
2029
|
+
*
|
|
2030
|
+
* Normalization steps:
|
|
2031
|
+
* 1. Collapse multiple whitespace to single space
|
|
2032
|
+
* 2. Remove special characters (keep alphanumeric + basic punctuation)
|
|
2033
|
+
* 3. Trim whitespace
|
|
2034
|
+
* 4. Convert to lowercase
|
|
2035
|
+
*
|
|
2036
|
+
* This prevents duplicate todos from terminal rendering artifacts.
|
|
2037
|
+
*
|
|
2038
|
+
* @param content - Raw todo content
|
|
2039
|
+
* @returns Normalized lowercase string
|
|
2040
|
+
*/
|
|
2041
|
+
normalizeTodoContent(content) {
|
|
2042
|
+
if (!content)
|
|
2043
|
+
return '';
|
|
2044
|
+
return content
|
|
2045
|
+
.replace(/\s+/g, ' ') // Collapse whitespace
|
|
2046
|
+
.replace(/[^a-zA-Z0-9\s.,!?'"-]/g, '') // Remove special chars (keep punctuation)
|
|
2047
|
+
.trim()
|
|
2048
|
+
.toLowerCase();
|
|
2049
|
+
}
|
|
2050
|
+
/**
|
|
2051
|
+
* Calculate similarity between two strings.
|
|
2052
|
+
*
|
|
2053
|
+
* P1-007: Uses a hybrid approach combining:
|
|
2054
|
+
* 1. Levenshtein-based similarity for edit-distance tolerance
|
|
2055
|
+
* 2. Bigram (Dice coefficient) for reordering tolerance
|
|
2056
|
+
* Returns the maximum of both methods.
|
|
2057
|
+
*
|
|
2058
|
+
* @param str1 - First string (will be normalized)
|
|
2059
|
+
* @param str2 - Second string (will be normalized)
|
|
2060
|
+
* @returns Similarity score from 0.0 (no similarity) to 1.0 (identical)
|
|
2061
|
+
*/
|
|
2062
|
+
calculateSimilarity(str1, str2) {
|
|
2063
|
+
const norm1 = this.normalizeTodoContent(str1);
|
|
2064
|
+
const norm2 = this.normalizeTodoContent(str2);
|
|
2065
|
+
// Identical after normalization
|
|
2066
|
+
if (norm1 === norm2)
|
|
2067
|
+
return 1.0;
|
|
2068
|
+
// If either is empty, no similarity
|
|
2069
|
+
if (!norm1 || !norm2)
|
|
2070
|
+
return 0.0;
|
|
2071
|
+
// Method 1: Levenshtein-based similarity (good for typos/minor edits)
|
|
2072
|
+
const levenshteinSim = stringSimilarity(norm1, norm2);
|
|
2073
|
+
// Method 2: Bigram/Dice similarity (good for word reordering)
|
|
2074
|
+
const bigramSim = this.calculateBigramSimilarity(norm1, norm2);
|
|
2075
|
+
// Return the higher of the two scores
|
|
2076
|
+
return Math.max(levenshteinSim, bigramSim);
|
|
2077
|
+
}
|
|
2078
|
+
/**
|
|
2079
|
+
* Calculate bigram (Dice coefficient) similarity.
|
|
2080
|
+
* Good for detecting near-duplicates with word reordering.
|
|
2081
|
+
*
|
|
2082
|
+
* @param norm1 - First normalized string
|
|
2083
|
+
* @param norm2 - Second normalized string
|
|
2084
|
+
* @returns Similarity score from 0.0 to 1.0
|
|
2085
|
+
*/
|
|
2086
|
+
calculateBigramSimilarity(norm1, norm2) {
|
|
2087
|
+
// Short strings: use simple character overlap
|
|
2088
|
+
if (norm1.length < 3 || norm2.length < 3) {
|
|
2089
|
+
const shorter = norm1.length <= norm2.length ? norm1 : norm2;
|
|
2090
|
+
const longer = norm1.length > norm2.length ? norm1 : norm2;
|
|
2091
|
+
return longer.includes(shorter) ? 0.9 : 0.0;
|
|
2092
|
+
}
|
|
2093
|
+
// Extract bigrams (pairs of consecutive characters)
|
|
2094
|
+
const getBigrams = (s) => {
|
|
2095
|
+
const bigrams = new Set();
|
|
2096
|
+
for (let i = 0; i < s.length - 1; i++) {
|
|
2097
|
+
bigrams.add(s.substring(i, i + 2));
|
|
2098
|
+
}
|
|
2099
|
+
return bigrams;
|
|
2100
|
+
};
|
|
2101
|
+
const bigrams1 = getBigrams(norm1);
|
|
2102
|
+
const bigrams2 = getBigrams(norm2);
|
|
2103
|
+
// Count intersection
|
|
2104
|
+
let intersection = 0;
|
|
2105
|
+
for (const bigram of bigrams1) {
|
|
2106
|
+
if (bigrams2.has(bigram)) {
|
|
2107
|
+
intersection++;
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
// Dice coefficient: 2 * intersection / (total bigrams)
|
|
2111
|
+
const totalBigrams = bigrams1.size + bigrams2.size;
|
|
2112
|
+
if (totalBigrams === 0)
|
|
2113
|
+
return 0.0;
|
|
2114
|
+
return (2 * intersection) / totalBigrams;
|
|
2115
|
+
}
|
|
2116
|
+
/**
|
|
2117
|
+
* Find an existing todo that is similar to the given content.
|
|
2118
|
+
* Returns the most similar todo if similarity >= threshold.
|
|
2119
|
+
*
|
|
2120
|
+
* Deduplication is intentionally conservative:
|
|
2121
|
+
* - Short strings (< 30 chars): require 95% similarity (nearly identical)
|
|
2122
|
+
* - Medium strings (30-60 chars): require 90% similarity
|
|
2123
|
+
* - Longer strings: use default 85% threshold
|
|
2124
|
+
*
|
|
2125
|
+
* This prevents over-aggressive deduplication of brief, numbered items
|
|
2126
|
+
* like "Task 1", "Task 2" while still catching true duplicates.
|
|
2127
|
+
*
|
|
2128
|
+
* @param content - New todo content to check against existing todos
|
|
2129
|
+
* @returns Similar todo item if found, undefined otherwise
|
|
2130
|
+
*/
|
|
2131
|
+
findSimilarTodo(content) {
|
|
2132
|
+
const normalized = this.normalizeTodoContent(content);
|
|
2133
|
+
// Determine appropriate threshold based on string length
|
|
2134
|
+
// Shorter strings need higher threshold to avoid false positives
|
|
2135
|
+
let threshold;
|
|
2136
|
+
if (normalized.length < 30) {
|
|
2137
|
+
threshold = 0.95; // Very strict for short strings
|
|
2138
|
+
}
|
|
2139
|
+
else if (normalized.length < 60) {
|
|
2140
|
+
threshold = 0.9; // Strict for medium strings
|
|
2141
|
+
}
|
|
2142
|
+
else {
|
|
2143
|
+
threshold = TODO_SIMILARITY_THRESHOLD; // 0.85 for longer strings
|
|
2144
|
+
}
|
|
2145
|
+
let bestMatch;
|
|
2146
|
+
let bestSimilarity = 0;
|
|
2147
|
+
for (const todo of this._todos.values()) {
|
|
2148
|
+
const similarity = this.calculateSimilarity(content, todo.content);
|
|
2149
|
+
if (similarity >= threshold && similarity > bestSimilarity) {
|
|
2150
|
+
bestSimilarity = similarity;
|
|
2151
|
+
bestMatch = todo;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
return bestMatch;
|
|
2155
|
+
}
|
|
2156
|
+
// ========== P1-009: Progress Estimation Methods ==========
|
|
2157
|
+
/**
|
|
2158
|
+
* Estimate complexity of a todo based on content keywords.
|
|
2159
|
+
* Used for duration estimation.
|
|
2160
|
+
*
|
|
2161
|
+
* @param content - Todo content text
|
|
2162
|
+
* @returns Complexity category
|
|
2163
|
+
*/
|
|
2164
|
+
estimateComplexity(content) {
|
|
2165
|
+
const lower = content.toLowerCase();
|
|
2166
|
+
// Trivial: Simple fixes, typos, documentation
|
|
2167
|
+
const trivialPatterns = [
|
|
2168
|
+
/\btypo\b/,
|
|
2169
|
+
/\bspelling\b/,
|
|
2170
|
+
/\bcomment\b/,
|
|
2171
|
+
/\bupdate\s+(?:version|readme)\b/,
|
|
2172
|
+
/\brename\b/,
|
|
2173
|
+
/\bformat(?:ting)?\b/,
|
|
2174
|
+
];
|
|
2175
|
+
// Complex: Architecture, refactoring, security, testing
|
|
2176
|
+
const complexPatterns = [
|
|
2177
|
+
/\barchitect(?:ure)?\b/,
|
|
2178
|
+
/\brefactor\b/,
|
|
2179
|
+
/\brewrite\b/,
|
|
2180
|
+
/\bsecurity\b/,
|
|
2181
|
+
/\bmigrat(?:e|ion)\b/,
|
|
2182
|
+
/\btest(?:s|ing)?\b/,
|
|
2183
|
+
/\bintegrat(?:e|ion)\b/,
|
|
2184
|
+
/\bperformance\b/,
|
|
2185
|
+
/\boptimiz(?:e|ation)\b/,
|
|
2186
|
+
/\bmultiple\s+files?\b/,
|
|
2187
|
+
];
|
|
2188
|
+
// Moderate: Bugs, features, enhancements
|
|
2189
|
+
const moderatePatterns = [/\bbug\b/, /\bfeature\b/, /\benhance(?:ment)?\b/, /\bimplement\b/, /\badd\b/, /\bfix\b/];
|
|
2190
|
+
for (const pattern of complexPatterns) {
|
|
2191
|
+
if (pattern.test(lower))
|
|
2192
|
+
return 'complex';
|
|
2193
|
+
}
|
|
2194
|
+
for (const trivialPattern of trivialPatterns) {
|
|
2195
|
+
if (trivialPattern.test(lower))
|
|
2196
|
+
return 'trivial';
|
|
2197
|
+
}
|
|
2198
|
+
for (const moderatePattern of moderatePatterns) {
|
|
2199
|
+
if (moderatePattern.test(lower))
|
|
2200
|
+
return 'moderate';
|
|
2201
|
+
}
|
|
2202
|
+
return 'simple';
|
|
2203
|
+
}
|
|
2204
|
+
/**
|
|
2205
|
+
* Get estimated duration for a complexity level (ms).
|
|
2206
|
+
* Based on historical patterns from similar tasks.
|
|
2207
|
+
*
|
|
2208
|
+
* @param complexity - Complexity category
|
|
2209
|
+
* @returns Estimated duration in milliseconds
|
|
2210
|
+
*/
|
|
2211
|
+
getEstimatedDuration(complexity) {
|
|
2212
|
+
// If we have historical data, use average adjusted by complexity
|
|
2213
|
+
const avgTime = this.getAverageCompletionTime();
|
|
2214
|
+
if (avgTime !== null) {
|
|
2215
|
+
const multipliers = {
|
|
2216
|
+
trivial: 0.25,
|
|
2217
|
+
simple: 0.5,
|
|
2218
|
+
moderate: 1.0,
|
|
2219
|
+
complex: 2.0,
|
|
2220
|
+
};
|
|
2221
|
+
return Math.round(avgTime * multipliers[complexity]);
|
|
2222
|
+
}
|
|
2223
|
+
// Default estimates (in ms) based on typical task durations
|
|
2224
|
+
const defaults = {
|
|
2225
|
+
trivial: 1 * 60 * 1000, // 1 minute
|
|
2226
|
+
simple: 3 * 60 * 1000, // 3 minutes
|
|
2227
|
+
moderate: 10 * 60 * 1000, // 10 minutes
|
|
2228
|
+
complex: 30 * 60 * 1000, // 30 minutes
|
|
2229
|
+
};
|
|
2230
|
+
return defaults[complexity];
|
|
2231
|
+
}
|
|
2232
|
+
/**
|
|
2233
|
+
* Get average completion time from historical data.
|
|
2234
|
+
* @returns Average time in ms, or null if no data
|
|
2235
|
+
*/
|
|
2236
|
+
getAverageCompletionTime() {
|
|
2237
|
+
if (this._completionTimes.length === 0)
|
|
2238
|
+
return null;
|
|
2239
|
+
const sum = this._completionTimes.reduce((a, b) => a + b, 0);
|
|
2240
|
+
return Math.round(sum / this._completionTimes.length);
|
|
2241
|
+
}
|
|
2242
|
+
/**
|
|
2243
|
+
* Record a todo completion for progress tracking.
|
|
2244
|
+
* @param todoId - ID of the completed todo
|
|
2245
|
+
*/
|
|
2246
|
+
recordTodoCompletion(todoId) {
|
|
2247
|
+
const startTime = this._todoStartTimes.get(todoId);
|
|
2248
|
+
if (startTime) {
|
|
2249
|
+
const duration = Date.now() - startTime;
|
|
2250
|
+
this._completionTimes.push(duration);
|
|
2251
|
+
// Keep only recent completion times
|
|
2252
|
+
while (this._completionTimes.length > RalphTracker.MAX_COMPLETION_TIMES) {
|
|
2253
|
+
this._completionTimes.shift();
|
|
2254
|
+
}
|
|
2255
|
+
this._todoStartTimes.delete(todoId);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
/**
|
|
2259
|
+
* Start tracking a todo for duration estimation.
|
|
2260
|
+
* @param todoId - ID of the todo being started
|
|
2261
|
+
*/
|
|
2262
|
+
startTrackingTodo(todoId) {
|
|
2263
|
+
if (!this._todoStartTimes.has(todoId)) {
|
|
2264
|
+
this._todoStartTimes.set(todoId, Date.now());
|
|
2265
|
+
}
|
|
2266
|
+
// Initialize session tracking if needed
|
|
2267
|
+
if (this._todosStartedAt === 0) {
|
|
2268
|
+
this._todosStartedAt = Date.now();
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2272
|
+
* Get progress estimation for the todo list.
|
|
2273
|
+
* P1-009: Provides completion percentage, estimated remaining time,
|
|
2274
|
+
* and projected completion timestamp.
|
|
2275
|
+
*
|
|
2276
|
+
* @returns Progress estimation object
|
|
2277
|
+
*/
|
|
2278
|
+
getTodoProgress() {
|
|
2279
|
+
const todos = Array.from(this._todos.values());
|
|
2280
|
+
const total = todos.length;
|
|
2281
|
+
const completed = todos.filter((t) => t.status === 'completed').length;
|
|
2282
|
+
const inProgress = todos.filter((t) => t.status === 'in_progress').length;
|
|
2283
|
+
const pending = todos.filter((t) => t.status === 'pending').length;
|
|
2284
|
+
const percentComplete = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
2285
|
+
// Calculate estimated remaining time
|
|
2286
|
+
let estimatedRemainingMs = null;
|
|
2287
|
+
let avgCompletionTimeMs = null;
|
|
2288
|
+
let projectedCompletionAt = null;
|
|
2289
|
+
avgCompletionTimeMs = this.getAverageCompletionTime();
|
|
2290
|
+
if (total > 0 && completed > 0) {
|
|
2291
|
+
// Method 1: Use historical average if available
|
|
2292
|
+
if (avgCompletionTimeMs !== null) {
|
|
2293
|
+
const remaining = total - completed;
|
|
2294
|
+
estimatedRemainingMs = remaining * avgCompletionTimeMs;
|
|
2295
|
+
}
|
|
2296
|
+
else {
|
|
2297
|
+
// Method 2: Calculate based on elapsed time and progress
|
|
2298
|
+
const elapsed = Date.now() - this._todosStartedAt;
|
|
2299
|
+
if (elapsed > 0 && completed > 0) {
|
|
2300
|
+
const timePerTodo = elapsed / completed;
|
|
2301
|
+
avgCompletionTimeMs = Math.round(timePerTodo);
|
|
2302
|
+
const remaining = total - completed;
|
|
2303
|
+
estimatedRemainingMs = Math.round(remaining * timePerTodo);
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
// Calculate projected completion timestamp
|
|
2307
|
+
if (estimatedRemainingMs !== null) {
|
|
2308
|
+
projectedCompletionAt = Date.now() + estimatedRemainingMs;
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
else if (total > 0 && completed === 0) {
|
|
2312
|
+
// No completions yet - use complexity-based estimates
|
|
2313
|
+
let totalEstimate = 0;
|
|
2314
|
+
for (const todo of todos) {
|
|
2315
|
+
if (todo.status !== 'completed') {
|
|
2316
|
+
const complexity = todo.estimatedComplexity || this.estimateComplexity(todo.content);
|
|
2317
|
+
totalEstimate += this.getEstimatedDuration(complexity);
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
estimatedRemainingMs = totalEstimate;
|
|
2321
|
+
projectedCompletionAt = Date.now() + totalEstimate;
|
|
2322
|
+
}
|
|
2323
|
+
return {
|
|
2324
|
+
total,
|
|
2325
|
+
completed,
|
|
2326
|
+
inProgress,
|
|
2327
|
+
pending,
|
|
2328
|
+
percentComplete,
|
|
2329
|
+
estimatedRemainingMs,
|
|
2330
|
+
avgCompletionTimeMs,
|
|
2331
|
+
projectedCompletionAt,
|
|
2332
|
+
};
|
|
2333
|
+
}
|
|
2334
|
+
/**
|
|
2335
|
+
* Generate a stable ID from todo content using djb2 hash.
|
|
2336
|
+
*
|
|
2337
|
+
* Uses the djb2 hash algorithm for good distribution across strings.
|
|
2338
|
+
* Content is normalized first to prevent duplicates from terminal artifacts.
|
|
2339
|
+
*
|
|
2340
|
+
* @param content - Todo content text
|
|
2341
|
+
* @returns Stable ID in format `todo-{hash}` (base36 encoded)
|
|
2342
|
+
*/
|
|
2343
|
+
/**
|
|
2344
|
+
* Generate a stable ID from todo content using content hashing.
|
|
2345
|
+
*
|
|
2346
|
+
* P1-007: Uses centralized todoContentHash utility for consistency
|
|
2347
|
+
* with deduplication logic.
|
|
2348
|
+
*
|
|
2349
|
+
* @param content - Todo content text
|
|
2350
|
+
* @returns Unique ID string prefixed with "todo-"
|
|
2351
|
+
*/
|
|
2352
|
+
generateTodoId(content) {
|
|
2353
|
+
if (!content)
|
|
2354
|
+
return 'todo-empty';
|
|
2355
|
+
// Use centralized hashing utility
|
|
2356
|
+
const hash = todoContentHash(content);
|
|
2357
|
+
return `todo-${hash}`;
|
|
2358
|
+
}
|
|
2359
|
+
/**
|
|
2360
|
+
* Find the todo item with the oldest detectedAt timestamp.
|
|
2361
|
+
* Used for LRU eviction when at MAX_TODOS_PER_SESSION limit.
|
|
2362
|
+
* @returns Oldest todo item, or undefined if map is empty
|
|
2363
|
+
*/
|
|
2364
|
+
findOldestTodo() {
|
|
2365
|
+
let oldest;
|
|
2366
|
+
for (const todo of this._todos.values()) {
|
|
2367
|
+
if (!oldest || todo.detectedAt < oldest.detectedAt) {
|
|
2368
|
+
oldest = todo;
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
return oldest;
|
|
2372
|
+
}
|
|
2373
|
+
/**
|
|
2374
|
+
* Conditionally run cleanup, throttled to CLEANUP_THROTTLE_MS.
|
|
2375
|
+
* Prevents cleanup from running on every data chunk (performance).
|
|
2376
|
+
*/
|
|
2377
|
+
maybeCleanupExpiredTodos() {
|
|
2378
|
+
const now = Date.now();
|
|
2379
|
+
if (now - this._lastCleanupTime < CLEANUP_THROTTLE_MS) {
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
this._lastCleanupTime = now;
|
|
2383
|
+
this.cleanupExpiredTodos();
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* Remove todo items older than TODO_EXPIRY_MS.
|
|
2387
|
+
* Emits todoUpdate if any items were removed.
|
|
2388
|
+
* @fires todoUpdate - When expired items are removed
|
|
2389
|
+
*/
|
|
2390
|
+
cleanupExpiredTodos() {
|
|
2391
|
+
const now = Date.now();
|
|
2392
|
+
const toDelete = [];
|
|
2393
|
+
for (const [id, todo] of this._todos) {
|
|
2394
|
+
if (now - todo.detectedAt > TODO_EXPIRY_MS) {
|
|
2395
|
+
toDelete.push(id);
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
if (toDelete.length > 0) {
|
|
2399
|
+
for (const id of toDelete) {
|
|
2400
|
+
this._todos.delete(id);
|
|
2401
|
+
}
|
|
2402
|
+
this.emit('todoUpdate', this.todos);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
/**
|
|
2406
|
+
* Programmatically start a loop (external API).
|
|
2407
|
+
*
|
|
2408
|
+
* Use when starting a loop from outside terminal detection,
|
|
2409
|
+
* such as from a user action or API call.
|
|
2410
|
+
*
|
|
2411
|
+
* Automatically enables the tracker if not already enabled.
|
|
2412
|
+
*
|
|
2413
|
+
* @param completionPhrase - Optional phrase that signals completion
|
|
2414
|
+
* @param maxIterations - Optional maximum iteration count
|
|
2415
|
+
* @fires enabled - If tracker was disabled
|
|
2416
|
+
* @fires loopUpdate - When loop state changes
|
|
2417
|
+
*/
|
|
2418
|
+
startLoop(completionPhrase, maxIterations) {
|
|
2419
|
+
this.enable(); // Ensure tracker is enabled
|
|
2420
|
+
this._loopState.active = true;
|
|
2421
|
+
this._loopState.startedAt = Date.now();
|
|
2422
|
+
this._loopState.cycleCount = 0;
|
|
2423
|
+
this._loopState.maxIterations = maxIterations ?? null;
|
|
2424
|
+
this._loopState.elapsedHours = null;
|
|
2425
|
+
this._loopState.lastActivity = Date.now();
|
|
2426
|
+
if (completionPhrase) {
|
|
2427
|
+
this._loopState.completionPhrase = completionPhrase;
|
|
2428
|
+
}
|
|
2429
|
+
this.emit('loopUpdate', this.loopState);
|
|
2430
|
+
}
|
|
2431
|
+
/**
|
|
2432
|
+
* Update the maximum iteration count (external API).
|
|
2433
|
+
*
|
|
2434
|
+
* @param maxIterations - New max iterations, or null to remove limit
|
|
2435
|
+
* @fires loopUpdate - When loop state changes
|
|
2436
|
+
*/
|
|
2437
|
+
setMaxIterations(maxIterations) {
|
|
2438
|
+
this._loopState.maxIterations = maxIterations;
|
|
2439
|
+
this._loopState.lastActivity = Date.now();
|
|
2440
|
+
this.emit('loopUpdate', this.loopState);
|
|
2441
|
+
}
|
|
2442
|
+
/**
|
|
2443
|
+
* Configure the tracker from external state (e.g. ralph plugin config).
|
|
2444
|
+
* Only updates fields that are provided, leaving others unchanged.
|
|
2445
|
+
*
|
|
2446
|
+
* @param config - Partial configuration to apply
|
|
2447
|
+
* @fires loopUpdate - When loop state changes
|
|
2448
|
+
*/
|
|
2449
|
+
configure(config) {
|
|
2450
|
+
if (config.enabled !== undefined) {
|
|
2451
|
+
this._loopState.enabled = config.enabled;
|
|
2452
|
+
}
|
|
2453
|
+
if (config.completionPhrase !== undefined) {
|
|
2454
|
+
this._loopState.completionPhrase = config.completionPhrase;
|
|
2455
|
+
}
|
|
2456
|
+
if (config.maxIterations !== undefined) {
|
|
2457
|
+
this._loopState.maxIterations = config.maxIterations;
|
|
2458
|
+
}
|
|
2459
|
+
this._loopState.lastActivity = Date.now();
|
|
2460
|
+
this.emit('loopUpdate', this.loopState);
|
|
2461
|
+
}
|
|
2462
|
+
/**
|
|
2463
|
+
* Programmatically stop the loop (external API).
|
|
2464
|
+
*
|
|
2465
|
+
* Sets loop to inactive. Does not disable the tracker
|
|
2466
|
+
* or clear todos - use reset() or clear() for that.
|
|
2467
|
+
*
|
|
2468
|
+
* @fires loopUpdate - When loop state changes
|
|
2469
|
+
*/
|
|
2470
|
+
stopLoop() {
|
|
2471
|
+
this._loopState.active = false;
|
|
2472
|
+
this._loopState.lastActivity = Date.now();
|
|
2473
|
+
this.emit('loopUpdate', this.loopState);
|
|
2474
|
+
}
|
|
2475
|
+
/**
|
|
2476
|
+
* Enforce size limit on _taskNumberToContent map.
|
|
2477
|
+
* Removes lowest task numbers (oldest tasks) when limit exceeded.
|
|
2478
|
+
*/
|
|
2479
|
+
enforceTaskMappingLimit() {
|
|
2480
|
+
if (this._taskNumberToContent.size <= MAX_TASK_MAPPINGS)
|
|
2481
|
+
return;
|
|
2482
|
+
// Sort keys and remove lowest (oldest) task numbers
|
|
2483
|
+
const sortedKeys = Array.from(this._taskNumberToContent.keys()).sort((a, b) => a - b);
|
|
2484
|
+
const keysToRemove = sortedKeys.slice(0, this._taskNumberToContent.size - MAX_TASK_MAPPINGS);
|
|
2485
|
+
for (const key of keysToRemove) {
|
|
2486
|
+
this._taskNumberToContent.delete(key);
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
/**
|
|
2490
|
+
* Clear all state and disable the tracker.
|
|
2491
|
+
*
|
|
2492
|
+
* Use when the session is cleared or closed.
|
|
2493
|
+
* Resets everything to initial disabled state.
|
|
2494
|
+
*
|
|
2495
|
+
* @fires loopUpdate - With initial state
|
|
2496
|
+
* @fires todoUpdate - With empty array
|
|
2497
|
+
*/
|
|
2498
|
+
clear() {
|
|
2499
|
+
// Clear debounce timers to prevent stale emissions after clear
|
|
2500
|
+
this.clearDebounceTimers();
|
|
2501
|
+
// Stop fix plan file watcher to prevent memory leak
|
|
2502
|
+
this.stopWatchingFixPlan();
|
|
2503
|
+
// Stop iteration stall detection timer to prevent leak
|
|
2504
|
+
this.stopIterationStallDetection();
|
|
2505
|
+
this._loopState = createInitialRalphTrackerState(); // This sets enabled: false
|
|
2506
|
+
this._todos.clear();
|
|
2507
|
+
this._taskNumberToContent.clear();
|
|
2508
|
+
this._todoStartTimes.clear();
|
|
2509
|
+
this._alternateCompletionPhrases.clear();
|
|
2510
|
+
this._lineBuffer = '';
|
|
2511
|
+
this._partialPromiseBuffer = '';
|
|
2512
|
+
this._completionPhraseCount.clear();
|
|
2513
|
+
// Clear RALPH_STATUS block and circuit breaker state
|
|
2514
|
+
this._statusBlockBuffer = [];
|
|
2515
|
+
this._inStatusBlock = false;
|
|
2516
|
+
this._lastStatusBlock = null;
|
|
2517
|
+
this._completionIndicators = 0;
|
|
2518
|
+
this._exitGateMet = false;
|
|
2519
|
+
this._totalFilesModified = 0;
|
|
2520
|
+
this._totalTasksCompleted = 0;
|
|
2521
|
+
this._circuitBreaker = createInitialCircuitBreakerStatus();
|
|
2522
|
+
this.emit('loopUpdate', this.loopState);
|
|
2523
|
+
this.emit('todoUpdate', this.todos);
|
|
2524
|
+
}
|
|
2525
|
+
/**
|
|
2526
|
+
* Get aggregated statistics about tracked todos.
|
|
2527
|
+
*
|
|
2528
|
+
* @returns Object with counts by status:
|
|
2529
|
+
* - total: Total number of tracked todos
|
|
2530
|
+
* - pending: Todos not yet started
|
|
2531
|
+
* - inProgress: Todos currently in progress
|
|
2532
|
+
* - completed: Finished todos
|
|
2533
|
+
*/
|
|
2534
|
+
getTodoStats() {
|
|
2535
|
+
let pending = 0;
|
|
2536
|
+
let inProgress = 0;
|
|
2537
|
+
let completed = 0;
|
|
2538
|
+
for (const todo of this._todos.values()) {
|
|
2539
|
+
switch (todo.status) {
|
|
2540
|
+
case 'pending':
|
|
2541
|
+
pending++;
|
|
2542
|
+
break;
|
|
2543
|
+
case 'in_progress':
|
|
2544
|
+
inProgress++;
|
|
2545
|
+
break;
|
|
2546
|
+
case 'completed':
|
|
2547
|
+
completed++;
|
|
2548
|
+
break;
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
return {
|
|
2552
|
+
total: this._todos.size,
|
|
2553
|
+
pending,
|
|
2554
|
+
inProgress,
|
|
2555
|
+
completed,
|
|
2556
|
+
};
|
|
2557
|
+
}
|
|
2558
|
+
/**
|
|
2559
|
+
* Restore tracker state from persisted data.
|
|
2560
|
+
*
|
|
2561
|
+
* Use after loading state from StateStore. Handles backwards
|
|
2562
|
+
* compatibility by defaulting missing `enabled` flag to false.
|
|
2563
|
+
*
|
|
2564
|
+
* Note: Does not emit events (caller should handle if needed).
|
|
2565
|
+
*
|
|
2566
|
+
* @param loopState - Persisted loop state object
|
|
2567
|
+
* @param todos - Persisted todo items array
|
|
2568
|
+
*/
|
|
2569
|
+
restoreState(loopState, todos) {
|
|
2570
|
+
// Ensure enabled flag exists (backwards compatibility)
|
|
2571
|
+
this._loopState = {
|
|
2572
|
+
...loopState,
|
|
2573
|
+
enabled: loopState.enabled ?? false, // Override after spread for backwards compat
|
|
2574
|
+
};
|
|
2575
|
+
this._todos.clear();
|
|
2576
|
+
for (const todo of todos) {
|
|
2577
|
+
// Backwards compatibility: ensure priority field exists
|
|
2578
|
+
this._todos.set(todo.id, {
|
|
2579
|
+
...todo,
|
|
2580
|
+
priority: todo.priority ?? null,
|
|
2581
|
+
});
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
// ========== RALPH_STATUS Block Detection ==========
|
|
2585
|
+
/**
|
|
2586
|
+
* Process a line for RALPH_STATUS block detection.
|
|
2587
|
+
* Buffers lines between ---RALPH_STATUS--- and ---END_RALPH_STATUS---
|
|
2588
|
+
* then parses the complete block.
|
|
2589
|
+
*
|
|
2590
|
+
* @param line - Single line to process (already trimmed)
|
|
2591
|
+
* @fires statusBlockDetected - When a complete block is parsed
|
|
2592
|
+
*/
|
|
2593
|
+
processStatusBlockLine(line) {
|
|
2594
|
+
// Check for block start
|
|
2595
|
+
if (RALPH_STATUS_START_PATTERN.test(line)) {
|
|
2596
|
+
this._inStatusBlock = true;
|
|
2597
|
+
this._statusBlockBuffer = [];
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
// Check for block end
|
|
2601
|
+
if (this._inStatusBlock && RALPH_STATUS_END_PATTERN.test(line)) {
|
|
2602
|
+
this._inStatusBlock = false;
|
|
2603
|
+
this.parseStatusBlock(this._statusBlockBuffer);
|
|
2604
|
+
this._statusBlockBuffer = [];
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
// Buffer lines while in block
|
|
2608
|
+
if (this._inStatusBlock) {
|
|
2609
|
+
this._statusBlockBuffer.push(line);
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
/**
|
|
2613
|
+
* Parse buffered RALPH_STATUS block lines into structured data.
|
|
2614
|
+
*
|
|
2615
|
+
* P1-004: Enhanced with schema validation and error recovery
|
|
2616
|
+
*
|
|
2617
|
+
* @param lines - Array of lines between block markers
|
|
2618
|
+
* @fires statusBlockDetected - When parsing succeeds
|
|
2619
|
+
*/
|
|
2620
|
+
parseStatusBlock(lines) {
|
|
2621
|
+
const block = {
|
|
2622
|
+
parsedAt: Date.now(),
|
|
2623
|
+
};
|
|
2624
|
+
const parseErrors = [];
|
|
2625
|
+
const unknownFields = [];
|
|
2626
|
+
for (const line of lines) {
|
|
2627
|
+
const trimmedLine = line.trim();
|
|
2628
|
+
if (!trimmedLine)
|
|
2629
|
+
continue;
|
|
2630
|
+
// Track whether this line matched any known field
|
|
2631
|
+
let matched = false;
|
|
2632
|
+
// STATUS field (required)
|
|
2633
|
+
const statusMatch = trimmedLine.match(RALPH_STATUS_FIELD_PATTERN);
|
|
2634
|
+
if (statusMatch) {
|
|
2635
|
+
const value = statusMatch[1].toUpperCase();
|
|
2636
|
+
if (['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(value)) {
|
|
2637
|
+
block.status = value;
|
|
2638
|
+
}
|
|
2639
|
+
else {
|
|
2640
|
+
parseErrors.push(`Invalid STATUS value: "${value}". Expected: IN_PROGRESS, COMPLETE, or BLOCKED`);
|
|
2641
|
+
}
|
|
2642
|
+
matched = true;
|
|
2643
|
+
}
|
|
2644
|
+
// TASKS_COMPLETED_THIS_LOOP field
|
|
2645
|
+
const tasksMatch = trimmedLine.match(RALPH_TASKS_COMPLETED_PATTERN);
|
|
2646
|
+
if (tasksMatch) {
|
|
2647
|
+
const value = parseInt(tasksMatch[1], 10);
|
|
2648
|
+
if (!Number.isNaN(value) && value >= 0) {
|
|
2649
|
+
block.tasksCompletedThisLoop = value;
|
|
2650
|
+
}
|
|
2651
|
+
else {
|
|
2652
|
+
parseErrors.push(`Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer`);
|
|
2653
|
+
}
|
|
2654
|
+
matched = true;
|
|
2655
|
+
}
|
|
2656
|
+
// FILES_MODIFIED field
|
|
2657
|
+
const filesMatch = trimmedLine.match(RALPH_FILES_MODIFIED_PATTERN);
|
|
2658
|
+
if (filesMatch) {
|
|
2659
|
+
const value = parseInt(filesMatch[1], 10);
|
|
2660
|
+
if (!Number.isNaN(value) && value >= 0) {
|
|
2661
|
+
block.filesModified = value;
|
|
2662
|
+
}
|
|
2663
|
+
else {
|
|
2664
|
+
parseErrors.push(`Invalid FILES_MODIFIED value: "${filesMatch[1]}". Expected: non-negative integer`);
|
|
2665
|
+
}
|
|
2666
|
+
matched = true;
|
|
2667
|
+
}
|
|
2668
|
+
// TESTS_STATUS field
|
|
2669
|
+
const testsMatch = trimmedLine.match(RALPH_TESTS_STATUS_PATTERN);
|
|
2670
|
+
if (testsMatch) {
|
|
2671
|
+
const value = testsMatch[1].toUpperCase();
|
|
2672
|
+
if (['PASSING', 'FAILING', 'NOT_RUN'].includes(value)) {
|
|
2673
|
+
block.testsStatus = value;
|
|
2674
|
+
}
|
|
2675
|
+
else {
|
|
2676
|
+
parseErrors.push(`Invalid TESTS_STATUS value: "${value}". Expected: PASSING, FAILING, or NOT_RUN`);
|
|
2677
|
+
}
|
|
2678
|
+
matched = true;
|
|
2679
|
+
}
|
|
2680
|
+
// WORK_TYPE field
|
|
2681
|
+
const workMatch = trimmedLine.match(RALPH_WORK_TYPE_PATTERN);
|
|
2682
|
+
if (workMatch) {
|
|
2683
|
+
const value = workMatch[1].toUpperCase();
|
|
2684
|
+
if (['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(value)) {
|
|
2685
|
+
block.workType = value;
|
|
2686
|
+
}
|
|
2687
|
+
else {
|
|
2688
|
+
parseErrors.push(`Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`);
|
|
2689
|
+
}
|
|
2690
|
+
matched = true;
|
|
2691
|
+
}
|
|
2692
|
+
// EXIT_SIGNAL field
|
|
2693
|
+
const exitMatch = trimmedLine.match(RALPH_EXIT_SIGNAL_PATTERN);
|
|
2694
|
+
if (exitMatch) {
|
|
2695
|
+
block.exitSignal = exitMatch[1].toLowerCase() === 'true';
|
|
2696
|
+
matched = true;
|
|
2697
|
+
}
|
|
2698
|
+
// RECOMMENDATION field
|
|
2699
|
+
const recMatch = trimmedLine.match(RALPH_RECOMMENDATION_PATTERN);
|
|
2700
|
+
if (recMatch) {
|
|
2701
|
+
block.recommendation = recMatch[1].trim();
|
|
2702
|
+
matched = true;
|
|
2703
|
+
}
|
|
2704
|
+
// Track unknown fields for debugging (only if looks like a field)
|
|
2705
|
+
if (!matched && trimmedLine.includes(':')) {
|
|
2706
|
+
const fieldName = trimmedLine.split(':')[0].trim().toUpperCase();
|
|
2707
|
+
if (fieldName && !['#', '//'].some((c) => fieldName.startsWith(c))) {
|
|
2708
|
+
unknownFields.push(fieldName);
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
// Log parse errors if any
|
|
2713
|
+
if (parseErrors.length > 0) {
|
|
2714
|
+
console.warn(`[RalphTracker] RALPH_STATUS parse errors:\n - ${parseErrors.join('\n - ')}`);
|
|
2715
|
+
}
|
|
2716
|
+
// Log unknown fields if any
|
|
2717
|
+
if (unknownFields.length > 0) {
|
|
2718
|
+
console.warn(`[RalphTracker] RALPH_STATUS unknown fields: ${unknownFields.join(', ')}`);
|
|
2719
|
+
}
|
|
2720
|
+
// Validate required field: STATUS
|
|
2721
|
+
if (block.status === undefined) {
|
|
2722
|
+
console.warn('[RalphTracker] RALPH_STATUS block missing required STATUS field, skipping');
|
|
2723
|
+
return;
|
|
2724
|
+
}
|
|
2725
|
+
// Fill in defaults for missing optional fields
|
|
2726
|
+
const fullBlock = {
|
|
2727
|
+
status: block.status,
|
|
2728
|
+
tasksCompletedThisLoop: block.tasksCompletedThisLoop ?? 0,
|
|
2729
|
+
filesModified: block.filesModified ?? 0,
|
|
2730
|
+
testsStatus: block.testsStatus ?? 'NOT_RUN',
|
|
2731
|
+
workType: block.workType ?? 'IMPLEMENTATION',
|
|
2732
|
+
exitSignal: block.exitSignal ?? false,
|
|
2733
|
+
recommendation: block.recommendation ?? '',
|
|
2734
|
+
parsedAt: block.parsedAt,
|
|
2735
|
+
};
|
|
2736
|
+
this._lastStatusBlock = fullBlock;
|
|
2737
|
+
this.handleStatusBlock(fullBlock);
|
|
2738
|
+
}
|
|
2739
|
+
/**
|
|
2740
|
+
* Handle a parsed RALPH_STATUS block.
|
|
2741
|
+
* Updates circuit breaker, checks exit conditions.
|
|
2742
|
+
*
|
|
2743
|
+
* @param block - Parsed status block
|
|
2744
|
+
* @fires statusBlockDetected - With the block data
|
|
2745
|
+
* @fires circuitBreakerUpdate - If state changes
|
|
2746
|
+
* @fires exitGateMet - If dual-condition exit triggered
|
|
2747
|
+
*/
|
|
2748
|
+
handleStatusBlock(block) {
|
|
2749
|
+
// Auto-enable tracker when we see a status block
|
|
2750
|
+
if (!this._loopState.enabled && !this._autoEnableDisabled) {
|
|
2751
|
+
this.enable();
|
|
2752
|
+
}
|
|
2753
|
+
// Update cumulative counts
|
|
2754
|
+
this._totalFilesModified += block.filesModified;
|
|
2755
|
+
this._totalTasksCompleted += block.tasksCompletedThisLoop;
|
|
2756
|
+
// Check for progress (for circuit breaker)
|
|
2757
|
+
const hasProgress = block.filesModified > 0 || block.tasksCompletedThisLoop > 0;
|
|
2758
|
+
// Update circuit breaker
|
|
2759
|
+
this.updateCircuitBreaker(hasProgress, block.testsStatus, block.status);
|
|
2760
|
+
// Check completion indicators
|
|
2761
|
+
if (block.status === 'COMPLETE') {
|
|
2762
|
+
this._completionIndicators++;
|
|
2763
|
+
}
|
|
2764
|
+
// Check dual-condition exit gate
|
|
2765
|
+
if (block.exitSignal && this._completionIndicators >= 2 && !this._exitGateMet) {
|
|
2766
|
+
this._exitGateMet = true;
|
|
2767
|
+
this.emit('exitGateMet', {
|
|
2768
|
+
completionIndicators: this._completionIndicators,
|
|
2769
|
+
exitSignal: true,
|
|
2770
|
+
});
|
|
2771
|
+
}
|
|
2772
|
+
// Update loop state
|
|
2773
|
+
this._loopState.lastActivity = Date.now();
|
|
2774
|
+
// Emit the status block
|
|
2775
|
+
this.emit('statusBlockDetected', block);
|
|
2776
|
+
this.emitLoopUpdateDebounced();
|
|
2777
|
+
}
|
|
2778
|
+
// ========== Circuit Breaker ==========
|
|
2779
|
+
/**
|
|
2780
|
+
* Update circuit breaker state based on iteration results.
|
|
2781
|
+
*
|
|
2782
|
+
* @param hasProgress - Whether this iteration made progress
|
|
2783
|
+
* @param testsStatus - Current test status
|
|
2784
|
+
* @param status - Overall status from RALPH_STATUS
|
|
2785
|
+
* @fires circuitBreakerUpdate - If state changes
|
|
2786
|
+
*/
|
|
2787
|
+
updateCircuitBreaker(hasProgress, testsStatus, status) {
|
|
2788
|
+
const prevState = this._circuitBreaker.state;
|
|
2789
|
+
if (hasProgress) {
|
|
2790
|
+
// Progress detected - reset counters, possibly close circuit
|
|
2791
|
+
this._circuitBreaker.consecutiveNoProgress = 0;
|
|
2792
|
+
this._circuitBreaker.consecutiveSameError = 0;
|
|
2793
|
+
this._circuitBreaker.lastProgressIteration = this._loopState.cycleCount;
|
|
2794
|
+
if (this._circuitBreaker.state === 'HALF_OPEN') {
|
|
2795
|
+
this._circuitBreaker.state = 'CLOSED';
|
|
2796
|
+
this._circuitBreaker.reason = 'Progress detected, circuit closed';
|
|
2797
|
+
this._circuitBreaker.reasonCode = 'progress_detected';
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
else {
|
|
2801
|
+
// No progress
|
|
2802
|
+
this._circuitBreaker.consecutiveNoProgress++;
|
|
2803
|
+
// State transitions based on consecutive no-progress
|
|
2804
|
+
if (this._circuitBreaker.state === 'CLOSED') {
|
|
2805
|
+
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
|
|
2806
|
+
this._circuitBreaker.state = 'OPEN';
|
|
2807
|
+
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
|
|
2808
|
+
this._circuitBreaker.reasonCode = 'no_progress_open';
|
|
2809
|
+
}
|
|
2810
|
+
else if (this._circuitBreaker.consecutiveNoProgress >= 2) {
|
|
2811
|
+
this._circuitBreaker.state = 'HALF_OPEN';
|
|
2812
|
+
this._circuitBreaker.reason = 'Warning: no progress detected';
|
|
2813
|
+
this._circuitBreaker.reasonCode = 'no_progress_warning';
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
else if (this._circuitBreaker.state === 'HALF_OPEN') {
|
|
2817
|
+
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
|
|
2818
|
+
this._circuitBreaker.state = 'OPEN';
|
|
2819
|
+
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
|
|
2820
|
+
this._circuitBreaker.reasonCode = 'no_progress_open';
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
// Track tests failure
|
|
2825
|
+
if (testsStatus === 'FAILING') {
|
|
2826
|
+
this._circuitBreaker.consecutiveTestsFailure++;
|
|
2827
|
+
if (this._circuitBreaker.consecutiveTestsFailure >= 5 && this._circuitBreaker.state !== 'OPEN') {
|
|
2828
|
+
this._circuitBreaker.state = 'OPEN';
|
|
2829
|
+
this._circuitBreaker.reason = `Tests failing for ${this._circuitBreaker.consecutiveTestsFailure} iterations`;
|
|
2830
|
+
this._circuitBreaker.reasonCode = 'tests_failing_too_long';
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
else {
|
|
2834
|
+
this._circuitBreaker.consecutiveTestsFailure = 0;
|
|
2835
|
+
}
|
|
2836
|
+
// Track blocked status
|
|
2837
|
+
if (status === 'BLOCKED' && this._circuitBreaker.state !== 'OPEN') {
|
|
2838
|
+
this._circuitBreaker.state = 'OPEN';
|
|
2839
|
+
this._circuitBreaker.reason = 'Claude reported BLOCKED status';
|
|
2840
|
+
this._circuitBreaker.reasonCode = 'same_error_repeated';
|
|
2841
|
+
}
|
|
2842
|
+
// Emit if state changed
|
|
2843
|
+
if (prevState !== this._circuitBreaker.state) {
|
|
2844
|
+
this._circuitBreaker.lastTransitionAt = Date.now();
|
|
2845
|
+
this.emit('circuitBreakerUpdate', { ...this._circuitBreaker });
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
/**
|
|
2849
|
+
* Manually reset circuit breaker to CLOSED state.
|
|
2850
|
+
* Use when user acknowledges the issue is resolved.
|
|
2851
|
+
*
|
|
2852
|
+
* @fires circuitBreakerUpdate
|
|
2853
|
+
*/
|
|
2854
|
+
resetCircuitBreaker() {
|
|
2855
|
+
this._circuitBreaker = createInitialCircuitBreakerStatus();
|
|
2856
|
+
this._circuitBreaker.reason = 'Manual reset';
|
|
2857
|
+
this._circuitBreaker.reasonCode = 'manual_reset';
|
|
2858
|
+
this.emit('circuitBreakerUpdate', { ...this._circuitBreaker });
|
|
2859
|
+
}
|
|
2860
|
+
/**
|
|
2861
|
+
* Get current circuit breaker status.
|
|
2862
|
+
*/
|
|
2863
|
+
get circuitBreakerStatus() {
|
|
2864
|
+
return { ...this._circuitBreaker };
|
|
2865
|
+
}
|
|
2866
|
+
/**
|
|
2867
|
+
* Get last parsed RALPH_STATUS block.
|
|
2868
|
+
*/
|
|
2869
|
+
get lastStatusBlock() {
|
|
2870
|
+
return this._lastStatusBlock ? { ...this._lastStatusBlock } : null;
|
|
2871
|
+
}
|
|
2872
|
+
/**
|
|
2873
|
+
* Get cumulative stats from status blocks.
|
|
2874
|
+
*/
|
|
2875
|
+
get cumulativeStats() {
|
|
2876
|
+
return {
|
|
2877
|
+
filesModified: this._totalFilesModified,
|
|
2878
|
+
tasksCompleted: this._totalTasksCompleted,
|
|
2879
|
+
completionIndicators: this._completionIndicators,
|
|
2880
|
+
};
|
|
2881
|
+
}
|
|
2882
|
+
/**
|
|
2883
|
+
* Whether dual-condition exit gate has been met.
|
|
2884
|
+
*/
|
|
2885
|
+
get exitGateMet() {
|
|
2886
|
+
return this._exitGateMet;
|
|
2887
|
+
}
|
|
2888
|
+
// ========== Completion Indicator Detection ==========
|
|
2889
|
+
/**
|
|
2890
|
+
* Check line for completion indicators (natural language patterns).
|
|
2891
|
+
* Used for dual-condition exit gate.
|
|
2892
|
+
*
|
|
2893
|
+
* @param line - Line to check
|
|
2894
|
+
*/
|
|
2895
|
+
detectCompletionIndicators(line) {
|
|
2896
|
+
for (const pattern of COMPLETION_INDICATOR_PATTERNS) {
|
|
2897
|
+
if (pattern.test(line)) {
|
|
2898
|
+
this._completionIndicators++;
|
|
2899
|
+
break; // Only count once per line
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
// ========== @fix_plan.md Generation & Import ==========
|
|
2904
|
+
/**
|
|
2905
|
+
* Generate @fix_plan.md content from current todos.
|
|
2906
|
+
* Groups todos by priority and status.
|
|
2907
|
+
*
|
|
2908
|
+
* @returns Markdown content for @fix_plan.md
|
|
2909
|
+
*/
|
|
2910
|
+
generateFixPlanMarkdown() {
|
|
2911
|
+
const todos = this.todos;
|
|
2912
|
+
const lines = ['# Fix Plan', ''];
|
|
2913
|
+
// Group by priority
|
|
2914
|
+
const p0 = [];
|
|
2915
|
+
const p1 = [];
|
|
2916
|
+
const p2 = [];
|
|
2917
|
+
const noPriority = [];
|
|
2918
|
+
const completed = [];
|
|
2919
|
+
for (const todo of todos) {
|
|
2920
|
+
if (todo.status === 'completed') {
|
|
2921
|
+
completed.push(todo);
|
|
2922
|
+
}
|
|
2923
|
+
else if (todo.priority === 'P0') {
|
|
2924
|
+
p0.push(todo);
|
|
2925
|
+
}
|
|
2926
|
+
else if (todo.priority === 'P1') {
|
|
2927
|
+
p1.push(todo);
|
|
2928
|
+
}
|
|
2929
|
+
else if (todo.priority === 'P2') {
|
|
2930
|
+
p2.push(todo);
|
|
2931
|
+
}
|
|
2932
|
+
else {
|
|
2933
|
+
noPriority.push(todo);
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
// High Priority (P0)
|
|
2937
|
+
if (p0.length > 0) {
|
|
2938
|
+
lines.push('## High Priority (P0)');
|
|
2939
|
+
for (const todo of p0) {
|
|
2940
|
+
const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]';
|
|
2941
|
+
lines.push(`- ${checkbox} ${todo.content}`);
|
|
2942
|
+
}
|
|
2943
|
+
lines.push('');
|
|
2944
|
+
}
|
|
2945
|
+
// Standard (P1)
|
|
2946
|
+
if (p1.length > 0) {
|
|
2947
|
+
lines.push('## Standard (P1)');
|
|
2948
|
+
for (const todo of p1) {
|
|
2949
|
+
const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]';
|
|
2950
|
+
lines.push(`- ${checkbox} ${todo.content}`);
|
|
2951
|
+
}
|
|
2952
|
+
lines.push('');
|
|
2953
|
+
}
|
|
2954
|
+
// Nice to Have (P2)
|
|
2955
|
+
if (p2.length > 0) {
|
|
2956
|
+
lines.push('## Nice to Have (P2)');
|
|
2957
|
+
for (const todo of p2) {
|
|
2958
|
+
const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]';
|
|
2959
|
+
lines.push(`- ${checkbox} ${todo.content}`);
|
|
2960
|
+
}
|
|
2961
|
+
lines.push('');
|
|
2962
|
+
}
|
|
2963
|
+
// Tasks (no priority)
|
|
2964
|
+
if (noPriority.length > 0) {
|
|
2965
|
+
lines.push('## Tasks');
|
|
2966
|
+
for (const todo of noPriority) {
|
|
2967
|
+
const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]';
|
|
2968
|
+
lines.push(`- ${checkbox} ${todo.content}`);
|
|
2969
|
+
}
|
|
2970
|
+
lines.push('');
|
|
2971
|
+
}
|
|
2972
|
+
// Completed
|
|
2973
|
+
if (completed.length > 0) {
|
|
2974
|
+
lines.push('## Completed');
|
|
2975
|
+
for (const todo of completed) {
|
|
2976
|
+
lines.push(`- [x] ${todo.content}`);
|
|
2977
|
+
}
|
|
2978
|
+
lines.push('');
|
|
2979
|
+
}
|
|
2980
|
+
return lines.join('\n');
|
|
2981
|
+
}
|
|
2982
|
+
/**
|
|
2983
|
+
* Parse @fix_plan.md content and import todos.
|
|
2984
|
+
* Replaces current todos with imported ones.
|
|
2985
|
+
*
|
|
2986
|
+
* @param content - Markdown content from @fix_plan.md
|
|
2987
|
+
* @returns Number of todos imported
|
|
2988
|
+
*/
|
|
2989
|
+
importFixPlanMarkdown(content) {
|
|
2990
|
+
const lines = content.split('\n');
|
|
2991
|
+
const newTodos = [];
|
|
2992
|
+
let currentPriority = null;
|
|
2993
|
+
// Patterns for section headers
|
|
2994
|
+
const p0HeaderPattern = /^##\s*(High Priority|Critical|P0)/i;
|
|
2995
|
+
const p1HeaderPattern = /^##\s*(Standard|P1|Medium Priority)/i;
|
|
2996
|
+
const p2HeaderPattern = /^##\s*(Nice to Have|P2|Low Priority)/i;
|
|
2997
|
+
const completedHeaderPattern = /^##\s*Completed/i;
|
|
2998
|
+
const tasksHeaderPattern = /^##\s*Tasks/i;
|
|
2999
|
+
// Pattern for todo items
|
|
3000
|
+
const todoPattern = /^-\s*\[([ x-])\]\s*(.+)$/;
|
|
3001
|
+
let inCompletedSection = false;
|
|
3002
|
+
for (const line of lines) {
|
|
3003
|
+
const trimmed = line.trim();
|
|
3004
|
+
// Check for section headers
|
|
3005
|
+
if (p0HeaderPattern.test(trimmed)) {
|
|
3006
|
+
currentPriority = 'P0';
|
|
3007
|
+
inCompletedSection = false;
|
|
3008
|
+
continue;
|
|
3009
|
+
}
|
|
3010
|
+
if (p1HeaderPattern.test(trimmed)) {
|
|
3011
|
+
currentPriority = 'P1';
|
|
3012
|
+
inCompletedSection = false;
|
|
3013
|
+
continue;
|
|
3014
|
+
}
|
|
3015
|
+
if (p2HeaderPattern.test(trimmed)) {
|
|
3016
|
+
currentPriority = 'P2';
|
|
3017
|
+
inCompletedSection = false;
|
|
3018
|
+
continue;
|
|
3019
|
+
}
|
|
3020
|
+
if (completedHeaderPattern.test(trimmed)) {
|
|
3021
|
+
inCompletedSection = true;
|
|
3022
|
+
continue;
|
|
3023
|
+
}
|
|
3024
|
+
if (tasksHeaderPattern.test(trimmed)) {
|
|
3025
|
+
currentPriority = null;
|
|
3026
|
+
inCompletedSection = false;
|
|
3027
|
+
continue;
|
|
3028
|
+
}
|
|
3029
|
+
// Parse todo item
|
|
3030
|
+
const match = trimmed.match(todoPattern);
|
|
3031
|
+
if (match) {
|
|
3032
|
+
const [, checkboxState, content] = match;
|
|
3033
|
+
let status;
|
|
3034
|
+
if (inCompletedSection || checkboxState === 'x' || checkboxState === 'X') {
|
|
3035
|
+
status = 'completed';
|
|
3036
|
+
}
|
|
3037
|
+
else if (checkboxState === '-') {
|
|
3038
|
+
status = 'in_progress';
|
|
3039
|
+
}
|
|
3040
|
+
else {
|
|
3041
|
+
status = 'pending';
|
|
3042
|
+
}
|
|
3043
|
+
// Parse priority from content if not in a priority section
|
|
3044
|
+
const parsedPriority = inCompletedSection ? null : currentPriority || this.parsePriority(content);
|
|
3045
|
+
const id = this.generateTodoId(content);
|
|
3046
|
+
newTodos.push({
|
|
3047
|
+
id,
|
|
3048
|
+
content: content.trim(),
|
|
3049
|
+
status,
|
|
3050
|
+
detectedAt: Date.now(),
|
|
3051
|
+
priority: parsedPriority,
|
|
3052
|
+
});
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
// Replace current todos with imported ones
|
|
3056
|
+
this._todos.clear();
|
|
3057
|
+
for (const todo of newTodos) {
|
|
3058
|
+
this._todos.set(todo.id, todo);
|
|
3059
|
+
}
|
|
3060
|
+
// Emit update
|
|
3061
|
+
this.emit('todoUpdate', this.todos);
|
|
3062
|
+
return newTodos.length;
|
|
3063
|
+
}
|
|
3064
|
+
// ========== Enhanced Plan Management Methods ==========
|
|
3065
|
+
/**
|
|
3066
|
+
* Initialize plan tasks from generated plan items.
|
|
3067
|
+
* Called when wizard generates a new plan.
|
|
3068
|
+
*/
|
|
3069
|
+
initializePlanTasks(items) {
|
|
3070
|
+
// Save current plan to history before replacing
|
|
3071
|
+
if (this._planTasks.size > 0) {
|
|
3072
|
+
this._savePlanToHistory('Plan replaced with new generation');
|
|
3073
|
+
}
|
|
3074
|
+
// Clear and rebuild
|
|
3075
|
+
this._planTasks.clear();
|
|
3076
|
+
this._planVersion++;
|
|
3077
|
+
items.forEach((item, idx) => {
|
|
3078
|
+
const id = item.id || `task-${idx}`;
|
|
3079
|
+
const task = {
|
|
3080
|
+
id,
|
|
3081
|
+
content: item.content,
|
|
3082
|
+
priority: item.priority || null,
|
|
3083
|
+
verificationCriteria: item.verificationCriteria,
|
|
3084
|
+
testCommand: item.testCommand,
|
|
3085
|
+
dependencies: item.dependencies || [],
|
|
3086
|
+
status: 'pending',
|
|
3087
|
+
attempts: 0,
|
|
3088
|
+
version: this._planVersion,
|
|
3089
|
+
tddPhase: item.tddPhase,
|
|
3090
|
+
pairedWith: item.pairedWith,
|
|
3091
|
+
complexity: item.complexity,
|
|
3092
|
+
};
|
|
3093
|
+
this._planTasks.set(id, task);
|
|
3094
|
+
});
|
|
3095
|
+
this.emit('planInitialized', { version: this._planVersion, taskCount: this._planTasks.size });
|
|
3096
|
+
}
|
|
3097
|
+
/**
|
|
3098
|
+
* Update a specific plan task's status, attempts, or error.
|
|
3099
|
+
*/
|
|
3100
|
+
updatePlanTask(taskId, update) {
|
|
3101
|
+
const task = this._planTasks.get(taskId);
|
|
3102
|
+
if (!task) {
|
|
3103
|
+
return { success: false, error: 'Task not found' };
|
|
3104
|
+
}
|
|
3105
|
+
if (update.status) {
|
|
3106
|
+
task.status = update.status;
|
|
3107
|
+
if (update.status === 'completed') {
|
|
3108
|
+
task.completedAt = Date.now();
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
if (update.error) {
|
|
3112
|
+
task.lastError = update.error;
|
|
3113
|
+
}
|
|
3114
|
+
if (update.incrementAttempts) {
|
|
3115
|
+
task.attempts++;
|
|
3116
|
+
// After 3 failed attempts, mark as blocked and emit warning
|
|
3117
|
+
if (task.attempts >= 3 && task.status === 'failed') {
|
|
3118
|
+
task.status = 'blocked';
|
|
3119
|
+
this.emit('taskBlocked', {
|
|
3120
|
+
taskId,
|
|
3121
|
+
content: task.content,
|
|
3122
|
+
attempts: task.attempts,
|
|
3123
|
+
lastError: task.lastError,
|
|
3124
|
+
});
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
// Update blocked tasks when a dependency completes
|
|
3128
|
+
if (update.status === 'completed') {
|
|
3129
|
+
this._unblockDependentTasks(taskId);
|
|
3130
|
+
}
|
|
3131
|
+
// Check for checkpoint
|
|
3132
|
+
this._checkForCheckpoint();
|
|
3133
|
+
this.emit('planTaskUpdate', { taskId, task });
|
|
3134
|
+
return { success: true, task };
|
|
3135
|
+
}
|
|
3136
|
+
/**
|
|
3137
|
+
* Unblock tasks that were waiting on a completed dependency.
|
|
3138
|
+
*/
|
|
3139
|
+
_unblockDependentTasks(completedTaskId) {
|
|
3140
|
+
for (const [_, task] of this._planTasks) {
|
|
3141
|
+
if (task.dependencies.includes(completedTaskId)) {
|
|
3142
|
+
// Check if all dependencies are now complete
|
|
3143
|
+
const allDepsComplete = task.dependencies.every((depId) => {
|
|
3144
|
+
const dep = this._planTasks.get(depId);
|
|
3145
|
+
return dep && dep.status === 'completed';
|
|
3146
|
+
});
|
|
3147
|
+
if (allDepsComplete && task.status === 'blocked') {
|
|
3148
|
+
task.status = 'pending';
|
|
3149
|
+
this.emit('taskUnblocked', { taskId: task.id });
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
/**
|
|
3155
|
+
* Check if current iteration is a checkpoint and emit review if so.
|
|
3156
|
+
*/
|
|
3157
|
+
_checkForCheckpoint() {
|
|
3158
|
+
const currentIteration = this._loopState.cycleCount;
|
|
3159
|
+
if (this._checkpointIterations.includes(currentIteration) && currentIteration > this._lastCheckpointIteration) {
|
|
3160
|
+
this._lastCheckpointIteration = currentIteration;
|
|
3161
|
+
const checkpoint = this.generateCheckpointReview();
|
|
3162
|
+
this.emit('planCheckpoint', checkpoint);
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
/**
|
|
3166
|
+
* Generate a checkpoint review summarizing plan progress and stuck tasks.
|
|
3167
|
+
*/
|
|
3168
|
+
generateCheckpointReview() {
|
|
3169
|
+
const tasks = Array.from(this._planTasks.values());
|
|
3170
|
+
const summary = {
|
|
3171
|
+
total: tasks.length,
|
|
3172
|
+
completed: tasks.filter((t) => t.status === 'completed').length,
|
|
3173
|
+
failed: tasks.filter((t) => t.status === 'failed').length,
|
|
3174
|
+
blocked: tasks.filter((t) => t.status === 'blocked').length,
|
|
3175
|
+
pending: tasks.filter((t) => t.status === 'pending').length,
|
|
3176
|
+
inProgress: tasks.filter((t) => t.status === 'in_progress').length,
|
|
3177
|
+
};
|
|
3178
|
+
// Find stuck tasks (3+ attempts or blocked)
|
|
3179
|
+
const stuckTasks = tasks
|
|
3180
|
+
.filter((t) => t.attempts >= 3 || t.status === 'blocked')
|
|
3181
|
+
.map((t) => ({
|
|
3182
|
+
id: t.id,
|
|
3183
|
+
content: t.content,
|
|
3184
|
+
attempts: t.attempts,
|
|
3185
|
+
lastError: t.lastError,
|
|
3186
|
+
}));
|
|
3187
|
+
// Generate recommendations
|
|
3188
|
+
const recommendations = [];
|
|
3189
|
+
if (stuckTasks.length > 0) {
|
|
3190
|
+
recommendations.push(`${stuckTasks.length} task(s) are stuck. Consider breaking them into smaller steps.`);
|
|
3191
|
+
}
|
|
3192
|
+
if (summary.failed > summary.completed && summary.total > 5) {
|
|
3193
|
+
recommendations.push('More tasks have failed than completed. Review approach and consider plan adjustment.');
|
|
3194
|
+
}
|
|
3195
|
+
const progressPercent = summary.total > 0 ? Math.round((summary.completed / summary.total) * 100) : 0;
|
|
3196
|
+
if (progressPercent < 20 && this._loopState.cycleCount > 10) {
|
|
3197
|
+
recommendations.push('Progress is slow. Consider simplifying tasks or reviewing dependencies.');
|
|
3198
|
+
}
|
|
3199
|
+
if (summary.total > 0 && summary.blocked > summary.total / 3) {
|
|
3200
|
+
recommendations.push('Many tasks are blocked. Review dependency chain for bottlenecks.');
|
|
3201
|
+
}
|
|
3202
|
+
return {
|
|
3203
|
+
iteration: this._loopState.cycleCount,
|
|
3204
|
+
timestamp: Date.now(),
|
|
3205
|
+
summary,
|
|
3206
|
+
stuckTasks,
|
|
3207
|
+
recommendations,
|
|
3208
|
+
};
|
|
3209
|
+
}
|
|
3210
|
+
/**
|
|
3211
|
+
* Save current plan state to history.
|
|
3212
|
+
*/
|
|
3213
|
+
_savePlanToHistory(summary) {
|
|
3214
|
+
// Clone current tasks
|
|
3215
|
+
const tasksCopy = new Map();
|
|
3216
|
+
for (const [id, task] of this._planTasks) {
|
|
3217
|
+
tasksCopy.set(id, { ...task });
|
|
3218
|
+
}
|
|
3219
|
+
this._planHistory.push({
|
|
3220
|
+
version: this._planVersion,
|
|
3221
|
+
timestamp: Date.now(),
|
|
3222
|
+
tasks: tasksCopy,
|
|
3223
|
+
summary,
|
|
3224
|
+
});
|
|
3225
|
+
// Limit history size
|
|
3226
|
+
if (this._planHistory.length > MAX_PLAN_HISTORY) {
|
|
3227
|
+
this._planHistory.shift();
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
/**
|
|
3231
|
+
* Get plan version history.
|
|
3232
|
+
*/
|
|
3233
|
+
getPlanHistory() {
|
|
3234
|
+
return this._planHistory.map((h) => {
|
|
3235
|
+
const tasks = Array.from(h.tasks.values());
|
|
3236
|
+
return {
|
|
3237
|
+
version: h.version,
|
|
3238
|
+
timestamp: h.timestamp,
|
|
3239
|
+
summary: h.summary,
|
|
3240
|
+
stats: {
|
|
3241
|
+
total: tasks.length,
|
|
3242
|
+
completed: tasks.filter((t) => t.status === 'completed').length,
|
|
3243
|
+
failed: tasks.filter((t) => t.status === 'failed').length,
|
|
3244
|
+
},
|
|
3245
|
+
};
|
|
3246
|
+
});
|
|
3247
|
+
}
|
|
3248
|
+
/**
|
|
3249
|
+
* Rollback to a previous plan version.
|
|
3250
|
+
*/
|
|
3251
|
+
rollbackToVersion(version) {
|
|
3252
|
+
const historyEntry = this._planHistory.find((h) => h.version === version);
|
|
3253
|
+
if (!historyEntry) {
|
|
3254
|
+
return { success: false, error: `Version ${version} not found in history` };
|
|
3255
|
+
}
|
|
3256
|
+
// Save current state first
|
|
3257
|
+
this._savePlanToHistory(`Rolled back from v${this._planVersion} to v${version}`);
|
|
3258
|
+
// Restore the historical version
|
|
3259
|
+
this._planTasks.clear();
|
|
3260
|
+
for (const [id, task] of historyEntry.tasks) {
|
|
3261
|
+
// Reset execution state for retry
|
|
3262
|
+
this._planTasks.set(id, {
|
|
3263
|
+
...task,
|
|
3264
|
+
status: task.status === 'completed' ? 'completed' : 'pending',
|
|
3265
|
+
attempts: task.status === 'completed' ? task.attempts : 0,
|
|
3266
|
+
lastError: undefined,
|
|
3267
|
+
});
|
|
3268
|
+
}
|
|
3269
|
+
this._planVersion++;
|
|
3270
|
+
this.emit('planRollback', { version, newVersion: this._planVersion });
|
|
3271
|
+
return { success: true, plan: Array.from(this._planTasks.values()) };
|
|
3272
|
+
}
|
|
3273
|
+
/**
|
|
3274
|
+
* Add a new task to the plan (for runtime adaptation).
|
|
3275
|
+
*/
|
|
3276
|
+
addPlanTask(task) {
|
|
3277
|
+
// Generate unique ID
|
|
3278
|
+
const existingIds = Array.from(this._planTasks.keys());
|
|
3279
|
+
const prefix = task.priority || 'P1';
|
|
3280
|
+
let counter = existingIds.filter((id) => id.startsWith(prefix)).length + 1;
|
|
3281
|
+
let id = `${prefix}-${String(counter).padStart(3, '0')}`;
|
|
3282
|
+
while (this._planTasks.has(id)) {
|
|
3283
|
+
counter++;
|
|
3284
|
+
id = `${prefix}-${String(counter).padStart(3, '0')}`;
|
|
3285
|
+
}
|
|
3286
|
+
const newTask = {
|
|
3287
|
+
id,
|
|
3288
|
+
content: task.content,
|
|
3289
|
+
priority: task.priority || null,
|
|
3290
|
+
verificationCriteria: task.verificationCriteria || 'Task completed successfully',
|
|
3291
|
+
dependencies: task.dependencies || [],
|
|
3292
|
+
status: 'pending',
|
|
3293
|
+
attempts: 0,
|
|
3294
|
+
version: this._planVersion,
|
|
3295
|
+
};
|
|
3296
|
+
this._planTasks.set(id, newTask);
|
|
3297
|
+
this.emit('planTaskAdded', { task: newTask });
|
|
3298
|
+
return { task: newTask };
|
|
3299
|
+
}
|
|
3300
|
+
/**
|
|
3301
|
+
* Get all plan tasks.
|
|
3302
|
+
*/
|
|
3303
|
+
getPlanTasks() {
|
|
3304
|
+
return Array.from(this._planTasks.values());
|
|
3305
|
+
}
|
|
3306
|
+
/**
|
|
3307
|
+
* Get current plan version.
|
|
3308
|
+
*/
|
|
3309
|
+
get planVersion() {
|
|
3310
|
+
return this._planVersion;
|
|
3311
|
+
}
|
|
3312
|
+
/**
|
|
3313
|
+
* Check if checkpoint review is due for current iteration.
|
|
3314
|
+
*/
|
|
3315
|
+
isCheckpointDue() {
|
|
3316
|
+
const currentIteration = this._loopState.cycleCount;
|
|
3317
|
+
return this._checkpointIterations.includes(currentIteration) && currentIteration > this._lastCheckpointIteration;
|
|
3318
|
+
}
|
|
3319
|
+
/**
|
|
3320
|
+
* Clean up all resources and release memory.
|
|
3321
|
+
*
|
|
3322
|
+
* Call this when the session is being destroyed to prevent memory leaks.
|
|
3323
|
+
* Stops file watchers, clears all timers, data, and removes event listeners.
|
|
3324
|
+
*/
|
|
3325
|
+
destroy() {
|
|
3326
|
+
this.clearDebounceTimers();
|
|
3327
|
+
this.stopWatchingFixPlan();
|
|
3328
|
+
this.stopIterationStallDetection();
|
|
3329
|
+
this._todos.clear();
|
|
3330
|
+
this._taskNumberToContent.clear();
|
|
3331
|
+
this._todoStartTimes.clear();
|
|
3332
|
+
this._alternateCompletionPhrases.clear();
|
|
3333
|
+
this._completionPhraseCount.clear();
|
|
3334
|
+
this._planTasks.clear();
|
|
3335
|
+
this._completionTimes.length = 0;
|
|
3336
|
+
this._lineBuffer = '';
|
|
3337
|
+
this._partialPromiseBuffer = '';
|
|
3338
|
+
this._statusBlockBuffer.length = 0;
|
|
3339
|
+
this._planHistory.length = 0;
|
|
3340
|
+
this.removeAllListeners();
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
//# sourceMappingURL=ralph-tracker.js.map
|