diff --git a/docs/README.md b/docs/README.md
index 31ec96b..1d09129 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -20,6 +20,7 @@ One file per surface, each readable cold. The README is the two-minute landing p
**Working on pm**
+- [architecture.md](architecture.md) - one diagram of how the packages fit: the storage layer, the service layer, the four front ends (CLI, board, MCP server, web cockpit) and the executor beside them, drawn from the real import graph.
- [development.md](development.md) - build, test, the web toolchain, CI, releasing, the deploy target, and the one rule about names in a public repo.
- [design-log.md](design-log.md) - the rationale, the bug stories and the measurements behind the rules in `CLAUDE.md`: what was tried, what it cost, why the current shape won. A rule ending `(history: docs/design-log.md)` has its story here.
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..03bd2ac
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,92 @@
+# Architecture
+
+pm is one data layer with several front ends on top of it, plus the executor as a module beside them. Everything lives in one binary, `cmd/pm`. The diagram is drawn from the real import graph (`go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./...`): a solid arrow means "imports", and every one of them is an import in the code. A dashed arrow is not an import. It marks a process boundary: the package starts `pm` as a child process to run the executor. These are not the only `pm` child processes. The board also runs `pm runs --json`, and the executor runs `pm finish` after `run-epic --then-finish` (or on a tracker with `finish_mode: auto`) and registers `pm worker-guard` as a hook.
+
+```mermaid
+flowchart TB
+ bin["cmd/pm
the binary"]
+
+ subgraph fronts["Front ends"]
+ cli["internal/cmd
CLI commands (cobra)"]
+ tui["internal/tui/board
terminal board (pm board)"]
+ mcp["internal/mcpserver
MCP server (pm mcp)"]
+ web["internal/server
web cockpit API + SPA (pm serve)"]
+ end
+
+ exec["internal/executor
pm work, pm run-epic, pm finish,
pm executor, the worker guard"]
+
+ subgraph cockpit["Cockpit back end"]
+ feed["internal/feed"]
+ report["internal/report"]
+ review["internal/review"]
+ runctl["internal/runctl"]
+ solo["internal/solo"]
+ end
+
+ service["internal/service
tool semantics shared by MCP and the web API"]
+ storage["internal/storage
task files, project.yaml, run state, journals, timeline"]
+
+ bin --> cli
+ bin --> tui
+ cli --> tui
+ cli --> mcp
+ cli --> web
+ cli --> exec
+ cli --> service
+ cli --> storage
+ cli --> feed
+ mcp --> service
+ mcp --> storage
+ web --> service
+ web --> storage
+ web --> cockpit
+ service --> feed
+ service --> storage
+ tui --> storage
+ exec --> storage
+ feed --> storage
+ report --> feed
+ report --> storage
+ review --> feed
+ review --> report
+ review --> solo
+ review --> storage
+ runctl --> storage
+ solo --> storage
+
+ tui -.->|starts pm work, run-epic, finish| bin
+ runctl -.->|starts pm work, run-epic, finish| bin
+```
+
+## The boxes
+
+- **`internal/storage`** - the data layer. Tasks are markdown files with YAML frontmatter under `~/.claude/pm//`, next to `project.yaml`. This package also owns the executor's run state and journal, the per-project journals and timeline, the attention queue and the cross-process locks. It imports no other pm package.
+- **`internal/service`** - what each MCP tool means: status validation, the tri-state fields, link merging, the Spec/Log rules, output budgets. The MCP server and the web API both call it, so the two cannot drift apart. It imports `internal/feed` only to read the change feed's digest into the attention queue.
+- **`internal/cmd`** - the CLI. It builds the cobra tree and registers every command, the executor's included, and starts the board, the MCP server and the web server. No other package imports it.
+- **`internal/tui/board`** - the Bubble Tea board. It reads and writes through `internal/storage` directly, not through `internal/service`.
+- **`internal/mcpserver`** - the stdio MCP server. Its handlers are thin: decode, call `internal/service`, encode.
+- **`internal/server`** - the HTTP server behind the web cockpit, plus the embedded SPA (`web/`). Its reads go through `internal/service` and `internal/storage`. What only the cockpit does lives in the packages it imports:
+ - **`internal/feed`** - the change feed.
+ - **`internal/report`** - the LLM report over the feed.
+ - **`internal/review`** - a code review of a pull or merge request, started from the cockpit.
+ - **`internal/runctl`** - starting and stopping executor runs, by the board's rules.
+ - **`internal/solo`** - starting a solo Claude Code session.
+- **`internal/executor`** - runs pm tasks through headless `claude -p` workers: the `pm work`, `pm run-epic`, `pm finish` and `pm executor` commands, and the `pm worker-guard` hook. It depends on `internal/storage` and the leaf helpers below, never on `internal/cmd`. Its internals are in [executor.md](executor.md).
+
+The leaf helpers are left out of the diagram to keep it on one screen. Their importers are `internal/cmd`, `internal/executor`, `internal/mcpserver`, `internal/tui/board` and, for `exitcode`, `internal/projectarg`:
+
+- **`internal/proc`** - process groups.
+- **`internal/exitcode`** - exit codes.
+- **`internal/projectarg`** - the `[project]` argument.
+- **`internal/ccpath`** - Claude Code's project-dir name.
+- **`internal/version`** - the version string.
+- **`internal/tui/common`** - shared TUI pieces.
+- **`docs`** - the agent guides embedded in the binary for `pm docs`.
+
+None of them imports another pm package, except `projectarg`, which imports `exitcode` and `storage`.
+
+## Where the boundaries are
+
+- **Nothing imports `internal/cmd`** except the binary. `go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./internal/... | grep pm-cli/internal/cmd` lists only `internal/cmd`'s own line.
+- **The board and the cockpit never import the executor.** They start it as a process (`exec.Command` with `pm work ...`). Their contract with it is the command names, the flags, the run-state files in `/.executor/` and the shape of `pm runs --json`, not Go types. That is why `internal/cmd/executor_contract_test.go` pins those names and exit codes through the root command.
+- **The front ends share state through files, not calls.** Tasks, projects, run state, journals and the timeline go through `internal/storage`. The one exception is the change feed's on-disk cache (`/.cockpit/changes`), which `internal/feed` writes itself. `pm today` (through `internal/service`) and `pm serve` both read it. `internal/cmd` imports the other front ends only to start them (`pm board`, `pm mcp`, `pm serve`).