wiggum-cli 0.16.0 → 0.17.1

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 (101) hide show
  1. package/bin/ralph.js +0 -0
  2. package/dist/agent/memory/ingest.d.ts +14 -0
  3. package/dist/agent/memory/ingest.js +77 -0
  4. package/dist/agent/memory/store.d.ts +15 -0
  5. package/dist/agent/memory/store.js +98 -0
  6. package/dist/agent/memory/types.d.ts +16 -0
  7. package/dist/agent/memory/types.js +14 -0
  8. package/dist/agent/orchestrator.d.ts +7 -0
  9. package/dist/agent/orchestrator.js +266 -0
  10. package/dist/agent/resolve-config.d.ts +26 -0
  11. package/dist/agent/resolve-config.js +43 -0
  12. package/dist/agent/tools/backlog.d.ts +27 -0
  13. package/dist/agent/tools/backlog.js +51 -0
  14. package/dist/agent/tools/dry-run.d.ts +106 -0
  15. package/dist/agent/tools/dry-run.js +119 -0
  16. package/dist/agent/tools/execution.d.ts +51 -0
  17. package/dist/agent/tools/execution.js +256 -0
  18. package/dist/agent/tools/feature-state.d.ts +43 -0
  19. package/dist/agent/tools/feature-state.js +184 -0
  20. package/dist/agent/tools/introspection.d.ts +23 -0
  21. package/dist/agent/tools/introspection.js +40 -0
  22. package/dist/agent/tools/memory.d.ts +44 -0
  23. package/dist/agent/tools/memory.js +99 -0
  24. package/dist/agent/tools/preflight.d.ts +7 -0
  25. package/dist/agent/tools/preflight.js +137 -0
  26. package/dist/agent/tools/reporting.d.ts +58 -0
  27. package/dist/agent/tools/reporting.js +119 -0
  28. package/dist/agent/tools/schemas.d.ts +2 -0
  29. package/dist/agent/tools/schemas.js +3 -0
  30. package/dist/agent/types.d.ts +45 -0
  31. package/dist/agent/types.js +1 -0
  32. package/dist/ai/conversation/conversation-manager.js +8 -0
  33. package/dist/ai/conversation/url-fetcher.js +27 -0
  34. package/dist/ai/providers.js +5 -5
  35. package/dist/commands/agent.d.ts +17 -0
  36. package/dist/commands/agent.js +114 -0
  37. package/dist/commands/monitor.js +50 -183
  38. package/dist/commands/new-auto.d.ts +15 -0
  39. package/dist/commands/new-auto.js +237 -0
  40. package/dist/commands/run.js +20 -10
  41. package/dist/commands/sync.d.ts +15 -0
  42. package/dist/commands/sync.js +68 -0
  43. package/dist/generator/config.d.ts +1 -41
  44. package/dist/generator/config.js +7 -0
  45. package/dist/generator/index.d.ts +2 -2
  46. package/dist/generator/templates.d.ts +2 -0
  47. package/dist/generator/templates.js +9 -1
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +115 -4
  50. package/dist/repl/command-parser.d.ts +10 -0
  51. package/dist/repl/command-parser.js +10 -0
  52. package/dist/templates/prompts/PROMPT.md.tmpl +13 -10
  53. package/dist/templates/prompts/PROMPT_e2e.md.tmpl +13 -7
  54. package/dist/templates/prompts/PROMPT_feature.md.tmpl +16 -3
  55. package/dist/templates/prompts/PROMPT_review_auto.md.tmpl +32 -12
  56. package/dist/templates/prompts/PROMPT_review_manual.md.tmpl +4 -1
  57. package/dist/templates/prompts/PROMPT_review_merge.md.tmpl +39 -14
  58. package/dist/templates/prompts/PROMPT_verify.md.tmpl +5 -2
  59. package/dist/templates/scripts/feature-loop.sh.tmpl +441 -69
  60. package/dist/tui/app.d.ts +19 -2
  61. package/dist/tui/app.js +23 -4
  62. package/dist/tui/components/IssuePicker.d.ts +27 -0
  63. package/dist/tui/components/IssuePicker.js +73 -0
  64. package/dist/tui/components/RunCompletionSummary.js +6 -3
  65. package/dist/tui/components/StatusLine.d.ts +3 -1
  66. package/dist/tui/components/StatusLine.js +2 -2
  67. package/dist/tui/hooks/useAgentOrchestrator.d.ts +40 -0
  68. package/dist/tui/hooks/useAgentOrchestrator.js +491 -0
  69. package/dist/tui/orchestration/interview-orchestrator.d.ts +5 -1
  70. package/dist/tui/orchestration/interview-orchestrator.js +27 -6
  71. package/dist/tui/screens/AgentScreen.d.ts +24 -0
  72. package/dist/tui/screens/AgentScreen.js +209 -0
  73. package/dist/tui/screens/InitScreen.js +4 -0
  74. package/dist/tui/screens/InterviewScreen.d.ts +3 -1
  75. package/dist/tui/screens/InterviewScreen.js +146 -10
  76. package/dist/tui/screens/MainShell.d.ts +1 -1
  77. package/dist/tui/screens/MainShell.js +115 -4
  78. package/dist/tui/screens/RunScreen.js +72 -16
  79. package/dist/tui/utils/build-run-summary.d.ts +1 -1
  80. package/dist/tui/utils/build-run-summary.js +40 -84
  81. package/dist/tui/utils/clear-screen.d.ts +14 -0
  82. package/dist/tui/utils/clear-screen.js +16 -0
  83. package/dist/tui/utils/git-summary.d.ts +1 -0
  84. package/dist/tui/utils/git-summary.js +16 -0
  85. package/dist/tui/utils/loop-status.d.ts +41 -1
  86. package/dist/tui/utils/loop-status.js +243 -35
  87. package/dist/tui/utils/pr-summary.d.ts +3 -2
  88. package/dist/tui/utils/pr-summary.js +41 -6
  89. package/dist/utils/config.d.ts +8 -0
  90. package/dist/utils/config.js +8 -0
  91. package/dist/utils/github.d.ts +32 -0
  92. package/dist/utils/github.js +106 -0
  93. package/package.json +4 -1
  94. package/src/templates/prompts/PROMPT.md.tmpl +13 -10
  95. package/src/templates/prompts/PROMPT_e2e.md.tmpl +13 -7
  96. package/src/templates/prompts/PROMPT_feature.md.tmpl +16 -3
  97. package/src/templates/prompts/PROMPT_review_auto.md.tmpl +32 -12
  98. package/src/templates/prompts/PROMPT_review_manual.md.tmpl +4 -1
  99. package/src/templates/prompts/PROMPT_review_merge.md.tmpl +39 -14
  100. package/src/templates/prompts/PROMPT_verify.md.tmpl +5 -2
  101. package/src/templates/scripts/feature-loop.sh.tmpl +441 -69
