flat planning repo built-in

This commit is contained in:
Levi Neely 2026-04-13 11:40:52 +02:00
parent c320693ab6
commit e2e7f594ca
2 changed files with 171 additions and 53 deletions

View File

@ -3,71 +3,73 @@ package p9
import (
"context"
"fmt"
"os"
"regexp"
"strings"
"ollie/pkg/agent"
"ollie/pkg/tools"
)
// queuePlanBackend implements tools.PlanBackend by enqueuing each step as a
// prompt into the session's queue via agent.Core.Queue. It is used as the
// reasoning_plan fallback when no task_create MCP tool is available.
// filePlanBackend implements tools.PlanBackend by writing a markdown checklist
// to the planning directory. The file persists across crashes so another
// session can pick up where the previous one left off.
//
// Steps are enqueued in topological order (blockers before dependents). The
// goal description is prepended to the first step for context. Placeholder IDs
// ("q1", "q2", …) are returned so the agent can refer to steps by name.
type queuePlanBackend struct {
core agent.Core
// Filename: {sid}--{goal-slugified}__wip.md
type filePlanBackend struct {
dir string // planning directory path
sid string // session ID (used in filename)
}
// CreatePlan enqueues each plan step as a prompt. Steps with After dependencies
// are sorted so blockers are enqueued before the steps that depend on them.
// The returned IDs are positional placeholders ("q1", "q2", …).
func (b *queuePlanBackend) CreatePlan(_ context.Context, goal string, steps []tools.PlanStep) ([]string, string, error) {
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
func slugify(s string) string {
s = strings.ToLower(s)
s = slugRe.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if len(s) > 60 {
s = s[:60]
if i := strings.LastIndex(s, "-"); i > 20 {
s = s[:i]
}
}
return s
}
// CreatePlan writes a markdown checklist to disk and returns step IDs.
func (b *filePlanBackend) CreatePlan(_ context.Context, goal string, steps []tools.PlanStep) ([]string, string, error) {
order, err := topoSort(steps)
if err != nil {
return nil, "", fmt.Errorf("queue plan: %w", err)
return nil, "", fmt.Errorf("file plan: %w", err)
}
slug := slugify(goal)
filename := b.sid + "--" + slug + "__wip.md"
var md strings.Builder
fmt.Fprintf(&md, "# %s\n\n", goal)
ids := make([]string, len(steps))
for i := range steps {
ids[i] = fmt.Sprintf("q%d", i+1)
for _, idx := range order {
step := steps[idx]
ids[idx] = fmt.Sprintf("s%d", idx+1)
fmt.Fprintf(&md, "- [ ] %s\n", step.Title)
if step.Body != "" {
for _, line := range strings.Split(step.Body, "\n") {
fmt.Fprintf(&md, " %s\n", line)
}
}
}
for pos, idx := range order {
step := steps[idx]
var sb strings.Builder
if pos == 0 {
sb.WriteString("Goal: ")
sb.WriteString(goal)
sb.WriteString("\n\n")
}
sb.WriteString("Step ")
sb.WriteString(ids[idx])
sb.WriteString(": ")
sb.WriteString(step.Title)
if step.Body != "" {
sb.WriteString("\n")
sb.WriteString(step.Body)
}
if len(step.After) > 0 {
sb.WriteString("\n(after: ")
for i, dep := range step.After {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(ids[dep])
}
sb.WriteString(")")
}
b.core.Queue(sb.String())
if err := os.MkdirAll(b.dir, 0755); err != nil {
return nil, "", fmt.Errorf("file plan: %w", err)
}
path := b.dir + "/" + filename
if err := os.WriteFile(path, []byte(md.String()), 0644); err != nil {
return nil, "", fmt.Errorf("file plan: %w", err)
}
msg := fmt.Sprintf(
"Plan queued (%d steps). Stop here — do not execute. "+
"The queue will re-enter you with each step in order.",
len(steps),
"Plan saved to pl/%s (%d steps). Work through the checklist — mark items [x] as you complete them.",
filename, len(steps),
)
return ids, msg, nil
}

View File

@ -95,6 +95,7 @@ type Server struct {
agentsDir string
sessionsDir string
promptsDir string
planDir string // planning directory for /pl
}
// New creates a new Server.
@ -104,9 +105,19 @@ func New() *Server {
agentsDir: agent.DefaultAgentsDir(),
sessionsDir: agent.DefaultSessionsDir(),
promptsDir: agent.DefaultPromptsDir(),
planDir: defaultPlanDir(),
}
}
// defaultPlanDir returns the planning directory from OLLIE_PLAN_PATH or the default.
func defaultPlanDir() string {
if p := os.Getenv("OLLIE_PLAN_PATH"); p != "" {
return p
}
home, _ := os.UserHomeDir()
return home + "/.config/ollie/planning"
}
// Serve handles a single 9P connection.
func (s *Server) Serve(conn net.Conn) {
defer conn.Close()
@ -148,8 +159,7 @@ func (s *Server) handle(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
case plan9.Tstat:
return s.stat(cs, fc)
case plan9.Twstat:
// Accept and ignore wstat; 9pfuse sends Twstat for O_TRUNC on open files.
return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag}
return s.wstat(cs, fc)
case plan9.Tflush:
// No blocking reads to cancel; always succeed.
return &plan9.Fcall{Type: plan9.Rflush, Tag: fc.Tag}
@ -190,6 +200,8 @@ func (s *Server) pathType(path string) string {
return "dir"
case len(parts) == 1 && parts[0] == "p":
return "dir"
case len(parts) == 1 && parts[0] == "pl":
return "dir"
case len(parts) == 1 && parts[0] == "sk":
return "dir"
case len(parts) == 1 && parts[0] == "t":
@ -198,6 +210,10 @@ func (s *Server) pathType(path string) string {
if _, err := os.Stat(s.promptsDir + "/" + parts[1]); err == nil {
return "file"
}
case len(parts) == 2 && parts[0] == "pl":
if _, err := os.Stat(s.planDir + "/" + parts[1]); err == nil {
return "file"
}
case len(parts) == 2 && parts[0] == "sk":
name := strings.TrimSuffix(parts[1], ".md")
if _, err := skills.Read(name); err == nil {
@ -337,7 +353,7 @@ func (s *Server) create(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
if !ok {
return errFcall(fc, "bad fid")
}
if f.path != "/p" && f.path != "/sk" && f.path != "/t" {
if f.path != "/p" && f.path != "/pl" && f.path != "/sk" && f.path != "/t" {
return errFcall(fc, "create not supported")
}
@ -351,6 +367,11 @@ func (s *Server) create(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
if err := os.WriteFile(s.promptsDir+"/"+fc.Name, nil, 0644); err != nil {
return errFcall(fc, err.Error())
}
case "/pl":
os.MkdirAll(s.planDir, 0755) //nolint:errcheck
if err := os.WriteFile(s.planDir+"/"+fc.Name, nil, 0644); err != nil {
return errFcall(fc, err.Error())
}
case "/t":
dir := execute.ToolsPath()
os.MkdirAll(dir, 0755) //nolint:errcheck
@ -419,6 +440,24 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
}
// Plan files are served from disk.
if strings.HasPrefix(path, "/pl/") {
content, err := os.ReadFile(s.planDir + "/" + pathBase(path))
if err != nil {
return errFcall(fc, err.Error())
}
var data []byte
off := int(fc.Offset)
if off < len(content) {
end := off + int(fc.Count)
if end > len(content) {
end = len(content)
}
data = content[off:end]
}
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
}
// Skill files are served via pkg/skills (reads from disk each time).
if strings.HasPrefix(path, "/sk/") {
name := strings.TrimSuffix(pathBase(path), ".md")
@ -630,6 +669,43 @@ func (s *Server) stat(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
return &plan9.Fcall{Type: plan9.Rstat, Tag: fc.Tag, Stat: stat}
}
func (s *Server) wstat(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
cs.mu.Lock()
f, ok := cs.fids[fc.Fid]
cs.mu.Unlock()
if !ok {
return errFcall(fc, "bad fid")
}
// Only /pl files support rename via wstat.
if !strings.HasPrefix(f.path, "/pl/") {
return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag}
}
// Parse the new Dir from the stat bytes.
newDir, err := plan9.UnmarshalDir(fc.Stat)
if err != nil {
// Some clients send minimal wstat (e.g. truncate); accept silently.
return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag}
}
// If the name changed, rename the file on disk.
oldName := pathBase(f.path)
if newDir.Name != "" && newDir.Name != oldName {
oldPath := s.planDir + "/" + oldName
newPath := s.planDir + "/" + newDir.Name
if err := os.Rename(oldPath, newPath); err != nil {
return errFcall(fc, err.Error())
}
cs.mu.Lock()
f.path = "/pl/" + newDir.Name
f.qid.Path = qidPath(f.path)
cs.mu.Unlock()
}
return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag}
}
func (s *Server) clunk(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
cs.mu.Lock()
f, ok := cs.fids[fc.Fid]
@ -666,6 +742,15 @@ func (s *Server) handleWrite(path, input string) {
return
}
// Plan file writes go directly to disk.
if strings.HasPrefix(path, "/pl/") {
os.MkdirAll(s.planDir, 0755) //nolint:errcheck
if err := os.WriteFile(s.planDir+"/"+pathBase(path), []byte(input), 0644); err != nil {
fmt.Fprintf(os.Stderr, "olliesrv: write plan: %v\n", err)
}
return
}
// Skill file writes: create skill directory and write SKILL.md.
if strings.HasPrefix(path, "/sk/") {
name := strings.TrimSuffix(pathBase(path), ".md")
@ -855,9 +940,17 @@ func (s *Server) createSession(args []string) error {
})
sessID := agent.NewSessionID()
fallback := &queuePlanBackend{}
fallback := &filePlanBackend{dir: s.planDir, sid: sessID}
env := agent.BuildAgentEnv(cfg, newDisp(), workdir, agent.WithFallbackPlanBackend(fallback))
// Inject per-session env vars into the execute server.
if srv, ok := env.Dispatcher().GetServer("execute"); ok {
if es, ok := srv.(tools.EnvSetter); ok {
es.SetEnv("OLLIE_SESSION_ID", sessID)
es.SetEnv("OLLIE_PLAN_PATH", s.planDir)
}
}
ctx, cancel := context.WithCancel(context.Background())
core := agent.NewAgentCore(agent.AgentCoreConfig{
Backend: be,
@ -869,7 +962,6 @@ func (s *Server) createSession(args []string) error {
Env: env,
NewDispatcher: newDisp,
})
fallback.core = core
sess := &session{
id: sessID,
@ -951,6 +1043,7 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
if path == "/" {
dirs = append(dirs, makeDir("ctl", "/ctl", false, 0200))
dirs = append(dirs, makeDir("p", "/p", true, plan9.DMDIR|0755))
dirs = append(dirs, makeDir("pl", "/pl", true, plan9.DMDIR|0755))
dirs = append(dirs, makeDir("s", "/s", true, plan9.DMDIR|0555))
dirs = append(dirs, makeDir("sk", "/sk", true, plan9.DMDIR|0555))
dirs = append(dirs, makeDir("t", "/t", true, plan9.DMDIR|0777))
@ -961,6 +1054,18 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
dirs = append(dirs, makeDir(e.Name(), "/p/"+e.Name(), false, 0666))
}
}
} else if path == "/pl" {
entries, _ := os.ReadDir(s.planDir)
for _, e := range entries {
if !e.IsDir() {
d := makeDir(e.Name(), "/pl/"+e.Name(), false, 0666)
if info, err := e.Info(); err == nil {
d.Atime = uint32(info.ModTime().Unix())
d.Mtime = uint32(info.ModTime().Unix())
}
dirs = append(dirs, d)
}
}
} else if path == "/sk" {
for _, m := range skills.List() {
name := m.Name + ".md"
@ -1057,7 +1162,7 @@ func (s *Server) makeStat(path string) plan9.Dir {
qid.Type = QTDir
if path == "/t" {
mode = plan9.DMDIR | 0777
} else if path == "/p" {
} else if path == "/p" || path == "/pl" {
mode = plan9.DMDIR | 0755
} else {
mode = plan9.DMDIR | 0555
@ -1073,6 +1178,8 @@ func (s *Server) makeStat(path string) plan9.Dir {
default:
if strings.HasPrefix(path, "/p/") {
mode = 0666
} else if strings.HasPrefix(path, "/pl/") {
mode = 0666
} else if strings.HasPrefix(path, "/sk/") {
mode = 0666
} else if strings.HasPrefix(path, "/t/") {
@ -1117,5 +1224,14 @@ func (s *Server) makeStat(path string) plan9.Dir {
}
}
// For plan files, report real size and timestamps from disk.
if strings.HasPrefix(path, "/pl/") {
if info, err := os.Stat(s.planDir + "/" + base); err == nil {
dir.Length = uint64(info.Size())
dir.Atime = uint32(info.ModTime().Unix())
dir.Mtime = uint32(info.ModTime().Unix())
}
}
return dir
}