Skip to content
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,40 @@ All notable changes to ike are recorded here. The format follows

## [Unreleased]

### Changed

- **Each space is now its own file** (data format version 5). `tasks.json`
becomes a small manifest — which space is current, plus the two consent
settings — and the spaces live beside it in `tasks.json.spaces/`, one JSON
file each with its own rolling `.bak`. A mutation rewrites only the files it
touched, so corruption or a bad write in one space can never take the others
with it. One damaged space file no longer blocks the rest: the other spaces
keep working, the damaged one is listed as *unreadable* instead of silently
vanishing, nothing ever writes over its file, and
`ike space rm <name> --force` retires it to `.bak` once you give up on
repairing it.
- **Migration is automatic and keeps a permanent escape hatch.** An existing
file (any version back to v1) is read as-is and split on the first change you
make; the pre-split file is kept as `tasks.json.pre-v5.bak` and never
overwritten. Older ike binaries refuse the new manifest outright rather than
misreading it as empty.
- **`ike space export` now writes exactly the space's own file** — export and a
hand copy of `tasks.json.spaces/<name>.json` are byte-identical, and neither
can carry the consent flags because the format has no field for them. A
single exported space still opens with `--file` for reading *and* editing;
only operations that would need a second space refuse. `ike space import`
reads all three shapes: an old single-file export, a v5 space file, and a
whole v5 tree with `--all`.
- Removing a space renames its file to `.bak` — removal and backup in one
atomic step — instead of relying on the document-wide backup.

### Fixed

- **Renaming a space now moves its plans too.** `ike space rename` used to
leave `tasks.json.plans/<old-name>/` behind, stranding every plan the space
had. `ike plan --prune` additionally sweeps plan directories whose space no
longer exists — while leaving an unreadable space's plans strictly alone.

## [0.2.0] - 2026-08-01

### Added
Expand Down
34 changes: 20 additions & 14 deletions CLAUDE.md

Large diffs are not rendered by default.

13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,14 @@ ike space use work # every later command follows
ike add "Fix prod bug" -s work # or act on one space just once
```

Everything lives in `~/.local/share/ike/tasks.json` (mode `0600`, in a `0700`
directory). Writes go through a lock file and an atomic rename with the previous
contents kept as `.bak`, so three frontends can run at once and an interrupted
write costs one change rather than the matrix. The file is self-contained and
portable — copy it to another machine and every space comes with it.
Everything lives under `~/.local/share/ike/` (mode `0600` files in `0700`
directories): a small `tasks.json` manifest, and one file per space beside it
in `tasks.json.spaces/`. Writes go through a lock file and atomic renames with
each file's previous contents kept as its `.bak`, so three frontends can run at
once and an interrupted write costs one change rather than the matrix. One
damaged space file costs that one space, never the others. A space's file is
self-contained and portable — it *is* the export format, so copying it to
another machine is the whole move.

→ **[docs/data.md](docs/data.md)** covers spaces in full, exporting and
importing a single matrix, the file's durability guarantees and their one
Expand Down
111 changes: 80 additions & 31 deletions docs/data.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
# Spaces, files, and your data

Everything ike knows lives in one JSON file you own. This page covers how that
file is organised, where it lives, how it survives being written to by three
frontends at once, and how to move it around.
Everything ike knows lives in a handful of JSON files you own: a small
manifest, and one file per space beside it. This page covers how that data is
organised, where it lives, how it survives being written to by three frontends
at once, and how to move it around.

- [Spaces](#spaces)
- [Moving a matrix between machines](#moving-a-matrix-between-machines)
- [Where your data lives](#where-your-data-lives)
- [When a space file is damaged](#when-a-space-file-is-damaged)
- [Undo and redo](#undo-and-redo)
- [Renaming the quadrants](#renaming-the-quadrants)

---

## Spaces

One data file holds several independent matrices, called **spaces** — work and
Your data holds several independent matrices, called **spaces** — work and
personal, say. Each has its own tasks, archive, quadrant headings, ID numbering,
and undo history, so `ike undo` in one can never reach into another. A fresh
install has a single space named `default`, and nothing changes until you make
a second.

**Each space is one file on disk.** `tasks.json` is a small manifest recording
which space is current and the two agent-consent settings; the spaces
themselves live beside it in `tasks.json.spaces/`, one JSON file each, named
after the space. Corruption in one space's file cannot touch the others, and
copying a space to another machine is copying one file.

```sh
ike space # list spaces, marking the current one
ike space new work # create it (does not switch)
Expand All @@ -42,46 +50,74 @@ the matrix. In the TUI, `s` opens a space picker and `]`/`[` move between spaces

**Deleting a space cannot be undone.** History lives inside the space, so there
is no stack left to revert from — which is why `ike space rm` names the counts it
is about to destroy and needs `--force` if the space still holds anything. The
previous file contents remain in `tasks.json.bak` until the next change.
is about to destroy and needs `--force` if the space still holds anything.
Removing a space renames its file to `<name>.json.bak` inside the spaces
directory rather than deleting it, so the contents survive until a new space
claims the name.

## Moving a matrix between machines

The data file is self-contained and fully portable: nothing in it refers to a
path or a machine, and timestamps are stored in UTC. Copy it to another computer
and every space comes with it. The sidecar `.lock` and `.bak` files do not need
copying.

To move one space rather than the whole file:
A space's file is self-contained and fully portable: nothing in it refers to a
path or a machine, and timestamps are stored in UTC. `ike space export` writes
exactly the same bytes as the space's own file in `tasks.json.spaces/`, so
exporting and copying the file by hand are the same operation.

```sh
ike space export work ~/work-matrix.json # a standalone ike data file
ike space export work ~/work-matrix.json # a standalone space file
# copy that one file to the other machine, then:
ike space import ~/work-matrix.json
ike space import ~/old.json --as archive-2025
ike --file ~/work-matrix.json list # or just open it in place
```

An export is an ordinary data file, so `--file` opens it directly. **MCP access
is always off in an exported file**, whatever it was in the original: agent
access is a decision about a file on a machine, and an export is made to travel.
Importing a name that is already in use is an error rather than a merge — use
`--as` to bring it in under a different name.
A single space file opens with `--file` for reading and editing — task changes,
undo, everything except growing more spaces, which a one-space file has no room
for. **Neither consent setting travels in an exported file**: the space-file
format simply has no field for them, because agent access is a decision about a
file on a machine, and an export is made to travel. Importing a name that is
already in use is an error rather than a merge — use `--as` to bring it in
under a different name.

To move *everything*, copy `tasks.json` together with the whole
`tasks.json.spaces/` directory (and `tasks.json.plans/` if you use plans), then
`ike space import <copied tasks.json> --all` — or just point `IKE_DATA_FILE` at
the copy. The sidecar `.lock` and `.bak` files never need copying.

Note that attached plans do **not** yet travel with `ike space export`.
`ike space import` still reads data files from every earlier version of ike,
including the old single-file format.

Note that attached plans do **not** yet travel with `ike space export`. To move
them by hand, copy `tasks.json.plans/<space>/` alongside the exported file.

## Where your data lives

Tasks live in `$XDG_DATA_HOME/ike/tasks.json` (default
`~/.local/share/ike/tasks.json`), created mode `0600` in a `0700` directory —
your matrix is not readable by other users on the machine.
Tasks live under `$XDG_DATA_HOME/ike/` (default `~/.local/share/ike/`):

```
tasks.json # manifest: current space + consent settings
tasks.json.spaces/ # one file per space
work.json
work.json.bak # that space's previous contents
tasks.json.plans/ # plan bodies, one file per task
tasks.json.bak # the manifest's previous contents
tasks.json.lock # write lock; never needs touching
```

Everything is created mode `0600` in `0700` directories — your matrix is not
readable by other users on the machine.

Writes are serialized through the sidecar lock file and land via atomic
renames, so the TUI, CLI, and MCP server can run at the same time without
losing updates. A mutation rewrites only the files it changed. Each write is
flushed to disk before the rename, and every file's previous contents are kept
as its `.bak`, so an interrupted write costs at most one change rather than the
whole matrix. If a file is ever unreadable, ike refuses to overwrite it rather
than starting fresh over the top.

Writes are serialized through a sidecar lock file and land via an atomic
rename, so the TUI, CLI, and MCP server can run at the same time without losing
updates. Each write is flushed to disk before the rename, and the previous
contents are kept as `tasks.json.bak`, so an interrupted write costs at most
one change rather than the whole matrix. If the file is ever unreadable, ike
refuses to overwrite it rather than starting fresh over the top.
Data from older ike versions (a single `tasks.json` holding everything) is read
as-is and split into the new layout on the first change you make. The original
file is kept as `tasks.json.pre-v5.bak`, permanently — it is your escape hatch
back to the pre-split state, and nothing ever overwrites it.

Override the location with `--file` or `IKE_DATA_FILE` — highest precedence
first: `--file`, then `IKE_DATA_FILE`, then the default above. Either must be an
Expand All @@ -94,14 +130,27 @@ unreliable on NFS and some FUSE mounts — so pointing the data file at a Dropbo
iCloud, or network-mounted folder and writing from two machines at once is not
covered. A single machine writing to a synced folder is fine.

## When a space file is damaged

One damaged space costs that one space, never the others. If a space's file
cannot be parsed — a bad sync, a stray hand edit — every other space keeps
working, and the damaged one shows up in `ike space list` and the TUI picker
marked **unreadable** rather than silently disappearing. Commands aimed at it
say what is wrong and which file to look at.

Nothing ike does will touch an unreadable file, so you can try to repair it in
place (it is JSON; the `.bak` beside it may also be intact). Once you give up
on it, `ike space rm <name> --force` retires it — even then the file is renamed
to `.bak`, not deleted, in case it can still be recovered later.

## Undo and redo

