From 6671d08e41bd1b2dc6e9291d16ffdd83d8ad33c8 Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:34:39 +0200 Subject: [PATCH 1/4] docs: tell agents to replace em dashes with plain punctuation The agent guide told agents to replace em dashes with hyphens or sentence breaks, which is not the rule: a spaced hyphen is the same dash in another character. `AGENTS.md` rule 10 and the code style section of `docs/agent/dev.md` now say to use a comma, colon, period or parentheses. `.gitignore` meant to keep stray local agent guides out of git but ignored `AGENT.md` and re-included a root `/AGENT.md` that does not exist. It now ignores `AGENTS.md` and `AGENT.md` anywhere except the root guide, the root `CLAUDE.md` and the family agent's workspace guide under `stacklets/agent/workspace/`, which the agent stacklet ships. --- .gitignore | 9 ++++++--- AGENTS.md | 2 +- docs/agent/dev.md | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 0e922566..0bf560ad 100644 --- a/.gitignore +++ b/.gitignore @@ -69,10 +69,13 @@ impl/ **CLAUDE.md **AGENT.md -# …except the repo's canonical agent guide at the root, and the -# CLAUDE.md that loads it into Claude Code. -!/AGENT.md +**AGENTS.md +# …except the repo's canonical agent guide at the root, the CLAUDE.md +# that loads it into Claude Code, and the family agent's workspace +# guide, which is product content the agent stacklet ships. +!/AGENTS.md !/CLAUDE.md +!/stacklets/agent/workspace/AGENTS.md # Local planning workspace (Nimbalyst) — not product source nimbalyst-local/ diff --git a/AGENTS.md b/AGENTS.md index 4487d3dd..ea14af74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ Apply to every role, every session. 7. **Never read or copy a user's production family vault.** Reference paths only; fabricate test data. 8. **Destructive ops need confirmation.** `stack destroy`, `stack uninstall`, `rm -rf`, `git reset --hard`, force-push. 9. **Announce actions before running them.** No silent long running or integration test runs, scripts, or background commands. -10. **No em dashes in user-facing prose.** Use hyphens or sentence breaks. +10. **No em dashes in user-facing prose.** Use a comma, colon, period or parentheses instead. 11. **Put data in through the front door.** Seed and exercise a stacklet the way a family does, never by writing to the service behind it. Documents go through `tools/family-docs/ingest.py` (Matrix room -> archivist -> OCR -> classify -> mirror), never a direct `POST /api/documents/post_document/`. The back door skips the pipeline, so what lands is not what users get: no tags, no correspondent, no document type, no rewritten title, no summary note, no vault entry. Any conclusion drawn from that data is about a system famstack does not ship. 12. **Comments explain the code, not sell it.** Literate but neutral: state the constraint and the reason, without rhetoric or drama. Write `# Tags are mutable, so only the digest identifies the image this dump came from.` rather than `# Tags lie over time -- the digest is the load-bearing field that still names this exact image in five years.` diff --git a/docs/agent/dev.md b/docs/agent/dev.md index 3343ff31..4dcdd9d6 100644 --- a/docs/agent/dev.md +++ b/docs/agent/dev.md @@ -251,7 +251,7 @@ Testing rules: - **Literate code.** Narrative docstrings, section dividers (`# ── Section ──`), prose flow over terse chains. The code IS the specification AND the implementation - keep both legible. - **Python 3.11 floor.** `tomllib` is stdlib; no compat shims. Use modern Python (`match`, structural pattern matching, walrus when it earns its keep). - **Comments explain WHY, not WHAT.** If a comment paraphrases the code, delete it. Keep comments that document constraints, invariants, or surprises. -- **No em dashes in user-facing text** (commit messages, rendered docstrings, blog drafts). Hyphens or sentence breaks instead. +- **No em dashes in user-facing text** (commit messages, rendered docstrings, blog drafts). Use a comma, colon, period or parentheses instead. - **No `--no-verify` or `--no-gpg-sign`** on commits unless the user explicitly asks. If a hook fails, fix the underlying issue. - **Unchecked return values are a smell.** On the third site in a session, propose an audit instead of patching a third instance. - **Re-read the full error line before calling a failure a duplicate.** Check sender, target, specific IDs. Two errors that look similar at a glance often differ in the load-bearing field. From 92dff9fe9e59650a264587fd1205c4dcfb728927 Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:36:01 +0200 Subject: [PATCH 2/4] ci: reject em dashes in commits and pull requests Commit subjects and bodies are quoted verbatim into the release notes, which use no em dashes. `tools/commit-lint` now refuses an em dash (U+2014) in a commit message, a PR title and a PR description, and says to use a comma, colon, period or parentheses instead. It runs wherever the attribution check runs: the `commit-msg` hook, the range CI and the release gate check, and the PR title and body. An en dash in a range and a plain hyphen still pass. Commits already on main are not affected: CI only checks the commits of a pull request. --- docs/agent/dev.md | 5 ++ hooks/commit-msg | 3 +- tests/unit/framework/test_commit_lint.py | 90 ++++++++++++++++++++++++ tools/commit-lint | 23 ++++-- 4 files changed, 116 insertions(+), 5 deletions(-) diff --git a/docs/agent/dev.md b/docs/agent/dev.md index 4dcdd9d6..f35e80fd 100644 --- a/docs/agent/dev.md +++ b/docs/agent/dev.md @@ -339,6 +339,11 @@ needed and what it changes; the diff shows how. It must make sense without the PR. Bullets for sets of changes. No debugging history, test narrative or rhetoric; those go in the PR. +**No em dashes**, in the subject, the body, or the PR title and +description. All of them are published as release notes. Use a comma, +colon, period or parentheses. An en dash in a range (`1–2`) and a hyphen +are fine. `tools/commit-lint` rejects the em dash in all four places. + | Type | Body states | |---|---| | `feat` | what is now possible, how to use it, limits | diff --git a/hooks/commit-msg b/hooks/commit-msg index ade11279..31399082 100755 --- a/hooks/commit-msg +++ b/hooks/commit-msg @@ -1,6 +1,7 @@ #!/bin/sh # Block commit subjects that would not parse as a changelog entry, and -# messages that carry a credit line for the tool that wrote them. +# messages that carry a credit line for the tool that wrote them or an +# em dash. # # Enable once per clone (same switch as the pre-commit hook): # git config core.hooksPath hooks diff --git a/tests/unit/framework/test_commit_lint.py b/tests/unit/framework/test_commit_lint.py index 39b73923..869c7103 100644 --- a/tests/unit/framework/test_commit_lint.py +++ b/tests/unit/framework/test_commit_lint.py @@ -287,3 +287,93 @@ def test_a_range_fails_on_a_trailer_in_any_body(self, tmp_path): result = self._run("--range", "HEAD~1..HEAD", cwd=repo) assert result.returncode == 1 assert "Co-Authored-By" in result.stderr + + +# ── No em dashes ───────────────────────────────────────────────────────── + +class TestNoEmDashes: + """Subjects and bodies are quoted verbatim into the release notes, + and the release notes use no em dashes. A comma, colon, period or + parentheses does the same job. The check covers whole messages and + pull request text, like the attribution check, because a body is + published as much as a subject is.""" + + @pytest.mark.parametrize("text", [ + "fix(ai): stop the engine — for good", + "fix(ai): stop the engine\n\nIt restarted — every time.\n", + "fix(ai): stop the engine—for good", + ]) + def test_an_em_dash_is_found_anywhere_in_the_text(self, text): + problems = commit_lint.em_dashes(text) + assert problems + assert "comma, colon, period or parentheses" in problems[0] + + @pytest.mark.parametrize("text", [ + "fix(photos): retry the upload after 1–2 minutes", + "fix(photos): keep the built-in thumbnails - the rest regenerate", + "docs: explain the pre-tag gate", + ]) + def test_an_en_dash_or_a_hyphen_is_not_an_em_dash(self, text): + """U+2013 in a number range is correct typography, and a + hyphen is a different character altogether.""" + assert commit_lint.em_dashes(text) == [] + + +class TestEmDashesOnTheCommandLine: + """The same four ways in as every other rule: the hook, a range for + CI and the release gate, and the pull request title and body.""" + + def _run(self, *args, cwd=None): + return subprocess.run( + [sys.executable, str(REPO_ROOT / "tools" / "commit-lint"), *args], + capture_output=True, text=True, cwd=cwd or REPO_ROOT, + ) + + def test_a_title_with_an_em_dash_fails_and_names_the_alternatives(self): + result = self._run("--title", "fix(ai): stop the engine — for good") + assert result.returncode == 1 + assert "em dash" in result.stderr + assert "comma, colon, period or parentheses" in result.stderr + + def test_an_em_dash_in_the_body_fails_the_commit(self, tmp_path): + message = tmp_path / "COMMIT_EDITMSG" + message.write_text( + "fix(core): answer the first message\n\n" + "The bot slept through it — now it does not.\n") + result = self._run("--message", str(message)) + assert result.returncode == 1 + assert "em dash" in result.stderr + + def test_the_diff_below_the_scissors_is_not_the_message(self, tmp_path): + """`git commit -v` appends the diff under a scissors line. A + commit that removes em dashes from a document has them in that + diff, and git drops it before the commit exists.""" + message = tmp_path / "COMMIT_EDITMSG" + message.write_text( + "docs(readme): replace em dashes with commas\n\n" + "# ------------------------ >8 ------------------------\n" + "-Local first — your data stays home.\n" + "+Local first, your data stays home.\n") + assert self._run("--message", str(message)).returncode == 0 + + def test_a_pull_request_body_with_an_em_dash_fails(self): + result = self._run("--body", "## Summary\n- stop the engine — for good\n") + assert result.returncode == 1 + assert "em dash" in result.stderr + + def test_a_range_fails_on_an_em_dash_in_any_body(self, tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True) + for name, message in [ + ("a", "feat(photos): back up over Wi-Fi"), + ("b", "fix(core): answer the first message\n\n" + "It slept through it — now it does not."), + ]: + (repo / name).write_text(name) + subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True) + subprocess.run(["git", "-C", str(repo), "-c", "user.email=t@example.com", + "-c", "user.name=T", "commit", "-qm", message], check=True) + result = self._run("--range", "HEAD~1..HEAD", cwd=repo) + assert result.returncode == 1 + assert "em dash" in result.stderr diff --git a/tools/commit-lint b/tools/commit-lint index 88cb4709..f72a9fd6 100755 --- a/tools/commit-lint +++ b/tools/commit-lint @@ -10,6 +10,7 @@ It also refuses credit lines for the tool that helped write a change: a Co-Authored-By trailer, a "Generated with Claude Code" footer, its link, or an Anthropic noreply address. That is checked in whole messages and in pull request descriptions, not just in subjects. +So is the em dash, which the published release notes do not use. Four ways in, one rule set: @@ -166,6 +167,20 @@ def attribution(text: str) -> list[str]: return [why for pattern, why in ATTRIBUTION if pattern.search(text)] +# Subjects and bodies are quoted verbatim into the release notes, which +# use no em dashes. Only U+2014 is refused: an en dash in a range such +# as "1–2" and a plain hyphen are different characters and pass. +EM_DASH = "\u2014" + + +def em_dashes(text: str) -> list[str]: + """The em dash rule, for a subject, a whole message or a description.""" + if EM_DASH in text: + return [f"an em dash ({EM_DASH}); use a comma, colon, period " + "or parentheses instead"] + return [] + + def without_comments(message: str) -> str: """What git keeps of an edited message: comment lines are stripped before the commit exists, and everything below the scissors goes.""" @@ -227,19 +242,19 @@ def main() -> int: with open(args.message, encoding="utf-8") as f: message = without_comments(f.read()) header = message.split("\n", 1)[0] - problems = check(header) + attribution(message) + problems = check(header) + attribution(message) + em_dashes(message) if problems: report(header.rstrip(), problems) failures = 1 elif args.body is not None: - problems = attribution(args.body) + problems = attribution(args.body) + em_dashes(args.body) if problems: report("pull request description", problems) failures = 1 elif args.title: - problems = check(args.title) + problems = check(args.title) + em_dashes(args.title) if problems: report(args.title, problems) failures = 1 @@ -257,7 +272,7 @@ def main() -> int: return 1 for sha, message in pairs: subject = message.split("\n", 1)[0] - problems = check(subject) + attribution(message) + problems = check(subject) + attribution(message) + em_dashes(message) if problems: report(subject, problems, where=sha) failures += 1 From 3cc864bd9386ef5e9390a0e216395a6952a066c8 Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:46:24 +0200 Subject: [PATCH 3/4] build: generate the release notes from the commit log The GitHub release body was written by hand, although `docs/agent/dev.md` already specifies how commit messages become release notes. `script/release-notes` now prints them as markdown, with git-cliff run through `uvx`, so there is nothing to install: - Action required first: any `!`, `Upgrade:` or `BREAKING CHANGE:` commit, whatever its type, with the footer quoted. - Then Security, Added, Fixed, Performance and Documentation, grouped by the rendered scope label. Internal types and merges are left out, and a subject that does not parse shows under Unclassified. - No argument prints what the next tag will ship, `--latest` what the newest tag shipped, and a range works too. With nothing to announce it says so. It runs from any directory, so a release workflow outside the repo can call it. It refuses a shallow clone, where git-cliff silently finds no commits. `cliff.toml` repeats commit-lint's types and scopes, and a unit test fails and names the difference when the two disagree. Releases can still be written by hand; nothing else changes. --- cliff.toml | 109 ++++++++ docs/agent/dev.md | 19 +- script/release-notes | 35 +++ tests/unit/framework/test_release_notes.py | 293 +++++++++++++++++++++ 4 files changed, 450 insertions(+), 6 deletions(-) create mode 100644 cliff.toml create mode 100755 script/release-notes create mode 100644 tests/unit/framework/test_release_notes.py diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 00000000..2aab2c1b --- /dev/null +++ b/cliff.toml @@ -0,0 +1,109 @@ +# Release notes, generated from the commit log by git-cliff. +# +# script/release-notes what the next tag will ship +# script/release-notes --latest what the newest tag shipped +# +# The layout is the one docs/agent/dev.md specifies ("Commits: the subject +# is the changelog"): Action required first, then Security, Added, Fixed, +# Performance and Documentation, each grouped by rendered scope. +# +# The types and scopes below repeat TYPES and SCOPES in tools/commit-lint, +# because git-cliff cannot read Python. A unit test fails when the two +# disagree (tests/unit/framework/test_commit_lint.py). + +[changelog] +header = "" +# Tera template. An entry is "- **:** ", and an +# Action required entry adds its Upgrade: or BREAKING CHANGE: footer as a +# second paragraph, verbatim, because that text is what the admin must do. +# Entries without a scope come last in their section (General). +body = """ +{%- macro entry(commit, label) %} +- {% if label %}**{{ label }}:** {% endif %}{{ commit.message | upper_first }} +{%- for footer in commit.footers | default(value=[]) %}{% if footer.token == "Upgrade" or footer.breaking %} + + **{{ footer.token }}:** {{ footer.value | upper_first | replace(from="\n", to="\n ") }} +{%- endif %}{% endfor %} +{%- endmacro entry -%} + +{%- if commits | length == 0 -%} +No user-facing changes since {{ previous.version | default(value="the last release") }}. +{% else -%} +{%- for group, commits in commits | group_by(attribute="group") -%} +{% if not loop.first %} +{% endif %}### {{ group | striptags | trim }} +{% for scope, scoped in commits | group_by(attribute="scope") %}{% for commit in scoped %}{{ self::entry(commit=commit, label=scope) }}{% endfor %}{% endfor %} +{%- for commit in commits %}{% if not commit.scope %}{{ self::entry(commit=commit, label="") }}{% endif %}{% endfor %} +{% endfor -%} +{%- endif -%} +""" +# Off, because it strips the indent that keeps a footer inside its entry. +trim = false +# Render a release with nothing to announce, so that it says so. +render_always = true + +[git] +conventional_commits = true +filter_unconventional = false +split_commits = false +protect_breaking_commits = true +filter_commits = false +tag_pattern = "v[0-9].*" +sort_commits = "oldest" + +# Scope to label, one entry per SCOPES key in tools/commit-lint. Every +# label has a space in it, so the last entry only strips a scope that is +# not in the table, which then lands under General as dev.md says. +commit_preprocessors = [ + { pattern = "^([a-z]+)\\(agent\\)", replace = "${1}(Agent Stacklet)" }, + { pattern = "^([a-z]+)\\(ai\\)", replace = "${1}(AI Stacklet)" }, + { pattern = "^([a-z]+)\\(backup\\)", replace = "${1}(Backup Stacklet)" }, + { pattern = "^([a-z]+)\\(chatai\\)", replace = "${1}(ChatAI Stacklet)" }, + { pattern = "^([a-z]+)\\(code\\)", replace = "${1}(Code Stacklet)" }, + { pattern = "^([a-z]+)\\(core\\)", replace = "${1}(Core Stacklet)" }, + { pattern = "^([a-z]+)\\(docs\\)", replace = "${1}(Docs Stacklet)" }, + { pattern = "^([a-z]+)\\(infra\\)", replace = "${1}(Infra Stacklet)" }, + { pattern = "^([a-z]+)\\(memory\\)", replace = "${1}(Memory Stacklet)" }, + { pattern = "^([a-z]+)\\(messages\\)", replace = "${1}(Messages Stacklet)" }, + { pattern = "^([a-z]+)\\(photos\\)", replace = "${1}(Photos Stacklet)" }, + { pattern = "^([a-z]+)\\(archivist\\)", replace = "${1}(Archivist Bot)" }, + { pattern = "^([a-z]+)\\(mail\\)", replace = "${1}(Mail Bot)" }, + { pattern = "^([a-z]+)\\(scribe\\)", replace = "${1}(Scribe Bot)" }, + { pattern = "^([a-z]+)\\(stacker\\)", replace = "${1}(Stacker Bot)" }, + { pattern = "^([a-z]+)\\(curator\\)", replace = "${1}(Memory Stacklet)" }, + { pattern = "^([a-z]+)\\(wiki\\)", replace = "${1}(Memory Stacklet)" }, + { pattern = "^([a-z]+)\\(diary\\)", replace = "${1}(Memory Stacklet)" }, + { pattern = "^([a-z]+)\\(capture\\)", replace = "${1}(Archivist Bot)" }, + { pattern = "^([a-z]+)\\(voice\\)", replace = "${1}(AI Stacklet)" }, + { pattern = "^([a-z]+)\\(stack\\)", replace = "${1}(stack CLI)" }, + { pattern = "^([a-z]+)\\(cli\\)", replace = "${1}(stack CLI)" }, + { pattern = "^([a-z]+)\\(doctor\\)", replace = "${1}(stack CLI)" }, + { pattern = "^([a-z]+)\\(update\\)", replace = "${1}(stack CLI)" }, + { pattern = "^([a-z]+)\\(install\\)", replace = "${1}(stack CLI)" }, + { pattern = "^([a-z]+)\\(web\\)", replace = "${1}(web fetch)" }, + { pattern = "^([a-z]+)\\([a-z0-9-]+\\)", replace = "${1}" }, +] + +# First match wins. Anything that needs the admin to act goes to Action +# required whatever its type. A type with no section is committed but not +# announced. Merges are skipped because the commits they merge are already +# listed; commit-lint --range leaves them out the same way. What is left +# does not parse and shows as Unclassified, which blocks a tag (dev.md, +# Releases, step 3b). +commit_parsers = [ + { field = "breaking", pattern = "true", group = "Action required" }, + { footer = "^Upgrade", group = "Action required" }, + { message = "^Merge ", skip = true }, + { message = "^security", group = "Security" }, + { message = "^feat", group = "Added" }, + { message = "^fix", group = "Fixed" }, + { message = "^perf", group = "Performance" }, + { message = "^docs", group = "Documentation" }, + { message = "^refactor", skip = true }, + { message = "^test", skip = true }, + { message = "^chore", skip = true }, + { message = "^ci", skip = true }, + { message = "^style", skip = true }, + { message = "^build", skip = true }, + { message = ".*", group = "Unclassified" }, +] diff --git a/docs/agent/dev.md b/docs/agent/dev.md index f35e80fd..411a7f07 100644 --- a/docs/agent/dev.md +++ b/docs/agent/dev.md @@ -402,10 +402,17 @@ release gate runs it over the range being tagged. `script/setup` turns the hooks on, as do `make test-unit` and `make typecheck`; git cannot do it on clone, by design. -A generator reads the header as -`^(type)(\((scope)\))?(!)?: (subject)( \(#(pr)\))?$` and groups into **Action -required** (any `!` or `Upgrade:`), Security, Added, Fixed, Performance, -Documentation, by rendered scope within each. +`script/release-notes` generates the release notes from these messages +with git-cliff, through `uvx`, so there is nothing to install. It reads the +header as `^(type)(\((scope)\))?(!)?: (subject)( \(#(pr)\))?$` and groups +into **Action required** (any `!`, `Upgrade:` or `BREAKING CHANGE:`, with the +footer quoted), Security, Added, Fixed, Performance, Documentation, by +rendered scope within each. With no argument it prints what the next tag +will ship; `--latest` prints what the newest tag shipped, and a range such +as `v0.3.0-beta.3..main` works too. It runs from any directory and needs the +full history: in a shallow clone it stops and says to `git fetch +--unshallow`. `cliff.toml` repeats commit-lint's types and scopes, and a +unit test fails when the two disagree. ## Branch rules @@ -428,10 +435,10 @@ Pre-tag gate, in order. A published tag is never moved; anything missed here shi 1. Working tree clean - `git status` shows nothing modified, no stale `uv.lock` (the version bump touches `pyproject.toml` AND the lock; commit them together). 2. Version bumped in `lib/stack/cli.py` (`VERSION`) and `pyproject.toml`. 3. Full test round green: framework, stacklets, integration. -3b. Every commit since the previous tag parses as a changelog entry. An unclassified subject blocks the tag: it would be missing from the release notes and from the website. Reword it if it has not shipped, add the entry by hand if it has. +3b. Every commit since the previous tag parses as a changelog entry. An unclassified subject blocks the tag: it would be missing from the release notes and from the website. Reword it if it has not shipped, add the entry by hand if it has. `script/release-notes` shows such a subject under Unclassified. 4. Fresh-instance install verified. 5. Stale references updated: README version callouts, docs links, blog "Try it" instructions. -6. Tag (`vX.Y.Z` / `vX.Y.Z-beta.N`, annotated), push main + tag, publish the GitHub release with Highlights and an "Upgrading from" section. +6. Tag (`vX.Y.Z` / `vX.Y.Z-beta.N`, annotated), push main + tag, publish the GitHub release with Highlights and an "Upgrading from" section, followed by the change list from `script/release-notes --latest`. **The tag format is load-bearing.** `stack update` parses `v?MAJOR.MINOR.PATCH[-label.N]` and ignores anything else, so a tag spelled `v0.3.0.beta1` is invisible: `stack update` would keep offering the previous release and never mention it. Hyphen before the label, dot before its number, as SemVer has it. The prerelease sorts below the release it leads to, and label numbers compare as numbers, so `beta.10` is newer than `beta.9`. diff --git a/script/release-notes b/script/release-notes new file mode 100755 index 00000000..5e49c292 --- /dev/null +++ b/script/release-notes @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# script/release-notes: the release notes, generated from the commit log. +# +# script/release-notes what the next tag will ship +# script/release-notes --latest what the newest tag shipped +# script/release-notes .. any range, e.g. v0.3.0-beta.3..main +# script/release-notes --bumped-version the next tag, from the commit types +# +# Markdown on stdout, ready to paste into a GitHub release. It reads the +# checkout this script lives in, so it can be called from any directory. +# The layout and the tables are in cliff.toml; git-cliff runs through +# uvx, pinned to the version the template was written for, so there is +# nothing to install. + +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" + +# git-cliff reads history through libgit2, which does not handle a shallow +# clone: it finds no commits, or fails, and neither says why. An empty +# release would look like a quiet one, so refuse instead. +if [[ "$(git -C "$root" rev-parse --is-shallow-repository)" == true ]]; then + echo "release-notes: $root is a shallow clone, so its history is incomplete." >&2 + echo " Fetch the rest first: git -C $root fetch --unshallow" >&2 + exit 1 +fi + +if [[ $# -eq 0 ]]; then + set -- --unreleased +fi + +# git-cliff logs which config it read at INFO level, on every run. +export RUST_LOG="${RUST_LOG:-warn}" +exec uvx --quiet git-cliff@2.14.2 \ + --repository "$root" --config "$root/cliff.toml" --strip all "$@" diff --git a/tests/unit/framework/test_release_notes.py b/tests/unit/framework/test_release_notes.py new file mode 100644 index 00000000..98ec2526 --- /dev/null +++ b/tests/unit/framework/test_release_notes.py @@ -0,0 +1,293 @@ +"""The release notes, generated from the commit log. + +`script/release-notes` turns the commits since the last tag into the +change list of a GitHub release, laid out the way docs/agent/dev.md +specifies ("Commits: the subject is the changelog"). These tests drive it +the way a maintainer does, from outside a repository whose history is +known, and hold the output to that spec. + +The last class keeps git-cliff's copy of the types and scopes in step with +commit-lint's. git-cliff cannot read Python, so the table exists twice, +and two copies of one list drift unless something checks them. +""" + +import importlib.machinery +import importlib.util +import os +import re +import shutil +import subprocess +import tomllib +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent +SCRIPT = REPO_ROOT / "script" / "release-notes" +CLIFF = tomllib.loads((REPO_ROOT / "cliff.toml").read_text()) + +_spec = importlib.util.spec_from_loader( + "commit_lint", + importlib.machinery.SourceFileLoader( + "commit_lint", str(REPO_ROOT / "tools" / "commit-lint")), +) +commit_lint = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(commit_lint) + + +# git-cliff runs through uvx, which downloads it on first use. The unit +# lane runs offline, so the tests that render notes run only where uv has +# it cached, which one `script/release-notes` call takes care of. +GIT_CLIFF = re.search(r"git-cliff@[\d.]+", SCRIPT.read_text())[0] +OFFLINE = {**os.environ, "UV_OFFLINE": "1"} + + +def _git_cliff_cached() -> bool: + if shutil.which("uvx") is None: + return False + result = subprocess.run(["uvx", "--quiet", GIT_CLIFF, "--version"], + capture_output=True, env=OFFLINE) + return result.returncode == 0 + + +needs_git_cliff = pytest.mark.skipif( + not _git_cliff_cached(), reason=f"{GIT_CLIFF} is not in the uv cache") + + +# ── A repository with a known history ──────────────────────────────────── + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", "-C", str(repo), "-c", "user.email=t@example.com", + "-c", "user.name=T", *args], check=True, capture_output=True) + + +def _commit(repo: Path, message: str) -> None: + (repo / "change").write_text(message) + _git(repo, "add", "-A") + _git(repo, "commit", "-qm", message) + + +@pytest.fixture +def repo(tmp_path): + """A checkout carrying the real script and config, tagged v0.1.0.""" + repo = tmp_path / "repo" + (repo / "script").mkdir(parents=True) + shutil.copy(SCRIPT, repo / "script") + shutil.copy(REPO_ROOT / "cliff.toml", repo) + _git(tmp_path, "init", "-q", str(repo)) + _commit(repo, "chore: start") + _git(repo, "tag", "v0.1.0") + return repo + + +def _notes(repo: Path, *args: str) -> subprocess.CompletedProcess: + """Run the command from the directory above the checkout, the way the + workspace release skill calls it.""" + return subprocess.run([str(repo / "script" / "release-notes"), *args], + capture_output=True, text=True, cwd=repo.parent, + env=OFFLINE) + + +def _sections(notes: str) -> list[str]: + return re.findall(r"^### (.+)$", notes, re.M) + + +def _section(notes: str, heading: str) -> str: + return notes.split(f"### {heading}\n", 1)[1].split("\n### ", 1)[0].strip() + + +# ── The layout ─────────────────────────────────────────────────────────── + +@needs_git_cliff +class TestTheLayout: + + def test_sections_come_in_the_documented_order(self, repo): + """What needs the admin first, then what matters most to them.""" + for message in ["docs: explain the release gate", + "perf(ai): answer faster", + "fix(core): answer the first message", + "feat(photos): back up over Wi-Fi", + "security(messages): close the open room", + "feat(docs)!: move Paperless to 3.0.4"]: + _commit(repo, message) + + result = _notes(repo) + + assert result.returncode == 0, result.stderr + assert _sections(result.stdout) == [ + "Action required", "Security", "Added", "Fixed", "Performance", + "Documentation"] + + def test_an_entry_names_what_changed_the_way_a_reader_knows_it(self, repo): + """Scopes render as labels, entries with one label sit together, + and entries with no scope come last in their section.""" + for message in ["fix(wiki): show the new page", + "fix: stop the clock drifting", + "fix(core): answer the first message", + "fix(diary): keep the entry date"]: + _commit(repo, message) + + fixed = _section(_notes(repo).stdout, "Fixed") + + assert fixed.split("\n")[:4] == [ + "- **Core Stacklet:** Answer the first message", + "- **Memory Stacklet:** Show the new page", + "- **Memory Stacklet:** Keep the entry date", + "- Stop the clock drifting", + ] + + def test_internal_work_is_committed_but_not_announced(self, repo): + for message in ["feat(photos): back up over Wi-Fi", + "refactor(core): split the runner", + "test(stack): cover the empty range", + "chore: tidy imports", "ci: cache uv", + "style: wrap long lines", "build: pin git-cliff"]: + _commit(repo, message) + + notes = _notes(repo).stdout + + assert _sections(notes) == ["Added"] + for internal in ["runner", "empty range", "imports", "cache uv", + "long lines", "pin git-cliff"]: + assert internal not in notes + + def test_a_merge_is_not_an_entry(self, repo): + """The merged commits are listed already. commit-lint's range + check leaves merges out for the same reason.""" + _git(repo, "checkout", "-qb", "topic") + _commit(repo, "feat(photos): back up over Wi-Fi") + _git(repo, "checkout", "-q", "-") + _git(repo, "merge", "-q", "--no-ff", "-m", "Merge pull request #1 from x/topic", + "topic") + + notes = _notes(repo).stdout + + assert "Merge pull request" not in notes + assert "Back up over Wi-Fi" in notes + + def test_action_required_quotes_what_the_admin_must_do(self, repo): + """Any `!`, `Upgrade:` or `BREAKING CHANGE:` lands here, whatever + the type, with the footer quoted as markdown and kept inside its + entry. The footer is the instruction; the subject alone is not.""" + _commit(repo, "feat(docs)!: move Paperless to 3.0.4\n\nWhy.\n\n" + "Upgrade: back up `~/famstack-data/docs` before restarting\n" + "with `./stack restart docs`. There is no downgrade.") + _commit(repo, "chore(stack): drop the old label\n\n" + "Upgrade: run `./stack restart all` once.") + _commit(repo, "fix(ai)!: drop the old engine\n\n" + "BREAKING CHANGE: remove `[ai] engine` from `stack.toml`.") + + notes = _notes(repo).stdout + action = _section(notes, "Action required") + + assert _sections(notes) == ["Action required"] + assert ("- **Docs Stacklet:** Move Paperless to 3.0.4\n\n" + " **Upgrade:** Back up `~/famstack-data/docs` before restarting\n" + " with `./stack restart docs`. There is no downgrade.") in action + assert (" **Upgrade:** Run `./stack restart all` once.") in action + assert (" **BREAKING CHANGE:** Remove `[ai] engine` from `stack.toml`." + ) in action + + def test_a_subject_that_does_not_parse_is_shown_not_dropped(self, repo): + """An unclassified subject blocks the tag (dev.md, Releases, step + 3b), so the notes put it where the maintainer will see it.""" + _commit(repo, "Move the docs stacklet to Paperless-ngx 3.0.4") + + notes = _notes(repo).stdout + + assert _section(notes, "Unclassified") == ( + "- Move the docs stacklet to Paperless-ngx 3.0.4") + + +# ── Which release ──────────────────────────────────────────────────────── + +@needs_git_cliff +class TestWhichRelease: + + def test_by_default_it_is_what_the_next_tag_will_ship(self, repo): + _commit(repo, "feat(photos): back up over Wi-Fi") + _git(repo, "tag", "v0.2.0") + _commit(repo, "fix(core): answer the first message") + + notes = _notes(repo).stdout + + assert "Answer the first message" in notes + assert "Back up over Wi-Fi" not in notes + + def test_latest_is_what_the_newest_tag_shipped(self, repo): + _commit(repo, "feat(photos): back up over Wi-Fi") + _git(repo, "tag", "v0.2.0") + _commit(repo, "fix(core): answer the first message") + + notes = _notes(repo, "--latest").stdout + + assert "Back up over Wi-Fi" in notes + assert "Answer the first message" not in notes + + def test_nothing_to_announce_says_so_instead_of_an_empty_release(self, repo): + assert _notes(repo).stdout.strip() == "No user-facing changes since v0.1.0." + + _commit(repo, "chore: tidy imports") + + assert _notes(repo).stdout.strip() == "No user-facing changes since v0.1.0." + + +class TestAShallowCheckout: + + def test_is_refused_rather_than_read_as_a_quiet_release(self, repo, tmp_path): + """git-cliff finds no commits in a shallow clone and says nothing + about it, which reads exactly like a release with no changes.""" + _commit(repo, "feat(photos): back up over Wi-Fi") + shallow = tmp_path / "shallow" + subprocess.run(["git", "clone", "-q", "--depth", "1", f"file://{repo}", + str(shallow)], check=True) + + result = _notes(shallow) + + assert result.returncode == 1 + assert "fetch --unshallow" in result.stderr + + +# ── One table, two readers ─────────────────────────────────────────────── + +class TestTheTablesAgreeWithCommitLint: + """cliff.toml repeats TYPES and SCOPES from tools/commit-lint. These + apply git-cliff's own rules to each name and compare the result.""" + + @staticmethod + def _section_for(kind: str) -> str: + """The section a commit of this type lands in, "" if it is skipped.""" + for parser in CLIFF["git"]["commit_parsers"]: + if "message" in parser and re.search(parser["message"], f"{kind}: x"): + return "" if parser.get("skip") else re.sub(r"", "", parser["group"]) + raise AssertionError(f"no parser matches {kind!r}") + + @staticmethod + def _label_for(scope: str) -> str | None: + """The label git-cliff's preprocessors give this scope, None for General.""" + message = f"feat({scope}): x" + for rule in CLIFF["git"]["commit_preprocessors"]: + message = re.sub(rule["pattern"], rule["replace"].replace("${1}", r"\g<1>"), + message) + match = re.fullmatch(r"feat\((.+)\): x", message) + return match and match[1] + + def test_every_type_lands_in_the_section_commit_lint_names(self): + named_in_cliff = {m[1] for p in CLIFF["git"]["commit_parsers"] + if (m := re.fullmatch(r"\^([a-z]+)", p.get("message", "")))} + cliff = {kind: self._section_for(kind) + for kind in named_in_cliff | set(commit_lint.TYPES)} + + assert cliff == commit_lint.TYPES, ( + f"only in commit-lint: {sorted(set(commit_lint.TYPES) - named_in_cliff)}, " + f"only in cliff.toml: {sorted(named_in_cliff - set(commit_lint.TYPES))}") + + def test_every_scope_renders_the_label_commit_lint_names(self): + named_in_cliff = {m[1] for p in CLIFF["git"]["commit_preprocessors"] + if (m := re.search(r"\\\(([a-z0-9-]+)\\\)", p["pattern"]))} + cliff = {scope: self._label_for(scope) + for scope in named_in_cliff | set(commit_lint.SCOPES)} + + assert cliff == commit_lint.SCOPES, ( + f"only in commit-lint: {sorted(set(commit_lint.SCOPES) - named_in_cliff)}, " + f"only in cliff.toml: {sorted(named_in_cliff - set(commit_lint.SCOPES))}") From 7cfcafd660b9cca6c5f9f83332debc88cb51fcd0 Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:47:55 +0200 Subject: [PATCH 4/4] chore: add a skill that writes pull requests by the repo's rules Every pull request needs a title that is its changelog line and a description by the rules in `docs/agent/dev.md`, and agents had to be told the convention each time. The `pr` skill in `.claude/skills/pr/` reads the branch, says when it holds two changelog entries and should be split, writes the title and description, and checks both and the branch's commits with `tools/commit-lint` before showing them. It asks for Arthur's go before pushing or opening anything, and never merges. `.gitignore` re-includes the skill, as it does the LSP plugin, so every clone gets it. --- .claude/skills/pr/SKILL.md | 101 +++++++++++++++++++++++++++++++++++++ .gitignore | 6 ++- 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/pr/SKILL.md diff --git a/.claude/skills/pr/SKILL.md b/.claude/skills/pr/SKILL.md new file mode 100644 index 00000000..78491fc0 --- /dev/null +++ b/.claude/skills/pr/SKILL.md @@ -0,0 +1,101 @@ +--- +name: pr +description: Write the pull request for the current famstack branch, a title that is the changelog line and a description by the repo's Pull requests rules, both checked with tools/commit-lint. Use when asked to write, draft or open a PR, or for its title or squash message. +--- + +# Write the pull request + +A PR is squash merged, so its title becomes the commit subject on main, and +that subject is the line in the release notes. The rules live in +`docs/agent/dev.md`, sections "Commits: the subject is the changelog" and +"Pull requests". Read both before writing: this file is the procedure, dev.md +is the rule, and where they differ dev.md wins. + +## 1. Read the branch + +```bash +git fetch origin +git log --reverse --format='%h %s%n%b' origin/main..HEAD +git diff --stat origin/main...HEAD +``` + +Read the diff wherever the subjects don't say what an admin would notice. If +`HEAD..origin/main` is not empty, say the branch is behind; don't rebase unasked. + +## 2. One changelog entry, or two + +List what a reader of the release notes would see: commits of a shown type +(`feat`, `fix`, `security`, `perf`, `docs`) and any commit with `!`, `Upgrade:` +or `BREAKING CHANGE:`. Tests, refactors and chores that serve one of those +belong to it and add no entry of their own. + +If that leaves two changes a reader would see separately, stop there. Don't +write a title with "and": say the PR should be split and which commits go in +which PR. One PR is one changelog entry. + +## 3. The title + +- `type(scope)!: subject`, with the type and scope from the tables in dev.md. + The scope is what a reader recognises (a stacklet or a bot), never a file. +- The change as an admin sees it: imperative, lowercase after the colon, no + full stop, no class or function names. At most 72 characters as commit-lint + counts them; dev.md asks under 70 for a PR. The ` (#N)` GitHub appends is + not counted. +- `!` only when the admin has to act, and then the description carries the + `Upgrade:` or `BREAKING CHANGE:` footer. +- For a one-commit branch whose subject already passes, use that subject: + GitHub takes the commit's subject as the squash title of a one-commit PR. + +## 4. The description + +``` +## Summary +- + +Upgrade: +Refs: FAM-12 +``` + +- One to three bullets. The footers only when they apply. +- No "Test plan" section. Test output and measurements go in a PR comment. +- No em dashes: a comma, colon, period or parentheses. +- No Co-Authored-By trailer, no "Generated with" line, no tool link. The repo + is public and commit-lint rejects all three. + +## 5. Check before showing it + +```bash +python3 tools/commit-lint --title "" +python3 tools/commit-lint --body "<description>" +python3 tools/commit-lint --range origin/main..HEAD +``` + +The last one is what CI runs over every commit in the PR. Fix and re-run until +all three pass. Rewording a commit that is already pushed needs a force push, +which needs Arthur's go. + +Then show the title, the description in a code block, and the three results. + +## Opening it + +Pushing and opening the PR are Arthur's call. Ask for his go every time, even +if he gave it for the last one. With it: + +```bash +git push -u origin HEAD +gh pr create --base main --title "<title>" --body-file <file> +``` + +`--draft` if he asks for a draft. Never merge. + +## At merge time + +The repo's squash setting fills the commit body from the branch's commit +messages, not from the PR description. A footer in the description reaches +main only if the merge uses the description as the body, in the merge dialog +or with `gh pr merge --squash --body-file`. Merging is Arthur's: say this when +handing the PR over, and don't run it. + +A PR Arthur marks for a rebase merge is the exception: every commit lands as +its own changelog line, so each one has to pass commit-lint on its own, and +the title is not a changelog line. diff --git a/.gitignore b/.gitignore index 0bf560ad..37f39571 100644 --- a/.gitignore +++ b/.gitignore @@ -48,13 +48,15 @@ tests/e2e/eval/runs/ # AI assistant. Local settings and transcripts stay out, but the LSP plugin # is repo configuration: it makes agents read this codebase with a language -# server instead of grep, so every clone should get it. +# server instead of grep, so every clone should get it. The same goes for +# the pr skill, which applies this repo's pull request rules. # Git cannot re-include a file whose parent directory is excluded, so these -# ignore directory *contents* and re-open the one path down to the plugin. +# ignore directory *contents* and re-open each path that is tracked. .claude/* !.claude/skills/ .claude/skills/* !.claude/skills/famstack-lsp/ +!.claude/skills/pr/ # Extension stacklets live in ~/<product>-extensions, not here. This is # the obvious place to try anyway, so keep anything dropped here out of