diff --git a/ingestion/internal/api/passrate.go b/ingestion/internal/api/passrate.go
new file mode 100644
index 0000000..076e069
--- /dev/null
+++ b/ingestion/internal/api/passrate.go
@@ -0,0 +1,140 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+type passRateResponse struct {
+ Skill string `json:"skill"`
+ Window string `json:"window"`
+ Pass int `json:"pass"`
+ Fail int `json:"fail"`
+ Skip int `json:"skip"`
+ Total int `json:"total"`
+ PassRate *float64 `json:"pass_rate"`
+}
+
+// PassRate handles GET /pass-rate?skill=X&window=Y.
+// Walks brainDir/sessions/*.jsonl, filters by skill name and timestamp,
+// returns aggregated counts and pass rate.
+func (h *Handler) PassRate(w http.ResponseWriter, r *http.Request) {
+ skill := r.URL.Query().Get("skill")
+ if skill == "" {
+ writeError(w, http.StatusBadRequest, "skill is required")
+ return
+ }
+
+ windowStr := r.URL.Query().Get("window")
+ if windowStr == "" {
+ windowStr = "7d"
+ }
+ window, err := parseWindow(windowStr)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "invalid window: "+err.Error())
+ return
+ }
+
+ cutoff := time.Now().UTC().Add(-window)
+ pass, fail, skip := 0, 0, 0
+
+ sessionsDir := filepath.Join(h.brainDir, "sessions")
+ entries, err := os.ReadDir(sessionsDir)
+ if err != nil && !os.IsNotExist(err) {
+ writeError(w, http.StatusInternalServerError, "read sessions dir: "+err.Error())
+ return
+ }
+
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
+ continue
+ }
+ body, err := os.ReadFile(filepath.Join(sessionsDir, entry.Name()))
+ if err != nil {
+ continue // skip unreadable files
+ }
+ for _, line := range strings.Split(string(body), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ var rec struct {
+ Timestamp string `json:"timestamp"`
+ Skill string `json:"skill"`
+ FinalStatus string `json:"final_status"`
+ }
+ if err := json.Unmarshal([]byte(line), &rec); err != nil {
+ continue // malformed — skip
+ }
+ if rec.Skill != skill {
+ continue
+ }
+ ts, err := time.Parse(time.RFC3339, rec.Timestamp)
+ if err != nil {
+ continue
+ }
+ if ts.Before(cutoff) {
+ continue
+ }
+ switch normalizeStatus(rec.FinalStatus) {
+ case "pass":
+ pass++
+ case "fail":
+ fail++
+ case "skip":
+ skip++
+ }
+ }
+ }
+
+ total := pass + fail + skip
+ resp := passRateResponse{
+ Skill: skill,
+ Window: windowStr,
+ Pass: pass,
+ Fail: fail,
+ Skip: skip,
+ Total: total,
+ }
+ if pass+fail > 0 {
+ rate := float64(pass) / float64(pass+fail)
+ resp.PassRate = &rate
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+}
+
+// normalizeStatus maps both new (pass/fail/skip) and legacy (ok/error/skipped)
+// vocabularies to the canonical pass/fail/skip set. Unknown values are treated
+// as skip for safety.
+func normalizeStatus(s string) string {
+ switch s {
+ case "pass", "ok":
+ return "pass"
+ case "fail", "error":
+ return "fail"
+ case "skip", "skipped":
+ return "skip"
+ default:
+ return "skip"
+ }
+}
+
+// parseWindow accepts Go-style durations plus "Nd" for days.
+func parseWindow(s string) (time.Duration, error) {
+ if strings.HasSuffix(s, "d") {
+ // Replace "d" with "h" * 24
+ days := strings.TrimSuffix(s, "d")
+ d, err := time.ParseDuration(days + "h")
+ if err != nil {
+ return 0, err
+ }
+ return d * 24, nil
+ }
+ return time.ParseDuration(s)
+}
diff --git a/ingestion/internal/api/passrate_test.go b/ingestion/internal/api/passrate_test.go
new file mode 100644
index 0000000..26f2d46
--- /dev/null
+++ b/ingestion/internal/api/passrate_test.go
@@ -0,0 +1,172 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// writeSession writes one or more JSONL entries to
/sessions/.jsonl.
+// The handler scans /sessions/, so test fixtures must mirror that layout.
+func writeSession(t *testing.T, dir, sessionID string, entries ...string) {
+ t.Helper()
+ sessionsDir := filepath.Join(dir, "sessions")
+ require.NoError(t, os.MkdirAll(sessionsDir, 0o755))
+ path := filepath.Join(sessionsDir, sessionID+".jsonl")
+ body := ""
+ for _, e := range entries {
+ body += e + "\n"
+ }
+ require.NoError(t, os.WriteFile(path, []byte(body), 0o644))
+}
+
+func TestPassRate_HappyPath(t *testing.T) {
+ dir := t.TempDir()
+ now := time.Now().UTC()
+ recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
+
+ writeSession(t, dir, "s1",
+ `{"timestamp":"`+recent+`","skill":"tdd","phase":"red","final_status":"pass"}`,
+ `{"timestamp":"`+recent+`","skill":"tdd","phase":"green","final_status":"pass"}`,
+ `{"timestamp":"`+recent+`","skill":"tdd","phase":"refactor","final_status":"fail"}`,
+ )
+ writeSession(t, dir, "s2",
+ `{"timestamp":"`+recent+`","skill":"code-review","phase":"review","final_status":"pass"}`,
+ )
+
+ h := &Handler{brainDir: dir}
+ req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil)
+ w := httptest.NewRecorder()
+ h.PassRate(w, req)
+
+ resp := w.Result()
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+
+ var got passRateResponse
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
+ assert.Equal(t, "tdd", got.Skill)
+ assert.Equal(t, "24h", got.Window)
+ assert.Equal(t, 2, got.Pass)
+ assert.Equal(t, 1, got.Fail)
+ assert.Equal(t, 0, got.Skip)
+ assert.Equal(t, 3, got.Total)
+ require.NotNil(t, got.PassRate)
+ assert.InDelta(t, 0.6667, *got.PassRate, 0.001)
+}
+
+func TestPassRate_LegacyVocabulary(t *testing.T) {
+ dir := t.TempDir()
+ now := time.Now().UTC().Format(time.RFC3339)
+ writeSession(t, dir, "s1",
+ `{"timestamp":"`+now+`","skill":"tdd","final_status":"ok"}`,
+ `{"timestamp":"`+now+`","skill":"tdd","final_status":"error"}`,
+ `{"timestamp":"`+now+`","skill":"tdd","final_status":"skipped"}`,
+ )
+
+ h := &Handler{brainDir: dir}
+ req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil)
+ w := httptest.NewRecorder()
+ h.PassRate(w, req)
+
+ var got passRateResponse
+ require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got))
+ assert.Equal(t, 1, got.Pass, "ok→pass")
+ assert.Equal(t, 1, got.Fail, "error→fail")
+ assert.Equal(t, 1, got.Skip, "skipped→skip")
+}
+
+func TestPassRate_OutsideWindow_Excluded(t *testing.T) {
+ dir := t.TempDir()
+ old := time.Now().UTC().Add(-30 * 24 * time.Hour).Format(time.RFC3339)
+ recent := time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339)
+ writeSession(t, dir, "s1",
+ `{"timestamp":"`+old+`","skill":"tdd","final_status":"pass"}`,
+ `{"timestamp":"`+recent+`","skill":"tdd","final_status":"pass"}`,
+ )
+
+ h := &Handler{brainDir: dir}
+ req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil)
+ w := httptest.NewRecorder()
+ h.PassRate(w, req)
+
+ var got passRateResponse
+ require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got))
+ assert.Equal(t, 1, got.Pass)
+ assert.Equal(t, 1, got.Total)
+}
+
+func TestPassRate_NoData_ReturnsZerosAndNullRate(t *testing.T) {
+ dir := t.TempDir()
+ h := &Handler{brainDir: dir}
+ req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil)
+ w := httptest.NewRecorder()
+ h.PassRate(w, req)
+
+ var got passRateResponse
+ require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got))
+ assert.Equal(t, 0, got.Pass)
+ assert.Equal(t, 0, got.Fail)
+ assert.Equal(t, 0, got.Skip)
+ assert.Equal(t, 0, got.Total)
+ assert.Nil(t, got.PassRate, "pass_rate must be null when pass+fail == 0")
+}
+
+func TestPassRate_DefaultsTo7d(t *testing.T) {
+ dir := t.TempDir()
+ now := time.Now().UTC().Format(time.RFC3339)
+ writeSession(t, dir, "s1", `{"timestamp":"`+now+`","skill":"tdd","final_status":"pass"}`)
+
+ h := &Handler{brainDir: dir}
+ req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd", nil) // no window
+ w := httptest.NewRecorder()
+ h.PassRate(w, req)
+
+ var got passRateResponse
+ require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got))
+ assert.Equal(t, "7d", got.Window)
+ assert.Equal(t, 1, got.Pass)
+}
+
+func TestPassRate_MissingSkill_ReturnsBadRequest(t *testing.T) {
+ dir := t.TempDir()
+ h := &Handler{brainDir: dir}
+ req := httptest.NewRequest(http.MethodGet, "/pass-rate", nil)
+ w := httptest.NewRecorder()
+ h.PassRate(w, req)
+ assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode)
+}
+
+func TestPassRate_BadWindow_ReturnsBadRequest(t *testing.T) {
+ dir := t.TempDir()
+ h := &Handler{brainDir: dir}
+ req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=foo", nil)
+ w := httptest.NewRecorder()
+ h.PassRate(w, req)
+ assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode)
+}
+
+func TestPassRate_MalformedLine_Skipped(t *testing.T) {
+ dir := t.TempDir()
+ now := time.Now().UTC().Format(time.RFC3339)
+ writeSession(t, dir, "s1",
+ `{"timestamp":"`+now+`","skill":"tdd","final_status":"pass"}`,
+ `not valid json`,
+ `{"timestamp":"`+now+`","skill":"tdd","final_status":"pass"}`,
+ )
+
+ h := &Handler{brainDir: dir}
+ req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil)
+ w := httptest.NewRecorder()
+ h.PassRate(w, req)
+
+ var got passRateResponse
+ require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got))
+ assert.Equal(t, 2, got.Pass, "the malformed line is silently skipped")
+}