• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / ralphex / 25115074240

29 Apr 2026 02:30PM UTC coverage: 82.853% (+0.4%) from 82.426%
25115074240

Pull #309

github

bronislav
fix: address code review findings

- document {{TASK_HEADER_PATTERNS}} in CLAUDE.md and README.md variable lists
- update CLAUDE.md 'Plan format' key pattern to reflect configurable headers
  and the removal of the package-level taskHeaderPattern regex
- rename TestDefaultPatternsMatchLegacyRegex and rewrite its comment to be
  honest: the template-driven compiler is stricter than the legacy regex
  about whitespace, so the test only covers canonical inputs
- exclude gosec G704 (SSRF taint) in *_test.go files; false positive on
  httptest server URLs in pkg/web/watcher_test.go
Pull Request #309: feat: configurable task header patterns

367 of 399 new or added lines in 13 files covered. (91.98%)

1 existing line in 1 file now uncovered.

7011 of 8462 relevant lines covered (82.85%)

227.55 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.28
/pkg/progress/progress.go
1
// Package progress provides timestamped logging to file and stdout with color support.
2
package progress
3

4
import (
5
        "fmt"
6
        "io"
7
        "os"
8
        "path/filepath"
9
        "regexp"
10
        "strconv"
11
        "strings"
12
        "time"
13
        "unicode"
14

15
        "github.com/fatih/color"
16
        "golang.org/x/term"
17

18
        "github.com/umputun/ralphex/pkg/config"
19
        "github.com/umputun/ralphex/pkg/status"
20
)
21

22
// multiDashRegex collapses multiple consecutive dashes into one.
23
var multiDashRegex = regexp.MustCompile(`-{2,}`)
24

25
// Colors holds all color configuration for output formatting.
26
// use NewColors to create from config.ColorConfig.
27
type Colors struct {
28
        task       *color.Color
29
        review     *color.Color
30
        codex      *color.Color
31
        claudeEval *color.Color
32
        warn       *color.Color
33
        err        *color.Color
34
        signal     *color.Color
35
        timestamp  *color.Color
36
        info       *color.Color
37
        phases     map[status.Phase]*color.Color
38
}
39

40
// NewColors creates Colors from config.ColorConfig.
41
// all colors must be provided - use config with embedded defaults fallback.
42
// panics if any color value is invalid (configuration error).
43
func NewColors(cfg config.ColorConfig) *Colors {
37✔
44
        c := &Colors{phases: make(map[status.Phase]*color.Color)}
37✔
45
        c.task = parseColorOrPanic(cfg.Task, "task")
37✔
46
        c.review = parseColorOrPanic(cfg.Review, "review")
37✔
47
        c.codex = parseColorOrPanic(cfg.Codex, "codex")
37✔
48
        c.claudeEval = parseColorOrPanic(cfg.ClaudeEval, "claude_eval")
37✔
49
        c.warn = parseColorOrPanic(cfg.Warn, "warn")
37✔
50
        c.err = parseColorOrPanic(cfg.Error, "error")
37✔
51
        c.signal = parseColorOrPanic(cfg.Signal, "signal")
37✔
52
        c.timestamp = parseColorOrPanic(cfg.Timestamp, "timestamp")
37✔
53
        c.info = parseColorOrPanic(cfg.Info, "info")
37✔
54

37✔
55
        c.phases[status.PhaseTask] = c.task
37✔
56
        c.phases[status.PhaseReview] = c.review
37✔
57
        c.phases[status.PhaseCodex] = c.codex
37✔
58
        c.phases[status.PhaseClaudeEval] = c.claudeEval
37✔
59
        c.phases[status.PhasePlan] = c.task     // plan phase uses task color (green)
37✔
60
        c.phases[status.PhaseFinalize] = c.task // finalize phase uses task color (green)
37✔
61

37✔
62
        return c
37✔
63
}
37✔
64

65
// parseColorOrPanic parses RGB string and returns color, panics on invalid input.
66
func parseColorOrPanic(s, name string) *color.Color {
335✔
67
        parseRGB := func(s string) []int {
670✔
68
                if s == "" {
337✔
69
                        return nil
2✔
70
                }
2✔
71
                parts := strings.Split(s, ",")
333✔
72
                if len(parts) != 3 {
338✔
73
                        return nil
5✔
74
                }
5✔
75

76
                // parse each component
77
                r, err := strconv.Atoi(strings.TrimSpace(parts[0]))
328✔
78
                if err != nil || r < 0 || r > 255 {
331✔
79
                        return nil
3✔
80
                }
3✔
81
                g, err := strconv.Atoi(strings.TrimSpace(parts[1]))
325✔
82
                if err != nil || g < 0 || g > 255 {
328✔
83
                        return nil
3✔
84
                }
3✔
85
                b, err := strconv.Atoi(strings.TrimSpace(parts[2]))
322✔
86
                if err != nil || b < 0 || b > 255 {
325✔
87
                        return nil
3✔
88
                }
3✔
89
                return []int{r, g, b}
319✔
90
        }
91

92
        rgb := parseRGB(s)
335✔
93
        if rgb == nil {
351✔
94
                panic(fmt.Sprintf("invalid color_%s value: %q", name, s))
16✔
95
        }
96
        return color.RGB(rgb[0], rgb[1], rgb[2])
319✔
97
}
98

99
// Info returns the info color for informational messages.
100
func (c *Colors) Info() *color.Color { return c.info }
8✔
101

102
// ForPhase returns the color for the given execution phase.
103
func (c *Colors) ForPhase(p status.Phase) *color.Color {
26✔
104
        if clr, ok := c.phases[p]; ok {
38✔
105
                return clr
12✔
106
        }
12✔
107
        return c.task // fallback to task color for unknown/empty phase
14✔
108
}
109

110
// Timestamp returns the timestamp color.
111
func (c *Colors) Timestamp() *color.Color { return c.timestamp }
29✔
112

113
// Warn returns the warning color.
114
func (c *Colors) Warn() *color.Color { return c.warn }
4✔
115

116
// Error returns the error color.
117
func (c *Colors) Error() *color.Color { return c.err }
3✔
118

119
// Signal returns the signal color.
120
func (c *Colors) Signal() *color.Color { return c.signal }
2✔
121

122
// Logger writes timestamped output to both file and stdout.
123
type Logger struct {
124
        file      *os.File
125
        stdout    io.Writer
126
        startTime time.Time
127
        holder    *status.PhaseHolder
128
        colors    *Colors
129
        runErr    error // set via SetFailed to record non-success outcome for the footer
130
}
131

132
// Config holds logger configuration.
133
type Config struct {
134
        PlanFile           string   // plan filename (used to derive progress filename)
135
        PlanDescription    string   // plan description for plan mode (used for filename)
136
        Mode               string   // execution mode: full, review, codex-only, plan
137
        Branch             string   // current git branch
138
        NoColor            bool     // disable color output (sets color.NoColor globally)
139
        TaskHeaderPatterns []string // task-header templates for this run; written into the header so multi-repo dashboards can parse watched sessions' plans with the correct templates
140
}
141

142
// NewLogger creates a logger writing to both a progress file and stdout.
143
// if the progress file already ends with a "Completed:" footer (successful run),
144
// it is truncated and a fresh header is written. if the file ended with a "Failed:"
145
// footer or has no footer (interrupted/failed run), the existing log is preserved
146
// and a restart separator is written so history carries across retries (issue #288).
147
// colors must be provided (created via NewColors from config).
148
// holder is the shared PhaseHolder for reading the current execution phase.
149
func NewLogger(cfg Config, colors *Colors, holder *status.PhaseHolder) (*Logger, error) {
47✔
150
        // set global color setting
47✔
151
        if cfg.NoColor {
60✔
152
                color.NoColor = true
13✔
153
        }
13✔
154

155
        progressPath := progressFilename(cfg.PlanFile, cfg.PlanDescription, cfg.Mode)
47✔
156

47✔
157
        // resolve to absolute path so Logger.Path() works from any CWD (e.g. after worktree chdir)
47✔
158
        if absPath, absErr := filepath.Abs(progressPath); absErr == nil {
94✔
159
                progressPath = absPath
47✔
160
        }
47✔
161

162
        // ensure progress files are tracked by creating parent dir
163
        if dir := filepath.Dir(progressPath); dir != "." {
94✔
164
                if err := os.MkdirAll(dir, 0o750); err != nil {
47✔
165
                        return nil, fmt.Errorf("create progress dir: %w", err)
×
166
                }
×
167
        }
168

169
        f, err := os.OpenFile(progressPath, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0o600) //nolint:gosec // path derived from plan filename
47✔
170
        if err != nil {
47✔
171
                return nil, fmt.Errorf("open progress file: %w", err)
×
172
        }
×
173

174
        // acquire exclusive lock on progress file to signal active session.
175
        // the lock is held for the duration of execution and released on Close().
176
        // lock MUST be acquired before stat to avoid TOCTOU race:
177
        // without this ordering, a concurrent process could stat size==0, block on lock,
178
        // then write a full header instead of restart separator after another process already wrote content.
179
        if lockErr := lockFile(f); lockErr != nil {
47✔
180
                f.Close()
×
181
                return nil, fmt.Errorf("acquire file lock: %w", lockErr)
×
182
        }
×
183
        registerActiveLock(f.Name())
47✔
184

47✔
185
        cleanup := func() {
47✔
186
                _ = unlockFile(f)
×
187
                unregisterActiveLock(f.Name())
×
188
                f.Close()
×
189
        }
×
190

191
        // check if file already has content (restart case) — safe after lock acquisition
192
        fi, err := f.Stat()
47✔
193
        if err != nil {
47✔
194
                cleanup()
×
195
                return nil, fmt.Errorf("stat progress file: %w", err)
×
196
        }
×
197

198
        restart := fi.Size() > 0
47✔
199

47✔
200
        // if the file has a completion footer from a previous run, truncate and start fresh.
47✔
201
        // this prevents mixing unrelated content when the same plan filename is reused.
47✔
202
        // the file lock is held here, so path and fd refer to the same inode; path-based
47✔
203
        // truncation is safe. os.Truncate (path-based) is used instead of f.Truncate
47✔
204
        // (fd-based) because on Windows a fd opened with O_APPEND does not have the
47✔
205
        // FILE_WRITE_DATA permission required for fd-based truncation ("access is denied").
47✔
206
        if restart && isProgressCompleted(f, fi.Size()) {
48✔
207
                if tErr := os.Truncate(f.Name(), 0); tErr != nil {
1✔
208
                        cleanup()
×
209
                        return nil, fmt.Errorf("truncate completed progress file: %w", tErr)
×
210
                }
×
211
                restart = false
1✔
212
        }
213

214
        l := &Logger{
47✔
215
                file:      f,
47✔
216
                stdout:    os.Stdout,
47✔
217
                startTime: time.Now(),
47✔
218
                holder:    holder,
47✔
219
                colors:    colors,
47✔
220
        }
47✔
221

47✔
222
        if restart {
50✔
223
                // write restart separator (matches sectionRegex in web parser).
3✔
224
                // re-emit the current run's TaskHeaderPatterns so a dashboard reading
3✔
225
                // this file after the restart picks up the NEW patterns rather than
3✔
226
                // the possibly-stale or absent value from the original header. the
3✔
227
                // web parser skips TaskHeaderPatterns lines, so this does not show up
3✔
228
                // as content in the event stream.
3✔
229
                l.writeFile("\n\n--- restarted at %s ---\n", time.Now().Format("2006-01-02 15:04:05"))
3✔
230
                if patterns := sanitizePatterns(cfg.TaskHeaderPatterns); len(patterns) > 0 {
4✔
231
                        l.writeFile("TaskHeaderPatterns: %s\n", strings.Join(patterns, ","))
1✔
232
                }
1✔
233
                l.writeFile("\n")
3✔
234
        } else {
44✔
235
                l.writeHeader(cfg)
44✔
236
        }
44✔
237

238
        return l, nil
47✔
239
}
240

241
// writeHeader writes the initial progress log header for a new file.
242
func (l *Logger) writeHeader(cfg Config) {
44✔
243
        planStr := cfg.PlanFile
44✔
244
        if planStr == "" {
73✔
245
                planStr = "(no plan - review only)"
29✔
246
        }
29✔
247
        l.writeFile("# Ralphex Progress Log\n")
44✔
248
        l.writeFile("Plan: %s\n", planStr)
44✔
249
        l.writeFile("Branch: %s\n", cfg.Branch)
44✔
250
        l.writeFile("Mode: %s\n", cfg.Mode)
44✔
251
        l.writeFile("Started: %s\n", time.Now().Format("2006-01-02 15:04:05"))
44✔
252
        // record per-run task_header_patterns so a dashboard watching this progress
44✔
253
        // file from another process with different config can parse the plan the
44✔
254
        // same way the executor did. commas inside a template would break the
44✔
255
        // comma-separated parse on read, so each template is rejected if it
44✔
256
        // contains a comma — project templates never legitimately need one.
44✔
257
        if patterns := sanitizePatterns(cfg.TaskHeaderPatterns); len(patterns) > 0 {
48✔
258
                l.writeFile("TaskHeaderPatterns: %s\n", strings.Join(patterns, ","))
4✔
259
        }
4✔
260
        l.writeFile("%s\n\n", separatorLine)
44✔
261
}
262

