plan.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package cli
  6
  7import (
  8	"context"
  9	"errors"
 10	"sort"
 11	"strings"
 12
 13	"git.secluded.site/np/internal/goal"
 14	"git.secluded.site/np/internal/i18n"
 15	"git.secluded.site/np/internal/task"
 16)
 17
 18var statusIcons = map[task.Status]string{
 19	task.StatusPending:    "☐",
 20	task.StatusInProgress: "⟳",
 21	task.StatusCompleted:  "☑",
 22	task.StatusFailed:     "☒",
 23	task.StatusCancelled:  "⊗",
 24}
 25
 26var legendBaseOrder = []task.Status{
 27	task.StatusPending,
 28	task.StatusInProgress,
 29	task.StatusCompleted,
 30}
 31
 32var legendOptionalOrder = []task.Status{
 33	task.StatusFailed,
 34	task.StatusCancelled,
 35}
 36
 37// PlanState captures goal/tasks for rendering.
 38type PlanState struct {
 39	Goal  *goal.Document
 40	Tasks []task.Task
 41}
 42
 43// BuildPlanState aggregates goal and tasks for sid using env.
 44func BuildPlanState(ctx context.Context, env *Environment, sid string) (PlanState, error) {
 45	if env == nil {
 46		return PlanState{}, errors.New("cli: environment not initialised")
 47	}
 48
 49	tasks, err := env.LoadTasks(ctx, sid)
 50	if err != nil {
 51		return PlanState{}, err
 52	}
 53
 54	state := PlanState{
 55		Tasks: tasks,
 56	}
 57
 58	goalDoc, ok, err := env.LoadGoal(ctx, sid)
 59	if err != nil {
 60		return PlanState{}, err
 61	}
 62	if ok {
 63		state.Goal = &goalDoc
 64	}
 65
 66	return state, nil
 67}
 68
 69// RenderPlan produces the textual plan layout consumed by LLM agents.
 70func RenderPlan(state PlanState, localizer *i18n.Localizer) string {
 71	var b strings.Builder
 72
 73	if state.Goal != nil {
 74		b.WriteString(strings.TrimSpace(state.Goal.Title))
 75		b.WriteString("\n")
 76
 77		if desc := strings.TrimSpace(state.Goal.Description); desc != "" {
 78			b.WriteString("\n")
 79			writeIndentedBlock(&b, desc, "")
 80			b.WriteString("\n")
 81		} else {
 82			b.WriteString("\n")
 83		}
 84	} else {
 85		b.WriteString(localizer.T("plan.no_goal"))
 86		b.WriteString("\n\n")
 87	}
 88
 89	b.WriteString(renderTaskList(state.Tasks, localizer))
 90	return b.String()
 91}
 92
 93// RenderTasksOnly renders just the tasks without the goal header.
 94func RenderTasksOnly(tasks []task.Task, localizer *i18n.Localizer) string {
 95	return renderTaskList(tasks, localizer)
 96}
 97
 98func renderTaskList(tasks []task.Task, localizer *i18n.Localizer) string {
 99	var b strings.Builder
100
101	legend := buildLegend(tasks, localizer)
102	if legend != "" {
103		b.WriteString(localizer.T("plan.legend_label"))
104		b.WriteString(" ")
105		b.WriteString(legend)
106		b.WriteString("\n")
107	}
108
109	if len(tasks) == 0 {
110		b.WriteString(localizer.T("plan.no_tasks"))
111		b.WriteString("\n")
112		return b.String()
113	}
114
115	sorted := make([]task.Task, len(tasks))
116	copy(sorted, tasks)
117	sort.Slice(sorted, func(i, j int) bool {
118		if sorted[i].CreatedSeq != sorted[j].CreatedSeq {
119			return sorted[i].CreatedSeq < sorted[j].CreatedSeq
120		}
121		if !sorted[i].CreatedAt.Equal(sorted[j].CreatedAt) {
122			return sorted[i].CreatedAt.Before(sorted[j].CreatedAt)
123		}
124		return sorted[i].ID < sorted[j].ID
125	})
126
127	for _, t := range sorted {
128		icon := statusIcons[t.Status]
129		if icon == "" {
130			icon = "?"
131		}
132		b.WriteString(icon)
133		b.WriteString(" ")
134		b.WriteString(strings.TrimSpace(t.Title))
135		b.WriteString(" [")
136		b.WriteString(strings.TrimSpace(t.ID))
137		b.WriteString("]\n")
138
139		if desc := strings.TrimSpace(t.Description); desc != "" {
140			writeIndentedBlock(&b, desc, "  ")
141		}
142	}
143
144	return b.String()
145}
146
147func buildLegend(tasks []task.Task, localizer *i18n.Localizer) string {
148	present := map[task.Status]bool{}
149	for _, t := range tasks {
150		present[t.Status] = true
151	}
152
153	var parts []string
154	for _, status := range legendBaseOrder {
155		parts = append(parts, legendEntry(status, localizer))
156	}
157	for _, status := range legendOptionalOrder {
158		if present[status] {
159			parts = append(parts, legendEntry(status, localizer))
160		}
161	}
162	return strings.Join(parts, "  ")
163}
164
165func legendEntry(status task.Status, localizer *i18n.Localizer) string {
166	icon := statusIcons[status]
167	if icon == "" {
168		icon = "?"
169	}
170	return icon + " " + statusLabel(status, localizer)
171}
172
173func writeIndentedBlock(b *strings.Builder, text string, prefix string) {
174	lines := strings.Split(text, "\n")
175	for _, line := range lines {
176		b.WriteString(prefix)
177		b.WriteString(strings.TrimSpace(line))
178		b.WriteString("\n")
179	}
180}
181
182func statusLabel(status task.Status, localizer *i18n.Localizer) string {
183	key := "status." + status.String()
184	return localizer.T(key)
185}