agileflow 2.89.3 → 2.90.0

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 (37) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +3 -3
  3. package/lib/placeholder-registry.js +617 -0
  4. package/lib/smart-json-file.js +205 -1
  5. package/lib/table-formatter.js +504 -0
  6. package/lib/transient-status.js +374 -0
  7. package/lib/ui-manager.js +612 -0
  8. package/lib/validate-args.js +213 -0
  9. package/lib/validate-names.js +143 -0
  10. package/lib/validate-paths.js +434 -0
  11. package/lib/validate.js +37 -737
  12. package/package.json +4 -1
  13. package/scripts/check-update.js +16 -3
  14. package/scripts/lib/sessionRegistry.js +682 -0
  15. package/scripts/session-manager.js +77 -10
  16. package/scripts/tui/App.js +176 -0
  17. package/scripts/tui/index.js +75 -0
  18. package/scripts/tui/lib/crashRecovery.js +302 -0
  19. package/scripts/tui/lib/eventStream.js +316 -0
  20. package/scripts/tui/lib/keyboard.js +252 -0
  21. package/scripts/tui/lib/loopControl.js +371 -0
  22. package/scripts/tui/panels/OutputPanel.js +278 -0
  23. package/scripts/tui/panels/SessionPanel.js +178 -0
  24. package/scripts/tui/panels/TracePanel.js +333 -0
  25. package/src/core/commands/tui.md +91 -0
  26. package/tools/cli/commands/config.js +7 -30
  27. package/tools/cli/commands/doctor.js +18 -38
  28. package/tools/cli/commands/list.js +47 -35
  29. package/tools/cli/commands/status.js +13 -37
  30. package/tools/cli/commands/uninstall.js +9 -38
  31. package/tools/cli/installers/core/installer.js +13 -0
  32. package/tools/cli/lib/command-context.js +374 -0
  33. package/tools/cli/lib/config-manager.js +394 -0
  34. package/tools/cli/lib/ide-registry.js +186 -0
  35. package/tools/cli/lib/npm-utils.js +16 -3
  36. package/tools/cli/lib/self-update.js +148 -0
  37. package/tools/cli/lib/validation-middleware.js +491 -0
