wiggum-cli 0.14.0 → 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 (38) 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 +157 -7
  12. package/dist/tui/app.js +20 -2
  13. package/dist/tui/components/ChatInput.d.ts +3 -1
  14. package/dist/tui/components/ChatInput.js +23 -4
  15. package/dist/tui/components/CommandDropdown.d.ts +3 -1
  16. package/dist/tui/components/CommandDropdown.js +10 -7
  17. package/dist/tui/components/SpecCompletionSummary.d.ts +3 -2
  18. package/dist/tui/components/SpecCompletionSummary.js +26 -9
  19. package/dist/tui/components/SummaryBox.d.ts +0 -3
  20. package/dist/tui/components/SummaryBox.js +4 -2
  21. package/dist/tui/orchestration/interview-orchestrator.js +35 -5
  22. package/dist/tui/screens/MainShell.js +2 -1
  23. package/dist/tui/screens/RunScreen.js +81 -12
  24. package/dist/tui/utils/action-inbox.d.ts +43 -0
  25. package/dist/tui/utils/action-inbox.js +109 -0
  26. package/dist/tui/utils/polishGoal.d.ts +37 -0
  27. package/dist/tui/utils/polishGoal.js +170 -0
  28. package/dist/utils/config.d.ts +1 -1
  29. package/dist/utils/fuzzy-match.d.ts +5 -0
  30. package/dist/utils/fuzzy-match.js +16 -0
  31. package/dist/utils/spec-names.d.ts +6 -0
  32. package/dist/utils/spec-names.js +23 -0
  33. package/package.json +9 -4
  34. package/src/templates/config/ralph.config.cjs.tmpl +1 -1
  35. package/src/templates/prompts/PROMPT_review_auto.md.tmpl +7 -41
  36. package/src/templates/prompts/PROMPT_review_merge.md.tmpl +163 -0
  37. package/src/templates/scripts/feature-loop-actions.test.ts +92 -0
  38. package/src/templates/scripts/feature-loop.sh.tmpl +157 -7
@@ -6,8 +6,8 @@
6
6
  # Options:
7
7
  # --worktree Use git worktree for isolation (enables parallel execution)
8
8
  # --resume Resume an interrupted loop (reuses existing branch/worktree)
9
- # --model MODEL Claude model to use (e.g., opus, sonnet, claude-sonnet-4-5-20250514)
10
- # --review-mode MODE Review mode: 'manual' (stop at PR) or 'auto' (review + merge). Default: 'manual'
9
+ # --model MODEL Claude model to use (e.g., opus, sonnet, claude-sonnet-4-5-20250929)
10
+ # --review-mode MODE Review mode: 'manual' (stop at PR), 'auto' (review, no merge), or 'merge' (review + merge). Default: 'manual'
11
11
 
12
12
  set -e
13
13
  set -o pipefail
@@ -91,8 +91,8 @@ if [ -z "$REVIEW_MODE" ]; then
91
91
  fi
92
92
 
93
93
  # Validate review mode
94
- if [ "$REVIEW_MODE" != "manual" ] && [ "$REVIEW_MODE" != "auto" ]; then
95
- echo "ERROR: Invalid review mode: '$REVIEW_MODE'. Allowed values are 'manual' or 'auto'." >&2
94
+ if [ "$REVIEW_MODE" != "manual" ] && [ "$REVIEW_MODE" != "auto" ] && [ "$REVIEW_MODE" != "merge" ]; then
95
+ echo "ERROR: Invalid review mode: '$REVIEW_MODE'. Allowed values are 'manual', 'auto', or 'merge'." >&2
96
96
  exit 1
97
97
  fi
98
98
 
@@ -138,6 +138,68 @@ parse_and_accumulate_tokens() {
138
138
  echo "${new_input}|${new_output}" > "$TOKENS_FILE"
139
139
  }
140
140
 
141
+ # Action inbox: write request file if not already present
142
+ write_action_request() {
143
+ local action_file="/tmp/ralph-loop-${FEATURE}.action.json"
144
+ if [ -f "$action_file" ]; then
145
+ echo "WARNING: Action request file already exists, skipping write: $action_file" >&2
146
+ return 0
147
+ fi
148
+ cat > "$action_file" << 'EOF'
149
+ {
150
+ "id": "post_pr_choice",
151
+ "prompt": "Loop complete. What would you like to do?",
152
+ "choices": [
153
+ {"id": "done", "label": "Done — end loop"},
154
+ {"id": "merge_local", "label": "Merge back to main locally"},
155
+ {"id": "keep_branch", "label": "Keep branch as-is"},
156
+ {"id": "discard", "label": "Discard this work"}
157
+ ],
158
+ "default": "done"
159
+ }
160
+ EOF
161
+ echo "Action request written: $action_file"
162
+ }
163
+
164
+ # Action inbox: poll for reply file, fallback to default after 15 minutes
165
+ poll_action_reply() {
166
+ local action_file="/tmp/ralph-loop-${FEATURE}.action.json"
167
+ local reply_file="/tmp/ralph-loop-${FEATURE}.action.reply.json"
168
+ local default_choice="keep_branch"
169
+ local timeout=900 # 15 minutes in seconds
170
+ local elapsed=0
171
+
172
+ # Read default from action file if present
173
+ if [ -f "$action_file" ]; then
174
+ local parsed_default
175
+ parsed_default=$(node -e "try { const d=require('fs').readFileSync(process.argv[1],'utf8'); console.log(JSON.parse(d).default||'keep_branch'); } catch(e) { console.log('keep_branch'); }" "$action_file" 2>/dev/null || echo "keep_branch")
176
+ if [ -n "$parsed_default" ]; then
177
+ default_choice="$parsed_default"
178
+ fi
179
+ fi
180
+
181
+ while [ $elapsed -lt $timeout ]; do
182
+ if [ -f "$reply_file" ]; then
183
+ local choice
184
+ choice=$(node -e "try { const d=require('fs').readFileSync(process.argv[1],'utf8'); console.log(JSON.parse(d).choice||''); } catch(e) { console.log(''); }" "$reply_file" 2>/dev/null || echo "")
185
+ if [ -n "$choice" ]; then
186
+ echo "User selected: $choice" >&2
187
+ # Cleanup both files
188
+ rm -f "$action_file" "$reply_file" 2>/dev/null || true
189
+ echo "$choice"
190
+ return 0
191
+ fi
192
+ fi
193
+ sleep 1
194
+ elapsed=$((elapsed + 1))
195
+ done
196
+
197
+ # Timeout: use default
198
+ echo "Action reply timeout after ${timeout}s, using default: $default_choice" >&2
199
+ rm -f "$action_file" "$reply_file" 2>/dev/null || true
200
+ echo "$default_choice"
201
+ }
202
+
141
203
  # Initialize tokens
142
204
  init_tokens
143
205
 
@@ -343,18 +405,106 @@ echo "======================== PR & REVIEW PHASE ========================"
343
405
  write_phase_start "pr_review"
344
406
  export FEATURE APP_DIR SPEC_DIR PROMPTS_DIR
345
407
  PR_STATUS="success"
408
+ MAX_REVIEW_ATTEMPTS=3
409
+
346
410
  if [ "$REVIEW_MODE" = "manual" ]; then
347
411
  if ! cat "$PROMPTS_DIR/PROMPT_review_manual.md" | envsubst | $CLAUDE_CMD_OPUS 2>&1 | tee "$CLAUDE_OUTPUT"; then
348
412
  PR_STATUS="failed"
349
413
  fi
350
- else
351
- if ! cat "$PROMPTS_DIR/PROMPT_review_auto.md" | envsubst | $CLAUDE_CMD_OPUS 2>&1 | tee "$CLAUDE_OUTPUT"; then
414
+ parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
415
+
416
+ elif [ "$REVIEW_MODE" = "merge" ]; then
417
+ # Merge mode: create PR, iterate review+fixes until approved, then merge
418
+ REVIEW_ATTEMPT=0
419
+ REVIEW_APPROVED=false
420
+ while [ $REVIEW_ATTEMPT -lt $MAX_REVIEW_ATTEMPTS ]; do
421
+ REVIEW_ATTEMPT=$((REVIEW_ATTEMPT + 1))
422
+ echo "--- Review attempt $REVIEW_ATTEMPT of $MAX_REVIEW_ATTEMPTS ---"
423
+ cat "$PROMPTS_DIR/PROMPT_review_merge.md" | envsubst | $CLAUDE_CMD_OPUS 2>&1 | tee "$CLAUDE_OUTPUT" || true
424
+ parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
425
+
426
+ # Check if output contains APPROVED
427
+ if grep -qi "APPROVED" "$CLAUDE_OUTPUT" 2>/dev/null; then
428
+ echo "Review approved!"
429
+ REVIEW_APPROVED=true
430
+ break
431
+ fi
432
+
433
+ if [ $REVIEW_ATTEMPT -lt $MAX_REVIEW_ATTEMPTS ]; then
434
+ echo "Review found issues. Running fix iteration..."
435
+ echo "Fix the issues found in the code review above. Run git diff main to see the current changes, then:
436
+ 1. Fix each issue referenced in the review
437
+ 2. Run tests: npm test
438
+ 3. Commit and push the fixes
439
+ Do NOT propose completion options or ask interactive questions. Just fix, test, commit, push." | $CLAUDE_CMD_IMPL 2>&1 | tee "$CLAUDE_OUTPUT" || true
440
+ parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
441
+ fi
442
+ done
443
+ if [ "$REVIEW_APPROVED" != true ]; then
444
+ echo "Review not approved after $MAX_REVIEW_ATTEMPTS attempts."
352
445
  PR_STATUS="failed"
353
446
  fi
