Files
hyperguild/docs/superpowers/plans/2026-04-22-phase4-attempt-wiring.md
Mathias Bergqvist c9310b1079
All checks were successful
cd / Build and deploy (push) Successful in 9s
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 4s
fix(ingestion): always append .md extension to written filenames
brain_write with a custom filename omitted the .md extension, causing
search to skip the file (search.go filters on HasSuffix .md).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 19:23:07 +02:00

30 KiB

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 AttemptRecords 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:

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
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:

// 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
cd internal/session && go test ./... -v

Expected: all pass including the four new TestPrependHistory* tests.

  • Step 5: Commit
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:

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
cd internal/session && go test ./... -run TestAttemptsFrom -v

Expected: undefined: session.AttemptsFrom

  • Step 3: Create internal/session/attempts.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
cd internal/session && go test ./... -v

Expected: all pass.

  • Step 5: Commit
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:

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
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:

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
go build ./...

Expected: clean build.

  • Step 5: Commit
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

// 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
// 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
go build ./... && go test ./internal/skills/review/... ./internal/skills/debug/... -v

Expected: clean build and all existing tests pass.

  • Step 4: Commit
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

// 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
go build ./... && go test ./internal/skills/spec/... -v

Expected: clean build, all tests pass.

  • Step 3: Commit
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

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
go build ./... && go test ./internal/skills/tdd/... -v

Expected: clean build, all existing tests pass.

  • Step 3: Commit
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:

// 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:
// 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
go build ./... && go test ./internal/skills/retrospective/... ./internal/skills/trainer/... -v

Expected: clean build, all tests pass.

  • Step 4: Commit
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
go test ./... -v 2>&1 | tail -30

Expected: all packages pass, no failures.

  • Step 2: Run linter
task check

Expected: clean.

  • Step 3: Push to trigger CD
git push
  • Step 4: Watch CD pipeline
# 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
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
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