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: