55 "encoding/json"
66 "fmt"
77 "maps"
8+ "os"
9+ "path/filepath"
810 "sort"
911 "strconv"
1012 "strings"
@@ -31,8 +33,8 @@ type Task struct {
3133 ActiveForm string `json:"activeForm,omitempty"`
3234 Status TaskStatus `json:"status"`
3335 Owner string `json:"owner,omitempty"`
34- Blocks []string `json:"blocks,omitempty "`
35- BlockedBy []string `json:"blockedBy,omitempty "`
36+ Blocks []string `json:"blocks"`
37+ BlockedBy []string `json:"blockedBy"`
3638 Metadata map [string ]any `json:"metadata,omitempty"`
3739}
3840
@@ -48,12 +50,13 @@ type TaskSnapshot struct {
4850// TaskNotifyFn is called after each store mutation with the latest snapshot.
4951type TaskNotifyFn func (TaskSnapshot )
5052
51- // TaskStore is an in-memory, thread-safe task store with auto-increment IDs .
53+ // TaskStore is a thread-safe task store with optional file persistence .
5254type TaskStore struct {
5355 mu sync.RWMutex
5456 tasks map [string ]* Task
5557 nextID int
5658 notifyFn TaskNotifyFn
59+ dir string // persistence directory; empty = in-memory only
5760}
5861
5962// NewTaskStore creates an empty store.
@@ -82,13 +85,21 @@ func (s *TaskStore) Create(subject, description, activeForm string, metadata map
8285 Description : description ,
8386 ActiveForm : activeForm ,
8487 Status : TaskPending ,
88+ Blocks : []string {},
89+ BlockedBy : []string {},
8590 Metadata : metadata ,
8691 }
8792 s .tasks [id ] = t
88- snap := s .snapshot ()
93+ cp := copyTask (t )
94+ dir := s .dir
95+ hwm := s .nextID - 1
8996 s .mu .Unlock ()
90- s .notify (snap )
91- return copyTask (t )
97+ if dir != "" {
98+ s .persist (cp )
99+ s .writeHighWaterMark (hwm )
100+ }
101+ s .notify ()
102+ return cp
92103}
93104
94105// Get returns a copy of the task or false if not found.
@@ -126,9 +137,9 @@ func (s *TaskStore) Update(id string, opts UpdateOpts) (*Task, error) {
126137 if opts .Status != nil {
127138 if * opts .Status == "deleted" {
128139 delete (s .tasks , id )
129- snap := s .snapshot ()
130140 s .mu .Unlock ()
131- s .notify (snap )
141+ s .removeFile (id )
142+ s .notify ()
132143 return nil , nil
133144 }
134145 t .Status = * opts .Status
@@ -159,11 +170,13 @@ func (s *TaskStore) Update(id string, opts UpdateOpts) (*Task, error) {
159170 }
160171 // Dependency tracking: bidirectional, matching Claude Code's addDependency().
161172 // addBlocks: this task blocks others → add id to each target's blockedBy.
173+ var touched []* Task // tasks modified by dependency updates
162174 if len (opts .AddBlocks ) > 0 {
163175 t .Blocks = appendUnique (t .Blocks , opts .AddBlocks ... )
164176 for _ , blockedID := range opts .AddBlocks {
165177 if other , exists := s .tasks [blockedID ]; exists {
166178 other .BlockedBy = appendUnique (other .BlockedBy , id )
179+ touched = append (touched , other )
167180 }
168181 }
169182 }
@@ -173,14 +186,29 @@ func (s *TaskStore) Update(id string, opts UpdateOpts) (*Task, error) {
173186 for _ , blockerID := range opts .AddBlockedBy {
174187 if other , exists := s .tasks [blockerID ]; exists {
175188 other .Blocks = appendUnique (other .Blocks , id )
189+ touched = append (touched , other )
176190 }
177191 }
178192 }
179193
180194 cp := copyTask (t )
181- snap := s .snapshot ()
195+ var touchedCopies []* Task
196+ for _ , other := range touched {
197+ touchedCopies = append (touchedCopies , copyTask (other ))
198+ }
199+
200+ // If all tasks are now completed, clean up.
201+ allDone := s .allCompleted ()
202+
182203 s .mu .Unlock ()
183- s .notify (snap )
204+ s .persist (cp )
205+ for _ , tc := range touchedCopies {
206+ s .persist (tc )
207+ }
208+ if allDone {
209+ s .clearAll ()
210+ }
211+ s .notify ()
184212 return cp , nil
185213}
186214
@@ -225,9 +253,10 @@ func (s *TaskStore) snapshot() TaskSnapshot {
225253 return snap
226254}
227255
228- func (s * TaskStore ) notify (snap TaskSnapshot ) {
256+ func (s * TaskStore ) notify () {
229257 s .mu .RLock ()
230258 fn := s .notifyFn
259+ snap := s .snapshot ()
231260 s .mu .RUnlock ()
232261 if fn != nil {
233262 fn (snap )
@@ -268,6 +297,144 @@ func appendUnique(base []string, vals ...string) []string {
268297 return base
269298}
270299
300+ // ---------------------------------------------------------------------------
301+ // Persistence helpers
302+ // ---------------------------------------------------------------------------
303+
304+ const highWaterMarkFile = ".highwatermark"
305+
306+ // SetDir enables file persistence. It creates the directory if needed and
307+ // loads any existing tasks from disk. Call before the store is used.
308+ func (s * TaskStore ) SetDir (dir string ) error {
309+ if err := os .MkdirAll (dir , 0o755 ); err != nil {
310+ return fmt .Errorf ("create task dir: %w" , err )
311+ }
312+ s .mu .Lock ()
313+ s .dir = dir
314+ s .mu .Unlock ()
315+ return s .loadFromDir ()
316+ }
317+
318+ // Snapshot returns the current read-only snapshot (public, for initial TUI state).
319+ func (s * TaskStore ) Snapshot () TaskSnapshot {
320+ s .mu .RLock ()
321+ defer s .mu .RUnlock ()
322+ return s .snapshot ()
323+ }
324+
325+ // loadFromDir reads all {id}.json files and .highwatermark from s.dir.
326+ func (s * TaskStore ) loadFromDir () error {
327+ entries , err := os .ReadDir (s .dir )
328+ if err != nil {
329+ return fmt .Errorf ("read task dir: %w" , err )
330+ }
331+
332+ s .mu .Lock ()
333+ defer s .mu .Unlock ()
334+
335+ hwm := s .readHighWaterMark ()
336+ maxID := hwm
337+
338+ for _ , entry := range entries {
339+ name := entry .Name ()
340+ if ! strings .HasSuffix (name , ".json" ) {
341+ continue
342+ }
343+ idStr := strings .TrimSuffix (name , ".json" )
344+ id , err := strconv .Atoi (idStr )
345+ if err != nil {
346+ continue // skip non-numeric files
347+ }
348+
349+ data , err := os .ReadFile (filepath .Join (s .dir , name ))
350+ if err != nil {
351+ fmt .Fprintf (os .Stderr , "warning: read task %s: %v\n " , name , err )
352+ continue
353+ }
354+ var t Task
355+ if err := json .Unmarshal (data , & t ); err != nil {
356+ fmt .Fprintf (os .Stderr , "warning: parse task %s: %v\n " , name , err )
357+ continue
358+ }
359+ s .tasks [t .ID ] = & t
360+ if id > maxID {
361+ maxID = id
362+ }
363+ }
364+ s .nextID = maxID + 1
365+ return nil
366+ }
367+
368+ // persist writes a single task to {dir}/{id}.json.
369+ func (s * TaskStore ) persist (t * Task ) {
370+ if s .dir == "" {
371+ return
372+ }
373+ data , err := json .MarshalIndent (t , "" , " " )
374+ if err != nil {
375+ fmt .Fprintf (os .Stderr , "warning: marshal task %s: %v\n " , t .ID , err )
376+ return
377+ }
378+ path := filepath .Join (s .dir , t .ID + ".json" )
379+ if err := os .WriteFile (path , data , 0o644 ); err != nil {
380+ fmt .Fprintf (os .Stderr , "warning: write task %s: %v\n " , t .ID , err )
381+ }
382+ }
383+
384+ // removeFile deletes {dir}/{id}.json.
385+ func (s * TaskStore ) removeFile (id string ) {
386+ if s .dir == "" {
387+ return
388+ }
389+ _ = os .Remove (filepath .Join (s .dir , id + ".json" ))
390+ }
391+
392+ // readHighWaterMark reads the .highwatermark file (must be called with mu held).
393+ func (s * TaskStore ) readHighWaterMark () int {
394+ data , err := os .ReadFile (filepath .Join (s .dir , highWaterMarkFile ))
395+ if err != nil {
396+ return 0
397+ }
398+ n , _ := strconv .Atoi (strings .TrimSpace (string (data )))
399+ return n
400+ }
401+
402+ // writeHighWaterMark writes the .highwatermark file.
403+ func (s * TaskStore ) writeHighWaterMark (id int ) {
404+ if s .dir == "" {
405+ return
406+ }
407+ _ = os .WriteFile (
408+ filepath .Join (s .dir , highWaterMarkFile ),
409+ []byte (strconv .Itoa (id )),
410+ 0o644 ,
411+ )
412+ }
413+
414+ // allCompleted reports whether all tasks are completed (must be called with mu held).
415+ func (s * TaskStore ) allCompleted () bool {
416+ if len (s .tasks ) == 0 {
417+ return false
418+ }
419+ for _ , t := range s .tasks {
420+ if t .Status != TaskCompleted {
421+ return false
422+ }
423+ }
424+ return true
425+ }
426+
427+ // clearAll removes all tasks from memory and deletes the task directory.
428+ func (s * TaskStore ) clearAll () {
429+ s .mu .Lock ()
430+ clear (s .tasks )
431+ dir := s .dir
432+ s .mu .Unlock ()
433+ if dir != "" {
434+ _ = os .RemoveAll (dir )
435+ }
436+ }
437+
271438// ---------------------------------------------------------------------------
272439// TaskCreateTool
273440// ---------------------------------------------------------------------------
@@ -293,6 +460,9 @@ func (t *TaskCreateTool) SetNotifyFn(fn TaskNotifyFn) {
293460 t .store .SetNotifyFn (fn )
294461}
295462
463+ // Store returns the underlying TaskStore (used for persistence wiring).
464+ func (t * TaskCreateTool ) Store () * TaskStore { return t .store }
465+
296466type taskCreateArgs struct {
297467 Subject string `json:"subject"`
298468 Description string `json:"description"`
0 commit comments