Skip to content
Merged
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
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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).

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down
31 changes: 25 additions & 6 deletions cmd/ergo/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 5 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand Down
24 changes: 15 additions & 9 deletions docs/suggested-hooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <id> --state done)" >&2
fi
fi
Expand Down
4 changes: 2 additions & 2 deletions internal/ergo/commands_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions internal/ergo/commands_work.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/ergo/prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 31 additions & 6 deletions internal/ergo/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading