From 10ab9efc805e23055f76c09c8e11482bd38f1235 Mon Sep 17 00:00:00 2001 From: warren Date: Mon, 6 Apr 2026 09:56:21 -0400 Subject: [PATCH] feat: session lifecycle wrapper with manifest capture and session logging Refactor new/resume/open to use session.Run() which wraps child process launch with pre/post manifest capture and append-only session logging to logs/sessions.log. Bump version to 0.2.0. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/new.go | 27 +++--- cmd/open.go | 12 ++- cmd/resume.go | 32 ++++--- cmd/root.go | 2 +- internal/session/log.go | 122 +++++++++++++++++++++++++ internal/session/manifest.go | 171 +++++++++++++++++++++++++++++++++++ internal/session/run.go | 101 +++++++++++++++++++++ 7 files changed, 435 insertions(+), 32 deletions(-) create mode 100644 internal/session/log.go create mode 100644 internal/session/manifest.go create mode 100644 internal/session/run.go diff --git a/cmd/new.go b/cmd/new.go index a91e32c..9bcd471 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -5,14 +5,15 @@ import ( "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/shell" "github.com/warrenronsiek/ctask/internal/workspace" ) var newCmd = &cobra.Command{ - Use: "new [title]", - Short: "Create a new task workspace and launch the agent", - Long: "Create a new task workspace and launch the agent. If title is omitted, generates task-HHMMSS.", + Use: "new [title]", + Short: "Create a new task workspace and launch the agent", + Long: "Create a new task workspace and launch the agent. If title is omitted, generates task-HHMMSS.", Args: cobra.MaximumNArgs(1), SilenceUsage: true, RunE: runNew, @@ -28,7 +29,7 @@ var ( func init() { newCmd.Flags().StringVarP(&newCategory, "category", "c", "general", "Workspace category subdirectory") - newCmd.Flags().BoolVar(&newContainer, "container", false, "Launch in container sandbox (v0.2)") + newCmd.Flags().BoolVar(&newContainer, "container", false, "Launch in container sandbox (deferred)") newCmd.Flags().BoolVar(&newShell, "shell", false, "Open interactive shell instead of agent") newCmd.Flags().StringVarP(&newAgent, "agent", "a", "", "Command to exec as the agent") newCmd.Flags().BoolVar(&newNoLaunch, "no-launch", false, "Create workspace only, do not launch") @@ -72,14 +73,12 @@ func runNew(cmd *cobra.Command, args []string) error { envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category) - if newShell { - return shell.ExecShell(ws.Path, envVars, ws.Meta.Slug, ws.Meta.Mode) - } - - // Agent mode: print banner and exec - for _, line := range shell.BannerLines(ws.Meta.Mode, ws.Meta.Slug, ws.Path) { - fmt.Println(line) - } - - return shell.ExecAgent(agent, ws.Path, envVars) + return session.Run(session.LaunchOpts{ + WsDir: ws.Path, + EnvVars: envVars, + Agent: agent, + Mode: ws.Meta.Mode, + Slug: ws.Meta.Slug, + Shell: newShell, + }) } diff --git a/cmd/open.go b/cmd/open.go index 4afc3b6..2cc4fba 100644 --- a/cmd/open.go +++ b/cmd/open.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" - "github.com/warrenronsiek/ctask/internal/shell" + "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/workspace" ) @@ -40,6 +40,12 @@ func runOpen(cmd *cobra.Command, args []string) error { envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category) - // Spawn interactive subshell (not agent, not cd in parent) - return shell.ExecShell(ws.Path, envVars, ws.Meta.Slug, ws.Meta.Mode) + return session.Run(session.LaunchOpts{ + WsDir: ws.Path, + EnvVars: envVars, + Agent: ws.Meta.Agent, + Mode: ws.Meta.Mode, + Slug: ws.Meta.Slug, + Shell: true, // open always launches shell + }) } diff --git a/cmd/resume.go b/cmd/resume.go index 3603f90..016ef2c 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/shell" "github.com/warrenronsiek/ctask/internal/workspace" ) @@ -26,20 +27,25 @@ var ( ) func init() { - resumeCmd.Flags().BoolVar(&resumeContainer, "container", false, "Resume in container mode (v0.2)") + resumeCmd.Flags().BoolVar(&resumeContainer, "container", false, "Resume in container mode (deferred)") resumeCmd.Flags().BoolVar(&resumeShell, "shell", false, "Open shell instead of agent") resumeCmd.Flags().StringVarP(&resumeAgent, "agent", "a", "", "Override agent command") rootCmd.AddCommand(resumeCmd) } func runResume(cmd *cobra.Command, args []string) error { - if resumeContainer { + return doResume(args[0], resumeContainer, resumeShell, resumeAgent) +} + +// doResume is the shared resume logic used by both resume and last commands. +func doResume(query string, container, useShell bool, agentOverride string) error { + if container { fmt.Println(shell.ContainerNotice()) return nil } root := config.ResolveRoot() - ws := resolveOne(root, args[0], false) + ws := resolveOne(root, query, false) // Update updated_at now := time.Now().UTC().Truncate(time.Second) @@ -49,21 +55,19 @@ func runResume(cmd *cobra.Command, args []string) error { return fmt.Errorf("updating metadata: %w", err) } - agent := resumeAgent + agent := agentOverride if agent == "" { agent = ws.Meta.Agent } envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category) - if resumeShell { - return shell.ExecShell(ws.Path, envVars, ws.Meta.Slug, ws.Meta.Mode) - } - - // Agent mode - for _, line := range shell.BannerLines(ws.Meta.Mode, ws.Meta.Slug, ws.Path) { - fmt.Println(line) - } - - return shell.ExecAgent(agent, ws.Path, envVars) + return session.Run(session.LaunchOpts{ + WsDir: ws.Path, + EnvVars: envVars, + Agent: agent, + Mode: ws.Meta.Mode, + Slug: ws.Meta.Slug, + Shell: useShell, + }) } diff --git a/cmd/root.go b/cmd/root.go index 2d9e607..953dd29 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" ) -var version = "0.1.0" +var version = "0.2.0" var rootCmd = &cobra.Command{ Use: "ctask", diff --git a/internal/session/log.go b/internal/session/log.go new file mode 100644 index 0000000..11dbb05 --- /dev/null +++ b/internal/session/log.go @@ -0,0 +1,122 @@ +package session + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// SessionInfo holds data for a session log entry. +type SessionInfo struct { + Agent string + Mode string + StartTime time.Time + EndTime time.Time + Diff *ManifestDiff +} + +// FormatDuration returns a human-readable duration string like "1h 14m 48s". +func FormatDuration(d time.Duration) string { + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + s := int(d.Seconds()) % 60 + + if h > 0 { + return fmt.Sprintf("%dh %dm %ds", h, m, s) + } + if m > 0 { + return fmt.Sprintf("%dm %ds", m, s) + } + return fmt.Sprintf("%ds", s) +} + +// FormatSessionEntry formats a session log entry as a human-readable string. +func FormatSessionEntry(info *SessionInfo) string { + var b strings.Builder + + duration := info.EndTime.Sub(info.StartTime) + timeFmt := "2006-01-02 15:04:05" + + fmt.Fprintf(&b, "── Session %s ──\n", info.StartTime.Format(timeFmt)) + fmt.Fprintf(&b, "Agent: %s\n", info.Agent) + fmt.Fprintf(&b, "Mode: %s\n", info.Mode) + fmt.Fprintf(&b, "Start: %s\n", info.StartTime.Format(timeFmt)) + fmt.Fprintf(&b, "End: %s\n", info.EndTime.Format(timeFmt)) + fmt.Fprintf(&b, "Duration: %s\n", FormatDuration(duration)) + + b.WriteString("\nAdded:\n") + if len(info.Diff.Added) > 0 { + sorted := make([]string, len(info.Diff.Added)) + copy(sorted, info.Diff.Added) + sort.Strings(sorted) + for _, f := range sorted { + fmt.Fprintf(&b, " %s\n", f) + } + } else { + b.WriteString(" (none)\n") + } + + b.WriteString("\nModified:\n") + if len(info.Diff.Modified) > 0 { + sorted := make([]string, len(info.Diff.Modified)) + copy(sorted, info.Diff.Modified) + sort.Strings(sorted) + for _, f := range sorted { + fmt.Fprintf(&b, " %s\n", f) + } + } else { + b.WriteString(" (none)\n") + } + + b.WriteString("\nDeleted:\n") + if len(info.Diff.Deleted) > 0 { + sorted := make([]string, len(info.Diff.Deleted)) + copy(sorted, info.Diff.Deleted) + sort.Strings(sorted) + for _, f := range sorted { + fmt.Fprintf(&b, " %s\n", f) + } + } else { + b.WriteString(" (none)\n") + } + + notesChanged := NotesUpdated(info.Diff) + if notesChanged { + b.WriteString("\nNotes updated: yes\n") + } else { + b.WriteString("\nNotes updated: no\n") + } + + // Short session with no changes note + noChanges := len(info.Diff.Added) == 0 && len(info.Diff.Modified) == 0 && len(info.Diff.Deleted) == 0 + if duration < 5*time.Second && noChanges { + b.WriteString("(No changes detected)\n") + } + + b.WriteString("────────────────────────────────\n") + + return b.String() +} + +// AppendSessionLog appends a session entry to logs/sessions.log. +func AppendSessionLog(wsDir string, info *SessionInfo) error { + logDir := filepath.Join(wsDir, "logs") + if err := os.MkdirAll(logDir, 0755); err != nil { + return err + } + + logPath := filepath.Join(logDir, "sessions.log") + entry := FormatSessionEntry(info) + + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + _, err = f.WriteString("\n" + entry) + return err +} diff --git a/internal/session/manifest.go b/internal/session/manifest.go new file mode 100644 index 0000000..5912518 --- /dev/null +++ b/internal/session/manifest.go @@ -0,0 +1,171 @@ +package session + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "time" +) + +// FileEntry represents a single file in the workspace manifest. +type FileEntry struct { + Path string `json:"path"` + Size int64 `json:"size"` + Mtime time.Time `json:"mtime"` +} + +// Manifest represents a point-in-time snapshot of workspace files. +type Manifest struct { + CapturedAt time.Time `json:"captured_at"` + Files []FileEntry `json:"files"` +} + +// ignoredPaths returns true for paths that should be excluded from manifest diffing. +func ignoredPath(relPath string) bool { + // Ignore .ctask/ directory contents + if strings.HasPrefix(relPath, ".ctask/") || strings.HasPrefix(relPath, ".ctask\\") { + return true + } + // Ignore task.yaml (ctask updates this itself) + if relPath == "task.yaml" { + return true + } + // Ignore sessions.log (ctask's own logging) + if relPath == filepath.Join("logs", "sessions.log") { + return true + } + return false +} + +// CaptureManifest walks the workspace directory and captures file metadata. +func CaptureManifest(wsDir string) (*Manifest, error) { + m := &Manifest{ + CapturedAt: time.Now().UTC().Truncate(time.Second), + } + + err := filepath.Walk(wsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // skip files we can't stat + } + if info.IsDir() { + // Skip .ctask directory entirely + rel, _ := filepath.Rel(wsDir, path) + if rel == ".ctask" { + return filepath.SkipDir + } + return nil + } + + rel, err := filepath.Rel(wsDir, path) + if err != nil { + return nil + } + // Normalize to forward slashes for consistency + rel = filepath.ToSlash(rel) + + m.Files = append(m.Files, FileEntry{ + Path: rel, + Size: info.Size(), + Mtime: info.ModTime().UTC().Truncate(time.Second), + }) + return nil + }) + if err != nil { + return nil, err + } + + return m, nil +} + +// WriteManifest writes a manifest to a JSON file. +func WriteManifest(path string, m *Manifest) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// ReadManifest reads a manifest from a JSON file. +func ReadManifest(path string) (*Manifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + return &m, nil +} + +// ManifestDiff represents the differences between two manifests. +type ManifestDiff struct { + Added []string // files only in end manifest + Modified []string // files in both, but size or mtime differ + Deleted []string // files only in start manifest +} + +// DiffManifests compares start and end manifests, applying ignore rules. +func DiffManifests(start, end *Manifest) *ManifestDiff { + diff := &ManifestDiff{} + + startMap := make(map[string]FileEntry) + for _, f := range start.Files { + if !ignoredPath(f.Path) { + startMap[f.Path] = f + } + } + + endMap := make(map[string]FileEntry) + for _, f := range end.Files { + if !ignoredPath(f.Path) { + endMap[f.Path] = f + } + } + + // Added: in end but not start + for path := range endMap { + if _, ok := startMap[path]; !ok { + diff.Added = append(diff.Added, path) + } + } + + // Modified: in both but different size or mtime + for path, endEntry := range endMap { + if startEntry, ok := startMap[path]; ok { + if endEntry.Size != startEntry.Size || !endEntry.Mtime.Equal(startEntry.Mtime) { + diff.Modified = append(diff.Modified, path) + } + } + } + + // Deleted: in start but not end + for path := range startMap { + if _, ok := endMap[path]; !ok { + diff.Deleted = append(diff.Deleted, path) + } + } + + return diff +} + +// NotesUpdated checks if notes.md was modified between manifests. +func NotesUpdated(diff *ManifestDiff) bool { + for _, f := range diff.Modified { + if f == "notes.md" { + return true + } + } + for _, f := range diff.Added { + if f == "notes.md" { + return true + } + } + return false +} diff --git a/internal/session/run.go b/internal/session/run.go new file mode 100644 index 0000000..a50caf4 --- /dev/null +++ b/internal/session/run.go @@ -0,0 +1,101 @@ +package session + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/warrenronsiek/ctask/internal/shell" +) + +// LaunchOpts configures a session launch. +type LaunchOpts struct { + WsDir string + EnvVars map[string]string + Agent string + Mode string + Slug string + Shell bool // true = interactive shell, false = agent +} + +// manifestStartPath returns the path to the start manifest file. +func manifestStartPath(wsDir string) string { + return filepath.Join(wsDir, ".ctask", "manifest-start.json") +} + +// Run launches an agent or shell session with pre/post manifest capture and session logging. +// Returns the child process error (for exit code propagation). +func Run(opts LaunchOpts) error { + startTime := time.Now().UTC().Truncate(time.Second) + + // Pre-session: capture start manifest + startManifest, err := CaptureManifest(opts.WsDir) + if err != nil { + fmt.Fprintf(os.Stderr, "[ctask] warning: failed to capture start manifest: %v\n", err) + // Continue anyway — never block the user + } + + if startManifest != nil { + mPath := manifestStartPath(opts.WsDir) + if err := WriteManifest(mPath, startManifest); err != nil { + fmt.Fprintf(os.Stderr, "[ctask] warning: failed to write start manifest: %v\n", err) + } + } + + // Launch the session + var childErr error + if opts.Shell { + childErr = shell.ExecShell(opts.WsDir, opts.EnvVars, opts.Slug, opts.Mode) + } else { + // Print banner before agent launch + for _, line := range shell.BannerLines(opts.Mode, opts.Slug, opts.WsDir) { + fmt.Println(line) + } + childErr = shell.ExecAgent(opts.Agent, opts.WsDir, opts.EnvVars) + } + + // Post-session: capture end manifest and log + endTime := time.Now().UTC().Truncate(time.Second) + + if startManifest != nil { + if logErr := captureAndLog(opts, startManifest, startTime, endTime); logErr != nil { + fmt.Fprintf(os.Stderr, "[ctask] warning: session logging failed: %v\n", logErr) + // Keep manifest-start.json for debugging + } else { + // Clean up manifest-start.json on success + os.Remove(manifestStartPath(opts.WsDir)) + } + } + + return childErr +} + +// captureAndLog captures the end manifest, diffs, and appends to session log. +func captureAndLog(opts LaunchOpts, startManifest *Manifest, startTime, endTime time.Time) error { + endManifest, err := CaptureManifest(opts.WsDir) + if err != nil { + return fmt.Errorf("capturing end manifest: %w", err) + } + + diff := DiffManifests(startManifest, endManifest) + + info := &SessionInfo{ + Agent: opts.Agent, + Mode: opts.Mode, + StartTime: startTime, + EndTime: endTime, + Diff: diff, + } + + // For shell sessions, record "shell" as agent + if opts.Shell { + info.Agent = "shell" + } + + if err := AppendSessionLog(opts.WsDir, info); err != nil { + return fmt.Errorf("appending session log: %w", err) + } + + return nil +}