263
// sanitizePatterns returns patterns with leading/trailing whitespace trimmed and
264
// entries containing commas or newlines dropped, since those would corrupt the
265
// comma-separated single-line header encoding.
266
func sanitizePatterns(patterns []string) []string {
47✔
267
        out := make([]string, 0, len(patterns))
47✔
268
        for _, p := range patterns {
54✔
269
                p = strings.TrimSpace(p)
7✔
270
                if p == "" {
7✔
NEW
271
                        continue
×
272
                }
273
                if strings.ContainsAny(p, ",\n\r") {
8✔
274
                        continue
1✔
275
                }
276
                out = append(out, p)
6✔
277
        }
278
        return out
47✔
279
}
280

281
// Path returns the progress file path.
282
func (l *Logger) Path() string {
64✔
283
        if l.file == nil {
64✔
284
                return ""
×
285
        }
×
286
        return l.file.Name()
64✔
287
}
288

289
// timestampFormat is the format for timestamps: YY-MM-DD HH:MM:SS
290
const timestampFormat = "06-01-02 15:04:05"
291

292
// separatorLine is the 60-dash separator used in the header and completion footer.
293
// isProgressCompleted relies on this exact value to detect completed files.
294
var separatorLine = strings.Repeat("-", 60)
295

296
// writeTimestamped writes a message to both file and stdout with timestamp and optional prefix.
297
func (l *Logger) writeTimestamped(prefix string, clr *color.Color, msg string) {
19✔
298
        timestamp := time.Now().Format(timestampFormat)
19✔
299
        l.writeFile("[%s] %s%s\n", timestamp, prefix, msg)
19✔
300

19✔
301
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
19✔
302
        coloredMsg := clr.Sprintf("%s%s", prefix, msg)
19✔
303
        l.writeStdout("%s %s\n", tsStr, coloredMsg)
19✔
304
}
19✔
305

306
// Print writes a timestamped message to both file and stdout.
307
func (l *Logger) Print(format string, args ...any) {
16✔
308
        l.writeTimestamped("", l.colors.ForPhase(l.holder.Get()), fmt.Sprintf(format, args...))
16✔
309
}
16✔
310

311
// PrintRaw writes without timestamp (for streaming output).
312
func (l *Logger) PrintRaw(format string, args ...any) {
1✔
313
        msg := fmt.Sprintf(format, args...)
1✔
314
        l.writeFile("%s", msg)
1✔
315
        l.writeStdout("%s", msg)
1✔
316
}
1✔
317

318
// PrintSection writes a section header without timestamp in yellow.
319
// format: "\n--- {label} ---\n"
320
func (l *Logger) PrintSection(section status.Section) {
1✔
321
        header := fmt.Sprintf("\n--- %s ---\n", section.Label)
1✔
322
        l.writeFile("%s", header)
1✔
323
        l.writeStdout("%s", l.colors.Warn().Sprint(header))
1✔
324
}
1✔
325

326
// getTerminalWidth returns terminal width, using COLUMNS env var or syscall.
327
// Defaults to 80 if detection fails. Returns content width (total - 22 for timestamp prefix
328
// and safety margin to prevent terminal-level mid-word wrapping).
329
func (l *Logger) getTerminalWidth() int {
5✔
330
        const (
5✔
331
                minWidth       = 40
5✔
332
                prefixReserved = 22 // 20 for "[YY-MM-DD HH:MM:SS] " + 2 safety margin for list indent
5✔
333
        )
5✔
334

5✔
335
        // try COLUMNS env var first
5✔
336
        if cols := os.Getenv("COLUMNS"); cols != "" {
9✔
337
                if w, err := strconv.Atoi(cols); err == nil && w > 0 {
7✔
338
                        contentWidth := w - prefixReserved
3✔
339
                        if contentWidth < minWidth {
5✔
340
                                return minWidth
2✔
341
                        }
2✔
342
                        return contentWidth
1✔
343
                }
344
        }
345

346
        // try terminal syscall
347
        if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
2✔
348
                contentWidth := w - prefixReserved
×
349
                if contentWidth < minWidth {
×
350
                        return minWidth
×
351
                }
×
352
                return contentWidth
×
353
        }
354

355
        return 80 - prefixReserved // default 80 columns minus prefix
2✔
356
}
357

