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.
Files changed (246) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +403 -0
  3. package/dist/ai-checker-base.d.ts +175 -0
  4. package/dist/ai-checker-base.d.ts.map +1 -0
  5. package/dist/ai-checker-base.js +424 -0
  6. package/dist/ai-checker-base.js.map +1 -0
  7. package/dist/ai-idle-checker.d.ts +53 -0
  8. package/dist/ai-idle-checker.d.ts.map +1 -0
  9. package/dist/ai-idle-checker.js +141 -0
  10. package/dist/ai-idle-checker.js.map +1 -0
  11. package/dist/ai-plan-checker.d.ts +52 -0
  12. package/dist/ai-plan-checker.d.ts.map +1 -0
  13. package/dist/ai-plan-checker.js +103 -0
  14. package/dist/ai-plan-checker.js.map +1 -0
  15. package/dist/bash-tool-parser.d.ts +191 -0
  16. package/dist/bash-tool-parser.d.ts.map +1 -0
  17. package/dist/bash-tool-parser.js +598 -0
  18. package/dist/bash-tool-parser.js.map +1 -0
  19. package/dist/cli.d.ts +12 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +460 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/config/buffer-limits.d.ts +59 -0
  24. package/dist/config/buffer-limits.d.ts.map +1 -0
  25. package/dist/config/buffer-limits.js +74 -0
  26. package/dist/config/buffer-limits.js.map +1 -0
  27. package/dist/config/map-limits.d.ts +40 -0
  28. package/dist/config/map-limits.d.ts.map +1 -0
  29. package/dist/config/map-limits.js +52 -0
  30. package/dist/config/map-limits.js.map +1 -0
  31. package/dist/file-stream-manager.d.ts +148 -0
  32. package/dist/file-stream-manager.d.ts.map +1 -0
  33. package/dist/file-stream-manager.js +351 -0
  34. package/dist/file-stream-manager.js.map +1 -0
  35. package/dist/hooks-config.d.ts +31 -0
  36. package/dist/hooks-config.d.ts.map +1 -0
  37. package/dist/hooks-config.js +115 -0
  38. package/dist/hooks-config.js.map +1 -0
  39. package/dist/image-watcher.d.ts +86 -0
  40. package/dist/image-watcher.d.ts.map +1 -0
  41. package/dist/image-watcher.js +275 -0
  42. package/dist/image-watcher.js.map +1 -0
  43. package/dist/index.d.ts +11 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +54 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/mux-factory.d.ts +13 -0
  48. package/dist/mux-factory.d.ts.map +1 -0
  49. package/dist/mux-factory.js +19 -0
  50. package/dist/mux-factory.js.map +1 -0
  51. package/dist/mux-interface.d.ts +145 -0
  52. package/dist/mux-interface.d.ts.map +1 -0
  53. package/dist/mux-interface.js +9 -0
  54. package/dist/mux-interface.js.map +1 -0
  55. package/dist/plan-orchestrator.d.ts +123 -0
  56. package/dist/plan-orchestrator.d.ts.map +1 -0
  57. package/dist/plan-orchestrator.js +500 -0
  58. package/dist/plan-orchestrator.js.map +1 -0
  59. package/dist/prompts/index.d.ts +9 -0
  60. package/dist/prompts/index.d.ts.map +1 -0
  61. package/dist/prompts/index.js +9 -0
  62. package/dist/prompts/index.js.map +1 -0
  63. package/dist/prompts/planner.d.ts +14 -0
  64. package/dist/prompts/planner.d.ts.map +1 -0
  65. package/dist/prompts/planner.js +83 -0
  66. package/dist/prompts/planner.js.map +1 -0
  67. package/dist/prompts/research-agent.d.ts +10 -0
  68. package/dist/prompts/research-agent.d.ts.map +1 -0
  69. package/dist/prompts/research-agent.js +143 -0
  70. package/dist/prompts/research-agent.js.map +1 -0
  71. package/dist/push-store.d.ts +41 -0
  72. package/dist/push-store.d.ts.map +1 -0
  73. package/dist/push-store.js +168 -0
  74. package/dist/push-store.js.map +1 -0
  75. package/dist/ralph-config.d.ts +67 -0
  76. package/dist/ralph-config.d.ts.map +1 -0
  77. package/dist/ralph-config.js +134 -0
  78. package/dist/ralph-config.js.map +1 -0
  79. package/dist/ralph-loop.d.ts +124 -0
  80. package/dist/ralph-loop.d.ts.map +1 -0
  81. package/dist/ralph-loop.js +418 -0
  82. package/dist/ralph-loop.js.map +1 -0
  83. package/dist/ralph-tracker.d.ts +1081 -0
  84. package/dist/ralph-tracker.d.ts.map +1 -0
  85. package/dist/ralph-tracker.js +3343 -0
  86. package/dist/ralph-tracker.js.map +1 -0
  87. package/dist/respawn-controller.d.ts +1182 -0
  88. package/dist/respawn-controller.d.ts.map +1 -0
  89. package/dist/respawn-controller.js +2754 -0
  90. package/dist/respawn-controller.js.map +1 -0
  91. package/dist/run-summary.d.ts +123 -0
  92. package/dist/run-summary.d.ts.map +1 -0
  93. package/dist/run-summary.js +325 -0
  94. package/dist/run-summary.js.map +1 -0
  95. package/dist/session-lifecycle-log.d.ts +36 -0
  96. package/dist/session-lifecycle-log.d.ts.map +1 -0
  97. package/dist/session-lifecycle-log.js +101 -0
  98. package/dist/session-lifecycle-log.js.map +1 -0
  99. package/dist/session-manager.d.ts +97 -0
  100. package/dist/session-manager.d.ts.map +1 -0
  101. package/dist/session-manager.js +224 -0
  102. package/dist/session-manager.js.map +1 -0
  103. package/dist/session.d.ts +686 -0
  104. package/dist/session.d.ts.map +1 -0
  105. package/dist/session.js +2025 -0
  106. package/dist/session.js.map +1 -0
  107. package/dist/state-store.d.ts +189 -0
  108. package/dist/state-store.d.ts.map +1 -0
  109. package/dist/state-store.js +730 -0
  110. package/dist/state-store.js.map +1 -0
  111. package/dist/subagent-watcher.d.ts +345 -0
  112. package/dist/subagent-watcher.d.ts.map +1 -0
  113. package/dist/subagent-watcher.js +1469 -0
  114. package/dist/subagent-watcher.js.map +1 -0
  115. package/dist/task-queue.d.ts +108 -0
  116. package/dist/task-queue.d.ts.map +1 -0
  117. package/dist/task-queue.js +235 -0
  118. package/dist/task-queue.js.map +1 -0
  119. package/dist/task-tracker.d.ts +306 -0
  120. package/dist/task-tracker.d.ts.map +1 -0
  121. package/dist/task-tracker.js +488 -0
  122. package/dist/task-tracker.js.map +1 -0
  123. package/dist/task.d.ts +73 -0
  124. package/dist/task.d.ts.map +1 -0
  125. package/dist/task.js +177 -0
  126. package/dist/task.js.map +1 -0
  127. package/dist/team-watcher.d.ts +53 -0
  128. package/dist/team-watcher.d.ts.map +1 -0
  129. package/dist/team-watcher.js +313 -0
  130. package/dist/team-watcher.js.map +1 -0
  131. package/dist/templates/case-template.md +461 -0
  132. package/dist/templates/claude-md.d.ts +26 -0
  133. package/dist/templates/claude-md.d.ts.map +1 -0
  134. package/dist/templates/claude-md.js +74 -0
  135. package/dist/templates/claude-md.js.map +1 -0
  136. package/dist/tmux-manager.d.ts +181 -0
  137. package/dist/tmux-manager.d.ts.map +1 -0
  138. package/dist/tmux-manager.js +1405 -0
  139. package/dist/tmux-manager.js.map +1 -0
  140. package/dist/transcript-watcher.d.ts +110 -0
  141. package/dist/transcript-watcher.d.ts.map +1 -0
  142. package/dist/transcript-watcher.js +338 -0
  143. package/dist/transcript-watcher.js.map +1 -0
  144. package/dist/tunnel-manager.d.ts +54 -0
  145. package/dist/tunnel-manager.d.ts.map +1 -0
  146. package/dist/tunnel-manager.js +251 -0
  147. package/dist/tunnel-manager.js.map +1 -0
  148. package/dist/types.d.ts +1139 -0
  149. package/dist/types.d.ts.map +1 -0
  150. package/dist/types.js +215 -0
  151. package/dist/types.js.map +1 -0
  152. package/dist/utils/buffer-accumulator.d.ts +111 -0
  153. package/dist/utils/buffer-accumulator.d.ts.map +1 -0
  154. package/dist/utils/buffer-accumulator.js +172 -0
  155. package/dist/utils/buffer-accumulator.js.map +1 -0
  156. package/dist/utils/claude-cli-resolver.d.ts +26 -0
  157. package/dist/utils/claude-cli-resolver.d.ts.map +1 -0
  158. package/dist/utils/claude-cli-resolver.js +78 -0
  159. package/dist/utils/claude-cli-resolver.js.map +1 -0
  160. package/dist/utils/cleanup-manager.d.ts +165 -0
  161. package/dist/utils/cleanup-manager.d.ts.map +1 -0
  162. package/dist/utils/cleanup-manager.js +274 -0
  163. package/dist/utils/cleanup-manager.js.map +1 -0
  164. package/dist/utils/index.d.ts +19 -0
  165. package/dist/utils/index.d.ts.map +1 -0
  166. package/dist/utils/index.js +19 -0
  167. package/dist/utils/index.js.map +1 -0
  168. package/dist/utils/lru-map.d.ts +140 -0
  169. package/dist/utils/lru-map.d.ts.map +1 -0
  170. package/dist/utils/lru-map.js +234 -0
  171. package/dist/utils/lru-map.js.map +1 -0
  172. package/dist/utils/nice-wrapper.d.ts +13 -0
  173. package/dist/utils/nice-wrapper.d.ts.map +1 -0
  174. package/dist/utils/nice-wrapper.js +17 -0
  175. package/dist/utils/nice-wrapper.js.map +1 -0
  176. package/dist/utils/opencode-cli-resolver.d.ts +21 -0
  177. package/dist/utils/opencode-cli-resolver.d.ts.map +1 -0
  178. package/dist/utils/opencode-cli-resolver.js +67 -0
  179. package/dist/utils/opencode-cli-resolver.js.map +1 -0
  180. package/dist/utils/regex-patterns.d.ts +64 -0
  181. package/dist/utils/regex-patterns.d.ts.map +1 -0
  182. package/dist/utils/regex-patterns.js +74 -0
  183. package/dist/utils/regex-patterns.js.map +1 -0
  184. package/dist/utils/stale-expiration-map.d.ts +159 -0
  185. package/dist/utils/stale-expiration-map.d.ts.map +1 -0
  186. package/dist/utils/stale-expiration-map.js +277 -0
  187. package/dist/utils/stale-expiration-map.js.map +1 -0
  188. package/dist/utils/string-similarity.d.ts +108 -0
  189. package/dist/utils/string-similarity.d.ts.map +1 -0
  190. package/dist/utils/string-similarity.js +189 -0
  191. package/dist/utils/string-similarity.js.map +1 -0
  192. package/dist/utils/token-validation.d.ts +39 -0
  193. package/dist/utils/token-validation.d.ts.map +1 -0
  194. package/dist/utils/token-validation.js +59 -0
  195. package/dist/utils/token-validation.js.map +1 -0
  196. package/dist/utils/type-safety.d.ts +33 -0
  197. package/dist/utils/type-safety.d.ts.map +1 -0
  198. package/dist/utils/type-safety.js +35 -0
  199. package/dist/utils/type-safety.js.map +1 -0
  200. package/dist/web/public/app.js +491 -0
  201. package/dist/web/public/app.js.br +0 -0
  202. package/dist/web/public/app.js.gz +0 -0
  203. package/dist/web/public/index.html +1675 -0
  204. package/dist/web/public/index.html.br +0 -0
  205. package/dist/web/public/index.html.gz +0 -0
  206. package/dist/web/public/manifest.json +8 -0
  207. package/dist/web/public/mobile.css +1 -0
  208. package/dist/web/public/mobile.css.br +0 -0
  209. package/dist/web/public/mobile.css.gz +0 -0
  210. package/dist/web/public/ralph-wizard.js +1037 -0
  211. package/dist/web/public/ralph-wizard.js.br +0 -0
  212. package/dist/web/public/ralph-wizard.js.gz +0 -0
  213. package/dist/web/public/styles.css +1 -0
  214. package/dist/web/public/styles.css.br +0 -0
  215. package/dist/web/public/styles.css.gz +0 -0
  216. package/dist/web/public/sw.js +67 -0
  217. package/dist/web/public/sw.js.br +0 -0
  218. package/dist/web/public/sw.js.gz +0 -0
  219. package/dist/web/public/upload.html +155 -0
  220. package/dist/web/public/upload.html.br +0 -0
  221. package/dist/web/public/upload.html.gz +0 -0
  222. package/dist/web/public/vendor/xterm-addon-fit.min.js +1 -0
  223. package/dist/web/public/vendor/xterm-addon-fit.min.js.br +0 -0
  224. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  225. package/dist/web/public/vendor/xterm-addon-unicode11.min.js +1 -0
  226. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.br +0 -0
  227. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  228. package/dist/web/public/vendor/xterm-addon-webgl.min.js +2 -0
  229. package/dist/web/public/vendor/xterm-addon-webgl.min.js.br +0 -0
  230. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  231. package/dist/web/public/vendor/xterm.css +209 -0
  232. package/dist/web/public/vendor/xterm.css.br +0 -0
  233. package/dist/web/public/vendor/xterm.css.gz +0 -0
  234. package/dist/web/public/vendor/xterm.min.js +9 -0
  235. package/dist/web/public/vendor/xterm.min.js.br +0 -0
  236. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  237. package/dist/web/schemas.d.ts +479 -0
  238. package/dist/web/schemas.d.ts.map +1 -0
  239. package/dist/web/schemas.js +448 -0
  240. package/dist/web/schemas.js.map +1 -0
  241. package/dist/web/server.d.ts +207 -0
  242. package/dist/web/server.d.ts.map +1 -0
  243. package/dist/web/server.js +5784 -0
  244. package/dist/web/server.js.map +1 -0
  245. package/package.json +110 -0
  246. 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