wiggum-cli 0.12.1 → 0.13.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.
- package/dist/index.js +7 -6
- package/dist/tui/app.d.ts +12 -22
- package/dist/tui/app.js +130 -314
- package/dist/tui/components/AppShell.d.ts +47 -0
- package/dist/tui/components/AppShell.js +19 -0
- package/dist/tui/components/FooterStatusBar.js +2 -3
- package/dist/tui/components/HeaderContent.d.ts +28 -0
- package/dist/tui/components/HeaderContent.js +16 -0
- package/dist/tui/components/MessageList.d.ts +9 -7
- package/dist/tui/components/MessageList.js +23 -17
- package/dist/tui/components/RunCompletionSummary.d.ts +22 -0
- package/dist/tui/components/RunCompletionSummary.js +23 -0
- package/dist/tui/components/SpecCompletionSummary.d.ts +47 -0
- package/dist/tui/components/SpecCompletionSummary.js +124 -0
- package/dist/tui/components/TipsBar.d.ts +24 -0
- package/dist/tui/components/TipsBar.js +23 -0
- package/dist/tui/components/WiggumBanner.js +8 -3
- package/dist/tui/hooks/useBackgroundRuns.d.ts +52 -0
- package/dist/tui/hooks/useBackgroundRuns.js +121 -0
- package/dist/tui/orchestration/interview-orchestrator.js +1 -1
- package/dist/tui/screens/InitScreen.d.ts +13 -8
- package/dist/tui/screens/InitScreen.js +86 -87
- package/dist/tui/screens/InterviewScreen.d.ts +11 -8
- package/dist/tui/screens/InterviewScreen.js +145 -99
- package/dist/tui/screens/MainShell.d.ts +13 -12
- package/dist/tui/screens/MainShell.js +65 -69
- package/dist/tui/screens/RunScreen.d.ts +17 -1
- package/dist/tui/screens/RunScreen.js +235 -80
- package/dist/tui/screens/index.d.ts +0 -2
- package/dist/tui/screens/index.js +0 -1
- package/dist/tui/utils/loop-status.d.ts +22 -3
- package/dist/tui/utils/loop-status.js +65 -15
- package/package.json +5 -1
- package/dist/tui/screens/WelcomeScreen.d.ts +0 -44
- package/dist/tui/screens/WelcomeScreen.js +0 -54
|
@@ -3,22 +3,29 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
* RunScreen - TUI screen for running feature loop
|
|
4
4
|
*
|
|
5
5
|
* Spawns feature-loop.sh and polls status files for progress.
|
|
6
|
+
* Wrapped in AppShell for consistent layout.
|
|
7
|
+
*
|
|
8
|
+
* Supports two modes:
|
|
9
|
+
* - Foreground: spawns the process and monitors it
|
|
10
|
+
* - Monitor-only: polls status files without spawning (for /monitor)
|
|
11
|
+
*
|
|
12
|
+
* Esc backgrounds the run in foreground mode; in monitor mode, Esc returns to shell.
|
|
13
|
+
* On completion, shows RunCompletionSummary inline.
|
|
6
14
|
*/
|
|
7
15
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
8
16
|
import { Box, Text, useInput } from 'ink';
|
|
9
|
-
import { spawn } from 'node:child_process';
|
|
17
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
10
18
|
import { closeSync, existsSync, openSync, readFileSync } from 'node:fs';
|
|
11
19
|
import { dirname, join } from 'node:path';
|
|
12
|
-
import { FooterStatusBar } from '../components/FooterStatusBar.js';
|
|
13
20
|
import { Confirm } from '../components/Confirm.js';
|
|
21
|
+
import { AppShell } from '../components/AppShell.js';
|
|
22
|
+
import { RunCompletionSummary } from '../components/RunCompletionSummary.js';
|
|
14
23
|
import { colors, theme } from '../theme.js';
|
|
15
|
-
import { readLoopStatus, parseImplementationPlan, getGitBranch, formatNumber, } from '../utils/loop-status.js';
|
|
24
|
+
import { readLoopStatus, parseImplementationPlan, getGitBranch, formatNumber, getLoopLogPath, } from '../utils/loop-status.js';
|
|
16
25
|
import { loadConfigWithDefaults } from '../../utils/config.js';
|
|
26
|
+
import { logger } from '../../utils/logger.js';
|
|
17
27
|
const POLL_INTERVAL_MS = 2500;
|
|
18
28
|
const ERROR_TAIL_LINES = 12;
|
|
19
|
-
/**
|
|
20
|
-
* Find the feature-loop.sh script.
|
|
21
|
-
*/
|
|
22
29
|
function findFeatureLoopScript(projectRoot, scriptsDir) {
|
|
23
30
|
const localScript = join(projectRoot, scriptsDir, 'feature-loop.sh');
|
|
24
31
|
if (existsSync(localScript)) {
|
|
@@ -34,9 +41,6 @@ function findFeatureLoopScript(projectRoot, scriptsDir) {
|
|
|
34
41
|
}
|
|
35
42
|
return null;
|
|
36
43
|
}
|
|
37
|
-
/**
|
|
38
|
-
* Validate that the spec file exists.
|
|
39
|
-
*/
|
|
40
44
|
function findSpecFile(projectRoot, feature, specsDir) {
|
|
41
45
|
const possiblePaths = [
|
|
42
46
|
join(projectRoot, specsDir, `${feature}.md`),
|
|
@@ -50,9 +54,6 @@ function findSpecFile(projectRoot, feature, specsDir) {
|
|
|
50
54
|
}
|
|
51
55
|
return null;
|
|
52
56
|
}
|
|
53
|
-
/**
|
|
54
|
-
* Render a simple progress bar.
|
|
55
|
-
*/
|
|
56
57
|
function ProgressBar({ percent, width = 18 }) {
|
|
57
58
|
const safePercent = Math.max(0, Math.min(100, percent));
|
|
58
59
|
const filled = Math.round((safePercent / 100) * width);
|
|
@@ -75,12 +76,20 @@ function readLogTail(logPath, maxLines) {
|
|
|
75
76
|
return null;
|
|
76
77
|
return lines.slice(-maxLines).join('\n');
|
|
77
78
|
}
|
|
78
|
-
catch {
|
|
79
|
-
return
|
|
79
|
+
catch (err) {
|
|
80
|
+
return `[Unable to read log: ${err instanceof Error ? err.message : String(err)}]`;
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
|
-
export function RunScreen({ featureName, projectRoot, sessionState, onComplete, onCancel, }) {
|
|
83
|
-
const [status, setStatus] = useState(() =>
|
|
83
|
+
export function RunScreen({ header, featureName, projectRoot, sessionState, monitorOnly = false, onComplete, onBackground, onCancel, }) {
|
|
84
|
+
const [status, setStatus] = useState(() => {
|
|
85
|
+
try {
|
|
86
|
+
return readLoopStatus(featureName);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
logger.error(`Failed to read initial loop status: ${err instanceof Error ? err.message : String(err)}`);
|
|
90
|
+
return { running: false, iteration: 0, maxIterations: 0, phase: 'unknown', tokensInput: 0, tokensOutput: 0 };
|
|
91
|
+
}
|
|
92
|
+
});
|
|
84
93
|
const [tasks, setTasks] = useState({
|
|
85
94
|
tasksDone: 0,
|
|
86
95
|
tasksPending: 0,
|
|
@@ -89,18 +98,27 @@ export function RunScreen({ featureName, projectRoot, sessionState, onComplete,
|
|
|
89
98
|
});
|
|
90
99
|
const [branch, setBranch] = useState('-');
|
|
91
100
|
const [error, setError] = useState(null);
|
|
92
|
-
const [isStarting, setIsStarting] = useState(
|
|
101
|
+
const [isStarting, setIsStarting] = useState(!monitorOnly);
|
|
93
102
|
const [showConfirm, setShowConfirm] = useState(false);
|
|
103
|
+
const [completionSummary, setCompletionSummary] = useState(null);
|
|
94
104
|
const childRef = useRef(null);
|
|
95
105
|
const stopRequestedRef = useRef(false);
|
|
96
106
|
const isMountedRef = useRef(true);
|
|
97
107
|
const startTimeRef = useRef(Date.now());
|
|
98
108
|
const specsDirRef = useRef('.ralph/specs');
|
|
109
|
+
const completionSentRef = useRef(false);
|
|
99
110
|
const configRootRef = useRef('.ralph');
|
|
100
111
|
const scriptsDirRef = useRef('.ralph/scripts');
|
|
101
112
|
const maxIterationsRef = useRef(0);
|
|
102
113
|
const maxE2eAttemptsRef = useRef(0);
|
|
103
114
|
useInput((input, key) => {
|
|
115
|
+
// If showing completion summary, Enter or Esc dismisses
|
|
116
|
+
if (completionSummary) {
|
|
117
|
+
if (key.return || key.escape) {
|
|
118
|
+
onComplete(completionSummary);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
104
122
|
if (showConfirm)
|
|
105
123
|
return;
|
|
106
124
|
if (key.ctrl && input === 'c') {
|
|
@@ -108,7 +126,13 @@ export function RunScreen({ featureName, projectRoot, sessionState, onComplete,
|
|
|
108
126
|
return;
|
|
109
127
|
}
|
|
110
128
|
if (key.escape) {
|
|
111
|
-
|
|
129
|
+
if (onBackground && !monitorOnly) {
|
|
130
|
+
// Background the run: don't kill the child, just navigate away
|
|
131
|
+
onBackground(featureName);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
onCancel();
|
|
135
|
+
}
|
|
112
136
|
}
|
|
113
137
|
});
|
|
114
138
|
const refreshStatus = useCallback(async () => {
|
|
@@ -123,13 +147,62 @@ export function RunScreen({ featureName, projectRoot, sessionState, onComplete,
|
|
|
123
147
|
if (!isMountedRef.current)
|
|
124
148
|
return;
|
|
125
149
|
setBranch(getGitBranch(projectRoot));
|
|
126
|
-
|
|
150
|
+
// In monitor mode, detect completion (only fire once)
|
|
151
|
+
if (monitorOnly && !nextStatus.running && !completionSentRef.current) {
|
|
152
|
+
completionSentRef.current = true;
|
|
153
|
+
const logPath = getLoopLogPath(featureName);
|
|
154
|
+
const finalMarker = `/tmp/ralph-loop-${featureName}.final`;
|
|
155
|
+
const exitCode = existsSync(finalMarker) ? 0 : 1;
|
|
156
|
+
if (exitCode !== 0) {
|
|
157
|
+
logger.warn(`Monitor mode inferred exit code 1 for ${featureName} (no .final marker). This may be a false negative.`);
|
|
158
|
+
}
|
|
159
|
+
const tasksDone = nextTasks.tasksDone + nextTasks.e2eDone;
|
|
160
|
+
const tasksTotal = tasksDone + nextTasks.tasksPending + nextTasks.e2ePending;
|
|
161
|
+
const errorTail = exitCode !== 0 ? readLogTail(logPath, ERROR_TAIL_LINES) || undefined : undefined;
|
|
162
|
+
setCompletionSummary({
|
|
163
|
+
feature: featureName,
|
|
164
|
+
iterations: nextStatus.iteration,
|
|
165
|
+
maxIterations: nextStatus.maxIterations,
|
|
166
|
+
tasksDone,
|
|
167
|
+
tasksTotal,
|
|
168
|
+
tokensInput: nextStatus.tokensInput,
|
|
169
|
+
tokensOutput: nextStatus.tokensOutput,
|
|
170
|
+
exitCode,
|
|
171
|
+
exitCodeInferred: true,
|
|
172
|
+
branch: getGitBranch(projectRoot),
|
|
173
|
+
logPath,
|
|
174
|
+
errorTail,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}, [featureName, projectRoot, monitorOnly]);
|
|
127
178
|
const stopLoop = useCallback(() => {
|
|
128
179
|
stopRequestedRef.current = true;
|
|
129
180
|
if (childRef.current) {
|
|
130
181
|
childRef.current.kill('SIGINT');
|
|
131
182
|
}
|
|
132
|
-
|
|
183
|
+
else if (monitorOnly) {
|
|
184
|
+
// In monitor mode, find and kill the loop process by pattern
|
|
185
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(featureName)) {
|
|
186
|
+
logger.error(`Refusing to run pkill with unsafe feature name: ${featureName}`);
|
|
187
|
+
setError('Feature name contains invalid characters.');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
execFileSync('pkill', ['-INT', '-f', `feature-loop.sh.*${featureName}`]);
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
// pkill exit code 1 = no matching process (already exited)
|
|
195
|
+
if (err && typeof err === 'object' && 'status' in err && err.status === 1) {
|
|
196
|
+
// Expected — process already exited
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
200
|
+
logger.error(`Failed to stop loop process for ${featureName}: ${reason}`);
|
|
201
|
+
setError(`Could not stop the loop process (${reason}). You may need to kill it manually.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}, [monitorOnly, featureName]);
|
|
133
206
|
const handleConfirm = useCallback((value) => {
|
|
134
207
|
setShowConfirm(false);
|
|
135
208
|
if (value) {
|
|
@@ -139,6 +212,41 @@ export function RunScreen({ featureName, projectRoot, sessionState, onComplete,
|
|
|
139
212
|
useEffect(() => {
|
|
140
213
|
let cancelled = false;
|
|
141
214
|
let pollTimer = null;
|
|
215
|
+
if (monitorOnly) {
|
|
216
|
+
// Monitor mode: load config for correct specs path, then poll
|
|
217
|
+
const initMonitor = async () => {
|
|
218
|
+
try {
|
|
219
|
+
const config = sessionState.config ?? await loadConfigWithDefaults(projectRoot);
|
|
220
|
+
specsDirRef.current = config.paths.specs;
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
224
|
+
logger.error(`Failed to load config for monitor mode: ${reason}`);
|
|
225
|
+
// Keep default .ralph/specs but warn user
|
|
226
|
+
if (!cancelled)
|
|
227
|
+
setError(`Could not load project config (${reason}). Showing status from default paths.`);
|
|
228
|
+
}
|
|
229
|
+
if (cancelled)
|
|
230
|
+
return;
|
|
231
|
+
setIsStarting(false);
|
|
232
|
+
refreshStatus().catch((err) => {
|
|
233
|
+
logger.warn(`Status refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
234
|
+
});
|
|
235
|
+
pollTimer = setInterval(() => {
|
|
236
|
+
refreshStatus().catch((err) => {
|
|
237
|
+
logger.warn(`Status refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
238
|
+
});
|
|
239
|
+
}, POLL_INTERVAL_MS);
|
|
240
|
+
};
|
|
241
|
+
initMonitor();
|
|
242
|
+
return () => {
|
|
243
|
+
cancelled = true;
|
|
244
|
+
isMountedRef.current = false;
|
|
245
|
+
if (pollTimer)
|
|
246
|
+
clearInterval(pollTimer);
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
// Foreground mode: spawn the process
|
|
142
250
|
const startLoop = async () => {
|
|
143
251
|
try {
|
|
144
252
|
if (!/^[a-zA-Z0-9_-]+$/.test(featureName)) {
|
|
@@ -170,66 +278,100 @@ export function RunScreen({ featureName, projectRoot, sessionState, onComplete,
|
|
|
170
278
|
setIsStarting(false);
|
|
171
279
|
return;
|
|
172
280
|
}
|
|
173
|
-
const logPath =
|
|
281
|
+
const logPath = getLoopLogPath(featureName);
|
|
174
282
|
const logFd = openSync(logPath, 'a');
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
startTimeRef.current = Date.now();
|
|
194
|
-
setIsStarting(false);
|
|
195
|
-
if (stopRequestedRef.current) {
|
|
196
|
-
child.kill('SIGINT');
|
|
197
|
-
}
|
|
198
|
-
pollTimer = setInterval(() => {
|
|
199
|
-
refreshStatus();
|
|
200
|
-
}, POLL_INTERVAL_MS);
|
|
201
|
-
refreshStatus();
|
|
202
|
-
child.on('error', (err) => {
|
|
203
|
-
if (cancelled)
|
|
204
|
-
return;
|
|
205
|
-
setError(`Failed to start feature loop: ${err.message}`);
|
|
206
|
-
});
|
|
207
|
-
child.on('close', async (code) => {
|
|
208
|
-
if (cancelled)
|
|
209
|
-
return;
|
|
210
|
-
if (pollTimer)
|
|
211
|
-
clearInterval(pollTimer);
|
|
212
|
-
closeSync(logFd);
|
|
213
|
-
const latestStatus = readLoopStatus(featureName);
|
|
214
|
-
const latestTasks = await parseImplementationPlan(projectRoot, featureName, specsDirRef.current);
|
|
215
|
-
const tasksDone = latestTasks.tasksDone + latestTasks.e2eDone;
|
|
216
|
-
const tasksTotal = tasksDone + latestTasks.tasksPending + latestTasks.e2ePending;
|
|
217
|
-
const exitCode = typeof code === 'number' ? code : 1;
|
|
218
|
-
const errorTail = exitCode === 0 ? undefined : readLogTail(logPath, ERROR_TAIL_LINES) || undefined;
|
|
219
|
-
onComplete({
|
|
220
|
-
feature: featureName,
|
|
221
|
-
iterations: latestStatus.iteration,
|
|
222
|
-
maxIterations: latestStatus.maxIterations || config.loop.maxIterations,
|
|
223
|
-
tasksDone,
|
|
224
|
-
tasksTotal,
|
|
225
|
-
tokensInput: latestStatus.tokensInput,
|
|
226
|
-
tokensOutput: latestStatus.tokensOutput,
|
|
227
|
-
exitCode,
|
|
228
|
-
branch: getGitBranch(projectRoot),
|
|
229
|
-
logPath,
|
|
230
|
-
errorTail,
|
|
283
|
+
let logFdClosed = false;
|
|
284
|
+
try {
|
|
285
|
+
const args = [
|
|
286
|
+
featureName,
|
|
287
|
+
String(config.loop.maxIterations),
|
|
288
|
+
String(config.loop.maxE2eAttempts),
|
|
289
|
+
'--review-mode',
|
|
290
|
+
reviewMode,
|
|
291
|
+
];
|
|
292
|
+
const child = spawn('bash', [scriptPath, ...args], {
|
|
293
|
+
cwd: dirname(scriptPath),
|
|
294
|
+
stdio: ['ignore', logFd, logFd],
|
|
295
|
+
env: {
|
|
296
|
+
...process.env,
|
|
297
|
+
RALPH_CONFIG_ROOT: config.paths.root,
|
|
298
|
+
RALPH_SPEC_DIR: config.paths.specs,
|
|
299
|
+
RALPH_SCRIPTS_DIR: config.paths.scripts,
|
|
300
|
+
},
|
|
231
301
|
});
|
|
232
|
-
|
|
302
|
+
childRef.current = child;
|
|
303
|
+
startTimeRef.current = Date.now();
|
|
304
|
+
setIsStarting(false);
|
|
305
|
+
if (stopRequestedRef.current) {
|
|
306
|
+
child.kill('SIGINT');
|
|
307
|
+
}
|
|
308
|
+
pollTimer = setInterval(() => {
|
|
309
|
+
refreshStatus().catch((err) => {
|
|
310
|
+
logger.warn(`Status refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
311
|
+
});
|
|
312
|
+
}, POLL_INTERVAL_MS);
|
|
313
|
+
refreshStatus().catch((err) => {
|
|
314
|
+
logger.warn(`Status refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
315
|
+
});
|
|
316
|
+
child.on('error', (err) => {
|
|
317
|
+
if (!logFdClosed) {
|
|
318
|
+
closeSync(logFd);
|
|
319
|
+
logFdClosed = true;
|
|
320
|
+
}
|
|
321
|
+
if (cancelled)
|
|
322
|
+
return;
|
|
323
|
+
setError(`Failed to start feature loop: ${err.message}`);
|
|
324
|
+
});
|
|
325
|
+
child.on('close', async (code) => {
|
|
326
|
+
if (cancelled)
|
|
327
|
+
return;
|
|
328
|
+
if (pollTimer)
|
|
329
|
+
clearInterval(pollTimer);
|
|
330
|
+
if (!logFdClosed) {
|
|
331
|
+
closeSync(logFd);
|
|
332
|
+
logFdClosed = true;
|
|
333
|
+
}
|
|
334
|
+
if (!isMountedRef.current)
|
|
335
|
+
return;
|
|
336
|
+
let latestStatus;
|
|
337
|
+
let latestTasks;
|
|
338
|
+
try {
|
|
339
|
+
latestStatus = readLoopStatus(featureName);
|
|
340
|
+
latestTasks = await parseImplementationPlan(projectRoot, featureName, specsDirRef.current);
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
logger.error(`Failed to read final run status for ${featureName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
344
|
+
latestStatus = { running: false, iteration: 0, maxIterations: config.loop.maxIterations, phase: 'unknown', tokensInput: 0, tokensOutput: 0 };
|
|
345
|
+
latestTasks = { tasksDone: 0, tasksPending: 0, e2eDone: 0, e2ePending: 0 };
|
|
346
|
+
}
|
|
347
|
+
const tasksDone = latestTasks.tasksDone + latestTasks.e2eDone;
|
|
348
|
+
const tasksTotal = tasksDone + latestTasks.tasksPending + latestTasks.e2ePending;
|
|
349
|
+
const exitCode = typeof code === 'number' ? code : 1;
|
|
350
|
+
const errorTail = exitCode === 0 ? undefined : readLogTail(logPath, ERROR_TAIL_LINES) || undefined;
|
|
351
|
+
const summary = {
|
|
352
|
+
feature: featureName,
|
|
353
|
+
iterations: latestStatus.iteration,
|
|
354
|
+
maxIterations: latestStatus.maxIterations || config.loop.maxIterations,
|
|
355
|
+
tasksDone,
|
|
356
|
+
tasksTotal,
|
|
357
|
+
tokensInput: latestStatus.tokensInput,
|
|
358
|
+
tokensOutput: latestStatus.tokensOutput,
|
|
359
|
+
exitCode,
|
|
360
|
+
branch: getGitBranch(projectRoot),
|
|
361
|
+
logPath,
|
|
362
|
+
errorTail,
|
|
363
|
+
};
|
|
364
|
+
// Show completion summary inline
|
|
365
|
+
setCompletionSummary(summary);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
catch (spawnErr) {
|
|
369
|
+
if (!logFdClosed) {
|
|
370
|
+
closeSync(logFd);
|
|
371
|
+
logFdClosed = true;
|
|
372
|
+
}
|
|
373
|
+
throw spawnErr;
|
|
374
|
+
}
|
|
233
375
|
}
|
|
234
376
|
catch (err) {
|
|
235
377
|
if (cancelled)
|
|
@@ -245,7 +387,7 @@ export function RunScreen({ featureName, projectRoot, sessionState, onComplete,
|
|
|
245
387
|
if (pollTimer)
|
|
246
388
|
clearInterval(pollTimer);
|
|
247
389
|
};
|
|
248
|
-
}, [featureName, projectRoot, refreshStatus,
|
|
390
|
+
}, [featureName, projectRoot, refreshStatus, monitorOnly, sessionState.config]);
|
|
249
391
|
const totalTasks = tasks.tasksDone + tasks.tasksPending;
|
|
250
392
|
const totalE2e = tasks.e2eDone + tasks.e2ePending;
|
|
251
393
|
const totalAll = totalTasks + totalE2e;
|
|
@@ -255,5 +397,18 @@ export function RunScreen({ featureName, projectRoot, sessionState, onComplete,
|
|
|
255
397
|
const percentAll = totalAll > 0 ? Math.round((doneAll / totalAll) * 100) : 0;
|
|
256
398
|
const totalTokens = status.tokensInput + status.tokensOutput;
|
|
257
399
|
const phaseLine = isStarting ? 'Starting...' : status.phase;
|
|
258
|
-
|
|
400
|
+
const isRunning = !completionSummary && !error;
|
|
401
|
+
// Tips text
|
|
402
|
+
const tips = completionSummary
|
|
403
|
+
? 'Enter to return to shell'
|
|
404
|
+
: monitorOnly
|
|
405
|
+
? 'Ctrl+C stop, Esc back'
|
|
406
|
+
: 'Ctrl+C stop, Esc background';
|
|
407
|
+
// Input element (only show Confirm when stopping)
|
|
408
|
+
const inputElement = showConfirm ? (_jsx(Confirm, { message: stopRequestedRef.current ? 'Stopping loop...' : 'Stop the feature loop?', onConfirm: handleConfirm, onCancel: () => setShowConfirm(false), initialValue: false })) : null;
|
|
409
|
+
return (_jsx(AppShell, { header: header, tips: tips, isWorking: isRunning && !isStarting, workingStatus: `${phaseLine} \u2014 ${featureName}`, workingHint: monitorOnly ? 'esc to go back' : 'esc to background', input: inputElement, error: error, footerStatus: {
|
|
410
|
+
action: 'Run Loop',
|
|
411
|
+
phase: phaseLine,
|
|
412
|
+
path: featureName,
|
|
413
|
+
}, children: completionSummary ? (_jsx(RunCompletionSummary, { summary: completionSummary })) : (!error && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { children: "Phase: " }), _jsx(Text, { color: colors.yellow, children: phaseLine }), _jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsx(Text, { children: "Iter: " }), _jsx(Text, { color: colors.green, children: status.iteration }), _jsxs(Text, { dimColor: true, children: ["/", status.maxIterations || maxIterationsRef.current || '-'] }), _jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsx(Text, { children: "Branch: " }), _jsx(Text, { color: colors.blue, children: branch })] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { children: "Tokens: " }), _jsx(Text, { color: colors.pink, children: formatNumber(totalTokens) }), _jsxs(Text, { dimColor: true, children: [" (in:", formatNumber(status.tokensInput), " out:", formatNumber(status.tokensOutput), ")"] }), _jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsxs(Text, { dimColor: true, children: ["Elapsed: ", formatDuration(startTimeRef.current)] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", alignItems: "center", gap: 1, children: [_jsx(Text, { bold: true, children: "Implementation:" }), _jsx(ProgressBar, { percent: percentTasks }), _jsxs(Text, { children: [percentTasks, "%"] }), _jsxs(Text, { color: colors.green, children: ['\u2713', " ", tasks.tasksDone] }), _jsxs(Text, { color: colors.yellow, children: ['\u25cb', " ", tasks.tasksPending] })] }), totalE2e > 0 && (_jsxs(Box, { flexDirection: "row", alignItems: "center", gap: 1, children: [_jsx(Text, { bold: true, children: "E2E Tests:" }), _jsx(ProgressBar, { percent: percentE2e }), _jsxs(Text, { children: [percentE2e, "%"] }), _jsxs(Text, { color: colors.green, children: ['\u2713', " ", tasks.e2eDone] }), _jsxs(Text, { color: colors.yellow, children: ['\u25cb', " ", tasks.e2ePending] })] })), _jsxs(Box, { flexDirection: "row", alignItems: "center", gap: 1, marginTop: 1, children: [_jsx(Text, { bold: true, children: "Overall:" }), _jsx(ProgressBar, { percent: percentAll }), _jsxs(Text, { children: [percentAll, "%"] }), _jsxs(Text, { color: colors.green, children: ['\u2713', " ", doneAll] }), _jsxs(Text, { color: colors.yellow, children: ['\u25cb', " ", totalAll - doneAll] })] })] })] }))) }));
|
|
259
414
|
}
|
|
@@ -5,8 +5,6 @@ export { InterviewScreen } from './InterviewScreen.js';
|
|
|
5
5
|
export type { InterviewScreenProps } from './InterviewScreen.js';
|
|
6
6
|
export { InitScreen } from './InitScreen.js';
|
|
7
7
|
export type { InitScreenProps } from './InitScreen.js';
|
|
8
|
-
export { WelcomeScreen } from './WelcomeScreen.js';
|
|
9
|
-
export type { WelcomeScreenProps } from './WelcomeScreen.js';
|
|
10
8
|
export { MainShell } from './MainShell.js';
|
|
11
9
|
export type { MainShellProps, NavigationTarget, NavigationProps } from './MainShell.js';
|
|
12
10
|
export { RunScreen } from './RunScreen.js';
|
|
@@ -16,15 +16,34 @@ export interface TaskCounts {
|
|
|
16
16
|
e2ePending: number;
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
19
|
+
* Return the conventional log file path for a feature loop.
|
|
20
|
+
*/
|
|
21
|
+
export declare function getLoopLogPath(feature: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Detect current phase of the loop by checking for processes with prompt file patterns in their command line.
|
|
24
|
+
*
|
|
25
|
+
* Note: prompt-file checks (PROMPT_feature.md, etc.) are global — they match any
|
|
26
|
+
* running process, not just the one for `feature`. This is acceptable because
|
|
27
|
+
* concurrent loops are rare, but callers should be aware of the limitation.
|
|
20
28
|
*/
|
|
21
29
|
export declare function detectPhase(feature: string): string;
|
|
22
30
|
/**
|
|
23
|
-
* Read status from temp files.
|
|
31
|
+
* Read loop status from temp files written by feature-loop.sh.
|
|
32
|
+
*
|
|
33
|
+
* Reads `ralph-loop-<feature>.status` (or `.final`) for iteration progress
|
|
34
|
+
* and `ralph-loop-<feature>.tokens` for token counts. Also runs `pgrep` to
|
|
35
|
+
* check whether the loop process is still alive.
|
|
36
|
+
*
|
|
37
|
+
* @throws {Error} If `feature` contains invalid characters.
|
|
24
38
|
*/
|
|
25
39
|
export declare function readLoopStatus(feature: string): LoopStatus;
|
|
26
40
|
/**
|
|
27
|
-
* Parse implementation plan for task counts.
|
|
41
|
+
* Parse the markdown implementation plan for a feature to extract task/E2E counts.
|
|
42
|
+
*
|
|
43
|
+
* Looks for `- [x]` (done) and `- [ ]` (pending) checklist items.
|
|
44
|
+
* Items containing "E2E:" are counted separately as end-to-end tests.
|
|
45
|
+
*
|
|
46
|
+
* @returns Counts of done/pending tasks and E2E tests.
|
|
28
47
|
*/
|
|
29
48
|
export declare function parseImplementationPlan(projectRoot: string, feature: string, specsDirOverride?: string): Promise<TaskCounts>;
|
|
30
49
|
/**
|
|
@@ -5,20 +5,49 @@ import { execFileSync } from 'node:child_process';
|
|
|
5
5
|
import { existsSync, readFileSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { loadConfigWithDefaults } from '../../utils/config.js';
|
|
8
|
+
import { logger } from '../../utils/logger.js';
|
|
9
|
+
/**
|
|
10
|
+
* Track whether pgrep is available to avoid repeated failed calls.
|
|
11
|
+
* null = untested, true = available, false = unavailable
|
|
12
|
+
*/
|
|
13
|
+
let pgrepAvailable = null;
|
|
8
14
|
/**
|
|
9
15
|
* Check if a process matching pattern is running.
|
|
10
16
|
*/
|
|
11
17
|
function isProcessRunning(pattern) {
|
|
18
|
+
if (pgrepAvailable === false)
|
|
19
|
+
return false;
|
|
12
20
|
try {
|
|
13
21
|
const result = execFileSync('pgrep', ['-f', pattern], { encoding: 'utf-8' });
|
|
22
|
+
pgrepAvailable = true;
|
|
14
23
|
return result.trim().length > 0;
|
|
15
24
|
}
|
|
16
|
-
catch {
|
|
25
|
+
catch (err) {
|
|
26
|
+
// pgrep exits with code 1 when no processes match — that's expected
|
|
27
|
+
if (err && typeof err === 'object' && 'status' in err && err.status === 1) {
|
|
28
|
+
pgrepAvailable = true;
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
// Any other error (pgrep not installed, permission denied, etc.)
|
|
32
|
+
if (pgrepAvailable === null) {
|
|
33
|
+
logger.warn(`Process detection unavailable: ${err instanceof Error ? err.message : String(err)}. Background run status may be inaccurate.`);
|
|
34
|
+
pgrepAvailable = false;
|
|
35
|
+
}
|
|
17
36
|
return false;
|
|
18
37
|
}
|
|
19
38
|
}
|
|
20
39
|
/**
|
|
21
|
-
*
|
|
40
|
+
* Return the conventional log file path for a feature loop.
|
|
41
|
+
*/
|
|
42
|
+
export function getLoopLogPath(feature) {
|
|
43
|
+
return `/tmp/ralph-loop-${feature}.log`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Detect current phase of the loop by checking for processes with prompt file patterns in their command line.
|
|
47
|
+
*
|
|
48
|
+
* Note: prompt-file checks (PROMPT_feature.md, etc.) are global — they match any
|
|
49
|
+
* running process, not just the one for `feature`. This is acceptable because
|
|
50
|
+
* concurrent loops are rare, but callers should be aware of the limitation.
|
|
22
51
|
*/
|
|
23
52
|
export function detectPhase(feature) {
|
|
24
53
|
if (isProcessRunning('PROMPT_feature.md'))
|
|
@@ -38,9 +67,18 @@ export function detectPhase(feature) {
|
|
|
38
67
|
return 'Idle';
|
|
39
68
|
}
|
|
40
69
|
/**
|
|
41
|
-
* Read status from temp files.
|
|
70
|
+
* Read loop status from temp files written by feature-loop.sh.
|
|
71
|
+
*
|
|
72
|
+
* Reads `ralph-loop-<feature>.status` (or `.final`) for iteration progress
|
|
73
|
+
* and `ralph-loop-<feature>.tokens` for token counts. Also runs `pgrep` to
|
|
74
|
+
* check whether the loop process is still alive.
|
|
75
|
+
*
|
|
76
|
+
* @throws {Error} If `feature` contains invalid characters.
|
|
42
77
|
*/
|
|
43
78
|
export function readLoopStatus(feature) {
|
|
79
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(feature)) {
|
|
80
|
+
throw new Error(`Invalid feature name: "${feature}". Must contain only letters, numbers, hyphens, and underscores.`);
|
|
81
|
+
}
|
|
44
82
|
const statusFile = `/tmp/ralph-loop-${feature}.status`;
|
|
45
83
|
const finalStatusFile = `/tmp/ralph-loop-${feature}.final`;
|
|
46
84
|
const tokensFile = `/tmp/ralph-loop-${feature}.tokens`;
|
|
@@ -54,8 +92,8 @@ export function readLoopStatus(feature) {
|
|
|
54
92
|
iteration = parseInt(parts[0] || '0', 10) || 0;
|
|
55
93
|
maxIterations = parseInt(parts[1] || '0', 10) || 0;
|
|
56
94
|
}
|
|
57
|
-
catch {
|
|
58
|
-
|
|
95
|
+
catch (err) {
|
|
96
|
+
logger.debug(`Failed to parse status file: ${err instanceof Error ? err.message : String(err)}`);
|
|
59
97
|
}
|
|
60
98
|
}
|
|
61
99
|
let tokensInput = 0;
|
|
@@ -67,8 +105,8 @@ export function readLoopStatus(feature) {
|
|
|
67
105
|
tokensInput = parseInt(parts[0] || '0', 10) || 0;
|
|
68
106
|
tokensOutput = parseInt(parts[1] || '0', 10) || 0;
|
|
69
107
|
}
|
|
70
|
-
catch {
|
|
71
|
-
|
|
108
|
+
catch (err) {
|
|
109
|
+
logger.debug(`Failed to parse tokens file: ${err instanceof Error ? err.message : String(err)}`);
|
|
72
110
|
}
|
|
73
111
|
}
|
|
74
112
|
return {
|
|
@@ -81,12 +119,23 @@ export function readLoopStatus(feature) {
|
|
|
81
119
|
};
|
|
82
120
|
}
|
|
83
121
|
/**
|
|
84
|
-
* Parse implementation plan for task counts.
|
|
122
|
+
* Parse the markdown implementation plan for a feature to extract task/E2E counts.
|
|
123
|
+
*
|
|
124
|
+
* Looks for `- [x]` (done) and `- [ ]` (pending) checklist items.
|
|
125
|
+
* Items containing "E2E:" are counted separately as end-to-end tests.
|
|
126
|
+
*
|
|
127
|
+
* @returns Counts of done/pending tasks and E2E tests.
|
|
85
128
|
*/
|
|
86
129
|
export async function parseImplementationPlan(projectRoot, feature, specsDirOverride) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
130
|
+
let config = null;
|
|
131
|
+
if (!specsDirOverride) {
|
|
132
|
+
try {
|
|
133
|
+
config = await loadConfigWithDefaults(projectRoot);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
logger.debug(`Failed to load config for plan parsing: ${err instanceof Error ? err.message : String(err)}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
90
139
|
const specsDir = specsDirOverride || config?.paths.specs || '.ralph/specs';
|
|
91
140
|
const planPath = join(projectRoot, specsDir, `${feature}-implementation-plan.md`);
|
|
92
141
|
let tasksDone = 0;
|
|
@@ -117,8 +166,8 @@ export async function parseImplementationPlan(projectRoot, feature, specsDirOver
|
|
|
117
166
|
}
|
|
118
167
|
}
|
|
119
168
|
}
|
|
120
|
-
catch {
|
|
121
|
-
|
|
169
|
+
catch (err) {
|
|
170
|
+
logger.debug(`Failed to parse implementation plan: ${err instanceof Error ? err.message : String(err)}`);
|
|
122
171
|
}
|
|
123
172
|
}
|
|
124
173
|
return { tasksDone, tasksPending, e2eDone, e2ePending };
|
|
@@ -131,9 +180,10 @@ export function getGitBranch(projectRoot) {
|
|
|
131
180
|
return execFileSync('git', ['branch', '--show-current'], {
|
|
132
181
|
cwd: projectRoot,
|
|
133
182
|
encoding: 'utf-8',
|
|
134
|
-
}).trim();
|
|
183
|
+
}).trim() || '(detached HEAD)';
|
|
135
184
|
}
|
|
136
|
-
catch {
|
|
185
|
+
catch (err) {
|
|
186
|
+
logger.debug(`getGitBranch failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
137
187
|
return '-';
|
|
138
188
|
}
|
|
139
189
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiggum-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "AI-powered feature development loop CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
"code-generation",
|
|
34
34
|
"tech-stack-detection"
|
|
35
35
|
],
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "https://github.com/federiconeri/wiggum-cli"
|
|
39
|
+
},
|
|
36
40
|
"author": "Wiggum CLI Contributors",
|
|
37
41
|
"license": "SEE LICENSE IN LICENSE",
|
|
38
42
|
"dependencies": {
|