shipwright-cli 1.7.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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +926 -0
  3. package/claude-code/CLAUDE.md.shipwright +125 -0
  4. package/claude-code/hooks/notify-idle.sh +35 -0
  5. package/claude-code/hooks/pre-compact-save.sh +57 -0
  6. package/claude-code/hooks/task-completed.sh +170 -0
  7. package/claude-code/hooks/teammate-idle.sh +68 -0
  8. package/claude-code/settings.json.template +184 -0
  9. package/completions/_shipwright +140 -0
  10. package/completions/shipwright.bash +89 -0
  11. package/completions/shipwright.fish +107 -0
  12. package/docs/KNOWN-ISSUES.md +199 -0
  13. package/docs/TIPS.md +331 -0
  14. package/docs/definition-of-done.example.md +16 -0
  15. package/docs/patterns/README.md +139 -0
  16. package/docs/patterns/audit-loop.md +149 -0
  17. package/docs/patterns/bug-hunt.md +183 -0
  18. package/docs/patterns/feature-implementation.md +159 -0
  19. package/docs/patterns/refactoring.md +183 -0
  20. package/docs/patterns/research-exploration.md +144 -0
  21. package/docs/patterns/test-generation.md +173 -0
  22. package/package.json +49 -0
  23. package/scripts/adapters/docker-deploy.sh +50 -0
  24. package/scripts/adapters/fly-deploy.sh +41 -0
  25. package/scripts/adapters/iterm2-adapter.sh +122 -0
  26. package/scripts/adapters/railway-deploy.sh +34 -0
  27. package/scripts/adapters/tmux-adapter.sh +87 -0
  28. package/scripts/adapters/vercel-deploy.sh +35 -0
  29. package/scripts/adapters/wezterm-adapter.sh +103 -0
  30. package/scripts/cct +242 -0
  31. package/scripts/cct-cleanup.sh +172 -0
  32. package/scripts/cct-cost.sh +590 -0
  33. package/scripts/cct-daemon.sh +3189 -0
  34. package/scripts/cct-doctor.sh +328 -0
  35. package/scripts/cct-fix.sh +478 -0
  36. package/scripts/cct-fleet.sh +904 -0
  37. package/scripts/cct-init.sh +282 -0
  38. package/scripts/cct-logs.sh +273 -0
  39. package/scripts/cct-loop.sh +1332 -0
  40. package/scripts/cct-memory.sh +1148 -0
  41. package/scripts/cct-pipeline.sh +3844 -0
  42. package/scripts/cct-prep.sh +1352 -0
  43. package/scripts/cct-ps.sh +168 -0
  44. package/scripts/cct-reaper.sh +390 -0
  45. package/scripts/cct-session.sh +284 -0
  46. package/scripts/cct-status.sh +169 -0
  47. package/scripts/cct-templates.sh +242 -0
  48. package/scripts/cct-upgrade.sh +422 -0
  49. package/scripts/cct-worktree.sh +405 -0
  50. package/scripts/postinstall.mjs +96 -0
  51. package/templates/pipelines/autonomous.json +71 -0
  52. package/templates/pipelines/cost-aware.json +95 -0
  53. package/templates/pipelines/deployed.json +79 -0
  54. package/templates/pipelines/enterprise.json +114 -0
  55. package/templates/pipelines/fast.json +63 -0
  56. package/templates/pipelines/full.json +104 -0
  57. package/templates/pipelines/hotfix.json +63 -0
  58. package/templates/pipelines/standard.json +91 -0
  59. package/tmux/claude-teams-overlay.conf +109 -0
  60. package/tmux/templates/architecture.json +19 -0
  61. package/tmux/templates/bug-fix.json +24 -0
  62. package/tmux/templates/code-review.json +24 -0
  63. package/tmux/templates/devops.json +19 -0
  64. package/tmux/templates/documentation.json +19 -0
  65. package/tmux/templates/exploration.json +19 -0
  66. package/tmux/templates/feature-dev.json +24 -0
  67. package/tmux/templates/full-stack.json +24 -0
  68. package/tmux/templates/migration.json +24 -0
  69. package/tmux/templates/refactor.json +19 -0
  70. package/tmux/templates/security-audit.json +24 -0
  71. package/tmux/templates/testing.json +24 -0
  72. package/tmux/tmux.conf +167 -0
@@ -0,0 +1,478 @@
1
+ #!/usr/bin/env bash
2
+ # ╔═══════════════════════════════════════════════════════════════════════════╗
3
+ # ║ shipwright fix — Bulk Fix Across Multiple Repos ║
4
+ # ║ Clone a goal across repos · Run pipelines in parallel · Collect PRs ║
5
+ # ╚═══════════════════════════════════════════════════════════════════════════╝
6
+ set -euo pipefail
7
+
8
+ VERSION="1.7.0"
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+
11
+ # ─── Colors (matches Seth's tmux theme) ─────────────────────────────────────
12
+ CYAN='\033[38;2;0;212;255m' # #00d4ff — primary accent
13
+ PURPLE='\033[38;2;124;58;237m' # #7c3aed — secondary
14
+ BLUE='\033[38;2;0;102;255m' # #0066ff — tertiary
15
+ GREEN='\033[38;2;74;222;128m' # success
16
+ YELLOW='\033[38;2;250;204;21m' # warning
17
+ RED='\033[38;2;248;113;113m' # error
18
+ DIM='\033[2m'
19
+ BOLD='\033[1m'
20
+ RESET='\033[0m'
21
+
22
+ # ─── Output Helpers ─────────────────────────────────────────────────────────
23
+ info() { echo -e "${CYAN}${BOLD}▸${RESET} $*"; }
24
+ success() { echo -e "${GREEN}${BOLD}✓${RESET} $*"; }
25
+ warn() { echo -e "${YELLOW}${BOLD}⚠${RESET} $*"; }
26
+ error() { echo -e "${RED}${BOLD}✗${RESET} $*" >&2; }
27
+
28
+ now_iso() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
29
+ now_epoch() { date +%s; }
30
+
31
+ format_duration() {
32
+ local secs="$1"
33
+ if [[ "$secs" -ge 3600 ]]; then
34
+ printf "%dh %dm %ds" $((secs/3600)) $((secs%3600/60)) $((secs%60))
35
+ elif [[ "$secs" -ge 60 ]]; then
36
+ printf "%dm %ds" $((secs/60)) $((secs%60))
37
+ else
38
+ printf "%ds" "$secs"
39
+ fi
40
+ }
41
+
42
+ # ─── Structured Event Log ──────────────────────────────────────────────────
43
+ EVENTS_FILE="${HOME}/.claude-teams/events.jsonl"
44
+
45
+ emit_event() {
46
+ local event_type="$1"
47
+ shift
48
+ local json_fields=""
49
+ for kv in "$@"; do
50
+ local key="${kv%%=*}"
51
+ local val="${kv#*=}"
52
+ if [[ "$val" =~ ^-?[0-9]+\.?[0-9]*$ ]]; then
53
+ json_fields="${json_fields},\"${key}\":${val}"
54
+ else
55
+ val="${val//\"/\\\"}"
56
+ json_fields="${json_fields},\"${key}\":\"${val}\""
57
+ fi
58
+ done
59
+ mkdir -p "${HOME}/.claude-teams"
60
+ echo "{\"ts\":\"$(now_iso)\",\"ts_epoch\":$(now_epoch),\"type\":\"${event_type}\"${json_fields}}" >> "$EVENTS_FILE"
61
+ }
62
+
63
+ # ─── Defaults ───────────────────────────────────────────────────────────────
64
+ FIX_DIR="${HOME}/.claude-teams"
65
+ GOAL=""
66
+ REPOS=()
67
+ REPOS_FROM=""
68
+ TEMPLATE="fast"
69
+ MODEL=""
70
+ MAX_PARALLEL=3
71
+ DRY_RUN=false
72
+ BRANCH_PREFIX="fix/"
73
+
74
+ # ─── Help ───────────────────────────────────────────────────────────────────
75
+
76
+ show_help() {
77
+ echo -e "${CYAN}${BOLD}shipwright fix${RESET} — Bulk fix across multiple repos"
78
+ echo ""
79
+ echo -e "${BOLD}USAGE${RESET}"
80
+ echo -e " ${CYAN}shipwright fix${RESET} \"goal\" [options]"
81
+ echo ""
82
+ echo -e "${BOLD}OPTIONS${RESET}"
83
+ echo -e " ${DIM}--repos dir1,dir2,...${RESET} Comma-separated repo paths"
84
+ echo -e " ${DIM}--repos-from file${RESET} Read repo paths from file (one per line)"
85
+ echo -e " ${DIM}--pipeline template${RESET} Pipeline template (default: fast)"
86
+ echo -e " ${DIM}--model model${RESET} Model to use (default: auto)"
87
+ echo -e " ${DIM}--max-parallel N${RESET} Max concurrent pipelines (default: 3)"
88
+ echo -e " ${DIM}--branch-prefix prefix${RESET} Branch name prefix (default: \"fix/\")"
89
+ echo -e " ${DIM}--dry-run${RESET} Show what would happen without executing"
90
+ echo -e " ${DIM}--status${RESET} Show running fix sessions"
91
+ echo ""
92
+ echo -e "${BOLD}EXAMPLES${RESET}"
93
+ echo -e " ${DIM}shipwright fix \"Update lodash to 4.17.21\" --repos ~/api,~/web,~/mobile${RESET}"
94
+ echo -e " ${DIM}shipwright fix \"Fix SQL injection in auth\" --repos ~/api --pipeline fast${RESET}"
95
+ echo -e " ${DIM}shipwright fix \"Bump Node to 22\" --repos-from repos.txt --pipeline hotfix${RESET}"
96
+ echo -e " ${DIM}shipwright fix --status${RESET}"
97
+ echo ""
98
+ }
99
+
100
+ # ─── Argument Parsing ───────────────────────────────────────────────────────
101
+
102
+ parse_args() {
103
+ while [[ $# -gt 0 ]]; do
104
+ case "$1" in
105
+ --repos)
106
+ IFS=',' read -ra REPOS <<< "$2"
107
+ shift 2
108
+ ;;
109
+ --repos-from)
110
+ REPOS_FROM="$2"
111
+ shift 2
112
+ ;;
113
+ --pipeline)
114
+ TEMPLATE="$2"
115
+ shift 2
116
+ ;;
117
+ --model)
118
+ MODEL="$2"
119
+ shift 2
120
+ ;;
121
+ --max-parallel)
122
+ MAX_PARALLEL="$2"
123
+ shift 2
124
+ ;;
125
+ --branch-prefix)
126
+ BRANCH_PREFIX="$2"
127
+ shift 2
128
+ ;;
129
+ --dry-run)
130
+ DRY_RUN=true
131
+ shift
132
+ ;;
133
+ --status)
134
+ fix_status
135
+ exit 0
136
+ ;;
137
+ help|--help|-h)
138
+ show_help
139
+ exit 0
140
+ ;;
141
+ *)
142
+ if [[ -z "$GOAL" && ! "$1" =~ ^-- ]]; then
143
+ GOAL="$1"
144
+ fi
145
+ shift
146
+ ;;
147
+ esac
148
+ done
149
+
150
+ # Load repos from file if specified
151
+ if [[ -n "$REPOS_FROM" ]]; then
152
+ if [[ ! -f "$REPOS_FROM" ]]; then
153
+ error "Repos file not found: $REPOS_FROM"
154
+ exit 1
155
+ fi
156
+ while IFS= read -r line; do
157
+ line="${line%%#*}" # strip comments
158
+ line="${line// /}" # strip whitespace
159
+ if [[ -n "$line" ]]; then
160
+ REPOS+=("$line")
161
+ fi
162
+ done < "$REPOS_FROM"
163
+ fi
164
+ }
165
+
166
+ # ─── Sanitize Goal for Branch Names ────────────────────────────────────────
167
+
168
+ sanitize_branch() {
169
+ local raw="$1"
170
+ # Lowercase, replace spaces/special chars with hyphens, truncate
171
+ echo "$raw" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-50
172
+ }
173
+
174
+ # ─── Fix Status ─────────────────────────────────────────────────────────────
175
+
176
+ fix_status() {
177
+ local fix_files
178
+ fix_files=$(find "$FIX_DIR" -name 'fix-*.json' -maxdepth 1 2>/dev/null | sort -r) || true
179
+
180
+ if [[ -z "$fix_files" ]]; then
181
+ info "No fix sessions found."
182
+ return
183
+ fi
184
+
185
+ echo -e "${CYAN}${BOLD}═══ Fix Sessions ═══${RESET}"
186
+ echo ""
187
+
188
+ while IFS= read -r f; do
189
+ [[ -z "$f" ]] && continue
190
+ local goal status repo_count started
191
+ goal=$(jq -r '.goal // "unknown"' "$f" 2>/dev/null) || goal="unknown"
192
+ status=$(jq -r '.status // "unknown"' "$f" 2>/dev/null) || status="unknown"
193
+ repo_count=$(jq -r '.repos | length // 0' "$f" 2>/dev/null) || repo_count=0
194
+ started=$(jq -r '.started // "unknown"' "$f" 2>/dev/null) || started="unknown"
195
+
196
+ local status_color="$YELLOW"
197
+ [[ "$status" == "completed" ]] && status_color="$GREEN"
198
+ [[ "$status" == "failed" ]] && status_color="$RED"
199
+
200
+ echo -e " ${BOLD}${goal}${RESET}"
201
+ echo -e " Status: ${status_color}${status}${RESET} | Repos: ${repo_count} | Started: ${DIM}${started}${RESET}"
202
+
203
+ # Show per-repo status
204
+ local repo_statuses
205
+ repo_statuses=$(jq -r '.repos[]? | "\(.name)|\(.status // "pending")|\(.pr_url // "-")|\(.duration // "-")"' "$f" 2>/dev/null) || true
206
+ if [[ -n "$repo_statuses" ]]; then
207
+ while IFS='|' read -r rname rstatus rpr rdur; do
208
+ local ricon="⋯"
209
+ [[ "$rstatus" == "pass" ]] && ricon="${GREEN}✓${RESET}"
210
+ [[ "$rstatus" == "fail" ]] && ricon="${RED}✗${RESET}"
211
+ echo -e " ${ricon} ${rname} ${DIM}${rstatus}${RESET} ${DIM}${rpr}${RESET}"
212
+ done <<< "$repo_statuses"
213
+ fi
214
+ echo ""
215
+ done <<< "$fix_files"
216
+ }
217
+
218
+ # ─── Fix Start ──────────────────────────────────────────────────────────────
219
+
220
+ fix_start() {
221
+ # Validate
222
+ if [[ -z "$GOAL" ]]; then
223
+ error "Goal is required."
224
+ echo -e " Example: ${DIM}shipwright fix \"Update lodash to 4.17.21\" --repos ~/api,~/web${RESET}"
225
+ exit 1
226
+ fi
227
+
228
+ if [[ ${#REPOS[@]} -eq 0 ]]; then
229
+ error "No repos specified. Use --repos or --repos-from."
230
+ exit 1
231
+ fi
232
+
233
+ # Validate repos exist
234
+ for repo in "${REPOS[@]}"; do
235
+ local expanded
236
+ expanded=$(eval echo "$repo")
237
+ if [[ ! -d "$expanded" ]]; then
238
+ error "Repo directory not found: $expanded"
239
+ exit 1
240
+ fi
241
+ if [[ ! -d "$expanded/.git" ]]; then
242
+ warn "Not a git repo: $expanded (skipping)"
243
+ fi
244
+ done
245
+
246
+ local sanitized
247
+ sanitized=$(sanitize_branch "$GOAL")
248
+ local branch_name="${BRANCH_PREFIX}${sanitized}"
249
+ local session_id="fix-$(date +%s)"
250
+ local state_file="$FIX_DIR/${session_id}.json"
251
+ local log_dir="$FIX_DIR/${session_id}-logs"
252
+ local start_epoch
253
+ start_epoch=$(now_epoch)
254
+
255
+ mkdir -p "$FIX_DIR" "$log_dir"
256
+
257
+ # ─── Header ─────────────────────────────────────────────────────────────
258
+ echo ""
259
+ echo -e "${CYAN}${BOLD}╔═══════════════════════════════════════════════════════════════╗${RESET}"
260
+ echo -e "${CYAN}${BOLD}║ Shipwright Fix ║${RESET}"
261
+ echo -e "${CYAN}${BOLD}╚═══════════════════════════════════════════════════════════════╝${RESET}"
262
+ echo ""
263
+ echo -e " ${BOLD}Goal:${RESET} $GOAL"
264
+ echo -e " ${BOLD}Repos:${RESET} ${#REPOS[@]}"
265
+ echo -e " ${BOLD}Pipeline:${RESET} $TEMPLATE"
266
+ echo -e " ${BOLD}Branch:${RESET} $branch_name"
267
+ echo -e " ${BOLD}Parallel:${RESET} $MAX_PARALLEL"
268
+ [[ -n "$MODEL" ]] && echo -e " ${BOLD}Model:${RESET} $MODEL"
269
+ echo ""
270
+
271
+ # ─── Dry Run ────────────────────────────────────────────────────────────
272
+ if [[ "$DRY_RUN" == "true" ]]; then
273
+ info "Dry run — would execute:"
274
+ for repo in "${REPOS[@]}"; do
275
+ local expanded
276
+ expanded=$(eval echo "$repo")
277
+ local rname
278
+ rname=$(basename "$expanded")
279
+ echo -e " ${DIM}cd $expanded && git checkout -b $branch_name${RESET}"
280
+ echo -e " ${DIM}cct-pipeline.sh start --goal \"$GOAL\" --pipeline $TEMPLATE --skip-gates${RESET}"
281
+ echo ""
282
+ done
283
+ return
284
+ fi
285
+
286
+ # Build initial state JSON using jq
287
+ local repos_json="[]"
288
+ for repo in "${REPOS[@]}"; do
289
+ local expanded
290
+ expanded=$(eval echo "$repo")
291
+ local rname
292
+ rname=$(basename "$expanded")
293
+ repos_json=$(echo "$repos_json" | jq --arg name "$rname" --arg path "$expanded" \
294
+ '. + [{"name": $name, "path": $path, "status": "pending", "pr_url": "-", "duration": "-", "pid": 0}]')
295
+ done
296
+
297
+ # Atomic write initial state
298
+ local tmp_state
299
+ tmp_state=$(mktemp)
300
+ jq -n \
301
+ --arg goal "$GOAL" \
302
+ --arg branch "$branch_name" \
303
+ --arg template "$TEMPLATE" \
304
+ --arg started "$(now_iso)" \
305
+ --arg session_id "$session_id" \
306
+ --argjson repos "$repos_json" \
307
+ '{goal: $goal, branch: $branch, template: $template, started: $started, session_id: $session_id, status: "running", repos: $repos}' \
308
+ > "$tmp_state"
309
+ mv "$tmp_state" "$state_file"
310
+
311
+ emit_event "fix.started" "goal=$GOAL" "repos=${#REPOS[@]}" "template=$TEMPLATE" "session=$session_id"
312
+
313
+ # ─── Parallel Execution ─────────────────────────────────────────────────
314
+ local pids=()
315
+ local pid_to_idx=()
316
+ local idx=0
317
+
318
+ for repo in "${REPOS[@]}"; do
319
+ local expanded
320
+ expanded=$(eval echo "$repo")
321
+ local rname
322
+ rname=$(basename "$expanded")
323
+
324
+ # Throttle: wait for a slot if at max parallel
325
+ while [[ ${#pids[@]} -ge $MAX_PARALLEL ]]; do
326
+ # Wait for any one to finish
327
+ wait -n "${pids[@]}" 2>/dev/null || true
328
+ # Rebuild pids array — remove finished ones
329
+ local new_pids=()
330
+ for p in "${pids[@]}"; do
331
+ if kill -0 "$p" 2>/dev/null; then
332
+ new_pids+=("$p")
333
+ fi
334
+ done
335
+ pids=("${new_pids[@]}")
336
+ done
337
+
338
+ info "Starting: ${BOLD}${rname}${RESET}"
339
+
340
+ emit_event "fix.repo.started" "repo=$rname" "session=$session_id"
341
+
342
+ # Update state to running
343
+ tmp_state=$(mktemp)
344
+ jq --arg name "$rname" '(.repos[] | select(.name == $name)).status = "running"' "$state_file" > "$tmp_state"
345
+ mv "$tmp_state" "$state_file"
346
+
347
+ # Spawn pipeline in subshell
348
+ (
349
+ cd "$expanded"
350
+
351
+ # Determine base branch
352
+ local base
353
+ base=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') || base="main"
354
+
355
+ # Create fix branch
356
+ git checkout -b "$branch_name" "origin/$base" 2>/dev/null || git checkout -b "$branch_name" "$base" 2>/dev/null || true
357
+
358
+ # Build pipeline command
359
+ local cmd=("$SCRIPT_DIR/cct-pipeline.sh" start --goal "$GOAL" --pipeline "$TEMPLATE" --skip-gates)
360
+ [[ -n "$MODEL" ]] && cmd+=(--model "$MODEL")
361
+
362
+ # Run pipeline
363
+ "${cmd[@]}"
364
+ ) > "$log_dir/${rname}.log" 2>&1 &
365
+
366
+ local pid=$!
367
+ pids+=("$pid")
368
+
369
+ # Track PID → repo index via temp file (subshell-safe)
370
+ echo "$rname" > "$log_dir/.pid-${pid}"
371
+
372
+ idx=$((idx + 1))
373
+ done
374
+
375
+ # ─── Wait for All ───────────────────────────────────────────────────────
376
+ info "Waiting for ${#REPOS[@]} pipelines to complete..."
377
+ echo ""
378
+
379
+ local success_count=0
380
+ local fail_count=0
381
+
382
+ # Wait for remaining PIDs and collect results
383
+ for pid in "${pids[@]}"; do
384
+ local rname=""
385
+ if [[ -f "$log_dir/.pid-${pid}" ]]; then
386
+ rname=$(< "$log_dir/.pid-${pid}")
387
+ fi
388
+
389
+ local repo_start
390
+ repo_start=$(now_epoch)
391
+
392
+ if wait "$pid" 2>/dev/null; then
393
+ local repo_end
394
+ repo_end=$(now_epoch)
395
+ local repo_dur=$((repo_end - repo_start))
396
+
397
+ # Try to extract PR URL from log
398
+ local pr_url="-"
399
+ if [[ -f "$log_dir/${rname}.log" ]]; then
400
+ pr_url=$(grep -oE 'https://github\.com/[^ ]+/pull/[0-9]+' "$log_dir/${rname}.log" 2>/dev/null | tail -1) || pr_url="-"
401
+ [[ -z "$pr_url" ]] && pr_url="-"
402
+ fi
403
+
404
+ success " ${rname}: pass"
405
+ success_count=$((success_count + 1))
406
+
407
+ emit_event "fix.repo.completed" "repo=$rname" "status=pass" "pr_url=$pr_url" "session=$session_id"
408
+
409
+ # Update state
410
+ tmp_state=$(mktemp)
411
+ jq --arg name "$rname" --arg pr "$pr_url" --arg dur "$(format_duration $repo_dur)" \
412
+ '(.repos[] | select(.name == $name)) |= (.status = "pass" | .pr_url = $pr | .duration = $dur)' \
413
+ "$state_file" > "$tmp_state"
414
+ mv "$tmp_state" "$state_file"
415
+ else
416
+ local repo_end
417
+ repo_end=$(now_epoch)
418
+ local repo_dur=$((repo_end - repo_start))
419
+
420
+ error " ${rname}: fail"
421
+ fail_count=$((fail_count + 1))
422
+
423
+ emit_event "fix.repo.completed" "repo=$rname" "status=fail" "session=$session_id"
424
+
425
+ tmp_state=$(mktemp)
426
+ jq --arg name "$rname" --arg dur "$(format_duration $repo_dur)" \
427
+ '(.repos[] | select(.name == $name)) |= (.status = "fail" | .duration = $dur)' \
428
+ "$state_file" > "$tmp_state"
429
+ mv "$tmp_state" "$state_file"
430
+ fi
431
+ done
432
+
433
+ # ─── Summary ────────────────────────────────────────────────────────────
434
+ local end_epoch
435
+ end_epoch=$(now_epoch)
436
+ local total_dur=$((end_epoch - start_epoch))
437
+ local final_status="completed"
438
+ [[ $fail_count -gt 0 ]] && final_status="partial"
439
+ [[ $success_count -eq 0 ]] && final_status="failed"
440
+
441
+ # Update final state
442
+ tmp_state=$(mktemp)
443
+ jq --arg status "$final_status" --arg dur "$(format_duration $total_dur)" \
444
+ '.status = $status | .total_duration = $dur' "$state_file" > "$tmp_state"
445
+ mv "$tmp_state" "$state_file"
446
+
447
+ emit_event "fix.completed" "goal=$GOAL" "session=$session_id" \
448
+ "success=$success_count" "fail=$fail_count" "total=${#REPOS[@]}" \
449
+ "duration=$total_dur" "status=$final_status"
450
+
451
+ echo ""
452
+ echo -e "${CYAN}${BOLD}═══ Fix Complete: \"${GOAL}\" ═══${RESET}"
453
+ echo ""
454
+ printf " ${BOLD}%-16s %-10s %-30s %s${RESET}\n" "Repo" "Status" "PR" "Duration"
455
+ echo -e " ${DIM}────────────────────────────────────────────────────────────────${RESET}"
456
+
457
+ while IFS='|' read -r rname rstatus rpr rdur; do
458
+ [[ -z "$rname" ]] && continue
459
+ local icon="${YELLOW}⋯${RESET}"
460
+ [[ "$rstatus" == "pass" ]] && icon="${GREEN}✓${RESET}"
461
+ [[ "$rstatus" == "fail" ]] && icon="${RED}✗${RESET}"
462
+ printf " %-16s ${icon} %-8s %-30s %s\n" "$rname" "$rstatus" "$rpr" "$rdur"
463
+ done < <(jq -r '.repos[] | "\(.name)|\(.status)|\(.pr_url)|\(.duration)"' "$state_file" 2>/dev/null)
464
+
465
+ echo ""
466
+ echo -e " ${BOLD}Success:${RESET} ${success_count}/${#REPOS[@]} | ${BOLD}Duration:${RESET} $(format_duration $total_dur) (parallel)"
467
+
468
+ if [[ $fail_count -gt 0 ]]; then
469
+ echo ""
470
+ echo -e " ${DIM}Logs: $log_dir${RESET}"
471
+ fi
472
+ echo ""
473
+ }
474
+
475
+ # ─── Main ───────────────────────────────────────────────────────────────────
476
+
477
+ parse_args "$@"
478
+ fix_start