From 68a4f7e4cc538ec5645746f8bb387bdd1c6d529b Mon Sep 17 00:00:00 2001 From: typebasedio Date: Tue, 21 Apr 2026 17:07:47 -0400 Subject: [PATCH] feat(v0.4): add SessionSummary type with round-trip and launch-context formatter --- internal/session/summary.go | 148 +++++++++++++++++++++++++++++++ internal/session/summary_test.go | 109 +++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 internal/session/summary.go create mode 100644 internal/session/summary_test.go diff --git a/internal/session/summary.go b/internal/session/summary.go new file mode 100644 index 0000000..ef95ed6 --- /dev/null +++ b/internal/session/summary.go @@ -0,0 +1,148 @@ +package session + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// SessionSummary is the on-disk format of .ctask/last-session-summary.json. +// It records what the last session changed and what the workspace looked like +// at end-of-session, so the next session can (a) detect external modifications +// (Layer 3) and (b) display a launch-context banner (Layer 4). +type SessionSummary struct { + SessionID string `json:"session_id"` + Hostname string `json:"hostname"` + Agent string `json:"agent"` + Mode string `json:"mode"` + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` + DurationSeconds int64 `json:"duration_seconds"` + FilesAdded []string `json:"files_added"` + FilesModified []string `json:"files_modified"` + FilesDeleted []string `json:"files_deleted"` + NotesUpdated bool `json:"notes_updated"` + // EndManifest captures the workspace file list at end-of-session so the + // next session can diff current state against it (Layer 3). This field + // is ctask-internal; it is not part of the public summary format shown + // in the spec examples, but it enables stale-workspace detection from a + // single source file. + EndManifest []FileEntry `json:"end_manifest"` +} + +// SummaryPath returns the absolute path of the summary file for wsDir. +func SummaryPath(wsDir string) string { + return filepath.Join(wsDir, ".ctask", "last-session-summary.json") +} + +// WriteSummary serializes s to path with indented JSON. Creates .ctask/ if needed. +func WriteSummary(path string, s *SessionSummary) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + if s.FilesAdded == nil { + s.FilesAdded = []string{} + } + if s.FilesModified == nil { + s.FilesModified = []string{} + } + if s.FilesDeleted == nil { + s.FilesDeleted = []string{} + } + if s.EndManifest == nil { + s.EndManifest = []FileEntry{} + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// ReadSummary reads a summary from path. +// - missing file: returns (nil, nil) — normal pre-v0.4 case +// - corrupt JSON: returns (nil, wrapped err) +// - other I/O errors: returned to caller as-is +func ReadSummary(path string) (*SessionSummary, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var s SessionSummary + if err := json.Unmarshal(data, &s); err != nil { + return nil, fmt.Errorf("parsing summary: %w", err) + } + return &s, nil +} + +// SummarizeFromDiff builds a SessionSummary from a session's identity, +// start/end times, the diff, and the end manifest. +func SummarizeFromDiff( + sessionID, hostname, agent, mode string, + startedAt, endedAt time.Time, + diff *ManifestDiff, + endManifest *Manifest, +) *SessionSummary { + s := &SessionSummary{ + SessionID: sessionID, + Hostname: hostname, + Agent: agent, + Mode: mode, + StartedAt: startedAt, + EndedAt: endedAt, + DurationSeconds: int64(endedAt.Sub(startedAt).Seconds()), + FilesAdded: append([]string{}, diff.Added...), + FilesModified: append([]string{}, diff.Modified...), + FilesDeleted: append([]string{}, diff.Deleted...), + NotesUpdated: NotesUpdated(diff), + } + if endManifest != nil { + s.EndManifest = append([]FileEntry{}, endManifest.Files...) + } else { + s.EndManifest = []FileEntry{} + } + return s +} + +// FormatLaunchContext renders the short "Last session: ... Changed: ..." banner +// printed on session start (Layer 4). Returns empty string when s is nil. +func FormatLaunchContext(s *SessionSummary) string { + if s == nil { + return "" + } + var b strings.Builder + + fmt.Fprintf(&b, "[ctask] Last session: %s-%s (%s, %s)\n", + s.StartedAt.Local().Format("2006-01-02 15:04"), + s.EndedAt.Local().Format("15:04"), + s.Hostname, s.Agent) + + changed := append([]string{}, s.FilesModified...) + changed = append(changed, s.FilesAdded...) + const maxShown = 2 + more := 0 + preview := changed + if len(changed) > maxShown { + preview = changed[:maxShown] + more = len(changed) - maxShown + } + if len(preview) == 0 && len(s.FilesDeleted) == 0 { + fmt.Fprintln(&b, "[ctask] (no tracked file changes)") + return b.String() + } + if len(preview) > 0 { + fmt.Fprintf(&b, "[ctask] Changed: %s", strings.Join(preview, ", ")) + if more > 0 { + fmt.Fprintf(&b, " (+%d more)", more) + } + fmt.Fprintln(&b) + } + return b.String() +} diff --git a/internal/session/summary_test.go b/internal/session/summary_test.go new file mode 100644 index 0000000..d43d68e --- /dev/null +++ b/internal/session/summary_test.go @@ -0,0 +1,109 @@ +package session + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestSessionSummaryRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".ctask", "last-session-summary.json") + + start := time.Date(2026, 4, 21, 14, 30, 22, 0, time.UTC) + end := start.Add(1*time.Hour + 14*time.Minute + 48*time.Second) + + want := &SessionSummary{ + SessionID: "h-1-20260421143022", + Hostname: "h", + Agent: "claude", + Mode: "local", + StartedAt: start, + EndedAt: end, + DurationSeconds: int64(end.Sub(start).Seconds()), + FilesAdded: []string{"output/migration-plan.md", "output/schema.sql"}, + FilesModified: []string{"notes.md", "CLAUDE.md"}, + FilesDeleted: []string{}, + NotesUpdated: true, + EndManifest: []FileEntry{ + {Path: "notes.md", Size: 240, Mtime: end}, + {Path: "CLAUDE.md", Size: 300, Mtime: end}, + }, + } + + if err := WriteSummary(path, want); err != nil { + t.Fatalf("WriteSummary: %v", err) + } + + got, err := ReadSummary(path) + if err != nil { + t.Fatalf("ReadSummary: %v", err) + } + if got.SessionID != want.SessionID { + t.Errorf("SessionID: got %q, want %q", got.SessionID, want.SessionID) + } + if got.DurationSeconds != want.DurationSeconds { + t.Errorf("DurationSeconds: got %d, want %d", got.DurationSeconds, want.DurationSeconds) + } + if len(got.FilesAdded) != 2 { + t.Errorf("FilesAdded length: got %d, want 2", len(got.FilesAdded)) + } + if !got.NotesUpdated { + t.Error("NotesUpdated: got false, want true") + } + if len(got.EndManifest) != 2 { + t.Errorf("EndManifest length: got %d, want 2", len(got.EndManifest)) + } +} + +func TestSummaryJSONFields(t *testing.T) { + s := &SessionSummary{ + FilesAdded: []string{"a"}, + FilesModified: []string{}, + FilesDeleted: []string{}, + EndManifest: []FileEntry{}, + } + data, _ := json.Marshal(s) + for _, want := range []string{ + `"session_id"`, `"hostname"`, `"agent"`, `"mode"`, + `"started_at"`, `"ended_at"`, `"duration_seconds"`, + `"files_added"`, `"files_modified"`, `"files_deleted"`, + `"notes_updated"`, `"end_manifest"`, + } { + if !strings.Contains(string(data), want) { + t.Errorf("summary JSON missing %s: %s", want, data) + } + } +} + +func TestReadSummaryMissingFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".ctask", "last-session-summary.json") + + got, err := ReadSummary(path) + if err != nil { + t.Fatalf("ReadSummary on missing file should return (nil, nil), got err=%v", err) + } + if got != nil { + t.Errorf("expected nil summary for missing file, got %+v", got) + } + + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Errorf("expected missing file, got err=%v", err) + } +} + +func TestReadSummaryCorruptReturnsError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".ctask", "last-session-summary.json") + os.MkdirAll(filepath.Dir(path), 0755) + os.WriteFile(path, []byte("{not json"), 0644) + + if _, err := ReadSummary(path); err == nil { + t.Error("expected error reading corrupt summary") + } +}