package main import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "sync" "time" ) // Task statuses. The JSON files must use these exact values. const ( StatusTodo = "todo" StatusInProgress = "in_progress" StatusReview = "review" StatusDone = "done" // the agent finished the work and all its tests pass StatusReviewed = "reviewed" // a reviewer approved it (the final state) ) var validStatus = map[string]bool{ StatusTodo: true, StatusInProgress: true, StatusReview: true, StatusDone: true, StatusReviewed: true, } type Step struct { Text string `json:"text"` Done bool `json:"done"` } // Review states. A task's review moves todo -> done, or to rejected / hold. const ( ReviewTodo = "todo" // waiting for the reviewer ReviewRejected = "rejected" // sent back with changes to make ReviewHold = "hold" // paused, e.g. waiting for information ReviewDone = "done" // accepted ) var validReviewState = map[string]bool{"": true, ReviewTodo: true, ReviewRejected: true, ReviewHold: true, ReviewDone: true} // ChecklistItem is one numbered step the reviewer should do. Text is Markdown. type ChecklistItem struct { Text string `json:"text"` Done bool `json:"done"` } type Review struct { Reviewer string `json:"reviewer"` RequestedAt string `json:"requested_at"` State string `json:"state"` // todo | rejected | hold | done (empty = todo) Focus string `json:"focus"` // what to check, Markdown Checklist []ChecklistItem `json:"checklist"` Decision string `json:"decision"` // reviewer's result or reason, Markdown DecidedAt string `json:"decided_at"` DecidedBy string `json:"decided_by"` History []ReviewEvent `json:"history"` // every submitted decision, oldest first } // ReviewEvent is one submitted review decision. type ReviewEvent struct { State string `json:"state"` Decision string `json:"decision"` By string `json:"by"` At string `json:"at"` } // StateOrTodo is the review state, treating empty as todo. func (r Review) StateOrTodo() string { if r.State == "" { return ReviewTodo } return r.State } type TaskQuestion struct { ID string `json:"id"` AskedOf string `json:"asked_of"` Question string `json:"question"` Why string `json:"why"` State string `json:"state"` Answer string `json:"answer,omitempty"` AnsweredBy string `json:"answered_by,omitempty"` AnsweredAt string `json:"answered_at,omitempty"` Source string `json:"source,omitempty"` } type Task struct { Description string `json:"description,omitempty"` Deliverables []string `json:"deliverables,omitempty"` Dependencies []string `json:"dependencies,omitempty"` SuggestedOwner string `json:"suggested_owner,omitempty"` Questions []TaskQuestion `json:"questions,omitempty"` ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Owner string `json:"owner"` Summary string `json:"summary"` Started string `json:"started"` // when work began: date or RFC 3339 time Finished string `json:"finished"` // when work ended: date or RFC 3339 time Start string `json:"start"` // planned start (optional) Due string `json:"due"` // planned end (optional) Refs []string `json:"refs"` Steps []Step `json:"steps"` Review *Review `json:"review"` } // Epic groups related tasks inside a milestone. type Epic struct { ID string `json:"id"` Title string `json:"title"` Goal string `json:"goal"` Target string `json:"target"` Tasks []Task `json:"tasks"` } // Milestone is a stage of the project: Milestone -> Epic -> Task. // No is shown as the lane number; Milestone 0 holds the pre-project work. type Milestone struct { ID string `json:"id"` No int `json:"no"` Title string `json:"title"` Goal string `json:"goal"` Target string `json:"target"` Epics []Epic `json:"epics"` } // TestCase is one check, written as a sentence a BA can read. type TestCase struct { Name string `json:"name"` Status string `json:"status"` // pass | fail | skip Detail string `json:"detail"` } // TestGroup collects the checks for one topic and says who it matters to. type TestGroup struct { Name string `json:"name"` Audience string `json:"audience"` // ba | tech | both Purpose string `json:"purpose"` // why this topic matters, in plain language Cases []TestCase `json:"cases"` } type TestRun struct { ID string `json:"id"` Suite string `json:"suite"` Date string `json:"date"` Summary string `json:"summary"` // what this run shows, in plain language Groups []TestGroup `json:"groups"` Command string `json:"command"` Passed int `json:"passed"` // raw counts from the test tool (technical) Failed int `json:"failed"` Skipped int `json:"skipped"` Duration string `json:"duration"` Notes string `json:"notes"` Failures []string `json:"failures"` } // Failing reports whether the tool or any written check failed. func (r TestRun) Failing() bool { if r.Failed > 0 { return true } for _, g := range r.Groups { for _, c := range g.Cases { if c.Status == "fail" { return true } } } return false } // Checkpoint is a summary written by the agent (or a person) after a piece of // work: what was done, what changed, which tasks and files it touched. type Checkpoint struct { ID string `json:"id"` At string `json:"at"` // when the work was finished, RFC 3339 (e.g. 2026-10-30T15:40:00+07:00) Agent string `json:"agent"` Title string `json:"title"` Summary string `json:"summary"` Changes []string `json:"changes"` Tasks []string `json:"tasks"` // task ids Refs []string `json:"refs"` // files or docs touched Commit string `json:"commit"` // optional git commit for reference when time.Time } // When is the parsed time of At. func (c Checkpoint) When() time.Time { return c.when } type Problem struct { ID string `json:"id"` Title string `json:"title"` Date string `json:"date"` Status string `json:"status"` // open | solved Symptom string `json:"symptom"` Cause string `json:"cause"` Solution string `json:"solution"` Epic string `json:"epic"` // epic id the problem belongs to Refs []string `json:"refs"` } // Snapshot is one consistent view of all tracker data. type Snapshot struct { Milestones []Milestone Tests []TestRun Problems []Problem Checkpoints []Checkpoint // newest first } // Store holds the last good snapshot. A broken JSON edit keeps the previous // data on screen and reports the error instead of blanking the dashboard. type Store struct { dir string writeMu sync.Mutex // serialises writes from the review form mu sync.RWMutex snap Snapshot loadErr error loadedAt time.Time } func NewStore(dir string) *Store { s := &Store{dir: dir} s.Reload() return s } func (s *Store) Get() (Snapshot, error, time.Time) { s.mu.RLock() defer s.mu.RUnlock() return s.snap, s.loadErr, s.loadedAt } func (s *Store) Reload() { snap, err := loadSnapshot(s.dir) s.mu.Lock() defer s.mu.Unlock() s.loadErr = err if err == nil { s.snap = snap } s.loadedAt = time.Now() } func loadSnapshot(dir string) (Snapshot, error) { var snap Snapshot if err := readJSON(filepath.Join(dir, "milestones.json"), &snap.Milestones, true); err != nil { return snap, err } if err := readJSON(filepath.Join(dir, "tests.json"), &snap.Tests, false); err != nil { return snap, err } if err := readJSON(filepath.Join(dir, "problems.json"), &snap.Problems, false); err != nil { return snap, err } if err := readJSON(filepath.Join(dir, "checkpoints.json"), &snap.Checkpoints, false); err != nil { return snap, err } if err := prepareCheckpoints(snap.Checkpoints); err != nil { return snap, err } return snap, validate(snap) } func readJSON(path string, v any, required bool) error { b, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) && !required { return nil } if err != nil { return fmt.Errorf("อ่านไฟล์ %s ไม่ได้: %w", filepath.Base(path), err) } if err := json.Unmarshal(b, v); err != nil { return fmt.Errorf("ไฟล์ %s มีรูปแบบ JSON ไม่ถูกต้อง: %w", filepath.Base(path), err) } return nil } func validate(snap Snapshot) error { seen := map[string]string{} // id -> kind, ids must be unique across milestones, epics and tasks claim := func(id, kind, where string) error { if id == "" { return fmt.Errorf("milestones.json: มี %s ที่ไม่มี id%s", kind, where) } if prev, ok := seen[id]; ok { return fmt.Errorf("milestones.json: id %s ซ้ำ (ใช้แล้วกับ %s)", id, prev) } seen[id] = kind return nil } for _, m := range snap.Milestones { if err := claim(m.ID, "milestone", ""); err != nil { return err } for _, e := range m.Epics { if err := claim(e.ID, "epic", " ใน "+m.ID); err != nil { return err } for _, t := range e.Tasks { if err := claim(t.ID, "task", " ใน "+e.ID); err != nil { return err } if !validStatus[t.Status] { return fmt.Errorf("milestones.json: task %s มี status %q ซึ่งใช้ไม่ได้ (ใช้ todo, in_progress, done, review หรือ reviewed)", t.ID, t.Status) } if t.Review != nil && !validReviewState[t.Review.State] { return fmt.Errorf("milestones.json: task %s มี review.state %q ซึ่งใช้ไม่ได้ (ใช้ todo, rejected, hold หรือ done)", t.ID, t.Review.State) } } } } return nil } // prepareCheckpoints parses times, checks ids and sorts newest first. func prepareCheckpoints(cps []Checkpoint) error { seen := map[string]bool{} for i := range cps { c := &cps[i] if c.ID == "" { return fmt.Errorf("checkpoints.json: มี checkpoint ที่ไม่มี id") } if seen[c.ID] { return fmt.Errorf("checkpoints.json: id %s ซ้ำ", c.ID) } seen[c.ID] = true t, err := time.Parse(time.RFC3339, c.At) if err != nil { if t, err = time.ParseInLocation("2006-01-02", c.At, ict); err != nil { return fmt.Errorf("checkpoints.json: %s มีเวลา %q ที่อ่านไม่ได้ (ใช้รูปแบบ 2026-10-30T15:40:00+07:00)", c.ID, c.At) } } c.when = t } sort.SliceStable(cps, func(i, j int) bool { return cps[i].when.After(cps[j].when) }) return nil }