wiggum-cli 0.15.0 → 0.16.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 (35) hide show
  1. package/README.md +7 -1
  2. package/dist/generator/templates.d.ts +1 -0
  3. package/dist/generator/templates.js +14 -1
  4. package/dist/index.d.ts +14 -1
  5. package/dist/index.js +222 -40
  6. package/dist/templates/prompts/PROMPT_e2e.md.tmpl +151 -0
  7. package/dist/templates/prompts/PROMPT_feature.md.tmpl +23 -0
  8. package/dist/templates/prompts/PROMPT_review_auto.md.tmpl +7 -2
  9. package/dist/templates/prompts/PROMPT_review_merge.md.tmpl +7 -2
  10. package/dist/templates/scripts/feature-loop.sh.tmpl +190 -46
  11. package/dist/tui/app.d.ts +16 -1
  12. package/dist/tui/app.js +11 -3
  13. package/dist/tui/components/ActivityFeed.d.ts +18 -0
  14. package/dist/tui/components/ActivityFeed.js +31 -0
  15. package/dist/tui/components/RunCompletionSummary.d.ts +27 -1
  16. package/dist/tui/components/RunCompletionSummary.js +97 -7
  17. package/dist/tui/components/SummaryBox.d.ts +4 -0
  18. package/dist/tui/components/SummaryBox.js +4 -2
  19. package/dist/tui/hooks/useBackgroundRuns.js +1 -1
  20. package/dist/tui/screens/RunScreen.d.ts +15 -15
  21. package/dist/tui/screens/RunScreen.js +58 -5
  22. package/dist/tui/utils/build-run-summary.js +4 -1
  23. package/dist/tui/utils/git-summary.d.ts +13 -0
  24. package/dist/tui/utils/git-summary.js +30 -0
  25. package/dist/tui/utils/loop-status.d.ts +54 -0
  26. package/dist/tui/utils/loop-status.js +213 -1
  27. package/dist/utils/ci.d.ts +8 -0
  28. package/dist/utils/ci.js +13 -0
  29. package/dist/utils/spec-names.js +5 -1
  30. package/package.json +7 -2
  31. package/src/templates/prompts/PROMPT_e2e.md.tmpl +151 -0
  32. package/src/templates/prompts/PROMPT_feature.md.tmpl +23 -0
  33. package/src/templates/prompts/PROMPT_review_auto.md.tmpl +7 -2
  34. package/src/templates/prompts/PROMPT_review_merge.md.tmpl +7 -2
  35. package/src/templates/scripts/feature-loop.sh.tmpl +190 -46
