wiggum-cli 0.13.2 → 0.15.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/README.md +24 -5
- package/dist/ai/providers.js +19 -14
- package/dist/commands/run.d.ts +1 -1
- package/dist/commands/run.js +2 -2
- package/dist/index.js +7 -1
- package/dist/repl/session-state.d.ts +2 -0
- package/dist/templates/config/ralph.config.cjs.tmpl +1 -1
- package/dist/templates/prompts/PROMPT_review_auto.md.tmpl +7 -41
- package/dist/templates/prompts/PROMPT_review_merge.md.tmpl +163 -0
- package/dist/templates/scripts/feature-loop-actions.test.ts +92 -0
- package/dist/templates/scripts/feature-loop.sh.tmpl +236 -9
- package/dist/tui/app.js +22 -3
- package/dist/tui/components/ChatInput.d.ts +3 -1
- package/dist/tui/components/ChatInput.js +50 -13
- package/dist/tui/components/CommandDropdown.d.ts +3 -1
- package/dist/tui/components/CommandDropdown.js +10 -7
- package/dist/tui/components/RunCompletionSummary.d.ts +3 -9
- package/dist/tui/components/RunCompletionSummary.js +59 -14
- package/dist/tui/components/SpecCompletionSummary.d.ts +3 -2
- package/dist/tui/components/SpecCompletionSummary.js +26 -9
- package/dist/tui/components/SummaryBox.d.ts +56 -0
- package/dist/tui/components/SummaryBox.js +99 -0
- package/dist/tui/orchestration/interview-orchestrator.js +35 -5
- package/dist/tui/screens/MainShell.js +25 -3
- package/dist/tui/screens/RunScreen.d.ts +116 -1
- package/dist/tui/screens/RunScreen.js +114 -17
- package/dist/tui/utils/action-inbox.d.ts +43 -0
- package/dist/tui/utils/action-inbox.js +109 -0
- package/dist/tui/utils/build-run-summary.d.ts +24 -0
- package/dist/tui/utils/build-run-summary.js +241 -0
- package/dist/tui/utils/git-summary.d.ts +24 -0
- package/dist/tui/utils/git-summary.js +63 -0
- package/dist/tui/utils/input-utils.d.ts +20 -0
- package/dist/tui/utils/input-utils.js +27 -0
- package/dist/tui/utils/polishGoal.d.ts +37 -0
- package/dist/tui/utils/polishGoal.js +170 -0
- package/dist/tui/utils/pr-summary.d.ts +34 -0
- package/dist/tui/utils/pr-summary.js +84 -0
- package/dist/utils/config.d.ts +1 -1
- package/dist/utils/fuzzy-match.d.ts +5 -0
- package/dist/utils/fuzzy-match.js +16 -0
- package/dist/utils/spec-names.d.ts +6 -0
- package/dist/utils/spec-names.js +23 -0
- package/dist/utils/summary-file.d.ts +25 -0
- package/dist/utils/summary-file.js +37 -0
- package/package.json +9 -4
- package/src/templates/config/ralph.config.cjs.tmpl +1 -1
- package/src/templates/prompts/PROMPT_review_auto.md.tmpl +7 -41
- package/src/templates/prompts/PROMPT_review_merge.md.tmpl +163 -0
- package/src/templates/scripts/feature-loop-actions.test.ts +92 -0
- package/src/templates/scripts/feature-loop.sh.tmpl +236 -9
|
@@ -18,12 +18,16 @@ import { execFileSync, spawn } from 'node:child_process';
|
|
|
18
18
|
import { closeSync, existsSync, openSync, readFileSync } from 'node:fs';
|
|
19
19
|
import { dirname, join } from 'node:path';
|
|
20
20
|
import { Confirm } from '../components/Confirm.js';
|
|
21
|
+
import { Select } from '../components/Select.js';
|
|
21
22
|
import { AppShell } from '../components/AppShell.js';
|
|
22
23
|
import { RunCompletionSummary } from '../components/RunCompletionSummary.js';
|
|
23
24
|
import { colors, theme } from '../theme.js';
|
|
24
25
|
import { readLoopStatus, parseImplementationPlan, getGitBranch, formatNumber, getLoopLogPath, } from '../utils/loop-status.js';
|
|
26
|
+
import { buildEnhancedRunSummary } from '../utils/build-run-summary.js';
|
|
27
|
+
import { writeRunSummaryFile } from '../../utils/summary-file.js';
|
|
25
28
|
import { loadConfigWithDefaults } from '../../utils/config.js';
|
|
26
29
|
import { logger } from '../../utils/logger.js';
|
|
30
|
+
import { readActionRequest, writeActionReply, cleanupActionFiles } from '../utils/action-inbox.js';
|
|
27
31
|
const POLL_INTERVAL_MS = 2500;
|
|
28
32
|
const ERROR_TAIL_LINES = 12;
|
|
29
33
|
function findFeatureLoopScript(projectRoot, scriptsDir) {
|
|
@@ -80,7 +84,7 @@ function readLogTail(logPath, maxLines) {
|
|
|
80
84
|
return `[Unable to read log: ${err instanceof Error ? err.message : String(err)}]`;
|
|
81
85
|
}
|
|
82
86
|
}
|
|
83
|
-
export function RunScreen({ header, featureName, projectRoot, sessionState, monitorOnly = false, onComplete, onBackground, onCancel, }) {
|
|
87
|
+
export function RunScreen({ header, featureName, projectRoot, sessionState, monitorOnly = false, reviewMode: reviewModeProp, onComplete, onBackground, onCancel, }) {
|
|
84
88
|
const [status, setStatus] = useState(() => {
|
|
85
89
|
try {
|
|
86
90
|
return readLoopStatus(featureName);
|
|
@@ -101,6 +105,7 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
101
105
|
const [isStarting, setIsStarting] = useState(!monitorOnly);
|
|
102
106
|
const [showConfirm, setShowConfirm] = useState(false);
|
|
103
107
|
const [completionSummary, setCompletionSummary] = useState(null);
|
|
108
|
+
const [actionRequest, setActionRequest] = useState(null);
|
|
104
109
|
const childRef = useRef(null);
|
|
105
110
|
const stopRequestedRef = useRef(false);
|
|
106
111
|
const isMountedRef = useRef(true);
|
|
@@ -111,6 +116,7 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
111
116
|
const scriptsDirRef = useRef('.ralph/scripts');
|
|
112
117
|
const maxIterationsRef = useRef(0);
|
|
113
118
|
const maxE2eAttemptsRef = useRef(0);
|
|
119
|
+
const handledActionIdRef = useRef(null);
|
|
114
120
|
useInput((input, key) => {
|
|
115
121
|
// If showing completion summary, Enter or Esc dismisses
|
|
116
122
|
if (completionSummary) {
|
|
@@ -121,6 +127,9 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
121
127
|
}
|
|
122
128
|
if (showConfirm)
|
|
123
129
|
return;
|
|
130
|
+
// Action prompt handles its own input (Select component)
|
|
131
|
+
if (actionRequest)
|
|
132
|
+
return;
|
|
124
133
|
if (key.ctrl && input === 'c') {
|
|
125
134
|
setShowConfirm(true);
|
|
126
135
|
return;
|
|
@@ -147,6 +156,21 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
147
156
|
if (!isMountedRef.current)
|
|
148
157
|
return;
|
|
149
158
|
setBranch(getGitBranch(projectRoot));
|
|
159
|
+
// Check for pending action request (loop waiting for user input)
|
|
160
|
+
const request = readActionRequest(featureName);
|
|
161
|
+
if (!isMountedRef.current)
|
|
162
|
+
return;
|
|
163
|
+
if (request) {
|
|
164
|
+
// Only show if we haven't already handled this action
|
|
165
|
+
if (request.id !== handledActionIdRef.current) {
|
|
166
|
+
setActionRequest(request);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
// File cleaned up by shell — reset tracking so future requests work
|
|
171
|
+
handledActionIdRef.current = null;
|
|
172
|
+
setActionRequest((prev) => prev ? null : prev);
|
|
173
|
+
}
|
|
150
174
|
// In monitor mode, detect completion (only fire once)
|
|
151
175
|
if (monitorOnly && !nextStatus.running && !completionSentRef.current) {
|
|
152
176
|
completionSentRef.current = true;
|
|
@@ -159,7 +183,7 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
159
183
|
const tasksDone = nextTasks.tasksDone + nextTasks.e2eDone;
|
|
160
184
|
const tasksTotal = tasksDone + nextTasks.tasksPending + nextTasks.e2ePending;
|
|
161
185
|
const errorTail = exitCode !== 0 ? readLogTail(logPath, ERROR_TAIL_LINES) || undefined : undefined;
|
|
162
|
-
|
|
186
|
+
const basicSummary = {
|
|
163
187
|
feature: featureName,
|
|
164
188
|
iterations: nextStatus.iteration,
|
|
165
189
|
maxIterations: nextStatus.maxIterations,
|
|
@@ -172,9 +196,26 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
172
196
|
branch: getGitBranch(projectRoot),
|
|
173
197
|
logPath,
|
|
174
198
|
errorTail,
|
|
199
|
+
};
|
|
200
|
+
let enhancedSummary;
|
|
201
|
+
try {
|
|
202
|
+
enhancedSummary = buildEnhancedRunSummary(basicSummary, projectRoot, featureName);
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
logger.error(`Failed to build enhanced summary for ${featureName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
206
|
+
enhancedSummary = basicSummary;
|
|
207
|
+
}
|
|
208
|
+
setCompletionSummary(enhancedSummary);
|
|
209
|
+
// Persist summary to JSON file (non-blocking)
|
|
210
|
+
writeRunSummaryFile(featureName, enhancedSummary).catch((err) => {
|
|
211
|
+
logger.error(`Failed to persist summary file for ${featureName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
175
212
|
});
|
|
176
213
|
}
|
|
177
214
|
}, [featureName, projectRoot, monitorOnly]);
|
|
215
|
+
// Keep a stable ref to the latest refreshStatus so the spawn effect
|
|
216
|
+
// can schedule polls without re-running when refreshStatus changes.
|
|
217
|
+
const refreshStatusRef = useRef(refreshStatus);
|
|
218
|
+
useEffect(() => { refreshStatusRef.current = refreshStatus; }, [refreshStatus]);
|
|
178
219
|
const stopLoop = useCallback(() => {
|
|
179
220
|
stopRequestedRef.current = true;
|
|
180
221
|
if (childRef.current) {
|
|
@@ -229,11 +270,11 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
229
270
|
if (cancelled)
|
|
230
271
|
return;
|
|
231
272
|
setIsStarting(false);
|
|
232
|
-
|
|
273
|
+
refreshStatusRef.current().catch((err) => {
|
|
233
274
|
logger.warn(`Status refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
234
275
|
});
|
|
235
276
|
pollTimer = setInterval(() => {
|
|
236
|
-
|
|
277
|
+
refreshStatusRef.current().catch((err) => {
|
|
237
278
|
logger.warn(`Status refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
238
279
|
});
|
|
239
280
|
}, POLL_INTERVAL_MS);
|
|
@@ -272,12 +313,16 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
272
313
|
setIsStarting(false);
|
|
273
314
|
return;
|
|
274
315
|
}
|
|
275
|
-
const reviewMode = config.loop.reviewMode ?? 'manual';
|
|
276
|
-
if (reviewMode !== 'manual' && reviewMode !== 'auto') {
|
|
277
|
-
setError(`Invalid reviewMode '${reviewMode}'. Allowed values are 'manual' or '
|
|
316
|
+
const reviewMode = reviewModeProp ?? config.loop.reviewMode ?? 'manual';
|
|
317
|
+
if (reviewMode !== 'manual' && reviewMode !== 'auto' && reviewMode !== 'merge') {
|
|
318
|
+
setError(`Invalid reviewMode '${reviewMode}'. Allowed values are 'manual', 'auto', or 'merge'.`);
|
|
278
319
|
setIsStarting(false);
|
|
279
320
|
return;
|
|
280
321
|
}
|
|
322
|
+
// Clean up stale action files from previous runs
|
|
323
|
+
await cleanupActionFiles(featureName).catch((err) => {
|
|
324
|
+
logger.warn(`Failed to clean up stale action files: ${err instanceof Error ? err.message : String(err)}`);
|
|
325
|
+
});
|
|
281
326
|
const logPath = getLoopLogPath(featureName);
|
|
282
327
|
const logFd = openSync(logPath, 'a');
|
|
283
328
|
let logFdClosed = false;
|
|
@@ -306,11 +351,11 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
306
351
|
child.kill('SIGINT');
|
|
307
352
|
}
|
|
308
353
|
pollTimer = setInterval(() => {
|
|
309
|
-
|
|
354
|
+
refreshStatusRef.current().catch((err) => {
|
|
310
355
|
logger.warn(`Status refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
311
356
|
});
|
|
312
357
|
}, POLL_INTERVAL_MS);
|
|
313
|
-
|
|
358
|
+
refreshStatusRef.current().catch((err) => {
|
|
314
359
|
logger.warn(`Status refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
315
360
|
});
|
|
316
361
|
child.on('error', (err) => {
|
|
@@ -348,7 +393,7 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
348
393
|
const tasksTotal = tasksDone + latestTasks.tasksPending + latestTasks.e2ePending;
|
|
349
394
|
const exitCode = typeof code === 'number' ? code : 1;
|
|
350
395
|
const errorTail = exitCode === 0 ? undefined : readLogTail(logPath, ERROR_TAIL_LINES) || undefined;
|
|
351
|
-
const
|
|
396
|
+
const basicSummary = {
|
|
352
397
|
feature: featureName,
|
|
353
398
|
iterations: latestStatus.iteration,
|
|
354
399
|
maxIterations: latestStatus.maxIterations || config.loop.maxIterations,
|
|
@@ -361,8 +406,21 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
361
406
|
logPath,
|
|
362
407
|
errorTail,
|
|
363
408
|
};
|
|
409
|
+
// Build enhanced summary with phases, git stats, PR/issue metadata
|
|
410
|
+
let enhancedSummary;
|
|
411
|
+
try {
|
|
412
|
+
enhancedSummary = buildEnhancedRunSummary(basicSummary, projectRoot, featureName);
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
logger.error(`Failed to build enhanced summary for ${featureName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
416
|
+
enhancedSummary = basicSummary;
|
|
417
|
+
}
|
|
364
418
|
// Show completion summary inline
|
|
365
|
-
setCompletionSummary(
|
|
419
|
+
setCompletionSummary(enhancedSummary);
|
|
420
|
+
// Persist summary to JSON file (non-blocking)
|
|
421
|
+
writeRunSummaryFile(featureName, enhancedSummary).catch((err) => {
|
|
422
|
+
logger.error(`Failed to persist summary file for ${featureName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
423
|
+
});
|
|
366
424
|
});
|
|
367
425
|
}
|
|
368
426
|
catch (spawnErr) {
|
|
@@ -387,7 +445,10 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
387
445
|
if (pollTimer)
|
|
388
446
|
clearInterval(pollTimer);
|
|
389
447
|
};
|
|
390
|
-
|
|
448
|
+
// Note: refreshStatusRef (not refreshStatus) is used inside to avoid re-spawning
|
|
449
|
+
// the child process when the callback identity changes due to actionRequest updates.
|
|
450
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
451
|
+
}, [featureName, projectRoot, monitorOnly, sessionState.config]);
|
|
391
452
|
const totalTasks = tasks.tasksDone + tasks.tasksPending;
|
|
392
453
|
const totalE2e = tasks.e2eDone + tasks.e2ePending;
|
|
393
454
|
const totalAll = totalTasks + totalE2e;
|
|
@@ -401,11 +462,47 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
401
462
|
// Tips text
|
|
402
463
|
const tips = completionSummary
|
|
403
464
|
? 'Enter to return to shell'
|
|
404
|
-
:
|
|
405
|
-
? '
|
|
406
|
-
:
|
|
407
|
-
|
|
408
|
-
|
|
465
|
+
: actionRequest
|
|
466
|
+
? 'Select an option, Esc for default'
|
|
467
|
+
: monitorOnly
|
|
468
|
+
? 'Ctrl+C stop, Esc back'
|
|
469
|
+
: 'Ctrl+C stop, Esc background';
|
|
470
|
+
// Action select handler — awaits write before clearing prompt
|
|
471
|
+
const handleActionSelect = useCallback(async (choiceId) => {
|
|
472
|
+
if (!actionRequest)
|
|
473
|
+
return;
|
|
474
|
+
try {
|
|
475
|
+
await writeActionReply(featureName, { id: actionRequest.id, choice: choiceId });
|
|
476
|
+
handledActionIdRef.current = actionRequest.id;
|
|
477
|
+
setActionRequest(null);
|
|
478
|
+
}
|
|
479
|
+
catch (err) {
|
|
480
|
+
logger.error(`Failed to write action reply: ${err instanceof Error ? err.message : String(err)}`);
|
|
481
|
+
setError(`Failed to send action reply. The loop may time out to the default.`);
|
|
482
|
+
}
|
|
483
|
+
}, [actionRequest, featureName]);
|
|
484
|
+
// Action cancel handler (Esc = use default) — awaits write before clearing
|
|
485
|
+
const handleActionCancel = useCallback(async () => {
|
|
486
|
+
if (!actionRequest)
|
|
487
|
+
return;
|
|
488
|
+
try {
|
|
489
|
+
await writeActionReply(featureName, { id: actionRequest.id, choice: actionRequest.default });
|
|
490
|
+
handledActionIdRef.current = actionRequest.id;
|
|
491
|
+
setActionRequest(null);
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
logger.error(`Failed to write action reply (default): ${err instanceof Error ? err.message : String(err)}`);
|
|
495
|
+
setError(`Failed to send action reply. The loop may time out to the default.`);
|
|
496
|
+
}
|
|
497
|
+
}, [actionRequest, featureName]);
|
|
498
|
+
// Input element: completionSummary > showConfirm > actionRequest > null
|
|
499
|
+
const actionSelectOptions = actionRequest
|
|
500
|
+
? actionRequest.choices.map((c) => ({ value: c.id, label: c.label }))
|
|
501
|
+
: null;
|
|
502
|
+
const actionInitialIndex = actionRequest
|
|
503
|
+
? Math.max(0, actionRequest.choices.findIndex((c) => c.id === actionRequest.default))
|
|
504
|
+
: 0;
|
|
505
|
+
const inputElement = showConfirm ? (_jsx(Confirm, { message: stopRequestedRef.current ? 'Stopping loop...' : 'Stop the feature loop?', onConfirm: handleConfirm, onCancel: () => setShowConfirm(false), initialValue: false })) : !completionSummary && actionRequest && actionSelectOptions ? (_jsx(Select, { message: actionRequest.prompt, options: actionSelectOptions, onSelect: handleActionSelect, onCancel: handleActionCancel, initialIndex: actionInitialIndex })) : null;
|
|
409
506
|
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
507
|
action: 'Run Loop',
|
|
411
508
|
phase: phaseLine,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action inbox helpers for file-based IPC between loop processes and the TUI.
|
|
3
|
+
*
|
|
4
|
+
* The loop writes an action request file; the TUI reads it and writes a reply.
|
|
5
|
+
* Both files live in /tmp with the conventional ralph-loop-<feature> prefix.
|
|
6
|
+
*/
|
|
7
|
+
export interface ActionChoice {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ActionRequest {
|
|
12
|
+
id: string;
|
|
13
|
+
prompt: string;
|
|
14
|
+
choices: ActionChoice[];
|
|
15
|
+
default: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ActionReply {
|
|
18
|
+
id: string;
|
|
19
|
+
choice: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Return the path to the action request file for a feature.
|
|
23
|
+
*/
|
|
24
|
+
export declare function getActionRequestPath(feature: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Return the path to the action reply file for a feature.
|
|
27
|
+
*/
|
|
28
|
+
export declare function getActionReplyPath(feature: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* Read and validate the action request file for a feature.
|
|
31
|
+
*
|
|
32
|
+
* Returns null if the file does not exist, cannot be parsed, or is missing
|
|
33
|
+
* required fields. Logs a warning on parse errors.
|
|
34
|
+
*/
|
|
35
|
+
export declare function readActionRequest(feature: string): ActionRequest | null;
|
|
36
|
+
/**
|
|
37
|
+
* Write an action reply file atomically (write to .tmp then rename).
|
|
38
|
+
*/
|
|
39
|
+
export declare function writeActionReply(feature: string, reply: ActionReply): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Remove both action request and reply files. Only suppresses ENOENT (file not found).
|
|
42
|
+
*/
|
|
43
|
+
export declare function cleanupActionFiles(feature: string): Promise<void>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action inbox helpers for file-based IPC between loop processes and the TUI.
|
|
3
|
+
*
|
|
4
|
+
* The loop writes an action request file; the TUI reads it and writes a reply.
|
|
5
|
+
* Both files live in /tmp with the conventional ralph-loop-<feature> prefix.
|
|
6
|
+
*/
|
|
7
|
+
import { rename, unlink, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { logger } from '../../utils/logger.js';
|
|
10
|
+
const FEATURE_REGEX = /^[a-zA-Z0-9_-]+$/;
|
|
11
|
+
function validateFeature(feature) {
|
|
12
|
+
if (!FEATURE_REGEX.test(feature)) {
|
|
13
|
+
throw new Error(`Invalid feature name: "${feature}". Must contain only letters, numbers, hyphens, and underscores.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Return the path to the action request file for a feature.
|
|
18
|
+
*/
|
|
19
|
+
export function getActionRequestPath(feature) {
|
|
20
|
+
validateFeature(feature);
|
|
21
|
+
return `/tmp/ralph-loop-${feature}.action.json`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Return the path to the action reply file for a feature.
|
|
25
|
+
*/
|
|
26
|
+
export function getActionReplyPath(feature) {
|
|
27
|
+
validateFeature(feature);
|
|
28
|
+
return `/tmp/ralph-loop-${feature}.action.reply.json`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Read and validate the action request file for a feature.
|
|
32
|
+
*
|
|
33
|
+
* Returns null if the file does not exist, cannot be parsed, or is missing
|
|
34
|
+
* required fields. Logs a warning on parse errors.
|
|
35
|
+
*/
|
|
36
|
+
export function readActionRequest(feature) {
|
|
37
|
+
validateFeature(feature);
|
|
38
|
+
const path = getActionRequestPath(feature);
|
|
39
|
+
if (!existsSync(path)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
let raw;
|
|
43
|
+
try {
|
|
44
|
+
raw = readFileSync(path, 'utf-8');
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
logger.warn(`Failed to read action request file: ${err instanceof Error ? err.message : String(err)}`);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(raw);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
logger.warn(`Failed to parse action request JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (typeof parsed !== 'object' ||
|
|
59
|
+
parsed === null ||
|
|
60
|
+
typeof parsed.id !== 'string' ||
|
|
61
|
+
typeof parsed.prompt !== 'string' ||
|
|
62
|
+
!Array.isArray(parsed.choices) ||
|
|
63
|
+
parsed.choices.length === 0 ||
|
|
64
|
+
typeof parsed.default !== 'string') {
|
|
65
|
+
logger.warn('Action request file is missing required fields (id, prompt, choices, default) or choices is empty');
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const record = parsed;
|
|
69
|
+
const choices = record.choices;
|
|
70
|
+
for (const choice of choices) {
|
|
71
|
+
if (typeof choice !== 'object' ||
|
|
72
|
+
choice === null ||
|
|
73
|
+
typeof choice.id !== 'string' ||
|
|
74
|
+
typeof choice.label !== 'string') {
|
|
75
|
+
logger.warn('Action request choices contain invalid entries (each must have id and label)');
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
id: record.id,
|
|
81
|
+
prompt: record.prompt,
|
|
82
|
+
choices: choices,
|
|
83
|
+
default: record.default,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Write an action reply file atomically (write to .tmp then rename).
|
|
88
|
+
*/
|
|
89
|
+
export async function writeActionReply(feature, reply) {
|
|
90
|
+
validateFeature(feature);
|
|
91
|
+
const replyPath = getActionReplyPath(feature);
|
|
92
|
+
const tmpPath = `${replyPath}.tmp`;
|
|
93
|
+
const json = JSON.stringify(reply);
|
|
94
|
+
await writeFile(tmpPath, json, 'utf-8');
|
|
95
|
+
await rename(tmpPath, replyPath);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Remove both action request and reply files. Only suppresses ENOENT (file not found).
|
|
99
|
+
*/
|
|
100
|
+
export async function cleanupActionFiles(feature) {
|
|
101
|
+
validateFeature(feature);
|
|
102
|
+
const requestPath = getActionRequestPath(feature);
|
|
103
|
+
const replyPath = getActionReplyPath(feature);
|
|
104
|
+
const safeUnlink = (path) => unlink(path).catch((err) => {
|
|
105
|
+
if (err.code !== 'ENOENT')
|
|
106
|
+
throw err;
|
|
107
|
+
});
|
|
108
|
+
await Promise.all([safeUnlink(requestPath), safeUnlink(replyPath)]);
|
|
109
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build enhanced run summary from loop state and external data sources.
|
|
3
|
+
*/
|
|
4
|
+
import type { RunSummary } from '../screens/RunScreen.js';
|
|
5
|
+
/**
|
|
6
|
+
* Build enhanced run summary from loop completion state.
|
|
7
|
+
*
|
|
8
|
+
* This function aggregates data from:
|
|
9
|
+
* - Basic loop state (iterations, tasks, tokens, exit code)
|
|
10
|
+
* - Phase timing file (/tmp/ralph-loop-<feature>.phases)
|
|
11
|
+
* - Baseline commit file (/tmp/ralph-loop-<feature>.baseline)
|
|
12
|
+
* - Git diff stats (via git-summary utilities)
|
|
13
|
+
* - PR/issue metadata (via pr-summary utilities)
|
|
14
|
+
*
|
|
15
|
+
* @param basicSummary - The minimal RunSummary constructed by RunScreen
|
|
16
|
+
* @param projectRoot - Root directory of the project
|
|
17
|
+
* @param feature - Feature name
|
|
18
|
+
* @returns Enhanced RunSummary with all available metadata
|
|
19
|
+
*
|
|
20
|
+
* Note: This function performs synchronous I/O and subprocess execution,
|
|
21
|
+
* which blocks the event loop. Callers should wrap in try-catch to handle
|
|
22
|
+
* failures gracefully.
|
|
23
|
+
*/
|
|
24
|
+
export declare function buildEnhancedRunSummary(basicSummary: RunSummary, projectRoot: string, feature: string): RunSummary;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build enhanced run summary from loop state and external data sources.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { logger } from '../../utils/logger.js';
|
|
6
|
+
import { getCurrentCommitHash, getDiffStats } from './git-summary.js';
|
|
7
|
+
import { getPrForBranch, getLinkedIssue } from './pr-summary.js';
|
|
8
|
+
/**
|
|
9
|
+
* Phase ID to human-readable label mapping
|
|
10
|
+
*/
|
|
11
|
+
const PHASE_LABELS = {
|
|
12
|
+
planning: 'Planning',
|
|
13
|
+
implementation: 'Implementation',
|
|
14
|
+
e2e_testing: 'E2E Testing',
|
|
15
|
+
verification: 'Verification',
|
|
16
|
+
pr_review: 'PR & Review',
|
|
17
|
+
};
|
|
18
|
+
const VALID_PHASE_STATUSES = new Set(['success', 'skipped', 'failed']);
|
|
19
|
+
/**
|
|
20
|
+
* Parse phase information from the phases file written by feature-loop.sh.
|
|
21
|
+
*
|
|
22
|
+
* Format: phase_id|status|start_timestamp|end_timestamp
|
|
23
|
+
* The parser handles duplicate phase entries defensively (last status wins,
|
|
24
|
+
* durations aggregate), though feature-loop.sh normally writes one final
|
|
25
|
+
* line per phase.
|
|
26
|
+
*
|
|
27
|
+
* @param phasesFilePath - Path to the phases file
|
|
28
|
+
* @returns Array of phase info objects
|
|
29
|
+
*/
|
|
30
|
+
function parsePhases(phasesFilePath) {
|
|
31
|
+
if (!existsSync(phasesFilePath)) {
|
|
32
|
+
logger.debug(`Phases file not found: ${phasesFilePath}`);
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const content = readFileSync(phasesFilePath, 'utf-8').trim();
|
|
37
|
+
if (!content) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const lines = content.split('\n');
|
|
41
|
+
const phaseMap = new Map();
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
const parts = line.split('|');
|
|
44
|
+
if (parts.length < 4) {
|
|
45
|
+
logger.warn(`Skipping malformed phase line: ${line}`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const [id, status, startStr, endStr] = parts;
|
|
49
|
+
// Validate status
|
|
50
|
+
if (!VALID_PHASE_STATUSES.has(status)) {
|
|
51
|
+
logger.warn(`Unknown phase status "${status}" for phase "${id}", treating as failed`);
|
|
52
|
+
}
|
|
53
|
+
const validatedStatus = VALID_PHASE_STATUSES.has(status)
|
|
54
|
+
? status
|
|
55
|
+
: 'failed';
|
|
56
|
+
// Parse timestamps
|
|
57
|
+
const startTime = parseInt(startStr, 10) || 0;
|
|
58
|
+
const endTime = parseInt(endStr, 10) || 0;
|
|
59
|
+
// Calculate duration (end - start) in milliseconds
|
|
60
|
+
const durationMs = endTime > 0 && startTime > 0 ? (endTime - startTime) * 1000 : undefined;
|
|
61
|
+
// Get or create phase entry
|
|
62
|
+
let phase = phaseMap.get(id);
|
|
63
|
+
if (!phase) {
|
|
64
|
+
phase = {
|
|
65
|
+
id,
|
|
66
|
+
label: PHASE_LABELS[id] || id,
|
|
67
|
+
status: validatedStatus,
|
|
68
|
+
durationMs: 0,
|
|
69
|
+
};
|
|
70
|
+
phaseMap.set(id, phase);
|
|
71
|
+
}
|
|
72
|
+
// Update status (last status wins)
|
|
73
|
+
phase.status = validatedStatus;
|
|
74
|
+
// Aggregate duration
|
|
75
|
+
if (durationMs !== undefined) {
|
|
76
|
+
phase.durationMs = (phase.durationMs || 0) + durationMs;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return Array.from(phaseMap.values());
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
logger.warn(`Failed to parse phases file: ${err instanceof Error ? err.message : String(err)}`);
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Read baseline commit hash from the baseline file.
|
|
88
|
+
*
|
|
89
|
+
* @param baselineFilePath - Path to the baseline file
|
|
90
|
+
* @returns Short commit hash, or null if not available
|
|
91
|
+
*/
|
|
92
|
+
function readBaselineCommit(baselineFilePath) {
|
|
93
|
+
if (!existsSync(baselineFilePath)) {
|
|
94
|
+
logger.debug(`Baseline file not found: ${baselineFilePath}`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const content = readFileSync(baselineFilePath, 'utf-8').trim();
|
|
99
|
+
// Validate content looks like a hex commit hash
|
|
100
|
+
if (!/^[0-9a-f]{7,40}$/i.test(content)) {
|
|
101
|
+
logger.warn(`Baseline file ${baselineFilePath} contains invalid content: "${content.substring(0, 20)}"`);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return content.substring(0, 7) || null;
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
logger.warn(`Failed to read baseline file: ${err instanceof Error ? err.message : String(err)}`);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Build enhanced run summary from loop completion state.
|
|
113
|
+
*
|
|
114
|
+
* This function aggregates data from:
|
|
115
|
+
* - Basic loop state (iterations, tasks, tokens, exit code)
|
|
116
|
+
* - Phase timing file (/tmp/ralph-loop-<feature>.phases)
|
|
117
|
+
* - Baseline commit file (/tmp/ralph-loop-<feature>.baseline)
|
|
118
|
+
* - Git diff stats (via git-summary utilities)
|
|
119
|
+
* - PR/issue metadata (via pr-summary utilities)
|
|
120
|
+
*
|
|
121
|
+
* @param basicSummary - The minimal RunSummary constructed by RunScreen
|
|
122
|
+
* @param projectRoot - Root directory of the project
|
|
123
|
+
* @param feature - Feature name
|
|
124
|
+
* @returns Enhanced RunSummary with all available metadata
|
|
125
|
+
*
|
|
126
|
+
* Note: This function performs synchronous I/O and subprocess execution,
|
|
127
|
+
* which blocks the event loop. Callers should wrap in try-catch to handle
|
|
128
|
+
* failures gracefully.
|
|
129
|
+
*/
|
|
130
|
+
export function buildEnhancedRunSummary(basicSummary, projectRoot, feature) {
|
|
131
|
+
const phasesFilePath = `/tmp/ralph-loop-${feature}.phases`;
|
|
132
|
+
const baselineFilePath = `/tmp/ralph-loop-${feature}.baseline`;
|
|
133
|
+
// Parse phases and set implementation iterations from actual loop count
|
|
134
|
+
const phases = parsePhases(phasesFilePath);
|
|
135
|
+
const implPhase = phases.find((p) => p.id === 'implementation');
|
|
136
|
+
if (implPhase) {
|
|
137
|
+
implPhase.iterations = basicSummary.iterations;
|
|
138
|
+
}
|
|
139
|
+
// Calculate total duration from phases
|
|
140
|
+
let totalDurationMs;
|
|
141
|
+
if (phases.length > 0) {
|
|
142
|
+
const totalMs = phases.reduce((sum, phase) => sum + (phase.durationMs || 0), 0);
|
|
143
|
+
totalDurationMs = totalMs > 0 ? totalMs : undefined;
|
|
144
|
+
}
|
|
145
|
+
// Build iteration breakdown
|
|
146
|
+
const iterationBreakdown = {
|
|
147
|
+
total: basicSummary.iterations,
|
|
148
|
+
implementation: phases.find((p) => p.id === 'implementation')?.iterations,
|
|
149
|
+
// TODO: Detect resumes from multiple phase entries (future enhancement)
|
|
150
|
+
};
|
|
151
|
+
// Build task summary
|
|
152
|
+
const tasks = {
|
|
153
|
+
completed: basicSummary.tasksDone,
|
|
154
|
+
total: basicSummary.tasksTotal,
|
|
155
|
+
};
|
|
156
|
+
// Git changes and commits
|
|
157
|
+
const baselineCommit = readBaselineCommit(baselineFilePath);
|
|
158
|
+
const currentCommit = getCurrentCommitHash(projectRoot);
|
|
159
|
+
let changes = { available: false };
|
|
160
|
+
let commits = { available: false };
|
|
161
|
+
if (baselineCommit && currentCommit) {
|
|
162
|
+
commits = {
|
|
163
|
+
fromHash: baselineCommit,
|
|
164
|
+
toHash: currentCommit,
|
|
165
|
+
mergeType: 'none', // TODO: Detect merge type from git history
|
|
166
|
+
available: true,
|
|
167
|
+
};
|
|
168
|
+
// Get diff stats
|
|
169
|
+
const diffStats = getDiffStats(projectRoot, baselineCommit, currentCommit);
|
|
170
|
+
if (diffStats !== null) {
|
|
171
|
+
changes = {
|
|
172
|
+
totalFilesChanged: diffStats.length,
|
|
173
|
+
files: diffStats.map((stat) => ({
|
|
174
|
+
path: stat.path,
|
|
175
|
+
added: stat.added,
|
|
176
|
+
removed: stat.removed,
|
|
177
|
+
})),
|
|
178
|
+
available: true,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
changes = { available: true }; // Git is available but diff failed
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (currentCommit) {
|
|
186
|
+
// Only current commit available (no baseline)
|
|
187
|
+
commits = {
|
|
188
|
+
toHash: currentCommit,
|
|
189
|
+
available: true,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
// PR and issue metadata
|
|
193
|
+
let pr = { available: false, created: false };
|
|
194
|
+
let issue = { available: false, linked: false };
|
|
195
|
+
if (basicSummary.branch) {
|
|
196
|
+
try {
|
|
197
|
+
const prInfo = getPrForBranch(projectRoot, basicSummary.branch);
|
|
198
|
+
if (prInfo) {
|
|
199
|
+
pr = {
|
|
200
|
+
number: prInfo.number,
|
|
201
|
+
url: prInfo.url,
|
|
202
|
+
available: true,
|
|
203
|
+
created: true,
|
|
204
|
+
};
|
|
205
|
+
// Try to get linked issue, passing prInfo to avoid redundant gh call
|
|
206
|
+
const issueInfo = getLinkedIssue(projectRoot, basicSummary.branch, prInfo);
|
|
207
|
+
if (issueInfo) {
|
|
208
|
+
issue = {
|
|
209
|
+
number: issueInfo.number,
|
|
210
|
+
url: issueInfo.url,
|
|
211
|
+
status: issueInfo.state,
|
|
212
|
+
available: true,
|
|
213
|
+
linked: true,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
issue = { available: true, linked: false };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
pr = { available: true, created: false };
|
|
222
|
+
issue = { available: true, linked: false };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
logger.warn(`gh CLI query failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
227
|
+
// pr and issue remain { available: false } from defaults above
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
...basicSummary,
|
|
232
|
+
totalDurationMs,
|
|
233
|
+
iterationBreakdown,
|
|
234
|
+
tasks,
|
|
235
|
+
phases,
|
|
236
|
+
changes,
|
|
237
|
+
commits,
|
|
238
|
+
pr,
|
|
239
|
+
issue,
|
|
240
|
+
};
|
|
241
|
+
}
|