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

1074 lines
30 KiB
Markdown

# 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`