358
// wrapText wraps text to specified width, breaking on word boundaries.
359
// words longer than width are placed on their own line (terminal may wrap them).
360
// leading whitespace is preserved on the first line and subtracted from available width.
361
func (l *Logger) wrapText(text string, width int) string {
11✔
362
        if width <= 0 || len(text) <= width {
15✔
363
                return text
4✔
364
        }
4✔
365

366
        // preserve leading whitespace
367
        trimmed := strings.TrimLeft(text, " \t")
7✔
368
        indent := text[:len(text)-len(trimmed)]
7✔
369
        effectiveWidth := width - len(indent)
7✔
370
        if effectiveWidth <= 0 {
8✔
371
                return text
1✔
372
        }
1✔
373

374
        var result strings.Builder
6✔
375
        words := strings.Fields(trimmed)
6✔
376
        lineLen := 0
6✔
377
        currentWidth := effectiveWidth // first line uses reduced width for indent
6✔
378

6✔
379
        if indent != "" {
10✔
380
                result.WriteString(indent)
4✔
381
        }
4✔
382

383
        for i, word := range words {
45✔
384
                wordLen := len(word)
39✔
385

39✔
386
                if i == 0 {
45✔
387
                        result.WriteString(word)
6✔
388
                        lineLen = wordLen
6✔
389
                        continue
6✔
390
                }
391

392
                // check if word fits on current line
393
                if lineLen+1+wordLen <= currentWidth {
60✔
394
                        result.WriteString(" ")
27✔
395
                        result.WriteString(word)
27✔
396
                        lineLen += 1 + wordLen
27✔
397
                } else {
33✔
398
                        // continuation lines use full width (no indent)
6✔
399
                        result.WriteString("\n")
6✔
400
                        result.WriteString(word)
6✔
401
                        lineLen = wordLen
6✔
402
                        currentWidth = width
6✔
403
                }
6✔
404
        }
405

406
        return result.String()
6✔
407
}
408

409
// PrintAligned writes text with timestamp on each line, suppressing empty lines.
410
func (l *Logger) PrintAligned(text string) {
3✔
411
        if text == "" {
4✔
412
                return
1✔
413
        }
1✔
414

415
        // trim trailing newlines to avoid extra blank lines
416
        text = strings.TrimRight(text, "\n")
2✔
417
        if text == "" {
2✔
418
                return
×
419
        }
×
420

421
        phaseColor := l.colors.ForPhase(l.holder.Get())
2✔
422

2✔
423
        // wrap text to terminal width
2✔
424
        width := l.getTerminalWidth()
2✔
425

2✔
426
        // split into lines, apply list indent BEFORE wrapping (so indent is included in width calc),
2✔
427
        // then wrap each long line
2✔
428
        var lines []string
2✔
429
        for line := range strings.SplitSeq(text, "\n") {
6✔
430
                // apply list indent before wrapping so it's accounted for in width calculation
4✔
431
                formatted := l.formatListItem(line)
4✔
432
                if len(formatted) > width {
5✔
433
                        wrapped := l.wrapText(formatted, width)
1✔
434
                        for wrappedLine := range strings.SplitSeq(wrapped, "\n") {
3✔
435
                                lines = append(lines, wrappedLine)
2✔
436
                        }
2✔
437
                } else {
3✔
438
                        lines = append(lines, formatted)
3✔
439
                }
3✔
440
        }
441

442
        for _, line := range lines {
7✔
443
                if line == "" {
5✔
444
                        continue // skip empty lines
×
445
                }
446

447
                displayLine := line
5✔
448

5✔
449
                // timestamp each line
5✔
450
                timestamp := time.Now().Format(timestampFormat)
5✔
451
                tsPrefix := l.colors.Timestamp().Sprintf("[%s]", timestamp)
5✔
452
                l.writeFile("[%s] %s\n", timestamp, displayLine)
5✔
453

5✔
454
                // use red for signal lines
5✔
455
                lineColor := phaseColor
5✔
456

5✔
457
                // format signal lines nicely
5✔
458
                if sig := l.extractSignal(line); sig != "" {
5✔
459
                        displayLine = sig
×
460
                        lineColor = l.colors.Signal()
×
461
                }
×
462

463
                l.writeStdout("%s %s\n", tsPrefix, lineColor.Sprint(displayLine))
5✔
464
        }
465
}
466

