Implement REPL package with Run(io.Reader, io.Writer)

- Read-eval-print loop: reads lines, tokenizes, parses, evaluates, prints result
- Prints prompt '>> ' before each input
- Error handling: prints 'error: <msg>' and continues loop
- Skips empty lines
- Formats results with %g (clean integers, no trailing zeros)
- 10 unit tests covering: single/multiple expressions, error recovery,
  division by zero, empty lines, prompt display, float results,
  invalid chars, empty input, integer formatting
diff --git a/repl/repl.go b/repl/repl.go
new file mode 100644
index 0000000..bc14567
--- /dev/null
+++ b/repl/repl.go
@@ -0,0 +1,62 @@
+package repl
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"math"
+	"strings"
+
+	"matheval/evaluator"
+	"matheval/lexer"
+	"matheval/parser"
+)
+
+const prompt = ">> "
+
+// Run starts the read-eval-print loop, reading from r and writing to w.
+func Run(r io.Reader, w io.Writer) {
+	scanner := bufio.NewScanner(r)
+	fmt.Fprint(w, prompt)
+
+	for scanner.Scan() {
+		line := strings.TrimSpace(scanner.Text())
+		if line == "" {
+			fmt.Fprint(w, prompt)
+			continue
+		}
+
+		result, err := evalLine(line)
+		if err != nil {
+			fmt.Fprintf(w, "error: %s\n", err)
+		} else {
+			fmt.Fprintln(w, formatResult(result))
+		}
+
+		fmt.Fprint(w, prompt)
+	}
+}
+
+// evalLine tokenizes, parses, and evaluates a single expression string.
+func evalLine(line string) (float64, error) {
+	tokens, err := lexer.Tokenize(line)
+	if err != nil {
+		return 0, err
+	}
+
+	tree, err := parser.Parse(tokens)
+	if err != nil {
+		return 0, err
+	}
+
+	return evaluator.Eval(tree)
+}
+
+// formatResult formats a float64 for display.
+// Whole numbers are printed without decimal points.
+func formatResult(val float64) string {
+	if val == math.Trunc(val) && !math.IsInf(val, 0) && !math.IsNaN(val) {
+		return fmt.Sprintf("%g", val)
+	}
+	return fmt.Sprintf("%g", val)
+}