@@ -7,9 +7,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  * Wrapped in AppShell for consistent layout.
8
8
  */
9
9
  import { useState, useCallback, useEffect, useMemo } from 'react';
10
- import { Box, Text, useInput, useApp } from 'ink';
10
+ import { Box, Text, useInput, useApp, useStdout } from 'ink';
11
11
  import { MessageList } from '../components/MessageList.js';
12
12
  import { ChatInput } from '../components/ChatInput.js';
13
+ import { IssuePicker } from '../components/IssuePicker.js';
13
14
  import { ActionOutput } from '../components/ActionOutput.js';
14
15
  import { AppShell } from '../components/AppShell.js';
15
16
  import { colors, theme, phase } from '../theme.js';
@@ -18,7 +19,18 @@ import { logger } from '../../utils/logger.js';
18
19
  import { parseInput, resolveCommandAlias, formatHelpText, } from '../../repl/command-parser.js';
19
20
  import { readLoopStatus } from '../utils/loop-status.js';
20
21
  import { useSync } from '../hooks/useSync.js';
22
+ import { isGhInstalled, detectGitHubRemote, listRepoIssues, } from '../../utils/github.js';
23
+ import { clearScreen } from '../utils/clear-screen.js';
21
24
  import path from 'node:path';
25
+ function slugifyIssueTitle(title, maxWords = 4) {
26
+ return title
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9\s-]/g, '')
29
+ .trim()
30
+ .split(/\s+/)
31
+ .slice(0, maxWords)
32
+ .join('-') || 'untitled';
33
+ }
22
34
  /**
23
35
  * Generate a unique ID for messages
24
36
  */
