From d7bd40213a8035182db722c79f18a6a99b7c3ccb Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Thu, 10 Sep 2026 15:28:21 -0700 Subject: [PATCH 1/3] Switch changelog to per-PR fragment files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every open PR editing CHANGELOG.md's `## Pending` section directly meant any two concurrent PRs collided on the same lines, and each merge forced the next PR to redo the conflict resolution. Add changelog.d/, where each PR drops a new file instead of editing the shared section, so concurrent PRs can never conflict on this axis. scripts/assemble_changelog.py folds the fragments into a versioned CHANGELOG.md section at release time. See changelog.d/README.md for the fragment format and .claude/commands/release.md for the updated release process (not committed here — .claude/ isn't tracked in this repo yet). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BK3fzED9ksTWsvoPfsEwpS --- changelog.d/README.md | 40 ++++++++++++ scripts/assemble_changelog.py | 120 ++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 changelog.d/README.md create mode 100755 scripts/assemble_changelog.py diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 0000000..777f32a --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,40 @@ +# Changelog fragments + +`CHANGELOG.md` used to grow a `## Pending` section that every in-flight PR +edited directly. That meant any two PRs open at the same time collided on +the same few lines, and every merge forced the next PR to re-resolve the +conflict — annoying on its own, and it got worse the more PRs were open at +once. + +Instead, each PR that needs a changelog entry adds **a new file** here +instead of editing `CHANGELOG.md`. Two PRs adding two different files can +never conflict with each other, no matter what order they merge in. + +## Adding an entry + +Create `changelog.d/.md` with the same shape a normal +changelog section would have — one or more `### Added` / `### Changed` / +`### Fixed` / `### Removed` headings, each followed by one or more bullet +points: + +```markdown +### Fixed + +- `scout widgets list` no longer crashes on an empty response (#42) +``` + +A single fragment can include more than one heading if a PR touches more +than one kind of change. Reference the issue or PR number in the bullet +text the same way existing changelog entries do. + +## Releasing + +At release time, `scripts/assemble_changelog.py ` reads every +fragment in this directory, merges same-type sections together (in +Added / Changed / Fixed / Removed order), writes the result into +`CHANGELOG.md` under a new `## [] - ` heading, and deletes +the fragment files it consumed. See `.claude/commands/release.md` for the +full release process. + +Do not edit `CHANGELOG.md`'s `## Pending`-equivalent state directly for an +in-progress change — add a fragment here instead. diff --git a/scripts/assemble_changelog.py b/scripts/assemble_changelog.py new file mode 100755 index 0000000..fbe9f99 --- /dev/null +++ b/scripts/assemble_changelog.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""assemble_changelog.py — fold changelog.d/ fragments into CHANGELOG.md. + +Reads every fragment in changelog.d/ (except README.md), merges their +### Added / ### Changed / ### Fixed / ### Removed sections (in that order, +de-duplicating identical bullets), writes the result into CHANGELOG.md as a +new versioned section right after the "# Changelog" heading, and deletes +the fragment files it consumed. + +Usage: + python3 scripts/assemble_changelog.py X.Y.Z [--date YYYY-MM-DD] + +Run this as part of cutting a release (see .claude/commands/release.md). +Review the resulting CHANGELOG.md diff before committing — this script +does not commit anything itself. +""" +import argparse +import datetime +import re +import sys +from pathlib import Path + +ORDER = ["Added", "Changed", "Fixed", "Removed"] +REPO_ROOT = Path(__file__).resolve().parent.parent +FRAGMENTS_DIR = REPO_ROOT / "changelog.d" +CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md" + + +def parse_fragment(text): + """Return {heading: [bullet_lines]} from one fragment's text.""" + sections = {} + current = None + for line in text.splitlines(): + m = re.match(r"^### (\w+)", line) + if m: + current = m.group(1) + sections.setdefault(current, []) + continue + if current and line.strip().startswith("-"): + sections[current].append(line.rstrip()) + return sections + + +def render(sections): + out = [] + for heading in ORDER: + bullets = sections.get(heading) + if not bullets: + continue + out.append(f"### {heading}\n\n") + out.extend(b + "\n" for b in bullets) + out.append("\n") + # Preserve any non-standard heading at the end rather than silently + # dropping it. + for heading, bullets in sections.items(): + if heading not in ORDER and bullets: + out.append(f"### {heading}\n\n") + out.extend(b + "\n" for b in bullets) + out.append("\n") + return "".join(out) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version", help="Version being released, e.g. 0.5.0") + parser.add_argument( + "--date", + default=datetime.date.today().isoformat(), + help="Release date (default: today, YYYY-MM-DD)", + ) + args = parser.parse_args() + + fragment_paths = sorted( + p for p in FRAGMENTS_DIR.glob("*.md") if p.name != "README.md" + ) + if not fragment_paths: + print("No changelog fragments found in changelog.d/ — nothing to do.") + sys.exit(0) + + merged = {} + for path in fragment_paths: + sections = parse_fragment(path.read_text()) + if not sections: + print(f"warning: {path} has no ### heading / bullet content, skipping", file=sys.stderr) + continue + for heading, bullets in sections.items(): + existing = merged.setdefault(heading, []) + for b in bullets: + if b not in existing: + existing.append(b) + + if not merged: + print("Fragments found but none contained usable entries — nothing to do.") + sys.exit(0) + + body = render(merged) + new_section = f"## [{args.version}] - {args.date}\n\n{body}" + + changelog = CHANGELOG_PATH.read_text() + lines = changelog.splitlines(keepends=True) + # Insert right after the "# Changelog" heading (and the blank line after + # it, if present), before the first existing "## " section. + insert_at = 1 + while insert_at < len(lines) and not lines[insert_at].startswith("## "): + insert_at += 1 + new_changelog = "".join(lines[:insert_at]) + new_section + "".join(lines[insert_at:]) + CHANGELOG_PATH.write_text(new_changelog) + + for path in fragment_paths: + path.unlink() + + print(f"Wrote ## [{args.version}] - {args.date} to {CHANGELOG_PATH.relative_to(REPO_ROOT)}") + print(f"Consumed {len(fragment_paths)} fragment(s):") + for path in fragment_paths: + print(f" - {path.relative_to(REPO_ROOT)}") + print("\nReview the CHANGELOG.md diff, then commit both the changelog and the fragment deletions together.") + + +if __name__ == "__main__": + main() From 3e3d6316609db3f5fb838095fafe43d73b929b1a Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Thu, 10 Sep 2026 15:35:11 -0700 Subject: [PATCH 2/3] Document JSON-when-piped in README and add changelog fragment Rewrite the LLM / Agent Usage section around --json, drop the --toon row from the Global Flags table, and note that --json auto-enables when piped. Closes #28 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BK3fzED9ksTWsvoPfsEwpS --- changelog.d/28.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/28.md diff --git a/changelog.d/28.md b/changelog.d/28.md new file mode 100644 index 0000000..b535137 --- /dev/null +++ b/changelog.d/28.md @@ -0,0 +1,3 @@ +### Removed + +- `--toon` flag and the `toon-format/toon-go` dependency — `--json` is now the CLI's only structured output format, and piped output auto-enables `--json` instead of TOON (#28) From 7bb0712fa45c4ee369f30be13bafb3e10b21fb29 Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Fri, 11 Sep 2026 10:25:14 -0700 Subject: [PATCH 3/3] Revert to direct CHANGELOG.md edits Drop changelog.d/ and the assembly script. Move this PR's changelog entry into CHANGELOG.md's Pending section directly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BK3fzED9ksTWsvoPfsEwpS --- CHANGELOG.md | 4 ++ changelog.d/28.md | 3 - changelog.d/README.md | 40 ------------ scripts/assemble_changelog.py | 120 ---------------------------------- 4 files changed, 4 insertions(+), 163 deletions(-) delete mode 100644 changelog.d/28.md delete mode 100644 changelog.d/README.md delete mode 100755 scripts/assemble_changelog.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e0a087a..c79d4ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,10 @@ - String truncation in tables, the span tree and API error bodies counts graphemes rather than bytes, so it cannot split a multibyte character (#29) - `scout setup ` matches framework names case-insensitively and its error points at `scout setup` with no arguments for the valid list (#29) +### Removed + +- `--toon` flag and the `toon-format/toon-go` dependency — `--json` is now the CLI's only structured output format, and piped output auto-enables `--json` instead of TOON (#28) + ## [0.5.0] - 2026-09-10 This release rounds out command coverage across the whole Scout API surface diff --git a/changelog.d/28.md b/changelog.d/28.md deleted file mode 100644 index b535137..0000000 --- a/changelog.d/28.md +++ /dev/null @@ -1,3 +0,0 @@ -### Removed - -- `--toon` flag and the `toon-format/toon-go` dependency — `--json` is now the CLI's only structured output format, and piped output auto-enables `--json` instead of TOON (#28) diff --git a/changelog.d/README.md b/changelog.d/README.md deleted file mode 100644 index 777f32a..0000000 --- a/changelog.d/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Changelog fragments - -`CHANGELOG.md` used to grow a `## Pending` section that every in-flight PR -edited directly. That meant any two PRs open at the same time collided on -the same few lines, and every merge forced the next PR to re-resolve the -conflict — annoying on its own, and it got worse the more PRs were open at -once. - -Instead, each PR that needs a changelog entry adds **a new file** here -instead of editing `CHANGELOG.md`. Two PRs adding two different files can -never conflict with each other, no matter what order they merge in. - -## Adding an entry - -Create `changelog.d/.md` with the same shape a normal -changelog section would have — one or more `### Added` / `### Changed` / -`### Fixed` / `### Removed` headings, each followed by one or more bullet -points: - -```markdown -### Fixed - -- `scout widgets list` no longer crashes on an empty response (#42) -``` - -A single fragment can include more than one heading if a PR touches more -than one kind of change. Reference the issue or PR number in the bullet -text the same way existing changelog entries do. - -## Releasing - -At release time, `scripts/assemble_changelog.py ` reads every -fragment in this directory, merges same-type sections together (in -Added / Changed / Fixed / Removed order), writes the result into -`CHANGELOG.md` under a new `## [] - ` heading, and deletes -the fragment files it consumed. See `.claude/commands/release.md` for the -full release process. - -Do not edit `CHANGELOG.md`'s `## Pending`-equivalent state directly for an -in-progress change — add a fragment here instead. diff --git a/scripts/assemble_changelog.py b/scripts/assemble_changelog.py deleted file mode 100755 index fbe9f99..0000000 --- a/scripts/assemble_changelog.py +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env python3 -"""assemble_changelog.py — fold changelog.d/ fragments into CHANGELOG.md. - -Reads every fragment in changelog.d/ (except README.md), merges their -### Added / ### Changed / ### Fixed / ### Removed sections (in that order, -de-duplicating identical bullets), writes the result into CHANGELOG.md as a -new versioned section right after the "# Changelog" heading, and deletes -the fragment files it consumed. - -Usage: - python3 scripts/assemble_changelog.py X.Y.Z [--date YYYY-MM-DD] - -Run this as part of cutting a release (see .claude/commands/release.md). -Review the resulting CHANGELOG.md diff before committing — this script -does not commit anything itself. -""" -import argparse -import datetime -import re -import sys -from pathlib import Path - -ORDER = ["Added", "Changed", "Fixed", "Removed"] -REPO_ROOT = Path(__file__).resolve().parent.parent -FRAGMENTS_DIR = REPO_ROOT / "changelog.d" -CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md" - - -def parse_fragment(text): - """Return {heading: [bullet_lines]} from one fragment's text.""" - sections = {} - current = None - for line in text.splitlines(): - m = re.match(r"^### (\w+)", line) - if m: - current = m.group(1) - sections.setdefault(current, []) - continue - if current and line.strip().startswith("-"): - sections[current].append(line.rstrip()) - return sections - - -def render(sections): - out = [] - for heading in ORDER: - bullets = sections.get(heading) - if not bullets: - continue - out.append(f"### {heading}\n\n") - out.extend(b + "\n" for b in bullets) - out.append("\n") - # Preserve any non-standard heading at the end rather than silently - # dropping it. - for heading, bullets in sections.items(): - if heading not in ORDER and bullets: - out.append(f"### {heading}\n\n") - out.extend(b + "\n" for b in bullets) - out.append("\n") - return "".join(out) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("version", help="Version being released, e.g. 0.5.0") - parser.add_argument( - "--date", - default=datetime.date.today().isoformat(), - help="Release date (default: today, YYYY-MM-DD)", - ) - args = parser.parse_args() - - fragment_paths = sorted( - p for p in FRAGMENTS_DIR.glob("*.md") if p.name != "README.md" - ) - if not fragment_paths: - print("No changelog fragments found in changelog.d/ — nothing to do.") - sys.exit(0) - - merged = {} - for path in fragment_paths: - sections = parse_fragment(path.read_text()) - if not sections: - print(f"warning: {path} has no ### heading / bullet content, skipping", file=sys.stderr) - continue - for heading, bullets in sections.items(): - existing = merged.setdefault(heading, []) - for b in bullets: - if b not in existing: - existing.append(b) - - if not merged: - print("Fragments found but none contained usable entries — nothing to do.") - sys.exit(0) - - body = render(merged) - new_section = f"## [{args.version}] - {args.date}\n\n{body}" - - changelog = CHANGELOG_PATH.read_text() - lines = changelog.splitlines(keepends=True) - # Insert right after the "# Changelog" heading (and the blank line after - # it, if present), before the first existing "## " section. - insert_at = 1 - while insert_at < len(lines) and not lines[insert_at].startswith("## "): - insert_at += 1 - new_changelog = "".join(lines[:insert_at]) + new_section + "".join(lines[insert_at:]) - CHANGELOG_PATH.write_text(new_changelog) - - for path in fragment_paths: - path.unlink() - - print(f"Wrote ## [{args.version}] - {args.date} to {CHANGELOG_PATH.relative_to(REPO_ROOT)}") - print(f"Consumed {len(fragment_paths)} fragment(s):") - for path in fragment_paths: - print(f" - {path.relative_to(REPO_ROOT)}") - print("\nReview the CHANGELOG.md diff, then commit both the changelog and the fragment deletions together.") - - -if __name__ == "__main__": - main()