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) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 09:56:21 -04:00
parent 57f345ae2b
commit 10ab9efc80
7 changed files with 435 additions and 32 deletions
+171
View File
@@ -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
}