Add todo mode and replace the plan with TODO.md

This commit is contained in:
Ian Gulliver
2026-10-15 07:23:17 -07:00
parent 60a1d221f4
commit 1f44bc6d91
25 changed files with 1259 additions and 637 deletions
+20 -7
View File
@@ -3,9 +3,12 @@ package agents
import (
"context"
"encoding/json"
"fmt"
"regexp"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/claude"
"g.fc.run/misc/byre/internal/todo"
)
const guidance = `You are running inside byre. The human reads your work in byre's web UI, not a terminal.
@@ -55,11 +58,21 @@ func (m *Manager) bounceProseQuestion(l *live) claude.HookFunc {
}
}
func remindGuidance(ctx context.Context, input json.RawMessage) (map[string]any, error) {
return map[string]any{
"hookSpecificOutput": map[string]any{
"hookEventName": "UserPromptSubmit",
"additionalContext": guidance,
},
}, nil
func taskGuidance(task api.Task) string {
if task.TodoItem == nil {
return guidance
}
return guidance + fmt.Sprintf("\n- This task was started from the item %q in %s at the repo root. When the work is done, remove that item from %s in this worktree, so the removal lands in the same merge as the work.",
*task.TodoItem, todo.File, todo.File)
}
func remindGuidance(text string) claude.HookFunc {
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
return map[string]any{
"hookSpecificOutput": map[string]any{
"hookEventName": "UserPromptSubmit",
"additionalContext": text,
},
}, nil
}
}
+3 -2
View File
@@ -559,15 +559,16 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
projectID: task.ProjectId,
}
g := taskGuidance(task)
opts := claude.Options{
Cwd: task.WorktreePath,
Model: sess.Model,
GitName: creator.GitName,
GitEmail: creator.GitEmail,
Guidance: guidance,
Guidance: g,
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l)}}},
Hooks: map[string][]claude.HookMatcher{
"UserPromptSubmit": {{Func: remindGuidance}},
"UserPromptSubmit": {{Func: remindGuidance(g)}},
"Stop": {{Func: m.bounceProseQuestion(l)}},
"PostToolUse": {{Func: m.snapshotAfterTool(l)}},
},
+63
View File
@@ -2,6 +2,7 @@ package agents_test
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
@@ -873,6 +874,68 @@ func TestCreateTaskTool(t *testing.T) {
}
}
func TestStartTodo(t *testing.T) {
t.Parallel()
e := newEnv(t)
m := e.manager()
writeTestFile(t, filepath.Join(e.repo, "TODO.md"), "# Todo\n\n- Create hello.txt\n It holds the single line `hi`.\n- Leave this one alone\n")
gitOut(t, e.repo, "add", "TODO.md")
gitOut(t, e.repo, "commit", "--quiet", "--message", "add todo")
_, err := m.StartTodo(e.ctx, e.project, e.user, "No such item", testModel)
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("start unknown item: %v", err)
}
task, err := m.StartTodo(e.ctx, e.project, e.user, "Create hello.txt", testModel)
if err != nil {
t.Fatalf("start todo: %v", err)
}
if task.TodoItem == nil || *task.TodoItem != "Create hello.txt" || task.Title != "Create hello.txt" {
t.Fatalf("todo task %+v", task)
}
_, err = m.StartTodo(e.ctx, e.project, e.user, "Create hello.txt", testModel)
if !errors.Is(err, agents.ErrConflict) {
t.Fatalf("second start of the same item: %v", err)
}
events := e.waitFor("todo turn end", func(ev api.Event) bool {
return ev.Kind == "turn.ended" && ev.TaskId != nil && *ev.TaskId == task.Id
})
for _, ev := range events {
if ev.Kind == "user.message" && ev.TaskId != nil && *ev.TaskId == task.Id {
text, _ := ev.Payload["text"].(string)
if !strings.Contains(text, "- Create hello.txt\n It holds the single line `hi`.") || !strings.Contains(text, "remove this item from TODO.md") {
t.Fatalf("first prompt %q", text)
}
}
}
stored, err := e.store.GetTask(e.ctx, task.Id)
if err != nil || stored.TodoItem == nil || *stored.TodoItem != "Create hello.txt" {
t.Fatalf("stored task %+v %v", stored, err)
}
todoAfter, err := os.ReadFile(filepath.Join(task.WorktreePath, "TODO.md"))
if err != nil {
t.Fatalf("read worktree TODO.md: %v", err)
}
t.Logf("TODO.md after the turn:\n%s", todoAfter)
if strings.Contains(string(todoAfter), "Create hello.txt") || !strings.Contains(string(todoAfter), "Leave this one alone") {
t.Fatalf("agent didn't remove exactly its item from TODO.md")
}
}
func writeTestFile(t *testing.T, path, content string) {
t.Helper()
err := os.WriteFile(path, []byte(content), 0o644)
if err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func TestQuestionReachesInbox(t *testing.T) {
t.Parallel()
e := newEnv(t)
+50
View File
@@ -13,9 +13,58 @@ import (
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/store"
"g.fc.run/misc/byre/internal/todo"
)
func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, createdBy, base string) (api.Task, error) {
return m.createTask(ctx, project, title, createdBy, base, nil)
}
func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator api.User, title, model string) (api.Task, error) {
if creator.GitName == "" || creator.GitEmail == "" {
return api.Task{}, ErrNoGitIdentity
}
src, found, err := git.ReadFile(project.RepoPath, project.DefaultBranch, todo.File)
if err != nil {
return api.Task{}, err
}
if !found {
return api.Task{}, fmt.Errorf("%w: %s has no %s", store.ErrNotFound, project.DefaultBranch, todo.File)
}
item, found := todo.Find(todo.Parse(src), title)
if !found {
return api.Task{}, fmt.Errorf("%w: %s on %s has no item %q", store.ErrNotFound, todo.File, project.DefaultBranch, title)
}
tasks, err := m.store.ListTasks(ctx, project.Id)
if err != nil {
return api.Task{}, err
}
for _, t := range tasks {
if t.Status == api.TaskStatusActive && t.TodoItem != nil && *t.TodoItem == title {
return api.Task{}, fmt.Errorf("%w: task %s is already working on %q", ErrConflict, t.Id, title)
}
}
task, err := m.createTask(ctx, project, item.Title, creator.Login, project.DefaultBranch, &item.Title)
if err != nil {
return api.Task{}, err
}
_, err = m.create(ctx, task, creator, todoPrompt(item), model, creator.Login, nil)
if err != nil {
return api.Task{}, fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err)
}
return task, nil
}
func todoPrompt(item api.TodoItem) string {
return fmt.Sprintf("Work on this item from %[1]s at the repo root:\n\n%[2]s\n\nWhen the work is done, remove this item from %[1]s in this worktree, so the removal lands in the same merge as the work.",
todo.File, todo.Markdown(item))
}
func (m *Manager) createTask(ctx context.Context, project api.Project, title, createdBy, base string, todoItem *string) (api.Task, error) {
id := store.NewID()
t := api.Task{
Id: id,
@@ -27,6 +76,7 @@ func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, cr
Status: api.TaskStatusActive,
CreatedBy: createdBy,
CreatedAt: time.Now().UTC(),
TodoItem: todoItem,
}
var err error