You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/09/25 18:05:12 UTC

(magpie) branch main updated: feat(setup): make the commit-attribution trailer a project choice (#1385)

This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/magpie.git


The following commit(s) were added to refs/heads/main by this push:
     new fceabe0d feat(setup): make the commit-attribution trailer a project choice (#1385)
fceabe0d is described below

commit fceabe0d84e22f45582b9f6bd98238070d9a5517
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Fri Sep 25 20:05:07 2026 +0200

    feat(setup): make the commit-attribution trailer a project choice (#1385)
    
    Projects record agent assistance differently: the ASF uses Generated-by,
    others Assisted-by, GitHub Co-authored-by, some nothing. Magpie hard-coded
    Generated-by and the commit-trailer guard blocked Co-Authored-By
    everywhere, so an adopter using another convention had to fight the
    framework.
    
    The convention now lives in commit-attribution.toml: committed in
    .apache-magpie-overrides/ (project policy, which wins) and gitignored
    in .apache-magpie-local/ (a contributor's choice, used only when the
    project leaves it open). setup adopt and setup config ask for it, the
    guard allows Co-Authored-By only where the resolved convention is
    co-authored-by and fails closed on anything unreadable, and every skill
    that commits adds the trailer with git commit --trailer so it stays
    visible on the command line. The framework repository pins generated-by
    for itself.
    
    Generated-by: Claude Code (Claude Opus 5.5)
---
 .apache-magpie-overrides/commit-attribution.toml   |  20 +++++
 AGENTS.md                                          |  21 +++--
 docs/mode-economics.md                             |  18 ++--
 docs/setup/agentic-overrides.md                    |   6 ++
 docs/setup/commit-attribution.md                   | 100 +++++++++++++++++++++
 docs/setup/secure-agent-setup.md                   |   4 +-
 plugins/magpie-issue/skills/fix-workflow/SKILL.md  |  16 ++--
 .../skills/pre-first-pr-check/SKILL.md             |  14 ++-
 .../skills/audit-finding-fix/SKILL.md              |   3 +-
 plugins/magpie-security/skills/issue-fix/SKILL.md  |   4 +-
 .../skills/isolated-setup-update/SKILL.md          |   3 +-
 .../magpie-setup/skills/override-upstream/SKILL.md |   4 +-
 plugins/magpie-setup/skills/setup/adopt.md         |  21 +++++
 plugins/magpie-setup/skills/setup/config.md        |  12 ++-
 .../skills/shared-config-sync/SKILL.md             |   8 +-
 plugins/magpie-setup/skills/upstream-fix/SKILL.md  |   4 +-
 projects/_template/README.md                       |   1 +
 projects/_template/commit-attribution.toml         |  33 +++++++
 projects/_template/fix-workflow.md                 |  20 ++---
 tools/agent-guard/README.md                        |   2 +-
 tools/agent-guard/src/agent_guard/__init__.py      |  99 +++++++++++++++++++-
 tools/agent-guard/tests/test_guards.py             |  99 +++++++++++++++++++-
 22 files changed, 454 insertions(+), 58 deletions(-)

diff --git a/.apache-magpie-overrides/commit-attribution.toml b/.apache-magpie-overrides/commit-attribution.toml
new file mode 100644
index 00000000..b953bdd0
--- /dev/null
+++ b/.apache-magpie-overrides/commit-attribution.toml
@@ -0,0 +1,20 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# The framework repository's own commit-attribution policy: the ASF
+# convention, for every contributor. See docs/setup/commit-attribution.md.
+convention = "generated-by"
diff --git a/AGENTS.md b/AGENTS.md
index 80a0af79..727dd1c3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -522,11 +522,19 @@ to a home-dir path and update the tool to read from there.
   Generated-by: Claude Code (Opus 4.7)
   ```
 
-  For commits in adopting projects, the exact trailer wording may carry
-  additional project-specific elements (e.g. a URL to the project's Gen-AI
-  disclosure anchor) — see
-  [`<project-config>/fix-workflow.md`](<project-config>/fix-workflow.md#commit-trailer)
-  for that project's spec.
+  That is this repository's convention, pinned in
+  [`.apache-magpie-overrides/commit-attribution.toml`](.apache-magpie-overrides/commit-attribution.toml).
+- **In an adopting project, use the project's commit-attribution convention.**
+  `Generated-by:`, `Assisted-by:`, `Co-authored-by:`, no trailer, or a custom
+  one — the project chooses when it adopts Magpie, and a contributor chooses
+  only where the project leaves it open. Resolve it per
+  [`docs/setup/commit-attribution.md`](docs/setup/commit-attribution.md);
+  the `commit-trailer` guard resolves it the same way.
+- **Add the trailer with `git commit --trailer`**, never by typing it into the
+  message body or the `-F <file>`:
+  `git commit -F <file> --trailer "Generated-by: <agent name and version>"`.
+  git then places it in the trailer block, and it stays on the command line
+  where the guard and other hooks can see it.
 - **Always open PRs with `gh pr create --web`** so the human reviewer can check the title,
   body, and the generative-AI disclosure in the browser before submission. Pre-fill `--title`
   and `--body-file` (including the Gen-AI disclosure block) so they only need to review, not edit.
@@ -535,7 +543,8 @@ to a home-dir path and update the tool to read from there.
   Write the text to a file in the session scratch directory or `$TMPDIR` first,
   then hand the CLI the path: `gh pr create --body-file`, `gh pr comment --body-file`,
   `gh issue comment --body-file`, `gh pr review --body-file`,
-  `gh api … --input <file>` for JSON payloads, `git commit -F <file>`.
+  `gh api … --input <file>` for JSON payloads, `git commit -F <file>`
+  (with the attribution trailer as a separate `--trailer`, see above).
   Do not paste the body as a quoted argument, splice it in with `$(cat …)`,
   or feed it through a pipe or heredoc on the same command line.
   Three reasons:
diff --git a/docs/mode-economics.md b/docs/mode-economics.md
index 28e50e04..0d98a180 100644
--- a/docs/mode-economics.md
+++ b/docs/mode-economics.md
@@ -122,11 +122,11 @@ special-token spellings counted as ordinary text.
 Coverage: **75 of 75 local `skills/*/SKILL.md` files**.
 External `source.md` redirects and harness symlinks are excluded.
 
-Measurement manifest SHA-256: `ca1f7f556a7ed64112271356460c6f6376a6945cf00a1baeec4b4e47bd9dde30`.
+Measurement manifest SHA-256: `f1041a30a295af2528921db50a3bd71ef24937a3d4d144b6490b98af3590ed7b`.
 
 | Skill file | Measured tokens | Source SHA-256 (first 16 characters) |
 |---|---:|---|
-| [audit-finding-fix](../skills/audit-finding-fix/SKILL.md) | 6,103 | `7143ec2349b64539` |
+| [audit-finding-fix](../skills/audit-finding-fix/SKILL.md) | 6,148 | `8260b9620b8ae8b1` |
 | [ci-runner-audit](../skills/ci-runner-audit/SKILL.md) | 2,201 | `5f8e30197953dbf0` |
 | [committer-onboarding](../skills/committer-onboarding/SKILL.md) | 7,308 | `cd08c6bc681072fd` |
 | [contributor-activity-sweep](../skills/contributor-activity-sweep/SKILL.md) | 3,318 | `ddc43afc81c3130a` |
@@ -140,7 +140,7 @@ Measurement manifest SHA-256: `ca1f7f556a7ed64112271356460c6f6376a6945cf00a1baee
 | [good-first-issue-sweep](../skills/good-first-issue-sweep/SKILL.md) | 4,122 | `3693033470e53159` |
 | [issue-backlog-stats](../skills/issue-backlog-stats/SKILL.md) | 6,133 | `5882dfc676abef2d` |
 | [issue-deduplicate](../skills/issue-deduplicate/SKILL.md) | 4,538 | `2c4f5111dc2703fe` |
-| [issue-fix-workflow](../skills/issue-fix-workflow/SKILL.md) | 7,169 | `4ae5dd85ba342b49` |
+| [issue-fix-workflow](../skills/issue-fix-workflow/SKILL.md) | 7,153 | `56c8b0de8eb6d1fe` |
 | [issue-reassess](../skills/issue-reassess/SKILL.md) | 5,664 | `e62f01ec93cc8cff` |
 | [issue-reassess-stats](../skills/issue-reassess-stats/SKILL.md) | 2,993 | `4d98956ce99c29fa` |
 | [issue-reproducer](../skills/issue-reproducer/SKILL.md) | 6,545 | `59687205d3591a5c` |
@@ -160,7 +160,7 @@ Measurement manifest SHA-256: `ca1f7f556a7ed64112271356460c6f6376a6945cf00a1baee
 | [pr-management-stats](../skills/pr-management-stats/SKILL.md) | 7,211 | `6ebb2a6981ab526b` |
 | [pr-management-triage](../skills/pr-management-triage/SKILL.md) | 11,604 | `5c4829112ce7ac35` |
 | [pr-stale-sweep](../skills/pr-stale-sweep/SKILL.md) | 6,726 | `64755e0df9770efe` |
-| [pre-first-pr-check](../skills/pre-first-pr-check/SKILL.md) | 3,448 | `f5e7391d25780f3c` |
+| [pre-first-pr-check](../skills/pre-first-pr-check/SKILL.md) | 3,506 | `81f39d10b1da792a` |
 | [release-announce-draft](../skills/release-announce-draft/SKILL.md) | 6,967 | `a34cb2060ec0bc38` |
 | [release-archive-sweep](../skills/release-archive-sweep/SKILL.md) | 4,522 | `eb3d461d811ac046` |
 | [release-audit-report](../skills/release-audit-report/SKILL.md) | 6,688 | `8c518f39ab65df81` |
@@ -175,7 +175,7 @@ Measurement manifest SHA-256: `ca1f7f556a7ed64112271356460c6f6376a6945cf00a1baee
 | [reviewer-routing](../skills/reviewer-routing/SKILL.md) | 5,192 | `30260e588cefbccc` |
 | [security-cve-allocate](../skills/security-cve-allocate/SKILL.md) | 11,195 | `bc831cc943952df6` |
 | [security-issue-deduplicate](../skills/security-issue-deduplicate/SKILL.md) | 8,048 | `f318481078358fea` |
-| [security-issue-fix](../skills/security-issue-fix/SKILL.md) | 12,952 | `9d157b0252c0999e` |
+| [security-issue-fix](../skills/security-issue-fix/SKILL.md) | 12,976 | `5fafbf34ea3f20b6` |
 | [security-issue-import](../skills/security-issue-import/SKILL.md) | 28,928 | `ae09027f00cf4b89` |
 | [security-issue-import-from-md](../skills/security-issue-import-from-md/SKILL.md) | 9,169 | `e1af8e324d5663da` |
 | [security-issue-import-from-pr](../skills/security-issue-import-from-pr/SKILL.md) | 11,098 | `bafec61e623253bb` |
@@ -191,13 +191,13 @@ Measurement manifest SHA-256: `ca1f7f556a7ed64112271356460c6f6376a6945cf00a1baee
 | [setup](../skills/setup/SKILL.md) | 4,229 | `9d1f0c2cbeda3422` |
 | [setup-isolated-setup-doctor](../skills/setup-isolated-setup-doctor/SKILL.md) | 5,539 | `ba5d0773615f463d` |
 | [setup-isolated-setup-install](../skills/setup-isolated-setup-install/SKILL.md) | 4,414 | `c9bb83973f01078d` |
-| [setup-isolated-setup-update](../skills/setup-isolated-setup-update/SKILL.md) | 4,013 | `8f851263948c42a3` |
+| [setup-isolated-setup-update](../skills/setup-isolated-setup-update/SKILL.md) | 4,062 | `7906fe6011ec1442` |
 | [setup-isolated-setup-verify](../skills/setup-isolated-setup-verify/SKILL.md) | 4,796 | `f3e0c4771367dfe8` |
-| [setup-override-upstream](../skills/setup-override-upstream/SKILL.md) | 4,715 | `d04f8253084c8f00` |
+| [setup-override-upstream](../skills/setup-override-upstream/SKILL.md) | 4,750 | `500115be65241da1` |
 | [setup-privacy-llm](../skills/setup-privacy-llm/SKILL.md) | 2,051 | `daf38d5849397854` |
-| [setup-shared-config-sync](../skills/setup-shared-config-sync/SKILL.md) | 3,833 | `8a53b61ee3d3cb56` |
+| [setup-shared-config-sync](../skills/setup-shared-config-sync/SKILL.md) | 3,908 | `583eecc5d29db215` |
 | [setup-status](../skills/setup-status/SKILL.md) | 2,318 | `c21048d7cb777e69` |
-| [setup-upstream-fix](../skills/setup-upstream-fix/SKILL.md) | 5,279 | `268950702ab994f9` |
+| [setup-upstream-fix](../skills/setup-upstream-fix/SKILL.md) | 5,335 | `b37e1930cb6c219b` |
 | [skill-reconciler](../skills/skill-reconciler/SKILL.md) | 4,435 | `f2bb09ed61476c71` |
 | [workflow-security-audit](../skills/workflow-security-audit/SKILL.md) | 3,174 | `22ff4aaeba56ae41` |
 | [write-skill](../skills/write-skill/SKILL.md) | 2,456 | `d2fff0c2af1b87ea` |
diff --git a/docs/setup/agentic-overrides.md b/docs/setup/agentic-overrides.md
index b195d489..4692ea4a 100644
--- a/docs/setup/agentic-overrides.md
+++ b/docs/setup/agentic-overrides.md
@@ -92,6 +92,12 @@ are applied first and the committed file's are also applied unless the
 local one says to skip it. Neither directory is required to exist; a
 skill that finds neither proceeds with framework defaults.
 
+One file is the exception: `commit-attribution.toml`. Which trailer an
+agent-assisted commit carries is project policy, so the committed copy
+wins whenever it sets a convention, and the local copy applies only
+where the project leaves the choice open — see
+[`commit-attribution.md`](commit-attribution.md#how-the-convention-is-resolved).
+
 The consequence worth knowing: **once the project commits a file you
 also hold locally, yours keeps winning.** `/magpie-setup verify`
 reports every local file that shadows a committed one, and
diff --git a/docs/setup/commit-attribution.md b/docs/setup/commit-attribution.md
new file mode 100644
index 00000000..aa7df6f2
--- /dev/null
+++ b/docs/setup/commit-attribution.md
@@ -0,0 +1,100 @@
+<!-- SPDX-License-Identifier: Apache-2.0
+     https://www.apache.org/licenses/LICENSE-2.0 -->
+
+<!-- START doctoc generated TOC please keep comment here to allow auto update -->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+**Table of Contents**  *generated with [DocToc](https://github.com/thlorenz/doctoc)*
+
+- [Commit attribution — which trailer an agent-assisted commit carries](#commit-attribution--which-trailer-an-agent-assisted-commit-carries)
+  - [The conventions](#the-conventions)
+  - [Where it is set](#where-it-is-set)
+  - [How the convention is resolved](#how-the-convention-is-resolved)
+  - [How the trailer is added](#how-the-trailer-is-added)
+  - [What the guard does with it](#what-the-guard-does-with-it)
+  - [Adopting projects and skills](#adopting-projects-and-skills)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+# Commit attribution — which trailer an agent-assisted commit carries
+
+Projects disagree on how a commit records that an agent helped write it.
+The ASF convention is a `Generated-by:` trailer; other projects use `Assisted-by:`, GitHub's `Co-authored-by:`, or no trailer at all.
+Magpie does not pick one for everybody.
+The project chooses when it adopts Magpie, and a contributor chooses only where the project leaves the choice open.
+Every skill that commits, and the agent-guard `commit-trailer` guard, resolve the convention the same way, described here.
+
+## The conventions
+
+| `convention` | Trailer the agent adds | Used by |
+|---|---|---|
+| `generated-by` | `Generated-by: {agent} ({model})` | The ASF ([Generative Tooling guidance](https://www.apache.org/legal/generative-tooling.html)); the default |
+| `assisted-by` | `Assisted-by: {agent}:{model}` | Projects that name the tool without implying generation |
+| `co-authored-by` | `Co-authored-by: {agent} <{email}>` | GitHub's co-author form; several agent harnesses' own default |
+| `none` | no trailer | Projects that record agent use elsewhere, or not at all |
+| `custom` | the `template` value | Anything else |
+
+`{agent}` is the agent harness and `{model}` the model actually running, e.g. `Claude Code` and `Claude Opus 4.8`.
+`{email}` is the address the harness uses for itself as a co-author.
+Only `co-authored-by` needs it.
+A `template` value overrides the default wording of any convention, and `custom` requires one.
+
+## Where it is set
+
+One small TOML file per layer:
+
+| Layer | File | Committed? |
+|---|---|---|
+| Project | `.apache-magpie-overrides/commit-attribution.toml` | Yes — set by `setup adopt` |
+| Contributor | `.apache-magpie-local/commit-attribution.toml` | No — set by `setup config` |
+
+```toml
+# generated-by | assisted-by | co-authored-by | none | custom
+# The project file may also say contributor-choice.
+convention = "generated-by"
+
+# Optional; required when convention = "custom".
+# template = "Generated-by: {agent} ({model})"
+```
+
+`projects/_template/commit-attribution.toml` is the scaffold both layers start from.
+
+## How the convention is resolved
+
+This is the one configuration file where **the project wins**, not the contributor's local copy (the general rule in [agentic overrides](agentic-overrides.md) is local-wins, per file).
+Attribution is a project policy first: a project that requires `Generated-by:` must be able to rely on it.
+
+1. If the project file sets a `convention` other than `contributor-choice`, that is the convention.
+2. Otherwise — the project file is absent, has no `convention` key, or says `contributor-choice` — the contributor file's `convention` applies.
+3. Otherwise the convention is `generated-by`.
+
+Fail closed: a file that cannot be read or parsed, or a value that is not one of the conventions above, resolves to `generated-by`.
+`contributor-choice` in the contributor's own file is not a convention and resolves the same way.
+
+## How the trailer is added
+
+Always with `git commit --trailer`, never by typing it into the message body or the `-F <file>`:
+
+```bash
+git commit -F "$TMPDIR/commit-msg.txt" --trailer "Generated-by: Claude Code (Claude Opus 4.8)"
+```
+
+`--trailer` puts the line in the trailer block with the separating blank line git expects.
+It also keeps it on the command line, where the agent-guard and any other hook that inspects the command can see it, even when the body travels in a file.
+For `none`, pass no `--trailer`.
+
+## What the guard does with it
+
+The agent-guard `commit-trailer` guard blocks a `git commit` that carries `Co-Authored-By:` (in the message or a `--trailer`) unless the resolved convention for the repository being committed to is `co-authored-by`.
+It follows `git -C <dir>` to that repository.
+The per-command override `MAGPIE_ALLOW_COAUTHOR=1` still works.
+
+This is a policy aid, not a security boundary.
+A contributor can relax the guard with their own gitignored file whenever the project leaves the choice open, and the guard does not read the message inside a `-F <file>`.
+
+## Adopting projects and skills
+
+- `setup adopt` asks the maintainer which convention the project uses, including `contributor-choice`, and writes the project file.
+- `setup config` asks a contributor for their preference only when the project leaves it open.
+- Skills that commit resolve the convention as above and add the trailer with `--trailer`.
+  Skills that review someone else's commits check them against the same resolved convention.
+- The framework repository itself sets `generated-by` in its own `.apache-magpie-overrides/commit-attribution.toml`, so contributions to Magpie follow the ASF convention whatever a contributor prefers elsewhere.
diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md
index 34c9af7c..34e9c5e0 100644
--- a/docs/setup/secure-agent-setup.md
+++ b/docs/setup/secure-agent-setup.md
@@ -1436,7 +1436,9 @@ lives in [`tools/agent-guard`](../../tools/agent-guard/README.md) and
 ships two **bundled** (universal `git` hygiene) guards:
 
 - **commit-trailer** — never let a `git commit` carry a
-  `Co-Authored-By:` trailer (use `Generated-by:`).
+  `Co-Authored-By:` trailer, unless the repository's
+  [commit-attribution convention](commit-attribution.md) is
+  `co-authored-by` (the default is `Generated-by:`).
 - **empty-rebase** — never force-push a branch with no commits over
   its base (an empty push to a PR head auto-closes it and revokes
   write).
diff --git a/plugins/magpie-issue/skills/fix-workflow/SKILL.md b/plugins/magpie-issue/skills/fix-workflow/SKILL.md
index 7359c427..b9c2d8c5 100644
--- a/plugins/magpie-issue/skills/fix-workflow/SKILL.md
+++ b/plugins/magpie-issue/skills/fix-workflow/SKILL.md
@@ -430,15 +430,15 @@ shapes:
 - **Body** — a short paragraph explaining the cause (not just
   the symptom) and the chosen fix shape. One paragraph; not a
   novel.
-- **Trailers** — AI-assisted commits use a `Generated-by: <tool>`
-  trailer (e.g. `Generated-by: <tool-name>`), never
-  `Co-Authored-By:` with an agent as co-author — per
-  [`AGENTS.md` → *Commit and PR conventions*](../../../../AGENTS.md#commit-and-pr-conventions)
+- **Trailers** — AI-assisted commits carry the trailer the project's
+  commit-attribution convention names (`Generated-by:` by default;
+  `Assisted-by:`, `Co-authored-by:`, none or a custom wording where the
+  project chose one), resolved per
+  [`commit-attribution.md`](../../../../docs/setup/commit-attribution.md)
   and the [ASF Generative Tooling guidance](https://www.apache.org/legal/generative-tooling.html).
-  Including the tool name is a recommended practice per the policy;
-  the project's `<project-config>/fix-workflow.md` may specify a
-  preferred format. The trailer is the *contributor's* call on their
-  own commit; the skill does not add it to anyone else's commit.
+  Add it with `git commit --trailer "<trailer>"`, not in the message
+  body. The trailer is the *contributor's* call on their own commit;
+  the skill does not add it to anyone else's commit.
 - **Security language scrub** — before finalising the commit body,
   confirm no line references the security nature of the change
   (e.g. *"fixes CVE"*, *"security fix"*, *"patches
diff --git a/plugins/magpie-pr-management/skills/pre-first-pr-check/SKILL.md b/plugins/magpie-pr-management/skills/pre-first-pr-check/SKILL.md
index b1d9c511..61770684 100644
--- a/plugins/magpie-pr-management/skills/pre-first-pr-check/SKILL.md
+++ b/plugins/magpie-pr-management/skills/pre-first-pr-check/SKILL.md
@@ -175,15 +175,21 @@ Every commit on the branch must satisfy all three rules:
    A conventional-commits prefix (`feat:`, `fix:`, `docs:`, `chore:`, etc.) is
    acceptable as long as the remainder of the subject is imperative.
 
-2. **No `Co-Authored-By:` for an AI agent** — the commit must not carry a trailer of the
+Rules 2 and 3 follow the project's commit-attribution convention, resolved per
+[`commit-attribution.md`](../../../../docs/setup/commit-attribution.md);
+the default is `generated-by`.
+
+2. **No `Co-Authored-By:` for an AI agent** — unless the convention is
+   `co-authored-by`, the commit must not carry a trailer of the
    form `Co-Authored-By: Claude`, `Co-Authored-By: GPT`, `Co-Authored-By: Copilot`, or
    any equivalent that attributes authorship to an AI model or agent.
    Using `Co-Authored-By:` for a *human* co-author is fine.
    See [`AGENTS.md` § Commit and PR conventions](../../../../AGENTS.md#commit-and-pr-conventions).
 
-3. **`Generated-by:` trailer when AI-assisted** — any commit that was substantially
-   written or edited by an AI agent must carry a `Generated-by:` trailer naming the
-   agent, e.g. `Generated-by: Claude Code (Opus 4.7)`. If the contributor indicates
+3. **The convention's trailer when AI-assisted** — any commit that was substantially
+   written or edited by an AI agent must carry the trailer the convention names, e.g.
+   `Generated-by: Claude Code (Opus 4.7)` by default; under `none`, no trailer is
+   expected. If the contributor indicates
    the commit was hand-written, no trailer is required; if there is any uncertainty,
    add the trailer (it is opt-in and costs nothing).
 
diff --git a/plugins/magpie-repo-health/skills/audit-finding-fix/SKILL.md b/plugins/magpie-repo-health/skills/audit-finding-fix/SKILL.md
index 68dc7780..4400ad27 100644
--- a/plugins/magpie-repo-health/skills/audit-finding-fix/SKILL.md
+++ b/plugins/magpie-repo-health/skills/audit-finding-fix/SKILL.md
@@ -397,7 +397,8 @@ Write the commit message per the project's convention:
 - **Body** — one paragraph: which tool, how many findings, the
   rules addressed, and a one-sentence summary of the fix strategy.
   No security language.
-- **Trailer** — `Generated-by: <tool-name>` per the
+- **Trailer** — the trailer the repository's commit-attribution convention names, resolved per [`commit-attribution.md`](../../../../docs/setup/commit-attribution.md) (`Generated-by: <tool-name>` by default),
+  added with `git commit --trailer "<trailer>"` per the
   [`AGENTS.md` → *Commit and PR conventions*](../../../../AGENTS.md#commit-and-pr-conventions).
   The trailer is the contributor's call on their own commit; the
   skill does not add it to anyone else's commit.
diff --git a/plugins/magpie-security/skills/issue-fix/SKILL.md b/plugins/magpie-security/skills/issue-fix/SKILL.md
index 4c850106..a9822d67 100644
--- a/plugins/magpie-security/skills/issue-fix/SKILL.md
+++ b/plugins/magpie-security/skills/issue-fix/SKILL.md
@@ -834,8 +834,8 @@ After the user confirms the diff:
 
 1. Stage only the intentional changes (`git add <paths>` — never
    `git add -A` or `git add .`).
-2. Commit with the agreed message from 5c, ending in the
-   `Generated-by:` trailer (not `Co-Authored-By:`), per
+2. Commit with the agreed message from 5c, adding the trailer the repository's commit-attribution convention names, resolved per [`commit-attribution.md`](../../../../docs/setup/commit-attribution.md)
+   (`Generated-by:` by default), with `git commit --trailer`, per
    [`AGENTS.md`](../../../../AGENTS.md).
 3. Rebase onto the latest upstream base one more time in case
    something landed while you were working:
diff --git a/plugins/magpie-setup/skills/isolated-setup-update/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-update/SKILL.md
index c28cb6f1..d209609b 100644
--- a/plugins/magpie-setup/skills/isolated-setup-update/SKILL.md
+++ b/plugins/magpie-setup/skills/isolated-setup-update/SKILL.md
@@ -122,7 +122,8 @@ Walk each:
    Only when the plugin is **not** enabled does the user-scope wiring apply; diff it like any other script, per [`script-inventory.md`](script-inventory.md).
 
    **Either way, confirm the guard actually denies.**
-   A `git commit` whose message carries a `Co-Authored-By:` trailer is the cheap canary: the bundled `commit-trailer` guard blocks it before the commit runs, so a command that goes through means the hook is not firing, whatever the files and settings say.
+   A `git commit --no-verify --dry-run` is the cheap canary: the bundled `no-verify` guard blocks it before the commit runs, so a command that goes through means the hook is not firing, whatever the files and settings say.
+   (A `Co-Authored-By:` trailer is no longer a reliable canary: the `commit-trailer` guard allows it in a project whose [commit-attribution convention](../../../../docs/setup/commit-attribution.md) is `co-authored-by`.)
 
 4. **Settings.json shape drift.**
    Diff the user's project `.claude/settings.json` against the framework's dogfooded one.
diff --git a/plugins/magpie-setup/skills/override-upstream/SKILL.md b/plugins/magpie-setup/skills/override-upstream/SKILL.md
index a6bdc175..d851c57e 100644
--- a/plugins/magpie-setup/skills/override-upstream/SKILL.md
+++ b/plugins/magpie-setup/skills/override-upstream/SKILL.md
@@ -175,8 +175,8 @@ In `<framework-clone>`:
 4. Show the user the diff (`git diff`).
    Get explicit confirmation before committing.
 5. Commit with a message matching the framework's conventions (Conventional-Commits prefix: `feat(skills): ...` for new framework behaviour, `refactor(skills): ...` for restructure, etc.).
-   End the message with a `Generated-by: <agent> (<model>)` trailer, where `<agent>` and `<model>` are the actual agent and model you are running as (e.g. `Claude (Opus 4.8)`, `OpenCode (Big Pickle)`).
-   Do not hardcode either, per the framework's no-coauthored-by hook.
+   Add a `Generated-by: <agent> (<model>)` trailer with `git commit --trailer`, where `<agent>` and `<model>` are the actual agent and model you are running as (e.g. `Claude (Opus 4.8)`, `OpenCode (Big Pickle)`).
+   It is the framework repository's own convention ([`commit-attribution.md`](../../../../docs/setup/commit-attribution.md)), whatever the adopter uses; do not hardcode either value, per the framework's no-coauthored-by hook.
 
 ### Step 6 — Open the PR
 
diff --git a/plugins/magpie-setup/skills/setup/adopt.md b/plugins/magpie-setup/skills/setup/adopt.md
index 27d112ed..f3b74193 100644
--- a/plugins/magpie-setup/skills/setup/adopt.md
+++ b/plugins/magpie-setup/skills/setup/adopt.md
@@ -239,6 +239,27 @@ auto-detect-before-asking discipline `config` uses.
 A maintainer who knows they are adopting can arrive here directly,
 without having run `config` first; this step is what makes that work.
 
+**Ask the commit-attribution convention explicitly**, even when
+`commit-attribution.toml` would otherwise be scaffolded unasked: it is
+project policy, and unlike other configuration the project's value
+wins over every contributor's
+([`commit-attribution.md`](../../../../docs/setup/commit-attribution.md#how-the-convention-is-resolved)).
+One structured question, with these options:
+
+- `generated-by` — `Generated-by: <agent> (<model>)`; the ASF
+  convention and the default. Recommend it for ASF projects.
+- `assisted-by` — `Assisted-by: <agent>:<model>`.
+- `co-authored-by` — `Co-authored-by: <agent> <email>`; the guard then
+  stops blocking `Co-Authored-By:` in this project.
+- `none` — no trailer.
+- `contributor-choice` — each contributor decides, through `config`.
+- custom — ask for the wording and write it as `template`.
+
+Auto-detect first: if the project's contributing docs or recent history
+already use one of these trailers, offer it as the recommended option
+and say where it was found. Write the answer to
+`.apache-magpie-overrides/commit-attribution.toml`.
+
 `git add` the store; do not commit.
 
 ### 4c — Review the project's existing process
diff --git a/plugins/magpie-setup/skills/setup/config.md b/plugins/magpie-setup/skills/setup/config.md
index e7937af1..14505e6c 100644
--- a/plugins/magpie-setup/skills/setup/config.md
+++ b/plugins/magpie-setup/skills/setup/config.md
@@ -154,7 +154,17 @@ For each missing required file, in the order the skills need them
    structured-question tool. One question covering every remaining
    `TODO` across every file, grouped by file — not a per-field
    interrogation, and not one question per skill.
-4. **Leave what the user skips.** A `TODO` left in place is not an
+4. **Commit attribution is the one exception to "the project's file
+   is just a default".** Resolve it per
+   [`commit-attribution.md`](../../../../docs/setup/commit-attribution.md#how-the-convention-is-resolved).
+   When the project's `.apache-magpie-overrides/commit-attribution.toml`
+   sets a convention other than `contributor-choice`, do not ask and do
+   not write a local file: say which convention the project uses.
+   Otherwise include one question in the batch — `generated-by`
+   (default), `assisted-by`, `co-authored-by`, `none`, or custom
+   wording — and write the answer to
+   `.apache-magpie-local/commit-attribution.toml`.
+5. **Leave what the user skips.** A `TODO` left in place is not an
    error: the skill that needs it names it when it needs it, and the
    skills that do not need it never look. Say that, so a half-filled
    file does not read as a failed run.
diff --git a/plugins/magpie-setup/skills/shared-config-sync/SKILL.md b/plugins/magpie-setup/skills/shared-config-sync/SKILL.md
index 88566638..db2b76b8 100644
--- a/plugins/magpie-setup/skills/shared-config-sync/SKILL.md
+++ b/plugins/magpie-setup/skills/shared-config-sync/SKILL.md
@@ -99,7 +99,7 @@ Any remote the skill *creates* is **private** (see the golden rules); never publ
   Concurrent work from another machine takes precedence; the local commit lands on top, as the example `sync.sh` does for the periodic pull.
 - **Draft the commit message; never auto-send.** For every uncommitted change, draft a one-line commit subject (plus a 2–4 line body if the change merits it) and show it to the user.
   The user replies *"go"* / *"yes"* / edits / *"split into two commits"* etc. before any `git commit` runs.
-- **Use the `Generated-by:` trailer per AGENTS.md.** Agent-authored commits carry `Generated-by: <agent> (<model>)` at the end of the body, where `<agent>` and `<model>` are the agent and model you are actually running as (e.g. `Claude (Opus 4.8)`, `OpenCode (Big Pickle)`).
+- **Use the resolved attribution trailer, added with `--trailer`.** Agent-authored commits carry the trailer the user's commit-attribution convention names, resolved per [`commit-attribution.md`](../../../../docs/setup/commit-attribution.md) (the sync repo has no project file, so the user's choice applies, else `Generated-by: <agent> (<model>)`), where `<agent>` and `<model>` are the agent and model you are actually running as (e.g. `Claude (Opus 4.8)`, `OpenCode (Big Pickle)`). Add it w [...]
   Do not hardcode either, and never use `Co-Authored-By:`.
   Canonical wording: [AGENTS.md → Commit and PR conventions](../../../../AGENTS.md#commit-and-pr-conventions).
 - **Stop on lock conflict.** The example `sync.sh` uses `flock --nonblock` on `~/.claude-config/.sync.lock` so two sync runs do not race.
@@ -156,7 +156,7 @@ On approval:
    [A minimal `sync.sh`](../../../../docs/setup/secure-agent-setup.md#a-minimal-syncsh)
    section.
    `.gitignore` must at minimum carry `.sync.lock` (the `flock` file) so the lock is never committed.
-3. **Initial commit + push.** `git add` the scaffolded files individually (never `git add -A`; see [Walk-through](#walk-through) step 5), commit with the `Generated-by:` trailer, `git remote add origin <url>`, then `git push -u origin main`.
+3. **Initial commit + push.** `git add` the scaffolded files individually (never `git add -A`; see [Walk-through](#walk-through) step 5), commit with the resolved attribution trailer (via `--trailer`), `git remote add origin <url>`, then `git push -u origin main`.
 
 ### Step B4 — fresh-host symlink wiring
 
@@ -201,8 +201,8 @@ Wire only the artifacts the checkout contains; on a brand-new scaffold `scripts/
 
 5. **Stage + commit (when applicable).** For each modification the user approves:
    - `git add <file>` for the specific file (never `git add -A` or `git add .` — the sync repo is the user's most personal directory and `git add -A` risks staging an editor swap file or a `.DS_Store` you forgot to gitignore),
-   - `git commit -m '<subject>' -m '<body>'` with the approved message.
-     Always include a `Generated-by: <agent> (<model>)` trailer in the body per AGENTS.md — the actual agent and model you are running as, not a hardcoded value.
+   - `git commit -m '<subject>' -m '<body>' --trailer '<trailer>'` with the approved message.
+     `<trailer>` is the resolved attribution trailer (see the golden rule above) — the actual agent and model you are running as, not a hardcoded value; omit `--trailer` when the convention is `none`.
 
    Probe the gpg-agent cache first when `commit.gpgsign` is true: a token-backed key with a cold cache stalls this commit until `gpg: signing failed: Timeout`.
    Surface a dialogue, or hand the user the command — see
diff --git a/plugins/magpie-setup/skills/upstream-fix/SKILL.md b/plugins/magpie-setup/skills/upstream-fix/SKILL.md
index 085c0a60..7803b791 100644
--- a/plugins/magpie-setup/skills/upstream-fix/SKILL.md
+++ b/plugins/magpie-setup/skills/upstream-fix/SKILL.md
@@ -189,7 +189,7 @@ Do this **once per quirk**, in `<framework-clone>`:
 3. Run `prek run --all-files` (or `--files <changed>`) and fix anything it flags.
    Never bypass with `--no-verify`.
 4. Show the user `git diff` and get explicit confirmation before committing.
-5. Commit with a Conventional-Commits prefix (`fix(<area>): …`) and a `Generated-by: <agent name and version>` trailer;
+5. Commit with a Conventional-Commits prefix (`fix(<area>): …`) and add the framework's `Generated-by: <agent name and version>` trailer with `git commit -F <file> --trailer "Generated-by: …"`;
    the framework's [no-`Co-Authored-By`](../../../../AGENTS.md) hook rejects AI co-authorship.
 6. Push to the fork: `git push <fork-remote> fix/<short-description>`.
    - **Fork-push gotcha.** If the push is rejected for a `workflow` scope the token lacks, the fork's `main` is stale and the branch carries historical `.github/workflows/` changes.
@@ -310,7 +310,7 @@ Every `apache/magpie#NNN` reference in the recap is a clickable link.
 - **Propose → confirm → apply.** Nothing is cloned, committed, pushed, PR'd, or commented without explicit confirmation.
 - **`--body-file` only.** Never `gh … --body "$(…)"` or `--title '<attacker-influenced>'`; PR/issue text goes through a tempfile.
   Quirk text in a PR body is agent-authored, but keep the tempfile discipline uniform.
-- **`Generated-by:` trailer, never `Co-Authored-By:`.** The framework's commit hook rejects AI co-authorship.
+- **`Generated-by:` trailer via `--trailer`, never `Co-Authored-By:`.** It is the framework repository's own convention ([`commit-attribution.md`](../../../../docs/setup/commit-attribution.md)), whatever the adopter uses; the framework's commit hook rejects AI co-authorship.
 - **Never `git push --force`** to a branch that already has a PR; never delete the branch mid-review.
 
 ## Silencing the session-end offer
diff --git a/projects/_template/README.md b/projects/_template/README.md
index 208636dc..695adcb6 100644
--- a/projects/_template/README.md
+++ b/projects/_template/README.md
@@ -99,6 +99,7 @@ the rest.
 | File | Purpose |
 |---|---|
 | [`fix-workflow.md`](fix-workflow.md) | Fork / clone / toolchain specifics, backport-label policy, commit-trailer wording, PR scrubbing, private-PR fallback. |
+| [`commit-attribution.toml`](commit-attribution.toml) | Which trailer an agent-assisted commit carries (`generated-by`, `assisted-by`, `co-authored-by`, `none`, custom, or `contributor-choice`). The project's value wins over a contributor's — see [`commit-attribution.md`](../../docs/setup/commit-attribution.md). |
 
 ### Editorial + reporter-facing
 
diff --git a/projects/_template/commit-attribution.toml b/projects/_template/commit-attribution.toml
new file mode 100644
index 00000000..ea359bb5
--- /dev/null
+++ b/projects/_template/commit-attribution.toml
@@ -0,0 +1,33 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Which trailer an agent-assisted commit carries.
+# Resolution rules and the default wording of each convention:
+# docs/setup/commit-attribution.md.
+#
+# In .apache-magpie-overrides/ (committed) this is the project's policy,
+# and it wins. In .apache-magpie-local/ (gitignored) it is one
+# contributor's preference, used only when the project's file is absent
+# or says contributor-choice.
+
+# generated-by | assisted-by | co-authored-by | none | custom
+# The project's file may also say contributor-choice.
+convention = "generated-by"
+
+# Optional; required when convention = "custom".
+# Placeholders: {agent}, {model}, {email}.
+# template = "Generated-by: {agent} ({model})"
diff --git a/projects/_template/fix-workflow.md b/projects/_template/fix-workflow.md
index 9fc2153f..604cb9dc 100644
--- a/projects/_template/fix-workflow.md
+++ b/projects/_template/fix-workflow.md
@@ -64,16 +64,16 @@ default policy. Most projects need only one or two.
 
 ## Commit trailer
 
-TODO: the project's convention for AI-assisted commits. Example:
-
-> Never use `Co-Authored-By:` with an AI agent as co-author. Use a
-> `Generated-by:` trailer instead.
-
-And the concrete trailer text for this project:
-
-```text
-Generated-by: TODO: model + URL to the project's Gen-AI disclosure anchor
-```
+Which trailer an AI-assisted commit carries is set in
+[`commit-attribution.toml`](commit-attribution.toml) — `generated-by`,
+`assisted-by`, `co-authored-by`, `none`, `custom`, or `contributor-choice`
+— and resolved per
+[`docs/setup/commit-attribution.md`](../../docs/setup/commit-attribution.md).
+`setup adopt` asks for it.
+
+TODO (optional): anything the project adds to the trailer beyond the
+convention's default wording, e.g. a URL to its Gen-AI disclosure anchor.
+Put the full wording in the `template` key of `commit-attribution.toml`.
 
 ## PR title / body scrubbing
 
diff --git a/tools/agent-guard/README.md b/tools/agent-guard/README.md
index 8ac733a3..4f00e971 100644
--- a/tools/agent-guard/README.md
+++ b/tools/agent-guard/README.md
@@ -103,7 +103,7 @@ project):
 
 | Guard | Blocks | Rule it enforces |
 |---|---|---|
-| `commit-trailer` | `git commit` whose message contains `Co-Authored-By:` | AGENTS.md: agents use a `Generated-by:` trailer, never co-author |
+| `commit-trailer` | `git commit` whose message contains `Co-Authored-By:`, unless the repository's [commit-attribution convention](../../docs/setup/commit-attribution.md) is `co-authored-by` | AGENTS.md: agents use the project's attribution trailer (default `Generated-by:`), not co-authorship |
 | `empty-rebase` | `git push --force[-with-lease]` of a branch with 0 commits over its base | an empty push to a PR head auto-closes it + revokes write |
 
 **Skill-owned** (each lives in its skill's `guards/` dir, discovered the same
diff --git a/tools/agent-guard/src/agent_guard/__init__.py b/tools/agent-guard/src/agent_guard/__init__.py
index c2a55944..c3936a26 100644
--- a/tools/agent-guard/src/agent_guard/__init__.py
+++ b/tools/agent-guard/src/agent_guard/__init__.py
@@ -46,7 +46,9 @@ The engine ships two **bundled** guards — the universal ``git`` hygiene rules
 that apply to every project:
 
 1. **commit-trailer** — never let a ``git commit`` carry a ``Co-Authored-By:``
-   trailer (AGENTS.md: agents use ``Generated-by:``, never co-author).
+   trailer unless the repository's commit-attribution convention is
+   ``co-authored-by`` (see :func:`resolve_commit_attribution` and
+   ``docs/setup/commit-attribution.md``; the default is ``generated-by``).
 2. **empty-rebase** — never force-push a branch that has no commits over its
    base (an empty push to a PR head auto-closes the PR and revokes write).
 
@@ -86,6 +88,7 @@ import re
 import shlex
 import subprocess
 import sys
+import tomllib
 from collections.abc import Callable
 from pathlib import Path
 
@@ -112,6 +115,15 @@ _INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
 
 GUARD_TIMEOUT = 10  # seconds for any subprocess (gh / git) a guard shells out to.
 
+# Commit attribution (docs/setup/commit-attribution.md): one small TOML file
+# per layer, the project's committed and the contributor's gitignored.
+ATTRIBUTION_FILE = "commit-attribution.toml"
+ATTRIBUTION_PROJECT_DIR = ".apache-magpie-overrides"
+ATTRIBUTION_LOCAL_DIR = ".apache-magpie-local"
+ATTRIBUTION_CONVENTIONS = frozenset({"generated-by", "assisted-by", "co-authored-by", "none", "custom"})
+ATTRIBUTION_CONTRIBUTOR_CHOICE = "contributor-choice"  # project file only
+DEFAULT_ATTRIBUTION = "generated-by"
+
 
 class Segment:
     """One simple command from a (possibly compound) shell line.
@@ -398,6 +410,80 @@ def _repo_flag(argv: list[str]) -> list[str]:
 # --------------------------------------------------------------------------- #
 
 
+def _find_repo_root(start: Path) -> Path | None:
+    """The nearest ancestor of ``start`` (inclusive) holding a ``.git`` entry.
+
+    A plain filesystem walk rather than ``git rev-parse``: this runs on every
+    ``git commit`` the agent makes, and ``.git`` is a directory in a main
+    checkout and a file in a linked worktree, so ``exists()`` covers both.
+    """
+    for candidate in (start, *start.parents):
+        if (candidate / ".git").exists():
+            return candidate
+    return None
+
+
+def _read_attribution(path: Path) -> str | None:
+    """The ``convention`` value of one ``commit-attribution.toml``.
+
+    ``None`` when the file does not exist. Raises ``ValueError`` when it
+    exists but cannot be read or parsed, so the caller can fail closed.
+    """
+    try:
+        text = path.read_text(encoding="utf-8")
+    except FileNotFoundError:
+        return None
+    except (OSError, UnicodeDecodeError) as exc:
+        raise ValueError(f"{path}: {exc}") from exc
+    try:
+        data = tomllib.loads(text)
+    except tomllib.TOMLDecodeError as exc:
+        raise ValueError(f"{path}: {exc}") from exc
+    value = data.get("convention")
+    if value is None:
+        return None
+    if not isinstance(value, str):
+        raise ValueError(f"{path}: convention must be a string")
+    return value.strip().lower()
+
+
+def resolve_commit_attribution(repo_root: Path | None) -> str:
+    """The commit-attribution convention in force for ``repo_root``.
+
+    See ``docs/setup/commit-attribution.md``. The project's committed choice
+    wins when it makes one; the contributor's gitignored choice applies only
+    when the project's file is absent, sets no ``convention``, or sets
+    ``contributor-choice``. Anything unreadable, unparsable or unknown
+    resolves to the default, which is the conservative answer for every guard
+    that consults this: the default never permits ``Co-Authored-By:``.
+    """
+    if repo_root is None:
+        return DEFAULT_ATTRIBUTION
+    try:
+        project = _read_attribution(repo_root / ATTRIBUTION_PROJECT_DIR / ATTRIBUTION_FILE)
+        if project is not None and project != ATTRIBUTION_CONTRIBUTOR_CHOICE:
+            return project if project in ATTRIBUTION_CONVENTIONS else DEFAULT_ATTRIBUTION
+        local = _read_attribution(repo_root / ATTRIBUTION_LOCAL_DIR / ATTRIBUTION_FILE)
+    except ValueError:
+        return DEFAULT_ATTRIBUTION
+    if local in ATTRIBUTION_CONVENTIONS:
+        return local
+    return DEFAULT_ATTRIBUTION
+
+
+def _commit_repo_root(argv: list[str], sub_index: int, cwd: str | None) -> Path | None:
+    """The repository a ``git [-C <dir>]… commit`` would commit to."""
+    base = Path(cwd) if cwd else Path.cwd()
+    i = 1
+    while i < sub_index:
+        if argv[i] == "-C" and i + 1 < sub_index:
+            base = base / argv[i + 1]
+            i += 2
+            continue
+        i += 1
+    return _find_repo_root(base)
+
+
 def guard_commit_trailer(seg: Segment, cwd: str | None) -> str | None:
     idx = git_subcommand_index(seg.argv)
     if idx is None or seg.argv[idx] != "commit":
@@ -406,11 +492,16 @@ def guard_commit_trailer(seg: Segment, cwd: str | None) -> str | None:
         return None
     if seg.override("MAGPIE_ALLOW_COAUTHOR"):
         return None
+    convention = resolve_commit_attribution(_commit_repo_root(seg.argv, idx, cwd))
+    if convention == "co-authored-by":
+        return None
     return (
         "agent-guard[commit-trailer]: this commit message carries a 'Co-Authored-By:' "
-        "trailer. Per AGENTS.md, agents are assistants, not authors — use a "
-        "'Generated-by: <agent name and version>' trailer instead and remove the "
-        "Co-Authored-By line. Override (not for AI co-authorship): MAGPIE_ALLOW_COAUTHOR=1."
+        f"trailer, but this repository's commit-attribution convention is '{convention}'. "
+        "Agents are assistants, not authors: remove the Co-Authored-By line and add the "
+        "trailer the convention names with `git commit --trailer` (see "
+        "docs/setup/commit-attribution.md). Override (not for AI co-authorship): "
+        "MAGPIE_ALLOW_COAUTHOR=1."
     )
 
 
diff --git a/tools/agent-guard/tests/test_guards.py b/tools/agent-guard/tests/test_guards.py
index ad8b8b5c..251227a4 100644
--- a/tools/agent-guard/tests/test_guards.py
+++ b/tools/agent-guard/tests/test_guards.py
@@ -109,6 +109,101 @@ def test_commit_coauthor_denied_past_a_global_flag(command):
     assert reason and "Co-Authored-By" in reason
 
 
+# --------------------------------------------------------------------------- #
+# commit-trailer guard: the configured commit-attribution convention
+# --------------------------------------------------------------------------- #
+
+COAUTHOR_COMMIT = 'git commit -m "x\n\nCo-Authored-By: a <x...@y.z>"'
+
+
+def _attribution_repo(tmp_path, project=None, local=None):
+    """A bare-bones repo with optional project / contributor attribution files.
+
+    ``project`` / ``local`` are the raw TOML text of each layer's file.
+    """
+    (tmp_path / ".git").mkdir(parents=True)
+    for layer, text in ((".apache-magpie-overrides", project), (".apache-magpie-local", local)):
+        if text is not None:
+            (tmp_path / layer).mkdir()
+            (tmp_path / layer / "commit-attribution.toml").write_text(text)
+    return tmp_path
+
+
+@pytest.mark.parametrize(
+    "project, local, allowed",
+    [
+        # Nothing configured: the default, generated-by, keeps the block.
+        (None, None, False),
+        # The project chose co-authored-by.
+        ('convention = "co-authored-by"\n', None, True),
+        # The project's choice wins over the contributor's.
+        ('convention = "generated-by"\n', 'convention = "co-authored-by"\n', False),
+        ('convention = "none"\n', 'convention = "co-authored-by"\n', False),
+        # The project leaves it open, explicitly or by not deciding.
+        ('convention = "contributor-choice"\n', 'convention = "co-authored-by"\n', True),
+        (None, 'convention = "co-authored-by"\n', True),
+        ("# no convention key yet\n", 'convention = "co-authored-by"\n', True),
+        ('convention = "contributor-choice"\n', None, False),
+        ('convention = "contributor-choice"\n', 'convention = "assisted-by"\n', False),
+        # Case and whitespace in the value are not a way around the choice.
+        ('convention = " Co-Authored-By "\n', None, True),
+    ],
+)
+def test_commit_coauthor_follows_the_resolved_convention(tmp_path, project, local, allowed):
+    repo = _attribution_repo(tmp_path, project, local)
+    reason = dispatch(COAUTHOR_COMMIT, cwd=str(repo))
+    assert (reason is None) is allowed, reason
+
+
+@pytest.mark.parametrize(
+    "project, local",
+    [
+        # Unparsable project file: fail closed, even though the contributor chose.
+        ("convention = [unclosed\n", 'convention = "co-authored-by"\n'),
+        # Unknown project value: the default, not the contributor's choice.
+        ('convention = "anything-goes"\n', 'convention = "co-authored-by"\n'),
+        # contributor-choice is a project-only value.
+        (None, 'convention = "contributor-choice"\n'),
+        # A non-string value.
+        ("convention = 1\n", None),
+    ],
+)
+def test_commit_attribution_fails_closed(tmp_path, project, local):
+    repo = _attribution_repo(tmp_path, project, local)
+    reason = dispatch(COAUTHOR_COMMIT, cwd=str(repo))
+    assert reason and "Co-Authored-By" in reason and "'generated-by'" in reason
+
+
+def test_commit_attribution_found_from_a_subdirectory(tmp_path):
+    repo = _attribution_repo(tmp_path, 'convention = "co-authored-by"\n')
+    sub = repo / "src" / "pkg"
+    sub.mkdir(parents=True)
+    assert dispatch(COAUTHOR_COMMIT, cwd=str(sub)) is None
+
+
+def test_commit_attribution_follows_git_dash_c(tmp_path):
+    # `git -C <repo> commit` commits to <repo>, so its convention is the one
+    # that applies, not the one where the command was typed.
+    repo = _attribution_repo(tmp_path / "repo", 'convention = "co-authored-by"\n')
+    elsewhere = tmp_path / "elsewhere"
+    elsewhere.mkdir()
+    (elsewhere / ".git").mkdir()
+    command = f'git -C {repo} commit -m "x\n\nCo-Authored-By: a <x...@y.z>"'
+    assert dispatch(command, cwd=str(elsewhere)) is None
+    assert dispatch(COAUTHOR_COMMIT, cwd=str(elsewhere)) is not None
+
+
+def test_commit_attribution_trailer_flag_is_seen(tmp_path):
+    # The trailer added the way the convention docs prescribe is still caught.
+    repo = _attribution_repo(tmp_path, 'convention = "generated-by"\n')
+    command = "git commit -F msg.txt --trailer 'Co-authored-by: a <x...@y.z>'"
+    assert dispatch(command, cwd=str(repo)) is not None
+
+
+def test_resolve_commit_attribution_outside_a_repo():
+    assert agent_guard.resolve_commit_attribution(None) == "generated-by"
+
+
 # --------------------------------------------------------------------------- #
 # empty-rebase guard (bundled)
 # --------------------------------------------------------------------------- #
@@ -298,8 +393,8 @@ def test_main_allows_malformed_stdin(monkeypatch, capsys):
 
 
 # Convenience wrapper so each test reads cleanly.
-def dispatch(command):
-    return agent_guard.dispatch(command, cwd=None)
+def dispatch(command, cwd=None):
+    return agent_guard.dispatch(command, cwd=cwd)
 
 
 class _Stdin: