8 Commits

Author SHA1 Message Date
Mathias Bergqvist
7d5289ac54 chore: bump version to v0.4.0
All checks were successful
cd / Build and deploy (push) Successful in 6s
CI / Lint / Test / Vet (push) Successful in 1m9s
CI / Mirror to GitHub (push) Successful in 3s
2026-04-22 13:38:23 +02:00
Mathias Bergqvist
3d8fc9dacd feat(skills): wire session.Append into retrospective and trainer 2026-04-22 13:37:43 +02:00
Mathias Bergqvist
f9f804cd49 feat(skills): wire session.Append and PrependHistory into tdd 2026-04-22 13:37:06 +02:00
Mathias Bergqvist
85f142ade0 feat(skills): wire session.Append and PrependHistory into spec 2026-04-22 13:36:35 +02:00
Mathias Bergqvist
0dfad02513 feat(skills): wire session.Append and PrependHistory into review and debug 2026-04-22 13:36:13 +02:00
Mathias Bergqvist
c44eb680b2 feat(exec): surface AttemptRecord slice on Result for session logging 2026-04-22 13:35:38 +02:00
Mathias Bergqvist
38ada998a2 feat(session): add AttemptsFrom converter for exec.AttemptRecord 2026-04-22 13:35:09 +02:00
Mathias Bergqvist
74547c2bdf feat(session): export PrependHistory for shared use across skills 2026-04-22 13:34:48 +02:00
12 changed files with 263 additions and 70 deletions

View File

@@ -101,7 +101,9 @@ func main() {
} }
attempts := make([]iexec.AttemptRecord, 0, len(chain)) attempts := make([]iexec.AttemptRecord, 0, len(chain))
orch := iexec.NewOrchestrator(chain, litellmExec.Run, claudeExec.Run, verifier, models.LlamaSwapURL(), &attempts) orch := iexec.NewOrchestrator(chain, litellmExec.Run, claudeExec.Run, verifier, models.LlamaSwapURL(), &attempts)
return orch.Run(ctx, req) result, err := orch.Run(ctx, req)
result.Attempts = attempts // attach orchestration metadata before returning
return result, err
} }
} }
@@ -164,7 +166,7 @@ func main() {
mux.Handle("/mcp", srv) mux.Handle("/mcp", srv)
addr := ":" + cfg.Port addr := ":" + cfg.Port
logger.Info("supervisor starting", "addr", addr, "version", "v0.3.1") logger.Info("supervisor starting", "addr", addr, "version", "v0.4.0")
if err := http.ListenAndServe(addr, mux); err != nil { if err := http.ListenAndServe(addr, mux); err != nil {
logger.Error("server stopped", "err", err) logger.Error("server stopped", "err", err)
os.Exit(1) os.Exit(1)

View File

@@ -17,6 +17,7 @@ type Result struct {
Verified bool `json:"verified"` // based on exit code, never self-report Verified bool `json:"verified"` // based on exit code, never self-report
ModelUsed string `json:"model_used"` // model name or "self" ModelUsed string `json:"model_used"` // model name or "self"
Message string `json:"message"` // one sentence summary Message string `json:"message"` // one sentence summary
Attempts []AttemptRecord `json:"attempts,omitempty"` // populated by orchestrator, not Claude
} }
var validStatuses = map[string]bool{"pass": true, "fail": true, "error": true} var validStatuses = map[string]bool{"pass": true, "fail": true, "error": true}

View File

@@ -0,0 +1,26 @@
// 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
}

View File

@@ -0,0 +1,37 @@
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)
}

View File

@@ -36,3 +36,21 @@ func FormatHistory(entries []Entry, excludePhase string) string {
} }
return b.String() return b.String()
} }
// 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
}

View File