447
+
448
+ else
449
+ # Auto mode: create PR, iterate review+fixes until approved (no merge)
450
+ REVIEW_ATTEMPT=0
451
+ REVIEW_APPROVED=false
452
+ while [ $REVIEW_ATTEMPT -lt $MAX_REVIEW_ATTEMPTS ]; do
453
+ REVIEW_ATTEMPT=$((REVIEW_ATTEMPT + 1))
454
+ echo "--- Review attempt $REVIEW_ATTEMPT of $MAX_REVIEW_ATTEMPTS ---"
455
+ cat "$PROMPTS_DIR/PROMPT_review_auto.md" | envsubst | $CLAUDE_CMD_OPUS 2>&1 | tee "$CLAUDE_OUTPUT" || true
456
+ parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
457
+
458
+ # Check if output contains APPROVED
459
+ if grep -qi "APPROVED" "$CLAUDE_OUTPUT" 2>/dev/null; then
460
+ echo "Review approved!"
461
+ REVIEW_APPROVED=true
462
+ break
463
+ fi
464
+
465
+ if [ $REVIEW_ATTEMPT -lt $MAX_REVIEW_ATTEMPTS ]; then
466
+ echo "Review found issues. Running fix iteration..."
467
+ echo "Fix the issues found in the code review above. Run git diff main to see the current changes, then:
468
+ 1. Fix each issue referenced in the review
469
+ 2. Run tests: npm test
470
+ 3. Commit and push the fixes
471
+ Do NOT propose completion options or ask interactive questions. Just fix, test, commit, push." | $CLAUDE_CMD_IMPL 2>&1 | tee "$CLAUDE_OUTPUT" || true
472
+ parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
473
+ fi
474
+ done
475
+ if [ "$REVIEW_APPROVED" != true ]; then
476
+ echo "Review not approved after $MAX_REVIEW_ATTEMPTS attempts. PR ready for manual review."
477
+ fi
354
478
  fi
355
- parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
356
479
  write_phase_end "pr_review" "$PR_STATUS"
357
480
 
481
+ # Phase 7.5: Post-completion action request
482
+ echo "======================== ACTION REQUEST PHASE ========================"
483
+ write_action_request
484
+ CHOSEN_ACTION=$(poll_action_reply)
485
+ echo "User chose: $CHOSEN_ACTION"
486
+
487
+ # Dispatch based on user choice
488
+ case "$CHOSEN_ACTION" in
489
+ done)
490
+ echo "Loop complete. Exiting."
491
+ ;;
492
+ merge_local)
493
+ echo "Merging back to main locally..."
494
+ git checkout main 2>/dev/null || git checkout master
495
+ git merge --squash "$BRANCH" && git commit -m "feat($FEATURE): squash merge from $BRANCH"
496
+ echo "Merged. You can delete the branch with: git branch -D $BRANCH"
497
+ ;;
498
+ discard)
499
+ echo "Discarding work on branch $BRANCH..."
500
+ git checkout main 2>/dev/null || git checkout master
501
+ git branch -D "$BRANCH" 2>/dev/null || echo "Branch $BRANCH not found locally."
502
+ ;;
503
+ keep_branch|*)
504
+ echo "Keeping branch $BRANCH as-is."
505
+ ;;
506
+ esac
507
+
358
508
  # Persist final status for TUI summaries
359
509
  if ! echo "$ITERATION|$MAX_ITERATIONS|$(date +%s)|done" > "$FINAL_STATUS_FILE"; then
360
510
  echo "WARNING: Failed to write final status file: $FINAL_STATUS_FILE" >&2
package/dist/tui/app.js CHANGED
@@ -14,6 +14,7 @@ import { Box, Text, render, useStdout } from 'ink';
14
14
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
15
15
  import { join } from 'node:path';
16
16
  import { loadConfigWithDefaults } from '../utils/config.js';
17
+ import { listSpecNames } from '../utils/spec-names.js';
17
18
  import { logger } from '../utils/logger.js';
18
19
  import { InterviewScreen } from './screens/InterviewScreen.js';
19
20
  import { InitScreen } from './screens/InitScreen.js';
@@ -65,6 +66,14 @@ interviewProps, onComplete, onExit, }) {
65
66
  }
66
67
  savedPath = join(specsDir, `${featureName}.md`);
67
68
  writeFileSync(savedPath, spec, 'utf-8');
69
+ // Refresh spec name cache so the new spec shows in /run autocomplete
70
+ try {
71
+ const updatedSpecNames = await listSpecNames(specsDir);
72
+ setSessionState((prev) => ({ ...prev, specNames: updatedSpecNames }));
73
+ }
74
+ catch {
75
+ // Non-critical: autocomplete will update on next restart
76
+ }
68
77
  onComplete?.(savedPath);
69
78
  }
70
79
  catch (err) {
@@ -117,8 +126,17 @@ interviewProps, onComplete, onExit, }) {
117
126
  /**
118
127
  * Handle init completion - update state and navigate to shell
119
128
  */
120
- const handleInitComplete = useCallback((newState, generatedFiles) => {
121
- setSessionState(newState);
129
+ const handleInitComplete = useCallback(async (newState, generatedFiles) => {
130
+ // Refresh spec names after init (config may have changed)
131
+ let specNames = [];
132
+ try {
133
+ const specsDir = join(newState.projectRoot, newState.config?.paths.specs ?? '.ralph/specs');
134
+ specNames = await listSpecNames(specsDir);
135
+ }
136
+ catch {
137
+ // Non-critical: autocomplete will work without spec names
138
+ }
139
+ setSessionState({ ...newState, specNames });
122
140
  const fileCount = generatedFiles?.length ?? 0;
123
141
  const msg = fileCount > 0
124
142
  ? `\u2713 Initialization complete. Generated ${fileCount} configuration file${fileCount === 1 ? '' : 's'}.`
@@ -30,6 +30,8 @@ export interface ChatInputProps {
30
30
  commands?: Command[];
31
31
  /** Called when a slash command is selected */
32
32
  onCommand?: (command: string) => void;
33
+ /** Spec suggestions for /run argument autocomplete */
34
+ specSuggestions?: Command[];
33
35
  }
34
36
  /**
35
37
  * ChatInput component
@@ -61,4 +63,4 @@ export interface ChatInputProps {
61
63
  * // Renders: › Type your message...
62
64
  * ```
63
65
  */
64
- export declare function ChatInput({ onSubmit, placeholder, disabled, allowEmpty, commands, onCommand, }: ChatInputProps): React.ReactElement;
66
+ export declare function ChatInput({ onSubmit, placeholder, disabled, allowEmpty, commands, onCommand, specSuggestions, }: ChatInputProps): React.ReactElement;
@@ -19,6 +19,7 @@ import { theme } from '../theme.js';
19
19
  import { CommandDropdown, DEFAULT_COMMANDS } from './CommandDropdown.js';
20
20
  import { useCommandHistory } from '../hooks/useCommandHistory.js';
21
21
  import { normalizePastedText, insertTextAtCursor, deleteCharBefore, deleteWordBefore, moveCursorByWordLeft, moveCursorByWordRight, } from '../utils/input-utils.js';
22
+ const RUN_PREFIX = '/run ';
22
23
  /**
23
24
  * ChatInput component
24
25
  *
@@ -49,7 +50,7 @@ import { normalizePastedText, insertTextAtCursor, deleteCharBefore, deleteWordBe
49
50
  * // Renders: › Type your message...
50
51
  * ```
51
52
  */
52
- export function ChatInput({ onSubmit, placeholder = 'Type your message...', disabled = false, allowEmpty = false, commands = DEFAULT_COMMANDS, onCommand, }) {
53
+ export function ChatInput({ onSubmit, placeholder = 'Type your message...', disabled = false, allowEmpty = false, commands = DEFAULT_COMMANDS, onCommand, specSuggestions, }) {
53
54
  const [value, setValue] = useState('');
54
55
  const [cursorOffset, setCursorOffset] = useState(0);
55
56
  const [showDropdown, setShowDropdown] = useState(false);
@@ -61,6 +62,12 @@ export function ChatInput({ onSubmit, placeholder = 'Type your message...', disa
61
62
  const hasSpace = value.includes(' ');
62
63
  // Only filter on the command name part (before the first space)
63
64
  const commandFilter = isSlashCommand ? value.slice(1).split(' ')[0] : '';
65
+ // Detect "/run " argument autocomplete mode
66
+ const isRunArgMode = specSuggestions !== undefined &&
67
+ specSuggestions.length > 0 &&
68
+ value.startsWith(RUN_PREFIX);
69
+ // The text the user has typed after "/run "
70
+ const runArgFilter = isRunArgMode ? value.slice(RUN_PREFIX.length) : '';
64
71
  // Store draft input when starting history navigation
65
72
  const draftRef = useRef('');
66
73
  const clampCursor = useCallback((nextValue, nextCursor) => {
@@ -77,13 +84,17 @@ export function ChatInput({ onSubmit, placeholder = 'Type your message...', disa
77
84
  if (!fromHistory) {
78
85
  resetNavigation();
79
86
  }
80
- if (nextValue.startsWith('/') && !nextValue.includes(' ')) {
87
+ const isCommandMode = nextValue.startsWith('/') && !nextValue.includes(' ');
88
+ const isRunArgModeNext = specSuggestions !== undefined &&
89
+ specSuggestions.length > 0 &&
90
+ nextValue.startsWith('/run ');
91
+ if (isCommandMode || isRunArgModeNext) {
81
92
  setShowDropdown(true);
82
93
  }
83
94
  else {
84
95
  setShowDropdown(false);
85
96
  }
86
- }, [clampCursor, resetNavigation]);
97
+ }, [clampCursor, resetNavigation, specSuggestions]);
87
98
  const handleEscapeSequence = useCallback((input) => {
88
99
  const seq = input;
89
100
  if (seq === '\u001bb' || seq === '\u001b[1;3D') {
@@ -254,6 +265,14 @@ export function ChatInput({ onSubmit, placeholder = 'Type your message...', disa
254
265
  updateValue('', 0, true);
255
266
  setShowDropdown(false);
256
267
  }, [onCommand, onSubmit, addToHistory, updateValue]);
268
+ /**
269
+ * Handle spec selection from /run argument dropdown
270
+ */
271
+ const handleSpecSelect = useCallback((specName) => {
272
+ const newValue = `/run ${specName}`;
273
+ updateValue(newValue, newValue.length, true);
274
+ setShowDropdown(false);
275
+ }, [updateValue]);
257
276
  /**
258
277
  * Handle dropdown cancel
259
278
  */
@@ -268,5 +287,5 @@ export function ChatInput({ onSubmit, placeholder = 'Type your message...', disa
268
287
  const rightText = value.slice(cursorOffset);
269
288
  const cursorChar = rightText ? rightText[0] : ' ';
270
289
  const remainder = rightText ? rightText.slice(1) : '';
271
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: theme.colors.prompt, bold: true, children: [theme.chars.prompt, ' '] }), value.length === 0 ? (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { inverse: true, children: ' ' }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: leftText }), _jsx(Text, { inverse: true, children: cursorChar }), _jsx(Text, { children: remainder })] }))] }), showDropdown && isSlashCommand && !hasSpace && (_jsx(CommandDropdown, { commands: commands, filter: commandFilter, onSelect: handleCommandSelect, onCancel: handleDropdownCancel }))] }));
290
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: theme.colors.prompt, bold: true, children: [theme.chars.prompt, ' '] }), value.length === 0 ? (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { inverse: true, children: ' ' }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: leftText }), _jsx(Text, { inverse: true, children: cursorChar }), _jsx(Text, { children: remainder })] }))] }), showDropdown && isRunArgMode && specSuggestions && (_jsx(CommandDropdown, { commands: specSuggestions, filter: runArgFilter, onSelect: handleSpecSelect, onCancel: handleDropdownCancel, itemPrefix: "" })), showDropdown && isSlashCommand && !hasSpace && !isRunArgMode && (_jsx(CommandDropdown, { commands: commands, filter: commandFilter, onSelect: handleCommandSelect, onCancel: handleDropdownCancel }))] }));
272
291
  }
@@ -27,6 +27,8 @@ export interface CommandDropdownProps {
27
27
  onSelect: (command: string) => void;
28
28
  /** Called when dropdown is dismissed (Escape) */
29
29
  onCancel: () => void;
30
+ /** Prefix displayed before each item name (default: '/') */
31
+ itemPrefix?: string;
30
32
  }
31
33
  /**
32
34
  * CommandDropdown component
@@ -47,7 +49,7 @@ export interface CommandDropdownProps {
47
49
  * />
48
50
  * ```
49
51
  */
50
- export declare function CommandDropdown({ commands, filter, onSelect, onCancel, }: CommandDropdownProps): React.ReactElement;
52
+ export declare function CommandDropdown({ commands, filter, onSelect, onCancel, itemPrefix, }: CommandDropdownProps): React.ReactElement;
51
53
  /**
52
54
  * Default commands available in wiggum
53
55
  */
@@ -9,11 +9,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  import React, { useState } from 'react';
10
10
  import { Box, Text, useInput } from 'ink';
11
11
  import { colors, box } from '../theme.js';
12
+ import { fuzzyMatch } from '../../utils/fuzzy-match.js';
12
13
  /**
13
14
  * Calculate the max width needed for command names
14
15
  */
15
- function getMaxCommandWidth(commands) {
16
- return Math.max(...commands.map((cmd) => cmd.name.length + 1)); // +1 for the /
16
+ function getMaxCommandWidth(commands, prefix) {
17
+ if (commands.length === 0)
18
+ return 0;
19
+ return Math.max(...commands.map((cmd) => cmd.name.length + prefix.length));
17
20
  }
18
21
  /**
19
22
  * CommandDropdown component
@@ -34,12 +37,12 @@ function getMaxCommandWidth(commands) {
34
37
  * />
35
38
  * ```
36
39
  */
37
- export function CommandDropdown({ commands, filter, onSelect, onCancel, }) {
40
+ export function CommandDropdown({ commands, filter, onSelect, onCancel, itemPrefix = '/', }) {
38
41
  const [selectedIndex, setSelectedIndex] = useState(0);
39
- // Filter commands based on input
40
- const filteredCommands = commands.filter((cmd) => cmd.name.toLowerCase().includes(filter.toLowerCase()));
42
+ // Filter commands based on input using fuzzy matching
43
+ const filteredCommands = commands.filter((cmd) => fuzzyMatch(filter, cmd.name));
41
44
  // Calculate column widths for alignment
42
- const maxCmdWidth = getMaxCommandWidth(filteredCommands);
45
+ const maxCmdWidth = getMaxCommandWidth(filteredCommands, itemPrefix);
43
46
  // Handle keyboard input
44
47
  useInput((input, key) => {
45
48
  if (key.escape) {
@@ -72,7 +75,7 @@ export function CommandDropdown({ commands, filter, onSelect, onCancel, }) {
72
75
  const bottomBorder = box.bottomLeft + box.horizontal.repeat(contentWidth) + box.bottomRight;
73
76
  return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: topBorder }), filteredCommands.map((cmd, index) => {
74
77
  const isSelected = index === selectedIndex;
75
- const cmdText = `/${cmd.name}`;
78
+ const cmdText = `${itemPrefix}${cmd.name}`;
76
79
  const padding = ' '.repeat(Math.max(0, maxCmdWidth - cmdText.length + 2));
77
80
  return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: box.vertical }), _jsxs(Text, { backgroundColor: isSelected ? colors.yellow : undefined, color: isSelected ? colors.brown : colors.yellow, children: [' ', cmdText] }), _jsx(Text, { children: padding }), _jsx(Text, { backgroundColor: isSelected ? colors.yellow : undefined, color: isSelected ? colors.brown : undefined, dimColor: !isSelected, children: cmd.description }), _jsx(Text, { children: ' '.repeat(Math.max(0, contentWidth - cmdText.length - padding.length - cmd.description.length - 1)) }), _jsx(Text, { dimColor: true, children: box.vertical })] }, cmd.name));
78
81
  }), _jsx(Text, { dimColor: true, children: bottomBorder })] }));
