Commit 1a7066e

Eric Bower  ·  2026-08-16 09:45:42 -0400 EDT
parent 50d7d59
feat: reporter ui and plain
7 files changed,  +1704, -211
+10, -211
  1@@ -16,13 +16,11 @@ import (
  2 	"log/slog"
  3 	"os"
  4 	"os/exec"
  5-	"os/signal"
  6 	"path/filepath"
  7 	"runtime"
  8 	"sort"
  9 	"strconv"
 10 	"strings"
 11-	"syscall"
 12 	"time"
 13 )
 14 
 15@@ -1027,7 +1025,7 @@ See: https://github.com/picosh/pici
 16 	fmt.Fprintln(os.Stdout, "āœ… job launched") //nolint:errcheck
 17 
 18 	if cfg.Wait {
 19-		if err := waitAndReport(cfg, log, eventData.Name, jobID, eventData.Type); err != nil {
 20+		if err := waitAndReportPlain(cfg, log, eventData.Name, jobID, eventData.Type); err != nil {
 21 			runErr = err
 22 			return fmt.Errorf("wait: %w", err)
 23 		}
 24@@ -1043,210 +1041,12 @@ See: https://github.com/picosh/pici
 25 }
 26 
 27 // waitAndReport polls the job's sessions until all complete, prints live
 28-// progress to stdout, then dumps session history and a final summary.
 29+// interactive progress to stdout, updates stats.json, and outputs a final summary.
 30 func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) error {
 31-	domain := getDomain(eventType)
 32-	prefix := domain + "." + name + "." + jobID + "."
 33-	interval := cfg.MonitorInterval
 34-	if interval <= 0 {
 35-		interval = 5 * time.Second
 36-	}
 37-	ticker := time.NewTicker(interval)
 38-	defer ticker.Stop()
 39-
 40-	// Handle ^C and termination signals gracefully
 41-	sigCh := make(chan os.Signal, 1)
 42-	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
 43-	defer signal.Stop(sigCh)
 44-
 45-	fmt.Fprintln(os.Stdout)                                //nolint:errcheck
 46-	fmt.Fprint(os.Stdout, "ā³ waiting for completion...\n") //nolint:errcheck
 47-
 48-	// Track state for each session
 49-	known := make(map[string]*sessionState)
 50-	var jobSessions []SessionInfo
 51-	var sessionOrder []string // track insertion order for deterministic output
 52-	var liveLines []string    // last set of status lines printed (for overwrite)
 53-
 54-	var done <-chan struct{}
 55-	if cfg.Ctx != nil {
 56-		done = cfg.Ctx.Done()
 57-	}
 58-
 59-	for {
 60-		select {
 61-		case <-done:
 62-			cancelJobSessions(prefix)
 63-			fmt.Fprintln(os.Stdout, "\nā¹ job cancelled") //nolint:errcheck
 64-			return cfg.Ctx.Err()
 65-		case <-sigCh:
 66-			cancelJobSessions(prefix)
 67-			fmt.Fprintln(os.Stdout, "\nā¹ job cancelled") //nolint:errcheck
 68-			return fmt.Errorf("job cancelled by signal")
 69-		case <-ticker.C:
 70-		}
 71-
 72-		// Fetch current session list
 73-		listOutput, err := exec.Command("zmx", "list").CombinedOutput()
 74-		if err != nil {
 75-			log.Error("zmx list", "err", err)
 76-			continue
 77-		}
 78-
 79-		sessions := parseZMXList(string(listOutput))
 80-		jobSessions = nil
 81-		for _, s := range sessions {
 82-			if strings.HasPrefix(s.Name, prefix) {
 83-				s.Short = cleanSessionShort(s.Name, prefix, name, jobID)
 84-				jobSessions = append(jobSessions, s)
 85-			}
 86-		}
 87-
 88-		if len(jobSessions) == 0 {
 89-			continue // runner session may not have appeared yet
 90-		}
 91-
 92-		// Update state for each session
 93-		for _, s := range jobSessions {
 94-			state, ok := known[s.Short]
 95-			if !ok {
 96-				state = &sessionState{}
 97-				known[s.Short] = state
 98-				sessionOrder = append(sessionOrder, s.Short)
 99-			}
100-
101-			if s.Ended == "" {
102-				state.status = "running"
103-				created, _ := strconv.ParseInt(s.Created, 10, 64)
104-				state.duration = fmtDurationTs(created, time.Now().Unix())
105-			} else {
106-				if s.ExitCode == "0" {
107-					state.status = "success"
108-				} else {
109-					state.status = "failed"
110-					state.exitCode = s.ExitCode
111-				}
112-				state.duration = fmtDuration(s.Created, s.Ended)
113-				state.printed = true // lock final state
114-			}
115-		}
116-
117-		// Build current status lines
118-		lines := make([]string, 0, len(sessionOrder))
119-		for _, short := range sessionOrder {
120-			state := known[short]
121-			icon := map[string]string{"running": "šŸš€", "success": "āœ…", "failed": "āŒ"}[state.status]
122-			detail := ""
123-			if state.status == "failed" && state.exitCode != "" {
124-				detail = fmt.Sprintf(", exit %s", state.exitCode)
125-			}
126-			if state.duration != "" && state.duration != "—" {
127-				detail += fmt.Sprintf(" (%s)", state.duration)
128-			}
129-			lines = append(lines, fmt.Sprintf("   %-12s %s %s%s", short, icon, state.status, detail))
130-		}
131-
132-		// Overwrite previous lines with cursor-up, or print fresh
133-		if len(liveLines) > 0 {
134-			// Move cursor up to overwrite previous lines //nolint:errcheck
135-			for range len(liveLines) {
136-				fmt.Fprint(os.Stdout, "\033[A") //nolint:errcheck
137-			}
138-			// Clear each line
139-			for i, line := range lines { //nolint:errcheck
140-				if i > 0 {
141-					fmt.Fprint(os.Stdout, "\n") //nolint:errcheck
142-				}
143-				fmt.Fprint(os.Stdout, line+"\033[K") //nolint:errcheck
144-			}
145-			fmt.Fprint(os.Stdout, "\n") //nolint:errcheck
146-		} else { //nolint:errcheck
147-			for _, line := range lines {
148-				fmt.Fprintln(os.Stdout, line) //nolint:errcheck
149-			}
150-		}
151-		liveLines = lines
152-
153-		// Stage local artifacts on tick so progress and HTML reports update live
154-		_ = stageLocalArtifacts(cfg, log, name, jobID, eventType)
155-
156-		// Check if all sessions are done and the runner session has finished
157-		if isJobComplete(jobSessions) {
158-			break
159-		}
160-	}
161-
162-	// Print last 25 lines of history for failed sessions only
163-	fmt.Fprintln(os.Stdout) //nolint:errcheck
164-	for _, s := range jobSessions {
165-		state := known[s.Short]
166-		if state == nil || state.status != "failed" {
167-			continue
168-		}
169-
170-		separator := strings.Repeat("\u2500", 50)
171-		fmt.Fprintln(os.Stdout, separator)                                         //nolint:errcheck
172-		fmt.Fprintf(os.Stdout, "Session: %s (exit %s)\n", s.Short, state.exitCode) //nolint:errcheck
173-		fmt.Fprintln(os.Stdout, separator)                                         //nolint:errcheck
174-		fmt.Fprintln(os.Stdout)                                                    //nolint:errcheck
175-
176-		history, err := fetchHistoryPlain(s.Name)
177-		if err != nil {
178-			fmt.Fprintf(os.Stdout, "   (history unavailable: %v)\n", err) //nolint:errcheck
179-		} else {
180-			lines := strings.Split(history, "\n")
181-			// Show last 25 lines
182-			if len(lines) > 25 {
183-				fmt.Fprintf(os.Stdout, "   ... (%d lines omitted)\n", len(lines)-25) //nolint:errcheck
184-				lines = lines[len(lines)-25:]
185-			}
186-			for _, line := range lines {
187-				if line != "" {
188-					fmt.Fprintf(os.Stdout, "   %s\n", line) //nolint:errcheck
189-				}
190-			}
191-		}
192-		fmt.Fprintln(os.Stdout) //nolint:errcheck
193-	}
194-
195-	// Final summary
196-	exitCode, status := resolveJobExitCode(jobSessions)
197-	_, _, duration := computeJobTiming(jobSessions)
198-	icon := map[string]string{"success": "āœ…", "failed": "āŒ"}[status]
199-	if exitCode == 0 {
200-		fmt.Fprintf(os.Stdout, "%s job finished: %s (%s)\n", icon, status, duration) //nolint:errcheck
201-	} else {
202-		fmt.Fprintf(os.Stdout, "%s job failed: exit %d (%s)\n", icon, exitCode, duration) //nolint:errcheck
203-	}
204-
205-	topic := fmt.Sprintf("%s.%s.%s", domain, name, jobID)
206-	if exitCode != 0 && cfg.DebugOnFail {
207-		cwd, _ := os.Getwd()
208-		debugCfg := DebugBridgeConfig{
209-			Topic:        topic,
210-			KeyLocation:  cfg.KeyLocation,
211-			CertLocation: cfg.CertificateLocation,
212-			WorkDir:      cwd,
213-			Timeout:      cfg.DebugTimeout,
214-			Logger:       log,
215-			Stdout:       os.Stdout,
216-		}
217-		if err := startDebugBridge(cfg.Ctx, debugCfg); err != nil {
218-			log.Error("debug bridge failed", "err", err)
219-		}
220-	}
221-
222-	if cfg.SummaryFile != "" {
223-		if err := writeSummaryMarkdown(cfg.SummaryFile, name, jobID, jobSessions, known, cfg.DebugOnFail && exitCode != 0, topic); err != nil {
224-			log.Error("write summary markdown", "err", err, "file", cfg.SummaryFile)
225-		}
226-	}
227-
228-	if exitCode != 0 {
229-		return &JobFailedError{ExitCode: exitCode}
230-	}
231-
232-	return nil
233+	cwd, _ := os.Getwd()
234+	branch := detectGitBranch(cwd)
235+	commit := detectGitCommit(cwd)
236+	return waitAndReportUI(cfg, log, name, jobID, eventType, branch, commit)
237 }
238 
239 func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions []SessionInfo, known map[string]*sessionState, debugActive bool, debugTopic string) error {
240@@ -1351,7 +1151,6 @@ type sessionState struct {
241 	status   string // "running", "success", "failed"
242 	exitCode string
243 	duration string
244-	printed  bool // true once final state (success/failed) is printed
245 }
246 
247 // monitorJobState tracks display state for a single job across ticks.
248@@ -2630,14 +2429,14 @@ func runLocal(cfg *Cfg, dest string) error {
249 		}
250 	}()
251 
252-	fmt.Fprintf(os.Stdout, "šŸš€ starting local job local.%s.%s\n", eventData.Name, jobID) //nolint:errcheck
253+	log.Info("starting local job", "job_id", jobID)
254 	if err := eng.Setup(); err != nil {
255 		return fmt.Errorf("workspace setup: %w", err)
256 	}
257 	if cfg.InPlace {
258-		fmt.Fprintf(os.Stdout, "šŸ“ using in-place workspace directory %s\n", eng.Wk.GetDir()) //nolint:errcheck
259+		log.Debug("using in-place workspace directory", "dir", eng.Wk.GetDir())
260 	} else {
261-		fmt.Fprintf(os.Stdout, "šŸ“¦ syncing workspace to temp directory %s\n", eng.Wk.GetDir()) //nolint:errcheck
262+		log.Debug("syncing workspace to temp directory", "dir", eng.Wk.GetDir())
263 	}
264 	log.Debug("workspace directory", "dir", eng.Wk.GetDir())
265 
266@@ -2674,7 +2473,7 @@ func runLocal(cfg *Cfg, dest string) error {
267 		return err
268 	}
269 
270-	fmt.Fprintln(os.Stdout, "šŸƒ launching sessions...") //nolint:errcheck
271+	log.Debug("launching sessions")
272 	if err := eng.Run(manifest); err != nil {
273 		return fmt.Errorf("run: %w", err)
274 	}
+145, -0
  1@@ -0,0 +1,145 @@
  2+package main
  3+
  4+import (
  5+	"fmt"
  6+	"log/slog"
  7+	"os"
  8+	"os/exec"
  9+	"os/signal"
 10+	"path/filepath"
 11+	"strconv"
 12+	"strings"
 13+	"syscall"
 14+	"time"
 15+)
 16+
 17+// waitAndReportPlain provides clean, linear append-only output for background services
 18+// like pici-runner.service, bypassing all TTY cursor overwrites and spinner loops.
 19+func waitAndReportPlain(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) error {
 20+	domain := getDomain(eventType)
 21+	prefix := fmt.Sprintf("%s.%s.%s.", domain, name, jobID)
 22+	interval := cfg.MonitorInterval
 23+	if interval <= 0 {
 24+		interval = 1 * time.Second
 25+	}
 26+	ticker := time.NewTicker(interval)
 27+	defer ticker.Stop()
 28+
 29+	sigCh := make(chan os.Signal, 1)
 30+	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
 31+	defer signal.Stop(sigCh)
 32+
 33+	startedSessions := make(map[string]bool)
 34+	completedSessions := make(map[string]bool)
 35+	var finalSessions []SessionInfo
 36+
 37+	var done <-chan struct{}
 38+	if cfg.Ctx != nil {
 39+		done = cfg.Ctx.Done()
 40+	}
 41+
 42+	for {
 43+		select {
 44+		case <-done:
 45+			cancelJobSessions(prefix)
 46+			fmt.Fprintf(os.Stdout, "[%s] ā¹ runner cancelled\n", time.Now().Format("15:04:05")) //nolint:errcheck
 47+			return cfg.Ctx.Err()
 48+		case <-sigCh:
 49+			cancelJobSessions(prefix)
 50+			fmt.Fprintf(os.Stdout, "\n[%s] ā¹ runner terminated by signal\n", time.Now().Format("15:04:05")) //nolint:errcheck
 51+			return fmt.Errorf("job cancelled by signal")
 52+		case <-ticker.C:
 53+		}
 54+
 55+		listOutput, err := exec.Command("zmx", "list").CombinedOutput()
 56+		if err != nil {
 57+			if log != nil {
 58+				log.Error("zmx list", "err", err)
 59+			}
 60+			continue
 61+		}
 62+
 63+		sessions := parseZMXList(string(listOutput))
 64+		var jobSessions []SessionInfo
 65+		for _, s := range sessions {
 66+			if strings.HasPrefix(s.Name, prefix) {
 67+				s.Short = cleanSessionShort(s.Name, prefix, name, jobID)
 68+				jobSessions = append(jobSessions, s)
 69+			}
 70+		}
 71+
 72+		finalSessions = jobSessions
 73+
 74+		for _, s := range jobSessions {
 75+			if s.Short == "runner" || s.Short == "" {
 76+				continue
 77+			}
 78+
 79+			if !startedSessions[s.Short] {
 80+				startedSessions[s.Short] = true
 81+				fmt.Fprintf(os.Stdout, "[%s] ā–¶ [%s] started\n", time.Now().Format("15:04:05"), s.Short) //nolint:errcheck
 82+			}
 83+
 84+			if s.Ended != "" && !completedSessions[s.Short] {
 85+				completedSessions[s.Short] = true
 86+				created, _ := strconv.ParseInt(s.Created, 10, 64)
 87+				ended, _ := strconv.ParseInt(s.Ended, 10, 64)
 88+				dur := time.Duration(ended-created) * time.Second
 89+				if s.ExitCode == "0" {
 90+					fmt.Fprintf(os.Stdout, "[%s] āœ” [%s] succeeded (%s)\n", time.Now().Format("15:04:05"), s.Short, formatShortDuration(dur)) //nolint:errcheck
 91+				} else {
 92+					fmt.Fprintf(os.Stdout, "[%s] āœ– [%s] failed with exit %s (%s)\n", time.Now().Format("15:04:05"), s.Short, s.ExitCode, formatShortDuration(dur)) //nolint:errcheck
 93+					hist, err := fetchHistoryPlain(s.Name)
 94+					if err == nil && hist != "" {
 95+						errPreview := extractErrorPreview(hist, 5)
 96+						for _, line := range errPreview {
 97+							fmt.Fprintf(os.Stdout, "    │ %s\n", line) //nolint:errcheck
 98+						}
 99+					}
100+				}
101+			}
102+		}
103+
104+		// Stage local artifacts
105+		_ = stageLocalArtifacts(cfg, log, name, jobID, eventType)
106+
107+		if isJobComplete(jobSessions) {
108+			break
109+		}
110+	}
111+
112+	exitCode, status := resolveJobExitCode(finalSessions)
113+	_, _, duration := computeJobTiming(finalSessions)
114+
115+	reportPath := filepath.Join(cfg.ArtifactDir, name, jobID, "index.html")
116+	if exitCode == 0 {
117+		fmt.Fprintf(os.Stdout, "[%s] āœ” job finished: %s (%s)\n", time.Now().Format("15:04:05"), status, duration) //nolint:errcheck
118+	} else {
119+		fmt.Fprintf(os.Stdout, "[%s] āŒ job failed: exit %d (%s)\n", time.Now().Format("15:04:05"), exitCode, duration) //nolint:errcheck
120+	}
121+	fmt.Fprintf(os.Stdout, "Artifacts: %s\n", reportPath) //nolint:errcheck
122+
123+	if cfg.SummaryFile != "" {
124+		knownStates := make(map[string]*sessionState)
125+		for _, s := range finalSessions {
126+			st := &sessionState{
127+				status:   "failed",
128+				exitCode: s.ExitCode,
129+			}
130+			if s.ExitCode == "0" {
131+				st.status = "success"
132+			}
133+			knownStates[s.Short] = st
134+		}
135+		topic := fmt.Sprintf("%s.%s.%s", domain, name, jobID)
136+		if err := writeSummaryMarkdown(cfg.SummaryFile, name, jobID, finalSessions, knownStates, cfg.DebugOnFail && exitCode != 0, topic); err != nil && log != nil {
137+			log.Error("write summary markdown", "err", err, "file", cfg.SummaryFile)
138+		}
139+	}
140+
141+	if exitCode != 0 {
142+		return &JobFailedError{ExitCode: exitCode}
143+	}
144+
145+	return nil
146+}
+49, -0
 1@@ -0,0 +1,49 @@
 2+package main
 3+
 4+import (
 5+	"context"
 6+	"fmt"
 7+	"io"
 8+	"log/slog"
 9+	"os/exec"
10+	"testing"
11+	"time"
12+)
13+
14+func TestWaitAndReportPlain_Cancellation(t *testing.T) {
15+	if _, err := exec.LookPath("zmx"); err != nil {
16+		t.Skip("zmx not found, skipping integration test")
17+	}
18+
19+	ctx, cancel := context.WithCancel(context.Background())
20+	cfg := &Cfg{
21+		Ctx:             ctx,
22+		Cancel:          cancel,
23+		MonitorInterval: 50 * time.Millisecond,
24+		Logger:          slog.New(slog.NewTextHandler(io.Discard, nil)),
25+		ArtifactDir:     t.TempDir(),
26+	}
27+
28+	jobID := fmt.Sprintf("plain-cancel-%d", time.Now().UnixNano())
29+	prefix := "local.testrepo." + jobID + "."
30+	runnerSession := prefix + "runner"
31+
32+	t.Cleanup(func() {
33+		_ = exec.Command("zmx", "kill", "-f", runnerSession).Run()
34+	})
35+
36+	cmd := exec.Command("zmx", "run", runnerSession, "-d", "sleep", "30")
37+	if err := cmd.Run(); err != nil {
38+		t.Fatalf("failed to start zmx session: %v", err)
39+	}
40+
41+	go func() {
42+		time.Sleep(100 * time.Millisecond)
43+		cancel()
44+	}()
45+
46+	err := waitAndReportPlain(cfg, nil, "testrepo", jobID, "local")
47+	if err == nil {
48+		t.Error("expected error when waitAndReportPlain is cancelled, got nil")
49+	}
50+}
+863, -0
  1@@ -0,0 +1,863 @@
  2+package main
  3+
  4+import (
  5+	"context"
  6+	"fmt"
  7+	"io"
  8+	"log/slog"
  9+	"os"
 10+	"os/exec"
 11+	"os/signal"
 12+	"path/filepath"
 13+	"regexp"
 14+	"sort"
 15+	"strconv"
 16+	"strings"
 17+	"sync"
 18+	"syscall"
 19+	"time"
 20+
 21+	"golang.org/x/sys/unix"
 22+)
 23+
 24+// ---------------------------------------------------------
 25+// UI State Data Model
 26+// ---------------------------------------------------------
 27+
 28+type TaskUIState struct {
 29+	Name          string
 30+	Status        string // "expected", "running", "success", "failed", "cancelled"
 31+	Created       int64
 32+	Ended         int64
 33+	Duration      time.Duration
 34+	ExitCode      string
 35+	LastOutput    string   // Last non-empty line for running peek
 36+	ErrorPreview  []string // Captured error lines on failure
 37+	PredictedDur  time.Duration
 38+	PrevStatus    string
 39+	PrevExitCode  int
 40+	FailureStreak int
 41+	Transition    string
 42+}
 43+
 44+type PipelineUIState struct {
 45+	Repo           string
 46+	Branch         string
 47+	Commit         string
 48+	JobID          string
 49+	StartedAt      time.Time
 50+	Elapsed        time.Duration
 51+	Tasks          map[string]*TaskUIState
 52+	TaskOrder      []string
 53+	PredictedTotal time.Duration
 54+	IsComplete     bool
 55+	Cancelled      bool
 56+	Stats          *RepoStats
 57+	mu             sync.Mutex
 58+}
 59+
 60+var spinnerFrames = []rune{'ā ‹', 'ā ™', 'ā ¹', 'ā ø', 'ā ¼', 'ā “', 'ā ¦', 'ā §', 'ā ‡', 'ā '}
 61+
 62+func newPipelineUIState(repo, branch, commit, jobID string, stats *RepoStats) *PipelineUIState {
 63+	ui := &PipelineUIState{
 64+		Repo:      repo,
 65+		Branch:    branch,
 66+		Commit:    commit,
 67+		JobID:     jobID,
 68+		StartedAt: time.Now(),
 69+		Tasks:     make(map[string]*TaskUIState),
 70+		Stats:     stats,
 71+	}
 72+
 73+	if stats != nil {
 74+		ui.PredictedTotal = time.Duration(stats.AvgWallDurationMs) * time.Millisecond
 75+
 76+		// Pre-populate expected tasks in deterministic order
 77+		var names []string
 78+		for name := range stats.Tasks {
 79+			names = append(names, name)
 80+		}
 81+		sort.Strings(names)
 82+
 83+		for _, name := range names {
 84+			ts := stats.Tasks[name]
 85+			ui.Tasks[name] = &TaskUIState{
 86+				Name:          name,
 87+				Status:        "expected",
 88+				PredictedDur:  time.Duration(ts.AvgDurationMs) * time.Millisecond,
 89+				PrevStatus:    ts.LastStatus,
 90+				PrevExitCode:  ts.LastExitCode,
 91+				FailureStreak: ts.FailureStreak,
 92+			}
 93+			ui.TaskOrder = append(ui.TaskOrder, name)
 94+		}
 95+	}
 96+
 97+	return ui
 98+}
 99+
100+// ---------------------------------------------------------
101+// UI Helpers & Formatters
102+// ---------------------------------------------------------
103+
104+var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\([a-zA-Z]`)
105+
106+func stripANSI(s string) string {
107+	return ansiRegex.ReplaceAllString(s, "")
108+}
109+
110+func getTerminalWidth() int {
111+	ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ)
112+	if err == nil && ws.Col > 0 {
113+		return int(ws.Col)
114+	}
115+	if cols := os.Getenv("COLUMNS"); cols != "" {
116+		if n, err := strconv.Atoi(cols); err == nil && n > 0 {
117+			return n
118+		}
119+	}
120+	return 80
121+}
122+
123+func truncateLine(s string, maxWidth int) string {
124+	if maxWidth <= 0 {
125+		maxWidth = 80
126+	}
127+	clean := stripANSI(s)
128+	runes := []rune(clean)
129+	if len(runes) <= maxWidth {
130+		return s
131+	}
132+	if maxWidth > 3 {
133+		return string(runes[:maxWidth-3]) + "..."
134+	}
135+	return string(runes[:maxWidth])
136+}
137+
138+func extractLastOutputLine(raw string) string {
139+	clean := stripANSI(raw)
140+	lines := strings.Split(clean, "\n")
141+	for i := len(lines) - 1; i >= 0; i-- {
142+		line := strings.TrimSpace(lines[i])
143+		if line != "" && !strings.HasPrefix(line, "step starting") && !strings.HasPrefix(line, "step completed") {
144+			return line
145+		}
146+	}
147+	return ""
148+}
149+
150+func extractErrorPreview(raw string, maxLines int) []string {
151+	clean := stripANSI(raw)
152+	lines := strings.Split(clean, "\n")
153+	var nonEmpties []string
154+	for _, l := range lines {
155+		trimmed := strings.TrimRight(l, "\r\n ")
156+		if trimmed != "" {
157+			nonEmpties = append(nonEmpties, trimmed)
158+		}
159+	}
160+	if len(nonEmpties) == 0 {
161+		return nil
162+	}
163+	if len(nonEmpties) > maxLines {
164+		nonEmpties = nonEmpties[len(nonEmpties)-maxLines:]
165+	}
166+	return nonEmpties
167+}
168+
169+func renderProgressBar(elapsed, predicted time.Duration, width int) (string, int) {
170+	if width <= 0 {
171+		width = 10
172+	}
173+	if predicted <= 0 {
174+		return "[" + strings.Repeat("ā–ˆ", width) + "]", 100
175+	}
176+
177+	pct := int((float64(elapsed) / float64(predicted)) * 100)
178+	displayPct := pct
179+	if displayPct > 100 {
180+		displayPct = 100
181+	}
182+	if displayPct < 0 {
183+		displayPct = 0
184+	}
185+
186+	filled := (displayPct * width) / 100
187+	unfilled := width - filled
188+	if unfilled < 0 {
189+		unfilled = 0
190+	}
191+	bar := "[" + strings.Repeat("ā–ˆ", filled) + strings.Repeat("ā–‘", unfilled) + "]"
192+	return bar, displayPct
193+}
194+
195+func renderErrorPreviewBox(lines []string, width int) string {
196+	if len(lines) == 0 {
197+		return ""
198+	}
199+	if width < 30 {
200+		width = 60
201+	}
202+
203+	var sb strings.Builder
204+	headerTitle := "ā”Œā”€ [error preview] "
205+	headerLine := headerTitle + strings.Repeat("─", max(0, width-len(headerTitle)))
206+	sb.WriteString("    " + headerLine + "\n")
207+
208+	for _, line := range lines {
209+		if len(line) > width-4 {
210+			line = line[:width-7] + "..."
211+		}
212+		fmt.Fprintf(&sb, "    │ %s\n", line)
213+	}
214+
215+	footer := "ā””" + strings.Repeat("─", max(0, width-1))
216+	sb.WriteString("    " + footer)
217+	return sb.String()
218+}
219+
220+func formatShortDuration(d time.Duration) string {
221+	secs := d.Seconds()
222+	if secs < 0 {
223+		secs = 0
224+	}
225+	if secs >= 60 {
226+		m := int(secs) / 60
227+		s := int(secs) % 60
228+		return fmt.Sprintf("%dm%ds", m, s)
229+	}
230+	if secs >= 10 {
231+		return fmt.Sprintf("%.1fs", secs)
232+	}
233+	return fmt.Sprintf("%.1fs", secs)
234+}
235+
236+// ---------------------------------------------------------
237+// Full Interactive UI State Rendering
238+// ---------------------------------------------------------
239+
240+func (ui *PipelineUIState) Render(frame int) string {
241+	ui.mu.Lock()
242+	defer ui.mu.Unlock()
243+
244+	var sb strings.Builder
245+	commitShort := ui.Commit
246+	if len(commitShort) > 7 {
247+		commitShort = commitShort[:7]
248+	}
249+	if commitShort == "" {
250+		commitShort = "HEAD"
251+	}
252+	branch := ui.Branch
253+	if branch == "" {
254+		branch = "local"
255+	}
256+
257+	// Header line 1: repo info
258+	fmt.Fprintf(&sb, "šŸš€ pici: %s (branch: %s • %s)\n", ui.Repo, branch, commitShort)
259+
260+	// Calculate counts
261+	totalTasks := len(ui.TaskOrder)
262+	completedCount := 0
263+	failedCount := 0
264+	for _, name := range ui.TaskOrder {
265+		task := ui.Tasks[name]
266+		if task.Status == "success" || task.Status == "failed" {
267+			completedCount++
268+		}
269+		if task.Status == "failed" {
270+			failedCount++
271+		}
272+	}
273+
274+	elapsed := ui.Elapsed
275+	if elapsed == 0 {
276+		elapsed = time.Since(ui.StartedAt)
277+	}
278+
279+	// Header line 2: timing & status
280+	fmt.Fprintf(&sb, "ā±  Running for %s [ ", formatShortDuration(elapsed))
281+	if failedCount > 0 {
282+		fmt.Fprintf(&sb, "%d/%d completed • %d failed ]\n", completedCount, totalTasks, failedCount)
283+	} else if completedCount == 0 {
284+		estTotal := "~" + formatShortDuration(ui.PredictedTotal)
285+		if ui.PredictedTotal == 0 {
286+			estTotal = "estimating"
287+		}
288+		fmt.Fprintf(&sb, "0/%d tasks • est. %s ]\n", totalTasks, estTotal)
289+	} else {
290+		rem := ui.PredictedTotal - elapsed
291+		if rem < 0 {
292+			rem = 0
293+		}
294+		fmt.Fprintf(&sb, "%d/%d completed • est. ~%s remaining ]\n", completedCount, totalTasks, formatShortDuration(rem))
295+	}
296+
297+	sb.WriteString("\n")
298+
299+	spinnerRune := spinnerFrames[frame%len(spinnerFrames)]
300+
301+	// Render task list
302+	for _, name := range ui.TaskOrder {
303+		task := ui.Tasks[name]
304+		switch task.Status {
305+		case "expected":
306+			pred := ""
307+			if task.PredictedDur > 0 {
308+				pred = fmt.Sprintf("~%s", formatShortDuration(task.PredictedDur))
309+			}
310+			notes := ""
311+			if task.PrevStatus == "failed" {
312+				notes = " • āš ļø failed last run"
313+			}
314+			detail := ""
315+			if pred != "" || notes != "" {
316+				detail = fmt.Sprintf("(expected %s%s)", pred, notes)
317+			}
318+			fmt.Fprintf(&sb, "  ā—Œ %-8s %s\n", task.Name, detail)
319+
320+		case "running":
321+			taskDur := task.Duration
322+			if taskDur == 0 && task.Created > 0 {
323+				taskDur = time.Duration(time.Now().Unix()-task.Created) * time.Second
324+			}
325+			predStr := ""
326+			var bar string
327+			var pct int
328+			if task.PredictedDur > 0 {
329+				predStr = fmt.Sprintf("/ ~%s", formatShortDuration(task.PredictedDur))
330+				bar, pct = renderProgressBar(taskDur, task.PredictedDur, 10)
331+			}
332+			extraNote := ""
333+			if task.PrevStatus == "failed" {
334+				extraNote = " (retesting previous failure)"
335+			}
336+
337+			barPart := ""
338+			if bar != "" {
339+				barPart = fmt.Sprintf("  %s %d%%%s", bar, pct, extraNote)
340+			}
341+
342+			fmt.Fprintf(&sb, "  %c %-8s %s %s%s\n", spinnerRune, task.Name, formatShortDuration(taskDur), predStr, barPart)
343+			if task.LastOutput != "" {
344+				fmt.Fprintf(&sb, "    └─ %s\n", task.LastOutput)
345+			}
346+
347+		case "success":
348+			transitionNote := ""
349+			if task.Transition == "fixed" {
350+				transitionNote = " • šŸŽ‰ fixed!"
351+			}
352+			durStr := formatShortDuration(task.Duration)
353+			if transitionNote != "" {
354+				fmt.Fprintf(&sb, "  āœ” %-8s %s  (%s)\n", task.Name, durStr, strings.TrimPrefix(transitionNote, " • "))
355+			} else {
356+				fmt.Fprintf(&sb, "  āœ” %-8s %s\n", task.Name, durStr)
357+			}
358+
359+		case "failed":
360+			notes := ""
361+			switch task.Transition {
362+			case "still_failing":
363+				notes = " • āš ļø still failing"
364+			case "new_failure":
365+				notes = " • āŒ new failure"
366+			}
367+			exitStr := "exit 1"
368+			if task.ExitCode != "" {
369+				exitStr = "exit " + task.ExitCode
370+			}
371+			fmt.Fprintf(&sb, "  āœ– %-8s %s (%s%s)\n", task.Name, formatShortDuration(task.Duration), exitStr, notes)
372+			if len(task.ErrorPreview) > 0 {
373+				sb.WriteString(renderErrorPreviewBox(task.ErrorPreview, 65))
374+				sb.WriteString("\n")
375+			}
376+
377+		case "cancelled":
378+			fmt.Fprintf(&sb, "  āœ– %-8s cancelled\n", task.Name)
379+		}
380+	}
381+
382+	return sb.String()
383+}
384+
385+// RenderSummary renders the post-run completion summary.
386+func (ui *PipelineUIState) RenderSummary(reportPath string, exitCode int) string {
387+	ui.mu.Lock()
388+	defer ui.mu.Unlock()
389+
390+	var sb strings.Builder
391+	totalTasks := len(ui.TaskOrder)
392+	failedCount := 0
393+	var failedTasks []*TaskUIState
394+
395+	for _, name := range ui.TaskOrder {
396+		task := ui.Tasks[name]
397+		if task.Status == "failed" {
398+			failedCount++
399+			failedTasks = append(failedTasks, task)
400+		}
401+	}
402+
403+	if exitCode != 0 && failedCount == 0 {
404+		failedCount = 1
405+	}
406+
407+	elapsed := ui.Elapsed
408+	if elapsed == 0 {
409+		elapsed = time.Since(ui.StartedAt)
410+	}
411+	wallStr := formatShortDuration(elapsed)
412+
413+	if exitCode == 0 && failedCount == 0 {
414+		fmt.Fprintf(&sb, "āœ” All %d tasks succeeded (total time: %s)\n\n", totalTasks, wallStr)
415+		for _, name := range ui.TaskOrder {
416+			task := ui.Tasks[name]
417+			avgStr := ""
418+			if task.PredictedDur > 0 {
419+				avgStr = fmt.Sprintf("(avg %s", formatShortDuration(task.PredictedDur))
420+				if task.Transition == "fixed" {
421+					avgStr += " • šŸŽ‰ fixed!)"
422+				} else {
423+					avgStr += ")"
424+				}
425+			}
426+			fmt.Fprintf(&sb, "  āœ” %-9s %6s   %s\n", task.Name, formatShortDuration(task.Duration), avgStr)
427+		}
428+	} else {
429+		taskWord := "task"
430+		if failedCount > 1 {
431+			taskWord = "tasks"
432+		}
433+		fmt.Fprintf(&sb, "āŒ %d %s failed (total time: %s)\n\n", failedCount, taskWord, wallStr)
434+		for _, name := range ui.TaskOrder {
435+			task := ui.Tasks[name]
436+			switch task.Status {
437+			case "success":
438+				fmt.Fprintf(&sb, "  āœ” %-9s %6s\n", task.Name, formatShortDuration(task.Duration))
439+			case "failed":
440+				exitStr := "exit " + task.ExitCode
441+				if task.ExitCode == "" {
442+					exitStr = "exit 1"
443+				}
444+				fmt.Fprintf(&sb, "  āœ– %-9s %6s   %s (see error preview above)\n", task.Name, formatShortDuration(task.Duration), exitStr)
445+			default:
446+				fmt.Fprintf(&sb, "  āœ– %-9s cancelled\n", task.Name)
447+			}
448+		}
449+	}
450+
451+	sb.WriteString("\nArtifacts:\n")
452+	if reportPath != "" {
453+		fmt.Fprintf(&sb, "  šŸ“„ Report: %s\n", reportPath)
454+	}
455+	if failedCount > 0 {
456+		if len(failedTasks) > 0 {
457+			for _, ft := range failedTasks {
458+				fmt.Fprintf(&sb, "  šŸ” Debug:  zmx attach local.%s.%s.step.%s\n", ui.Repo, ui.JobID, ft.Name)
459+			}
460+		} else {
461+			fmt.Fprintf(&sb, "  šŸ” Debug:  zmx attach local.%s.%s.runner\n", ui.Repo, ui.JobID)
462+		}
463+	}
464+
465+	return sb.String()
466+}
467+
468+// ---------------------------------------------------------
469+// Non-TTY Plain Stream Reporter
470+// ---------------------------------------------------------
471+
472+type plainStreamReporter struct {
473+	out io.Writer
474+	ui  *PipelineUIState
475+	log *slog.Logger
476+}
477+
478+func (p *plainStreamReporter) OnTaskStart(name string) {
479+	fmt.Fprintf(p.out, "ā–¶ starting %s\n", name) //nolint:errcheck
480+}
481+
482+func (p *plainStreamReporter) OnTaskOutput(name, line string) {
483+	if line != "" {
484+		fmt.Fprintf(p.out, "  [%s] %s\n", name, line) //nolint:errcheck
485+	}
486+}
487+
488+func (p *plainStreamReporter) OnTaskComplete(name string, exitCode int, dur time.Duration, errorLines []string) {
489+	durStr := formatShortDuration(dur)
490+	if exitCode == 0 {
491+		fmt.Fprintf(p.out, "āœ” %s succeeded (%s)\n", name, durStr) //nolint:errcheck
492+	} else {
493+		fmt.Fprintf(p.out, "āœ– %s failed with exit %d (%s)\n", name, exitCode, durStr) //nolint:errcheck
494+		if len(errorLines) > 0 {
495+			for _, line := range errorLines {
496+				fmt.Fprintf(p.out, "    │ %s\n", line) //nolint:errcheck
497+			}
498+		}
499+	}
500+}
501+
502+// ---------------------------------------------------------
503+// Interactive Controller Loop
504+// ---------------------------------------------------------
505+
506+type interactiveController struct {
507+	ctx      context.Context
508+	out      io.Writer
509+	ui       *PipelineUIState
510+	isTTY    bool
511+	rendered int // number of lines rendered in previous frame for cursor overwrite
512+	streamer *plainStreamReporter
513+}
514+
515+func newInteractiveController(ctx context.Context, out io.Writer, repo, branch, commit, jobID string, stats *RepoStats, isTTY bool) *interactiveController {
516+	if ctx == nil {
517+		ctx = context.Background()
518+	}
519+	ui := newPipelineUIState(repo, branch, commit, jobID, stats)
520+	return &interactiveController{
521+		ctx:      ctx,
522+		out:      out,
523+		ui:       ui,
524+		isTTY:    isTTY,
525+		streamer: &plainStreamReporter{out: out, ui: ui},
526+	}
527+}
528+
529+func (c *interactiveController) WaitUntilDone(fetchSessions func() ([]SessionInfo, bool)) error {
530+	sigCh := make(chan os.Signal, 1)
531+	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
532+	defer signal.Stop(sigCh)
533+
534+	ticker := time.NewTicker(100 * time.Millisecond)
535+	defer ticker.Stop()
536+
537+	frame := 0
538+
539+	// Initial render
540+	c.redraw(c.ui.Render(frame))
541+
542+	prevStatus := make(map[string]string)
543+	prevOutput := make(map[string]string)
544+
545+	for {
546+		select {
547+		case <-c.ctx.Done():
548+			c.ui.Cancelled = true
549+			c.clear()
550+			fmt.Fprintln(c.out, "\nā¹ cancelling job and terminating active sessions...") //nolint:errcheck
551+			return c.ctx.Err()
552+
553+		case <-sigCh:
554+			c.ui.Cancelled = true
555+			c.clear()
556+			fmt.Fprintln(c.out, "\n^C")                                                //nolint:errcheck
557+			fmt.Fprintln(c.out, "ā¹ cancelling job and terminating active sessions...") //nolint:errcheck
558+			return fmt.Errorf("job cancelled by signal")
559+
560+		case <-ticker.C:
561+			frame++
562+			sessions, isComplete := fetchSessions()
563+
564+			// Update UI state with current sessions
565+			c.ui.mu.Lock()
566+			for _, s := range sessions {
567+				name := s.Short
568+				if name == "runner" || name == "" {
569+					continue
570+				}
571+
572+				task, exists := c.ui.Tasks[name]
573+				if !exists {
574+					task = &TaskUIState{
575+						Name:   name,
576+						Status: "running",
577+					}
578+					c.ui.Tasks[name] = task
579+					c.ui.TaskOrder = append(c.ui.TaskOrder, name)
580+				}
581+
582+				created, _ := strconv.ParseInt(s.Created, 10, 64)
583+				ended, _ := strconv.ParseInt(s.Ended, 10, 64)
584+				task.Created = created
585+				task.Ended = ended
586+
587+				oldState := prevStatus[name]
588+				if s.Ended == "" {
589+					task.Status = "running"
590+					if created > 0 {
591+						task.Duration = time.Duration(time.Now().Unix()-created) * time.Second
592+					}
593+					if !c.isTTY && (oldState == "" || oldState == "expected") {
594+						c.streamer.OnTaskStart(name)
595+					}
596+					if !c.isTTY && task.LastOutput != "" && task.LastOutput != prevOutput[name] {
597+						c.streamer.OnTaskOutput(name, task.LastOutput)
598+						prevOutput[name] = task.LastOutput
599+					}
600+				} else {
601+					task.ExitCode = s.ExitCode
602+					exitCodeInt, _ := strconv.Atoi(s.ExitCode)
603+					if s.ExitCode == "0" {
604+						task.Status = "success"
605+					} else {
606+						task.Status = "failed"
607+					}
608+					if created > 0 && ended > 0 {
609+						task.Duration = time.Duration(ended-created) * time.Second
610+					}
611+					if task.Transition == "" && c.ui.Stats != nil {
612+						task.Transition = computeOutcomeTransition(c.ui.Stats.Tasks[name], exitCodeInt)
613+					}
614+					if !c.isTTY && oldState != task.Status {
615+						c.streamer.OnTaskComplete(name, exitCodeInt, task.Duration, task.ErrorPreview)
616+					}
617+				}
618+				prevStatus[name] = task.Status
619+			}
620+			c.ui.mu.Unlock()
621+
622+			c.redraw(c.ui.Render(frame))
623+
624+			if isComplete {
625+				c.ui.Elapsed = time.Since(c.ui.StartedAt)
626+				c.ui.IsComplete = true
627+				c.clear()
628+				return nil
629+			}
630+		}
631+	}
632+}
633+
634+func (c *interactiveController) redraw(outStr string) {
635+	if !c.isTTY {
636+		return
637+	}
638+	trimmed := strings.TrimRight(outStr, "\n")
639+	rawLines := strings.Split(trimmed, "\n")
640+	if len(rawLines) == 0 || (len(rawLines) == 1 && rawLines[0] == "") {
641+		return
642+	}
643+
644+	termWidth := getTerminalWidth()
645+	lines := make([]string, len(rawLines))
646+	for i, l := range rawLines {
647+		lines[i] = truncateLine(l, termWidth-1)
648+	}
649+
650+	var b strings.Builder
651+
652+	// Move cursor up from the bottom of previous render block to the top line
653+	if c.rendered > 0 {
654+		fmt.Fprintf(&b, "\r\033[%dA", c.rendered)
655+	}
656+
657+	// Print each line, clearing to the end of the line and resetting carriage
658+	for i, line := range lines {
659+		b.WriteString("\r\033[2K") // clear entire line
660+		b.WriteString(line)
661+		if i < len(lines)-1 {
662+			b.WriteString("\n")
663+		}
664+	}
665+
666+	// If the previous frame had more lines, clear the leftover trailing lines
667+	if c.rendered > len(lines)-1 {
668+		extra := c.rendered - (len(lines) - 1)
669+		for i := 0; i < extra; i++ {
670+			b.WriteString("\n\r\033[2K")
671+		}
672+		fmt.Fprintf(&b, "\r\033[%dA", extra)
673+	}
674+
675+	c.rendered = len(lines) - 1
676+	fmt.Fprint(c.out, b.String()) //nolint:errcheck
677+}
678+
679+func (c *interactiveController) clear() {
680+	if !c.isTTY || c.rendered < 0 {
681+		return
682+	}
683+	var b strings.Builder
684+	if c.rendered > 0 {
685+		fmt.Fprintf(&b, "\r\033[%dA", c.rendered)
686+	}
687+	for i := 0; i <= c.rendered; i++ {
688+		b.WriteString("\r\033[2K")
689+		if i < c.rendered {
690+			b.WriteString("\n")
691+		}
692+	}
693+	if c.rendered > 0 {
694+		fmt.Fprintf(&b, "\r\033[%dA", c.rendered)
695+	}
696+	c.rendered = 0
697+	fmt.Fprint(c.out, b.String()) //nolint:errcheck
698+}
699+
700+// ---------------------------------------------------------
701+// Interactive waitAndReport Entrypoint
702+// ---------------------------------------------------------
703+
704+func isTerminal(w io.Writer) bool {
705+	if os.Getenv("CI") != "" || os.Getenv("TERM") == "dumb" {
706+		return false
707+	}
708+	if f, ok := w.(*os.File); ok {
709+		stat, err := f.Stat()
710+		if err == nil && (stat.Mode()&os.ModeCharDevice) != 0 {
711+			return true
712+		}
713+	}
714+	return false
715+}
716+
717+// waitAndReportUI executes the rich interactive reporting loop, updating stats.json.
718+func waitAndReportUI(cfg *Cfg, log *slog.Logger, repoName, jobID, eventType, branch, commit string) error {
719+	domain := getDomain(eventType)
720+	prefix := fmt.Sprintf("%s.%s.%s.", domain, repoName, jobID)
721+
722+	stats, _ := loadRepoStats(repoName)
723+	isTTY := isTerminal(os.Stdout)
724+
725+	ctrl := newInteractiveController(cfg.Ctx, os.Stdout, repoName, branch, commit, jobID, stats, isTTY)
726+
727+	lastStage := time.Now()
728+
729+	// Session poller & peek fetcher
730+	fetchSessions := func() ([]SessionInfo, bool) {
731+		listOutput, err := exec.Command("zmx", "list").CombinedOutput()
732+		if err != nil {
733+			if log != nil {
734+				log.Error("zmx list", "err", err)
735+			}
736+			return nil, false
737+		}
738+
739+		sessions := parseZMXList(string(listOutput))
740+		var jobSessions []SessionInfo
741+		for _, s := range sessions {
742+			if strings.HasPrefix(s.Name, prefix) {
743+				s.Short = cleanSessionShort(s.Name, prefix, repoName, jobID)
744+				jobSessions = append(jobSessions, s)
745+			}
746+		}
747+
748+		// Update tail history for live activity and error peeks
749+		for _, s := range jobSessions {
750+			if s.Short == "runner" || s.Short == "" {
751+				continue
752+			}
753+			hist, err := fetchHistoryPlain(s.Name)
754+			if err == nil && hist != "" {
755+				ctrl.ui.mu.Lock()
756+				if task, ok := ctrl.ui.Tasks[s.Short]; ok {
757+					if s.Ended == "" {
758+						task.LastOutput = extractLastOutputLine(hist)
759+					} else if s.ExitCode != "0" && len(task.ErrorPreview) == 0 {
760+						task.ErrorPreview = extractErrorPreview(hist, 5)
761+					}
762+				}
763+				ctrl.ui.mu.Unlock()
764+			}
765+		}
766+
767+		// Stage local artifacts periodically (every 2 seconds) so reports stay updated live without disk trashing
768+		if time.Since(lastStage) >= 2*time.Second {
769+			_ = stageLocalArtifacts(cfg, log, repoName, jobID, eventType)
770+			lastStage = time.Now()
771+		}
772+
773+		return jobSessions, isJobComplete(jobSessions)
774+	}
775+
776+	waitErr := ctrl.WaitUntilDone(fetchSessions)
777+	if waitErr != nil {
778+		cancelJobSessions(prefix)
779+		if ctrl.ui.Cancelled {
780+			for _, task := range ctrl.ui.Tasks {
781+				if task.Status == "running" {
782+					task.Status = "cancelled"
783+				}
784+			}
785+			fmt.Fprintln(os.Stdout, "ā¹ job cancelled") //nolint:errcheck
786+		}
787+		return waitErr
788+	}
789+
790+	// Fetch final session list
791+	var finalSessions []SessionInfo
792+	if listOutput, err := exec.Command("zmx", "list").CombinedOutput(); err == nil {
793+		for _, s := range parseZMXList(string(listOutput)) {
794+			if strings.HasPrefix(s.Name, prefix) {
795+				s.Short = cleanSessionShort(s.Name, prefix, repoName, jobID)
796+				finalSessions = append(finalSessions, s)
797+			}
798+		}
799+	}
800+
801+	exitCode, overallStatus := resolveJobExitCode(finalSessions)
802+
803+	// Update historical stats.json
804+	if stats != nil {
805+		for _, s := range finalSessions {
806+			if s.Short == "runner" || s.Short == "" {
807+				continue
808+			}
809+			created, _ := strconv.ParseInt(s.Created, 10, 64)
810+			ended, _ := strconv.ParseInt(s.Ended, 10, 64)
811+			dur := time.Duration(ended-created) * time.Second
812+			code, _ := strconv.Atoi(s.ExitCode)
813+			updateTaskStats(stats, s.Short, dur, code)
814+		}
815+		updateRepoStats(stats, ctrl.ui.Elapsed, overallStatus)
816+		_ = saveRepoStats(stats)
817+	}
818+
819+	// Output summary
820+	reportPath := filepath.Join(cfg.ArtifactDir, repoName, jobID, "index.html")
821+	fmt.Fprintln(os.Stdout)                                            //nolint:errcheck
822+	fmt.Fprint(os.Stdout, ctrl.ui.RenderSummary(reportPath, exitCode)) //nolint:errcheck
823+
824+	// Debug bridge if failed
825+	topic := fmt.Sprintf("%s.%s.%s", domain, repoName, jobID)
826+	if exitCode != 0 && cfg.DebugOnFail {
827+		cwd, _ := os.Getwd()
828+		debugCfg := DebugBridgeConfig{
829+			Topic:        topic,
830+			KeyLocation:  cfg.KeyLocation,
831+			CertLocation: cfg.CertificateLocation,
832+			WorkDir:      cwd,
833+			Timeout:      cfg.DebugTimeout,
834+			Logger:       log,
835+			Stdout:       os.Stdout,
836+		}
837+		if err := startDebugBridge(cfg.Ctx, debugCfg); err != nil && log != nil {
838+			log.Error("debug bridge failed", "err", err)
839+		}
840+	}
841+
842+	if cfg.SummaryFile != "" {
843+		knownStates := make(map[string]*sessionState)
844+		for _, s := range finalSessions {
845+			st := &sessionState{
846+				status:   "failed",
847+				exitCode: s.ExitCode,
848+			}
849+			if s.ExitCode == "0" {
850+				st.status = "success"
851+			}
852+			knownStates[s.Short] = st
853+		}
854+		if err := writeSummaryMarkdown(cfg.SummaryFile, repoName, jobID, finalSessions, knownStates, cfg.DebugOnFail && exitCode != 0, topic); err != nil && log != nil {
855+			log.Error("write summary markdown", "err", err, "file", cfg.SummaryFile)
856+		}
857+	}
858+
859+	if exitCode != 0 {
860+		return &JobFailedError{ExitCode: exitCode}
861+	}
862+
863+	return nil
864+}
+334, -0
  1@@ -0,0 +1,334 @@
  2+package main
  3+
  4+import (
  5+	"bytes"
  6+	"context"
  7+	"strings"
  8+	"testing"
  9+	"time"
 10+)
 11+
 12+func TestUI_ForecastStateInitialization(t *testing.T) {
 13+	stats := &RepoStats{
 14+		Repo:              "my-repo",
 15+		AvgWallDurationMs: 14200,
 16+		Tasks: map[string]*TaskStats{
 17+			"fmt": {
 18+				AvgDurationMs: 850,
 19+				LastStatus:    "success",
 20+				LastExitCode:  0,
 21+			},
 22+			"lint": {
 23+				AvgDurationMs: 4200,
 24+				LastStatus:    "failed",
 25+				LastExitCode:  1,
 26+				FailureStreak: 1,
 27+			},
 28+			"test": {
 29+				AvgDurationMs: 13500,
 30+				LastStatus:    "success",
 31+				LastExitCode:  0,
 32+			},
 33+		},
 34+	}
 35+
 36+	ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", stats)
 37+
 38+	if len(ui.Tasks) != 3 {
 39+		t.Fatalf("expected 3 tasks, got %d", len(ui.Tasks))
 40+	}
 41+	if ui.Tasks["fmt"].Status != "expected" {
 42+		t.Errorf("expected fmt status to be expected, got %s", ui.Tasks["fmt"].Status)
 43+	}
 44+	if ui.Tasks["lint"].PrevStatus != "failed" {
 45+		t.Errorf("expected lint prev status failed, got %s", ui.Tasks["lint"].PrevStatus)
 46+	}
 47+	if ui.Tasks["lint"].PredictedDur != 4200*time.Millisecond {
 48+		t.Errorf("expected predicted duration 4.2s, got %v", ui.Tasks["lint"].PredictedDur)
 49+	}
 50+}
 51+
 52+func TestUI_RenderProgressBar(t *testing.T) {
 53+	tests := []struct {
 54+		elapsed   time.Duration
 55+		predicted time.Duration
 56+		width     int
 57+		wantBar   string
 58+		wantPct   int
 59+	}{
 60+		{0, 10 * time.Second, 10, "[ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘]", 0},
 61+		{5 * time.Second, 10 * time.Second, 10, "[ā–ˆā–ˆā–ˆā–ˆā–ˆā–‘ā–‘ā–‘ā–‘ā–‘]", 50},
 62+		{10 * time.Second, 10 * time.Second, 10, "[ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ]", 100},
 63+		{15 * time.Second, 10 * time.Second, 10, "[ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ]", 100}, // clamped to 100%
 64+		{1 * time.Second, 0, 10, "[ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ]", 100},                 // zero predicted fallback
 65+	}
 66+
 67+	for _, tt := range tests {
 68+		bar, pct := renderProgressBar(tt.elapsed, tt.predicted, tt.width)
 69+		if bar != tt.wantBar || pct != tt.wantPct {
 70+			t.Errorf("renderProgressBar(%v, %v, %d) = (%s, %d), want (%s, %d)",
 71+				tt.elapsed, tt.predicted, tt.width, bar, pct, tt.wantBar, tt.wantPct)
 72+		}
 73+	}
 74+}
 75+
 76+func TestUI_EarlyFailurePeekBox(t *testing.T) {
 77+	lines := []string{
 78+		"cmd/server/main.go:58:2: errcheck: unchecked error",
 79+		"api/v1/routes.go:19:1: revive: exported function missing doc",
 80+	}
 81+
 82+	box := renderErrorPreviewBox(lines, 60)
 83+	if !strings.Contains(box, "ā”Œā”€ [error preview]") {
 84+		t.Errorf("expected box header, got:\n%s", box)
 85+	}
 86+	if !strings.Contains(box, "│ cmd/server/main.go:58:2: errcheck: unchecked error") {
 87+		t.Errorf("expected line 1 in box, got:\n%s", box)
 88+	}
 89+	if !strings.Contains(box, "ā””") {
 90+		t.Errorf("expected box footer, got:\n%s", box)
 91+	}
 92+}
 93+
 94+func TestUI_LiveActivityPeekExtraction(t *testing.T) {
 95+	rawHistory := "step starting\n\n   \x1b[32mrunning golangci-lint on 28 packages...\x1b[0m\n\n"
 96+	peek := extractLastOutputLine(rawHistory)
 97+	if peek != "running golangci-lint on 28 packages..." {
 98+		t.Errorf("expected clean peek line, got '%s'", peek)
 99+	}
100+
101+	emptyHistory := "   \n\n\x1b[0m\n"
102+	peekEmpty := extractLastOutputLine(emptyHistory)
103+	if peekEmpty != "" {
104+		t.Errorf("expected empty peek, got '%s'", peekEmpty)
105+	}
106+}
107+
108+func TestUI_RenderStateTransitions(t *testing.T) {
109+	stats := &RepoStats{
110+		Repo:              "my-repo",
111+		AvgWallDurationMs: 14000,
112+		Tasks: map[string]*TaskStats{
113+			"fmt":  {AvgDurationMs: 850, LastStatus: "success", LastExitCode: 0},
114+			"lint": {AvgDurationMs: 4200, LastStatus: "failed", LastExitCode: 1, FailureStreak: 1},
115+			"test": {AvgDurationMs: 13500, LastStatus: "success", LastExitCode: 0},
116+		},
117+	}
118+
119+	ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", stats)
120+
121+	// State 1: Forecast
122+	render1 := ui.Render(0)
123+	if !strings.Contains(render1, "ā—Œ lint") || !strings.Contains(render1, "failed last run") {
124+		t.Errorf("expected State 1 forecast output, got:\n%s", render1)
125+	}
126+
127+	// State 2: Running fmt and lint
128+	ui.Tasks["fmt"].Status = "running"
129+	ui.Tasks["fmt"].Duration = 400 * time.Millisecond
130+	ui.Tasks["lint"].Status = "running"
131+	ui.Tasks["lint"].Duration = 5400 * time.Millisecond
132+	ui.Tasks["lint"].LastOutput = "running golangci-lint on 28 packages..."
133+
134+	render2 := ui.Render(0)
135+	if !strings.Contains(render2, "fmt") || !strings.Contains(render2, "lint") {
136+		t.Errorf("expected State 2 running tasks, got:\n%s", render2)
137+	}
138+	if !strings.Contains(render2, "└─ running golangci-lint on 28 packages...") {
139+		t.Errorf("expected live activity peek under lint, got:\n%s", render2)
140+	}
141+
142+	// State 3: Failure with error preview
143+	ui.Tasks["fmt"].Status = "success"
144+	ui.Tasks["fmt"].Duration = 800 * time.Millisecond
145+	ui.Tasks["lint"].Status = "failed"
146+	ui.Tasks["lint"].ExitCode = "1"
147+	ui.Tasks["lint"].Duration = 4200 * time.Millisecond
148+	ui.Tasks["lint"].Transition = "still_failing"
149+	ui.Tasks["lint"].ErrorPreview = []string{
150+		"cmd/server/main.go:58:2: errcheck: unchecked error",
151+		"api/v1/routes.go:19:1: revive: exported function missing doc",
152+	}
153+
154+	render3 := ui.Render(0)
155+	if !strings.Contains(render3, "lint") || !strings.Contains(render3, "still failing") {
156+		t.Errorf("expected State 3 failure indicator, got:\n%s", render3)
157+	}
158+	if !strings.Contains(render3, "cmd/server/main.go:58:2") {
159+		t.Errorf("expected inline error preview box, got:\n%s", render3)
160+	}
161+
162+	// State 5: Completion summary (success vs failure)
163+	summary := ui.RenderSummary("/tmp/pici-artifacts/my-repo/job123/index.html", 1)
164+	if !strings.Contains(summary, "1 task failed") {
165+		t.Errorf("expected failure summary, got:\n%s", summary)
166+	}
167+	if !strings.Contains(summary, "Report: /tmp/pici-artifacts/my-repo/job123/index.html") {
168+		t.Errorf("expected report path in summary, got:\n%s", summary)
169+	}
170+}
171+
172+func TestUI_RenderSummary_SuccessWithFixed(t *testing.T) {
173+	stats := &RepoStats{
174+		Repo: "my-repo",
175+		Tasks: map[string]*TaskStats{
176+			"fmt":  {AvgDurationMs: 900, LastStatus: "success", LastExitCode: 0},
177+			"lint": {AvgDurationMs: 4200, LastStatus: "failed", LastExitCode: 1, FailureStreak: 1},
178+			"test": {AvgDurationMs: 13500, LastStatus: "success", LastExitCode: 0},
179+		},
180+	}
181+
182+	ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", stats)
183+	ui.Tasks["fmt"].Status = "success"
184+	ui.Tasks["fmt"].Duration = 800 * time.Millisecond
185+	ui.Tasks["lint"].Status = "success"
186+	ui.Tasks["lint"].Duration = 4100 * time.Millisecond
187+	ui.Tasks["lint"].Transition = "fixed"
188+	ui.Tasks["test"].Status = "success"
189+	ui.Tasks["test"].Duration = 14100 * time.Millisecond
190+
191+	summary := ui.RenderSummary("/tmp/report.html", 0)
192+	if !strings.Contains(summary, "All 3 tasks succeeded") {
193+		t.Errorf("expected success summary, got:\n%s", summary)
194+	}
195+	if !strings.Contains(summary, "šŸŽ‰ fixed!") {
196+		t.Errorf("expected fixed badge in summary, got:\n%s", summary)
197+	}
198+	if !strings.Contains(summary, "Report: /tmp/report.html") {
199+		t.Errorf("expected report path, got:\n%s", summary)
200+	}
201+}
202+
203+func TestUI_NonTTYStreaming(t *testing.T) {
204+	var buf bytes.Buffer
205+	logger := newLogger("ci", "error")
206+
207+	stats := &RepoStats{Repo: "test"}
208+	ui := newPipelineUIState("test", "main", "abc", "job1", stats)
209+
210+	streamer := &plainStreamReporter{
211+		out: &buf,
212+		ui:  ui,
213+		log: logger,
214+	}
215+
216+	streamer.OnTaskStart("fmt")
217+	streamer.OnTaskOutput("fmt", "formatting...")
218+	streamer.OnTaskComplete("fmt", 0, 800*time.Millisecond, nil)
219+
220+	out := buf.String()
221+	if !strings.Contains(out, "starting fmt") {
222+		t.Errorf("expected non-TTY start event, got: %s", out)
223+	}
224+	if !strings.Contains(out, "fmt succeeded (0.8s)") {
225+		t.Errorf("expected non-TTY complete event, got: %s", out)
226+	}
227+}
228+
229+func TestInteractiveController_Cancellation(t *testing.T) {
230+	ctx, cancel := context.WithCancel(context.Background())
231+	var out bytes.Buffer
232+
233+	ctrl := newInteractiveController(ctx, &out, "testrepo", "main", "head", "job1", nil, false)
234+	go func() {
235+		time.Sleep(50 * time.Millisecond)
236+		cancel()
237+	}()
238+
239+	err := ctrl.WaitUntilDone(func() ([]SessionInfo, bool) {
240+		return []SessionInfo{
241+			{Name: "local.testrepo.job1.step.test", Short: "test"},
242+		}, false
243+	})
244+
245+	if err == nil {
246+		t.Fatal("expected error on cancellation, got nil")
247+	}
248+	if !strings.Contains(out.String(), "cancelling job and terminating active sessions") && !ctrl.ui.Cancelled {
249+		t.Errorf("expected cancellation message, got: %s", out.String())
250+	}
251+}
252+
253+func TestInteractiveController_FullRunSimulation(t *testing.T) {
254+	var out bytes.Buffer
255+	ctx := context.Background()
256+
257+	stats := &RepoStats{
258+		Repo: "my-repo",
259+		Tasks: map[string]*TaskStats{
260+			"test": {AvgDurationMs: 1000, LastStatus: "success"},
261+		},
262+	}
263+
264+	ctrl := newInteractiveController(ctx, &out, "my-repo", "main", "abcd123", "job99", stats, true)
265+
266+	step := 0
267+	err := ctrl.WaitUntilDone(func() ([]SessionInfo, bool) {
268+		step++
269+		if step == 1 {
270+			// Step running
271+			return []SessionInfo{
272+				{Name: "local.my-repo.job99.step.test", Short: "test", Created: "1000", Ended: ""},
273+			}, false
274+		}
275+		// Step completed
276+		return []SessionInfo{
277+			{Name: "local.my-repo.job99.step.test", Short: "test", Created: "1000", Ended: "1002", ExitCode: "0"},
278+			{Name: "local.my-repo.job99.runner", Short: "runner", Created: "1000", Ended: "1002", ExitCode: "0"},
279+		}, true
280+	})
281+
282+	if err != nil {
283+		t.Fatalf("expected nil error, got %v", err)
284+	}
285+	if !ctrl.ui.IsComplete {
286+		t.Errorf("expected UI to be marked complete")
287+	}
288+	if ctrl.ui.Tasks["test"].Status != "success" {
289+		t.Errorf("expected test status success, got %s", ctrl.ui.Tasks["test"].Status)
290+	}
291+}
292+
293+func TestController_RedrawNoDrift(t *testing.T) {
294+	var buf bytes.Buffer
295+	ctrl := &interactiveController{
296+		out:   &buf,
297+		isTTY: true,
298+	}
299+
300+	// Frame 1: 3 lines
301+	ctrl.redraw("line1\nline2\nline3\n")
302+	if ctrl.rendered != 2 {
303+		t.Errorf("expected rendered=2, got %d", ctrl.rendered)
304+	}
305+
306+	// Frame 2: same 3 lines (should move up 2 lines, not drift)
307+	buf.Reset()
308+	ctrl.redraw("line1\nline2\nline3\n")
309+	out := buf.String()
310+	if !strings.HasPrefix(out, "\r\033[2A") {
311+		t.Errorf("expected move up 2 lines '\\r\\033[2A', got %q", out)
312+	}
313+
314+	// Frame 3: expanded to 5 lines
315+	buf.Reset()
316+	ctrl.redraw("line1\nline2\nline3\nline4\nline5\n")
317+	out = buf.String()
318+	if !strings.HasPrefix(out, "\r\033[2A") {
319+		t.Errorf("expected move up 2 lines before expanding, got %q", out)
320+	}
321+	if ctrl.rendered != 4 {
322+		t.Errorf("expected rendered=4, got %d", ctrl.rendered)
323+	}
324+
325+	// Frame 4: shrink back to 3 lines
326+	buf.Reset()
327+	ctrl.redraw("line1\nline2\nline3\n")
328+	out = buf.String()
329+	if !strings.HasPrefix(out, "\r\033[4A") {
330+		t.Errorf("expected move up 4 lines before shrinking, got %q", out)
331+	}
332+	if ctrl.rendered != 2 {
333+		t.Errorf("expected rendered=2, got %d", ctrl.rendered)
334+	}
335+}
+146, -0
  1@@ -0,0 +1,146 @@
  2+package main
  3+
  4+import (
  5+	"encoding/json"
  6+	"math"
  7+	"os"
  8+	"path/filepath"
  9+	"time"
 10+)
 11+
 12+// RepoStats tracks historical run durations and outcomes across runs.
 13+type RepoStats struct {
 14+	Repo              string                `json:"repo"`
 15+	LastRunAt         string                `json:"last_run_at"`
 16+	LastOverallStatus string                `json:"last_overall_status"`
 17+	RunsCount         int                   `json:"runs_count"`
 18+	AvgWallDurationMs int64                 `json:"avg_wall_duration_ms"`
 19+	Tasks             map[string]*TaskStats `json:"tasks"`
 20+}
 21+
 22+// TaskStats tracks duration averages, exit codes, and failure streaks per task.
 23+type TaskStats struct {
 24+	AvgDurationMs  int64  `json:"avg_duration_ms"`
 25+	LastDurationMs int64  `json:"last_duration_ms"`
 26+	LastStatus     string `json:"last_status"`
 27+	LastExitCode   int    `json:"last_exit_code"`
 28+	FailureStreak  int    `json:"failure_streak,omitempty"`
 29+	SeenCount      int    `json:"seen_count"`
 30+}
 31+
 32+// getStatsPath returns the path to stats.json for the specified repository.
 33+func getStatsPath(repo string) string {
 34+	cacheDir := os.Getenv("XDG_CACHE_HOME")
 35+	if cacheDir == "" {
 36+		home, err := os.UserHomeDir()
 37+		if err != nil {
 38+			cacheDir = "/tmp"
 39+		} else {
 40+			cacheDir = filepath.Join(home, ".cache")
 41+		}
 42+	}
 43+	return filepath.Join(cacheDir, "pici", repo, "stats.json")
 44+}
 45+
 46+// loadRepoStats loads historical stats for a repo, or returns an initialized empty struct.
 47+func loadRepoStats(repo string) (*RepoStats, error) {
 48+	path := getStatsPath(repo)
 49+	data, err := os.ReadFile(path)
 50+	if err != nil {
 51+		if os.IsNotExist(err) {
 52+			return &RepoStats{
 53+				Repo:  repo,
 54+				Tasks: make(map[string]*TaskStats),
 55+			}, nil
 56+		}
 57+		return nil, err
 58+	}
 59+
 60+	var stats RepoStats
 61+	if err := json.Unmarshal(data, &stats); err != nil {
 62+		return &RepoStats{
 63+			Repo:  repo,
 64+			Tasks: make(map[string]*TaskStats),
 65+		}, nil
 66+	}
 67+	if stats.Tasks == nil {
 68+		stats.Tasks = make(map[string]*TaskStats)
 69+	}
 70+	return &stats, nil
 71+}
 72+
 73+// saveRepoStats writes repo stats to stats.json.
 74+func saveRepoStats(stats *RepoStats) error {
 75+	if stats == nil || stats.Repo == "" {
 76+		return nil
 77+	}
 78+	path := getStatsPath(stats.Repo)
 79+	if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
 80+		return err
 81+	}
 82+	data, err := json.MarshalIndent(stats, "", "  ")
 83+	if err != nil {
 84+		return err
 85+	}
 86+	return os.WriteFile(path, data, 0644)
 87+}
 88+
 89+// updateTaskStats updates single task statistics using EMA duration (alpha = 0.3).
 90+func updateTaskStats(stats *RepoStats, name string, actualDur time.Duration, exitCode int) {
 91+	if stats.Tasks == nil {
 92+		stats.Tasks = make(map[string]*TaskStats)
 93+	}
 94+	task, ok := stats.Tasks[name]
 95+	if !ok {
 96+		task = &TaskStats{}
 97+		stats.Tasks[name] = task
 98+	}
 99+
100+	actualMs := actualDur.Milliseconds()
101+	if task.SeenCount == 0 || task.AvgDurationMs == 0 {
102+		task.AvgDurationMs = actualMs
103+	} else {
104+		// EMA: alpha = 0.3
105+		task.AvgDurationMs = int64(math.Round(0.3*float64(actualMs) + 0.7*float64(task.AvgDurationMs)))
106+	}
107+
108+	task.LastDurationMs = actualMs
109+	task.LastExitCode = exitCode
110+	task.SeenCount++
111+
112+	if exitCode == 0 {
113+		task.LastStatus = "success"
114+		task.FailureStreak = 0
115+	} else {
116+		task.LastStatus = "failed"
117+		task.FailureStreak++
118+	}
119+}
120+
121+// updateRepoStats updates overall repository stats after a run.
122+func updateRepoStats(stats *RepoStats, wallDur time.Duration, overallStatus string) {
123+	stats.RunsCount++
124+	stats.LastRunAt = time.Now().UTC().Format(time.RFC3339)
125+	stats.LastOverallStatus = overallStatus
126+
127+	wallMs := wallDur.Milliseconds()
128+	if stats.RunsCount == 1 || stats.AvgWallDurationMs == 0 {
129+		stats.AvgWallDurationMs = wallMs
130+	} else {
131+		stats.AvgWallDurationMs = int64(math.Round(0.3*float64(wallMs) + 0.7*float64(stats.AvgWallDurationMs)))
132+	}
133+}
134+
135+// computeOutcomeTransition returns "fixed", "still_failing", "new_failure", or "".
136+func computeOutcomeTransition(prev *TaskStats, currentExitCode int) string {
137+	if prev != nil && (prev.LastStatus == "failed" || prev.LastExitCode != 0) {
138+		if currentExitCode == 0 {
139+			return "fixed"
140+		}
141+		return "still_failing"
142+	}
143+	if currentExitCode != 0 {
144+		return "new_failure"
145+	}
146+	return ""
147+}
+157, -0
  1@@ -0,0 +1,157 @@
  2+package main
  3+
  4+import (
  5+	"path/filepath"
  6+	"testing"
  7+	"time"
  8+)
  9+
 10+func TestStats_EMACalculation(t *testing.T) {
 11+	// Formula: EMA_new = 0.3 * actual + 0.7 * EMA_prev
 12+	// If EMA_prev == 0, EMA_new = actual
 13+	stats := &RepoStats{
 14+		Repo: "testrepo",
 15+		Tasks: map[string]*TaskStats{
 16+			"fmt": {
 17+				AvgDurationMs:  1000,
 18+				LastDurationMs: 1000,
 19+				LastStatus:     "success",
 20+				LastExitCode:   0,
 21+				SeenCount:      1,
 22+			},
 23+		},
 24+	}
 25+
 26+	// Update with a new run of 2000ms
 27+	updateTaskStats(stats, "fmt", 2000*time.Millisecond, 0)
 28+	task := stats.Tasks["fmt"]
 29+	if task.SeenCount != 2 {
 30+		t.Errorf("expected seen count 2, got %d", task.SeenCount)
 31+	}
 32+	// 0.3 * 2000 + 0.7 * 1000 = 600 + 700 = 1300
 33+	if task.AvgDurationMs != 1300 {
 34+		t.Errorf("expected AvgDurationMs 1300, got %d", task.AvgDurationMs)
 35+	}
 36+	if task.LastDurationMs != 2000 {
 37+		t.Errorf("expected LastDurationMs 2000, got %d", task.LastDurationMs)
 38+	}
 39+	if task.LastStatus != "success" {
 40+		t.Errorf("expected LastStatus success, got %s", task.LastStatus)
 41+	}
 42+	if task.FailureStreak != 0 {
 43+		t.Errorf("expected FailureStreak 0, got %d", task.FailureStreak)
 44+	}
 45+
 46+	// Update with new task (not seen before)
 47+	updateTaskStats(stats, "lint", 4000*time.Millisecond, 1)
 48+	lintTask := stats.Tasks["lint"]
 49+	if lintTask.AvgDurationMs != 4000 {
 50+		t.Errorf("expected initial AvgDurationMs 4000, got %d", lintTask.AvgDurationMs)
 51+	}
 52+	if lintTask.LastStatus != "failed" {
 53+		t.Errorf("expected LastStatus failed, got %s", lintTask.LastStatus)
 54+	}
 55+	if lintTask.FailureStreak != 1 {
 56+		t.Errorf("expected FailureStreak 1, got %d", lintTask.FailureStreak)
 57+	}
 58+
 59+	// Fail again -> streak increments
 60+	updateTaskStats(stats, "lint", 4100*time.Millisecond, 1)
 61+	lintTask = stats.Tasks["lint"]
 62+	if lintTask.FailureStreak != 2 {
 63+		t.Errorf("expected FailureStreak 2, got %d", lintTask.FailureStreak)
 64+	}
 65+
 66+	// Succeed -> streak resets to 0
 67+	updateTaskStats(stats, "lint", 3900*time.Millisecond, 0)
 68+	lintTask = stats.Tasks["lint"]
 69+	if lintTask.FailureStreak != 0 {
 70+		t.Errorf("expected FailureStreak reset to 0, got %d", lintTask.FailureStreak)
 71+	}
 72+	if lintTask.LastStatus != "success" {
 73+		t.Errorf("expected LastStatus success, got %s", lintTask.LastStatus)
 74+	}
 75+}
 76+
 77+func TestStats_StorageAndPath(t *testing.T) {
 78+	tempCache := t.TempDir()
 79+	t.Setenv("XDG_CACHE_HOME", tempCache)
 80+
 81+	repo := "my-project"
 82+	expectedPath := filepath.Join(tempCache, "pici", repo, "stats.json")
 83+	path := getStatsPath(repo)
 84+	if path != expectedPath {
 85+		t.Errorf("expected stats path %s, got %s", expectedPath, path)
 86+	}
 87+
 88+	// Test Save & Load
 89+	stats := &RepoStats{
 90+		Repo:              repo,
 91+		LastRunAt:         time.Now().UTC().Format(time.RFC3339),
 92+		LastOverallStatus: "success",
 93+		RunsCount:         5,
 94+		AvgWallDurationMs: 12000,
 95+		Tasks: map[string]*TaskStats{
 96+			"build": {
 97+				AvgDurationMs:  6000,
 98+				LastDurationMs: 6100,
 99+				LastStatus:     "success",
100+				LastExitCode:   0,
101+				SeenCount:      5,
102+			},
103+		},
104+	}
105+
106+	if err := saveRepoStats(stats); err != nil {
107+		t.Fatalf("failed to save stats: %v", err)
108+	}
109+
110+	loaded, err := loadRepoStats(repo)
111+	if err != nil {
112+		t.Fatalf("failed to load stats: %v", err)
113+	}
114+	if loaded.Repo != repo || loaded.RunsCount != 5 || loaded.AvgWallDurationMs != 12000 {
115+		t.Errorf("loaded stats mismatch: %+v", loaded)
116+	}
117+	if task, ok := loaded.Tasks["build"]; !ok || task.AvgDurationMs != 6000 {
118+		t.Errorf("loaded task stats mismatch: %+v", task)
119+	}
120+}
121+
122+func TestStats_OutcomeTransitions(t *testing.T) {
123+	// 1. Fixed transition (failed in prev run, succeeded in current run)
124+	t1 := &TaskStats{
125+		LastStatus:   "failed",
126+		LastExitCode: 1,
127+	}
128+	if trans := computeOutcomeTransition(t1, 0); trans != "fixed" {
129+		t.Errorf("expected 'fixed', got '%s'", trans)
130+	}
131+
132+	// 2. Still failing transition (failed in prev run, failed in current run)
133+	if trans := computeOutcomeTransition(t1, 1); trans != "still_failing" {
134+		t.Errorf("expected 'still_failing', got '%s'", trans)
135+	}
136+
137+	// 3. New failure transition (succeeded in prev run, failed in current run)
138+	t2 := &TaskStats{
139+		LastStatus:   "success",
140+		LastExitCode: 0,
141+	}
142+	if trans := computeOutcomeTransition(t2, 2); trans != "new_failure" {
143+		t.Errorf("expected 'new_failure', got '%s'", trans)
144+	}
145+
146+	// 4. Clean success (succeeded in prev run, succeeded in current run)
147+	if trans := computeOutcomeTransition(t2, 0); trans != "" {
148+		t.Errorf("expected '', got '%s'", trans)
149+	}
150+
151+	// 5. First time seen (nil prev)
152+	if trans := computeOutcomeTransition(nil, 0); trans != "" {
153+		t.Errorf("expected '', got '%s'", trans)
154+	}
155+	if trans := computeOutcomeTransition(nil, 1); trans != "new_failure" {
156+		t.Errorf("expected 'new_failure', got '%s'", trans)
157+	}
158+}