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>
1074 lines
30 KiB
Markdown
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`
|