@@ -97,8 +97,8 @@ if [ "$REVIEW_MODE" != "manual" ] && [ "$REVIEW_MODE" != "auto" ] && [ "$REVIEW_
97
97
  fi
98
98
 
99
99
  # Build claude commands
100
- CLAUDE_CMD_OPUS="claude -p --dangerously-skip-permissions --model ${PLANNING_MODEL}"
101
- CLAUDE_CMD_IMPL="claude -p --dangerously-skip-permissions --model ${MODEL:-$DEFAULT_MODEL}"
100
+ CLAUDE_CMD_OPUS="claude -p --output-format json --dangerously-skip-permissions --model ${PLANNING_MODEL}"
101
+ CLAUDE_CMD_IMPL="claude -p --output-format json --dangerously-skip-permissions --model ${MODEL:-$DEFAULT_MODEL}"
102
102
 
103
103
  # Token tracking
104
104
  TOKENS_FILE="/tmp/ralph-loop-${1}.tokens"
@@ -107,35 +107,130 @@ STATUS_FILE="/tmp/ralph-loop-${1}.status"
107
107
  FINAL_STATUS_FILE="/tmp/ralph-loop-${1}.final"
108
108
  PHASES_FILE="/tmp/ralph-loop-${1}.phases"
109
109
  BASELINE_FILE="/tmp/ralph-loop-${1}.baseline"
110
+ SESSIONS_FILE="/tmp/ralph-loop-${1}.sessions"
111
+ LOG_FILE="/tmp/ralph-loop-${1}.log"
110
112
 
111
- # Initialize token tracking
113
+ # Initialize token tracking (4-field format: input|output|cache_create|cache_read)
112
114
  init_tokens() {
113
- echo "0|0" > "$TOKENS_FILE"
115
+ echo "0|0|0|0" > "$TOKENS_FILE"
116
+ > "$SESSIONS_FILE"
117
+ > "$LOG_FILE"
114
118
  }
115
119
 
116
- # Parse and accumulate tokens
117
- parse_and_accumulate_tokens() {
118
- local output_file="$1"
119
- local input_tokens=$({ grep -oE "input[^0-9]*([0-9,]+)" "$output_file" 2>/dev/null || true; } | { grep -oE "[0-9,]+" || true; } | tr -d ',' | tail -1)
120
- local output_tokens=$({ grep -oE "output[^0-9]*([0-9,]+)" "$output_file" 2>/dev/null || true; } | { grep -oE "[0-9,]+" || true; } | tr -d ',' | tail -1)
120
+ # Extract session result from JSON output.
121
+ # Writes human-readable result text to the .log file and captures session_id.
122
+ # Usage: extract_session_result <raw_json_file>
123
+ # Sets: LAST_SESSION_ID variable
124
+ extract_session_result() {
125
+ local raw_file="$1"
126
+ LAST_SESSION_ID=""
127
+ if [ ! -f "$raw_file" ]; then return; fi
128
+
129
+ local result
130
+ result=$(python3 -c "
131
+ import json, sys
132
+ try:
133
+ data = json.load(open(sys.argv[1]))
134
+ if not isinstance(data, list): data = [data]
135
+ for entry in reversed(data):
136
+ if isinstance(entry, dict) and entry.get('type') == 'result':
137
+ print(entry.get('session_id', ''))
138
+ break
139
+ except Exception:
140
+ pass
141
+ " "$raw_file" 2>/dev/null) || true
142
+
143
+ LAST_SESSION_ID="$result"
144
+
145
+ if [ -n "$LAST_SESSION_ID" ]; then
146
+ echo "$LAST_SESSION_ID" >> "$SESSIONS_FILE"
147
+ fi
148
+
149
+ # Extract human-readable result text and append to log
150
+ python3 -c "
151
+ import json, sys
152
+ try:
153
+ data = json.load(open(sys.argv[1]))
154
+ if not isinstance(data, list): data = [data]
155
+ for entry in data:
156
+ if isinstance(entry, dict) and entry.get('type') == 'result':
157
+ text = entry.get('result', '')
158
+ if text:
159
+ print(text)
160
+ except Exception:
161
+ pass
162
+ " "$raw_file" >> "$LOG_FILE" 2>/dev/null || true
163
+ }
164
+
165
+ # Accumulate tokens from a session JSONL file into the .tokens file.
166
+ # Usage: accumulate_tokens_from_session <session_id>
167
+ accumulate_tokens_from_session() {
168
+ local session_id="$1"
169
+ if [ -z "$session_id" ]; then return; fi
170
+
171
+ # Find the JSONL file for this session
172
+ local jsonl_file=""
173
+ for f in ~/.claude/projects/*/"${session_id}.jsonl"; do
174
+ if [ -f "$f" ]; then
175
+ jsonl_file="$f"
176
+ break
177
+ fi
178
+ done
121
179
 
122
- input_tokens=${input_tokens:-0}
123
- output_tokens=${output_tokens:-0}
180
+ if [ -z "$jsonl_file" ]; then
181
+ echo "WARNING: Could not find JSONL for session $session_id" >&2
182
+ return
183
+ fi
124
184
 
185
+ # Extract and sum token usage from all assistant messages
186
+ local session_tokens
187
+ session_tokens=$(python3 -c "
188
+ import json, sys
189
+ totals = {'input': 0, 'output': 0, 'cache_create': 0, 'cache_read': 0}
190
+ for line in open(sys.argv[1]):
191
+ try:
192
+ obj = json.loads(line)
193
+ if obj.get('type') != 'assistant':
194
+ continue
195
+ usage = obj.get('message', {}).get('usage', {})
196
+ if not usage:
197
+ continue
198
+ totals['input'] += usage.get('input_tokens', 0)
199
+ totals['output'] += usage.get('output_tokens', 0)
200
+ totals['cache_create'] += usage.get('cache_creation_input_tokens', 0)
201
+ totals['cache_read'] += usage.get('cache_read_input_tokens', 0)
202
+ except (json.JSONDecodeError, AttributeError):
203
+ continue
204
+ print(f\"{totals['input']}|{totals['output']}|{totals['cache_create']}|{totals['cache_read']}\")
205
+ " "$jsonl_file" 2>/dev/null) || true
206
+
207
+ if [ -z "$session_tokens" ]; then return; fi
208
+
209
+ # Parse session tokens
210
+ local s_input s_output s_cache_create s_cache_read
211
+ s_input=$(echo "$session_tokens" | cut -d'|' -f1)
212
+ s_output=$(echo "$session_tokens" | cut -d'|' -f2)
213
+ s_cache_create=$(echo "$session_tokens" | cut -d'|' -f3)
214
+ s_cache_read=$(echo "$session_tokens" | cut -d'|' -f4)
215
+
216
+ # Read current totals
217
+ local current c_input c_output c_cache_create c_cache_read
125
218
  if [ -f "$TOKENS_FILE" ]; then
126
- local current=$(cat "$TOKENS_FILE")
127
- local current_input=$(echo "$current" | cut -d'|' -f1)
128
- local current_output=$(echo "$current" | cut -d'|' -f2)
129
- [[ "$current_input" =~ ^[0-9]+$ ]] || current_input=0
130
- [[ "$current_output" =~ ^[0-9]+$ ]] || current_output=0
219
+ current=$(cat "$TOKENS_FILE")
220
+ c_input=$(echo "$current" | cut -d'|' -f1)
221
+ c_output=$(echo "$current" | cut -d'|' -f2)
222
+ c_cache_create=$(echo "$current" | cut -d'|' -f3)
223
+ c_cache_read=$(echo "$current" | cut -d'|' -f4)
224
+ [[ "$c_input" =~ ^[0-9]+$ ]] || c_input=0
225
+ [[ "$c_output" =~ ^[0-9]+$ ]] || c_output=0
226
+ [[ "$c_cache_create" =~ ^[0-9]+$ ]] || c_cache_create=0
227
+ [[ "$c_cache_read" =~ ^[0-9]+$ ]] || c_cache_read=0
131
228
  else
132
- current_input=0
133
- current_output=0
229
+ c_input=0; c_output=0; c_cache_create=0; c_cache_read=0
134
230
  fi
135
231
 
136
- local new_input=$((current_input + input_tokens))
137
- local new_output=$((current_output + output_tokens))
138
- echo "${new_input}|${new_output}" > "$TOKENS_FILE"
232
+ # Accumulate
233
+ echo "$((c_input + s_input))|$((c_output + s_output))|$((c_cache_create + s_cache_create))|$((c_cache_read + s_cache_read))" > "$TOKENS_FILE"
139
234
  }
140
235
 
141
236
  # Action inbox: write request file if not already present
@@ -299,12 +394,13 @@ if [ ! -f "$PLAN_FILE" ]; then
299
394
  echo "======================== PLANNING PHASE ========================"
300
395
  write_phase_start "planning"
301
396
  export FEATURE APP_DIR SPEC_DIR PROMPTS_DIR
302
- cat "$PROMPTS_DIR/PROMPT_feature.md" | envsubst | $CLAUDE_CMD_OPUS 2>&1 | tee "$CLAUDE_OUTPUT" || {
397
+ cat "$PROMPTS_DIR/PROMPT_feature.md" | envsubst | $CLAUDE_CMD_OPUS 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || {
303
398
  echo "ERROR: Planning phase failed"
304
399
  write_phase_end "planning" "failed"
305
400
  exit 1
306
401
  }
307
- parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
402
+ extract_session_result "${CLAUDE_OUTPUT}.raw"
403
+ accumulate_tokens_from_session "$LAST_SESSION_ID"
308
404
  write_phase_end "planning" "success"
309
405
  else
310
406
  echo "Plan file exists, skipping planning phase"
@@ -337,8 +433,9 @@ while true; do
337
433
 
338
434
  echo "Pending implementation tasks: $PENDING_IMPL"
339
435
  export FEATURE APP_DIR SPEC_DIR PROMPTS_DIR
340
- cat "$PROMPTS_DIR/PROMPT.md" | envsubst | $CLAUDE_CMD_IMPL 2>&1 | tee "$CLAUDE_OUTPUT" || true
341
- parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
436
+ cat "$PROMPTS_DIR/PROMPT.md" | envsubst | $CLAUDE_CMD_IMPL 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
437
+ extract_session_result "${CLAUDE_OUTPUT}.raw"
438
+ accumulate_tokens_from_session "$LAST_SESSION_ID"
342
439
 
343
440
  sleep 2
344
441
  done
@@ -362,8 +459,9 @@ else
362
459
  echo "------------------------ E2E Attempt $E2E_ATTEMPT of $MAX_E2E_ATTEMPTS ------------------------"
363
460
 
364
461
  export FEATURE APP_DIR SPEC_DIR PROMPTS_DIR
365
- cat "$PROMPTS_DIR/PROMPT_e2e.md" | envsubst | $CLAUDE_CMD_IMPL 2>&1 | tee "$CLAUDE_OUTPUT" || true
366
- parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
462
+ cat "$PROMPTS_DIR/PROMPT_e2e.md" | envsubst | $CLAUDE_CMD_IMPL 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
463
+ extract_session_result "${CLAUDE_OUTPUT}.raw"
464
+ accumulate_tokens_from_session "$LAST_SESSION_ID"
367
465
 
368
466
  # Check if all E2E tests passed
369
467
  E2E_FAILED=$({ grep "^- \[ \].*E2E:.*FAILED" "$PLAN_FILE" 2>/dev/null || true; } | wc -l | tr -d ' ')
@@ -377,8 +475,9 @@ else
377
475
 
378
476
  if [ $E2E_ATTEMPT -lt $MAX_E2E_ATTEMPTS ]; then
379
477
  echo "E2E tests have failures. Running fix iteration..."
380
- cat "$PROMPTS_DIR/PROMPT.md" | envsubst | $CLAUDE_CMD_IMPL 2>&1 | tee "$CLAUDE_OUTPUT" || true
381
- parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
478
+ cat "$PROMPTS_DIR/PROMPT.md" | envsubst | $CLAUDE_CMD_IMPL 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
479
+ extract_session_result "${CLAUDE_OUTPUT}.raw"
480
+ accumulate_tokens_from_session "$LAST_SESSION_ID"
382
481
  fi
383
482
  done
384
483
 
@@ -394,10 +493,11 @@ echo "======================== SPEC VERIFICATION PHASE ========================"
394
493
  write_phase_start "verification"
395
494
  export FEATURE APP_DIR SPEC_DIR PROMPTS_DIR
396
495
  VERIFY_STATUS="success"
397
- if ! cat "$PROMPTS_DIR/PROMPT_verify.md" | envsubst | $CLAUDE_CMD_OPUS 2>&1 | tee "$CLAUDE_OUTPUT"; then
496
+ if ! cat "$PROMPTS_DIR/PROMPT_verify.md" | envsubst | $CLAUDE_CMD_OPUS 2>&1 | tee "${CLAUDE_OUTPUT}.raw"; then
398
497
  VERIFY_STATUS="failed"
399
498
  fi
400
- parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
499
+ extract_session_result "${CLAUDE_OUTPUT}.raw"
500
+ accumulate_tokens_from_session "$LAST_SESSION_ID"
401
501
  write_phase_end "verification" "$VERIFY_STATUS"
402
502
 
403
503
  # Phase 7: PR and Review
@@ -407,11 +507,37 @@ export FEATURE APP_DIR SPEC_DIR PROMPTS_DIR
407
507
  PR_STATUS="success"
408
508
  MAX_REVIEW_ATTEMPTS=3
409
509
 
510
+ # Check for review approval in stdout or latest PR comment.
511
+ # Returns 0 (true) if approved, 1 (false) otherwise.
512
+ check_review_approved() {
513
+ local output_file="$1"
514
+
515
+ # Primary: check stdout for explicit verdict line
516
+ if grep -qi "VERDICT:.*APPROVED" "$output_file" 2>/dev/null; then
517
+ # Make sure it's not "NOT APPROVED"
518
+ if ! grep -qi "VERDICT:.*NOT APPROVED" "$output_file" 2>/dev/null; then
519
+ return 0
520
+ fi
521
+ fi
522
+
523
+ # Fallback: check the latest PR comment for approval signal
524
+ local latest_comment
525
+ latest_comment=$(gh pr view "$BRANCH" --json comments --jq '.comments[-1].body' 2>/dev/null || echo "")
526
+ if echo "$latest_comment" | grep -qi "VERDICT:.*APPROVED\|Verdict:.*APPROVED" 2>/dev/null; then
527
+ if ! echo "$latest_comment" | grep -qi "NOT APPROVED" 2>/dev/null; then
528
+ return 0
529
+ fi
530
+ fi
531
+
532
+ return 1
533
+ }
534
+
410
535
  if [ "$REVIEW_MODE" = "manual" ]; then
411
- if ! cat "$PROMPTS_DIR/PROMPT_review_manual.md" | envsubst | $CLAUDE_CMD_OPUS 2>&1 | tee "$CLAUDE_OUTPUT"; then
536
+ if ! cat "$PROMPTS_DIR/PROMPT_review_manual.md" | envsubst | $CLAUDE_CMD_OPUS 2>&1 | tee "${CLAUDE_OUTPUT}.raw"; then
412
537
  PR_STATUS="failed"
413
538
  fi
414
- parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
539
+ extract_session_result "${CLAUDE_OUTPUT}.raw"
540
+ accumulate_tokens_from_session "$LAST_SESSION_ID"
415
541
 
416
542
  elif [ "$REVIEW_MODE" = "merge" ]; then
417
543
  # Merge mode: create PR, iterate review+fixes until approved, then merge
@@ -420,11 +546,12 @@ elif [ "$REVIEW_MODE" = "merge" ]; then
420
546
  while [ $REVIEW_ATTEMPT -lt $MAX_REVIEW_ATTEMPTS ]; do
421
547
  REVIEW_ATTEMPT=$((REVIEW_ATTEMPT + 1))
422
548
  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"
549
+ cat "$PROMPTS_DIR/PROMPT_review_merge.md" | envsubst | $CLAUDE_CMD_OPUS 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
550
+ extract_session_result "${CLAUDE_OUTPUT}.raw"
551
+ accumulate_tokens_from_session "$LAST_SESSION_ID"
425
552
 
426
- # Check if output contains APPROVED
427
- if grep -qi "APPROVED" "$CLAUDE_OUTPUT" 2>/dev/null; then
553
+ # Check stdout and PR comment for approval
554
+ if check_review_approved "${CLAUDE_OUTPUT}.raw"; then
428
555
  echo "Review approved!"
429
556
  REVIEW_APPROVED=true
430
557
  break
@@ -436,8 +563,9 @@ elif [ "$REVIEW_MODE" = "merge" ]; then
436
563
  1. Fix each issue referenced in the review
437
564
  2. Run tests: npm test
438
565
  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"
566
+ Do NOT propose completion options or ask interactive questions. Just fix, test, commit, push." | $CLAUDE_CMD_IMPL 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
567
+ extract_session_result "${CLAUDE_OUTPUT}.raw"
568
+ accumulate_tokens_from_session "$LAST_SESSION_ID"
441
569
  fi
442
570
  done
443
571
  if [ "$REVIEW_APPROVED" != true ]; then
@@ -452,11 +580,12 @@ else
452
580
  while [ $REVIEW_ATTEMPT -lt $MAX_REVIEW_ATTEMPTS ]; do
453
581
  REVIEW_ATTEMPT=$((REVIEW_ATTEMPT + 1))
454
582
  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"
583
+ cat "$PROMPTS_DIR/PROMPT_review_auto.md" | envsubst | $CLAUDE_CMD_OPUS 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
584
+ extract_session_result "${CLAUDE_OUTPUT}.raw"
585
+ accumulate_tokens_from_session "$LAST_SESSION_ID"
457
586
 
458
- # Check if output contains APPROVED
459
- if grep -qi "APPROVED" "$CLAUDE_OUTPUT" 2>/dev/null; then
587
+ # Check stdout and PR comment for approval
588
+ if check_review_approved "${CLAUDE_OUTPUT}.raw"; then
460
589
  echo "Review approved!"
461
590
  REVIEW_APPROVED=true
462
591
  break
@@ -468,8 +597,9 @@ else
468
597
  1. Fix each issue referenced in the review
469
598
  2. Run tests: npm test
470
599
  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"
600
+ Do NOT propose completion options or ask interactive questions. Just fix, test, commit, push." | $CLAUDE_CMD_IMPL 2>&1 | tee "${CLAUDE_OUTPUT}.raw" || true
601
+ extract_session_result "${CLAUDE_OUTPUT}.raw"
602
+ accumulate_tokens_from_session "$LAST_SESSION_ID"
473
603
  fi
474
604
  done
475
605
  if [ "$REVIEW_APPROVED" != true ]; then
@@ -513,16 +643,30 @@ fi
513
643
  # Cleanup temp files
514
644
  rm -f "$STATUS_FILE" 2>/dev/null || true
515
645
  rm -f "/tmp/ralph-loop-${FEATURE}.output" 2>/dev/null || true
646
+ rm -f "/tmp/ralph-loop-${FEATURE}.output.raw" 2>/dev/null || true
516
647
 
517
648
  # Print final token usage
518
649
  if [ -f "/tmp/ralph-loop-${FEATURE}.tokens" ]; then
519
650
  echo ""
520
651
  echo "=========================================="
521
652
  echo "Final Token Usage:"
522
- cat "/tmp/ralph-loop-${FEATURE}.tokens" | awk -F'|' '{printf " Input: %s tokens\n Output: %s tokens\n Total: %s tokens\n", $1, $2, $1+$2}'
653
+ awk -F'|' '{
654
+ printf " Input: %d tokens\n", $1
655
+ printf " Output: %d tokens\n", $2
656
+ printf " Cache create: %d tokens\n", $3
657
+ printf " Cache read: %d tokens\n", $4
658
+ printf " Total: %d tokens\n", $1+$2+$3+$4
659
+ }' "/tmp/ralph-loop-${FEATURE}.tokens"
523
660
  echo "=========================================="
524
661
  fi
525
662
 
663
+ # Print session IDs
664
+ if [ -f "/tmp/ralph-loop-${FEATURE}.sessions" ]; then
665
+ SESSION_COUNT=$(wc -l < "/tmp/ralph-loop-${FEATURE}.sessions" | tr -d ' ')
666
+ echo "Sessions: $SESSION_COUNT"
667
+ cat "/tmp/ralph-loop-${FEATURE}.sessions"
668
+ fi
669
+
526
670
  echo "=========================================="
527
671
  echo "Ralph loop completed: $FEATURE"
528
672
  echo "=========================================="
package/dist/tui/app.d.ts CHANGED
@@ -32,6 +32,17 @@ export interface InterviewAppProps {
32
32
  /** Optional scan result with detected tech stack */
33
33
  scanResult?: ScanResult;
34
34
  }
35
+ /**
36
+ * Props for the run/monitor screen when launched directly from CLI
37
+ */
38
+ export interface RunAppProps {
39
+ /** Name of the feature to run or monitor */
40
+ featureName: string;
41
+ /** If true, opens in monitor-only (read-only) mode — no loop is spawned */
42
+ monitorOnly?: boolean;
43
+ /** Review mode override */
44
+ reviewMode?: 'manual' | 'auto';
45
+ }
35
46
  /**
36
47
  * Props for the main App component
37
48
  */
@@ -44,6 +55,8 @@ export interface AppProps {
44
55
  version?: string;
45
56
  /** Props for the interview screen (required when screen is 'interview') */
46
57
  interviewProps?: InterviewAppProps;
58
+ /** Props for the run/monitor screen (required when screen is 'run') */
59
+ runProps?: RunAppProps;
47
60
  /** Called when the screen completes successfully */
48
61
  onComplete?: (result: string) => void;
49
62
  /** Called when the user exits/cancels */
@@ -56,7 +69,7 @@ export interface AppProps {
56
69
  * and receives a shared headerElement prop.
57
70
  */
58
71
  export declare function App({ screen: initialScreen, initialSessionState, version, // Fallback if package.json read fails (keep in sync with index.ts)
59
- interviewProps, onComplete, onExit, }: AppProps): React.ReactElement | null;
72
+ interviewProps, runProps, onComplete, onExit, }: AppProps): React.ReactElement | null;
60
73
  /**
61
74
  * Render options for renderApp
62
75
  */
@@ -69,6 +82,8 @@ export interface RenderAppOptions {
69
82
  version?: string;
70
83
  /** Props for interview screen (if starting directly on interview) */
71
84
  interviewProps?: InterviewAppProps;
85
+ /** Props for run/monitor screen (if starting directly on run screen) */
86
+ runProps?: RunAppProps;
72
87
  /** Called when spec generation completes */
73
88
  onComplete?: (result: string) => void;
74
89
  /** Called when user exits */
package/dist/tui/app.js CHANGED
@@ -29,9 +29,17 @@ import { useBackgroundRuns } from './hooks/useBackgroundRuns.js';
29
29
  * and receives a shared headerElement prop.
30
30
  */
31
31
  export function App({ screen: initialScreen, initialSessionState, version = '0.12.1', // Fallback if package.json read fails (keep in sync with index.ts)
32
- interviewProps, onComplete, onExit, }) {
32
+ interviewProps, runProps, onComplete, onExit, }) {
33
33
  const [currentScreen, setCurrentScreen] = useState(initialScreen);
34
- const [screenProps, setScreenProps] = useState(interviewProps ? { featureName: interviewProps.featureName } : null);
34
+ const [screenProps, setScreenProps] = useState(() => {
35
+ if (initialScreen === 'run' && runProps) {
36
+ return { featureName: runProps.featureName, monitorOnly: runProps.monitorOnly, reviewMode: runProps.reviewMode };
37
+ }
38
+ if (interviewProps) {
39
+ return { featureName: interviewProps.featureName };
40
+ }
41
+ return null;
42
+ });
35
43
  const [sessionState, setSessionState] = useState(initialSessionState);
36
44
  // Background run tracking
37
45
  const { runs: backgroundRuns, background, dismiss } = useBackgroundRuns();
@@ -216,5 +224,5 @@ interviewProps, onComplete, onExit, }) {
216
224
  * Render the App component to the terminal
217
225
  */
218
226
  export function renderApp(options) {
219
- return render(_jsx(App, { screen: options.screen, initialSessionState: options.initialSessionState, version: options.version, interviewProps: options.interviewProps, onComplete: options.onComplete, onExit: options.onExit }));
227
+ return render(_jsx(App, { screen: options.screen, initialSessionState: options.initialSessionState, version: options.version, interviewProps: options.interviewProps, runProps: options.runProps, onComplete: options.onComplete, onExit: options.onExit }));
220
228
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * ActivityFeed - Live activity feed for RunScreen
3
+ *
4
+ * Renders the last N activity events derived from the loop log and phase changes.
5
+ * Each row shows a relative timestamp, a status icon, and the event message.
6
+ * Color-coded by status: green (success), red/pink (error), yellow (in-progress).
7
+ */
8
+ import React from 'react';
9
+ import { type ActivityEvent } from '../utils/loop-status.js';
10
+ export interface ActivityFeedProps {
11
+ /** Activity events to display (newest last) */
12
+ events: ActivityEvent[];
13
+ /** Maximum number of events to show (default: 10) */
14
+ maxEvents?: number;
15
+ /** Commit range string to display as footer (e.g., "b425c40 → 6efaf80") */
16
+ latestCommit?: string;
17
+ }
18
+ export declare function ActivityFeed({ events, maxEvents, latestCommit }: ActivityFeedProps): React.ReactElement;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { colors, phase } from '../theme.js';
4
+ import { formatRelativeTime } from '../utils/loop-status.js';
5
+ const MAX_MESSAGE_LENGTH = 90;
6
+ const STATUS_COLOR = {
7
+ success: colors.green,
8
+ error: colors.pink,
9
+ 'in-progress': colors.yellow,
10
+ };
11
+ const STATUS_ICON = {
12
+ success: phase.complete, // ✓
13
+ error: phase.error, // ✗
14
+ 'in-progress': phase.active, // ◐
15
+ };
16
+ function truncateMessage(message, maxLen) {
17
+ if (message.length <= maxLen)
18
+ return message;
19
+ return message.slice(0, maxLen - 1) + '\u2026';
20
+ }
21
+ export function ActivityFeed({ events, maxEvents = 10, latestCommit }) {
22
+ const visible = events.slice(-maxEvents);
23
+ if (visible.length === 0) {
24
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "No activity yet" }), latestCommit && _jsxs(Text, { dimColor: true, children: ["Commit: ", latestCommit] })] }));
25
+ }
26
+ return (_jsxs(Box, { flexDirection: "column", children: [visible.map((event, idx) => {
27
+ const color = STATUS_COLOR[event.status];
28
+ const icon = STATUS_ICON[event.status];
29
+ return (_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { dimColor: true, children: formatRelativeTime(event.timestamp) }), _jsx(Text, { color: color, children: icon }), _jsx(Text, { color: color, children: truncateMessage(event.message, MAX_MESSAGE_LENGTH) })] }, idx));
30
+ }), latestCommit && _jsxs(Text, { dimColor: true, children: ["Commit: ", latestCommit] })] }));
31
+ }
@@ -5,7 +5,33 @@
5
5
  * commits, and PR/issue links after a feature loop completes.