Every change records a snapshot, so `ike undo` (or `u` in the TUI) reverts the
last one — including a delete, and including changes made from a different
frontend. Run it repeatedly to walk further back; the last 20 changes are kept
in the data file, so history survives restarts. Undo does not recycle task IDs.
History belongs to the space it was made in, so `ike undo` never reverts a change
made in a different one.
in the space's file, so history survives restarts. Undo does not recycle task
IDs. History belongs to the space it was made in, so `ike undo` never reverts a
change made in a different one.

`ike redo` (or `U` / `ctrl+r`) re-applies what you just undid, and is itself
undoable. **Any new change discards the redo history** — that includes a change
Expand Down
17 changes: 14 additions & 3 deletions internal/cli/spaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,11 @@ func spaceSummary(spaces []store.SpaceInfo) string {

// spaceCounts describes what a space holds as a listing column.
func spaceCounts(sp store.SpaceInfo) string {
if sp.Unreadable {
// Counts would be a lie — the file cannot be parsed, so nothing is
// known about what it holds.
return "unreadable — recover its file or `ike space rm` it with --force"
}
if sp.Archived == 0 {
return fmt.Sprintf("%d active", sp.Active)
}
Expand Down Expand Up @@ -269,8 +274,9 @@ func newSpaceRmCmd(open opener) *cobra.Command {
Long: "Delete a space, its tasks, its archive, and its history.\n\n" +
"Unlike deleting a task, this cannot be undone — the space has no\n" +
"history left to undo it from. A space holding anything needs --force,\n" +
"and the previous file contents remain in tasks.json.bak until the next\n" +
"change.",
"and the space's file is kept as a .bak beside the others until a new\n" +
"space claims the name. An unreadable space also needs --force, since\n" +
"its file may still hold everything the space ever had.",
Args: cobra.ExactArgs(1),
RunE: withStore(open, func(cmd *cobra.Command, args []string, s *store.Store) error {
if err := rejectSpaceFlag(cmd); err != nil {
Expand All @@ -288,7 +294,12 @@ func newSpaceRmCmd(open opener) *cobra.Command {
name := task.SanitizeDisplay(removed.Name)
// Say what was destroyed, not just that something was: the counts
// are the only record left once the space is gone.
fmt.Fprintf(cmd.OutOrStdout(), "deleted space %s (%s)\n", name, spaceCounts(removed))
if removed.Unreadable {
fmt.Fprintf(cmd.OutOrStdout(),
"deleted unreadable space %s; its file is kept as a .bak in case it can be recovered\n", name)
} else {
fmt.Fprintf(cmd.OutOrStdout(), "deleted space %s (%s)\n", name, spaceCounts(removed))
}
if removed.Current {
d, err := s.Load()
if err != nil {
Expand Down
27 changes: 23 additions & 4 deletions internal/store/durability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,24 +102,28 @@ func TestWriteDoesNotUsePredictableTempName(t *testing.T) {
}
}

// Losing the data file used to mean hand-editing JSON or starting over.
// Losing the data used to mean hand-editing JSON or starting over. Since the
// split into per-space files the backup lives beside each space's file — a
// task mutation touches one space, so that is the file whose previous state
// needs preserving.
func TestWriteKeepsABackupOfThePreviousContents(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tasks.json")
spacePath := filepath.Join(spacesDir(path), encodeSpaceFilename(defaultSpace))
s := OpenAt(path)

if _, _, err := s.Add("first", task.Do); err != nil {
t.Fatal(err)
}
// No backup yet: there was nothing to preserve before the first write.
if _, err := os.Stat(path + ".bak"); !os.IsNotExist(err) {
if _, err := os.Stat(spacePath + ".bak"); !os.IsNotExist(err) {
t.Errorf("unexpected backup after the first write: %v", err)
}

if _, _, err := s.Add("second", task.Do); err != nil {
t.Fatal(err)
}
bak, err := os.ReadFile(path + ".bak")
bak, err := os.ReadFile(spacePath + ".bak")
if err != nil {
t.Fatalf("no backup after the second write: %v", err)
}
Expand All @@ -130,13 +134,28 @@ func TestWriteKeepsABackupOfThePreviousContents(t *testing.T) {
t.Error("backup holds the new state, not the previous one")
}
// The backup is as private as the data file.
fi, err := os.Stat(path + ".bak")
fi, err := os.Stat(spacePath + ".bak")
if err != nil {
t.Fatal(err)
}
if got := fi.Mode().Perm(); got != dataFileMode {
t.Errorf("backup mode = %#o, want %#o", got, dataFileMode)
}

// The manifest gets the same treatment when it is the thing that changed.
if _, err := s.NewSpace("other"); err != nil {
t.Fatal(err)
}
if _, err := s.UseSpace("other"); err != nil {
t.Fatal(err)
}
mbak, err := os.ReadFile(path + ".bak")
if err != nil {
t.Fatalf("no manifest backup after a document-level change: %v", err)
}
if !strings.Contains(string(mbak), `"current": "default"`) {
t.Errorf("manifest backup does not hold the previous state: %s", mbak)
}
}

// Every byte of a completed write must be on disk before the rename, or a
Expand Down
Loading
Loading