v0.13.0 befd698

state.go RawHistoryFolder

1package main
2
3import (
4	"crypto/sha256"
5	"encoding/hex"
6	"encoding/json"
7	"errors"
8	"fmt"
9	"os"
10	"path/filepath"
11	"regexp"
12	"strconv"
13	"strings"
14	"sync"
15	"time"
16	"unicode"
17	"unicode/utf8"
18)
19
20const (
21	actionGetStatus       = "get_status"
22	actionSetSpec         = "set_spec"
23	actionReplacePlan     = "replace_plan"
24	actionAddPhase        = "add_phase"
25	actionUpdatePhase     = "update_phase"
26	actionRemovePhase     = "remove_phase"
27	actionAddTask         = "add_task"
28	actionUpdateTask      = "update_task"
29	actionRemoveTask      = "remove_task"
30	actionSetCurrentPhase = "set_current_phase"
31	actionSetTaskChecked  = "set_task_checked"
32	actionSetPhaseChecked = "set_phase_checked"
33	actionClear           = "clear"
34)
35
36var (
37	phaseIDPattern     = regexp.MustCompile(`^phase-(\d+)$`)
38	taskIDPattern      = regexp.MustCompile(`^task-(\d+)$`)
39	bracketedIDPattern = regexp.MustCompile(`\[([^\]]+)\]`)
40)
41
42var toolActions = []string{
43	actionGetStatus,
44	actionSetSpec,
45	actionReplacePlan,
46	actionAddPhase,
47	actionUpdatePhase,
48	actionRemovePhase,
49	actionAddTask,
50	actionUpdateTask,
51	actionRemoveTask,
52	actionSetCurrentPhase,
53	actionSetTaskChecked,
54	actionSetPhaseChecked,
55	actionClear,
56}
57
58var closedPlanBlockedActions = map[string]bool{
59	actionReplacePlan:     true,
60	actionAddPhase:        true,
61	actionUpdatePhase:     true,
62	actionRemovePhase:     true,
63	actionAddTask:         true,
64	actionUpdateTask:      true,
65	actionRemoveTask:      true,
66	actionSetCurrentPhase: true,
67	actionSetTaskChecked:  true,
68	actionSetPhaseChecked: true,
69}
70
71type PhaseTask struct {
72	ID      string `json:"id"`
73	Text    string `json:"text"`
74	Checked bool   `json:"checked"`
75}
76
77type Phase struct {
78	ID    string      `json:"id"`
79	Title string      `json:"title"`
80	Goal  string      `json:"goal,omitempty"`
81	Tasks []PhaseTask `json:"tasks"`
82}
83
84type PlanState struct {
85	Version         int     `json:"version"`
86	Spec            string  `json:"spec,omitempty"`
87	Phases          []Phase `json:"phases"`
88	CurrentPhaseID  string  `json:"currentPhaseId,omitempty"`
89	ClosedAt        *int64  `json:"closedAt,omitempty"`
90	ClosedSummary   string  `json:"closedSummary,omitempty"`
91	NextPhaseNumber int     `json:"nextPhaseNumber"`
92	NextTaskNumber  int     `json:"nextTaskNumber"`
93	UpdatedAt       int64   `json:"updatedAt"`
94}
95
96type TaskInput struct {
97	ID      *string `json:"id"`
98	Text    string  `json:"text"`
99	Checked *bool   `json:"checked"`
100}
101
102type PhaseInput struct {
103	ID    *string      `json:"id"`
104	Title string       `json:"title"`
105	Goal  *string      `json:"goal"`
106	Tasks *[]TaskInput `json:"tasks"`
107}
108
109type toolArgs struct {
110	Action     string        `json:"action"`
111	Spec       *string       `json:"spec"`
112	PhaseID    string        `json:"phaseId"`
113	PhaseTitle *string       `json:"phaseTitle"`
114	PhaseGoal  *string       `json:"phaseGoal"`
115	TaskID     string        `json:"taskId"`
116	TaskText   *string       `json:"taskText"`
117	Checked    *bool         `json:"checked"`
118	Phases     *[]PhaseInput `json:"phases"`
119}
120
121func nowMillis() int64 { return time.Now().UnixMilli() }
122
123func createEmptyState() PlanState {
124	return PlanState{
125		Version:         1,
126		Phases:          []Phase{},
127		NextPhaseNumber: 1,
128		NextTaskNumber:  1,
129		UpdatedAt:       nowMillis(),
130	}
131}
132
133func cloneState(state PlanState) PlanState {
134	cloned := state
135	cloned.Phases = make([]Phase, len(state.Phases))
136	for i, phase := range state.Phases {
137		cloned.Phases[i] = phase
138		cloned.Phases[i].Tasks = append([]PhaseTask(nil), phase.Tasks...)
139	}
140	return cloned
141}
142
143func normalizeOptionalText(value string) string {
144	return strings.TrimSpace(value)
145}
146
147// sanitizeDisplayText removes terminal escape sequences and non-printing
148// control characters at the rendering boundary. Persisted state is never
149// modified, so the original extension-supplied text remains recoverable.
150func sanitizeDisplayText(text string) string {
151	var out strings.Builder
152	for index := 0; index < len(text); {
153		if text[index] == 0x1b {
154			index++
155			if index >= len(text) {
156				break
157			}
158			switch text[index] {
159			case '[': // CSI sequence.
160				index++
161				for index < len(text) {
162					final := text[index]
163					index++
164					if final >= 0x40 && final <= 0x7e {
165						break
166					}
167				}
168			case ']': // OSC sequence, terminated by BEL or ST.
169				index++
170				for index < len(text) {
171					if text[index] == 0x07 {
172						index++
173						break
174					}
175					if text[index] == 0x1b && index+1 < len(text) && text[index+1] == '\\' {
176						index += 2
177						break
178					}
179					index++
180				}
181			default:
182				index++
183			}
184			continue
185		}
186		r, size := utf8.DecodeRuneInString(text[index:])
187		index += size
188		if r != '\n' && (unicode.IsControl(r) || r == 0x7f) {
189			continue
190		}
191		out.WriteRune(r)
192	}
193	return out.String()
194}
195
196func singleLine(text string) string {
197	return strings.Join(strings.Fields(text), " ")
198}
199
200func displaySingleLine(text string) string {
201	return singleLine(sanitizeDisplayText(text))
202}
203
204func truncatePlain(text string, maxLength int) string {
205	if maxLength < 1 {
206		return ""
207	}
208	runes := []rune(text)
209	if len(runes) <= maxLength {
210		return text
211	}
212	return strings.TrimRight(string(runes[:maxLength-1]), " \t\r\n") + "…"
213}
214
215func getPhaseProgress(phase Phase) (done, total int) {
216	total = len(phase.Tasks)
217	for _, task := range phase.Tasks {
218		if task.Checked {
219			done++
220		}
221	}
222	return done, total
223}
224
225func getPlanProgress(state PlanState) (done, total int) {
226	for _, phase := range state.Phases {
227		phaseDone, phaseTotal := getPhaseProgress(phase)
228		done += phaseDone
229		total += phaseTotal
230	}
231	return done, total
232}
233
234func isPhaseDone(phase Phase) bool {
235	done, total := getPhaseProgress(phase)
236	return total > 0 && done == total
237}
238
239func hasStoredPlan(state PlanState) bool {
240	return state.Spec != "" || len(state.Phases) > 0
241}
242
243func isPlanComplete(state PlanState) bool {
244	if len(state.Phases) == 0 {
245		return false
246	}
247	for _, phase := range state.Phases {
248		if !isPhaseDone(phase) {
249			return false
250		}
251	}
252	return true
253}
254
255func isPlanClosed(state PlanState) bool { return state.ClosedAt != nil }
256
257func closePlanIfComplete(state *PlanState) {
258	if !isPlanComplete(*state) {
259		return
260	}
261	done, total := getPlanProgress(*state)
262	if state.ClosedAt == nil {
263		closedAt := nowMillis()
264		state.ClosedAt = &closedAt
265	}
266	state.ClosedSummary = fmt.Sprintf("Completed %d/%d tasks across %d phase(s).", done, total, len(state.Phases))
267	state.CurrentPhaseID = ""
268}
269
270func getCurrentPhase(state PlanState) *Phase {
271	if state.CurrentPhaseID == "" {
272		return nil
273	}
274	for i := range state.Phases {
275		if state.Phases[i].ID == state.CurrentPhaseID {
276			return &state.Phases[i]
277		}
278	}
279	return nil
280}
281
282func getActivePhase(state PlanState) *Phase {
283	if current := getCurrentPhase(state); current != nil {
284		return current
285	}
286	for i := range state.Phases {
287		if !isPhaseDone(state.Phases[i]) {
288			return &state.Phases[i]
289		}
290	}
291	return nil
292}
293
294func getSuggestedCurrentPhaseID(state PlanState) string {
295	if state.CurrentPhaseID != "" {
296		for _, phase := range state.Phases {
297			if phase.ID == state.CurrentPhaseID {
298				return state.CurrentPhaseID
299			}
300		}
301	}
302	for _, phase := range state.Phases {
303		if !isPhaseDone(phase) {
304			return phase.ID
305		}
306	}
307	if len(state.Phases) > 0 {
308		return state.Phases[len(state.Phases)-1].ID
309	}
310	return ""
311}
312
313func extractHighestIDNumber(prefix string, ids []string) int {
314	highest := 0
315	pattern := taskIDPattern
316	if prefix == "phase" {
317		pattern = phaseIDPattern
318	}
319	for _, id := range ids {
320		match := pattern.FindStringSubmatch(id)
321		if len(match) != 2 {
322			continue
323		}
324		number, err := strconv.Atoi(match[1])
325		if err == nil && number > highest {
326			highest = number
327		}
328	}
329	return highest
330}
331
332func ensureState(state PlanState) PlanState {
333	normalized := cloneState(state)
334	normalized.Version = 1
335	normalized.Spec = normalizeOptionalText(normalized.Spec)
336	if normalized.Phases == nil {
337		normalized.Phases = []Phase{}
338	}
339	for i := range normalized.Phases {
340		normalized.Phases[i].Goal = optionalNormalized(normalized.Phases[i].Goal)
341		if normalized.Phases[i].Tasks == nil {
342			normalized.Phases[i].Tasks = []PhaseTask{}
343		}
344	}
345	normalized.ClosedSummary = optionalNormalized(normalized.ClosedSummary)
346	if isPlanComplete(normalized) {
347		closePlanIfComplete(&normalized)
348	} else {
349		normalized.ClosedAt = nil
350		normalized.ClosedSummary = ""
351	}
352
353	highestPhase := make([]string, 0, len(normalized.Phases))
354	highestTask := make([]string, 0)
355	for _, phase := range normalized.Phases {
356		highestPhase = append(highestPhase, phase.ID)
357		for _, task := range phase.Tasks {
358			highestTask = append(highestTask, task.ID)
359		}
360	}
361	if normalized.NextPhaseNumber < 1 {
362		normalized.NextPhaseNumber = 1
363	}
364	if normalized.NextTaskNumber < 1 {
365		normalized.NextTaskNumber = 1
366	}
367	normalized.NextPhaseNumber = maxInt(normalized.NextPhaseNumber, extractHighestIDNumber("phase", highestPhase)+1)
368	normalized.NextTaskNumber = maxInt(normalized.NextTaskNumber, extractHighestIDNumber("task", highestTask)+1)
369	if isPlanClosed(normalized) {
370		normalized.CurrentPhaseID = ""
371	} else {
372		normalized.CurrentPhaseID = getSuggestedCurrentPhaseID(normalized)
373	}
374	normalized.UpdatedAt = nowMillis()
375	return normalized
376}
377
378func optionalNormalized(value string) string {
379	return normalizeOptionalText(value)
380}
381
382func maxInt(a, b int) int {
383	if a > b {
384		return a
385	}
386	return b
387}
388
389func nextPhaseID(state *PlanState) string {
390	id := fmt.Sprintf("phase-%d", state.NextPhaseNumber)
391	state.NextPhaseNumber++
392	return id
393}
394
395func nextTaskID(state *PlanState) string {
396	id := fmt.Sprintf("task-%d", state.NextTaskNumber)
397	state.NextTaskNumber++
398	return id
399}
400
401func extractBracketedPhaseID(value string) string {
402	matches := bracketedIDPattern.FindAllStringSubmatch(value, -1)
403	candidate := ""
404	for _, match := range matches {
405		if len(match) == 2 {
406			if normalized := normalizeOptionalText(match[1]); normalized != "" {
407				candidate = normalized
408			}
409		}
410	}
411	return candidate
412}
413
414func findUniquePhaseByTitle(state PlanState, phaseTitle string) *Phase {
415	normalizedTitle := singleLine(phaseTitle)
416	var match *Phase
417	matches := 0
418	for i := range state.Phases {
419		if singleLine(state.Phases[i].Title) == normalizedTitle {
420			match = &state.Phases[i]
421			matches++
422		}
423	}
424	if matches == 1 {
425		return match
426	}
427	return nil
428}
429
430// findPhase returns a pointer into state.Phases, aliasing the caller's slice
431// backing array so mutations are applied to the caller's state copy.
432func findPhase(state PlanState, phaseID string) *Phase {
433	candidate := normalizeOptionalText(phaseID)
434	if candidate == "" {
435		return nil
436	}
437	for i := range state.Phases {
438		if state.Phases[i].ID == candidate {
439			return &state.Phases[i]
440		}
441	}
442	if bracketedID := extractBracketedPhaseID(candidate); bracketedID != "" {
443		for i := range state.Phases {
444			if state.Phases[i].ID == bracketedID {
445				return &state.Phases[i]
446			}
447		}
448	}
449	return findUniquePhaseByTitle(state, candidate)
450}
451
452func formatValidPhaseIDs(state PlanState) string {
453	if len(state.Phases) == 0 {
454		return "none"
455	}
456	parts := make([]string, 0, len(state.Phases))
457	for _, phase := range state.Phases {
458		parts = append(parts, fmt.Sprintf("%s (%s)", phase.ID, singleLine(phase.Title)))
459	}
460	return strings.Join(parts, ", ")
461}
462
463func phaseIDError(state PlanState, action, phaseID string) string {
464	candidate := normalizeOptionalText(phaseID)
465	validIDs := formatValidPhaseIDs(state)
466	if candidate == "" {
467		return fmt.Sprintf("phaseId is required for %s. Use a valid phase id. Valid phase ids: %s.", action, validIDs)
468	}
469	return fmt.Sprintf("phaseId %q was not found for %s. Use the raw id from brackets without brackets. Valid phase ids: %s.", singleLine(candidate), action, validIDs)
470}
471
472func formatValidTaskIDs(state PlanState) string {
473	parts := make([]string, 0)
474	for _, phase := range state.Phases {
475		for _, task := range phase.Tasks {
476			if !task.Checked {
477				parts = append(parts, fmt.Sprintf("%s (%s)", task.ID, displaySingleLine(phase.Title)))
478			}
479		}
480	}
481	if len(parts) == 0 {
482		return "none"
483	}
484	return strings.Join(parts, ", ")
485}
486
487func taskIDError(state PlanState, action, taskID string) string {
488	candidate := normalizeOptionalText(taskID)
489	validIDs := formatValidTaskIDs(state)
490	if candidate == "" {
491		return fmt.Sprintf("taskId is required for %s. Use a valid task id. Valid incomplete task ids: %s.", action, validIDs)
492	}
493	return fmt.Sprintf("taskId %q was not found for %s. Use the task id from brackets. Valid incomplete task ids: %s.", displaySingleLine(candidate), action, validIDs)
494}
495
496// findTask returns pointers into state.Phases and their Tasks, aliasing the
497// caller's slice backing arrays so mutations are applied to the caller's state.
498func findTask(state PlanState, taskID, phaseID string) (phase *Phase, task *PhaseTask) {
499	if taskID == "" {
500		return nil, nil
501	}
502	if phaseID != "" {
503		phase = findPhase(state, phaseID)
504		if phase == nil {
505			return nil, nil
506		}
507		for i := range phase.Tasks {
508			if phase.Tasks[i].ID == taskID {
509				return phase, &phase.Tasks[i]
510			}
511		}
512		return nil, nil
513	}
514	for i := range state.Phases {
515		for j := range state.Phases[i].Tasks {
516			if state.Phases[i].Tasks[j].ID == taskID {
517				return &state.Phases[i], &state.Phases[i].Tasks[j]
518			}
519		}
520	}
521	return nil, nil
522}
523
524func buildPhaseFromInput(state *PlanState, input PhaseInput, phaseIDs, taskIDs map[string]struct{}) (Phase, error) {
525	id := ""
526	if input.ID != nil {
527		id = normalizeOptionalText(*input.ID)
528	}
529	if id == "" {
530		id = nextPhaseID(state)
531	}
532	if _, exists := phaseIDs[id]; exists {
533		return Phase{}, fmt.Errorf("duplicate phase id %q", id)
534	}
535	phaseIDs[id] = struct{}{}
536	phase := Phase{
537		ID:    id,
538		Title: strings.TrimSpace(input.Title),
539		Goal:  optionalInputText(input.Goal),
540		Tasks: []PhaseTask{},
541	}
542	if input.Tasks != nil {
543		phase.Tasks = make([]PhaseTask, 0, len(*input.Tasks))
544		for _, inputTask := range *input.Tasks {
545			taskID := ""
546			if inputTask.ID != nil {
547				taskID = normalizeOptionalText(*inputTask.ID)
548			}
549			if taskID == "" {
550				taskID = nextTaskID(state)
551			}
552			if _, exists := taskIDs[taskID]; exists {
553				return Phase{}, fmt.Errorf("duplicate task id %q", taskID)
554			}
555			taskIDs[taskID] = struct{}{}
556			checked := false
557			if inputTask.Checked != nil {
558				checked = *inputTask.Checked
559			}
560			phase.Tasks = append(phase.Tasks, PhaseTask{ID: taskID, Text: strings.TrimSpace(inputTask.Text), Checked: checked})
561		}
562	}
563	return phase, nil
564}
565
566func optionalInputText(value *string) string {
567	if value == nil {
568		return ""
569	}
570	return normalizeOptionalText(*value)
571}
572
573// applyAction is the state transition used by the protocol handler. It does
574// not persist the result; stateStore performs that step while holding its
575// mutex, so a failed write never publishes an unpersisted state.
576func applyAction(state PlanState, params toolArgs) (PlanState, string, error) {
577	state = cloneState(state)
578	nextState := cloneState(state)
579	if isPlanClosed(state) && closedPlanBlockedActions[params.Action] {
580		reopen := (params.Action == actionSetTaskChecked || params.Action == actionSetPhaseChecked) && params.Checked != nil && !*params.Checked
581		if !reopen {
582			return state, "Plan is closed", errors.New("this plan is already complete and closed; call clear before starting new work, or call set_spec immediately followed by replace_plan")
583		}
584	}
585
586	switch params.Action {
587	case actionGetStatus:
588		return state, "Current phased plan status", nil
589	case actionSetSpec:
590		spec := ""
591		if params.Spec != nil {
592			spec = normalizeOptionalText(*params.Spec)
593		}
594		if spec == "" {
595			return state, "Spec not updated", errors.New("spec is required for set_spec")
596		}
597		nextState.Spec = spec
598		if isPlanClosed(state) {
599			nextState.Phases = []Phase{}
600			nextState.CurrentPhaseID = ""
601			nextState.NextPhaseNumber = 1
602			nextState.NextTaskNumber = 1
603		}
604		nextState.ClosedAt = nil
605		nextState.ClosedSummary = ""
606		return nextState, "Saved spec", nil
607	case actionReplacePlan:
608		if params.Phases == nil {
609			return state, "Plan not replaced", errors.New("phases is required for replace_plan")
610		}
611		nextState.Phases = []Phase{}
612		nextState.CurrentPhaseID = ""
613		nextState.NextPhaseNumber = 1
614		nextState.NextTaskNumber = 1
615		phaseIDs := map[string]struct{}{}
616		taskIDs := map[string]struct{}{}
617		for _, phaseInput := range *params.Phases {
618			phase, err := buildPhaseFromInput(&nextState, phaseInput, phaseIDs, taskIDs)
619			if err != nil {
620				return state, "Plan not replaced", err
621			}
622			nextState.Phases = append(nextState.Phases, phase)
623		}
624		nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState)
625		return nextState, fmt.Sprintf("Replaced plan with %d phase(s)", len(nextState.Phases)), nil
626	case actionAddPhase:
627		phaseTitle := ""
628		if params.PhaseTitle != nil {
629			phaseTitle = normalizeOptionalText(*params.PhaseTitle)
630		}
631		if phaseTitle == "" {
632			return state, "Phase not added", errors.New("phaseTitle is required for add_phase")
633		}
634		nextState.Phases = append(nextState.Phases, Phase{ID: nextPhaseID(&nextState), Title: phaseTitle, Tasks: []PhaseTask{}})
635		if params.PhaseGoal != nil {
636			nextState.Phases[len(nextState.Phases)-1].Goal = normalizeOptionalText(*params.PhaseGoal)
637		}
638		if nextState.CurrentPhaseID == "" {
639			nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState)
640		}
641		return nextState, "Added phase " + phaseTitle, nil
642	case actionUpdatePhase:
643		phase := findPhase(nextState, params.PhaseID)
644		if phase == nil {
645			return state, "Phase not updated", errors.New(phaseIDError(nextState, actionUpdatePhase, params.PhaseID))
646		}
647		if params.PhaseTitle != nil {
648			if phaseTitle := normalizeOptionalText(*params.PhaseTitle); phaseTitle != "" {
649				phase.Title = phaseTitle
650			}
651		}
652		if params.PhaseGoal != nil {
653			phase.Goal = normalizeOptionalText(*params.PhaseGoal)
654		}
655		return nextState, "Updated phase " + phase.Title, nil
656	case actionRemovePhase:
657		phase := findPhase(nextState, params.PhaseID)
658		if phase == nil {
659			return state, "Phase not removed", errors.New(phaseIDError(nextState, actionRemovePhase, params.PhaseID))
660		}
661		removedID := phase.ID
662		filtered := make([]Phase, 0, len(nextState.Phases)-1)
663		for _, entry := range nextState.Phases {
664			if entry.ID != removedID {
665				filtered = append(filtered, entry)
666			}
667		}
668		nextState.Phases = filtered
669		nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState)
670		return nextState, "Removed phase " + removedID, nil
671	case actionAddTask:
672		phase := findPhase(nextState, params.PhaseID)
673		taskText := ""
674		if params.TaskText != nil {
675			taskText = normalizeOptionalText(*params.TaskText)
676		}
677		if phase == nil {
678			return state, "Task not added", errors.New(phaseIDError(nextState, actionAddTask, params.PhaseID))
679		}
680		if taskText == "" {
681			return state, "Task not added", errors.New("taskText is required for add_task")
682		}
683		phase.Tasks = append(phase.Tasks, PhaseTask{ID: nextTaskID(&nextState), Text: taskText})
684		return nextState, "Added task to " + phase.Title, nil
685	case actionUpdateTask:
686		if params.TaskID == "" {
687			return state, "Task not updated", errors.New("taskId is required for update_task")
688		}
689		_, task := findTask(nextState, params.TaskID, params.PhaseID)
690		taskText := ""
691		if params.TaskText != nil {
692			taskText = normalizeOptionalText(*params.TaskText)
693		}
694		if task == nil {
695			return state, "Task not updated", errors.New(taskIDError(nextState, actionUpdateTask, params.TaskID))
696		}
697		if taskText == "" {
698			return state, "Task not updated", errors.New("taskText is required for update_task")
699		}
700		task.Text = taskText
701		return nextState, "Updated task " + task.ID, nil
702	case actionRemoveTask:
703		if params.TaskID == "" {
704			return state, "Task not removed", errors.New("taskId is required")
705		}
706		phase, task := findTask(nextState, params.TaskID, params.PhaseID)
707		if task == nil {
708			return state, "Task not removed", errors.New("taskId was not found")
709		}
710		filtered := make([]PhaseTask, 0, len(phase.Tasks)-1)
711		for _, entry := range phase.Tasks {
712			if entry.ID != params.TaskID {
713				filtered = append(filtered, entry)
714			}
715		}
716		phase.Tasks = filtered
717		return nextState, "Removed task " + params.TaskID, nil
718	case actionSetCurrentPhase:
719		phase := findPhase(nextState, params.PhaseID)
720		if phase == nil {
721			return state, "Current phase not updated", errors.New(phaseIDError(nextState, actionSetCurrentPhase, params.PhaseID))
722		}
723		nextState.CurrentPhaseID = phase.ID
724		return nextState, "Current phase set to " + phase.Title, nil
725	case actionSetTaskChecked:
726		if params.TaskID == "" {
727			return state, "Task not updated", errors.New("taskId is required for set_task_checked")
728		}
729		phase, task := findTask(nextState, params.TaskID, params.PhaseID)
730		if task == nil {
731			return state, "Task not updated", errors.New(taskIDError(nextState, actionSetTaskChecked, params.TaskID))
732		}
733		if params.Checked == nil {
734			return state, "Task not updated", errors.New("checked must be provided for set_task_checked")
735		}
736		task.Checked = *params.Checked
737		if *params.Checked && nextState.CurrentPhaseID == phase.ID && isPhaseDone(*phase) {
738			nextState.CurrentPhaseID = ""
739		}
740		return nextState, fmt.Sprintf("%s task %s", checkedWord(*params.Checked), task.ID), nil
741	case actionSetPhaseChecked:
742		phase := findPhase(nextState, params.PhaseID)
743		if phase == nil {
744			return state, "Phase not updated", errors.New(phaseIDError(nextState, actionSetPhaseChecked, params.PhaseID))
745		}
746		if params.Checked == nil {
747			return state, "Phase not updated", errors.New("checked must be provided for set_phase_checked")
748		}
749		if len(phase.Tasks) == 0 {
750			return state, "Phase not updated", errors.New("set_phase_checked requires the phase to have at least one task")
751		}
752		for i := range phase.Tasks {
753			phase.Tasks[i].Checked = *params.Checked
754		}
755		if *params.Checked && nextState.CurrentPhaseID == phase.ID {
756			nextState.CurrentPhaseID = ""
757		}
758		if !*params.Checked {
759			nextState.CurrentPhaseID = phase.ID
760		}
761		return nextState, fmt.Sprintf("%s phase %s", checkedWord(*params.Checked), phase.Title), nil
762	case actionClear:
763		return createEmptyState(), "Cleared stored spec and phased checklist", nil
764	default:
765		return state, "Unsupported action", errors.New("unknown action")
766	}
767}
768
769func checkedWord(checked bool) string {
770	if checked {
771		return "Checked"
772	}
773	return "Unchecked"
774}
775
776const (
777	turnContextVisibleIncompleteTasks = 3
778	maxTurnContextChars               = 2048
779)
780
781func getIncompleteTasks(phase Phase) []PhaseTask {
782	incomplete := make([]PhaseTask, 0)
783	for _, task := range phase.Tasks {
784		if !task.Checked {
785			incomplete = append(incomplete, task)
786		}
787	}
788	return incomplete
789}
790
791func formatPhaseTitle(phase Phase) string  { return truncatePlain(displaySingleLine(phase.Title), 120) }
792func formatTaskText(task PhaseTask) string { return truncatePlain(displaySingleLine(task.Text), 180) }
793
794func formatTurnContextID(id string) string { return truncatePlain(singleLine(id), 96) }
795
796func appendIncompleteTaskLines(lines *[]string, tasks []PhaseTask, visibleCount int, indent string) {
797	visible := tasks
798	if len(visible) > visibleCount {
799		visible = visible[:visibleCount]
800	}
801	for _, task := range visible {
802		*lines = append(*lines, fmt.Sprintf("%s[ ] %s [%s]", indent, formatTaskText(task), formatTurnContextID(task.ID)))
803	}
804	if omitted := len(tasks) - len(visible); omitted > 0 {
805		*lines = append(*lines, fmt.Sprintf("%s... %d more incomplete task(s) omitted; call tasked_phases get_status for the full checklist.", indent, omitted))
806	}
807}
808
809func buildSummary(state PlanState) string {
810	if !hasStoredPlan(state) {
811		return "No spec or phased checklist has been stored yet."
812	}
813	lines := make([]string, 0)
814	if state.Spec != "" {
815		lines = append(lines, "Spec:", sanitizeDisplayText(state.Spec), "")
816	}
817	if len(state.Phases) == 0 {
818		return strings.Join(append(lines, "Phases: (none)"), "\n")
819	}
820	done, total := getPlanProgress(state)
821	lines = append(lines, fmt.Sprintf("Plan progress: %d/%d tasks checked", done, total))
822	if isPlanClosed(state) {
823		closedSummary := state.ClosedSummary
824		if closedSummary == "" {
825			closedSummary = "all phases complete"
826		}
827		lines = append(lines, "Plan closed: "+displaySingleLine(closedSummary))
828	}
829	lines = append(lines, "Phases:")
830	for _, phase := range state.Phases {
831		phaseDone, phaseTotal := getPhaseProgress(phase)
832		currentMarker := " "
833		if phase.ID == state.CurrentPhaseID {
834			currentMarker = ">"
835		}
836		phaseMarker := "[ ]"
837		if isPhaseDone(phase) {
838			phaseMarker = "[x]"
839		}
840		goalSuffix := ""
841		if phase.Goal != "" {
842			goalSuffix = " - " + displaySingleLine(phase.Goal)
843		}
844		lines = append(lines, fmt.Sprintf("%s %s %s [%s] (%d/%d)%s", currentMarker, phaseMarker, formatPhaseTitle(phase), formatTurnContextID(phase.ID), phaseDone, phaseTotal, goalSuffix))
845		if len(phase.Tasks) == 0 {
846			lines = append(lines, "    - No tasks yet")
847			continue
848		}
849		for _, task := range phase.Tasks {
850			marker := "[ ]"
851			if task.Checked {
852				marker = "[x]"
853			}
854			lines = append(lines, fmt.Sprintf("    %s %s [%s]", marker, formatTaskText(task), formatTurnContextID(task.ID)))
855		}
856	}
857	return strings.Join(lines, "\n")
858}
859
860// buildTurnContext returns the small amount of plan state the model needs at
861// the start of a turn. It deliberately excludes the spec, completed phases,
862// and future phases; get_status and /phases remain the full-state views.
863func buildTurnContext(state PlanState) string {
864	if !hasStoredPlan(state) {
865		return ""
866	}
867	if isPlanClosed(state) {
868		return truncatePlain("Tasked phases focus: the current plan is complete and closed. Use clear before creating a plan for unrelated work.", maxTurnContextChars)
869	}
870	phase := getActivePhase(state)
871	if phase == nil {
872		return ""
873	}
874	lines := []string{
875		"[TASKED PHASES STATE - SOURCE OF TRUTH]",
876		"Tasked phases focus:",
877		"Call tasked_phases get_status if the exact full checklist is needed.",
878		"Update tasked_phases continuously while implementing, not only at the end.",
879		"After completing each checklist task, immediately call tasked_phases set_task_checked.",
880		"After moving to another phase, immediately call tasked_phases set_current_phase.",
881		"When a phase is shown as Title [id], pass phaseId as the raw id only, without brackets.",
882		"Do not rely on prose alone for completion state.",
883		fmt.Sprintf("Current phase: %s [%s]", formatPhaseTitle(*phase), formatTurnContextID(phase.ID)),
884	}
885	if phase.Goal != "" {
886		lines = append(lines, "Goal: "+truncatePlain(displaySingleLine(phase.Goal), 160))
887	}
888	incompleteTasks := getIncompleteTasks(*phase)
889	if len(incompleteTasks) == 0 {
890		lines = append(lines, "No incomplete tasks remain in this phase; update the plan or advance the phase as appropriate.")
891		return truncatePlain(strings.Join(lines, "\n"), maxTurnContextChars)
892	}
893	lines = append(lines, "Incomplete tasks:")
894	appendIncompleteTaskLines(&lines, incompleteTasks, turnContextVisibleIncompleteTasks, "  ")
895	return truncatePlain(strings.Join(lines, "\n"), maxTurnContextChars)
896}
897
898func shouldReturnFullSummary(action string, err error) bool {
899	return err != nil || action == actionGetStatus || action == actionReplacePlan || action == actionClear
900}
901
902func buildToolResultText(action string, state PlanState, headline string, err error) string {
903	if err != nil {
904		return "Error: " + err.Error() + "\n\n" + buildSummary(state)
905	}
906	if shouldReturnFullSummary(action, nil) {
907		return headline + "\n\n" + buildSummary(state)
908	}
909	return fmt.Sprintf("%s (%s)", headline, buildChromeSummary(state))
910}
911
912func buildViewLines(state PlanState) []string {
913	lines := []string{"", " Tasked phases ", ""}
914	if !hasStoredPlan(state) {
915		return append(lines,
916			"  No spec or phases stored yet.",
917			"",
918			"  Ask the agent to create a spec and phased checklist.",
919			"",
920			"  Press Escape to close",
921		)
922	}
923	if state.Spec != "" {
924		lines = append(lines, "  Spec")
925		for _, specLine := range strings.Split(sanitizeDisplayText(state.Spec), "\n") {
926			lines = append(lines, "  "+specLine)
927		}
928		lines = append(lines, "")
929	}
930	done, total := getPlanProgress(state)
931	lines = append(lines, fmt.Sprintf("  Progress: %d/%d tasks checked", done, total))
932	if isPlanClosed(state) {
933		closedSummary := state.ClosedSummary
934		if closedSummary == "" {
935			closedSummary = "all phases complete"
936		}
937		lines = append(lines,
938			"  Closed: "+displaySingleLine(closedSummary),
939			"  New work should create a fresh plan instead of extending this one.",
940		)
941	}
942	lines = append(lines, "")
943	for _, phase := range state.Phases {
944		phaseDone, phaseTotal := getPhaseProgress(phase)
945		marker := "[ ]"
946		if isPhaseDone(phase) {
947			marker = "[x]"
948		}
949		currentTitle := phase.Title
950		if phase.ID == state.CurrentPhaseID {
951			currentTitle = "> " + currentTitle
952		}
953		lines = append(lines, fmt.Sprintf("  %s %s [%s] (%d/%d)", marker, displaySingleLine(currentTitle), formatTurnContextID(phase.ID), phaseDone, phaseTotal))
954		if phase.Goal != "" {
955			lines = append(lines, "    "+displaySingleLine(phase.Goal))
956		}
957		if len(phase.Tasks) == 0 {
958			lines = append(lines, "    - No tasks yet")
959			continue
960		}
961		for _, task := range phase.Tasks {
962			taskMarker := "[ ]"
963			if task.Checked {
964				taskMarker = "[x]"
965			}
966			lines = append(lines, fmt.Sprintf("    %s %s [%s]", taskMarker, formatTaskText(task), formatTurnContextID(task.ID)))
967		}
968		lines = append(lines, "")
969	}
970	return append(lines, "  Press Escape to close")
971}
972
973type stateStore struct {
974	mu      sync.Mutex
975	state   PlanState
976	path    string
977	loaded  bool
978	loadErr error
979	// pathErr records a persistence fault independently of the active
980	// session state. Activating a session must never clear this fault.
981	pathErr error
982}
983
984func newStateStore() *stateStore {
985	return &stateStore{state: createEmptyState()}
986}
987
988func projectStatePath(dataDir, cwd string) string {
989	key := sha256.Sum256([]byte(cwd))
990	return filepath.Join(dataDir, "projects", hex.EncodeToString(key[:])+".json")
991}
992
993func (s *stateStore) load(dataDir, cwd string) error {
994	s.mu.Lock()
995	defer s.mu.Unlock()
996	if s.loaded {
997		return s.loadErr
998	}
999	s.loaded = true
1000	s.state = createEmptyState()
1001	if strings.TrimSpace(dataDir) == "" {
1002		s.loadErr = errors.New("host did not provide extension data_dir")
1003		s.pathErr = s.loadErr
1004		return s.loadErr
1005	}
1006	s.path = projectStatePath(dataDir, cwd)
1007	if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
1008		s.loadErr = fmt.Errorf("create state directory: %w", err)
1009		s.pathErr = s.loadErr
1010		return s.loadErr
1011	}
1012	// #nosec G302 -- this is a directory; 0700 is the minimum mode that permits traversal.
1013	if err := os.Chmod(filepath.Dir(s.path), 0o700); err != nil {
1014		s.loadErr = fmt.Errorf("restrict state directory: %w", err)
1015		s.pathErr = s.loadErr
1016		return s.loadErr
1017	}
1018	data, err := os.ReadFile(s.path)
1019	if os.IsNotExist(err) {
1020		return nil
1021	}
1022	if err != nil {
1023		s.loadErr = fmt.Errorf("read state: %w", err)
1024		s.pathErr = s.loadErr
1025		return s.loadErr
1026	}
1027	if err := os.Chmod(s.path, 0o600); err != nil {
1028		s.loadErr = fmt.Errorf("restrict state file: %w", err)
1029		s.pathErr = s.loadErr
1030		return s.loadErr
1031	}
1032	if len(data) == 0 {
1033		return nil
1034	}
1035	var state PlanState
1036	if err := json.Unmarshal(data, &state); err != nil {
1037		s.loadErr = fmt.Errorf("parse state: %w", err)
1038		s.pathErr = s.loadErr
1039		return s.loadErr
1040	}
1041	s.state = ensureState(state)
1042	return nil
1043}
1044
1045// activateSession replaces the project fallback with the opaque snapshot
1046// supplied by zut for the active session branch. An empty snapshot means the
1047// branch has no extension state yet and therefore starts clean.
1048func (s *stateStore) activateSession(raw json.RawMessage) error {
1049	s.mu.Lock()
1050	defer s.mu.Unlock()
1051	if !s.loaded {
1052		return errors.New("state has not been loaded from hello")
1053	}
1054	if s.pathErr != nil {
1055		return s.pathErr
1056	}
1057	if len(raw) == 0 || strings.TrimSpace(string(raw)) == "null" {
1058		s.state = createEmptyState()
1059		s.loadErr = nil
1060		return nil
1061	}
1062	var state PlanState
1063	if err := json.Unmarshal(raw, &state); err != nil {
1064		return fmt.Errorf("parse session state: %w", err)
1065	}
1066	s.state = ensureState(state)
1067	s.loadErr = nil
1068	return nil
1069}
1070
1071func (s *stateStore) snapshot() (PlanState, error) {
1072	s.mu.Lock()
1073	defer s.mu.Unlock()
1074	if !s.loaded {
1075		return PlanState{}, errors.New("state has not been loaded from hello")
1076	}
1077	if s.loadErr != nil {
1078		return cloneState(s.state), s.loadErr
1079	}
1080	return cloneState(s.state), nil
1081}
1082
1083func (s *stateStore) transact(fn func(PlanState) (PlanState, string, error)) (PlanState, string, error) {
1084	s.mu.Lock()
1085	defer s.mu.Unlock()
1086	if !s.loaded {
1087		return PlanState{}, "", errors.New("state has not been loaded from hello")
1088	}
1089	if s.pathErr != nil {
1090		return cloneState(s.state), "", s.pathErr
1091	}
1092	if s.loadErr != nil {
1093		return cloneState(s.state), "", s.loadErr
1094	}
1095	current := cloneState(s.state)
1096	next, headline, err := fn(current)
1097	if err != nil {
1098		return current, headline, err
1099	}
1100	next = ensureState(next)
1101	if err := writeJSONAtomic(s.path, next); err != nil {
1102		return current, headline, fmt.Errorf("persist state: %w", err)
1103	}
1104	s.state = next
1105	return cloneState(next), headline, nil
1106}
1107
1108func writeJSONAtomic(path string, value any) error {
1109	if strings.TrimSpace(path) == "" {
1110		return errors.New("state path is empty")
1111	}
1112	data, err := json.MarshalIndent(value, "", "  ")
1113	if err != nil {
1114		return err
1115	}
1116	data = append(data, '\n')
1117	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
1118		return err
1119	}
1120	tmp, err := os.CreateTemp(filepath.Dir(path), ".tasked-phases-*.tmp")
1121	if err != nil {
1122		return err
1123	}
1124	tmpName := tmp.Name()
1125	defer os.Remove(tmpName)
1126	if err := tmp.Chmod(0o600); err != nil {
1127		_ = tmp.Close()
1128		return err
1129	}
1130	if _, err := tmp.Write(data); err != nil {
1131		_ = tmp.Close()
1132		return err
1133	}
1134	if err := tmp.Sync(); err != nil {
1135		_ = tmp.Close()
1136		return err
1137	}
1138	if err := tmp.Close(); err != nil {
1139		return err
1140	}
1141	if err := os.Rename(tmpName, path); err != nil {
1142		return err
1143	}
1144	return nil
1145}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close