@@ -2,11 +2,13 @@
package session_test package session_test
import ( import (
"strings"
"testing" "testing"
"time" "time"
"github.com/mathiasbq/supervisor/internal/session" "github.com/mathiasbq/supervisor/internal/session"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestFormatHistoryEmpty(t *testing.T) { func TestFormatHistoryEmpty(t *testing.T) {
@@ -39,3 +41,45 @@ func TestFormatHistoryExcludesCurrentPhase(t *testing.T) {
assert.Contains(t, result, "red done") assert.Contains(t, result, "red done")
assert.NotContains(t, result, "green done") assert.NotContains(t, result, "green done")
} }
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.True(t, strings.HasSuffix(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")
}

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"time"
iexec "github.com/mathiasbq/supervisor/internal/exec" iexec "github.com/mathiasbq/supervisor/internal/exec"
"github.com/mathiasbq/supervisor/internal/session" "github.com/mathiasbq/supervisor/internal/session"
@@ -43,11 +44,12 @@ func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (
"phase: debug\nproject_root: %s\nerror: %s\ncontext: %s\nmodel: %s", "phase: debug\nproject_root: %s\nerror: %s\ncontext: %s\nmodel: %s",
a.ProjectRoot, a.Error, a.Context, model, a.ProjectRoot, a.Error, a.Context, model,
) )
task = s.prependHistory(a.SessionID, "debug", task) task = session.PrependHistory(s.cfg.SessionsDir, a.SessionID, "debug", task)
if s.cfg.ExecutorFn == nil { if s.cfg.ExecutorFn == nil {
return nil, fmt.Errorf("no executor configured") return nil, fmt.Errorf("no executor configured")
} }
t0 := time.Now()
result, err := s.cfg.ExecutorFn(ctx, iexec.Request{ result, err := s.cfg.ExecutorFn(ctx, iexec.Request{
SkillPrompt: s.cfg.SkillPrompt, SkillPrompt: s.cfg.SkillPrompt,
TaskPrompt: task, TaskPrompt: task,
@@ -57,24 +59,25 @@ func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (
if err != nil { if err != nil {
return nil, err 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) b, err := json.Marshal(result)
if err != nil { if err != nil {
return nil, fmt.Errorf("marshal result: %w", err) return nil, fmt.Errorf("marshal result: %w", err)
} }
return b, nil return b, nil
} }
func (s *Skill) prependHistory(sessionID, currentPhase, task string) string {
if sessionID == "" || s.cfg.SessionsDir == "" {
return task
}
entries, err := session.Read(s.cfg.SessionsDir, sessionID)
if err != nil || len(entries) == 0 {
return task
}
history := session.FormatHistory(entries, currentPhase)
if history == "" {
return task
}
return history + "\n---\n\n" + task
}

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"time"
iexec "github.com/mathiasbq/supervisor/internal/exec" iexec "github.com/mathiasbq/supervisor/internal/exec"
"github.com/mathiasbq/supervisor/internal/session" "github.com/mathiasbq/supervisor/internal/session"
@@ -52,6 +53,7 @@ func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (
if s.cfg.ExecutorFn == nil { if s.cfg.ExecutorFn == nil {
return nil, fmt.Errorf("no executor configured") return nil, fmt.Errorf("no executor configured")
} }
t0 := time.Now()
result, err := s.cfg.ExecutorFn(ctx, iexec.Request{ result, err := s.cfg.ExecutorFn(ctx, iexec.Request{
SkillPrompt: s.cfg.SkillPrompt, SkillPrompt: s.cfg.SkillPrompt,
TaskPrompt: taskPrompt, TaskPrompt: taskPrompt,
@@ -62,6 +64,18 @@ func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (
return nil, fmt.Errorf("retrospective worker: %w", err) 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) b, err := json.Marshal(result)
if err != nil { if err != nil {
return nil, fmt.Errorf("marshal result: %w", err) return nil, fmt.Errorf("marshal result: %w", err)

View File

@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "strings"
"time"
iexec "github.com/mathiasbq/supervisor/internal/exec" iexec "github.com/mathiasbq/supervisor/internal/exec"
"github.com/mathiasbq/supervisor/internal/session" "github.com/mathiasbq/supervisor/internal/session"
@@ -44,11 +45,12 @@ func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (
"phase: review\nproject_root: %s\nfiles: %s\ncontext: %s\nmodel: %s", "phase: review\nproject_root: %s\nfiles: %s\ncontext: %s\nmodel: %s",
a.ProjectRoot, strings.Join(a.Files, ", "), a.Context, model, a.ProjectRoot, strings.Join(a.Files, ", "), a.Context, model,
) )
task = s.prependHistory(a.SessionID, "review", task) task = session.PrependHistory(s.cfg.SessionsDir, a.SessionID, "review", task)
if s.cfg.ExecutorFn == nil { if s.cfg.ExecutorFn == nil {
return nil, fmt.Errorf("no executor configured") return nil, fmt.Errorf("no executor configured")
} }
t0 := time.Now()
result, err := s.cfg.ExecutorFn(ctx, iexec.Request{ result, err := s.cfg.ExecutorFn(ctx, iexec.Request{
SkillPrompt: s.cfg.SkillPrompt, SkillPrompt: s.cfg.SkillPrompt,
TaskPrompt: task, TaskPrompt: task,
@@ -58,24 +60,26 @@ func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (
if err != nil { if err != nil {
return nil, err 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) b, err := json.Marshal(result)
if err != nil { if err != nil {
return nil, fmt.Errorf("marshal result: %w", err) return nil, fmt.Errorf("marshal result: %w", err)
} }
return b, nil return b, nil
} }
func (s *Skill) prependHistory(sessionID, currentPhase, task string) string {
if sessionID == "" || s.cfg.SessionsDir == "" {
return task
}
entries, err := session.Read(s.cfg.SessionsDir, sessionID)
if err != nil || len(entries) == 0 {
return task
}
history := session.FormatHistory(entries, currentPhase)
if history == "" {
return task
}
return history + "\n---\n\n" + task
}

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"time"
iexec "github.com/mathiasbq/supervisor/internal/exec" iexec "github.com/mathiasbq/supervisor/internal/exec"
"github.com/mathiasbq/supervisor/internal/session" "github.com/mathiasbq/supervisor/internal/session"
@@ -48,11 +49,12 @@ func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (
"phase: spec\nproject_root: %s\nrequirements: %s\noutput_path: %s\ncontext: %s\nmodel: %s", "phase: spec\nproject_root: %s\nrequirements: %s\noutput_path: %s\ncontext: %s\nmodel: %s",
a.ProjectRoot, a.Requirements, outputPath, a.Context, model, a.ProjectRoot, a.Requirements, outputPath, a.Context, model,
) )
task = s.prependHistory(a.SessionID, "spec", task) task = session.PrependHistory(s.cfg.SessionsDir, a.SessionID, "spec", task)
if s.cfg.ExecutorFn == nil { if s.cfg.ExecutorFn == nil {
return nil, fmt.Errorf("no executor configured") return nil, fmt.Errorf("no executor configured")
} }
t0 := time.Now()
result, err := s.cfg.ExecutorFn(ctx, iexec.Request{ result, err := s.cfg.ExecutorFn(ctx, iexec.Request{
SkillPrompt: s.cfg.SkillPrompt, SkillPrompt: s.cfg.SkillPrompt,
TaskPrompt: task, TaskPrompt: task,
@@ -62,24 +64,26 @@ func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (
if err != nil { if err != nil {
return nil, err 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) b, err := json.Marshal(result)
if err != nil { if err != nil {
return nil, fmt.Errorf("marshal result: %w", err) return nil, fmt.Errorf("marshal result: %w", err)
} }
return b, nil return b, nil
} }
func (s *Skill) prependHistory(sessionID, currentPhase, task string) string {
if sessionID == "" || s.cfg.SessionsDir == "" {
return task
}
entries, err := session.Read(s.cfg.SessionsDir, sessionID)
if err != nil || len(entries) == 0 {
return task
}
history := session.FormatHistory(entries, currentPhase)
if history == "" {
return task
}
return history + "\n---\n\n" + task
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"time"
iexec "github.com/mathiasbq/supervisor/internal/exec" iexec "github.com/mathiasbq/supervisor/internal/exec"
"github.com/mathiasbq/supervisor/internal/session" "github.com/mathiasbq/supervisor/internal/session"
@@ -70,8 +71,15 @@ func (s *Skill) handleGreen(ctx context.Context, raw json.RawMessage) (json.RawM
"phase: green\nproject_root: %s\ntest_path: %s\nmodel: %s\ntest_cmd: %s", "phase: green\nproject_root: %s\ntest_path: %s\nmodel: %s\ntest_cmd: %s",
args.ProjectRoot, args.TestPath, s.resolveModel(args.Model), args.TestCmd, args.ProjectRoot, args.TestPath, s.resolveModel(args.Model), args.TestCmd,
) )
task = s.prependHistory(args.SessionID, "green", task) task = session.PrependHistory(s.cfg.SessionsDir, args.SessionID, "green", task)
return s.execute(ctx, 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 { type refactorArgs struct {
@@ -101,23 +109,15 @@ func (s *Skill) handleRefactor(ctx context.Context, raw json.RawMessage) (json.R
"phase: refactor\nproject_root: %s\ntest_path: %s\nimpl_path: %s\nmodel: %s\ntest_cmd: %s", "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, args.ProjectRoot, args.TestPath, args.ImplPath, s.resolveModel(args.Model), args.TestCmd,
) )
task = s.prependHistory(args.SessionID, "refactor", task) task = session.PrependHistory(s.cfg.SessionsDir, args.SessionID, "refactor", task)
return s.execute(ctx, task)
}
func (s *Skill) prependHistory(sessionID, currentPhase, task string) string { t0 := time.Now()
if sessionID == "" || s.cfg.SessionsDir == "" { result, err := s.execute(ctx, task)
return task if err != nil {
return nil, err
} }
entries, err := session.Read(s.cfg.SessionsDir, sessionID) s.logAttempt(args.SessionID, args.ProjectRoot, "tdd", "refactor", t0, result)
if err != nil || len(entries) == 0 { return result, nil
return task
}
history := session.FormatHistory(entries, currentPhase)
if history == "" {
return task
}
return history + "\n---\n\n" + task
} }
func (s *Skill) resolveModel(override string) string { func (s *Skill) resolveModel(override string) string {
@@ -127,6 +127,7 @@ func (s *Skill) resolveModel(override string) string {
return s.cfg.DefaultModel return s.cfg.DefaultModel
} }
// execute calls ExecutorFn and returns the marshaled result.
func (s *Skill) execute(ctx context.Context, task string) (json.RawMessage, error) { func (s *Skill) execute(ctx context.Context, task string) (json.RawMessage, error) {
if s.cfg.ExecutorFn == nil { if s.cfg.ExecutorFn == nil {
return nil, fmt.Errorf("no executor configured") return nil, fmt.Errorf("no executor configured")
@@ -141,3 +142,28 @@ func (s *Skill) execute(ctx context.Context, task string) (json.RawMessage, erro
} }
return json.Marshal(result) 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,
})
}

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"time"
iexec "github.com/mathiasbq/supervisor/internal/exec" iexec "github.com/mathiasbq/supervisor/internal/exec"
"github.com/mathiasbq/supervisor/internal/session" "github.com/mathiasbq/supervisor/internal/session"
@@ -58,6 +59,7 @@ func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (
} }
// ── Step 2: Writer agent (receives reader candidates) ──────────────────── // ── Step 2: Writer agent (receives reader candidates) ────────────────────
t0 := time.Now()
writerTask := fmt.Sprintf( writerTask := fmt.Sprintf(
"role: writer\nsession_id: %s\nbrain_dir: %s\n\nreader_summary: %s\nreader_candidates:\n%s", "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, a.SessionID, s.cfg.BrainDir, readerResult.Message, readerResult.RunnerOutput,
@@ -72,6 +74,18 @@ func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (
return nil, fmt.Errorf("writer agent: %w", err) 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) b, err := json.Marshal(writerResult)
if err != nil { if err != nil {
return nil, fmt.Errorf("marshal result: %w", err) return nil, fmt.Errorf("marshal result: %w", err)