# Phase 4: AttemptRecord Wiring + Shared PrependHistory Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Wire orchestrator `AttemptRecord`s into the session JSONL log so every skill invocation records which models ran, verdicts, and timings; simultaneously eliminate the `prependHistory` copy-paste across four skill packages by exporting it from the `session` package. **Architecture:** Two changes composed: 1. `session.PrependHistory` — exported function replaces 4 identical private methods. Lives in `internal/session/history.go`. 2. `session.AttemptsFrom` — converter in new `internal/session/attempts.go` that turns `[]exec.AttemptRecord` into `[]session.Attempt`. Introduces `session → exec` dependency (no circular risk). 3. `exec.Result.Attempts` — new `[]AttemptRecord` field populated by `buildOrch` after `orch.Run`. Each skill handler calls `session.Append` after `ExecutorFn` if `session_id` is set. **Tech Stack:** Go stdlib, `internal/session`, `internal/exec`, `internal/skills/*`, `cmd/supervisor/main.go`. No new dependencies. --- ## File map | Action | File | Responsibility | |--------|------|---------------| | Modify | `internal/session/history.go` | Add exported `PrependHistory` function | | Modify | `internal/session/history_test.go` | Add `TestPrependHistory` cases | | Create | `internal/session/attempts.go` | `AttemptsFrom([]exec.AttemptRecord) []Attempt` | | Create | `internal/session/attempts_test.go` | Unit tests for `AttemptsFrom` | | Modify | `internal/exec/result.go` | Add `Attempts []AttemptRecord` field to `Result` | | Modify | `cmd/supervisor/main.go` | Set `result.Attempts = attempts` in `buildOrch` after `orch.Run` | | Modify | `internal/skills/review/handlers.go` | Use `session.PrependHistory`, add `session.Append`, remove private method | | Modify | `internal/skills/debug/handlers.go` | Same | | Modify | `internal/skills/spec/handlers.go` | Same | | Modify | `internal/skills/tdd/handlers.go` | Use `session.PrependHistory` for green/refactor, remove private method, add `session.Append` per phase | | Modify | `internal/skills/retrospective/handlers.go` | Add `session.Append` after `ExecutorFn` | | Modify | `internal/skills/trainer/handlers.go` | Add `session.Append` after writer agent | --- ## Task 1: Export `PrependHistory` from session package **Files:** - Modify: `internal/session/history.go` - Modify: `internal/session/history_test.go` - [ ] **Step 1: Write failing test** Add to `internal/session/history_test.go`: ```go func TestPrependHistoryNoSessionID(t *testing.T) { result := session.PrependHistory("", "", "review", "do the task") assert.Equal(t, "do the task", result) } func TestPrependHistoryNoLog(t *testing.T) { dir := t.TempDir() result := session.PrependHistory(dir, "sess-abc", "review", "do the task") assert.Equal(t, "do the task", result) } func TestPrependHistoryPrependsHistory(t *testing.T) { dir := t.TempDir() entry := session.Entry{ SessionID: "sess-abc", Skill: "tdd", Phase: "red", FinalStatus: "pass", Message: "wrote test", Timestamp: time.Now(), } require.NoError(t, session.Append(dir, "sess-abc", entry)) result := session.PrependHistory(dir, "sess-abc", "review", "do the task") assert.Contains(t, result, "## Session history") assert.Contains(t, result, "wrote test") assert.HasSuffix(t, result, "do the task") } func TestPrependHistoryExcludesCurrentPhase(t *testing.T) { dir := t.TempDir() require.NoError(t, session.Append(dir, "sess-abc", session.Entry{ SessionID: "sess-abc", Skill: "tdd", Phase: "red", FinalStatus: "pass", Message: "red done", Timestamp: time.Now(), })) require.NoError(t, session.Append(dir, "sess-abc", session.Entry{ SessionID: "sess-abc", Skill: "tdd", Phase: "green", FinalStatus: "pass", Message: "green done", Timestamp: time.Now(), })) result := session.PrependHistory(dir, "sess-abc", "green", "do the task") assert.Contains(t, result, "red done") assert.NotContains(t, result, "green done") } ``` - [ ] **Step 2: Run tests, verify failure** ```bash cd internal/session && go test ./... -run TestPrepend -v ``` Expected: `undefined: session.PrependHistory` - [ ] **Step 3: Add `PrependHistory` to `internal/session/history.go`** Append after the existing `FormatHistory` function: ```go // PrependHistory reads the session log for sessionID and prepends a formatted // history block to task. Returns task unchanged if sessionID or sessionsDir is // empty, or if no prior entries exist. func PrependHistory(sessionsDir, sessionID, currentPhase, task string) string { if sessionID == "" || sessionsDir == "" { return task } entries, err := Read(sessionsDir, sessionID) if err != nil || len(entries) == 0 { return task } history := FormatHistory(entries, currentPhase) if history == "" { return task } return history + "\n---\n\n" + task } ``` - [ ] **Step 4: Run tests, verify pass** ```bash cd internal/session && go test ./... -v ``` Expected: all pass including the four new `TestPrependHistory*` tests. - [ ] **Step 5: Commit** ```bash git add internal/session/history.go internal/session/history_test.go git commit -m "feat(session): export PrependHistory for shared use across skills" ``` --- ## Task 2: Add `AttemptsFrom` converter **Files:** - Create: `internal/session/attempts.go` - Create: `internal/session/attempts_test.go` - [ ] **Step 1: Write failing test** Create `internal/session/attempts_test.go`: ```go package session_test import ( "testing" "github.com/mathiasbq/supervisor/internal/exec" "github.com/mathiasbq/supervisor/internal/session" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestAttemptsFromEmpty(t *testing.T) { result := session.AttemptsFrom(nil) assert.Empty(t, result) } func TestAttemptsFromSetsIndex(t *testing.T) { records := []exec.AttemptRecord{ {Model: "ollama/phi4", Tier: "local", DurationMs: 1200, WarmStart: true, Verdict: "escalate", Feedback: "too vague"}, {Model: "claude-sonnet-4-6", Tier: "subagent", DurationMs: 3400, WarmStart: false, Verdict: "accept"}, } result := session.AttemptsFrom(records) require.Len(t, result, 2) assert.Equal(t, 1, result[0].Attempt) assert.Equal(t, "ollama/phi4", result[0].Model) assert.Equal(t, "local", result[0].Tier) assert.Equal(t, int64(1200), result[0].DurationMs) assert.True(t, result[0].WarmStart) assert.Equal(t, "escalate", result[0].Verdict) assert.Equal(t, "too vague", result[0].Feedback) assert.False(t, result[0].Verified) assert.Equal(t, 2, result[1].Attempt) assert.Equal(t, "claude-sonnet-4-6", result[1].Model) assert.True(t, result[1].Verified) } ``` - [ ] **Step 2: Run test, verify failure** ```bash cd internal/session && go test ./... -run TestAttemptsFrom -v ``` Expected: `undefined: session.AttemptsFrom` - [ ] **Step 3: Create `internal/session/attempts.go`** ```go // internal/session/attempts.go package session import iexec "github.com/mathiasbq/supervisor/internal/exec" // AttemptsFrom converts exec.AttemptRecord slice to session.Attempt slice // for writing into a session JSONL entry. func AttemptsFrom(records []iexec.AttemptRecord) []Attempt { if len(records) == 0 { return nil } out := make([]Attempt, len(records)) for i, r := range records { out[i] = Attempt{ Attempt: i + 1, Model: r.Model, Tier: r.Tier, DurationMs: r.DurationMs, WarmStart: r.WarmStart, Verdict: r.Verdict, Feedback: r.Feedback, Verified: r.Verdict == "accept", } } return out } ``` - [ ] **Step 4: Run tests, verify pass** ```bash cd internal/session && go test ./... -v ``` Expected: all pass. - [ ] **Step 5: Commit** ```bash git add internal/session/attempts.go internal/session/attempts_test.go git commit -m "feat(session): add AttemptsFrom converter for exec.AttemptRecord" ``` --- ## Task 3: Add `Attempts` field to `exec.Result` and wire in `buildOrch` **Files:** - Modify: `internal/exec/result.go` - Modify: `cmd/supervisor/main.go` - [ ] **Step 1: Add `Attempts` to `exec.Result`** In `internal/exec/result.go`, add one field to the `Result` struct after `Message`: ```go type Result struct { Status string `json:"status"` Phase string `json:"phase"` Skill string `json:"skill"` FilePath string `json:"file_path"` RunnerOutput string `json:"runner_output"` Verified bool `json:"verified"` ModelUsed string `json:"model_used"` Message string `json:"message"` Attempts []AttemptRecord `json:"attempts,omitempty"` // populated by orchestrator, not Claude } ``` - [ ] **Step 2: Run tests, verify no regressions** ```bash go test ./internal/exec/... -v ``` Expected: all existing tests pass (adding a field is backward-compatible). - [ ] **Step 3: Wire attempts into `buildOrch` in `cmd/supervisor/main.go`** Find the `buildOrch` closure and add one line after `orch.Run`: ```go buildOrch := func(skill string) func(ctx context.Context, req iexec.Request) (iexec.Result, error) { return func(ctx context.Context, req iexec.Request) (iexec.Result, error) { rawChain := models.ChainFor(skill, req.Model) chain := make([]iexec.ChainEntry, len(rawChain)) for i, m := range rawChain { chain[i] = iexec.EntryFor(m) } attempts := make([]iexec.AttemptRecord, 0, len(chain)) orch := iexec.NewOrchestrator(chain, litellmExec.Run, claudeExec.Run, verifier, models.LlamaSwapURL(), &attempts) result, err := orch.Run(ctx, req) result.Attempts = attempts // attach orchestration metadata before returning return result, err } } ``` - [ ] **Step 4: Build to verify no compile errors** ```bash go build ./... ``` Expected: clean build. - [ ] **Step 5: Commit** ```bash git add internal/exec/result.go cmd/supervisor/main.go git commit -m "feat(exec): surface AttemptRecord slice on Result for session logging" ``` --- ## Task 4: Update `review` and `debug` skill handlers **Files:** - Modify: `internal/skills/review/handlers.go` - Modify: `internal/skills/debug/handlers.go` - [ ] **Step 1: Rewrite `review/handlers.go`** ```go // internal/skills/review/handlers.go package review import ( "context" "encoding/json" "fmt" "strings" "time" iexec "github.com/mathiasbq/supervisor/internal/exec" "github.com/mathiasbq/supervisor/internal/session" ) type reviewArgs struct { ProjectRoot string `json:"project_root"` Files []string `json:"files"` Context string `json:"context"` Model string `json:"model"` SessionID string `json:"session_id"` } // Handle dispatches the MCP tool call to the appropriate handler. func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { if tool != "review" { return nil, fmt.Errorf("unknown tool: %s", tool) } var a reviewArgs if err := json.Unmarshal(args, &a); err != nil { return nil, fmt.Errorf("parse args: %w", err) } if a.ProjectRoot == "" { return nil, fmt.Errorf("project_root is required") } if len(a.Files) == 0 { return nil, fmt.Errorf("files is required") } model := a.Model if model == "" { model = s.cfg.DefaultModel } task := fmt.Sprintf( "phase: review\nproject_root: %s\nfiles: %s\ncontext: %s\nmodel: %s", a.ProjectRoot, strings.Join(a.Files, ", "), a.Context, model, ) task = session.PrependHistory(s.cfg.SessionsDir, a.SessionID, "review", task) if s.cfg.ExecutorFn == nil { return nil, fmt.Errorf("no executor configured") } t0 := time.Now() result, err := s.cfg.ExecutorFn(ctx, iexec.Request{ SkillPrompt: s.cfg.SkillPrompt, TaskPrompt: task, Model: model, Tools: "Read,Bash", }) if err != nil { return nil, err } if a.SessionID != "" && s.cfg.SessionsDir != "" { _ = session.Append(s.cfg.SessionsDir, a.SessionID, session.Entry{ SessionID: a.SessionID, Timestamp: time.Now(), Skill: "review", Phase: "review", ProjectRoot: a.ProjectRoot, Attempts: session.AttemptsFrom(result.Attempts), FinalStatus: result.Status, FilePath: result.FilePath, ModelUsed: result.ModelUsed, DurationMs: time.Since(t0).Milliseconds(), Message: result.Message, }) } b, err := json.Marshal(result) if err != nil { return nil, fmt.Errorf("marshal result: %w", err) } return b, nil } ``` - [ ] **Step 2: Rewrite `debug/handlers.go`** ```go // internal/skills/debug/handlers.go package debug import ( "context" "encoding/json" "fmt" "time" iexec "github.com/mathiasbq/supervisor/internal/exec" "github.com/mathiasbq/supervisor/internal/session" ) type debugArgs struct { ProjectRoot string `json:"project_root"` Error string `json:"error"` Context string `json:"context"` Model string `json:"model"` SessionID string `json:"session_id"` } // Handle dispatches the MCP tool call to the appropriate handler. func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { if tool != "debug" { return nil, fmt.Errorf("unknown tool: %s", tool) } var a debugArgs if err := json.Unmarshal(args, &a); err != nil { return nil, fmt.Errorf("parse args: %w", err) } if a.ProjectRoot == "" { return nil, fmt.Errorf("project_root is required") } if a.Error == "" { return nil, fmt.Errorf("error is required") } model := a.Model if model == "" { model = s.cfg.DefaultModel } task := fmt.Sprintf( "phase: debug\nproject_root: %s\nerror: %s\ncontext: %s\nmodel: %s", a.ProjectRoot, a.Error, a.Context, model, ) task = session.PrependHistory(s.cfg.SessionsDir, a.SessionID, "debug", task) if s.cfg.ExecutorFn == nil { return nil, fmt.Errorf("no executor configured") } t0 := time.Now() result, err := s.cfg.ExecutorFn(ctx, iexec.Request{ SkillPrompt: s.cfg.SkillPrompt, TaskPrompt: task, Model: model, Tools: "Read,Bash", }) if err != nil { return nil, err } if a.SessionID != "" && s.cfg.SessionsDir != "" { _ = session.Append(s.cfg.SessionsDir, a.SessionID, session.Entry{ SessionID: a.SessionID, Timestamp: time.Now(), Skill: "debug", Phase: "debug", ProjectRoot: a.ProjectRoot, Attempts: session.AttemptsFrom(result.Attempts), FinalStatus: result.Status, ModelUsed: result.ModelUsed, DurationMs: time.Since(t0).Milliseconds(), Message: result.Message, }) } b, err := json.Marshal(result) if err != nil { return nil, fmt.Errorf("marshal result: %w", err) } return b, nil } ``` - [ ] **Step 3: Build and test** ```bash go build ./... && go test ./internal/skills/review/... ./internal/skills/debug/... -v ``` Expected: clean build and all existing tests pass. - [ ] **Step 4: Commit** ```bash git add internal/skills/review/handlers.go internal/skills/debug/handlers.go git commit -m "feat(skills): wire session.Append and PrependHistory into review and debug" ``` --- ## Task 5: Update `spec` skill handler **Files:** - Modify: `internal/skills/spec/handlers.go` - [ ] **Step 1: Rewrite `spec/handlers.go`** ```go // internal/skills/spec/handlers.go package spec import ( "context" "encoding/json" "fmt" "time" iexec "github.com/mathiasbq/supervisor/internal/exec" "github.com/mathiasbq/supervisor/internal/session" ) type specArgs struct { ProjectRoot string `json:"project_root"` Requirements string `json:"requirements"` OutputPath string `json:"output_path"` Context string `json:"context"` Model string `json:"model"` SessionID string `json:"session_id"` } // Handle dispatches the MCP tool call to the appropriate handler. func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { if tool != "spec" { return nil, fmt.Errorf("unknown tool: %s", tool) } var a specArgs if err := json.Unmarshal(args, &a); err != nil { return nil, fmt.Errorf("parse args: %w", err) } if a.ProjectRoot == "" { return nil, fmt.Errorf("project_root is required") } if a.Requirements == "" { return nil, fmt.Errorf("requirements is required") } outputPath := a.OutputPath if outputPath == "" { outputPath = "docs/spec.md" } model := a.Model if model == "" { model = s.cfg.DefaultModel } task := fmt.Sprintf( "phase: spec\nproject_root: %s\nrequirements: %s\noutput_path: %s\ncontext: %s\nmodel: %s", a.ProjectRoot, a.Requirements, outputPath, a.Context, model, ) task = session.PrependHistory(s.cfg.SessionsDir, a.SessionID, "spec", task) if s.cfg.ExecutorFn == nil { return nil, fmt.Errorf("no executor configured") } t0 := time.Now() result, err := s.cfg.ExecutorFn(ctx, iexec.Request{ SkillPrompt: s.cfg.SkillPrompt, TaskPrompt: task, Model: model, Tools: "Read,Write", }) if err != nil { return nil, err } if a.SessionID != "" && s.cfg.SessionsDir != "" { _ = session.Append(s.cfg.SessionsDir, a.SessionID, session.Entry{ SessionID: a.SessionID, Timestamp: time.Now(), Skill: "spec", Phase: "spec", ProjectRoot: a.ProjectRoot, Attempts: session.AttemptsFrom(result.Attempts), FinalStatus: result.Status, FilePath: result.FilePath, ModelUsed: result.ModelUsed, DurationMs: time.Since(t0).Milliseconds(), Message: result.Message, }) } b, err := json.Marshal(result) if err != nil { return nil, fmt.Errorf("marshal result: %w", err) } return b, nil } ``` - [ ] **Step 2: Build and test** ```bash go build ./... && go test ./internal/skills/spec/... -v ``` Expected: clean build, all tests pass. - [ ] **Step 3: Commit** ```bash git add internal/skills/spec/handlers.go git commit -m "feat(skills): wire session.Append and PrependHistory into spec" ``` --- ## Task 6: Update `tdd` skill handler **Files:** - Modify: `internal/skills/tdd/handlers.go` - [ ] **Step 1: Rewrite `tdd/handlers.go`** ```go package tdd import ( "context" "encoding/json" "fmt" "time" iexec "github.com/mathiasbq/supervisor/internal/exec" "github.com/mathiasbq/supervisor/internal/session" ) func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { switch tool { case "tdd_red": return s.handleRed(ctx, args) case "tdd_green": return s.handleGreen(ctx, args) case "tdd_refactor": return s.handleRefactor(ctx, args) default: return nil, fmt.Errorf("unknown tool: %s", tool) } } type redArgs struct { ProjectRoot string `json:"project_root"` Spec string `json:"spec"` Model string `json:"model"` TestCmd string `json:"test_cmd"` } func (s *Skill) handleRed(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { var args redArgs if err := json.Unmarshal(raw, &args); err != nil { return nil, fmt.Errorf("parse args: %w", err) } if args.ProjectRoot == "" { return nil, fmt.Errorf("project_root is required") } if args.Spec == "" { return nil, fmt.Errorf("spec is required") } task := fmt.Sprintf( "phase: red\nproject_root: %s\nspec: %s\nmodel: %s\ntest_cmd: %s", args.ProjectRoot, args.Spec, s.resolveModel(args.Model), args.TestCmd, ) return s.execute(ctx, task) } type greenArgs struct { ProjectRoot string `json:"project_root"` TestPath string `json:"test_path"` Model string `json:"model"` TestCmd string `json:"test_cmd"` SessionID string `json:"session_id"` } func (s *Skill) handleGreen(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { var args greenArgs if err := json.Unmarshal(raw, &args); err != nil { return nil, fmt.Errorf("parse args: %w", err) } if args.ProjectRoot == "" { return nil, fmt.Errorf("project_root is required") } if args.TestPath == "" { return nil, fmt.Errorf("test_path is required") } task := fmt.Sprintf( "phase: green\nproject_root: %s\ntest_path: %s\nmodel: %s\ntest_cmd: %s", args.ProjectRoot, args.TestPath, s.resolveModel(args.Model), args.TestCmd, ) task = session.PrependHistory(s.cfg.SessionsDir, args.SessionID, "green", task) t0 := time.Now() result, err := s.execute(ctx, task) if err != nil { return nil, err } s.logAttempt(args.SessionID, args.ProjectRoot, "tdd", "green", t0, result) return result, nil } type refactorArgs struct { ProjectRoot string `json:"project_root"` TestPath string `json:"test_path"` ImplPath string `json:"impl_path"` Model string `json:"model"` TestCmd string `json:"test_cmd"` SessionID string `json:"session_id"` } func (s *Skill) handleRefactor(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { var args refactorArgs if err := json.Unmarshal(raw, &args); err != nil { return nil, fmt.Errorf("parse args: %w", err) } if args.ProjectRoot == "" { return nil, fmt.Errorf("project_root is required") } if args.TestPath == "" { return nil, fmt.Errorf("test_path is required") } if args.ImplPath == "" { return nil, fmt.Errorf("impl_path is required") } task := fmt.Sprintf( "phase: refactor\nproject_root: %s\ntest_path: %s\nimpl_path: %s\nmodel: %s\ntest_cmd: %s", args.ProjectRoot, args.TestPath, args.ImplPath, s.resolveModel(args.Model), args.TestCmd, ) task = session.PrependHistory(s.cfg.SessionsDir, args.SessionID, "refactor", task) t0 := time.Now() result, err := s.execute(ctx, task) if err != nil { return nil, err } s.logAttempt(args.SessionID, args.ProjectRoot, "tdd", "refactor", t0, result) return result, nil } func (s *Skill) resolveModel(override string) string { if override != "" { return override } return s.cfg.DefaultModel } // execute calls ExecutorFn and returns the marshaled result. func (s *Skill) execute(ctx context.Context, task string) (json.RawMessage, error) { if s.cfg.ExecutorFn == nil { return nil, fmt.Errorf("no executor configured") } req := iexec.Request{ SkillPrompt: s.cfg.SkillPrompt, TaskPrompt: task, } result, err := s.cfg.ExecutorFn(ctx, req) if err != nil { return nil, err } return json.Marshal(result) } // logAttempt writes a session.Entry for a completed phase if session_id is set. // raw is the marshaled Result returned by execute; we unmarshal to extract fields. func (s *Skill) logAttempt(sessionID, projectRoot, skill, phase string, t0 time.Time, raw json.RawMessage) { if sessionID == "" || s.cfg.SessionsDir == "" { return } var result iexec.Result if err := json.Unmarshal(raw, &result); err != nil { return } _ = session.Append(s.cfg.SessionsDir, sessionID, session.Entry{ SessionID: sessionID, Timestamp: time.Now(), Skill: skill, Phase: phase, ProjectRoot: projectRoot, Attempts: session.AttemptsFrom(result.Attempts), FinalStatus: result.Status, FilePath: result.FilePath, ModelUsed: result.ModelUsed, DurationMs: time.Since(t0).Milliseconds(), Message: result.Message, }) } ``` - [ ] **Step 2: Build and test** ```bash go build ./... && go test ./internal/skills/tdd/... -v ``` Expected: clean build, all existing tests pass. - [ ] **Step 3: Commit** ```bash git add internal/skills/tdd/handlers.go git commit -m "feat(skills): wire session.Append and PrependHistory into tdd" ``` --- ## Task 7: Update `retrospective` and `trainer` handlers **Files:** - Modify: `internal/skills/retrospective/handlers.go` - Modify: `internal/skills/trainer/handlers.go` - [ ] **Step 1: Update `retrospective/handlers.go`** — add `session.Append` after `ExecutorFn`: ```go // internal/skills/retrospective/handlers.go package retrospective import ( "context" "encoding/json" "fmt" "time" iexec "github.com/mathiasbq/supervisor/internal/exec" "github.com/mathiasbq/supervisor/internal/session" ) type retroArgs struct { SessionID string `json:"session_id"` Model string `json:"model,omitempty"` } // Handle dispatches the retrospective tool call. func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { if tool != "retrospective" { return nil, fmt.Errorf("unknown retrospective tool: %s", tool) } var a retroArgs if err := json.Unmarshal(args, &a); err != nil { return nil, fmt.Errorf("parse args: %w", err) } if a.SessionID == "" { return nil, fmt.Errorf("session_id is required") } model := a.Model if model == "" { model = s.cfg.DefaultModel } entries, err := session.Read(s.cfg.SessionsDir, a.SessionID) if err != nil { return nil, fmt.Errorf("read session log: %w", err) } logJSON, err := json.MarshalIndent(entries, "", " ") if err != nil { return nil, fmt.Errorf("marshal session log: %w", err) } taskPrompt := fmt.Sprintf( "SESSION_ID: %s\n\nSESSION_LOG:\n%s\n\nReview this session log. Identify what is novel or worth preserving as organizational knowledge. Write structured entries to brain/raw/ via brain_write. Return JSON result when done.", a.SessionID, string(logJSON), ) if s.cfg.ExecutorFn == nil { return nil, fmt.Errorf("no executor configured") } t0 := time.Now() result, err := s.cfg.ExecutorFn(ctx, iexec.Request{ SkillPrompt: s.cfg.SkillPrompt, TaskPrompt: taskPrompt, Model: model, Tools: "Bash,Read,Write", }) if err != nil { return nil, fmt.Errorf("retrospective worker: %w", err) } _ = session.Append(s.cfg.SessionsDir, a.SessionID, session.Entry{ SessionID: a.SessionID, Timestamp: time.Now(), Skill: "retrospective", Phase: "retrospective", Attempts: session.AttemptsFrom(result.Attempts), FinalStatus: result.Status, ModelUsed: result.ModelUsed, DurationMs: time.Since(t0).Milliseconds(), Message: result.Message, }) b, err := json.Marshal(result) if err != nil { return nil, fmt.Errorf("marshal result: %w", err) } return b, nil } ``` - [ ] **Step 2: Update `trainer/handlers.go`** — add `session.Append` after writer agent: ```go // internal/skills/trainer/handlers.go package trainer import ( "context" "encoding/json" "fmt" "time" iexec "github.com/mathiasbq/supervisor/internal/exec" "github.com/mathiasbq/supervisor/internal/session" ) type trainArgs struct { SessionID string `json:"session_id"` Model string `json:"model"` } // Handle dispatches the MCP tool call to the trainer handler. func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { if tool != "trainer" { return nil, fmt.Errorf("unknown tool: %s", tool) } var a trainArgs if err := json.Unmarshal(args, &a); err != nil { return nil, fmt.Errorf("parse args: %w", err) } if a.SessionID == "" { return nil, fmt.Errorf("session_id is required") } if s.cfg.ExecutorFn == nil { return nil, fmt.Errorf("no executor configured") } model := a.Model if model == "" { model = s.cfg.DefaultModel } entries, err := session.Read(s.cfg.SessionsDir, a.SessionID) if err != nil { return nil, fmt.Errorf("read session log: %w", err) } // ── Step 1: Reader agent ───────────────────────────────────────────────── history := session.FormatHistory(entries, "") readerTask := fmt.Sprintf( "role: reader\nsession_id: %s\nbrain_dir: %s\n\n%s", a.SessionID, s.cfg.BrainDir, history, ) readerResult, err := s.cfg.ExecutorFn(ctx, iexec.Request{ SkillPrompt: s.cfg.ReaderPrompt, TaskPrompt: readerTask, Model: model, Tools: "Read", }) if err != nil { return nil, fmt.Errorf("reader agent: %w", err) } // ── Step 2: Writer agent (receives reader candidates) ──────────────────── t0 := time.Now() writerTask := fmt.Sprintf( "role: writer\nsession_id: %s\nbrain_dir: %s\n\nreader_summary: %s\nreader_candidates:\n%s", a.SessionID, s.cfg.BrainDir, readerResult.Message, readerResult.RunnerOutput, ) writerResult, err := s.cfg.ExecutorFn(ctx, iexec.Request{ SkillPrompt: s.cfg.WriterPrompt, TaskPrompt: writerTask, Model: model, Tools: "Read,Write", }) if err != nil { return nil, fmt.Errorf("writer agent: %w", err) } _ = session.Append(s.cfg.SessionsDir, a.SessionID, session.Entry{ SessionID: a.SessionID, Timestamp: time.Now(), Skill: "trainer", Phase: "trainer", Attempts: session.AttemptsFrom(writerResult.Attempts), FinalStatus: writerResult.Status, ModelUsed: writerResult.ModelUsed, DurationMs: time.Since(t0).Milliseconds(), Message: writerResult.Message, }) b, err := json.Marshal(writerResult) if err != nil { return nil, fmt.Errorf("marshal result: %w", err) } return b, nil } ``` - [ ] **Step 3: Build and test** ```bash go build ./... && go test ./internal/skills/retrospective/... ./internal/skills/trainer/... -v ``` Expected: clean build, all tests pass. - [ ] **Step 4: Commit** ```bash git add internal/skills/retrospective/handlers.go internal/skills/trainer/handlers.go git commit -m "feat(skills): wire session.Append into retrospective and trainer" ``` --- ## Task 8: Full test suite + push + verify deployment - [ ] **Step 1: Run full test suite** ```bash go test ./... -v 2>&1 | tail -30 ``` Expected: all packages pass, no failures. - [ ] **Step 2: Run linter** ```bash task check ``` Expected: clean. - [ ] **Step 3: Push to trigger CD** ```bash git push ``` - [ ] **Step 4: Watch CD pipeline** ```bash # Poll until complete (replace RUN_ID with the new run): curl -s "https://gitea.d-ma.be/api/v1/repos/mathias/hyperguild/actions/runs?limit=2" \ -H "Authorization: token 736a8c36adcc6ecb41fff56e5ae4d0eb3105a670" \ | python3 -c "import sys,json; [print(r['id'],r['status'],r.get('conclusion','—')) for r in json.load(sys.stdin)['workflow_runs']]" ``` - [ ] **Step 5: Verify pod rolled to new image** ```bash ssh koala "kubectl get pod -n supervisor -o wide && kubectl logs -n supervisor deployment/supervisor --tail=3" ``` Expected: new pod SHA in image tag, `supervisor starting` log line. - [ ] **Step 6: Smoke-test MCP responds** ```bash ssh koala "curl -s -X POST http://10.43.197.185:3200/mcp \ -H 'Content-Type: application/json' \ -d '{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\",\"params\":{},\"id\":1}' \ | python3 -c \"import sys,json; tools=json.load(sys.stdin)['result']['tools']; print(len(tools), 'tools OK')\"" ``` Expected: `12 tools OK`