From 739f46fd2b11daf56cd79bb29914bf0865cb531e Mon Sep 17 00:00:00 2001 From: Marcin Balazy Date: Sat, 10 Oct 2026 18:15:42 +0200 Subject: [PATCH 1/4] docs: add an architecture page with the package layers drawn from the import graph --- docs/README.md | 1 + docs/architecture.md | 92 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 docs/architecture.md 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..b7dbf3d --- /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 runs `pm` as a child process. + +```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 is the only package that knows all the others: it builds the cobra tree and registers every command, the executor's included. 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, since nearly every box imports one of them: + +- **`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 storage layer is the only shared state.** Every front end, and the executor, reads and writes the same files through `internal/storage`. `internal/cmd` imports the other front ends only to start them (`pm board`, `pm mcp`, `pm serve`). From f2ddc66ab664e5a186911ccb7fb0a6b05d191b56 Mon Sep 17 00:00:00 2001 From: Marcin Balazy Date: Sat, 10 Oct 2026 18:17:33 +0200 Subject: [PATCH 2/4] docs: use mermaid's pipe form for the process-boundary edge labels --- docs/architecture.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index b7dbf3d..a9f1064 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,8 +54,8 @@ flowchart TB runctl --> storage solo --> storage - tui -. "starts pm work / run-epic / finish" .-> bin - runctl -. "starts pm work / run-epic / finish" .-> bin + tui -.->|starts pm work, run-epic, finish| bin + runctl -.->|starts pm work, run-epic, finish| bin ``` ## The boxes From 0d6880c5098c325f0ab4ea844f170318ce74173d Mon Sep 17 00:00:00 2001 From: Marcin Balazy Date: Sat, 10 Oct 2026 18:25:45 +0200 Subject: [PATCH 3/4] docs: correct the architecture page on shared state, helper imports and child processes --- docs/architecture.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a9f1064..c3448ad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # 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 runs `pm` as a child process. +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` and registers `pm worker-guard` as a hook. ```mermaid flowchart TB @@ -73,7 +73,7 @@ flowchart TB - **`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, since nearly every box imports one of them: +The leaf helpers are left out of the diagram to keep it on one screen. Only `internal/cmd`, `internal/executor`, `internal/mcpserver` and `internal/tui/board` import them: - **`internal/proc`** - process groups. - **`internal/exitcode`** - exit codes. @@ -89,4 +89,4 @@ None of them imports another pm package, except `projectarg`, which imports `exi - **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 storage layer is the only shared state.** Every front end, and the executor, reads and writes the same files through `internal/storage`. `internal/cmd` imports the other front ends only to start them (`pm board`, `pm mcp`, `pm serve`). +- **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`). From 3260e06d15212451c01de0bdc38c40a126bddbf5 Mon Sep 17 00:00:00 2001 From: Marcin Balazy Date: Sat, 10 Oct 2026 18:36:43 +0200 Subject: [PATCH 4/4] docs: correct three sentences on the architecture page against the import graph --- docs/architecture.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index c3448ad..03bd2ac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # 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` and registers `pm worker-guard` as a hook. +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 @@ -62,7 +62,7 @@ flowchart TB - **`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 is the only package that knows all the others: it builds the cobra tree and registers every command, the executor's included. No other package imports it. +- **`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: @@ -73,7 +73,7 @@ flowchart TB - **`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. Only `internal/cmd`, `internal/executor`, `internal/mcpserver` and `internal/tui/board` import them: +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.