diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6b77604f..706f8ae2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -74,3 +74,17 @@ jobs:
- name: the Stage-B schedules loader's tests
run: uv run --frozen pytest
working-directory: tools/servicetag-schedules
+ hygiene:
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
+ with:
+ ref: ${{ github.event.pull_request.head.sha }} # the pull request's own tip, not the merge ref
+ fetch-depth: 0 # the whole range, and origin/
+ persist-credentials: false # this job needs no git credentials after the checkout
+ - name: the pull request's own commits follow CLAUDE.md
+ env:
+ BASE: origin/${{ github.base_ref }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: bash tools/check-commit-hygiene.sh "$BASE" "$HEAD_SHA"
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..9b8a940c
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1 @@
+See CLAUDE.md.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..dee37bd6
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,50 @@
+# Rules for every session
+
+Every clone, session and agent working here follows these. They override any tool's default
+commit or pull request attribution. Product design lives in `docs/`, not here.
+
+## Commits
+
+- Before the first commit in a clone or a session, set the owner's identity:
+ `git config user.name GonzRon` and
+ `git config user.email "$(git log -1 --format=%ae servicetag-v1.7.0)"` — the address of the
+ owner's own release commit (`git fetch origin tag servicetag-v1.7.0` first if the clone lacks it).
+ Never write the address itself into a file.
+- It must be the author **and** the committer. `GIT_AUTHOR_*` and `GIT_COMMITTER_*` in the
+ environment beat the config: unset them. Check with `git var GIT_COMMITTER_IDENT`.
+- A commit message is its subject line alone: no body, no `Co-Authored-By`, no `Claude-Session`,
+ no `Generated-by` or "Generated with", no session links.
+- No tool or model name anywhere in a commit message, a branch name, a pull request title or a pull
+ request body. Every commit reads as the owner's alone.
+- Subjects are casual, terse and lowercase-leaning, as in
+ `git log servicetag-v1.6.0..servicetag-v1.7.0`: `supply row can hide its chevron`,
+ `#16 plan: rev 1`, `delete asset sweeps its installed components' file bytes too`.
+
+## Pull requests
+
+- Name the branch for the work, `fix-…` or `issue-123-…`. Never start it with a tool's name.
+- Merge with a merge commit whose subject carries no tool-named branch. Never squash or rebase
+ away the owner's history.
+- CI's `hygiene` job runs `tools/check-commit-hygiene.sh` over the pull request's own commits. Run
+ it before pushing: `bash tools/check-commit-hygiene.sh origin/master HEAD`.
+
+## The gates CI cannot run
+
+- The connected suite runs only on `emulator-5554`, by the controller, before a release cut
+ (`docs/release-proofs.md` R2). A pull request that touches `app/src/androidTest` or a screen
+ says so in its description.
+- CI compiles the instrumented sources and runs lint. Both stay green.
+
+## Never in the repository
+
+E-mail addresses, home paths, device serials other than `emulator-5554`, real endpoints, host
+names or tokens. Use fictional ones only: `192.168.0.10`, `ha.example`, `example-…`, and names
+under the reserved `.invalid` domain such as `manuals.example.invalid`.
+
+## Where the rest lives
+
+- `docs/versioning.md`: classify the change before choosing the number.
+- `docs/release-proofs.md`: the release runbook.
+- `docs/superpowers/planning-policy.md`: plans specify, implementers author.
+- `docs/localization.md`: every owner-facing string is a resource; `UiLiteralGuardTest` holds it.
+- Issue #104: the roadmap of record.
diff --git a/docs/release-proofs.md b/docs/release-proofs.md
index 06b4d35e..ccb037bb 100644
--- a/docs/release-proofs.md
+++ b/docs/release-proofs.md
@@ -79,7 +79,9 @@ prints the three activities sorted and `other 0 []`; a fourth exported component
## R6's greps
-Each prints nothing, or the count stated.
+Each prints nothing, or the count stated. The author, attribution and empty-body lines, and the
+three content greps, are now also enforced on every pull request, over its own commits, by
+`tools/check-commit-hygiene.sh` (CI's `hygiene` job); the controller still runs them all here.
- no e-mail in added lines: `git diff ..HEAD | grep -nE '^\+.*[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'` (fixture URLs carry none)
- no home path: `git diff ..HEAD | grep -nE '^\+.*/hom[e]/[a-z]'`
diff --git a/tools/check-commit-hygiene.sh b/tools/check-commit-hygiene.sh
new file mode 100755
index 00000000..d5c9e5ac
--- /dev/null
+++ b/tools/check-commit-hygiene.sh
@@ -0,0 +1,99 @@
+#!/usr/bin/env bash
+# The commit rules in CLAUDE.md and R6's content greps (docs/release-proofs.md), over the commits in
+# ..
. CI's hygiene job runs it on every pull request over the pull request's own commits.
+# bash tools/check-commit-hygiene.sh
+# Every commit: author and committer GonzRon (committer GitHub only on a merge, the merge button);
+# no attribution, session or tool line in the message; a commit that is not a merge is its subject
+# line alone; no merge subject names a claude branch. Every added line: no e-mail address but a
+# reserved fictional one, no home path, no device serial but emulator-5554.
+# Prints one line per violation and exits 1, or prints "commit hygiene ok: N commits".
+set -euo pipefail
+
+if [ "$#" -ne 2 ]; then
+ echo "usage: tools/check-commit-hygiene.sh " >&2
+ exit 2
+fi
+base=$1
+head=$2
+owner=GonzRon
+attribution='co-authored-by|claude-session|generated[- ](by|with)|anthropic|claude\.ai|noreply@'
+# "from GonzRon/claude/x" (the merge button) and "branch 'claude-x'" (a local merge) alike
+claude_branch="(from [^ ]*/|'([^ ']*/)?)claude[/-]"
+
+cd "$(git rev-parse --show-toplevel)"
+for rev in "$base" "$head"; do
+ git rev-parse --verify --quiet "$rev^{commit}" >/dev/null \
+ || { echo "commit hygiene: no such commit: $rev" >&2; exit 2; }
+done
+
+violations=0
+flag() {
+ echo "commit hygiene: $1"
+ violations=$((violations + 1))
+}
+
+commits=0
+range=$(git rev-list --reverse --parents "$base..$head")
+while read -r sha _first_parent second_parent _; do
+ [ -n "$sha" ] || continue
+ commits=$((commits + 1))
+ is_merge=false
+ [ -z "$second_parent" ] || is_merge=true
+ { IFS= read -r short; IFS= read -r author; IFS= read -r committer; IFS= read -r subject; } \
+ <<< "$(git show -s --format='%h%n%an%n%cn%n%s' "$sha")"
+ message=$(git show -s --format=%B "$sha")
+
+ [ "$author" = "$owner" ] || flag "$short author is '$author', not $owner: $subject"
+ if [ "$committer" != "$owner" ] && ! { $is_merge && [ "$committer" = GitHub ]; }; then
+ flag "$short committer is '$committer', not $owner: $subject"
+ fi
+ hit=$(printf '%s\n' "$message" | grep -iE "$attribution" | sed -n 1p || true)
+ [ -z "$hit" ] || flag "$short attribution in the message: $hit"
+ if ! $is_merge; then
+ lines=$(printf '%s\n' "$message" | grep -c . || true)
+ [ "$lines" -eq 1 ] || flag "$short has a body, $lines lines (subject line only): $subject"
+ elif printf '%s\n' "$subject" | grep -qiE "$claude_branch"; then
+ flag "$short merge names a claude branch: $subject"
+ fi
+done <<< "$range"
+
+# R6's content greps over the lines the range adds. ... diffs from the merge base, so a
+# base that moved on after the branch forked adds nothing here. Three paths are left out because
+# they quote these very patterns: this script, CLAUDE.md and docs/release-proofs.md.
+found=$(git diff --no-color --no-ext-diff "$base...$head" -- . \
+ ':(exclude)tools/check-commit-hygiene.sh' ':(exclude)CLAUDE.md' ':(exclude)docs/release-proofs.md' |
+ awk '
+ function report(what, text) {
+ sub(/^[ \t]+/, "", text)
+ printf "commit hygiene: %s:%d %s: %s\n", file, n, what, substr(text, 1, 120)
+ }
+ function check(text, rest, addr, domain, bad) {
+ rest = text
+ while (match(rest, /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+[.][A-Za-z][A-Za-z]+/)) {
+ addr = substr(rest, RSTART, RLENGTH)
+ domain = tolower(substr(addr, index(addr, "@") + 1))
+ # the reserved fictional names (RFC 2606): *.invalid, *.example, example.com/org/net
+ if (domain !~ /(^|[.])(invalid|example)$/ && domain !~ /(^|[.])example[.](com|org|net)$/) bad = addr
+ rest = substr(rest, RSTART + RLENGTH)
+ }
+ if (bad != "") report("e-mail address " bad, text)
+ if (text ~ /\/home\/[a-z]/) report("home path", text)
+ if (text ~ /(adb -s |ANDROID_SERIAL=)/ && text !~ /emulator-5554/) report("device serial", text)
+ }
+ /^diff --git / { hunk = 0; next }
+ !hunk && /^[+][+][+] / { file = substr($0, 7); next }
+ /^@@ / { hunk = 1; match($0, /[+][0-9]+/); n = substr($0, RSTART + 1, RLENGTH - 1) + 0; next }
+ !hunk { next }
+ /^[+]/ { check(substr($0, 2)); n++; next }
+ /^ / { n++ }
+ ')
+if [ -n "$found" ]; then
+ printf '%s\n' "$found"
+ violations=$((violations + $(printf '%s\n' "$found" | grep -c .)))
+fi
+
+if [ "$violations" -gt 0 ]; then
+ echo "commit hygiene FAILED: $violations violations in $commits commits ($base..$head)"
+ exit 1
+fi
+echo "commit hygiene ok: $commits commits"