6
6
  */
7
7
  import React from 'react';
8
- import type { RunSummary } from '../screens/RunScreen.js';
8
+ import type { RunSummary, FileChangeStat } from '../screens/RunScreen.js';
9
+ /**
10
+ * Formatted file change row for aligned display in the Changes section.
11
+ */
12
+ export interface FormattedFileChange {
13
+ /** Path, possibly truncated with prefix ellipsis, padded to path column width */
14
+ displayPath: string;
15
+ /** Insertions string, e.g. "+10" or "+ 3", right-aligned within fixed width */
16
+ addedStr: string;
17
+ /** Deletions string, e.g. "-5" or "-18", right-aligned within fixed width */
18
+ removedStr: string;
19
+ }
20
+ /**
21
+ * Truncate a file path to fit within maxWidth characters.
22
+ * If the path exceeds maxWidth, truncates from the start and prefixes with '…'.
23
+ */
24
+ export declare function truncatePath(path: string, maxWidth: number): string;
25
+ /**
26
+ * Truncate text from the end to fit within maxWidth characters.
27
+ * Appends '…' if truncated.
28
+ */
29
+ export declare function truncateEnd(text: string, maxWidth: number): string;
30
+ /**
31
+ * Format a list of file change stats into fixed-width, aligned display rows.
32
+ * Stats columns are always fully visible; the path column shrinks to fill remaining space.
33
+ */
34
+ export declare function formatChangesFiles(files: FileChangeStat[], contentWidth: number): FormattedFileChange[];
9
35
  /**
10
36
  * Props for RunCompletionSummary component
11
37
  */
