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.
Files changed (51) hide show
  1. package/README.md +24 -5
  2. package/dist/ai/providers.js +19 -14
  3. package/dist/commands/run.d.ts +1 -1
  4. package/dist/commands/run.js +2 -2
  5. package/dist/index.js +7 -1
  6. package/dist/repl/session-state.d.ts +2 -0
  7. package/dist/templates/config/ralph.config.cjs.tmpl +1 -1
  8. package/dist/templates/prompts/PROMPT_review_auto.md.tmpl +7 -41
  9. package/dist/templates/prompts/PROMPT_review_merge.md.tmpl +163 -0
  10. package/dist/templates/scripts/feature-loop-actions.test.ts +92 -0
  11. package/dist/templates/scripts/feature-loop.sh.tmpl +236 -9
  12. package/dist/tui/app.js +22 -3
  13. package/dist/tui/components/ChatInput.d.ts +3 -1
  14. package/dist/tui/components/ChatInput.js +50 -13
  15. package/dist/tui/components/CommandDropdown.d.ts +3 -1
  16. package/dist/tui/components/CommandDropdown.js +10 -7
  17. package/dist/tui/components/RunCompletionSummary.d.ts +3 -9
  18. package/dist/tui/components/RunCompletionSummary.js +59 -14
  19. package/dist/tui/components/SpecCompletionSummary.d.ts +3 -2
  20. package/dist/tui/components/SpecCompletionSummary.js +26 -9
  21. package/dist/tui/components/SummaryBox.d.ts +56 -0
  22. package/dist/tui/components/SummaryBox.js +99 -0
  23. package/dist/tui/orchestration/interview-orchestrator.js +35 -5
  24. package/dist/tui/screens/MainShell.js +25 -3
  25. package/dist/tui/screens/RunScreen.d.ts +116 -1
  26. package/dist/tui/screens/RunScreen.js +114 -17
  27. package/dist/tui/utils/action-inbox.d.ts +43 -0
  28. package/dist/tui/utils/action-inbox.js +109 -0
  29. package/dist/tui/utils/build-run-summary.d.ts +24 -0
  30. package/dist/tui/utils/build-run-summary.js +241 -0
  31. package/dist/tui/utils/git-summary.d.ts +24 -0
  32. package/dist/tui/utils/git-summary.js +63 -0
  33. package/dist/tui/utils/input-utils.d.ts +20 -0
  34. package/dist/tui/utils/input-utils.js +27 -0
  35. package/dist/tui/utils/polishGoal.d.ts +37 -0
  36. package/dist/tui/utils/polishGoal.js +170 -0
  37. package/dist/tui/utils/pr-summary.d.ts +34 -0
  38. package/dist/tui/utils/pr-summary.js +84 -0
  39. package/dist/utils/config.d.ts +1 -1
  40. package/dist/utils/fuzzy-match.d.ts +5 -0
  41. package/dist/utils/fuzzy-match.js +16 -0
  42. package/dist/utils/spec-names.d.ts +6 -0
  43. package/dist/utils/spec-names.js +23 -0
  44. package/dist/utils/summary-file.d.ts +25 -0
  45. package/dist/utils/summary-file.js +37 -0
  46. package/package.json +9 -4
  47. package/src/templates/config/ralph.config.cjs.tmpl +1 -1
  48. package/src/templates/prompts/PROMPT_review_auto.md.tmpl +7 -41
  49. package/src/templates/prompts/PROMPT_review_merge.md.tmpl +163 -0
  50. package/src/templates/scripts/feature-loop-actions.test.ts +92 -0
  51. 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
- setCompletionSummary({
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
- refreshStatus().catch((err) => {
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
- refreshStatus().catch((err) => {
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 'auto'.`);
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
- refreshStatus().catch((err) => {
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
- refreshStatus().catch((err) => {
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 summary = {
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(summary);
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
- }, [featureName, projectRoot, refreshStatus, monitorOnly, sessionState.config]);
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
- : 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;
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
+ }