@@ -0,0 +1,371 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Loop Control - Pause/Resume mechanism for Ralph Loop
5
+ *
6
+ * Provides file-based pause/resume signals that ralph-loop.js checks
7
+ * between iterations. This allows external control (TUI, CLI) to
8
+ * pause ongoing loops without interrupting mid-work.
9
+ *
10
+ * Mechanism:
11
+ * - Pause: Create .agileflow/sessions/loop.pause file
12
+ * - Resume: Remove the pause file
13
+ * - Status: Check if file exists
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const EventEmitter = require('events');
19
+
20
+ // Get project root
21
+ let getProjectRoot;
22
+ try {
23
+ getProjectRoot = require('../../../lib/paths').getProjectRoot;
24
+ } catch (e) {
25
+ getProjectRoot = () => process.cwd();
26
+ }
27
+
28
+ // Get safe JSON utilities
29
+ let safeReadJSON, safeWriteJSON;
30
+ try {
31
+ const errors = require('../../../lib/errors');
32
+ safeReadJSON = errors.safeReadJSON;
33
+ safeWriteJSON = errors.safeWriteJSON;
34
+ } catch (e) {
35
+ safeReadJSON = (path, opts = {}) => {
36
+ try {
37
+ const data = JSON.parse(fs.readFileSync(path, 'utf8'));
38
+ return { ok: true, data };
39
+ } catch (e) {
40
+ return { ok: false, error: e.message, data: opts.defaultValue };
41
+ }
42
+ };
43
+ safeWriteJSON = (path, data) => {
44
+ fs.writeFileSync(path, JSON.stringify(data, null, 2));
45
+ return { ok: true };
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Get pause file path
51
+ */
52
+ function getPauseFilePath(sessionId = 'default') {
53
+ const rootDir = getProjectRoot();
54
+ return path.join(rootDir, '.agileflow', 'sessions', `${sessionId}.pause`);
55
+ }
56
+
57
+ /**
58
+ * Get session state path
59
+ */
60
+ function getSessionStatePath() {
61
+ const rootDir = getProjectRoot();
62
+ return path.join(rootDir, 'docs', '09-agents', 'session-state.json');
63
+ }
64
+
65
+ /**
66
+ * Check if loop is paused
67
+ */
68
+ function isPaused(sessionId = 'default') {
69
+ const pauseFile = getPauseFilePath(sessionId);
70
+ return fs.existsSync(pauseFile);
71
+ }
72
+
73
+ /**
74
+ * Pause the loop
75
+ * Creates pause file and optionally updates session state
76
+ */
77
+ function pause(sessionId = 'default', reason = 'user_request') {
78
+ const pauseFile = getPauseFilePath(sessionId);
79
+
80
+ // Ensure directory exists
81
+ const dir = path.dirname(pauseFile);
82
+ if (!fs.existsSync(dir)) {
83
+ fs.mkdirSync(dir, { recursive: true });
84
+ }
85
+
86
+ // Create pause file with metadata
87
+ const pauseData = {
88
+ paused_at: new Date().toISOString(),
89
+ reason,
90
+ session_id: sessionId
91
+ };
92
+
93
+ fs.writeFileSync(pauseFile, JSON.stringify(pauseData, null, 2));
94
+
95
+ // Update session state
96
+ const statePath = getSessionStatePath();
97
+ const result = safeReadJSON(statePath, { defaultValue: {} });
98
+ const state = result.ok ? result.data : {};
99
+
100
+ if (state.ralph_loop) {
101
+ state.ralph_loop.paused = true;
102
+ state.ralph_loop.paused_at = pauseData.paused_at;
103
+ state.ralph_loop.pause_reason = reason;
104
+ safeWriteJSON(statePath, state);
105
+ }
106
+
107
+ return pauseData;
108
+ }
109
+
110
+ /**
111
+ * Resume the loop
112
+ * Removes pause file and updates session state
113
+ */
114
+ function resume(sessionId = 'default') {
115
+ const pauseFile = getPauseFilePath(sessionId);
116
+
117
+ // Get pause data before removing
118
+ let pauseData = null;
119
+ if (fs.existsSync(pauseFile)) {
120
+ try {
121
+ pauseData = JSON.parse(fs.readFileSync(pauseFile, 'utf8'));
122
+ } catch (e) {
123
+ // Ignore parse errors
124
+ }
125
+ fs.unlinkSync(pauseFile);
126
+ }
127
+
128
+ // Update session state
129
+ const statePath = getSessionStatePath();
130
+ const result = safeReadJSON(statePath, { defaultValue: {} });
131
+ const state = result.ok ? result.data : {};
132
+
133
+ if (state.ralph_loop) {
134
+ state.ralph_loop.paused = false;
135
+ state.ralph_loop.resumed_at = new Date().toISOString();
136
+ delete state.ralph_loop.paused_at;
137
+ delete state.ralph_loop.pause_reason;
138
+ safeWriteJSON(statePath, state);
139
+ }
140
+
141
+ return {
142
+ resumed_at: new Date().toISOString(),
143
+ was_paused: pauseData !== null,
144
+ pause_data: pauseData
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Get loop status
150
+ */
151
+ function getLoopStatus() {
152
+ const statePath = getSessionStatePath();
153
+ const result = safeReadJSON(statePath, { defaultValue: {} });
154
+ const state = result.ok ? result.data : {};
155
+
156
+ const loop = state.ralph_loop;
157
+ if (!loop || !loop.enabled) {
158
+ return {
159
+ active: false,
160
+ paused: false,
161
+ message: 'Loop not active'
162
+ };
163
+ }
164
+
165
+ const paused = isPaused() || loop.paused;
166
+
167
+ return {
168
+ active: true,
169
+ paused,
170
+ epic: loop.epic,
171
+ currentStory: loop.current_story,
172
+ iteration: loop.iteration || 0,
173
+ maxIterations: loop.max_iterations || 20,
174
+ visualMode: loop.visual_mode || false,
175
+ coverageMode: loop.coverage_mode || false,
176
+ coverageThreshold: loop.coverage_threshold || 80,
177
+ coverageCurrent: loop.coverage_current || 0,
178
+ pausedAt: loop.paused_at,
179
+ pauseReason: loop.pause_reason,
180
+ startedAt: loop.started_at
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Stop the loop completely
186
+ */
187
+ function stopLoop(reason = 'user_request') {
188
+ const statePath = getSessionStatePath();
189
+ const result = safeReadJSON(statePath, { defaultValue: {} });
190
+ const state = result.ok ? result.data : {};
191
+
192
+ if (state.ralph_loop) {
193
+ state.ralph_loop.enabled = false;
194
+ state.ralph_loop.stopped_at = new Date().toISOString();
195
+ state.ralph_loop.stopped_reason = reason;
196
+ safeWriteJSON(statePath, state);
197
+ }
198
+
199
+ // Also remove any pause file
200
+ const pauseFile = getPauseFilePath();
201
+ if (fs.existsSync(pauseFile)) {
202
+ fs.unlinkSync(pauseFile);
203
+ }
204
+
205
+ return {
206
+ stopped: true,
207
+ reason,
208
+ stopped_at: new Date().toISOString()
209
+ };
210
+ }
211
+
212
+ /**
213
+ * LoopController class - EventEmitter for loop control
214
+ *
215
+ * Emits events:
216
+ * - 'paused' - Loop was paused
217
+ * - 'resumed' - Loop was resumed
218
+ * - 'stopped' - Loop was stopped
219
+ * - 'statusChange' - Loop status changed
220
+ */
221
+ class LoopController extends EventEmitter {
222
+ constructor(options = {}) {
223
+ super();
224
+
225
+ this.sessionId = options.sessionId || 'default';
226
+ this.pollInterval = options.pollInterval || 1000;
227
+ this.lastStatus = null;
228
+ this.pollTimer = null;
229
+ this.isWatching = false;
230
+ }
231
+
232
+ /**
233
+ * Start watching for status changes
234
+ */
235
+ startWatching() {
236
+ if (this.isWatching) return;
237
+
238
+ this.lastStatus = getLoopStatus();
239
+ this.isWatching = true;
240
+
241
+ this.pollTimer = setInterval(() => {
242
+ this._checkStatus();
243
+ }, this.pollInterval);
244
+
245
+ this.emit('started');
246
+ }
247
+
248
+ /**
249
+ * Stop watching
250
+ */
251
+ stopWatching() {
252
+ if (!this.isWatching) return;
253
+
254
+ if (this.pollTimer) {
255
+ clearInterval(this.pollTimer);
256
+ this.pollTimer = null;
257
+ }
258
+
259
+ this.isWatching = false;
260
+ this.emit('stopped');
261
+ }
262
+
263
+ /**
264
+ * Check for status changes
265
+ */
266
+ _checkStatus() {
267
+ const newStatus = getLoopStatus();
268
+
269
+ if (!this.lastStatus) {
270
+ this.lastStatus = newStatus;
271
+ return;
272
+ }
273
+
274
+ // Check for changes
275
+ if (this.lastStatus.paused !== newStatus.paused) {
276
+ if (newStatus.paused) {
277
+ this.emit('paused', {
278
+ pausedAt: newStatus.pausedAt,
279
+ reason: newStatus.pauseReason
280
+ });
281
+ } else {
282
+ this.emit('resumed');
283
+ }
284
+ }
285
+
286
+ if (this.lastStatus.active !== newStatus.active) {
287
+ if (!newStatus.active && this.lastStatus.active) {
288
+ this.emit('loopStopped');
289
+ }
290
+ }
291
+
292
+ if (this.lastStatus.iteration !== newStatus.iteration) {
293
+ this.emit('iteration', {
294
+ iteration: newStatus.iteration,
295
+ maxIterations: newStatus.maxIterations
296
+ });
297
+ }
298
+
299
+ if (this.lastStatus.currentStory !== newStatus.currentStory) {
300
+ this.emit('storyChange', {
301
+ previousStory: this.lastStatus.currentStory,
302
+ currentStory: newStatus.currentStory
303
+ });
304
+ }
305
+
306
+ // Always emit statusChange if anything changed
307
+ const changed = JSON.stringify(this.lastStatus) !== JSON.stringify(newStatus);
308
+ if (changed) {
309
+ this.emit('statusChange', newStatus);
310
+ }
311
+
312
+ this.lastStatus = newStatus;
313
+ }
314
+
315
+ /**
316
+ * Pause the loop
317
+ */
318
+ pause(reason = 'user_request') {
319
+ const result = pause(this.sessionId, reason);
320
+ this._checkStatus();
321
+ return result;
322
+ }
323
+
324
+ /**
325
+ * Resume the loop
326
+ */
327
+ resume() {
328
+ const result = resume(this.sessionId);
329
+ this._checkStatus();
330
+ return result;
331
+ }
332
+
333
+ /**
334
+ * Stop the loop
335
+ */
336
+ stop(reason = 'user_request') {
337
+ const result = stopLoop(reason);
338
+ this._checkStatus();
339
+ return result;
340
+ }
341
+
342
+ /**
343
+ * Get current status
344
+ */
345
+ getStatus() {
346
+ return getLoopStatus();
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Default controller instance
352
+ */
353
+ let defaultController = null;
354
+
355
+ function getDefaultController() {
356
+ if (!defaultController) {
357
+ defaultController = new LoopController();
358
+ }
359
+ return defaultController;
360
+ }
361
+
362
+ module.exports = {
363
+ isPaused,
364
+ pause,
365
+ resume,
366
+ getLoopStatus,
367
+ stopLoop,
368
+ getPauseFilePath,
369
+ LoopController,
370
+ getDefaultController
371
+ };
@@ -0,0 +1,278 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Agent Output Panel - Live updates from agent event stream
5
+ *
6
+ * Displays real-time agent messages with timestamps and agent names.
7
+ * Auto-scrolls to bottom, limits display to configurable message count.
8
+ */
9
+
10
+ const React = require('react');
11
+ const { Box, Text, Newline } = require('ink');
12
+ const { EventStream, formatEvent } = require('../lib/eventStream');
13
+
14
+ /**
15
+ * Get color for agent type
16
+ */
17
+ function getAgentColor(agent) {
18
+ const colors = {
19
+ 'agileflow-api': 'cyan',
20
+ 'agileflow-ui': 'magenta',
21
+ 'agileflow-testing': 'yellow',
22
+ 'agileflow-ci': 'green',
23
+ 'agileflow-security': 'red',
24
+ 'agileflow-devops': 'blue',
25
+ 'agileflow-database': 'white',
26
+ 'agileflow-performance': 'cyan',
27
+ 'agileflow-documentation': 'gray'
28
+ };
29
+
30
+ // Check for partial matches
31
+ for (const [key, color] of Object.entries(colors)) {
32
+ if (agent && agent.includes(key.replace('agileflow-', ''))) {
33
+ return color;
34
+ }
35
+ }
36
+
37
+ return 'gray';
38
+ }
39
+
40
+ /**
41
+ * Get status indicator for event type
42
+ */
43
+ function getStatusIndicator(eventType) {
44
+ switch (eventType) {
45
+ case 'init':
46
+ return { symbol: '▶', color: 'blue' };
47
+ case 'iteration':
48
+ return { symbol: '↻', color: 'cyan' };
49
+ case 'passed':
50
+ return { symbol: '✓', color: 'green' };
51
+ case 'failed':
52
+ return { symbol: '✗', color: 'red' };
53
+ case 'abort':
54
+ return { symbol: '⊘', color: 'yellow' };
55
+ default:
56
+ return { symbol: '•', color: 'gray' };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Single output row component
62
+ */
63
+ function OutputRow({ event, showTimestamp = true }) {
64
+ const formatted = formatEvent(event);
65
+ const agentColor = getAgentColor(formatted.agent);
66
+ const status = getStatusIndicator(formatted.eventType);
67
+
68
+ return React.createElement(
69
+ Box,
70
+ { flexDirection: 'row' },
71
+ // Timestamp
72
+ showTimestamp && formatted.timestamp && React.createElement(
73
+ Text,
74
+ { dimColor: true },
75
+ `[${formatted.timestamp}] `
76
+ ),
77
+ // Status indicator
78
+ React.createElement(
79
+ Text,
80
+ { color: status.color },
81
+ `${status.symbol} `
82
+ ),
83
+ // Agent name
84
+ React.createElement(
85
+ Text,
86
+ { color: agentColor, bold: true },
87
+ `[${formatted.agent}] `
88
+ ),
89
+ // Message
90
+ React.createElement(
91
+ Text,
92
+ null,
93
+ formatted.message
94
+ )
95
+ );
96
+ }
97
+
98
+ /**
99
+ * Main Output Panel component
100
+ */
101
+ function OutputPanel({
102
+ maxMessages = 100,
103
+ showTimestamp = true,
104
+ logPath = null,
105
+ title = 'AGENT OUTPUT'
106
+ }) {
107
+ const [messages, setMessages] = React.useState([]);
108
+ const [isConnected, setIsConnected] = React.useState(false);
109
+ const [error, setError] = React.useState(null);
110
+ const streamRef = React.useRef(null);
111
+
112
+ React.useEffect(() => {
113
+ // Create event stream
114
+ const options = logPath ? { logPath, emitHistory: true, historyLimit: 10 } : { emitHistory: true, historyLimit: 10 };
115
+ const stream = new EventStream(options);
116
+ streamRef.current = stream;
117
+
118
+ // Handle events
119
+ stream.on('event', (event) => {
120
+ setMessages((prev) => {
121
+ const newMessages = [...prev, event];
122
+ // Limit to maxMessages
123
+ if (newMessages.length > maxMessages) {
124
+ return newMessages.slice(-maxMessages);
125
+ }
126
+ return newMessages;
127
+ });
128
+ });
129
+
130
+ stream.on('started', () => {
131
+ setIsConnected(true);
132
+ setError(null);
133
+ });
134
+
135
+ stream.on('stopped', () => {
136
+ setIsConnected(false);
137
+ });
138
+
139
+ stream.on('error', (err) => {
140
+ setError(err.message);
141
+ });
142
+
143
+ stream.on('truncated', () => {
144
+ // Clear messages on log rotation
145
+ setMessages([]);
146
+ });
147
+
148
+ // Start watching
149
+ stream.start();
150
+
151
+ // Cleanup
152
+ return () => {
153
+ if (streamRef.current) {
154
+ streamRef.current.stop();
155
+ }
156
+ };
157
+ }, [logPath, maxMessages]);
158
+
159
+ // Render panel
160
+ return React.createElement(
161
+ Box,
162
+ {
163
+ flexDirection: 'column',
164
+ borderStyle: 'single',
165
+ borderColor: isConnected ? 'green' : 'gray',
166
+ padding: 1,
167
+ flexGrow: 1
168
+ },
169
+ // Header
170
+ React.createElement(
171
+ Box,
172
+ { marginBottom: 1 },
173
+ React.createElement(
174
+ Text,
175
+ { bold: true, color: 'cyan' },
176
+ title
177
+ ),
178
+ React.createElement(
179
+ Text,
180
+ { dimColor: true },
181
+ ` (${messages.length}/${maxMessages})`
182
+ ),
183
+ isConnected && React.createElement(
184
+ Text,
185
+ { color: 'green' },
186
+ ' ●'
187
+ ),
188
+ !isConnected && !error && React.createElement(
189
+ Text,
190
+ { color: 'yellow' },
191
+ ' ○'
192
+ ),
193
+ error && React.createElement(
194
+ Text,
195
+ { color: 'red' },
196
+ ' ✗'
197
+ )
198
+ ),
199
+ // Messages or placeholder
200
+ messages.length === 0
201
+ ? React.createElement(
202
+ Text,
203
+ { dimColor: true, italic: true },
204
+ error ? `Error: ${error}` : 'Waiting for agent activity...'
205
+ )
206
+ : messages.map((event, index) =>
207
+ React.createElement(OutputRow, {
208
+ key: `msg-${index}`,
209
+ event,
210
+ showTimestamp
211
+ })
212
+ )
213
+ );
214
+ }
215
+
216
+ /**
217
+ * Compact output view (for split panels)
218
+ */
219
+ function CompactOutput({ maxLines = 5, logPath = null }) {
220
+ const [messages, setMessages] = React.useState([]);
221
+ const streamRef = React.useRef(null);
222
+
223
+ React.useEffect(() => {
224
+ const options = logPath ? { logPath } : {};
225
+ const stream = new EventStream(options);
226
+ streamRef.current = stream;
227
+
228
+ stream.on('event', (event) => {
229
+ setMessages((prev) => {
230
+ const newMessages = [...prev, event];
231
+ return newMessages.slice(-maxLines);
232
+ });
233
+ });
234
+
235
+ stream.start();
236
+
237
+ return () => {
238
+ if (streamRef.current) {
239
+ streamRef.current.stop();
240
+ }
241
+ };
242
+ }, [logPath, maxLines]);
243
+
244
+ if (messages.length === 0) {
245
+ return React.createElement(
246
+ Text,
247
+ { dimColor: true },
248
+ 'No recent activity'
249
+ );
250
+ }
251
+
252
+ return React.createElement(
253
+ Box,
254
+ { flexDirection: 'column' },
255
+ messages.map((event, index) => {
256
+ const formatted = formatEvent(event);
257
+ const status = getStatusIndicator(formatted.eventType);
258
+ return React.createElement(
259
+ Text,
260
+ { key: `compact-${index}` },
261
+ React.createElement(Text, { color: status.color }, status.symbol),
262
+ ' ',
263
+ React.createElement(Text, { dimColor: true }, `[${formatted.agent}]`),
264
+ ' ',
265
+ formatted.message.substring(0, 50),
266
+ formatted.message.length > 50 ? '...' : ''
267
+ );
268
+ })
269
+ );
270
+ }
271
+
272
+ module.exports = {
273
+ OutputPanel,
274
+ OutputRow,
275
+ CompactOutput,
276
+ getAgentColor,
277
+ getStatusIndicator
278
+ };