Skip to content
58 changes: 57 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,63 @@ All notable changes to ike are recorded here. The format follows

## [Unreleased]

Nothing yet.
### Added

- **Delegate a task to an agent.** `ike delegate <id>` (or `D` in the TUI) runs
the Claude Code CLI in the task's working directory and streams what it does.
It uses your own installation, so your authentication, settings, and
per-project `CLAUDE.md` apply. A delegated run never completes the task —
reading what it did and deciding is the point.
- **Attach a plan to a task.** `ike plan <id>` (or `P`) asks an agent to draft
one; `--show`, `--edit`, `--from-file`, and `--clear` manage it by hand, and
`p` in the TUI shows it. Drafting is read-only and needs no permission.
`ike delegate` follows the attached plan if there is one, and `--plan-first`
does both in one command. Plan the work now, decide later whether to do it
yourself or hand it on.
- **`ike agent enable|disable|status`**, off by default and separate from
`ike mcp`. Letting an agent edit your task list and letting ike start a process
that edits your files are different decisions. Like the MCP gate it is per data
file, never carried into an export, and outside undo history.
- **Tasks remember a working directory.** The first run stores the one you give
it or the directory you ran from, so a task stays attached to its project.
`✎` marks a task with a plan and `⣾` one an agent is working on; `esc` detaches
from a run without stopping it, and `ctrl+c` there stops it.
- **`--permission-mode` on `ike delegate`**, validated against the modes the CLI
accepts so a typo fails before a process starts rather than mid-run. Delegated
runs default to `auto`, the mode intended for unattended work.
- **`--effort` on `ike plan` and `ike delegate`, with a level chosen per run.**
Effort controls how deeply the agent thinks and how many tools it reaches for,
which moves a run's wall clock more than the model does. Drafting a plan runs
at `high` because the thinking is the product; carrying out a plan you already
reviewed runs at `medium`, because re-deriving decisions the plan records buys
nothing; a run with no plan stays at `high`. The level and the reason are
printed in the run header, and `--effort` overrides both — validated, so a typo
fails before a process starts.
- **Jump into a real Claude Code session on a task** with `-i` (`ike plan 3 -i`,
`ike delegate 3 -i`) or `c`/`C` in the TUI. ike steps aside, the agent gets the
terminal already briefed on the task and opened in its directory, and exiting
puts you back where you were.
- **The conversation belongs to the task.** ike pins a session ID the first time
and resumes it on every later visit, so you can talk something through, leave,
and come back days later to the same history instead of re-explaining it.
`--new-session` starts over; `⌁` marks a task that has one waiting.
- **A plan agreed in conversation is attached automatically.** The agent is given
a path to write it to, and ike picks it up as you come back — so
`ike plan 3 --show` reflects what you decided together.

### Notes

- **Permission modes are not a safety ladder.** Measured against real runs,
`acceptEdits` and `auto` both let a delegated agent run `rm`; only `manual`
denied it. Use `--permission-mode manual` for a run that stops at anything it
would otherwise have to ask about. The consent gate, not the mode, is the
practical control.
- Plan bodies are stored beside the data file as
`tasks.json.plans/<space>/<id>.md`, not inside `tasks.json`, so they are not
copied into every undo snapshot. They do **not** yet travel with
`ike space export`.
- Deleting a task leaves its plan file, so undoing the delete restores both.
`ike plan --prune` sweeps the orphans.

## [0.1.0] - 2026-07-29

Expand Down
30 changes: 27 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project

`ike` is an Eisenhower matrix task manager in Go with three frontends over one JSON store: a Bubble Tea TUI (bare `ike`), cobra CLI subcommands, and an MCP server (`ike mcp`, stdio).
`ike` is an Eisenhower matrix task manager in Go with three frontends over one JSON store: a Bubble Tea TUI (bare `ike`), cobra CLI subcommands, and an MCP server (`ike mcp`, stdio). A task can also be handed *to* an agent (`ike plan`, `ike delegate`), which runs the Claude Code CLI as a subprocess.

## Commands

Expand All @@ -20,7 +20,7 @@ IKE_DATA_FILE=$(mktemp -d)/t.json go run . list # run against a scratch data f

## Architecture