@@ -20,7 +20,7 @@ export interface SpecCompletionSummaryProps {
20
20
  /** Conversation messages from the interview */
21
21
  messages: Message[];
22
22
  }
23
- /** Strip filler prefixes ('you want', 'understood', 'got it') from AI recap text and capitalize. */
23
+ /** Strip filler prefixes from AI recap text and capitalize. */
24
24
  export declare function normalizeRecap(text: string): string;
25
25
  /** Strip user speech filler and normalize decision text: add trailing period if missing, capitalize. */
26
26
  export declare function normalizeUserDecision(text: string): string;
@@ -31,7 +31,8 @@ export declare function isUsefulDecision(entry: string): boolean;
31
31
  /**
32
32
  * Extract goal and key decisions from conversation messages.
33
33
  *
34
- * @returns `goalCandidate` — a one-line summary of the feature goal, and
34
+ * @returns `goalCandidate` — a one-line summary of the feature goal (polished
35
+ * via selectGoalSource + polishGoalSentence), and
35
36
  * `decisions` — up to 4 key decisions extracted from the conversation.
36
37
  */
37
38
  export declare function extractRecap(messages: Message[], featureName: string): {
@@ -3,14 +3,26 @@ import { Box, Text } from 'ink';
3
3
  import { StatusLine } from './StatusLine.js';
4
4
  import { colors, theme } from '../theme.js';
5
5
  import { PHASE_CONFIGS } from '../hooks/useSpecGenerator.js';
6
+ import { selectGoalSource, polishGoalSentence } from '../utils/polishGoal.js';
6
7
  const MAX_RECAP_SOURCE_LENGTH = 1200;
7
- /** Strip filler prefixes ('you want', 'understood', 'got it') from AI recap text and capitalize. */
8
+ /** Strip filler prefixes from AI recap text and capitalize. */
8
9
  export function normalizeRecap(text) {
9
10
  let result = text.trim();
10
11
  result = result.replace(/^[^a-z0-9]+/i, '');
11
12
  result = result.replace(/^you want\s*/i, '');
13
+ result = result.replace(/^you're\s+\w+ing\s+to\s*/i, '');
14
+ result = result.replace(/^you'd like to\s*/i, '');
15
+ result = result.replace(/^you need to\s*/i, '');
16
+ result = result.replace(/^you would like to\s*/i, '');
12
17
  result = result.replace(/^understood[:,]?\s*/i, '');
13
18
  result = result.replace(/^got it[-\u2014:]*\s*/i, '');
19
+ result = result.replace(/^i understand\s*(that\s*)?/i, '');
20
+ result = result.replace(/^so you\s*/i, '');
21
+ result = result.replace(/^to summarize[:,]?\s*/i, '');
22
+ result = result.replace(/^in summary[:,]?\s*/i, '');
23
+ result = result.replace(/^here'?s what I understand[:,]?\s*/i, '');
24
+ if (!result)
25
+ return text.trim();
14
26
  return result.charAt(0).toUpperCase() + result.slice(1);
15
27
  }
16
28
  /** Strip user speech filler and normalize decision text: add trailing period if missing, capitalize. */
@@ -48,7 +60,8 @@ export function isUsefulDecision(entry) {
48
60
  /**
49
61
  * Extract goal and key decisions from conversation messages.
50
62
  *
51
- * @returns `goalCandidate` — a one-line summary of the feature goal, and
63
+ * @returns `goalCandidate` — a one-line summary of the feature goal (polished
64
+ * via selectGoalSource + polishGoalSentence), and
52
65
  * `decisions` — up to 4 key decisions extracted from the conversation.
53
66
  */
54
67
  export function extractRecap(messages, featureName) {
@@ -64,14 +77,18 @@ export function extractRecap(messages, featureName) {
64
77
  .filter((para) => para.length > 0 && para.length <= 320);
65
78
  const recapCandidates = assistantParagraphs
66
79
  .map((para) => para.replace(/^[^a-z0-9]+/i, '').trim())
67
- .filter((para) => /^(you want|understood|got it)/i.test(para))
80
+ .filter((para) => /^(you want|you're\s+\w+ing\s+to|you'd like|you need|you would like|understood|got it|i understand|so you|to summarize|in summary|here'?s what)/i.test(para))
68
81
  .map((para) => para.split(/next question:/i)[0].trim())
69
82
  .filter((para) => para.length > 0);
70
- const goalCandidate = recapCandidates.length > 0
71
- ? normalizeRecap(recapCandidates[0])
72
- : (nonUrlUserMessages.find((content) => content.length > 20)
73
- ? normalizeUserDecision(nonUrlUserMessages.find((content) => content.length > 20))
74
- : (nonUrlUserMessages[0] ? normalizeUserDecision(nonUrlUserMessages[0]) : `Define "${featureName}"`));
83
+ // Build structured inputs for the goal-source selector
84
+ const aiRecap = recapCandidates.length > 0 ? normalizeRecap(recapCandidates[0]) : '';
85
+ const keyDecisions = recapCandidates.length > 1
86
+ ? recapCandidates.slice(1).map((c) => normalizeRecap(c)).filter(isUsefulDecision)
87
+ : [];
88
+ const firstSubstantialUserMessage = nonUrlUserMessages.find((content) => content.length > 20) ?? nonUrlUserMessages[0] ?? `Define "${featureName}"`;
89
+ const userRequest = normalizeUserDecision(firstSubstantialUserMessage);
90
+ const { text: goalSourceText } = selectGoalSource({ aiRecap, keyDecisions, userRequest });
91
+ const goalCandidate = polishGoalSentence(goalSourceText);
75
92
  const decisions = [];
76
93
  const seen = new Set();
77
94
  if (recapCandidates.length > 1) {
@@ -120,5 +137,5 @@ export function SpecCompletionSummary({ featureName, spec, specPath, messages, }
120
137
  const previewLines = specLines.slice(0, 5);
121
138
  const remainingLines = Math.max(0, totalLines - 5);
122
139
  const { goalCandidate, decisions } = extractRecap(messages, featureName);
123
- return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(StatusLine, { action: "New Spec", phase: `Complete (${PHASE_CONFIGS.complete.number}/${PHASE_CONFIGS.complete.number})`, path: featureName }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Summary" }), _jsxs(Text, { children: ["- Goal: ", summarizeText(goalCandidate)] }), _jsxs(Text, { children: ["- Outcome: Spec written to ", specPath || `${featureName}.md`, " (", totalLines, " lines)"] })] }), decisions.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Key decisions" }), decisions.map((decision, idx) => (_jsxs(Text, { children: [idx + 1, ". ", summarizeText(decision, 120)] }, `${decision}-${idx}`)))] })), _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsxs(Text, { color: colors.green, children: [theme.chars.bullet, " "] }), _jsx(Text, { bold: true, children: "Write" }), _jsxs(Text, { dimColor: true, children: ["(", specPath || `${featureName}.md`, ")"] })] }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: [theme.chars.lineEnd, " Wrote ", totalLines, " lines"] }) }), _jsxs(Box, { marginLeft: 4, flexDirection: "column", children: [previewLines.map((line, i) => (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { dimColor: true, children: [String(i + 1).padStart(4), " "] }), _jsx(Text, { dimColor: true, children: line })] }, i))), remainingLines > 0 && (_jsxs(Text, { dimColor: true, children: ['\u2026', " +", remainingLines, " lines"] }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsxs(Text, { color: colors.green, children: [theme.chars.bullet, " "] }), _jsx(Text, { children: "Done. Specification generated successfully." })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "What's next:" }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { color: colors.green, children: theme.chars.prompt }), _jsx(Text, { dimColor: true, children: "Review the spec in your editor" })] }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { color: colors.green, children: theme.chars.prompt }), _jsx(Text, { color: colors.blue, children: "/help" }), _jsx(Text, { dimColor: true, children: "See all commands" })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter or Esc to return to shell" }) })] }));
140
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(StatusLine, { action: "New Spec", phase: `Complete (${PHASE_CONFIGS.complete.number}/${PHASE_CONFIGS.complete.number})`, path: featureName }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Summary" }), _jsxs(Text, { children: ["- Goal: ", goalCandidate] }), _jsxs(Text, { children: ["- Outcome: Spec written to ", specPath || `${featureName}.md`, " (", totalLines, " lines)"] })] }), decisions.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Key decisions" }), decisions.map((decision, idx) => (_jsxs(Text, { children: [idx + 1, ". ", summarizeText(decision, 120)] }, `${decision}-${idx}`)))] })), _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsxs(Text, { color: colors.green, children: [theme.chars.bullet, " "] }), _jsx(Text, { bold: true, children: "Write" }), _jsxs(Text, { dimColor: true, children: ["(", specPath || `${featureName}.md`, ")"] })] }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: [theme.chars.lineEnd, " Wrote ", totalLines, " lines"] }) }), _jsxs(Box, { marginLeft: 4, flexDirection: "column", children: [previewLines.map((line, i) => (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { dimColor: true, children: [String(i + 1).padStart(4), " "] }), _jsx(Text, { dimColor: true, children: line })] }, i))), remainingLines > 0 && (_jsxs(Text, { dimColor: true, children: ['\u2026', " +", remainingLines, " lines"] }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsxs(Text, { color: colors.green, children: [theme.chars.bullet, " "] }), _jsx(Text, { children: "Done. Specification generated successfully." })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "What's next:" }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { color: colors.green, children: theme.chars.prompt }), _jsx(Text, { dimColor: true, children: "Review the spec in your editor" })] }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { color: colors.green, children: theme.chars.prompt }), _jsx(Text, { color: colors.blue, children: "/help" }), _jsx(Text, { dimColor: true, children: "See all commands" })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter or Esc to return to shell" }) })] }));
124
141
  }
@@ -5,9 +5,6 @@
5
5
  * terminal width. Provides section separators and content padding.
6
6
  */
7
7
  import React from 'react';
8
- /**
9
- * Props for SummaryBox component
10
- */
11
8
  export interface SummaryBoxProps {
12
9
  /** Child content to render inside the box */
13
10
  children: React.ReactNode;
@@ -8,6 +8,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
8
8
  import React from 'react';
9
9
  import { Box, Text, useStdout } from 'ink';
10
10
  import { box, colors } from '../theme.js';
11
+ /** Maximum box width to prevent layout conflicts with other components */
12
+ const MAX_BOX_WIDTH = 80;
11
13
  /**
12
14
  * SummaryBox component
13
15
  *
@@ -27,8 +29,8 @@ import { box, colors } from '../theme.js';
27
29
  export function SummaryBox({ children, minWidth = 60, }) {
28
30
  const { stdout } = useStdout();
29
31
  const terminalWidth = stdout?.columns ?? 80;
30
- // Use terminal width, but respect minimum width
31
- const boxWidth = Math.max(minWidth, terminalWidth);
32
+ // Use terminal width, clamped between minWidth and MAX_BOX_WIDTH
33
+ const boxWidth = Math.min(Math.max(minWidth, terminalWidth), MAX_BOX_WIDTH);
32
34
  const contentWidth = boxWidth - 4; // Account for borders and padding
33
35
  // Top border: ┌─────┐
34
36
  const topBorder = box.topLeft + box.horizontal.repeat(boxWidth - 2) + box.topRight;
@@ -179,9 +179,27 @@ When generating the spec, use this format:
179
179
  * @internal Exported for testing
180
180
  */
181
181
  export function parseInterviewResponse(response) {
182
- // Look for the ```options fenced block
183
- const optionsBlockRegex = /```options\s*\n([\s\S]*?)\n```/;
184
- const match = response.match(optionsBlockRegex);
182
+ // Try multiple fenced block formats: ```options, ```json, or plain ```
183
+ const fencedPatterns = [
184
+ /```options\s*\n([\s\S]*?)\n```/,
185
+ /```json\s*\n(\[[\s\S]*?\])\n```/,
186
+ /```\s*\n(\[[\s\S]*?\])\n```/,
187
+ ];
188
+ let match = null;
189
+ for (const pattern of fencedPatterns) {
190
+ match = response.match(pattern);
191
+ if (match)
192
+ break;
193
+ }
194
+ // Last resort: look for a bare JSON array containing "id" and "label" keys
195
+ if (!match) {
196
+ const bareArrayRegex = /(\[\s*\n\s*\{[^]*?"id"[^]*?"label"[^]*?\}\s*\n\s*\])/;
197
+ const bareMatch = response.match(bareArrayRegex);
198
+ if (bareMatch) {
199
+ // Synthesize a match-like result with index
200
+ match = bareMatch;
201
+ }
202
+ }
185
203
  if (!match) {
186
204
  return null;
187
205
  }
@@ -477,7 +495,12 @@ Respond with a VERY brief (1-2 sentence) summary of what you found relevant to t
477
495
  this.onWorkingChange(true, 'Formulating first question based on analysis...');
478
496
  const interviewPrompt = `Based on what you learned about the project, briefly acknowledge the user's goals for "${this.featureName}" and ask your FIRST clarifying question.
479
497
  Ask only ONE question. Be concise.`;
480
- const response = await this.conversation.chat(interviewPrompt);
498
+ let response = await this.conversation.chat(interviewPrompt);
499
+ // If the AI responded with only tool calls and no question text, retry once
500
+ if (!response || !response.trim() || !parseInterviewResponse(response)) {
501
+ const retryPrompt = `You explored the project but didn't ask a question yet. Please ask your FIRST clarifying question about "${this.featureName}" now. Ask only ONE question. Include an options block.`;
502
+ response = await this.conversation.chat(retryPrompt);
503
+ }
481
504
  this.emitParsedResponse(response);
482
505
  // Transition to interview phase
483
506
  this.phase = 'interview';
@@ -556,7 +579,14 @@ Ask only ONE question. Be concise.`;
556
579
  else {
557
580
  this.currentQuestion = null;
558
581
  this.onQuestion?.(null);
559
- this.onMessage('assistant', response);
582
+ // Strip any JSON-like option blocks from the displayed message to avoid
583
+ // showing raw JSON when structured parsing fails
584
+ const cleaned = response
585
+ .replace(/```(?:options|json)?\s*\n[\s\S]*?\n```/g, '')
586
+ .replace(/\[\s*\n\s*\{[^]*?"id"[^]*?"label"[^]*?\}\s*\n\s*\]/g, '')
587
+ .replace(/\n{3,}/g, '\n\n')
588
+ .trim();
589
+ this.onMessage('assistant', cleaned || response);
560
590
  }
561
591
  }
562
592
  /**
@@ -266,7 +266,8 @@ export function MainShell({ header, sessionState, onNavigate, backgroundRuns, in
266
266
  const tips = sessionState.initialized
267
267
  ? 'Tip: /new <feature> to create spec, /help for commands'
268
268
  : 'Tip: /init to set up, /help for commands';
269
- const inputElement = (_jsx(ChatInput, { onSubmit: handleSubmit, disabled: false, placeholder: "Enter command or type /help...", onCommand: (cmd) => handleSubmit(`/${cmd}`) }));
269
+ 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 }));
270
271
  return (_jsxs(AppShell, { header: header, tips: tips, isWorking: syncStatus === 'running', workingStatus: syncStatus === 'running' ? 'Syncing project context\u2026' : undefined, input: inputElement, footerStatus: {
271
272
  action: projectLabel || 'Main Shell',
272
273
  phase: sessionState.provider ? `${sessionState.provider}/${sessionState.model}` : 'No provider',