diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index 7103a53..0000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,80 +0,0 @@
-# Changelog
-
-All notable changes to `de-shell`. The format follows
-[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), the versioning is
-[semver](https://semver.org/) with the 0.x caveat: a breaking change to the
-sidecar protocol bumps the minor.
-
-## [Unreleased]
-
-## [0.2.2] - 2026-09-22
-
-### Added
-- `FigureView.add_rectangle_widget`: a draggable rectangle with an optional
- `max_extent` size cap; `on_change(x, y, w, h)` fires when a drag settles.
-- `FigureView.add_texts`: text labels at image-pixel positions, with an
- optional halo; the same `name` replaces them in place.
-- `FigureView.set_readout_visible` and `FigureFrame`'s `onReadout`: every
- figure frame relays anyplotlib's hover readout to the host page, so an app
- can hide the on-image pill and print the position and value in its own units.
-
-### Changed
-- anyplotlib floor raised to 0.10.0, for the readout event and text halos.
-
-## [0.2.1] - 2026-09-02
-
-Ground Crew's shell work from after the merge base, so it can move onto the
-package too.
-
-### Added
-- `createStdoutDemux` (main): the sidecar's stdout demuxer as a chunk-list
- accumulator that copies each byte once. The inline parser re-copied the
- whole buffered prefix per chunk, O(N^2/chunkSize) while a large frame
- streamed in (11.9 s per 64 MB frame at 64 KiB chunks). A malformed
- `PLOTAPP:` line is now reported on stderr rather than swallowed.
-- `createSizeReporter` (renderer): `FigureFrame` skips the zero-size first
- layout and any resize whose rounded size is unchanged, and holds its
- `onResize` in a ref so an inline callback no longer re-runs the effect
- (measured at ~1,500 sends/s over constant geometry before).
-- `attachFigure` (renderer): figure registration is owned by an effect and
- re-registers on every run, so React StrictMode's double-invoke no longer
- leaves a figure registered nowhere with its pane black.
-- `PIN_SCROLL`: every figure document undoes the focus-scroll that shifted a
- fresh pane by half its overflow on first hover.
-
-### Changed
-- anyplotlib floor raised to 0.8.0, for its fix to a tiled image born on a
- placeholder rendering solid black.
-
-## [0.2.0] — 2026-09-02
-
-The first release as its own package. Until now the shell lived as a vendored
-copy inside each of SpyDE, Ground Crew and Autopilot, and the three had
-diverged.
-
-### Added
-- **One package.** The TypeScript half (`de_shell/js`: the Electron main
- process, the preload bridge, the React renderer kernel, the Playwright
- harness) ships inside the wheel, so `pip install -U de-shell` moves both
- halves of the sidecar protocol together. `python -m de_shell.js` prints where
- the tree is; apps link it into their Electron project.
-- From SpyDE 0.4.3: the problem reporter (`errorReport`, `problemLog`,
- `sentryEnvelope`), `recentBackendOutput`, workspace-member wheels in the
- environment setup, an update handoff that tree-kills the sidecar first,
- `run_on_worker`'s in-flight count and `ComputeHandle` for cancelling a
- superseded compute.
-- From Ground Crew: the sidecar spawn-error trap, a 5 s tree-kill grace, a
- resolved `uv` path, the open-directory dialog channel, `_pin_tile_band` for
- large stills, JSON emit that never writes bare `NaN`, harness hardening, and
- the unit tests for all of it.
-- From Autopilot: the close handler forgets only its own child process, a
- report for malformed protocol messages, `useFigureEventForwarding`, and a
- `LOG_CLEAR` action in the renderer state.
-
-### Changed
-- License: MIT (the vendored copies were GPL-3.0-or-later inside SpyDE).
-- Line endings are LF throughout, enforced by `.gitattributes`.
-
-[Unreleased]: https://github.com/directelectron/de-shell/compare/v0.2.1...HEAD
-[0.2.1]: https://github.com/directelectron/de-shell/releases/tag/v0.2.1
-[0.2.0]: https://github.com/directelectron/de-shell/releases/tag/v0.2.0
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
new file mode 100644
index 0000000..b77276d
--- /dev/null
+++ b/CHANGELOG.rst
@@ -0,0 +1,96 @@
+=========
+Changelog
+=========
+
+All notable changes to ``de-shell`` are recorded here. Entries are written per
+pull request as fragment files under ``upcoming_changes/`` and assembled at
+release time by `towncrier `_ — see
+``upcoming_changes/README.rst``.
+
+Versioning is `semver `_ with the 0.x caveat: a breaking
+change to the sidecar protocol bumps the minor.
+
+.. towncrier release notes start
+
+0.2.2 (2026-09-22)
+==================
+
+New Features
+------------
+
+- ``FigureView.add_rectangle_widget``: a draggable rectangle with an optional
+ ``max_extent`` size cap; ``on_change(x, y, w, h)`` fires when a drag settles.
+- ``FigureView.add_texts``: text labels at image-pixel positions, with an
+ optional halo; the same ``name`` replaces them in place.
+- ``FigureView.set_readout_visible`` and ``FigureFrame``'s ``onReadout``: every
+ figure frame relays anyplotlib's hover readout to the host page, so an app
+ can hide the on-image pill and print the position and value in its own units.
+
+API and Behaviour Changes
+-------------------------
+
+- anyplotlib floor raised to 0.10.0, for the readout event and text halos.
+
+0.2.1 (2026-09-02)
+==================
+
+Ground Crew's shell work from after the merge base, so it can move onto the
+package too.
+
+New Features
+------------
+
+- ``createStdoutDemux`` (main): the sidecar's stdout demuxer as a chunk-list
+ accumulator that copies each byte once. The inline parser re-copied the whole
+ buffered prefix per chunk, O(N^2/chunkSize) while a large frame streamed in
+ (11.9 s per 64 MB frame at 64 KiB chunks). A malformed ``PLOTAPP:`` line is
+ now reported on stderr rather than swallowed.
+- ``createSizeReporter`` (renderer): ``FigureFrame`` skips the zero-size first
+ layout and any resize whose rounded size is unchanged, and holds its
+ ``onResize`` in a ref so an inline callback no longer re-runs the effect
+ (measured at ~1,500 sends/s over constant geometry before).
+- ``attachFigure`` (renderer): figure registration is owned by an effect and
+ re-registers on every run, so React StrictMode's double-invoke no longer
+ leaves a figure registered nowhere with its pane black.
+- ``PIN_SCROLL``: every figure document undoes the focus-scroll that shifted a
+ fresh pane by half its overflow on first hover.
+
+API and Behaviour Changes
+-------------------------
+
+- anyplotlib floor raised to 0.8.0, for its fix to a tiled image born on a
+ placeholder rendering solid black.
+
+0.2.0 (2026-09-02)
+==================
+
+The first release as its own package. Until now the shell lived as a vendored
+copy inside each of SpyDE, Ground Crew and Autopilot, and the three had
+diverged.
+
+New Features
+------------
+
+- **One package.** The TypeScript half (``de_shell/js``: the Electron main
+ process, the preload bridge, the React renderer kernel, the Playwright
+ harness) ships inside the wheel, so ``pip install -U de-shell`` moves both
+ halves of the sidecar protocol together. ``python -m de_shell.js`` prints
+ where the tree is; apps link it into their Electron project.
+- From SpyDE 0.4.3: the problem reporter (``errorReport``, ``problemLog``,
+ ``sentryEnvelope``), ``recentBackendOutput``, workspace-member wheels in the
+ environment setup, an update handoff that tree-kills the sidecar first,
+ ``run_on_worker``'s in-flight count and ``ComputeHandle`` for cancelling a
+ superseded compute.
+- From Ground Crew: the sidecar spawn-error trap, a 5 s tree-kill grace, a
+ resolved ``uv`` path, the open-directory dialog channel, ``_pin_tile_band``
+ for large stills, JSON emit that never writes bare ``NaN``, harness
+ hardening, and the unit tests for all of it.
+- From Autopilot: the close handler forgets only its own child process, a
+ report for malformed protocol messages, ``useFigureEventForwarding``, and a
+ ``LOG_CLEAR`` action in the renderer state.
+
+API and Behaviour Changes
+-------------------------
+
+- License: MIT (the vendored copies were GPL-3.0-or-later inside SpyDE).
+- Line endings are LF throughout, enforced by ``.gitattributes``.
diff --git a/README.md b/README.md
index e01573c..cfb9335 100644
--- a/README.md
+++ b/README.md
@@ -115,7 +115,12 @@ tests, and builds the wheel and checks what it carries.
The version is written once, in `de_shell/__init__.py`. To release:
-1. Bump `__version__`, move the `CHANGELOG.md` entries under the new version.
+1. Bump `__version__`, then assemble the changelog from the pull requests'
+ news fragments: `uv tool run towncrier build --version X.Y.Z`. That writes the new
+ section into `CHANGELOG.rst` and deletes the fragments it consumed, so stage
+ `upcoming_changes/` with `git add -A` — a plain `git add CHANGELOG.rst`
+ leaves the deletions behind and the next release re-publishes them. Preview
+ with `--draft` first; it consumes nothing.
2. Commit, tag `vX.Y.Z`, push the tag.
`.github/workflows/publish.yml` builds the distributions, refuses a tag that
@@ -156,6 +161,9 @@ SpyDE commit the app copies were taken from:
* **The protocol is the contract.** `PLOTAPP:` JSON lines and `PLOTBIN:`
binary frames over the sidecar's stdio. Both halves of it live in this one
package on purpose; keep it that way.
+* **Every pull request carries its own changelog entry**, as a news fragment
+ under [`upcoming_changes/`](upcoming_changes/README.rst) — one file per PR,
+ so two of them never conflict over the same lines of `CHANGELOG.rst`.
* **LF line endings**, enforced by `.gitattributes`.
## License
diff --git a/pyproject.toml b/pyproject.toml
index 502d3b1..ca6c7d1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -59,7 +59,7 @@ tests = [
Homepage = "https://github.com/directelectron/de-shell"
Repository = "https://github.com/directelectron/de-shell"
Issues = "https://github.com/directelectron/de-shell/issues"
-Changelog = "https://github.com/directelectron/de-shell/blob/main/CHANGELOG.md"
+Changelog = "https://github.com/directelectron/de-shell/blob/main/CHANGELOG.rst"
# One version, in one place: de_shell.__version__. The release workflow refuses
# a tag that does not match it.
@@ -90,3 +90,68 @@ de_shell = [
[tool.pytest.ini_options]
testpaths = ["tests"]
timeout = 120
+
+# ---------------------------------------------------------------------------
+# Changelog management (towncrier)
+# ---------------------------------------------------------------------------
+# towncrier assembles CHANGELOG.rst from one fragment file per PR, written while
+# the change is fresh. The alternative — deriving notes from commit subjects at
+# release time — reads like a commit log, because that is what it is: it cannot
+# say what a change means to an app upgrading, only what the author was doing.
+# It also stops two pull requests conflicting over the same few changelog lines.
+#
+# `package` is set: this repo's version of record IS de_shell.__version__ (the
+# publish workflow refuses a tag that disagrees with it), so towncrier can read
+# the version itself and `--version` is only needed to build notes for a bump
+# that has not been written yet. See upcoming_changes/README.rst.
+[tool.towncrier]
+package = "de_shell"
+package_dir = "."
+directory = "upcoming_changes"
+filename = "CHANGELOG.rst"
+start_string = ".. towncrier release notes start\n"
+issue_format = "`#{issue} `_"
+title_format = "{version} ({project_date})"
+underlines = ["=", "-", "~"]
+
+# Behaviour changes lead the notes: they are the entries an app upgrading has
+# to ACT on, and they are easy to miss filed under "Bug Fixes".
+[[tool.towncrier.type]]
+directory = "api_change"
+name = "API and Behaviour Changes"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "new_feature"
+name = "New Features"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "bugfix"
+name = "Bug Fixes"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "performance"
+name = "Performance"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "deprecation"
+name = "Deprecations"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "removal"
+name = "Removals"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "doc"
+name = "Documentation"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "maintenance"
+name = "Maintenance"
+showcontent = true
diff --git a/upcoming_changes/+towncrier.maintenance.rst b/upcoming_changes/+towncrier.maintenance.rst
new file mode 100644
index 0000000..d142367
--- /dev/null
+++ b/upcoming_changes/+towncrier.maintenance.rst
@@ -0,0 +1,4 @@
+The changelog is now assembled by `towncrier
+`_ from one news fragment per pull request
+under ``upcoming_changes/``, as SpyDE and anyplotlib already do, and lives in
+``CHANGELOG.rst`` rather than ``CHANGELOG.md``.
diff --git a/upcoming_changes/README.rst b/upcoming_changes/README.rst
new file mode 100644
index 0000000..7a4d0e3
--- /dev/null
+++ b/upcoming_changes/README.rst
@@ -0,0 +1,90 @@
+Filing Change Log Entries
+=========================
+
+de-shell uses `towncrier `_ to assemble
+``CHANGELOG.rst``. When you open a pull request that should appear in the next
+release notes, add a short news **fragment file** to this directory as part of
+that PR.
+
+Writing the entry with the change — rather than deriving notes from commit
+subjects at release time — is the whole point. A commit subject says what the
+author was doing; a release note has to say what the change means to someone
+upgrading, and only the author knows that. It also keeps two pull requests from
+conflicting over the same few lines of a shared changelog: one fragment per PR,
+no shared file to edit.
+
+Who the reader is
+-----------------
+
+de-shell's users are the **apps** — SpyDE, Ground Crew, Autopilot — and the
+people building them. Write for someone pinning a new ``de-shell`` and asking
+what they get and what they have to change. Both halves of the package count:
+the Python sidecar API and the TypeScript in ``de_shell/js`` (main, preload,
+renderer, the Playwright harness).
+
+Naming convention
+-----------------
+
+Each fragment is a plain ``.rst`` file named::
+
+ {PR_number}.{type}.rst
+
+If the change has no natural PR number (work batched on a long-lived feature
+branch), name it ``+{slug}.{type}.rst`` — the leading ``+`` marks it an
+"orphan" so towncrier omits the issue link. Without it the slug renders as a
+broken PR link.
+
+================= ==============================================================
+Type Use when …
+================= ==============================================================
+``api_change`` Existing behaviour changed in a way an app has to act on — a
+ signature, a default, a protocol message, an anyplotlib
+ floor. Use this even when the change is a *fix*: what
+ matters to someone upgrading is that the old behaviour is
+ gone, and that is easy to miss under ``bugfix``.
+``new_feature`` A user-visible capability has been added.
+``bugfix`` A bug has been fixed.
+``performance`` Something measurably got faster or lighter. Quote the number
+ — "11.9 s to 40 ms per 64 MB frame" is a release note;
+ "improved performance" is not.
+``deprecation`` Something is deprecated and will be removed later.
+``removal`` A previously deprecated API has been removed.
+``doc`` Documentation improved with no code change.
+``maintenance`` Internal / infrastructure change invisible to the apps.
+================= ==============================================================
+
+Content guidelines
+------------------
+
+* **One sentence per file**, in the **past tense**, from the *app's*
+ perspective — not the implementer's.
+* Say what changed for them, not which function you edited. "The packaged
+ sidecar no longer holds the install directory open" beats "changed ``cwd`` in
+ ``resolvePythonEnv``".
+* Do **not** put the PR number in the sentence; towncrier appends the link.
+
+Examples
+--------
+
+``12.bugfix.rst``::
+
+ A figure opened on a zero-size pane stayed black until the window was
+ resized.
+
+``13.performance.rst``::
+
+ The sidecar's stdout demuxer copies each byte once instead of re-copying
+ the buffered prefix per chunk — 11.9 s to 40 ms for a 64 MB frame at
+ 64 KiB chunks.
+
+Building
+--------
+
+There is no Prepare Release workflow here; the changelog is assembled by hand
+as step 1 of `Releasing <../README.md#releasing>`_::
+
+ uv tool run towncrier build --version X.Y.Z
+
+To preview without consuming the fragments::
+
+ uv tool run towncrier build --draft --version X.Y.Z