diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d1b8dc..b325f5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to ccx are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) +## [Unreleased] + +### Added +- goal attribution (#24): `ccx sessions` joins deva launch receipts + (`$XDG_DATA_HOME/ccx/launches/*.jsonl`, written by `deva --goal`, + thevibeworks/deva#499) to sessions by cwd + time. A receipt stamps + sessions in the same cwd starting within [ts-5m, ts+24h]; latest + qualifying receipt wins. `--json` output gains `goal`; new `--goal + SLUG` filter. Receipts are advisory: missing dir or malformed lines + never error, and session files stay untouched. + ## [0.12.0] - 2026-07-24 Both changes answer the second field report — the first one produced by ccx tracing itself: a user audited a live session's trace and caught its two headline numbers lying. Findings 3-5 from the same report are tracked in issues #21-#23. diff --git a/docs/devlog/2026-07-27-goal-attribution.org b/docs/devlog/2026-07-27-goal-attribution.org new file mode 100644 index 0000000..05c8f7d --- /dev/null +++ b/docs/devlog/2026-07-27-goal-attribution.org @@ -0,0 +1,41 @@ +* [2026-07-27] Dev Log: goal attribution from deva launch receipts :FEAT: + +** Context +The operator-ledger loop (goals/brief/debrief over ccx evidence) needs +sessions attributed to goals. Post-hoc inference from JSONL is lossy and +expensive; deva --goal (thevibeworks/deva#499) now stamps intent at +launch as JSONL receipts in $XDG_DATA_HOME/ccx/launches/. ccx side: #24. + +** Why +Sessions are born orphans. A receipt at launch costs one line; ccx can +then answer "which goal was this session serving?" at read time without +touching session files. + +** What +- =FEAT= internal/launches: Receipt, LoadDir, Index.GoalFor +- =FEAT= ccx sessions: goal field in --json, --goal SLUG filter + +** How +Join rule: a receipt stamps sessions in the same cwd starting within +[ts-5m, ts+24h]; the latest qualifying receipt wins. 5m grace absorbs +receipt-vs-session clock ordering at launch; 24h cap stops a stale +stamp from claiming next week's work. Receipts are advisory: missing +dir, unreadable file, malformed line all degrade to "no goal", never +an error. + +** Decisions +| Decision | Alternatives | Rationale | DRI | Timestamp | +| receipts live in ccx data dir | ops ledger stores them | receipts are evidence; the ledger cites session IDs, it does not store evidence | eric | 2026-07-27 | +| cwd+time join, no container match | match receipt.container | session JSONL does not record the container; cwd+time is checkable from both sides | eric | 2026-07-27 | +| latest-qualifying-wins | first-wins | re-launching with a fresh --goal must restamp the workspace | eric | 2026-07-27 | + +** Notes +- Verified live: a receipt at 11:40Z stamped exactly the one real + session starting 11:48Z in the same cwd; two earlier same-cwd + sessions (04:14Z, 08:43Z) stayed unstamped after the ts was + narrowed. First attempt used an over-broad ts and stamped all + three -- the rule is coarse by design (cwd-scoped, 24h): a receipt + claims the whole cwd until superseded. Frequent --goal launches + keep attribution fresh; that is the intended usage. +- Gotcha reconfirmed: session start_time/end_time in --json are UTC + (Z). --tz only moves the scope window, not the rendered instants. diff --git a/internal/cmd/sessions.go b/internal/cmd/sessions.go index 2612bda..afae648 100644 --- a/internal/cmd/sessions.go +++ b/internal/cmd/sessions.go @@ -12,6 +12,7 @@ import ( "github.com/thevibeworks/ccx/internal/catalog" "github.com/thevibeworks/ccx/internal/config" "github.com/thevibeworks/ccx/internal/insight" + "github.com/thevibeworks/ccx/internal/launches" "github.com/thevibeworks/ccx/internal/parser" "github.com/thevibeworks/ccx/internal/provider" ) @@ -41,6 +42,7 @@ var ( sessionsTZ string sessionsModel string sessionsAll bool + sessionsGoal string ) func init() { @@ -55,6 +57,7 @@ func init() { sessionsCmd.Flags().StringVar(&sessionsTZ, "tz", "local", "timezone for --scope/--after/--before: IANA name, UTC, local, or offset like +8") sessionsCmd.Flags().StringVar(&sessionsModel, "model", "", "filter by model name substring") sessionsCmd.Flags().BoolVar(&sessionsAll, "all", false, "list sessions across all projects") + sessionsCmd.Flags().StringVar(&sessionsGoal, "goal", "", "filter by launch-receipt goal slug (deva --goal)") } func runSessions(cmd *cobra.Command, args []string) error { @@ -118,13 +121,24 @@ func runSessions(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to list sessions: %w", err) } + + receipts := launches.Load() + if sessionsGoal != "" { + matched := sessions[:0] + for _, s := range sessions { + if receipts.GoalFor(s.CWD, s.StartTime) == sessionsGoal { + matched = append(matched, s) + } + } + sessions = matched + } if len(sessions) == 0 { fmt.Println("No sessions found.") return nil } if sessionsJSON { - return printSessionsJSON(sessions) + return printSessionsJSON(sessions, receipts) } return printSessionsTable(sessions, projectName == "" && sessionsAll) @@ -179,6 +193,7 @@ type sessionJSON struct { Provider string `json:"provider"` Project string `json:"project"` Workspace string `json:"workspace,omitempty"` + Goal string `json:"goal,omitempty"` Summary string `json:"summary"` StartTime string `json:"start_time"` EndTime string `json:"end_time"` @@ -191,7 +206,7 @@ type sessionJSON struct { FilePath string `json:"file_path,omitempty"` } -func printSessionsJSON(sessions []*parser.Session) error { +func printSessionsJSON(sessions []*parser.Session, receipts *launches.Index) error { items := make([]sessionJSON, len(sessions)) for i, s := range sessions { cacheTokens := s.Stats.CacheReadTokens + s.Stats.CacheCreateTokens @@ -200,6 +215,7 @@ func printSessionsJSON(sessions []*parser.Session) error { Provider: s.Provider, Project: s.ProjectName, Workspace: s.CWD, + Goal: receipts.GoalFor(s.CWD, s.StartTime), Summary: s.Summary, StartTime: s.StartTime.Format(time.RFC3339), EndTime: s.EndTime.Format(time.RFC3339), diff --git a/internal/cmd/sessions_test.go b/internal/cmd/sessions_test.go index 9ba8b7e..1c93f21 100644 --- a/internal/cmd/sessions_test.go +++ b/internal/cmd/sessions_test.go @@ -77,7 +77,7 @@ func TestPrintSessionsJSONIncludesWorkspaceEndAndMetrics(t *testing.T) { } out := captureStdout(t, func() { - if err := printSessionsJSON(sessions); err != nil { + if err := printSessionsJSON(sessions, nil); err != nil { t.Fatalf("printSessionsJSON() error: %v", err) } }) diff --git a/internal/launches/launches.go b/internal/launches/launches.go new file mode 100644 index 0000000..1aa7f8e --- /dev/null +++ b/internal/launches/launches.go @@ -0,0 +1,104 @@ +// Package launches reads deva launch receipts and attributes sessions +// to goals by cwd + time. Receipts are advisory evidence: a missing +// dir, unreadable file, or malformed line is never an error. +package launches + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "sort" + "time" + + "github.com/thevibeworks/ccx/internal/config" +) + +// Receipt is one launch stamp: an operator (via deva --goal) declaring +// that work in CWD from TS onward serves Goal. +type Receipt struct { + TS time.Time `json:"ts"` + Goal string `json:"goal"` + Agent string `json:"agent,omitempty"` + CWD string `json:"cwd"` + Container string `json:"container,omitempty"` + Source string `json:"source,omitempty"` +} + +// A receipt stamps sessions in the same cwd that start within +// [TS-startGrace, TS+maxAge]; the latest qualifying receipt wins. +// startGrace absorbs receipt-vs-session clock ordering at launch; +// maxAge stops a stale stamp from claiming next week's work. +const ( + startGrace = 5 * time.Minute + maxAge = 24 * time.Hour +) + +// Index answers "which goal was this session launched under?". +type Index struct { + byCWD map[string][]Receipt // each slice sorted by TS ascending +} + +// Dir is where deva writes receipts: /launches/YYYY-MM-DD.jsonl. +func Dir() string { + return filepath.Join(config.DataDir(), "launches") +} + +// Load reads every receipt from the default dir. +func Load() *Index { + return LoadDir(Dir()) +} + +// LoadDir reads every *.jsonl receipt file under dir. +func LoadDir(dir string) *Index { + idx := &Index{byCWD: map[string][]Receipt{}} + files, err := filepath.Glob(filepath.Join(dir, "*.jsonl")) + if err != nil || len(files) == 0 { + return idx + } + for _, f := range files { + fh, err := os.Open(f) + if err != nil { + continue + } + sc := bufio.NewScanner(fh) + for sc.Scan() { + var r Receipt + if json.Unmarshal(sc.Bytes(), &r) != nil { + continue + } + if r.Goal == "" || r.CWD == "" || r.TS.IsZero() { + continue + } + idx.byCWD[r.CWD] = append(idx.byCWD[r.CWD], r) + } + fh.Close() + } + for cwd, rs := range idx.byCWD { + sort.Slice(rs, func(i, j int) bool { return rs[i].TS.Before(rs[j].TS) }) + idx.byCWD[cwd] = rs + } + return idx +} + +// GoalFor returns the goal a session was launched under, or "". +func (x *Index) GoalFor(workspace string, start time.Time) string { + if x == nil || len(x.byCWD) == 0 || workspace == "" || start.IsZero() { + return "" + } + goal := "" + for _, r := range x.byCWD[workspace] { + if r.TS.After(start.Add(startGrace)) { + break // sorted ascending: later receipts can't qualify either + } + if start.Sub(r.TS) <= maxAge { + goal = r.Goal // latest qualifying wins + } + } + return goal +} + +// Empty reports whether the index holds no receipts. +func (x *Index) Empty() bool { + return x == nil || len(x.byCWD) == 0 +} diff --git a/internal/launches/launches_test.go b/internal/launches/launches_test.go new file mode 100644 index 0000000..46c3de2 --- /dev/null +++ b/internal/launches/launches_test.go @@ -0,0 +1,85 @@ +package launches + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func writeReceipts(t *testing.T, lines string) *Index { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "2026-07-27.jsonl"), []byte(lines), 0o644); err != nil { + t.Fatal(err) + } + return LoadDir(dir) +} + +func TestGoalForMatch(t *testing.T) { + idx := writeReceipts(t, `{"ts":"2026-07-27T10:00:00Z","goal":"auth-fix","cwd":"/w/app"} +`) + start := time.Date(2026, 7, 27, 11, 0, 0, 0, time.UTC) + if got := idx.GoalFor("/w/app", start); got != "auth-fix" { + t.Fatalf("got %q, want auth-fix", got) + } +} + +func TestGoalForLatestWins(t *testing.T) { + idx := writeReceipts(t, `{"ts":"2026-07-27T08:00:00Z","goal":"old-goal","cwd":"/w/app"} +{"ts":"2026-07-27T10:00:00Z","goal":"new-goal","cwd":"/w/app"} +`) + start := time.Date(2026, 7, 27, 11, 0, 0, 0, time.UTC) + if got := idx.GoalFor("/w/app", start); got != "new-goal" { + t.Fatalf("got %q, want new-goal", got) + } +} + +func TestGoalForExpiry(t *testing.T) { + idx := writeReceipts(t, `{"ts":"2026-07-25T10:00:00Z","goal":"stale","cwd":"/w/app"} +`) + start := time.Date(2026, 7, 27, 11, 0, 0, 0, time.UTC) + if got := idx.GoalFor("/w/app", start); got != "" { + t.Fatalf("got %q, want empty (24h expiry)", got) + } +} + +func TestGoalForStartGrace(t *testing.T) { + idx := writeReceipts(t, `{"ts":"2026-07-27T10:02:00Z","goal":"grace","cwd":"/w/app"} +`) + inside := time.Date(2026, 7, 27, 10, 0, 0, 0, time.UTC) + if got := idx.GoalFor("/w/app", inside); got != "grace" { + t.Fatalf("got %q, want grace (receipt 2m after start is within grace)", got) + } + outside := time.Date(2026, 7, 27, 9, 50, 0, 0, time.UTC) + if got := idx.GoalFor("/w/app", outside); got != "" { + t.Fatalf("got %q, want empty (receipt 12m after start)", got) + } +} + +func TestGoalForWrongCWD(t *testing.T) { + idx := writeReceipts(t, `{"ts":"2026-07-27T10:00:00Z","goal":"auth-fix","cwd":"/w/app"} +`) + start := time.Date(2026, 7, 27, 11, 0, 0, 0, time.UTC) + if got := idx.GoalFor("/w/other", start); got != "" { + t.Fatalf("got %q, want empty (different cwd)", got) + } +} + +func TestLoadSkipsMalformed(t *testing.T) { + idx := writeReceipts(t, `not json at all +{"ts":"2026-07-27T10:00:00Z","cwd":"/w/app"} +{"ts":"2026-07-27T10:00:00Z","goal":"ok","cwd":"/w/app"} +`) + start := time.Date(2026, 7, 27, 11, 0, 0, 0, time.UTC) + if got := idx.GoalFor("/w/app", start); got != "ok" { + t.Fatalf("got %q, want ok (bad lines skipped)", got) + } +} + +func TestLoadMissingDir(t *testing.T) { + idx := LoadDir(filepath.Join(t.TempDir(), "nope")) + if !idx.Empty() { + t.Fatal("missing dir must yield empty index") + } +} diff --git a/skills/ccx/SKILL.md b/skills/ccx/SKILL.md index 871d05a..7495ecb 100644 --- a/skills/ccx/SKILL.md +++ b/skills/ccx/SKILL.md @@ -22,6 +22,7 @@ ccx │ └── --search QUERY # Search summaries │ └── --after DATE # After date (YYYY-MM-DD) │ └── --sort time|messages|prompts # Sort order (prompts = human-turn count) +│ └── --goal SLUG # Filter by launch-receipt goal (deva --goal) ├── view [session] # View session in terminal │ └── --brief # Conversation only ├── export [session] # Export session