467
// extractSignal extracts signal name from <<<RALPHEX:SIGNAL_NAME>>> format.
468
// returns empty string if no signal found.
469
func (l *Logger) extractSignal(line string) string {
14✔
470
        const prefix = "<<<RALPHEX:"
14✔
471
        const suffix = ">>>"
14✔
472

14✔
473
        start := strings.Index(line, prefix)
14✔
474
        if start == -1 {
22✔
475
                return ""
8✔
476
        }
8✔
477

478
        end := strings.Index(line[start:], suffix)
6✔
479
        if end == -1 {
7✔
480
                return ""
1✔
481
        }
1✔
482

483
        return line[start+len(prefix) : start+end]
5✔
484
}
485

486
// formatListItem adds 2-space indent for list items (numbered or bulleted).
487
// detects patterns like "1. ", "12. ", "- ", "* " at line start.
488
func (l *Logger) formatListItem(line string) string {
10✔
489
        trimmed := strings.TrimLeft(line, " \t")
10✔
490
        if trimmed == line { // no leading whitespace
19✔
491
                if l.isListItem(trimmed) {
14✔
492
                        return "  " + line
5✔
493
                }
5✔
494
        }
495
        return line
5✔
496
}
497

498
// isListItem returns true if line starts with a list marker.
499
func (l *Logger) isListItem(line string) bool {
20✔
500
        // check for "- " or "* " (bullet lists)
20✔
501
        if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") {
25✔
502
                return true
5✔
503
        }
5✔
504
        // check for numbered lists like "1. ", "12. ", "123. "
505
        for i, r := range line {
40✔
506
                if r >= '0' && r <= '9' {
36✔
507
                        continue
11✔
508
                }
509
                if r == '.' && i > 0 && i < len(line)-1 && line[i+1] == ' ' {
19✔
510
                        return true
5✔
511
                }
5✔
512
                break
9✔
513
        }
514
        return false
10✔
515
}
516

517
// Error writes an error message in red.
518
func (l *Logger) Error(format string, args ...any) {
1✔
519
        l.writeTimestamped("ERROR: ", l.colors.Error(), fmt.Sprintf(format, args...))
1✔
520
}
1✔
521

522
// Warn writes a warning message in yellow.
523
func (l *Logger) Warn(format string, args ...any) {
1✔
524
        l.writeTimestamped("WARN: ", l.colors.Warn(), fmt.Sprintf(format, args...))
1✔
525
}
1✔
526

527
// LogQuestion logs a question and its options for plan creation mode.
528
// format: QUESTION: <question>\n OPTIONS: <opt1>, <opt2>, ...
529
func (l *Logger) LogQuestion(question string, options []string) {
1✔
530
        timestamp := time.Now().Format(timestampFormat)
1✔
531

1✔
532
        l.writeFile("[%s] QUESTION: %s\n", timestamp, question)
1✔
533
        l.writeFile("[%s] OPTIONS: %s\n", timestamp, strings.Join(options, ", "))
1✔
534

1✔
535
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
1✔
536
        questionStr := l.colors.Info().Sprintf("QUESTION: %s", question)
1✔
537
        optionsStr := l.colors.Info().Sprintf("OPTIONS: %s", strings.Join(options, ", "))
1✔
538
        l.writeStdout("%s %s\n", tsStr, questionStr)
1✔
539
        l.writeStdout("%s %s\n", tsStr, optionsStr)
1✔
540
}
1✔
541

542
// LogAnswer logs the user's answer for plan creation mode.
543
// format: ANSWER: <answer>
544
func (l *Logger) LogAnswer(answer string) {
1✔
545
        l.writeTimestamped("ANSWER: ", l.colors.Info(), answer)
1✔
546
}
1✔
547

548
// LogDraftReview logs the user's draft review action and optional feedback.
549
// format: DRAFT REVIEW: <action>
550
// if feedback is non-empty: FEEDBACK: <feedback>
551
func (l *Logger) LogDraftReview(action, feedback string) {
2✔
552
        timestamp := time.Now().Format(timestampFormat)
2✔
553

2✔
554
        l.writeFile("[%s] DRAFT REVIEW: %s\n", timestamp, action)
2✔
555

2✔
556
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
2✔
557
        actionStr := l.colors.Info().Sprintf("DRAFT REVIEW: %s", action)
2✔
558
        l.writeStdout("%s %s\n", tsStr, actionStr)
2✔
559

2✔
560
        if feedback != "" {
3✔
561
                l.writeFile("[%s] FEEDBACK: %s\n", timestamp, feedback)
1✔
562
                feedbackStr := l.colors.Info().Sprintf("FEEDBACK: %s", feedback)
1✔
563
                l.writeStdout("%s %s\n", tsStr, feedbackStr)
1✔
564
        }
1✔
565
}
566