@@ -33,6 +45,7 @@ function generateId() {
33
45
  */
34
46
  export function MainShell({ header, sessionState, onNavigate, backgroundRuns, initialMessage, initialFiles, }) {
35
47
  const { exit } = useApp();
48
+ const { stdout } = useStdout();
36
49
  const [messages, setMessages] = useState(() => {
37
50
  const initial = [];
38
51
  if (initialMessage) {
@@ -48,6 +61,12 @@ export function MainShell({ header, sessionState, onNavigate, backgroundRuns, in
48
61
  const [contextAge, setContextAge] = useState(null);
49
62
  // Sync hook
50
63
  const { status: syncStatus, error: syncError, sync } = useSync();
64
+ // Issue picker state
65
+ const [issuePickerVisible, setIssuePickerVisible] = useState(false);
66
+ const [issuePickerIssues, setIssuePickerIssues] = useState([]);
67
+ const [issuePickerLoading, setIssuePickerLoading] = useState(false);
68
+ const [issuePickerError, setIssuePickerError] = useState();
69
+ const [issuePickerRepo, setIssuePickerRepo] = useState(null);
51
70
  const addSystemMessage = useCallback((content) => {
52
71
  const message = {
53
72
  id: generateId(),
@@ -171,6 +190,92 @@ export function MainShell({ header, sessionState, onNavigate, backgroundRuns, in
171
190
  }
172
191
  addSystemMessage(`No running loop found for "${featureName}".`);
173
192
  }, [addSystemMessage, backgroundRuns, onNavigate]);
193
+ const handleAgent = useCallback((args) => {
194
+ if (!sessionState.initialized) {
195
+ addSystemMessage('Project not initialized. Run /init first.');
196
+ return;
197
+ }
198
+ // Parse optional flags
199
+ let dryRun = false;
200
+ let maxItems;
201
+ let reviewMode;
202
+ for (let i = 0; i < args.length; i++) {
203
+ if (args[i] === '--dry-run') {
204
+ dryRun = true;
205
+ }
206
+ else if (args[i] === '--max-items' && i + 1 < args.length) {
207
+ maxItems = parseInt(args[i + 1], 10);
208
+ if (Number.isNaN(maxItems)) {
209
+ addSystemMessage(`Invalid --max-items value '${args[i + 1]}'. Must be a number.`);
210
+ return;
211
+ }
212
+ i++;
213
+ }
214
+ else if (args[i] === '--review-mode' && i + 1 < args.length) {
215
+ reviewMode = args[i + 1];
216
+ i++;
217
+ }
218
+ }
219
+ if (reviewMode !== undefined && !['manual', 'auto', 'merge'].includes(reviewMode)) {
220
+ addSystemMessage(`Invalid --review-mode value '${reviewMode}'. Use 'manual', 'auto', or 'merge'.`);
221
+ return;
222
+ }
223
+ onNavigate('agent', { dryRun, maxItems, reviewMode });
224
+ }, [sessionState.initialized, addSystemMessage, onNavigate]);
225
+ const handleIssueCommand = useCallback(async (searchQuery) => {
226
+ if (!sessionState.initialized) {
227
+ addSystemMessage('Project not initialized. Run /init first.');
228
+ return;
229
+ }
230
+ setIssuePickerVisible(true);
231
+ setIssuePickerLoading(true);
232
+ setIssuePickerError(undefined);
233
+ try {
234
+ const ghAvailable = await isGhInstalled();
235
+ if (!ghAvailable) {
236
+ setIssuePickerError('Install GitHub CLI (gh) for issue browsing');
237
+ setIssuePickerLoading(false);
238
+ return;
239
+ }
240
+ let repo = issuePickerRepo;
241
+ if (!repo) {
242
+ repo = await detectGitHubRemote(sessionState.projectRoot);
243
+ if (!repo) {
244
+ setIssuePickerError('No GitHub remote detected in this project');
245
+ setIssuePickerLoading(false);
246
+ return;
247
+ }
248
+ setIssuePickerRepo(repo);
249
+ }
250
+ const result = await listRepoIssues(repo.owner, repo.repo, searchQuery);
251
+ if (result.error) {
252
+ setIssuePickerError(result.error);
253
+ }
254
+ setIssuePickerIssues(result.issues);
255
+ }
256
+ catch (err) {
257
+ setIssuePickerError(err instanceof Error ? err.message : String(err));
258
+ }
259
+ finally {
260
+ setIssuePickerLoading(false);
261
+ }
262
+ }, [sessionState.initialized, sessionState.projectRoot, issuePickerRepo, addSystemMessage]);
263
+ const handleIssueSelect = useCallback((issue) => {
264
+ clearScreen(stdout);
265
+ setIssuePickerVisible(false);
266
+ setIssuePickerIssues([]);
267
+ const featureName = slugifyIssueTitle(issue.title);
268
+ onNavigate('interview', {
269
+ featureName,
270
+ initialReferences: [`issue:${issue.number}`],
271
+ });
272
+ }, [stdout, onNavigate]);
273
+ const handleIssueCancel = useCallback(() => {
274
+ clearScreen(stdout);
275
+ setIssuePickerVisible(false);
276
+ setIssuePickerIssues([]);
277
+ setIssuePickerError(undefined);
278
+ }, [stdout]);
174
279
  const handleConfig = useCallback((args) => {
175
280
  if (args.length === 0) {
176
281
  addSystemMessage('Config management - not yet implemented in TUI mode. Use CLI: wiggum config');
@@ -220,6 +325,12 @@ export function MainShell({ header, sessionState, onNavigate, backgroundRuns, in
220
325
  case 'monitor':
221
326
  handleMonitor(args);
222
327
  break;
328
+ case 'issue':
329
+ handleIssueCommand(args.join(' ') || undefined);
330
+ break;
331
+ case 'agent':
332
+ handleAgent(args);
333
+ break;
223
334
  case 'config':
224
335
  handleConfig(args);
225
336
  break;
@@ -229,7 +340,7 @@ export function MainShell({ header, sessionState, onNavigate, backgroundRuns, in
229
340
  default:
230
341
  addSystemMessage(`Unknown command: ${commandName}`);
231
342
  }
232
- }, [handleHelp, handleInit, handleSync, handleNew, handleRun, handleMonitor, handleConfig, handleExit, addSystemMessage]);
343
+ }, [handleHelp, handleInit, handleSync, handleNew, handleRun, handleMonitor, handleIssueCommand, handleAgent, handleConfig, handleExit, addSystemMessage]);
233
344
  const handleNaturalLanguage = useCallback((_text) => {
234
345
  addSystemMessage('Tip: Use /help to see available commands, or /new <feature> to create a spec.');
235
346
  }, [addSystemMessage]);
@@ -264,10 +375,10 @@ export function MainShell({ header, sessionState, onNavigate, backgroundRuns, in
264
375
  });
265
376
  // Build tips text
266
377
  const tips = sessionState.initialized
267
- ? 'Tip: /new <feature> to create spec, /help for commands'
378
+ ? 'Tip: /new <feature> or /issue to browse issues, /help for commands'
268
379
  : 'Tip: /init to set up, /help for commands';
269
380
  const specSuggestions = useMemo(() => sessionState.specNames?.map((name) => ({ name, description: '' })), [sessionState.specNames]);
270
- const inputElement = (_jsx(ChatInput, { onSubmit: handleSubmit, disabled: false, placeholder: "Enter command or type /help...", onCommand: (cmd) => handleSubmit(`/${cmd}`), specSuggestions: specSuggestions }));
381
+ const inputElement = (_jsxs(Box, { flexDirection: "column", children: [_jsx(ChatInput, { onSubmit: handleSubmit, disabled: issuePickerVisible, placeholder: "Enter command or type /help...", onCommand: (cmd) => handleSubmit(`/${cmd}`), specSuggestions: specSuggestions }), issuePickerVisible && (_jsx(IssuePicker, { issues: issuePickerIssues, repoSlug: issuePickerRepo ? `${issuePickerRepo.owner}/${issuePickerRepo.repo}` : '...', onSelect: handleIssueSelect, onCancel: handleIssueCancel, isLoading: issuePickerLoading, error: issuePickerError }))] }));
271
382
  return (_jsxs(AppShell, { header: header, tips: tips, isWorking: syncStatus === 'running', workingStatus: syncStatus === 'running' ? 'Syncing project context\u2026' : undefined, input: inputElement, footerStatus: {
272
383
  action: projectLabel || 'Main Shell',
273
384
  phase: sessionState.provider ? `${sessionState.provider}/${sessionState.model}` : 'No provider',
@@ -25,13 +25,14 @@ import { ActivityFeed } from '../components/ActivityFeed.js';
25
25
  import { colors, theme } from '../theme.js';
26
26
  import { readLoopStatus, parseImplementationPlan, getGitBranch, formatNumber, getLoopLogPath, parseLoopLog, parsePhaseChanges, } from '../utils/loop-status.js';
27
27
  import { buildEnhancedRunSummary } from '../utils/build-run-summary.js';
28
- import { getCurrentCommitHash } from '../utils/git-summary.js';
28
+ import { getCurrentCommitHash, getRecentCommits } from '../utils/git-summary.js';
29
29
  import { writeRunSummaryFile } from '../../utils/summary-file.js';
30
30
  import { loadConfigWithDefaults } from '../../utils/config.js';
31
31
  import { logger } from '../../utils/logger.js';
32
32
  import { readActionRequest, writeActionReply, cleanupActionFiles } from '../utils/action-inbox.js';
33
33
  const POLL_INTERVAL_MS = 2500;
34
34
  const ERROR_TAIL_LINES = 12;
35
+ const RUN_REVIEW_MODES = ['manual', 'auto', 'merge'];
35
36
  function findFeatureLoopScript(projectRoot, scriptsDir) {
36
37
  const localScript = join(projectRoot, scriptsDir, 'feature-loop.sh');
37
38
  if (existsSync(localScript)) {
@@ -101,6 +102,7 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
101
102
  tasksPending: 0,
102
103
  e2eDone: 0,
103
104
  e2ePending: 0,
105
+ planExists: false,
104
106
  });
105
107
  const [branch, setBranch] = useState('-');
106
108
  const [error, setError] = useState(null);
@@ -111,6 +113,9 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
111
113
  const [activityEvents, setActivityEvents] = useState([]);
112
114
  const [latestCommit, setLatestCommit] = useState(null);
113
115
  const [baselineCommit, setBaselineCommit] = useState(null);
116
+ const [recentCommits, setRecentCommits] = useState([]);
117
+ const [reviewMode, setReviewMode] = useState(reviewModeProp ?? 'manual');
118
+ const [loopModel, setLoopModel] = useState(null);
114
119
  const childRef = useRef(null);
115
120
  const stopRequestedRef = useRef(false);
116
121
  const isMountedRef = useRef(true);
@@ -124,6 +129,8 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
124
129
  const handledActionIdRef = useRef(null);
125
130
  const lastLogLineCountRef = useRef(0);
126
131
  const lastKnownPhasesRef = useRef(undefined);
132
+ const lastActivityTimeRef = useRef(Date.now());
133
+ const lastCommitForEventRef = useRef(null);
127
134
  // Read baseline commit once on mount
128
135
  useEffect(() => {
129
136
  const baselinePath = `/tmp/ralph-loop-${featureName}.baseline`;
@@ -167,6 +174,14 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
167
174
  else {
168
175
  onCancel();
169
176
  }
177
+ return;
178
+ }
179
+ // Shift+R cycles review mode (manual → auto → merge)
180
+ if (input === 'R') {
181
+ setReviewMode((prev) => {
182
+ const idx = RUN_REVIEW_MODES.indexOf(prev);
183
+ return RUN_REVIEW_MODES[(idx + 1) % RUN_REVIEW_MODES.length];
184
+ });
170
185
  }
171
186
  });
172
187
  const refreshStatus = useCallback(async () => {
@@ -181,10 +196,12 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
181
196
  if (!isMountedRef.current)
182
197
  return;
183
198
  setBranch(getGitBranch(projectRoot));
184
- // Update latest commit hash
199
+ // Update latest commit hash and recent commits
185
200
  const head = getCurrentCommitHash(projectRoot);
186
- if (head && isMountedRef.current)
201
+ if (head && isMountedRef.current) {
187
202
  setLatestCommit(head);
203
+ setRecentCommits(getRecentCommits(projectRoot, 3));
204
+ }
188
205
  // Collect new activity events from log and phase changes
189
206
  const logPath = getLoopLogPath(featureName);
190
207
  const allLogEvents = parseLoopLog(logPath);
@@ -198,11 +215,33 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
198
215
  if (currentPhases) {
199
216
  lastKnownPhasesRef.current = currentPhases;
200
217
  }
218
+ // Emit a commit activity event when HEAD changes
219
+ if (head && head !== lastCommitForEventRef.current && lastCommitForEventRef.current !== null) {
220
+ newLogEvents.push({
221
+ timestamp: Date.now(),
222
+ message: `New commit: ${head.slice(0, 7)}`,
223
+ status: 'success',
224
+ });
225
+ }
226
+ lastCommitForEventRef.current = head ?? null;
201
227
  const MAX_STORED_EVENTS = 100;
202
228
  const newEvents = [...newLogEvents, ...phaseEvents];
203
229
  if (newEvents.length > 0 && isMountedRef.current) {
230
+ lastActivityTimeRef.current = Date.now();
204
231
  setActivityEvents((prev) => [...prev, ...newEvents].slice(-MAX_STORED_EVENTS));
205
232
  }
233
+ else if (nextStatus.running &&
234
+ nextStatus.phase !== 'Idle' &&
235
+ Date.now() - lastActivityTimeRef.current > 30_000 &&
236
+ isMountedRef.current) {
237
+ // Inject a synthetic "session in progress" event when stale
238
+ // Update lastActivityTimeRef so this doesn't fire every poll cycle
239
+ lastActivityTimeRef.current = Date.now();
240
+ setActivityEvents((prev) => [
241
+ ...prev,
242
+ { timestamp: Date.now(), message: `${nextStatus.phase} session in progress...`, status: 'in-progress' },
243
+ ].slice(-MAX_STORED_EVENTS));
244
+ }
206
245
  // Check for pending action request (loop waiting for user input)
207
246
  const request = readActionRequest(featureName);
208
247
  if (!isMountedRef.current)
@@ -230,6 +269,9 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
230
269
  const tasksDone = nextTasks.tasksDone + nextTasks.e2eDone;
231
270
  const tasksTotal = tasksDone + nextTasks.tasksPending + nextTasks.e2ePending;
232
271
  const errorTail = exitCode !== 0 ? readLogTail(logPath, ERROR_TAIL_LINES) || undefined : undefined;
272
+ // Use feat/<feature> as the branch name for summary. getGitBranch() returns
273
+ // "main" after squash-merge + worktree cleanup, which breaks PR/issue detection.
274
+ const summaryBranch = `feat/${featureName}`;
233
275
  const basicSummary = {
234
276
  feature: featureName,
235
277
  iterations: nextStatus.iteration,
@@ -242,13 +284,13 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
242
284
  cacheRead: nextStatus.cacheRead,
243
285
  exitCode,
244
286
  exitCodeInferred: true,
245
- branch: getGitBranch(projectRoot),
287
+ branch: summaryBranch,
246
288
  logPath,
247
289
  errorTail,
248
290
  };
249
291
  let enhancedSummary;
250
292
  try {
251
- enhancedSummary = buildEnhancedRunSummary(basicSummary, projectRoot, featureName);
293
+ enhancedSummary = buildEnhancedRunSummary(basicSummary, projectRoot, featureName, baselineCommit);
252
294
  }
253
295
  catch (err) {
254
296
  logger.error(`Failed to build enhanced summary for ${featureName}: ${err instanceof Error ? err.message : String(err)}`);
@@ -260,7 +302,7 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
260
302
  logger.error(`Failed to persist summary file for ${featureName}: ${err instanceof Error ? err.message : String(err)}`);
261
303
  });
262
304
  }
263
- }, [featureName, projectRoot, monitorOnly]);
305
+ }, [featureName, projectRoot, monitorOnly, baselineCommit]);
264
306
  // Keep a stable ref to the latest refreshStatus so the spawn effect
265
307
  // can schedule polls without re-running when refreshStatus changes.
266
308
  const refreshStatusRef = useRef(refreshStatus);
@@ -308,6 +350,10 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
308
350
  try {
309
351
  const config = sessionState.config ?? await loadConfigWithDefaults(projectRoot);
310
352
  specsDirRef.current = config.paths.specs;
353
+ if (!cancelled) {
354
+ setLoopModel(config.loop.defaultModel);
355
+ setReviewMode((prev) => reviewModeProp ?? config.loop.reviewMode ?? prev);
356
+ }
311
357
  }
312
358
  catch (err) {
313
359
  const reason = err instanceof Error ? err.message : String(err);
@@ -350,6 +396,7 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
350
396
  configRootRef.current = config.paths.root;
351
397
  maxIterationsRef.current = config.loop.maxIterations;
352
398
  maxE2eAttemptsRef.current = config.loop.maxE2eAttempts;
399
+ setLoopModel(config.loop.defaultModel);
353
400
  const specFile = findSpecFile(projectRoot, featureName, config.paths.specs);
354
401
  if (!specFile) {
355
402
  setError(`Spec file not found for "${featureName}". Run /new ${featureName} first.`);
@@ -362,9 +409,10 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
362
409
  setIsStarting(false);
363
410
  return;
364
411
  }
365
- const reviewMode = reviewModeProp ?? config.loop.reviewMode ?? 'manual';
366
- if (reviewMode !== 'manual' && reviewMode !== 'auto' && reviewMode !== 'merge') {
367
- setError(`Invalid reviewMode '${reviewMode}'. Allowed values are 'manual', 'auto', or 'merge'.`);
412
+ const effectiveReviewMode = reviewModeProp ?? config.loop.reviewMode ?? 'manual';
413
+ setReviewMode(effectiveReviewMode);
414
+ if (effectiveReviewMode !== 'manual' && effectiveReviewMode !== 'auto' && effectiveReviewMode !== 'merge') {
415
+ setError(`Invalid reviewMode '${effectiveReviewMode}'. Allowed values are 'manual', 'auto', or 'merge'.`);
368
416
  setIsStarting(false);
369
417
  return;
370
418
  }
@@ -381,7 +429,7 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
381
429
  String(config.loop.maxIterations),
382
430
  String(config.loop.maxE2eAttempts),
383
431
  '--review-mode',
384
- reviewMode,
432
+ effectiveReviewMode,
385
433
  ];
386
434
  const child = spawn('bash', [scriptPath, ...args], {
387
435
  cwd: dirname(scriptPath),
@@ -425,6 +473,10 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
425
473
  closeSync(logFd);
426
474
  logFdClosed = true;
427
475
  }
476
+ if (!isMountedRef.current)
477
+ return;
478
+ // Wait for bash to flush state files (.phases, .tokens, .final)
479
+ await new Promise((r) => setTimeout(r, 200));
428
480
  if (!isMountedRef.current)
429
481
  return;
430
482
  let latestStatus;
@@ -436,7 +488,7 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
436
488
  catch (err) {
437
489
  logger.error(`Failed to read final run status for ${featureName}: ${err instanceof Error ? err.message : String(err)}`);
438
490
  latestStatus = { running: false, iteration: 0, maxIterations: config.loop.maxIterations, phase: 'unknown', tokensInput: 0, tokensOutput: 0, cacheCreate: 0, cacheRead: 0 };
439
- latestTasks = { tasksDone: 0, tasksPending: 0, e2eDone: 0, e2ePending: 0 };
491
+ latestTasks = { tasksDone: 0, tasksPending: 0, e2eDone: 0, e2ePending: 0, planExists: false };
440
492
  }
441
493
  const tasksDone = latestTasks.tasksDone + latestTasks.e2eDone;
442
494
  const tasksTotal = tasksDone + latestTasks.tasksPending + latestTasks.e2ePending;
@@ -453,14 +505,14 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
453
505
  cacheCreate: latestStatus.cacheCreate,
454
506
  cacheRead: latestStatus.cacheRead,
455
507
  exitCode,
456
- branch: getGitBranch(projectRoot),
508
+ branch: `feat/${featureName}`,
457
509
  logPath,
458
510
  errorTail,
459
511
  };
460
512
  // Build enhanced summary with phases, git stats, PR/issue metadata
461
513
  let enhancedSummary;
462
514
  try {
463
- enhancedSummary = buildEnhancedRunSummary(basicSummary, projectRoot, featureName);
515
+ enhancedSummary = buildEnhancedRunSummary(basicSummary, projectRoot, featureName, baselineCommit);
464
516
  }
465
517
  catch (err) {
466
518
  logger.error(`Failed to build enhanced summary for ${featureName}: ${err instanceof Error ? err.message : String(err)}`);
@@ -516,8 +568,11 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
516
568
  : actionRequest
517
569
  ? 'Select an option, Esc for default'
518
570
  : monitorOnly
519
- ? 'Ctrl+C stop, Esc back'
520
- : 'Ctrl+C stop, Esc background';
571
+ ? 'Ctrl+C stop, Esc back, Shift+R review mode'
572
+ : 'Ctrl+C stop, Esc background, Shift+R review mode';
573
+ // Progress bar label padding — align all labels to the longest one
574
+ const PROGRESS_LABEL_WIDTH = 17; // "Implementation: " padded
575
+ const padLabel = (label) => label.padEnd(PROGRESS_LABEL_WIDTH);
521
576
  // Action select handler — awaits write before clearing prompt
522
577
  const handleActionSelect = useCallback(async (choiceId) => {
523
578
  if (!actionRequest)
@@ -558,7 +613,8 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
558
613
  action: 'Run Loop',
559
614
  phase: phaseLine,
560
615
  path: featureName,
561
- }, 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), " cache:", formatNumber(status.cacheRead), ")"] }), _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] })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Activity" }), _jsx(ActivityFeed, { events: activityEvents, latestCommit: baselineCommit && latestCommit && baselineCommit !== latestCommit
616
+ extra: `review: ${reviewMode}`,
617
+ }, 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 }), loopModel && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsx(Text, { children: "Model: " }), _jsx(Text, { color: colors.blue, children: loopModel })] }))] }), _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), " cache:", formatNumber(status.cacheRead), ")"] }), _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: padLabel('Implementation:') }), _jsx(ProgressBar, { percent: percentTasks }), _jsxs(Text, { children: [String(percentTasks).padStart(3), "%"] }), _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: padLabel('E2E Tests:') }), _jsx(ProgressBar, { percent: percentE2e }), _jsxs(Text, { children: [String(percentE2e).padStart(3), "%"] }), _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: padLabel('Overall:') }), _jsx(ProgressBar, { percent: percentAll }), _jsxs(Text, { children: [String(percentAll).padStart(3), "%"] }), _jsxs(Text, { color: colors.green, children: ['\u2713', " ", doneAll] }), _jsxs(Text, { color: colors.yellow, children: ['\u25cb', " ", totalAll - doneAll] })] })] }), recentCommits.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Recent Commits" }), recentCommits.map((c) => (_jsx(Box, { flexDirection: "row", children: _jsxs(Text, { dimColor: true, children: [" ", c.hash, " ", c.title] }) }, c.hash)))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Activity" }), _jsx(ActivityFeed, { events: activityEvents, latestCommit: baselineCommit && latestCommit && baselineCommit !== latestCommit
562
618
  ? `${baselineCommit} \u2192 ${latestCommit}`
563
619
  : latestCommit || undefined })] })] }))) }));
