Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 41 additions & 0 deletions docs/devlog/2026-07-27-goal-attribution.org
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 18 additions & 2 deletions internal/cmd/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -41,6 +42,7 @@ var (
sessionsTZ string
sessionsModel string
sessionsAll bool
sessionsGoal string
)

func init() {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"`
Expand All @@ -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
Expand All @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
Expand Down
104 changes: 104 additions & 0 deletions internal/launches/launches.go
Original file line number Diff line number Diff line change
@@ -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: <data>/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
}
85 changes: 85 additions & 0 deletions internal/launches/launches_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions skills/ccx/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading