From 1b18ff255c68c98b0deec68a371b0fc744704edd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 14:40:15 +0000 Subject: [PATCH] feat: rename events.jsonl to plans.jsonl with backwards compatibility - New repos initialized with `ergo init` now use `plans.jsonl` - Existing repos with `events.jsonl` continue to work seamlessly - Added getEventsPath() helper that prefers plans.jsonl, falls back to events.jsonl - Updated all event log references throughout codebase - Updated documentation (README, architecture.md, CHANGELOG, pre-commit hook) - Added comprehensive backwards compatibility tests - All tests passing https://claude.ai/code/session_01Nib7aACtqjiZTKmuxBguyu --- CHANGELOG.md | 7 +- README.md | 8 ++- cmd/ergo/integration_test.go | 31 +++++++-- docs/architecture.md | 7 +- docs/suggested-hooks/pre-commit | 24 ++++--- internal/ergo/commands_create.go | 4 +- internal/ergo/commands_work.go | 6 +- internal/ergo/prune.go | 2 +- internal/ergo/storage.go | 37 ++++++++-- internal/ergo/storage_test.go | 113 +++++++++++++++++++++++++++++++ 10 files changed, 205 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 350601d..73183c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -_No changes yet._ +### Changed +- Event log file renamed from `events.jsonl` to `plans.jsonl` for clarity. +- Existing repositories with `events.jsonl` continue to work (backwards compatible). +- New repositories initialized with `ergo init` will use `plans.jsonl`. ## [0.9.3] - 2026-02-05 @@ -78,7 +81,7 @@ _No changes yet._ ## [0.7.2] - 2026-01-30 ### Fixed -- `init` is idempotent and now repairs missing `.ergo/lock` or `.ergo/events.jsonl` when `.ergo/` already exists. +- `init` is idempotent and now repairs missing `.ergo/lock` or event log file when `.ergo/` already exists. - `new`/`set`/other write commands auto-create missing `.ergo/lock` on demand. - Event log parsing tolerates a truncated final line and reports corruption with file+line context (including git conflict marker hints). diff --git a/README.md b/README.md index 32c58bb..0ba6d32 100644 --- a/README.md +++ b/README.md @@ -138,13 +138,15 @@ All state lives in `.ergo/` at your repo root: ``` .ergo/ -├── events.jsonl # append-only event log (source of truth) +├── plans.jsonl # append-only event log (source of truth) └── lock # flock(2) lock file for write serialization ``` +(For backwards compatibility, `events.jsonl` is also supported if it already exists.) + **Why append-only JSONL?** - **Auditable:** Full history of every state change, who made it, when. -- **Inspectable:** `cat .ergo/events.jsonl | jq` — no special tools needed. +- **Inspectable:** `cat .ergo/plans.jsonl | jq` — no special tools needed. - **Recoverable:** Corrupt state? Replay events. Want to undo? Filter events. - **Diffable:** `git diff` shows exactly what changed. @@ -154,7 +156,7 @@ All state lives in `.ergo/` at your repo root: - Multiple agents can safely race to claim work; exactly one wins, others fail fast and should retry. **State reconstruction:** -On each command, ergo replays `events.jsonl` to build current state in memory quickly (100 tasks: ~3ms, 1000 tasks: ~15ms) and guarantees consistency. Run `ergo compact` to collapse history if the log grows large. To verify: `go test -bench=. -benchmem` +On each command, ergo replays `plans.jsonl` to build current state in memory quickly (100 tasks: ~3ms, 1000 tasks: ~15ms) and guarantees consistency. Run `ergo compact` to collapse history if the log grows large. To verify: `go test -bench=. -benchmem` **Why not SQLite?** SQLite is great, but binary files don't diff well in git. JSONL is trivially inspectable (`cat | jq`), merges via git, and append-only writes with `flock` are simple. For a few thousand tasks, replay is instant. diff --git a/cmd/ergo/integration_test.go b/cmd/ergo/integration_test.go index 592f3d0..0d055b6 100644 --- a/cmd/ergo/integration_test.go +++ b/cmd/ergo/integration_test.go @@ -137,12 +137,31 @@ func setupErgoWithEventsOnly(t *testing.T) string { return dir } +// getEventFilePath returns the path to the event log file (plans.jsonl or events.jsonl) +func getEventFilePath(dir string) string { + plansPath := filepath.Join(dir, ".ergo", "plans.jsonl") + eventsPath := filepath.Join(dir, ".ergo", "events.jsonl") + + // Prefer plans.jsonl if it exists + if _, err := os.Stat(plansPath); err == nil { + return plansPath + } + + // Fall back to events.jsonl + if _, err := os.Stat(eventsPath); err == nil { + return eventsPath + } + + // Default to plans.jsonl + return plansPath +} + func countEventLines(t *testing.T, dir string) int { t.Helper() - path := filepath.Join(dir, ".ergo", "events.jsonl") + path := getEventFilePath(dir) data, err := os.ReadFile(path) if err != nil { - t.Fatalf("failed to read events.jsonl: %v", err) + t.Fatalf("failed to read event log: %v", err) } trimmed := strings.TrimSpace(string(data)) if trimmed == "" { @@ -865,9 +884,9 @@ func TestPrune_CompactRemovesHistory(t *testing.T) { t.Fatalf("expected post-compact not-found error, got code=%d stderr=%q", code, stderr) } - data, err := os.ReadFile(filepath.Join(dir, ".ergo", "events.jsonl")) + data, err := os.ReadFile(getEventFilePath(dir)) if err != nil { - t.Fatalf("failed to read events.jsonl: %v", err) + t.Fatalf("failed to read event log: %v", err) } if strings.Contains(string(data), "tombstone") || strings.Contains(string(data), taskID) { t.Fatalf("expected compacted log to remove pruned history, got: %s", string(data)) @@ -962,9 +981,9 @@ func TestPrune_ConcurrentRuns(t *testing.T) { t.Fatalf("expected at least one prune to succeed, got codes %d and %d", r1.code, r2.code) } - data, err := os.ReadFile(filepath.Join(dir, ".ergo", "events.jsonl")) + data, err := os.ReadFile(getEventFilePath(dir)) if err != nil { - t.Fatalf("failed to read events.jsonl: %v", err) + t.Fatalf("failed to read event log: %v", err) } tombstones := strings.Count(string(data), "tombstone") if tombstones != 1 { diff --git a/docs/architecture.md b/docs/architecture.md index 3b1c526..4868b37 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,9 +12,11 @@ It is not a user manual—**the user manual is `ergo --help` and `ergo quickstar ## High-level mental model -ergo stores task/epic state as an **append-only JSONL event log** in `.ergo/events.jsonl`. +ergo stores task/epic state as an **append-only JSONL event log** in `.ergo/plans.jsonl`. Every command rebuilds current state by **replaying** events into an in-memory graph, then performs a read or appends new events. +(For backwards compatibility, `.ergo/events.jsonl` is also supported if it already exists.) + This design is intentionally “boring”: - Plain text storage is diffable and debuggable with common tools. - Replay is fast enough for the intended scale (hundreds to low thousands of items). @@ -24,7 +26,8 @@ This design is intentionally “boring”: ### `.ergo/` layout -- `.ergo/events.jsonl`: append-only event log (source of truth). +- `.ergo/plans.jsonl`: append-only event log (source of truth). + - For backwards compatibility, `.ergo/events.jsonl` is also supported if it already exists. - `.ergo/lock`: advisory lock file used to serialize writes. ### Event log invariants diff --git a/docs/suggested-hooks/pre-commit b/docs/suggested-hooks/pre-commit index 5baa1e5..f4edcc6 100755 --- a/docs/suggested-hooks/pre-commit +++ b/docs/suggested-hooks/pre-commit @@ -11,7 +11,7 @@ repo_root="$(git rev-parse --show-toplevel)" echo >&2 echo "# checklist (commit)" >&2 -echo "# - If you changed ergo plans/tasks: stage the log: git add .ergo/events.jsonl" >&2 +echo "# - If you changed ergo plans/tasks: stage the log: git add .ergo/plans.jsonl (or events.jsonl if using legacy name)" >&2 echo "# - Before pushing: run task ci (or let pre-push run it)" >&2 echo >&2 @@ -25,14 +25,20 @@ if (( ${#staged_go_files[@]} > 0 )); then git add -- "${staged_go_files[@]}" fi -# Warn (do not fail) if ergo tasks/log changed but .ergo/events.jsonl is not staged. -if [[ -f "$repo_root/.ergo/events.jsonl" ]]; then - if ! git diff --cached --name-only --diff-filter=ACM | grep -q '^\\.ergo/events\\.jsonl$'; then - if ! git diff --name-only | grep -q '^\\.ergo/events\\.jsonl$'; then - : - else - echo "warning: .ergo/events.jsonl changed but is not staged" >&2 - echo "action: git add .ergo/events.jsonl" >&2 +# Warn (do not fail) if ergo tasks/log changed but plans/events file is not staged. +# Check for plans.jsonl first (new), then events.jsonl (legacy) +ergo_log_file="" +if [[ -f "$repo_root/.ergo/plans.jsonl" ]]; then + ergo_log_file="plans.jsonl" +elif [[ -f "$repo_root/.ergo/events.jsonl" ]]; then + ergo_log_file="events.jsonl" +fi + +if [[ -n "$ergo_log_file" ]]; then + if ! git diff --cached --name-only --diff-filter=ACM | grep -q "^\\.ergo/$ergo_log_file\$"; then + if git diff --name-only | grep -q "^\\.ergo/$ergo_log_file\$"; then + echo "warning: .ergo/$ergo_log_file changed but is not staged" >&2 + echo "action: git add .ergo/$ergo_log_file" >&2 echo "action: close finished tasks (ergo set --state done)" >&2 fi fi diff --git a/internal/ergo/commands_create.go b/internal/ergo/commands_create.go index 9c4476c..7ed7936 100644 --- a/internal/ergo/commands_create.go +++ b/internal/ergo/commands_create.go @@ -35,9 +35,9 @@ func RunInit(args []string, opts GlobalOptions) error { if err := os.MkdirAll(target, 0755); err != nil { return err } - eventsPath := filepath.Join(target, "events.jsonl") + plansPath := filepath.Join(target, plansFileName) lockPath := filepath.Join(target, "lock") - if err := ensureFileExists(eventsPath, 0644); err != nil { + if err := ensureFileExists(plansPath, 0644); err != nil { return err } if err := ensureFileExists(lockPath, 0644); err != nil { diff --git a/internal/ergo/commands_work.go b/internal/ergo/commands_work.go index 7db9b3f..afdcd6d 100644 --- a/internal/ergo/commands_work.go +++ b/internal/ergo/commands_work.go @@ -281,7 +281,7 @@ func RunClaimOldestReady(epicID string, opts GlobalOptions) error { } lockPath := filepath.Join(dir, "lock") - eventsPath := filepath.Join(dir, "events.jsonl") + eventsPath := getEventsPath(dir) reminder := "When you have completed this claimed task, you MUST mark it done." @@ -398,7 +398,7 @@ func buildUpdatedFields(input *TaskInput) []string { func applySetUpdates(dir string, opts GlobalOptions, id string, updates map[string]string, agentID string, quiet bool) error { lockPath := filepath.Join(dir, "lock") - eventsPath := filepath.Join(dir, "events.jsonl") + eventsPath := getEventsPath(dir) // Handle result.path + result.summary (requires file I/O before lock) resultPath, hasPath := updates["result.path"] @@ -1191,7 +1191,7 @@ func RunCompact(opts GlobalOptions) error { return err } lockPath := filepath.Join(dir, "lock") - eventsPath := filepath.Join(dir, "events.jsonl") + eventsPath := getEventsPath(dir) if err := withLock(lockPath, syscall.LOCK_EX, func() error { events, err := readEvents(eventsPath) if err != nil { diff --git a/internal/ergo/prune.go b/internal/ergo/prune.go index 12e2265..555912b 100644 --- a/internal/ergo/prune.go +++ b/internal/ergo/prune.go @@ -36,7 +36,7 @@ func RunPruneApply(dir string, opts GlobalOptions) (PrunePlan, error) { func runPrune(dir string, opts GlobalOptions, apply bool) (PrunePlan, error) { lockPath := filepath.Join(dir, "lock") - eventsPath := filepath.Join(dir, "events.jsonl") + eventsPath := getEventsPath(dir) var plan PrunePlan err := withLock(lockPath, syscall.LOCK_EX, func() error { graph, err := loadGraph(dir) diff --git a/internal/ergo/storage.go b/internal/ergo/storage.go index fc94219..9c5fe41 100644 --- a/internal/ergo/storage.go +++ b/internal/ergo/storage.go @@ -20,7 +20,9 @@ import ( ) const ( - dataDirName = ".ergo" + dataDirName = ".ergo" + plansFileName = "plans.jsonl" + oldEventsFileName = "events.jsonl" // Legacy name, kept for backwards compatibility ) func resolveErgoDir(start string) (string, error) { @@ -72,8 +74,31 @@ func ergoDir(opts GlobalOptions) (string, error) { return resolveErgoDir(start) } +// getEventsPath returns the path to the events/plans file. +// For backwards compatibility: +// - If plans.jsonl exists, use it +// - Otherwise if events.jsonl exists, use it +// - For new files, default to plans.jsonl +func getEventsPath(dir string) string { + plansPath := filepath.Join(dir, plansFileName) + oldPath := filepath.Join(dir, oldEventsFileName) + + // If plans.jsonl exists, use it + if _, err := os.Stat(plansPath); err == nil { + return plansPath + } + + // If events.jsonl exists, use it (backwards compatibility) + if _, err := os.Stat(oldPath); err == nil { + return oldPath + } + + // Default to plans.jsonl for new files + return plansPath +} + func loadGraph(dir string) (*Graph, error) { - eventsPath := filepath.Join(dir, "events.jsonl") + eventsPath := getEventsPath(dir) events, err := readEvents(eventsPath) if err != nil { return nil, err @@ -134,7 +159,7 @@ func readEvents(path string) ([]Event, error) { } if err := scanner.Err(); err != nil { if errors.Is(err, bufio.ErrTooLong) { - return nil, fmt.Errorf("%s: event line too long (> %d bytes); events.jsonl may be corrupted (e.g. missing newlines)", path, maxEventLineBytes) + return nil, fmt.Errorf("%s: event line too long (> %d bytes); file may be corrupted (e.g. missing newlines)", path, maxEventLineBytes) } return nil, err } @@ -227,7 +252,7 @@ func writeAll(w *os.File, data []byte) error { func writeLinkEvent(dir string, opts GlobalOptions, eventType, from, to string) error { lockPath := filepath.Join(dir, "lock") - eventsPath := filepath.Join(dir, "events.jsonl") + eventsPath := getEventsPath(dir) return withLock(lockPath, syscall.LOCK_EX, func() error { graph, err := loadGraph(dir) if err != nil { @@ -274,7 +299,7 @@ func writeLinkEvent(dir string, opts GlobalOptions, eventType, from, to string) } func createTask(dir string, opts GlobalOptions, epicID string, isEpic bool, title, body string) (createOutput, error) { - eventsPath := filepath.Join(dir, "events.jsonl") + eventsPath := getEventsPath(dir) lockPath := filepath.Join(dir, "lock") return createTaskWithDir(dir, opts, lockPath, eventsPath, epicID, isEpic, title, body) } @@ -457,7 +482,7 @@ func getGitHead(repoDir string) string { // The file must exist and be within the project root. func writeResultEvent(dir string, opts GlobalOptions, taskID, summary, relPath string) error { lockPath := filepath.Join(dir, "lock") - eventsPath := filepath.Join(dir, "events.jsonl") + eventsPath := getEventsPath(dir) repoDir := filepath.Dir(dir) return withLock(lockPath, syscall.LOCK_EX, func() error { diff --git a/internal/ergo/storage_test.go b/internal/ergo/storage_test.go index ef46bae..da5c547 100644 --- a/internal/ergo/storage_test.go +++ b/internal/ergo/storage_test.go @@ -120,3 +120,116 @@ func TestReadEvents_TombstoneRoundTrip(t *testing.T) { t.Fatalf("replayEvents: %v", err) } } + +func TestGetEventsPath_PrefersPlansDotJsonl(t *testing.T) { + dir := t.TempDir() + plansPath := filepath.Join(dir, plansFileName) + oldPath := filepath.Join(dir, oldEventsFileName) + + // Create both files + if err := os.WriteFile(plansPath, []byte{}, 0644); err != nil { + t.Fatalf("write plans.jsonl: %v", err) + } + if err := os.WriteFile(oldPath, []byte{}, 0644); err != nil { + t.Fatalf("write events.jsonl: %v", err) + } + + // Should prefer plans.jsonl when both exist + result := getEventsPath(dir) + if result != plansPath { + t.Fatalf("expected %q, got %q", plansPath, result) + } +} + +func TestGetEventsPath_FallbackToEventsJsonl(t *testing.T) { + dir := t.TempDir() + oldPath := filepath.Join(dir, oldEventsFileName) + + // Create only events.jsonl + if err := os.WriteFile(oldPath, []byte{}, 0644); err != nil { + t.Fatalf("write events.jsonl: %v", err) + } + + // Should use events.jsonl when plans.jsonl doesn't exist + result := getEventsPath(dir) + if result != oldPath { + t.Fatalf("expected %q, got %q", oldPath, result) + } +} + +func TestGetEventsPath_DefaultToPlansJsonl(t *testing.T) { + dir := t.TempDir() + plansPath := filepath.Join(dir, plansFileName) + + // Neither file exists + result := getEventsPath(dir) + if result != plansPath { + t.Fatalf("expected %q, got %q", plansPath, result) + } +} + +func TestLoadGraph_WorksWithEventsJsonl(t *testing.T) { + dir := t.TempDir() + oldPath := filepath.Join(dir, oldEventsFileName) + now := time.Now().UTC() + + events := []Event{ + mustNewEvent("new_task", now, NewTaskEvent{ + ID: "T1", + UUID: "uuid-1", + State: stateTodo, + Title: "Task 1", + Body: "Test task", + CreatedAt: formatTime(now), + }), + } + + if err := appendEvents(oldPath, events); err != nil { + t.Fatalf("appendEvents: %v", err) + } + + graph, err := loadGraph(dir) + if err != nil { + t.Fatalf("loadGraph: %v", err) + } + + if len(graph.Tasks) != 1 { + t.Fatalf("expected 1 task, got %d", len(graph.Tasks)) + } + if graph.Tasks["T1"] == nil { + t.Fatal("expected task T1 to exist") + } +} + +func TestLoadGraph_WorksWithPlansJsonl(t *testing.T) { + dir := t.TempDir() + plansPath := filepath.Join(dir, plansFileName) + now := time.Now().UTC() + + events := []Event{ + mustNewEvent("new_task", now, NewTaskEvent{ + ID: "T2", + UUID: "uuid-2", + State: stateTodo, + Title: "Task 2", + Body: "Test task", + CreatedAt: formatTime(now), + }), + } + + if err := appendEvents(plansPath, events); err != nil { + t.Fatalf("appendEvents: %v", err) + } + + graph, err := loadGraph(dir) + if err != nil { + t.Fatalf("loadGraph: %v", err) + } + + if len(graph.Tasks) != 1 { + t.Fatalf("expected 1 task, got %d", len(graph.Tasks)) + } + if graph.Tasks["T2"] == nil { + t.Fatal("expected task T2 to exist") + } +}