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"` // EndReason is "child_exited" for direct mode, "tmux_session_ended" for // persistent mode (both owner-create and adopted reattach). Optional; // pre-v0.5.3 summaries omit it. EndReason string `json:"end_reason,omitempty"` // DetectedVia distinguishes the mechanism that observed session end: // "child_exit" for direct mode, "polling" for persistent mode. DetectedVia string `json:"detected_via,omitempty"` // SessionOwnership is "created" if this ctask process originated the // session (owner-create) or "adopted" if it took over an orphaned // persistent session. Omitted in direct mode. SessionOwnership string `json:"session_ownership,omitempty"` // AdoptedFromOrphanAt records the moment adoption took place. Set only // for adopted reattach. AdoptedFromOrphanAt *time.Time `json:"adopted_from_orphan_at,omitempty"` // 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() }