Dependency direction: `cli` → `tui`/`mcpserver` → `store` → `task`. **All business logic lives in `internal/store/ops.go`** (Add/Complete/Restore/Move/Reorder/Rename/Delete/SetQuadrantLabel/Undo/Redo/List/ListArchive); the three frontends must stay thin wrappers over it. `internal/task` is the zero-dependency domain (Task struct, Quadrant 1–4 with default labels, and `Less`/`SortOrder`, the single definition of display order).
Dependency direction: `cli` → `tui`/`mcpserver` → `store`/`agent` → `task`. **All business logic lives in `internal/store/ops.go`** (Add/Complete/Restore/Move/Reorder/Rename/Delete/SetQuadrantLabel/Undo/Redo/List/ListArchive); the three frontends must stay thin wrappers over it. `internal/task` is the zero-dependency domain (Task struct, Quadrant 1–4 with default labels, and `Less`/`SortOrder`, the single definition of display order).

**Every mutating op returns the post-mutation `Data` alongside its own result** — `Add` is `(task.Task, Data, error)`, `Undo` is `(string, Data, error)`. `Mutate` already produces that value, so handing it back costs nothing and spares the caller a second read. Frontends must render the outcome from it (`d.Labels.Of(q)`, `d.List(q)`) rather than calling `Load`/`QuadrantLabels` again: a follow-up read observes a *later* file state, and the helpers that used to do it (`cli.quadrantLabels`, `mcpserver.labelsOf`) both swallowed the error to stay "cosmetic". `SetMCPEnabled` is the deliberate exception — nothing renders from it.

Expand Down Expand Up @@ -67,7 +67,31 @@ Every mutation goes through **`m.apply(d, err) bool`**, which either shows the e

The CLI is **constructed, not registered**: `NewRootCmd(open)` builds the whole tree and every command has a `newXCmd(open) *cobra.Command` constructor, where `open` yields the store (lazily, inside `RunE`, so `ike --help` still works with a broken `IKE_DATA_FILE`). Command bodies wrap in `withStore` and print to `cmd.OutOrStdout()`. Do not go back to `init()` plus a package-level `rootCmd`: that made flag variables process-global and left every `RunE` untestable — `internal/cli` sat at 18.9% coverage with `ike mcp enable|disable` at 0%, i.e. the consent toggle was tested everywhere except where users invoke it. Tests build a tree over a scratch file with `runCLI`.

Store tests are split by concern: `ops_test.go` (tasks, ordering, ranks), `history_test.go` (undo/redo), `labels_test.go`, `mcp_test.go` (the gate), `store_test.go` + `durability_test.go` (the write path). Keep new tests in the matching file rather than growing one past ~1000 lines.
Store tests are split by concern: `ops_test.go` (tasks, ordering, ranks), `history_test.go` (undo/redo), `labels_test.go`, `mcp_test.go` (the gate), `agent_test.go` (the delegation gate), `plans_test.go` (plan sidecars, `SetDir`), `store_test.go` + `durability_test.go` (the write path). Keep new tests in the matching file rather than growing one past ~1000 lines.

**No test runs the real `claude`.** A run costs money, needs credentials, and would tie the suite to how a model happens to word a plan. `internal/agent`, `internal/cli`, and `internal/tui` each point `IKE_AGENT_CMD` at their own test binary re-executed with a marker in the environment, replaying a canned stream — no shell script to keep executable, no second language, no network. Two of the TUI tests drive the real Bubble Tea command loop against that fake (exec, `waitForEvent` re-issuing per event, `savePlanCmd`), because folding messages into the model correctly is not the same as the commands actually producing them.

**Plan bodies live beside the data file, not in it** (`internal/store/plans.go`, `<datafile>.plans/<space>/<id>.md`). `Snapshot` copies `Tasks` wholesale into as many as 40 snapshots, so a few KB of markdown per task would be amplified across the whole history — the same blow-up `Snapshot.ArchiveEntry` exists to have fixed once. Only `Task.PlanAt` is persisted in the matrix, and it is what frontends render the `✎` mark from: the matrix redraws on every keypress, so a stat per row per frame would be a poor trade for a symbol. Beside the *data file* rather than under `XDG_STATE_HOME` because a plan is user data — it must follow `--file` and `IKE_DATA_FILE`, so two matrices cannot share one set of plans. Plans are **not** carried by `ike space export`; that is a known gap. `dir` and `plan_at` were added within v4 as `omitempty` rather than bumping the version, by the `redo` reasoning above: an older binary drops them, costing a remembered directory and a mark a re-plan restores, which is the harmless category rather than the archive-wipe one.

The plan write happens inside the same `Mutate` callback that stamps `PlanAt`, through **`mutateSpace`** — `Mutate` with the resolved space name handed to the callback. Plans are filed per space and `Data.Space` is derived by `dataFor` only *after* `fn` returns, so resolving separately would land outside the lock, where an `ike space use` in between would file a plan under the wrong space. The bytes go through **`writeBytesAtomic`**, lifted out of `writeFileAtomic` so the sidecars get the same four durability properties rather than a second untested copy; `mutateFile` still owns the lock, the re-read, and the gate, and `durability_test.go` passing unchanged is what proves the lift was faithful. **`Delete` deliberately does not remove the plan file** — `Delete` is undoable, so removing it would make undo silently lossy, and since `NextID` is monotonic an orphan can never be picked up by a later task. `PrunePlans` is the explicit sweep.

**`task.SanitizeBlock` is the multi-line sibling of `SanitizeDisplay`, and the two are not interchangeable.** `SanitizeDisplay` replaces every rune below `0x20`, newline included, so using it on a plan or a line of agent output renders the whole thing as one line of U+FFFD; `SanitizeBlock` keeps `\n` and `\t` and replaces the rest. They are separate functions rather than one with a flag so neither call site can pick the wrong one silently — a single-line field sanitized with `SanitizeBlock` would let a newline forge an extra listing row. Agent output is the most untrusted text ike renders, and `internal/agent` puts every field through `SanitizeBlock` at the single point they leave the package, so no frontend has to remember to and the CLI and TUI cannot disagree about whether it happened.

**Delegation is gated separately from MCP** (`File.AgentEnabled`, `ike agent enable|disable|status`). Letting an agent edit the task list and letting ike start a process that edits files are different decisions with different blast radii, so consenting to one is not consenting to the other. Everything else mirrors the MCP gate: off by default, out of `Snapshot` so undo cannot reopen it, reached through `mutateFile`/`readFile` so it works when the current space is missing, and never written into an export. It differs in one way — the MCP gate is re-checked on every read and mutation because a session outlives its check, while a delegated run is started by a command that just read the flag, so it is checked **once, at launch, in `internal/cli`** (and freshly in `tui.startAgent`, not from the polled `Data`, so a revocation in another terminal does not wait for the 2s tick). `Data.AgentAllowed` is derived alongside `MCPAllowed` for the ambient footer line only: display, never decision.

**`internal/agent` is the only package that starts a process**, and stays a pure runner the way `mcpserver` is a pure transport — it knows nothing about the store. Four things there are load-bearing and were each verified against a real run rather than assumed: `--verbose` is mandatory alongside `--output-format stream-json` or the stream carries nothing; `cmd.Stdin` is left nil (so `/dev/null`) because the CLI otherwise waits 3s for input and a child sharing ike's stdin would eat the TUI's keystrokes; an **unrecognized event type is skipped, never an error** (an ordinary run already carries `rate_limit_event` and `system/thinking_tokens`, and the CLI adds more between releases), as is a non-JSON line; and a `result` event wins over a non-zero exit, with stderr surfaced only when the process dies without one. Thinking blocks usually arrive with a signature and empty text, so the emptiness check is what stops blank transcript rows. The child gets its own process group — split into `procgroup_unix.go` and a non-Unix fallback so the tree keeps compiling everywhere, which `.goreleaser.yaml` claims and CI's cross-build step does not check — because killing only the parent would leave the agent's own tools running against the user's files. Tests replay `testdata/toolrun.jsonl`, captured from a real run, against a fake CLI that is the test binary re-executed.

**Permission modes do not form a safety ladder, and the names invite a wrong guess.** Measured against real headless runs asking the agent to `rm` a file: `manual` denied it, `acceptEdits` allowed it, `auto` allowed it, `bypassPermissions` allows it by definition. So `acceptEdits` is *not* a middle setting that withholds the shell — an earlier version of this feature shipped documentation claiming it was, which was wrong. `manual` is the only mode that restrains a delegated run: with nobody to ask, it denies anything needing approval and the denials come back in the transcript. The default is `auto`, the mode intended for unattended work. **Do not re-derive this with a harmless command** — anything the harness classifies as safe (`echo`, `ls`) runs under every mode including `manual`, so a test with `echo hi` shows no difference between any of them and proves nothing. `agent.ValidatePermissionMode` rejects a typo before a process is started, and rejects `plan` for a delegated run because it would silently make it read-only, which is what `ike plan` is for.

**Effort is chosen per run, and the choice is always on screen.** `agent.ResolveEffort(mode, requested, hasPlan)` is the single definition: `high` to plan (the thinking is the product), `medium` to execute a task that has a plan attached (the approach was decided and reviewed, so re-deriving it buys nothing), `high` to execute one that does not. An explicit `--effort` wins and comes back with an **empty reason**, which is what tells a frontend to print the level alone rather than inventing an explanation for a flag someone typed. It is a pure function precisely so `args`/`sessionArgs` and the CLI and TUI headers can each call it instead of the spec running at one level while the header claims another — the same reason ops return the post-mutation `Data`. `--effort` is therefore *always* passed to the CLI, never omitted: falling back to the CLI's own default would silently erase the plan/execute distinction. Unlike the permission-mode table this is a judgement about how much thinking a run has left to do, **not** a measurement — so it is printed, and it is overridable, and `ValidateEffort` rejects a typo before a process starts.

**An interactive session is not a `Run`.** A `Run` owns a pipe and parses NDJSON; a session owns the *terminal*, so `agent.InteractiveCommand` returns an `*exec.Cmd` and starts nothing — the CLI hands it the real terminal and the TUI passes it to `tea.ExecProcess`, which releases the terminal, runs it, and restores the TUI. Its stdio is left nil on purpose, because that is the signal for `ExecProcess` to wire the terminal in; setting it would break the handover. It also gets **no process group**, unlike a headless run: it is the foreground job and must receive the ctrl+c typed at it, which its own group would prevent.

The conversation is pinned to the task by `Task.SessionID`. Claude Code lets the caller *choose* a session ID rather than only reporting one back, so ike mints a UUID, **stores it before starting the agent** (a session ike started but did not record would be unreachable), passes `--session-id` the first time and `--resume` after. `SetSession` deliberately does not `pushUndo`: the ID points at a conversation living outside ike, and undoing to a previous one would resume something the user moved on from — closer to reopening revoked access than to reversing an edit. **`--resume` still needs a prompt**; with none it fails with "Provide a prompt to continue the conversation" and the session never opens, which is invisible until someone tries to come back to one. Hence `resumePrompt` — a nudge, not a re-brief.

A plan agreed in conversation comes back through a **draft file**, `<datafile>.plans/<space>/<id>.draft.md`. Stable per task, not a temp path, because the instruction naming it lives in the conversation's history and has to still be valid next week. `PlanDraftPath` creates the directory as well as naming it — a task with no plan yet has no plan directory, and handing an agent a path inside one that does not exist loses the plan at the last step. `AdoptPlanDraft` routes it through `SetPlan` so it still gets validation, the atomic write, the stamp, and an undo entry, and **leaves a draft it could not store in place**: it is the only copy of what the agent just wrote, so deleting it on a validation failure would destroy it.

The TUI's run view goes through `listView`/`renderList` like every other full-screen list: a cursor pinned to the last row *is* following the tail, and `moveCursor` gives scrollback for nothing. A run outlives its view — `esc` detaches and it keeps accumulating, because a run takes minutes — so `Model.run` is checked for a live run in `taskMark` and in `Quit`. Runs carry a **sequence number**; without it, events already in flight when a run is canceled would land in its successor's transcript. `ctrl+c` in the run view stops the run rather than quitting ike, and quitting ike stops the run rather than orphaning it.

Invalid data from outside ike is repaired on read, not trusted: `clampQuadrants` moves any task whose quadrant is outside 1–4 into `Eliminate`. `Add`/`Move` validate, so only a hand edit or another writer produces one — and left alone it was invisible in the TUI, in `ike list`, and to `normalizeRanks` (so it never even got a rank), while still round-tripping through every write and still appearing in `--json`. Snapshots are clamped too, so undoing into one cannot bring it back.

Expand Down
Loading