564
620
  }
@@ -21,4 +21,4 @@ import type { RunSummary } from '../screens/RunScreen.js';
21
21
  * which blocks the event loop. Callers should wrap in try-catch to handle
22
22
  * failures gracefully.
23
23
  */
24
- export declare function buildEnhancedRunSummary(basicSummary: RunSummary, projectRoot: string, feature: string): RunSummary;
24
+ export declare function buildEnhancedRunSummary(basicSummary: RunSummary, projectRoot: string, feature: string, baselineOverride?: string | null): RunSummary;
@@ -3,86 +3,10 @@
3
3
  */
4
4
  import { existsSync, readFileSync } from 'node:fs';
5
5
  import { logger } from '../../utils/logger.js';
6
+ import { execFileSync } from 'node:child_process';
6
7
  import { getCurrentCommitHash, getDiffStats, getCommitList } from './git-summary.js';
7
8
  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
- }
9
+ import { parsePhases } from './loop-status.js';
86
10
  /**
87
11
  * Read baseline commit hash from the baseline file.
88
12
  *
@@ -127,7 +51,7 @@ function readBaselineCommit(baselineFilePath) {
127
51
  * which blocks the event loop. Callers should wrap in try-catch to handle
128
52
  * failures gracefully.
129
53
  */
130
- export function buildEnhancedRunSummary(basicSummary, projectRoot, feature) {
54
+ export function buildEnhancedRunSummary(basicSummary, projectRoot, feature, baselineOverride) {
131
55
  const phasesFilePath = `/tmp/ralph-loop-${feature}.phases`;
132
56
  const baselineFilePath = `/tmp/ralph-loop-${feature}.baseline`;
133
57
  // Parse phases and set implementation iterations from actual loop count
@@ -153,8 +77,10 @@ export function buildEnhancedRunSummary(basicSummary, projectRoot, feature) {
153
77
  completed: basicSummary.tasksDone,
154
78
  total: basicSummary.tasksTotal,
155
79
  };
156
- // Git changes and commits
157
- const baselineCommit = readBaselineCommit(baselineFilePath);
80
+ // Git changes and commits — use override if provided (avoids re-reading a cleaned-up file)
81
+ const baselineCommit = baselineOverride !== undefined
82
+ ? (baselineOverride ? baselineOverride.substring(0, 7) : null)
83
+ : readBaselineCommit(baselineFilePath);
158
84
  const currentCommit = getCurrentCommitHash(projectRoot);
159
85
  let changes = { available: false };
160
86
  let commits = { available: false };
@@ -205,13 +131,19 @@ export function buildEnhancedRunSummary(basicSummary, projectRoot, feature) {
205
131
  available: true,
206
132
  created: true,
207
133
  };
208
- // Try to get linked issue, passing prInfo to avoid redundant gh call
209
- const issueInfo = getLinkedIssue(projectRoot, basicSummary.branch, prInfo);
134
+ // Try to get linked issue, passing prInfo and feature name for fallback detection
135
+ const issueInfo = getLinkedIssue(projectRoot, basicSummary.branch, prInfo, feature);
210
136
  if (issueInfo) {
137
+ // When PR is merged with "Closes #N", GitHub auto-closes the issue.
138
+ // The summary may be built before GitHub processes the webhook, so
139
+ // infer closure from PR state to avoid showing stale "OPEN" status.
140
+ const inferredState = prInfo.state === 'MERGED' && issueInfo.state === 'OPEN'
141
+ ? 'CLOSED'
142
+ : issueInfo.state;
211
143
  issue = {
212
144
  number: issueInfo.number,
213
145
  url: issueInfo.url,
214
- status: issueInfo.state,
146
+ status: inferredState,
215
147
  available: true,
216
148
  linked: true,
217
149
  };
@@ -219,6 +151,30 @@ export function buildEnhancedRunSummary(basicSummary, projectRoot, feature) {
219
151
  else {
220
152
  issue = { available: true, linked: false };
221
153
  }
154
+ // Enrich commits from PR when squash-merge detected (1 local commit + merged PR)
155
+ if (prInfo.state === 'MERGED' &&
156
+ commits.available &&
157
+ commits.commitList &&
158
+ commits.commitList.length <= 1) {
159
+ try {
160
+ const prCommitsOutput = execFileSync('gh', ['pr', 'view', String(prInfo.number), '--json', 'commits'], { cwd: projectRoot, encoding: 'utf-8', timeout: 10_000 }).trim();
161
+ const prCommitsData = JSON.parse(prCommitsOutput);
162
+ const prCommits = prCommitsData.commits;
163
+ if (Array.isArray(prCommits) && prCommits.length > 1) {
164
+ commits = {
165
+ ...commits,
166
+ commitList: prCommits.map((c) => ({
167
+ hash: c.oid?.substring(0, 7) ?? '',
168
+ title: c.messageHeadline ?? '',
169
+ })),
170
+ mergeType: 'squash',
171
+ };
172
+ }
173
+ }
174
+ catch (err) {
175
+ logger.debug(`Failed to fetch PR commits: ${err instanceof Error ? err.message : String(err)}`);
176
+ }
177
+ }
222
178
  }
223
179
  else {
224
180
  pr = { available: true, created: false };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Clear the terminal screen to prevent stale PTY scroll buffer artifacts.
3
+ *
4
+ * When Ink re-renders significantly shorter output (e.g. after dismissing a
5
+ * 25-row IssuePicker), the old content remains in the PTY scroll buffer,
6
+ * causing visual duplication of the banner and other content. This sends
7
+ * ANSI escape sequences to clear the entire screen and reset the cursor.
8
+ *
9
+ * This is an intentional mix of imperative stdout writes with Ink's
10
+ * declarative rendering — Ink has no API to flush the PTY scroll buffer.
11
+ * Call this *before* state updates that shrink the rendered output so
12
+ * Ink's next render paints onto a clean screen.
13
+ */
14
+ export declare function clearScreen(stdout: NodeJS.WriteStream): void;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Clear the terminal screen to prevent stale PTY scroll buffer artifacts.
3
+ *
4
+ * When Ink re-renders significantly shorter output (e.g. after dismissing a
5
+ * 25-row IssuePicker), the old content remains in the PTY scroll buffer,
6
+ * causing visual duplication of the banner and other content. This sends
7
+ * ANSI escape sequences to clear the entire screen and reset the cursor.
8
+ *
9
+ * This is an intentional mix of imperative stdout writes with Ink's
10
+ * declarative rendering — Ink has no API to flush the PTY scroll buffer.
11
+ * Call this *before* state updates that shrink the rendered output so
12
+ * Ink's next render paints onto a clean screen.
13
+ */
14
+ export function clearScreen(stdout) {
15
+ stdout.write('\x1b[2J\x1b[H');
16
+ }
@@ -34,4 +34,5 @@ export declare function getDiffStats(projectRoot: string, fromHash: string, toHa
34
34
  * @param toHash - Ending commit hash (inclusive)
35
35
  * @returns Array of commit entries, or null if not available
36
36
  */
37
+ export declare function getRecentCommits(projectRoot: string, count?: number): CommitLogEntry[];
37
38
  export declare function getCommitList(projectRoot: string, fromHash: string, toHash: string): CommitLogEntry[] | null;