@@ -1,7 +1,50 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
3
- import { SummaryBox, SummaryBoxSection } from './SummaryBox.js';
2
+ import { Box, Text, useStdout } from 'ink';
3
+ import { SummaryBox, SummaryBoxSection, MAX_BOX_WIDTH, BOX_BORDER_OVERHEAD } from './SummaryBox.js';
4
4
  import { colors, phase } from '../theme.js';
5
+ import { formatNumber } from '../utils/loop-status.js';
6
+ const MIN_BOX_WIDTH = 60;
7
+ /**
8
+ * Truncate a file path to fit within maxWidth characters.
9
+ * If the path exceeds maxWidth, truncates from the start and prefixes with '…'.
10
+ */
11
+ export function truncatePath(path, maxWidth) {
12
+ if (path.length <= maxWidth)
13
+ return path;
14
+ if (maxWidth <= 1)
15
+ return '…';
16
+ return '…' + path.slice(-(maxWidth - 1));
17
+ }
18
+ /**
19
+ * Truncate text from the end to fit within maxWidth characters.
20
+ * Appends '…' if truncated.
21
+ */
22
+ export function truncateEnd(text, maxWidth) {
23
+ if (text.length <= maxWidth)
24
+ return text;
25
+ if (maxWidth <= 1)
26
+ return '…';
27
+ return text.slice(0, maxWidth - 1) + '…';
28
+ }
29
+ /**
30
+ * Format a list of file change stats into fixed-width, aligned display rows.
31
+ * Stats columns are always fully visible; the path column shrinks to fill remaining space.
32
+ */
33
+ export function formatChangesFiles(files, contentWidth) {
34
+ if (files.length === 0)
35
+ return [];
36
+ const GAP = 2;
37
+ const maxAddedDigits = Math.max(...files.map((f) => String(f.added).length));
38
+ const maxRemovedDigits = Math.max(...files.map((f) => String(f.removed).length));
39
+ // statsBlockWidth: "+NNN -NNN"
40
+ const statsBlockWidth = (1 + maxAddedDigits) + 1 + (1 + maxRemovedDigits);
41
+ const pathColWidth = Math.max(1, contentWidth - GAP - statsBlockWidth);
42
+ return files.map((file) => ({
43
+ displayPath: truncatePath(file.path, pathColWidth).padEnd(pathColWidth),
44
+ addedStr: '+' + String(file.added).padStart(maxAddedDigits),
45
+ removedStr: '-' + String(file.removed).padStart(maxRemovedDigits),
46
+ }));
47
+ }
5
48
  /**
6
49
  * Format milliseconds to human-readable duration (e.g., "12m 34s", "1h 15m 0s")
7
50
  */
