From 9bed9306a24c20878314b596fdc21c399bd4cd1a Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 27 Apr 2026 13:20:52 -0700 Subject: [PATCH 1/4] Add `relayburn` install wrapper so `npm i -g relayburn` exposes `burn` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a thin `packages/relayburn` wrapper package whose only job is to re-export `@relayburn/cli`'s `burn` bin under the unscoped `relayburn` name. Both `npm i -g @relayburn/cli` and `npm i -g relayburn` now install the same `burn` command — users no longer need to know the scoped package name to get started. Implementation: - `packages/relayburn/bin/burn.js` is a one-line shim that imports `@relayburn/cli/dist/cli.js`. The cli's `main()` runs at module top-level, so the import alone triggers the CLI. - `dependencies: { "@relayburn/cli": "workspace:*" }` — `pnpm pack` rewrites this to a concrete version at publish time, so the shipped tarball pins to the exact cli version released alongside it. - Root workspace renamed from `relayburn` to `relayburn-monorepo` to resolve a name collision: `pnpm --filter relayburn` would otherwise match both the root and the new package. Publish workflow updates: - `relayburn` added to package choices and to the `all` list (after `cli`, since the wrapper depends on it). - Hardcoded `@relayburn/$pkg` references replaced with a per-package npm-name lookup (`node -p require('./packages/$pkg/package.json').name`) so the workflow handles both scoped and unscoped names. - Pack/publish step derives the tarball filename from the npm name (`relayburn-0.33.0.tgz` for the wrapper vs. `relayburn-cli-0.33.0.tgz` for cli). - `verify-publish.yml` gains a `relayburn` choice that runs the same `burn --help` smoke test as `@relayburn/cli`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/publish.yml | 70 ++++++++++++++++++---------- .github/workflows/verify-publish.yml | 5 +- package.json | 2 +- packages/relayburn/CHANGELOG.md | 12 +++++ packages/relayburn/README.md | 17 +++++++ packages/relayburn/bin/burn.js | 2 + packages/relayburn/package.json | 32 +++++++++++++ pnpm-lock.yaml | 6 +++ 8 files changed, 119 insertions(+), 27 deletions(-) create mode 100644 packages/relayburn/CHANGELOG.md create mode 100644 packages/relayburn/README.md create mode 100755 packages/relayburn/bin/burn.js create mode 100644 packages/relayburn/package.json diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1f4df76..959407f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,6 +15,7 @@ on: - analyze - mcp - cli + - relayburn version: description: 'Version bump type (ignored if custom_version is set)' required: true @@ -100,10 +101,12 @@ jobs: run: | case "${{ github.event.inputs.package }}" in all) - # Must be in dependency order: reader → ledger → analyze → mcp → cli. - echo "packages=reader ledger analyze mcp cli" >> "$GITHUB_OUTPUT" + # Dependency order: reader → ledger → analyze → mcp → cli → relayburn. + # `relayburn` is a thin wrapper that depends on `@relayburn/cli`, + # so cli must publish first. + echo "packages=reader ledger analyze mcp cli relayburn" >> "$GITHUB_OUTPUT" ;; - reader|ledger|analyze|mcp|cli) + reader|ledger|analyze|mcp|cli|relayburn) echo "packages=${{ github.event.inputs.package }}" >> "$GITHUB_OUTPUT" ;; *) @@ -125,7 +128,8 @@ jobs: set -euo pipefail for pkg in ${{ steps.targets.outputs.packages }}; do LOCAL=$(node -p "require('./packages/$pkg/package.json').version") - REMOTE=$(npm view "@relayburn/$pkg" versions --json 2>/dev/null \ + NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name") + REMOTE=$(npm view "$NPM_NAME" versions --json 2>/dev/null \ | node -e ' const raw = require("fs").readFileSync(0, "utf8").trim() || "[]"; const parsed = JSON.parse(raw); @@ -142,7 +146,7 @@ jobs: process.stdout.write(stable.length ? stable[stable.length - 1] : ""); ' || echo "") if [ -z "$REMOTE" ]; then - echo "@relayburn/$pkg: no stable releases on npm yet — OK" + echo "$NPM_NAME: no stable releases on npm yet — OK" continue fi CMP=$(node -e ' @@ -157,10 +161,10 @@ jobs: console.log(0); ' "$LOCAL" "$REMOTE") if [ "$CMP" = "-1" ]; then - echo "::error title=npm/git version drift::@relayburn/$pkg: package.json is at $LOCAL but npm has $REMOTE published. A previous publish run likely succeeded on npm but failed before tagging. Bump packages/$pkg/package.json to >= $REMOTE on a branch, push, then re-run this workflow. (You may also want to tag the prior release commit as $pkg-v$REMOTE so the changelog generator picks the right baseline.)" + echo "::error title=npm/git version drift::$NPM_NAME: package.json is at $LOCAL but npm has $REMOTE published. A previous publish run likely succeeded on npm but failed before tagging. Bump packages/$pkg/package.json to >= $REMOTE on a branch, push, then re-run this workflow. (You may also want to tag the prior release commit as $pkg-v$REMOTE so the changelog generator picks the right baseline.)" exit 1 fi - echo "@relayburn/$pkg: local=$LOCAL, npm=$REMOTE — OK" + echo "$NPM_NAME: local=$LOCAL, npm=$REMOTE — OK" done - name: Bump versions @@ -198,12 +202,13 @@ jobs: for entry in ${{ steps.bump.outputs.versions }}; do pkg="${entry%%:*}" ver="${entry##*:}" - EXISTS=$(npm view "@relayburn/$pkg@$ver" version 2>/dev/null || true) + NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name") + EXISTS=$(npm view "$NPM_NAME@$ver" version 2>/dev/null || true) if [ -n "$EXISTS" ]; then - echo "::error title=Version already published::@relayburn/$pkg@$ver is already on npm. Pick a different bump type or set custom_version to a higher version." + echo "::error title=Version already published::$NPM_NAME@$ver is already on npm. Pick a different bump type or set custom_version to a higher version." exit 1 fi - echo "@relayburn/$pkg@$ver: unpublished — OK" + echo "$NPM_NAME@$ver: unpublished — OK" done # Per-package CHANGELOG.md generation. For each package being published: @@ -374,7 +379,8 @@ jobs: // --- Step 3: write the file with Unreleased reset + new block. --- if (!existing) { - const header = `# Changelog\n\nAll notable changes to \`@relayburn/${pkg}\` will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [Unreleased]\n\n`; + const npmName = JSON.parse(readFileSync(`packages/${pkg}/package.json`, 'utf-8')).name; + const header = `# Changelog\n\nAll notable changes to \`${npmName}\` will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [Unreleased]\n\n`; writeFileSync(path, header + newEntry + '\n'); } else if (parts) { // Reset Unreleased body to empty (single blank line after header) @@ -518,7 +524,8 @@ jobs: for entry in ${{ steps.bump.outputs.versions }}; do pkg="${entry%%:*}" version="${entry##*:}" - MSG+=" @relayburn/$pkg@$version" + NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name") + MSG+=" $NPM_NAME@$version" done git commit -m "$MSG" fi @@ -552,11 +559,17 @@ jobs: fi for pkg in ${{ steps.targets.outputs.packages }}; do - echo "==> Packing @relayburn/$pkg" - pnpm --filter "@relayburn/$pkg" pack --pack-destination "$PACK_DIR" - TARBALL=$(ls -1t "$PACK_DIR"/relayburn-$pkg-*.tgz | head -n1) - if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then - echo "::error::could not find packed tarball for $pkg in $PACK_DIR" >&2 + NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name") + VERSION=$(node -p "require('./packages/$pkg/package.json').version") + # `pnpm pack` writes -.tgz with `/` → `-`. + # @relayburn/cli@0.33.0 → relayburn-cli-0.33.0.tgz + # relayburn@0.33.0 → relayburn-0.33.0.tgz + TARBALL_BASENAME="$(echo "${NPM_NAME#@}" | tr '/' '-')-${VERSION}.tgz" + echo "==> Packing $NPM_NAME" + pnpm --filter "$NPM_NAME" pack --pack-destination "$PACK_DIR" + TARBALL="$PACK_DIR/$TARBALL_BASENAME" + if [ ! -f "$TARBALL" ]; then + echo "::error::could not find packed tarball $TARBALL_BASENAME in $PACK_DIR" >&2 ls -la "$PACK_DIR" >&2 || true exit 1 fi @@ -572,7 +585,8 @@ jobs: for entry in ${{ steps.bump.outputs.versions }}; do pkg="${entry%%:*}" version="${entry##*:}" - git tag -a "$pkg-v$version" -m "@relayburn/$pkg@$version" + NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name") + git tag -a "$pkg-v$version" -m "$NPM_NAME@$version" done git push origin HEAD --follow-tags @@ -584,7 +598,8 @@ jobs: for entry in ${{ steps.bump.outputs.versions }}; do pkg="${entry%%:*}" version="${entry##*:}" - echo "- \`@relayburn/$pkg@$version\`" + NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name") + echo "- \`$NPM_NAME@$version\`" done echo "" echo "- **dist-tag**: \`${{ github.event.inputs.tag }}\`" @@ -598,7 +613,7 @@ jobs: # One GitHub Release per package tag. Runs in parallel across packages; # each cell is gated on whether its package was published. create-release: - name: Release @relayburn/${{ matrix.package }} + name: Release ${{ matrix.package }} needs: publish if: ${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.version != 'none' || github.event.inputs.custom_version != '') }} runs-on: ubuntu-latest @@ -607,7 +622,7 @@ jobs: strategy: fail-fast: false matrix: - package: [reader, ledger, analyze, mcp, cli] + package: [reader, ledger, analyze, mcp, cli, relayburn] steps: - name: Check if this package was published id: check @@ -636,6 +651,13 @@ jobs: # Need the tag that the publish job just pushed. ref: ${{ matrix.package }}-v${{ steps.check.outputs.version }} + - name: Resolve npm name + if: steps.check.outputs.published == 'true' + id: name + run: | + NPM_NAME=$(node -p "require('./packages/${{ matrix.package }}/package.json').name") + echo "npm_name=$NPM_NAME" >> "$GITHUB_OUTPUT" + # Prefer the per-package CHANGELOG section that was generated and # committed during publish. Falls back to auto-generated notes for # prereleases and first publishes (where no CHANGELOG entry exists). @@ -666,7 +688,7 @@ jobs: uses: softprops/action-gh-release@v3 with: tag_name: ${{ matrix.package }}-v${{ steps.check.outputs.version }} - name: "@relayburn/${{ matrix.package }}@${{ steps.check.outputs.version }}" + name: "${{ steps.name.outputs.npm_name }}@${{ steps.check.outputs.version }}" body_path: /tmp/release-notes.md - name: Create GitHub Release (stable, auto notes) @@ -674,7 +696,7 @@ jobs: uses: softprops/action-gh-release@v3 with: tag_name: ${{ matrix.package }}-v${{ steps.check.outputs.version }} - name: "@relayburn/${{ matrix.package }}@${{ steps.check.outputs.version }}" + name: "${{ steps.name.outputs.npm_name }}@${{ steps.check.outputs.version }}" generate_release_notes: true - name: Create GitHub Release (prerelease) @@ -682,6 +704,6 @@ jobs: uses: softprops/action-gh-release@v3 with: tag_name: ${{ matrix.package }}-v${{ steps.check.outputs.version }} - name: "@relayburn/${{ matrix.package }}@${{ steps.check.outputs.version }} (prerelease)" + name: "${{ steps.name.outputs.npm_name }}@${{ steps.check.outputs.version }} (prerelease)" prerelease: true generate_release_notes: true diff --git a/.github/workflows/verify-publish.yml b/.github/workflows/verify-publish.yml index f5211cc..4cf275c 100644 --- a/.github/workflows/verify-publish.yml +++ b/.github/workflows/verify-publish.yml @@ -15,6 +15,7 @@ on: type: choice options: - '@relayburn/cli' + - 'relayburn' - '@relayburn/reader' - '@relayburn/ledger' - '@relayburn/analyze' @@ -55,7 +56,7 @@ jobs: echo "Verifying ${{ github.event.inputs.package }}@$RESOLVED" - name: CLI smoke test - if: ${{ github.event.inputs.package == '@relayburn/cli' }} + if: ${{ github.event.inputs.package == '@relayburn/cli' || github.event.inputs.package == 'relayburn' }} run: | set -euo pipefail npm install -g "${{ github.event.inputs.package }}@${{ steps.resolve.outputs.version }}" @@ -78,7 +79,7 @@ jobs: echo "CLI smoke test passed." - name: Library smoke test (ESM import + exports surface) - if: ${{ github.event.inputs.package != '@relayburn/cli' }} + if: ${{ github.event.inputs.package != '@relayburn/cli' && github.event.inputs.package != 'relayburn' }} run: | set -euo pipefail WORKDIR=$(mktemp -d) diff --git a/package.json b/package.json index 3d0e1f9..0aab29d 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "relayburn", + "name": "relayburn-monorepo", "private": true, "version": "0.0.1", "description": "Token usage & cost attribution for agent CLIs", diff --git a/packages/relayburn/CHANGELOG.md b/packages/relayburn/CHANGELOG.md new file mode 100644 index 0000000..2b21985 --- /dev/null +++ b/packages/relayburn/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to `relayburn` will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release. Thin wrapper around [`@relayburn/cli`](https://www.npmjs.com/package/@relayburn/cli) — installing `relayburn` globally exposes the same `burn` command. Lets users `npm i -g relayburn` without needing to know the scoped package name. diff --git a/packages/relayburn/README.md b/packages/relayburn/README.md new file mode 100644 index 0000000..d6b7dca --- /dev/null +++ b/packages/relayburn/README.md @@ -0,0 +1,17 @@ +# relayburn + +Install the [`burn`](https://github.com/AgentWorkforce/burn) CLI globally: + +```sh +npm i -g relayburn +``` + +This is a thin wrapper that depends on [`@relayburn/cli`](https://www.npmjs.com/package/@relayburn/cli) and exposes the `burn` command. Both names produce the same binary; pick whichever you find easier to type. + +```sh +burn --help +burn summary --since 7d +burn limits --watch +``` + +See the project [README](https://github.com/AgentWorkforce/burn#readme) for full documentation. diff --git a/packages/relayburn/bin/burn.js b/packages/relayburn/bin/burn.js new file mode 100755 index 0000000..8d5d331 --- /dev/null +++ b/packages/relayburn/bin/burn.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '@relayburn/cli/dist/cli.js'; diff --git a/packages/relayburn/package.json b/packages/relayburn/package.json new file mode 100644 index 0000000..5b14c70 --- /dev/null +++ b/packages/relayburn/package.json @@ -0,0 +1,32 @@ +{ + "name": "relayburn", + "version": "0.33.0", + "description": "Token usage & cost attribution for agent CLIs (installs the `burn` command)", + "type": "module", + "bin": { + "burn": "./bin/burn.js" + }, + "files": [ + "bin", + "README.md", + "CHANGELOG.md", + "package.json" + ], + "scripts": { + "build": "chmod +x bin/burn.js" + }, + "engines": { + "node": ">=22" + }, + "dependencies": { + "@relayburn/cli": "workspace:*" + }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/burn", + "directory": "packages/relayburn" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc57ced..0bbdfe1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,12 @@ importers: packages/reader: {} + packages/relayburn: + dependencies: + '@relayburn/cli': + specifier: workspace:* + version: link:../cli + packages: '@types/node@22.19.17': From f256bd7f902100572f7820de817a4524025f965a Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 27 Apr 2026 14:07:36 -0700 Subject: [PATCH 2/4] docs: update AGENTS.md and CHANGELOG.md for the relayburn wrapper AGENTS.md still described the workspace as "five published packages" and listed the dep-order chain, package-choice flag, and bump-order step without `relayburn`. CHANGELOG.md still said releases applied to "all five (`reader`, `ledger`, `analyze`, `mcp`, `cli`)". Update all four AGENTS sections and the root CHANGELOG header to include the new wrapper. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 9 +++++---- CHANGELOG.md | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 671d45a..d5cc60c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ Pairs with [`README.md`](./README.md) — README is what burn does, this file is ## Layout -pnpm workspace, five published packages in dependency order: +pnpm workspace, six published packages in dependency order: ``` @relayburn/reader — pure parsers (Claude Code / Codex / OpenCode session logs → TurnRecord) @@ -13,9 +13,10 @@ pnpm workspace, five published packages in dependency order: @relayburn/analyze — pricing + per-record cost derivation + comparison aggregator @relayburn/mcp — stdio MCP server exposing read-only ledger queries for in-session self-query @relayburn/cli — `burn` binary (summary, by-tool, compare, claude/codex/opencode wrappers, mcp-server, …) +relayburn — thin install-wrapper so `npm i -g relayburn` exposes the same `burn` bin as `@relayburn/cli` ``` -`reader → ledger → analyze → mcp → cli`. Always build the whole workspace; never touch a single package's `tsconfig.tsbuildinfo` to "skip" a dep. +`reader → ledger → analyze → mcp → cli → relayburn`. Always build the whole workspace; never touch a single package's `tsconfig.tsbuildinfo` to "skip" a dep. ## Common commands @@ -58,7 +59,7 @@ Breaking changes: append `!` to a Conventional Commits prefix (e.g. `feat!:`) to ```bash # from GitHub Actions: workflow_dispatch → "Publish Package" -# package: all | reader | ledger | analyze | mcp | cli +# package: all | reader | ledger | analyze | mcp | cli | relayburn # version: patch | minor | major | prepatch | … | none (re-publish current) # custom_version: 0.3.1 (overrides version type) # tag: latest | next | beta | alpha @@ -67,7 +68,7 @@ Breaking changes: append `!` to a Conventional Commits prefix (e.g. `feat!:`) to The workflow: 1. Builds + tests the whole workspace. -2. Bumps `package.json` versions in dep order (reader → ledger → analyze → mcp → cli). +2. Bumps `package.json` versions in dep order (reader → ledger → analyze → mcp → cli → relayburn). 3. Generates changelog entries from `git log -v..HEAD -- packages/`. 4. Publishes via `pnpm pack` + `npm publish` using OIDC trusted-publisher auth (no `NPM_TOKEN`). 5. Tags `-v` and creates a GitHub Release with the changelog body. diff --git a/CHANGELOG.md b/CHANGELOG.md index f3559b9..c1cc761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ Cross-package narrative for the relayburn monorepo. The per-package CHANGELOGs at `packages/*/CHANGELOG.md` are authoritative for exactly what shipped in each package; this file is the unified view. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and the workspace adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Packages are released in lockstep, so each version below applies to all five (`reader`, `ledger`, `analyze`, `mcp`, `cli`). +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and the workspace adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Packages are released in lockstep, so each version below applies to all six (`reader`, `ledger`, `analyze`, `mcp`, `cli`, `relayburn`). ## [Unreleased] From 87143ab1efa60570df84be8607cdd34f083234c5 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 27 Apr 2026 15:17:41 -0700 Subject: [PATCH 3/4] chore: align relayburn wrapper baseline with @relayburn/cli@0.34.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets the wrapper's package.json to 0.34.0 to match the cli's current published version. This is the manual baseline for the first publish of `relayburn` to npm — done locally to bootstrap OIDC trusted-publisher registration on npmjs.com (the trusted publisher is configured per package, so the package has to exist before OIDC can be wired up). After this lands, lockstep `package: all` releases keep wrapper + cli versions aligned without needing custom_version overrides. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/relayburn/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/relayburn/package.json b/packages/relayburn/package.json index 5b14c70..5345726 100644 --- a/packages/relayburn/package.json +++ b/packages/relayburn/package.json @@ -1,6 +1,6 @@ { "name": "relayburn", - "version": "0.33.0", + "version": "0.34.0", "description": "Token usage & cost attribution for agent CLIs (installs the `burn` command)", "type": "module", "bin": { From 1596ecc39aac58c8af39002f2a2e91cdf1cba3aa Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 27 Apr 2026 15:26:02 -0700 Subject: [PATCH 4/4] docs: fix stale "five packages" reference in AGENTS.md changelog section Line 8 (Layout) and CHANGELOG.md:5 were updated to "six packages" in f256bd7, but line 39 in the Changelog section still said "applies to all five packages". Bring it in sync so contributors reading the changelog guidance don't see a stale count. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index d5cc60c..467e7c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Tests run from `dist/` so a stale build will lie. If a test fails unexpectedly, Curate `[Unreleased]` in the relevant per-package `packages/*/CHANGELOG.md` as you land PRs — write the entry the way you'd want it to read in a release note. At publish time, the workflow (`.github/workflows/publish.yml`) **promotes** your `[Unreleased]` block verbatim into `## [x.y.z] - DATE` and resets `[Unreleased]` to empty. No double-writing, no post-release hand-editing. -The root `CHANGELOG.md` is the cross-package narrative. Packages release in lockstep, so each release in the root file is a single `## [x.y.z] - YYYY-MM-DD` header that applies to all five packages — no `**Versions:** ...` lines, no per-bullet `[reader, cli]` tags. Update `[Unreleased]` only when the work spans packages or warrants a top-level summary; single-package work belongs only in that package's CHANGELOG. +The root `CHANGELOG.md` is the cross-package narrative. Packages release in lockstep, so each release in the root file is a single `## [x.y.z] - YYYY-MM-DD` header that applies to all six packages — no `**Versions:** ...` lines, no per-bullet `[reader, cli]` tags. Update `[Unreleased]` only when the work spans packages or warrants a top-level summary; single-package work belongs only in that package's CHANGELOG. The publish workflow promotes the root `[Unreleased]` block the same way it does per-package files: at release time it stamps `## [x.y.z] - DATE` (using `max` of the versions bumped in the run) and resets `[Unreleased]` to empty. **No git-log fallback for the root file** — an empty `[Unreleased]` at release time means "no narrative-worthy changes this release" and the file is left alone. So if you want the root to record a release, write the bullet under `[Unreleased]` *before* the publish run.