567
// LogDiffStats writes git diff stats to the progress file (file-only, no stdout).
568
// format: [timestamp] DIFFSTATS: files=F additions=A deletions=D
569
func (l *Logger) LogDiffStats(files, additions, deletions int) {
2✔
570
        if l.file == nil || files <= 0 {
3✔
571
                return
1✔
572
        }
1✔
573
        timestamp := time.Now().Format(timestampFormat)
1✔
574
        l.writeFile("[%s] DIFFSTATS: files=%d additions=%d deletions=%d\n",
1✔
575
                timestamp, files, additions, deletions)
1✔
576
}
577

578
// Elapsed returns formatted elapsed time since start.
579
// for durations >= 1 hour, truncates to minutes (e.g. "1h23m"); otherwise to seconds (e.g. "5m30s").
580
func (l *Logger) Elapsed() string {
50✔
581
        d := time.Since(l.startTime)
50✔
582
        if d >= time.Hour {
53✔
583
                return strings.TrimSuffix(d.Truncate(time.Minute).String(), "0s")
3✔
584
        }
3✔
585
        return d.Truncate(time.Second).String()
47✔
586
}
587

588
// SetFailed marks the logger as failed with the given reason. On Close, a "Failed:"
589
// footer is written instead of "Completed:", so the restart path appends to the file
590
// (preserving history) rather than truncating. Safe to call multiple times; the most
591
// recent call wins (passing nil clears any previous failure state).
592
func (l *Logger) SetFailed(reason error) {
5✔
593
        l.runErr = reason
5✔
594
}
5✔
595

596
// Close writes footer, releases the file lock, and closes the progress file.
597
// Writes "Completed:" footer on success, or "Failed: ... - <reason>" if SetFailed
598
// was called with a non-nil error.
599
func (l *Logger) Close() error {
45✔
600
        if l.file == nil {
45✔
601
                return nil
×
602
        }
×
603

604
        l.writeFile("\n%s\n", separatorLine)
45✔
605
        ts := time.Now().Format("2006-01-02 15:04:05")
45✔
606
        if l.runErr == nil {
85✔
607
                l.writeFile("Completed: %s (%s)\n", ts, l.Elapsed())
40✔
608
        } else {
45✔
609
                reason := sanitizeFailureReason(l.runErr.Error())
5✔
610
                l.writeFile("Failed: %s (%s) - %s\n", ts, l.Elapsed(), reason)
5✔
611
        }
5✔
612

613
        // release file lock before closing
614
        _ = unlockFile(l.file)
45✔
615
        unregisterActiveLock(l.file.Name())
45✔
616

45✔
617
        if err := l.file.Close(); err != nil {
45✔
618
                return fmt.Errorf("close progress file: %w", err)
×
619
        }
×
620
        return nil
45✔
621
}
622

623
// maxFailureReasonRunes caps the failure reason written into the footer.
624
// Rune-based cap avoids splitting multibyte sequences at the boundary.
625
const maxFailureReasonRunes = 200
626

627
// sanitizeFailureReason prepares an error string for single-line footer output.
628
// Replaces Unicode control chars (unicode.IsControl, covers ASCII C0 + C1 ranges)
629
// and line/paragraph separators (U+2028, U+2029) with a single space, collapses
630
// consecutive whitespace via strings.Fields, and rune-aware truncates to
631
// maxFailureReasonRunes. Critical for isProgressCompleted integrity: the reason
632
// must not contain "\n<separatorLine>\nCompleted:" which would false-positive
633
// the tail scan.
634
func sanitizeFailureReason(s string) string {
7✔
635
        mapped := strings.Map(func(r rune) rune {
605✔
636
                if unicode.IsControl(r) || r == '\u2028' || r == '\u2029' {
609✔
637
                        return ' '
11✔
638
                }
11✔
639
                return r
587✔
640
        }, s)
641
        out := strings.Join(strings.Fields(mapped), " ")
7✔
642
        if runes := []rune(out); len(runes) > maxFailureReasonRunes {
8✔
643
                out = string(runes[:maxFailureReasonRunes]) + "..."
1✔
644
        }
1✔
645
        if out == "" {
9✔
646
                return "unknown error"
2✔
647
        }
2✔
648
        return out
5✔
649
}
650

651
func (l *Logger) writeFile(format string, args ...any) {
397✔
652
        if l.file != nil {
794✔
653
                fmt.Fprintf(l.file, format, args...)
397✔
654
        }
397✔
655
}
656