@@ -26,6 +69,10 @@ function formatDurationMs(ms) {
26
69
  */
27
70
  const stoppedCodes = new Set([130, 143]);
28
71
  export function RunCompletionSummary({ summary, }) {
72
+ const { stdout } = useStdout();
73
+ const terminalWidth = stdout?.columns ?? 80;
74
+ const boxWidth = Math.min(Math.max(MIN_BOX_WIDTH, terminalWidth), MAX_BOX_WIDTH);
75
+ const contentWidth = boxWidth - BOX_BORDER_OVERHEAD;
29
76
  // Determine final status and color
30
77
  const exitStatus = summary.exitCode === 0
31
78
  ? { label: 'Complete', color: colors.green }
@@ -48,11 +95,29 @@ export function RunCompletionSummary({ summary, }) {
48
95
  const tasksDisplay = tasksCompleted !== null && tasksTotal !== null
49
96
  ? `${tasksCompleted}/${tasksTotal} completed`
50
97
  : 'Not available';
51
- return (_jsxs(SummaryBox, { minWidth: 60, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: summary.feature }), _jsx(Text, { bold: true, color: exitStatus.color, children: exitStatus.label })] }), _jsxs(SummaryBoxSection, { children: [summary.totalDurationMs !== undefined ? (_jsxs(Text, { children: ["Duration: ", formatDurationMs(summary.totalDurationMs)] })) : (_jsx(Text, { children: "Duration: Not available" })), _jsxs(Text, { children: ["Iterations: ", iterationsDisplay] }), _jsxs(Text, { children: ["Tasks: ", tasksDisplay] })] }), _jsxs(SummaryBoxSection, { children: [_jsx(Text, { bold: true, children: "Phases" }), summary.phases && summary.phases.length > 0 ? (summary.phases.map((phaseInfo) => {
52
- const statusIcon = phaseInfo.status === 'success' ? phase.complete :
98
+ // Status icon for subtitle
99
+ const statusIcon = summary.exitCode === 0
100
+ ? phase.complete
101
+ : stoppedCodes.has(summary.exitCode) ? phase.active : phase.error;
102
+ // Total tokens across all categories
103
+ const totalTokens = summary.tokensInput + summary.tokensOutput + summary.cacheCreate + summary.cacheRead;
104
+ // Build compact subtitle parts
105
+ const subtitleParts = [];
106
+ if (summary.totalDurationMs !== undefined) {
107
+ subtitleParts.push(formatDurationMs(summary.totalDurationMs));
108
+ }
109
+ subtitleParts.push(`${iterationsTotal} iter`);
110
+ if (tasksCompleted !== null && tasksTotal !== null) {
111
+ subtitleParts.push(`${tasksCompleted}/${tasksTotal} tasks`);
112
+ }
113
+ if (totalTokens > 0) {
114
+ subtitleParts.push(`${formatNumber(totalTokens)} tokens`);
115
+ }
116
+ return (_jsxs(SummaryBox, { minWidth: 60, children: [_jsx(Text, { bold: true, children: summary.feature }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsxs(Text, { bold: true, color: exitStatus.color, children: [statusIcon, " ", exitStatus.label] }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { dimColor: true, children: subtitleParts.join(' · ') })] }), _jsxs(SummaryBoxSection, { children: [_jsx(Text, { bold: true, children: "Phases" }), summary.phases && summary.phases.length > 0 ? (summary.phases.map((phaseInfo) => {
117
+ const phaseIcon = phaseInfo.status === 'success' ? phase.complete :
53
118
  phaseInfo.status === 'failed' ? phase.error :
54
119
  phase.pending;
55
- const statusColor = phaseInfo.status === 'success' ? colors.green :
120
+ const phaseColor = phaseInfo.status === 'success' ? colors.green :
56
121
  phaseInfo.status === 'failed' ? colors.pink :
57
122
  colors.gray;
58
123
  const durationText = phaseInfo.durationMs !== undefined
@@ -63,6 +128,31 @@ export function RunCompletionSummary({ summary, }) {
63
128
  : '';
64
129
  const statusText = phaseInfo.status === 'skipped' ? ' skipped' :
65
130
  phaseInfo.status === 'failed' ? ' failed' : '';
66
- return (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: statusColor, children: [statusIcon, " "] }), _jsxs(Text, { children: [phaseInfo.label, " ", durationText, iterationsText, statusText] })] }, phaseInfo.id));
67
- })) : (_jsx(Text, { children: "No phase information available" }))] }), _jsxs(SummaryBoxSection, { children: [_jsx(Text, { bold: true, children: "Changes" }), summary.changes ? (!summary.changes.available ? (_jsx(Text, { children: "Changes: Not available" })) : summary.changes.totalFilesChanged === 0 || (summary.changes.files && summary.changes.files.length === 0) ? (_jsx(Text, { children: "No changes" })) : summary.changes.totalFilesChanged !== undefined || summary.changes.files ? (_jsxs(_Fragment, { children: [summary.changes.totalFilesChanged !== undefined && (_jsxs(Text, { children: [summary.changes.totalFilesChanged, " file", summary.changes.totalFilesChanged !== 1 ? 's' : '', " changed"] })), summary.changes.files && summary.changes.files.map((file) => (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { children: [file.path, " "] }), _jsxs(Text, { color: colors.green, children: ["+", file.added, " "] }), _jsxs(Text, { color: colors.pink, children: ["-", file.removed] }), _jsx(Text, { children: " lines" })] }, file.path)))] })) : (_jsx(Text, { children: "Changes: Could not compute diff" }))) : (_jsx(Text, { children: "Changes: Not available" })), summary.commits ? (!summary.commits.available ? (_jsx(Text, { children: "Commit: Not available" })) : summary.commits.fromHash && summary.commits.toHash ? (_jsxs(Text, { children: ["Commit: ", summary.commits.fromHash, " \u2192 ", summary.commits.toHash, summary.commits.mergeType === 'squash' && ' (squash-merged)', summary.commits.mergeType === 'normal' && ' (merged)'] })) : summary.commits.toHash ? (_jsxs(Text, { children: ["Commit: ", summary.commits.toHash] })) : (_jsx(Text, { children: "Commit: Not available" }))) : (_jsx(Text, { children: "Commit: Not available" }))] }), _jsxs(SummaryBoxSection, { children: [summary.pr ? (_jsx(_Fragment, { children: !summary.pr.available ? (_jsx(Text, { children: "PR: Not available" })) : summary.pr.created && summary.pr.number && summary.pr.url ? (_jsxs(Text, { children: ["PR #", summary.pr.number, ": ", summary.pr.url] })) : (_jsx(Text, { children: "PR: Not created" })) })) : (_jsx(Text, { children: "PR: Not available" })), summary.issue ? (_jsx(_Fragment, { children: !summary.issue.available ? (_jsx(Text, { children: "Issue: Not available" })) : summary.issue.linked && summary.issue.number ? (_jsxs(Text, { children: ["Issue #", summary.issue.number, ": ", summary.issue.status || 'Linked', summary.issue.url && ` (${summary.issue.url})`] })) : (_jsx(Text, { children: "Issue: Not linked" })) })) : (_jsx(Text, { children: "Issue: Not available" }))] })] }));
131
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: phaseColor, children: [phaseIcon, " "] }), _jsxs(Text, { children: [phaseInfo.label, " ", durationText, iterationsText, statusText] })] }, phaseInfo.id));
132
+ })) : (_jsx(Text, { children: "No phase information available" }))] }), _jsxs(SummaryBoxSection, { children: [_jsx(Text, { bold: true, children: "Changes" }), !summary.changes || !summary.changes.available ? (_jsx(Text, { children: "Changes: Not available" })) : summary.changes.totalFilesChanged === 0 || (summary.changes.files && summary.changes.files.length === 0) ? (_jsx(Text, { children: "No changes" })) : summary.changes.totalFilesChanged !== undefined ? (_jsxs(Text, { children: [summary.changes.totalFilesChanged, " file", summary.changes.totalFilesChanged !== 1 ? 's' : '', " changed"] })) : !summary.changes.files ? (_jsx(Text, { children: "Changes: Could not compute diff" })) : null, (() => {
133
+ const files = summary.changes?.available ? summary.changes.files : undefined;
134
+ if (!files || files.length === 0)
135
+ return null;
136
+ return formatChangesFiles(files, contentWidth).map((fmt, i) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: fmt.displayPath }), _jsx(Text, { children: ' ' }), _jsx(Text, { color: colors.green, children: fmt.addedStr }), _jsx(Text, { children: " " }), _jsx(Text, { color: colors.pink, children: fmt.removedStr })] }, files[i].path)));
137
+ })(), (() => {
138
+ if (!summary.commits || !summary.commits.available) {
139
+ return _jsx(Text, { children: "Commit: Not available" });
140
+ }
141
+ if (summary.commits.commitList && summary.commits.commitList.length > 0) {
142
+ // hash (7) + space (1) = 8 chars reserved for prefix
143
+ const titleMaxWidth = contentWidth - 8;
144
+ return [
145
+ _jsx(Text, { children: ' ' }, "commit-spacer"),
146
+ _jsx(Text, { bold: true, children: "Commits" }, "commit-label"),
147
+ ...summary.commits.commitList.map((commit) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: commit.hash }), _jsxs(Text, { children: [" ", truncateEnd(commit.title, titleMaxWidth)] })] }, commit.hash))),
148
+ ];
149
+ }
150
+ if (summary.commits.fromHash && summary.commits.toHash) {
151
+ return (_jsxs(Text, { children: ["Commit: ", summary.commits.fromHash, " \u2192 ", summary.commits.toHash, summary.commits.mergeType === 'squash' && ' (squash-merged)', summary.commits.mergeType === 'normal' && ' (merged)'] }));
152
+ }
153
+ if (summary.commits.toHash) {
154
+ return _jsxs(Text, { children: ["Commit: ", summary.commits.toHash] });
155
+ }
156
+ return _jsx(Text, { children: "Commit: Not available" });
157
+ })()] }), _jsxs(SummaryBoxSection, { children: [summary.pr ? (_jsx(_Fragment, { children: !summary.pr.available ? (_jsx(Text, { children: "PR: Not available" })) : summary.pr.created && summary.pr.number && summary.pr.url ? (_jsxs(Text, { children: ["PR #", summary.pr.number, ": ", summary.pr.url] })) : (_jsx(Text, { children: "PR: Not created" })) })) : (_jsx(Text, { children: "PR: Not available" })), summary.issue ? (_jsx(_Fragment, { children: !summary.issue.available ? (_jsx(Text, { children: "Issue: Not available" })) : summary.issue.linked && summary.issue.number ? (_jsxs(Text, { children: ["Issue #", summary.issue.number, ": ", summary.issue.status || 'Linked', summary.issue.url && ` (${summary.issue.url})`] })) : (_jsx(Text, { children: "Issue: Not linked" })) })) : (_jsx(Text, { children: "Issue: Not available" }))] })] }));
68
158
  }