diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6c4f1ffc..ca1620ae 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -78,9 +78,14 @@ jobs:
runs-on: ubuntu-latest
steps:
+ - uses: actions/checkout@v4
+
- name: Download all artifacts
uses: actions/download-artifact@v4
+ - name: Extract release notes from changelog
+ run: python3 scripts/changelog.py extract --version "${GITHUB_REF_NAME#v}" --output RELEASE_NOTES.md
+
- name: Create release
uses: softprops/action-gh-release@v2
with:
@@ -89,4 +94,4 @@ jobs:
herdr-linux-aarch64/herdr-linux-aarch64
herdr-macos-x86_64/herdr-macos-x86_64
herdr-macos-aarch64/herdr-macos-aarch64
- generate_release_notes: true
+ body_path: RELEASE_NOTES.md
diff --git a/.gitignore b/.gitignore
index 84b8c03e..83578415 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
/target
+__pycache__/
+*.pyc
*.swp
*.swo
*~
diff --git a/.pi/prompts/draft-changelog.md b/.pi/prompts/draft-changelog.md
new file mode 100644
index 00000000..e32df7a1
--- /dev/null
+++ b/.pi/prompts/draft-changelog.md
@@ -0,0 +1,73 @@
+---
+description: Draft changelog entry from commits and PRs since last tag
+---
+Draft a changelog entry for this repo.
+
+Optional starting ref override: `$1`
+Extra user intent/context: `${@:2}`
+
+Process:
+
+1. Determine the base ref.
+ - If `$1` is non-empty and looks like a ref/tag, use it.
+ - Otherwise use the latest release tag, preferring the repo's semver tag style (for example `v0.1.2`):
+ ```bash
+ git describe --tags --abbrev=0
+ ```
+
+2. Inspect the range from base ref to `HEAD`.
+ - Use first-parent history for release context:
+ ```bash
+ git log --first-parent --reverse --format='%H%x09%s' ..HEAD
+ ```
+ - Also inspect full commits when needed:
+ ```bash
+ git log --reverse --format='%H%x09%s' ..HEAD
+ ```
+
+3. Detect merged PRs if any.
+ - Look for first-parent subjects that indicate PR merges, including squash merges like `title (#123)`.
+ - If GitHub CLI is available and the PR number is known, use it to fetch PR title/body for context.
+ - Treat a merged PR as the primary release unit.
+ - Do **not** also list the individual commits that belong to that PR.
+
+4. Handle direct commits separately.
+ - Any commit in the range not represented by a merged PR should be considered on its own.
+
+5. Infer what matters.
+ - For each PR or direct commit, inspect changed files and diff stats.
+ - Read the most relevant files in full when needed to understand user-facing impact.
+ - Ignore pure housekeeping unless it has release value:
+ - version bumps
+ - release/tag commits
+ - changelog-only commits
+ - formatting-only changes
+ - comment-only/doc-only changes unless they materially affect users
+
+6. Draft the changelog entry.
+ - Group items under these sections when applicable:
+ - `### Added`
+ - `### Changed`
+ - `### Fixed`
+ - `### Removed`
+ - `### Breaking Changes`
+ - Write for end users, not for commit archaeology.
+ - Merge related low-level commits into one human-readable bullet when appropriate.
+ - Keep bullets concrete and outcome-focused.
+ - Prefer one bullet per meaningful shipped change.
+ - If there are both PRs and direct commits, include both, but exclude direct commits already covered by PRs.
+
+7. Respect repo reality.
+ - If `CHANGELOG.md` exists, read it before proposing edits and follow its existing style.
+ - If no changelog file exists, say so explicitly and produce a draft entry only.
+ - Do not edit files yet unless the user explicitly asks you to apply the draft.
+
+Output format:
+
+- `Base ref:`
+- `PRs included:`
+- `Direct commits included:`
+- `Excluded as housekeeping:`
+- `Proposed changelog entry:`
+
+If the range has no meaningful user-facing changes, say that plainly instead of forcing entries.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..44fbbaf0
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,19 @@
+# Changelog
+
+## Unreleased
+
+### Added
+- Added optional sound notifications for agent state changes, including a completion chime when background work finishes and an alert when an agent needs input.
+- Added per-agent sound overrides under `[ui.sound.agents]`, so you can mute or enable notifications by agent instead of using one global setting. Droid notifications are muted by default.
+
+### Changed
+- Request alerts now play even when the agent is in the active workspace, while completion sounds remain limited to background workspaces.
+
+### Fixed
+- Improved foreground job detection on Linux and macOS so herdr can recognize agents that run through wrapper processes or generic runtimes, including cases like Codex running under `node`.
+- Made Claude Code state detection more stable by handling more spinner variants and smoothing short busy/idle flicker during screen updates.
+
+## [0.1.0] - 2026-03-27
+
+### Added
+- Initial release.
diff --git a/justfile b/justfile
index e301cbd1..0c400b27 100644
--- a/justfile
+++ b/justfile
@@ -3,11 +3,13 @@
# Run unit tests
test:
cargo test
+ python3 -m unittest scripts.test_changelog
# Check formatting + run unit tests
check:
cargo fmt --check
cargo test
+ python3 -m unittest scripts.test_changelog
# Run integration tests (LLM-based, requires pi + tmux)
test-integration:
@@ -29,7 +31,7 @@ clean-tests:
@rm -f tests/integration/results/*.json tests/integration/results/*.txt 2>/dev/null || true
@echo "cleaned"
-# Bump version, commit, tag, push, trigger release build (usage: just release 0.1.1)
+# Finalize changelog, bump version, commit, tag, push, trigger release build (usage: just release 0.1.1)
release version:
@if [ -n "$(git status --porcelain)" ]; then \
echo "error: commit your changes first"; \
@@ -39,9 +41,11 @@ release version:
echo "error: tag v{{version}} already exists"; \
exit 1; \
fi
+ python3 scripts/changelog.py prepare --version {{version}}
sed -i.bak 's/^version = ".*"/version = "{{version}}"/' Cargo.toml && rm -f Cargo.toml.bak
cargo test --quiet
- git add Cargo.toml Cargo.lock
+ python3 -m unittest scripts.test_changelog
+ git add CHANGELOG.md Cargo.toml Cargo.lock
git diff --cached --quiet || git commit -m "release: v{{version}}"
git tag v{{version}}
git push --follow-tags
diff --git a/scripts/__init__.py b/scripts/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/scripts/changelog.py b/scripts/changelog.py
new file mode 100644
index 00000000..5a87d465
--- /dev/null
+++ b/scripts/changelog.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from dataclasses import dataclass
+from datetime import date
+from pathlib import Path
+
+SECTION_RE = re.compile(r"^##\s+(?:\[(?P[^\]]+)\]|(?P.+?))\s*$", re.MULTILINE)
+VERSION_WITH_DATE_RE = re.compile(r"^(?P.+?)\s+-\s+\d{4}-\d{2}-\d{2}$")
+
+
+@dataclass(frozen=True)
+class Section:
+ title: str
+ start: int
+ end: int
+ body_start: int
+
+
+class ChangelogError(ValueError):
+ pass
+
+
+def normalize_title(raw_title: str) -> str:
+ title = raw_title.strip()
+ match = VERSION_WITH_DATE_RE.match(title)
+ if match:
+ title = match.group("version").strip()
+ if title.startswith("[") and title.endswith("]"):
+ title = title[1:-1].strip()
+ return title
+
+
+def parse_sections(text: str) -> list[Section]:
+ matches = list(SECTION_RE.finditer(text))
+ sections: list[Section] = []
+
+ for index, match in enumerate(matches):
+ title = normalize_title(match.group("bracketed") or match.group("plain") or "")
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
+ body_start = match.end()
+ if body_start < len(text) and text[body_start : body_start + 1] == "\n":
+ body_start += 1
+ sections.append(Section(title=title, start=match.start(), end=end, body_start=body_start))
+
+ return sections
+
+
+def find_section(text: str, wanted_title: str) -> Section:
+ for section in parse_sections(text):
+ if section.title == wanted_title:
+ return section
+ raise ChangelogError(f"section not found: {wanted_title}")
+
+
+def extract_section_body(text: str, wanted_title: str) -> str:
+ section = find_section(text, wanted_title)
+ body = text[section.body_start : section.end].strip("\n")
+ if not body.strip():
+ raise ChangelogError(f"section is empty: {wanted_title}")
+ return body + "\n"
+
+
+def prepare_release(text: str, version: str, release_date: str) -> str:
+ unreleased = None
+ existing_version = False
+
+ for section in parse_sections(text):
+ if section.title == "Unreleased":
+ unreleased = section
+ if section.title == version:
+ existing_version = True
+
+ if existing_version:
+ raise ChangelogError(f"version already exists in changelog: {version}")
+ if unreleased is None:
+ raise ChangelogError("missing Unreleased section")
+
+ unreleased_body = text[unreleased.body_start : unreleased.end].strip("\n")
+ if not unreleased_body.strip():
+ raise ChangelogError("Unreleased section is empty")
+
+ prefix = text[: unreleased.start].rstrip("\n")
+ suffix = text[unreleased.end :].strip("\n")
+
+ rebuilt = f"## Unreleased\n\n## [{version}] - {release_date}\n\n{unreleased_body}"
+ if suffix:
+ rebuilt += f"\n\n{suffix}"
+
+ if prefix:
+ return f"{prefix}\n\n{rebuilt}\n"
+ return rebuilt + "\n"
+
+
+def load_text(path: Path) -> str:
+ try:
+ return path.read_text(encoding="utf-8")
+ except FileNotFoundError as exc:
+ raise ChangelogError(f"changelog not found: {path}") from exc
+
+
+def write_text(path: Path, text: str) -> None:
+ path.write_text(text, encoding="utf-8")
+
+
+def cmd_prepare(args: argparse.Namespace) -> int:
+ path = Path(args.path)
+ original = load_text(path)
+ updated = prepare_release(original, args.version, args.date)
+ write_text(path, updated)
+ return 0
+
+
+def cmd_extract(args: argparse.Namespace) -> int:
+ path = Path(args.path)
+ body = extract_section_body(load_text(path), args.version)
+ if args.output:
+ write_text(Path(args.output), body)
+ else:
+ sys.stdout.write(body)
+ return 0
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description="Prepare and extract changelog release notes")
+ subparsers = parser.add_subparsers(dest="command", required=True)
+
+ prepare = subparsers.add_parser("prepare", help="Move Unreleased into a versioned section")
+ prepare.add_argument("--path", default="CHANGELOG.md")
+ prepare.add_argument("--version", required=True)
+ prepare.add_argument("--date", default=str(date.today()))
+ prepare.set_defaults(func=cmd_prepare)
+
+ extract = subparsers.add_parser("extract", help="Extract a version section body")
+ extract.add_argument("--path", default="CHANGELOG.md")
+ extract.add_argument("--version", required=True)
+ extract.add_argument("--output")
+ extract.set_defaults(func=cmd_extract)
+
+ return parser
+
+
+def main() -> int:
+ parser = build_parser()
+ args = parser.parse_args()
+
+ try:
+ return args.func(args)
+ except ChangelogError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/test_changelog.py b/scripts/test_changelog.py
new file mode 100644
index 00000000..cf9e23ac
--- /dev/null
+++ b/scripts/test_changelog.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+import unittest
+
+from scripts.changelog import extract_section_body, prepare_release
+
+
+class ChangelogScriptTests(unittest.TestCase):
+ def test_prepare_release_moves_unreleased_into_versioned_section(self) -> None:
+ original = """# Changelog\n\n## Unreleased\n\n### Fixed\n- Smoothed Claude flapping.\n\n## [0.1.0] - 2026-03-27\n\n### Added\n- Initial release.\n"""
+
+ updated = prepare_release(original, "0.1.1", "2026-03-28")
+
+ self.assertIn("## Unreleased\n\n## [0.1.1] - 2026-03-28", updated)
+ self.assertIn("### Fixed\n- Smoothed Claude flapping.", updated)
+ self.assertIn("## [0.1.0] - 2026-03-27", updated)
+
+ def test_prepare_release_accepts_bracketed_unreleased_heading(self) -> None:
+ original = """# Changelog\n\n## [Unreleased]\n\n### Added\n- Added sounds.\n"""
+
+ updated = prepare_release(original, "0.1.1", "2026-03-28")
+
+ self.assertIn("## Unreleased\n\n## [0.1.1] - 2026-03-28", updated)
+ self.assertIn("### Added\n- Added sounds.", updated)
+
+ def test_extract_section_body_returns_requested_version_only(self) -> None:
+ changelog = """# Changelog\n\n## Unreleased\n\n## [0.1.1] - 2026-03-28\n\n### Fixed\n- Smoothed Claude flapping.\n\n## [0.1.0] - 2026-03-27\n\n### Added\n- Initial release.\n"""
+
+ body = extract_section_body(changelog, "0.1.1")
+
+ self.assertEqual(body, "### Fixed\n- Smoothed Claude flapping.\n")
+
+
+if __name__ == "__main__":
+ unittest.main()