657
func (l *Logger) writeStdout(format string, args ...any) {
31✔
658
        fmt.Fprintf(l.stdout, format, args...)
31✔
659
}
31✔
660

661
// isProgressCompleted reports whether the progress file ends with a successful "Completed:"
662
// footer written by Close(). "Failed:" footers are intentionally excluded so failed/aborted
663
// runs preserve history on restart (issue #288).
664
// reads the last ~256 bytes from the provided file descriptor and checks for the dash separator
665
// followed by "Completed:" — the exact pattern Close() writes on success.
666
// uses the already-locked fd to avoid TOCTOU path-vs-inode mismatch.
667
// returns false for zero-size files or read errors.
668
func isProgressCompleted(f *os.File, size int64) bool {
14✔
669
        if size == 0 {
16✔
670
                return false
2✔
671
        }
2✔
672

673
        // read the last 256 bytes (or less if file is smaller)
674
        const tailSize int64 = 256
12✔
675
        offset := max(0, size-tailSize)
12✔
676

12✔
677
        buf := make([]byte, tailSize)
12✔
678
        n, err := f.ReadAt(buf, offset)
12✔
679
        if err != nil && n == 0 {
12✔
680
                return false
×
681
        }
×
682

683
        // match the exact pattern written by Close(): 60-dash separator followed by "Completed:".
684
        // a plain "Completed:" check would false-positive on Claude output containing that text.
685
        return strings.Contains(string(buf[:n]), separatorLine+"\nCompleted:")
12✔
686
}
687

688
// progressDir is the directory for progress files within the project.
689
const progressDir = ".ralphex/progress"
690

691
// progressFilename returns progress file path based on plan and mode.
692
func progressFilename(planFile, planDescription, mode string) string {
58✔
693
        // plan mode uses sanitized plan description
58✔
694
        if mode == "plan" && planDescription != "" {
68✔
695
                sanitized := sanitizePlanName(planDescription)
10✔
696
                return filepath.Join(progressDir, fmt.Sprintf("progress-plan-%s.txt", sanitized))
10✔
697
        }
10✔
698

699
        if planFile != "" {
69✔
700
                stem := strings.TrimSuffix(filepath.Base(planFile), ".md")
21✔
701
                switch mode {
21✔
702
                case "codex-only":
2✔
703
                        return filepath.Join(progressDir, fmt.Sprintf("progress-%s-codex.txt", stem))
2✔
704
                case "review":
2✔
705
                        return filepath.Join(progressDir, fmt.Sprintf("progress-%s-review.txt", stem))
2✔
706
                default:
17✔
707
                        return filepath.Join(progressDir, fmt.Sprintf("progress-%s.txt", stem))
17✔
708
                }
709
        }
710

711
        switch mode {
27✔
712
        case "codex-only":
2✔
713
                return filepath.Join(progressDir, "progress-codex.txt")
2✔
714
        case "review":
2✔
715
                return filepath.Join(progressDir, "progress-review.txt")
2✔
716
        case "plan":
2✔
717
                return filepath.Join(progressDir, "progress-plan.txt")
2✔
718
        default:
21✔
719
                return filepath.Join(progressDir, "progress.txt")
21✔
720
        }
721
}
722

723
// sanitizePlanName converts plan description to a safe filename component.
724
// replaces spaces with dashes, removes special characters, and limits length.
725
func sanitizePlanName(desc string) string {
21✔
726
        // lowercase and replace spaces with dashes
21✔
727
        result := strings.ToLower(desc)
21✔
728
        result = strings.ReplaceAll(result, " ", "-")
21✔
729

21✔
730
        // keep only alphanumeric and dashes
21✔
731
        var clean strings.Builder
21✔
732
        for _, r := range result {
359✔
733
                if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
665✔
734
                        clean.WriteRune(r)
327✔
735
                }
327✔
736
        }
737
        result = clean.String()
21✔
738

21✔
739
        // collapse multiple dashes
21✔
740
        result = multiDashRegex.ReplaceAllString(result, "-")
21✔
741

21✔
742
        // trim leading/trailing dashes
21✔
743
        result = strings.Trim(result, "-")
21✔
744

21✔
745
        // limit length to 50 characters
21✔
746
        if len(result) > 50 {
23✔
747
                result = result[:50]
2✔
748
                // don't end with a dash
2✔
749
                result = strings.TrimRight(result, "-")
2✔
750
        }
2✔
751

752
        if result == "" {
23✔
753
                return "unnamed"
2✔
754
        }
2✔
755
        return result
19✔
756
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc