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}