ai-profile-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,215 @@
1
+ Metadata-Version: 2.4
2
+ Name: ai-profile-cli
3
+ Version: 0.3.0
4
+ Summary: Local-first, profile-level AI collaboration analytics for GitHub: aggregate explicit AI provenance across repositories into privacy-safe SVG/JSON README assets.
5
+ Project-URL: Homepage, https://github.com/WenyuChiou/ai-profile
6
+ Project-URL: Repository, https://github.com/WenyuChiou/ai-profile
7
+ Project-URL: Issues, https://github.com/WenyuChiou/ai-profile/issues
8
+ Project-URL: Changelog, https://github.com/WenyuChiou/ai-profile/blob/main/CHANGELOG.md
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai,attribution,git,github-profile,provenance
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Software Development :: Version Control :: Git
18
+ Requires-Python: >=3.11
19
+ Provides-Extra: dev
20
+ Requires-Dist: hypothesis>=6; extra == 'dev'
21
+ Requires-Dist: pytest>=8; extra == 'dev'
22
+ Requires-Dist: ruff>=0.4; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ <picture>
26
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/banner-dark.svg">
27
+ <img alt="ai-profile - prove your AI collaboration, privately" src="docs/assets/banner-light.svg" width="100%">
28
+ </picture>
29
+
30
+ # ai-profile
31
+
32
+ [![tests](https://github.com/WenyuChiou/ai-profile/actions/workflows/ci.yml/badge.svg)](https://github.com/WenyuChiou/ai-profile/actions/workflows/ci.yml)
33
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
34
+ [![python 3.11–3.14](https://img.shields.io/badge/python-3.11%E2%80%933.14-blue.svg)](pyproject.toml)
35
+
36
+ English · [繁體中文](README.zh-TW.md)
37
+
38
+ Local-first, profile-level **AI collaboration analytics** for your GitHub
39
+ README. `aiprofile` scans your local Git repositories for *explicit* AI
40
+ provenance — `AI-*` commit trailers and known AI co-author trailers (Claude
41
+ Code, Codex, Cursor, Copilot, Aider, …) — normalizes it into a common event
42
+ schema (ACE), stores it in a local SQLite database, and renders privacy-safe
43
+ SVG cards + a JSON summary you can embed in a GitHub Profile README.
44
+
45
+ ## What it looks like
46
+
47
+ <picture>
48
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/summary-sample-dark.svg">
49
+ <img alt="Sample AI collaboration summary card rendered from synthetic data" src="docs/assets/summary-sample-light.svg">
50
+ </picture>
51
+
52
+ <picture>
53
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/heatmap-sample-dark.svg">
54
+ <img alt="Sample collaboration heatmap: intensity is total commits including your own, hue is the day's AI share" src="docs/assets/heatmap-sample-light.svg">
55
+ </picture>
56
+
57
+ <picture>
58
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/badge-sample-dark.svg">
59
+ <img alt="AI-assisted share badge, verified from git provenance" src="docs/assets/badge-sample-light.svg">
60
+ </picture>
61
+
62
+ Samples rendered from a synthetic showcase fixture; no real repository
63
+ data. The heatmap is the view no other tool draws: intensity is your
64
+ whole commit rhythm (your own commits included), hue is how much of
65
+ each day was AI collaboration.
66
+
67
+ It is **not** an AI code detector: nothing is ever inferred from code style.
68
+ Commits without explicit evidence are honestly reported as `unknown` —
69
+ never silently counted as human, never guessed into a provider.
70
+
71
+ As far as we know it is the only free, local-first, cross-repository,
72
+ explicit-provenance profile aggregation tool (line-level attribution
73
+ belongs to tools like git-ai; full analysis:
74
+ [landscape & positioning](docs/landscape.md)).
75
+
76
+ Status: **v0.3** — the v0.1 vertical slice (one repo → trailers → SQLite →
77
+ aggregate → summary card) plus provider brand identity (15 marks, two
78
+ icon sources), the publishable-only isometric daily calendar, and the
79
+ collaboration-ratio heatmap + badge. Design docs live in
80
+ [`docs/`](docs/):
81
+ [architecture](docs/architecture.md) · [ACE schema](docs/schema.md) ·
82
+ [MVP boundary](docs/mvp.md) · [landscape & non-duplication](docs/landscape.md)
83
+ · [decision records](docs/decisions/).
84
+
85
+ ## Install
86
+
87
+ Compatibility: Python 3.11–3.14 · git ≥ 2.17 · SHA-1 repositories
88
+ (SHA-256 object format fails with a clear error in v0.1) · Windows,
89
+ macOS, Linux. Zero runtime dependencies.
90
+
91
+ ```bash
92
+ pip install git+https://github.com/WenyuChiou/ai-profile
93
+ ```
94
+
95
+ The PyPI package will be **`ai-profile-cli`** (upload in progress;
96
+ PyPI's name-similarity rule blocks `ai-profile` because an unrelated
97
+ project already holds `aiprofile` — and `pip install aiprofile` gets
98
+ you that other project, not this one). Once live:
99
+ `pip install ai-profile-cli`.
100
+
101
+ From a clone (development):
102
+
103
+ ```bash
104
+ pip install -e ".[dev]" # dev extras = pytest + ruff + hypothesis
105
+ ```
106
+
107
+ ## Quickstart
108
+
109
+ ```bash
110
+ aiprofile init # run from INSIDE one of your repos - seeds your
111
+ # identity from that repo's git config user.email
112
+ aiprofile scan ~/my/repo # register + scan (replace with a real path;
113
+ # private-safe default)
114
+ aiprofile aggregate # print the published stats = privacy preview
115
+ aiprofile render # write dist/: summary + heatmap + badge SVG
116
+ # pairs (light/dark) + profile.json
117
+ ```
118
+
119
+ Run **one `render` at a time per output directory** — concurrent renders
120
+ into the same directory are unsupported and can publish assets from
121
+ different scans.
122
+
123
+ Only commits authored by your configured identities count (seeded from
124
+ `git config user.email` at init; add more emails in
125
+ `~/.aiprofile/config.json`).
126
+
127
+ Run `aiprofile init` **from inside one of your own repos**: identity
128
+ seeding reads `git config user.email` in the directory where you run
129
+ it, so running from an unrelated folder can seed your global email
130
+ instead of the one you actually commit with (or none at all). Check
131
+ the seeded identities in `~/.aiprofile/config.json` after init.
132
+
133
+ Embed in your profile README:
134
+
135
+ ```html
136
+ <picture>
137
+ <source media="(prefers-color-scheme: dark)" srcset="dist/summary-dark.svg">
138
+ <img alt="AI collaboration summary" src="dist/summary-light.svg">
139
+ </picture>
140
+ ```
141
+
142
+ ## Declaring AI participation (trailers)
143
+
144
+ **If you commit through Claude Code, Codex, Cursor, Copilot, Aider, or
145
+ Amp, you likely have nothing to do**: tools that add their own
146
+ co-author trailer (e.g. Claude Code's
147
+ `Co-Authored-By: Claude <noreply@anthropic.com>`) are recognized
148
+ automatically via a verified identity registry.
149
+
150
+ For everything else — or for richer detail (model, role, review
151
+ status) — declare explicitly with `AI-*` trailers; product names like
152
+ `Kimi`, `Claude`, or `Gemini` resolve too:
153
+
154
+ ```text
155
+ feat: add aggregation service
156
+
157
+ AI-Provider: Anthropic
158
+ AI-Model: Claude-Sonnet
159
+ AI-Tool: Claude-Code
160
+ AI-Role: implementation, documentation
161
+ AI-Mode: AI-Assisted
162
+ AI-Reviewed-By: Human
163
+ ```
164
+
165
+ One commit can carry
166
+ several **AI actor presences** ("Claude implements, Codex reviews" =
167
+ 1 unique commit, 2 presences — the two metrics are never conflated; a
168
+ presence means "this provider/tool appeared in this commit", so Claude
169
+ implementing AND reviewing one commit counts once, honestly). A commit
170
+ that is explicitly yours alone: `AI-Mode: Human-Only`.
171
+
172
+ ## Privacy model (defaults are safe)
173
+
174
+ - Everything stays on your machine; no network calls, no telemetry.
175
+ - Every scanned repository defaults to `aggregate_only`: it contributes
176
+ counts, never its name. `scan --full` is the explicit opt-in that marks
177
+ a repository's counts as explicitly publishable (a policy decision you
178
+ make — NOT a claim about GitHub visibility); `excluded` removes a repository entirely.
179
+ - Publication policy lives in `config.json` only — edit it and the next
180
+ `aggregate`/`render` respects it, no rescan needed.
181
+ - Public outputs contain counts, provider names, evidence totals, and a
182
+ UTC date. Never: repository names/paths, org names, branches, commit
183
+ SHAs or messages, raw trailer strings, emails, or timestamps finer than
184
+ a date. Unrecognized provider spellings are bucketed as "Unrecognized"
185
+ in public assets (see the raw values locally with `aggregate -v`).
186
+ - `aiprofile aggregate` prints exactly what would be published — it *is*
187
+ the privacy preview.
188
+ - Honest limit: aggregate-only publication is **identity redaction, not
189
+ anonymity**. Repository names never appear, but repeatedly published
190
+ exact counts let an observer infer *when* your aggregate-only activity
191
+ changed and which provider appeared. Full threat model:
192
+ [`docs/PRIVACY.md`](docs/PRIVACY.md). Output labels are policy-based
193
+ ("explicitly publishable" / "aggregate-only"), never claims about
194
+ GitHub visibility.
195
+ - Do not sync `~/.aiprofile` into published dotfiles (it holds a salt and
196
+ private repository paths). Deleting that directory deletes all local
197
+ data; generated `dist/` files are yours to remove separately. On
198
+ POSIX the directory and files are owner-only (0700/0600); Windows
199
+ has no equivalent permission bits, so there `os.chmod` is a
200
+ documented no-op — the data still never leaves your machine.
201
+
202
+ ## Metrics, honestly labeled
203
+
204
+ - **AI-attributed commits** — unique commits with ≥1 explicit AI actor
205
+ presence. Per-provider counts may sum to more than this (multi-AI
206
+ commits) and are labeled as provider-attributed commits, never as
207
+ unique totals. Evidence chips state their population; percentages state
208
+ their denominator; active days are commit author dates.
209
+ - **Evidence quality** is first-class: `verified > declared > imported >
210
+ inferred > unknown`. v0.1 produces `declared` (trailers) and `unknown`.
211
+
212
+ ## License
213
+
214
+ MIT — see [LICENSE](LICENSE). Contributions welcome under
215
+ [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,32 @@
1
+ aiprofile/__init__.py,sha256=PQdSKLq8i3j8kBTe1VaLW1cp6EGcfWz_m24jFCZh9Qg,263
2
+ aiprofile/__main__.py,sha256=vfaB5IsNzfve4aiagsrV2m0JgLsUPhWmoASdomBUJIg,92
3
+ aiprofile/aggregate.py,sha256=3ELSLHGJ7zrtlhFa6IKjLZSvM9QJaSsf1AiGgKnl3LM,15276
4
+ aiprofile/cli.py,sha256=cODSlPQPCulZIwFoFfrMgB7bU1TSJF5i8wI334QOILs,10820
5
+ aiprofile/config.py,sha256=5L7WSVLn1HlNI0_hZycLod4skbYevJTqhLO9PwgmUoQ,8930
6
+ aiprofile/errors.py,sha256=fvh6rvg4LQcvc1yCyl_OQeHIEezflJa4X52tDYZZqUI,905
7
+ aiprofile/export.py,sha256=fJlz4kIfUJyqp6CjJy0qohfyI-QgudLKBr8OQnskiwE,9175
8
+ aiprofile/gitio.py,sha256=ZWA0ee5TDnC0LtOm21Nrrc8WlEcTnTJnEZKTOUZyJqs,13743
9
+ aiprofile/privacy.py,sha256=U1Q6SVEB6TexoNojy2785nOKnhd8EySi_qaifEEz5Lk,9662
10
+ aiprofile/registry.py,sha256=Wz2HL32C9OtsGZFIqJR0ZJxrMxuuNQgSNdOrTQb019I,8126
11
+ aiprofile/scanner.py,sha256=nQVSCrJs8ecLIvaei3wYBFL_oplAIPD7wnfO5NoyL7Y,7930
12
+ aiprofile/viz.py,sha256=8_WPOZRZdAQ9R-6xmwCBvmSe-hwGQU1yBN0xpO--I9M,23033
13
+ aiprofile/adapters/__init__.py,sha256=wi-dha9F06tN8T5VWTiLlLQHEHKBd3ZL3f1TRirFLI8,72
14
+ aiprofile/adapters/trailers.py,sha256=md9FwhU8OmkyhSoYo9yoD4jKL8rOIS52dVQ-GCx85Yw,14856
15
+ aiprofile/render/__init__.py,sha256=N-u5nMqIglDmNbfXluwpu0MrxInD7bN4k30ihNPgnZ8,85
16
+ aiprofile/render/badge_svg.py,sha256=-xcmzlxIxBMHimnpIDOBk1jWtZI0KmwC-iwugf_kSaM,3945
17
+ aiprofile/render/brand.py,sha256=3--vrXbvbjHbLuS3VAvRMQzzq6ykNay63FZpLlZ556Y,31936
18
+ aiprofile/render/heatmap_svg.py,sha256=ox2kYhfEKwLeBOKWFmj92JBFIA1EXPgutadrrIbJ_tk,13908
19
+ aiprofile/render/summary_svg.py,sha256=txXbwFtSB6QZQy8Jvg4pNUjfW8EQfaYuJ2x6-qF3n1A,48360
20
+ aiprofile/render/themes.py,sha256=_gKtBEdoS5z-SMFD2TsHqysuFzboBaSJVjm58K9_r6U,2222
21
+ aiprofile/schema/__init__.py,sha256=rS15vj43L5HhKcdxQCBWt5cGBs92vtmgpssOPwwePhE,644
22
+ aiprofile/schema/event.py,sha256=KGM_vMFEStzdVE39Nyuy-sY513BGnevErnBE5J2b6cg,21026
23
+ aiprofile/schema/vocab.py,sha256=c6juMN4x_AOjH0Kl6JVyDREdQQFcg4d-HDUd7EZCeQ8,6394
24
+ aiprofile/storage/__init__.py,sha256=SVLUBpdIYNvyIE5f1HlvnYEFMOBuBuK_0UEntlrvCLM,72
25
+ aiprofile/storage/db.py,sha256=JFujTDhW4Q430jcAp7CAp9tn_eLtdcwfnyqfhfds6fE,5421
26
+ aiprofile/storage/migrations.py,sha256=TPgRIvbwPGZYrB_qhrNfDwHD4HVoec0IxHNU2zP5BQM,2501
27
+ aiprofile/storage/store.py,sha256=sj4CP43BEZEbpvB56tYfhzNjnCOgQ3akmIyD3l7Jaz0,6445
28
+ ai_profile_cli-0.3.0.dist-info/METADATA,sha256=ZYmlw4q4j2RBIzD6I41CooKAswQl72AFn4D_Q1yW-1w,9702
29
+ ai_profile_cli-0.3.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
30
+ ai_profile_cli-0.3.0.dist-info/entry_points.txt,sha256=LhYqGd80ct4ObueO1tsAQbxhYZUO2z7ef4u_bcKFbFw,49
31
+ ai_profile_cli-0.3.0.dist-info/licenses/LICENSE,sha256=HgNCKf1GuKd0i84QnjvMB44YC7BeJ25RMDniuamQxjc,1068
32
+ ai_profile_cli-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aiprofile = aiprofile.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wenyu Chiou
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
aiprofile/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """ai-profile: local-first, profile-level AI collaboration analytics for Git repositories."""
2
+
3
+ __version__ = "0.3.0"
4
+
5
+ #: ACE (AI Collaboration Event) schema version written into every event.
6
+ #: Governed by docs/schema.md and ADR-012.
7
+ ACE_SCHEMA_VERSION = "0.2.0"
aiprofile/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """python -m aiprofile entry point."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Source adapters: provider-specific records → ParticipationSpec."""
@@ -0,0 +1,381 @@
1
+ """Git trailer adapter: trailer lines → ParticipationSpec (ADR-005).
2
+
3
+ INTERFACE PINNED BY THE ORCHESTRATOR — work package B implements
4
+ parse_commit_trailers without changing any signature or dataclass here.
5
+ Normative behavior: ADR-005 (grouping rule + carved exceptions),
6
+ schema.md sections 5 and 10, ADR-013 (registry matching).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass
14
+
15
+ from ..registry import match_coauthor, normalize_provider, resolve_tool
16
+ from ..schema.event import ProvenanceSource
17
+ from ..schema.vocab import ActorType, ContributionMode, EvidenceLevel, Role, SourceType
18
+
19
+ #: Recognized AI-* trailer keys (case-insensitive; ADR-005).
20
+ AI_TRAILER_KEYS = frozenset(
21
+ {"ai-provider", "ai-model", "ai-tool", "ai-role", "ai-mode", "ai-reviewed-by", "ai-schema"}
22
+ )
23
+ COAUTHOR_KEY = "co-authored-by"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ParticipationSpec:
28
+ """One AI actor presence (or human declaration) parsed from explicit
29
+ trailer evidence. The scanner turns specs into validated ACE events;
30
+ the adapter never invents data."""
31
+
32
+ actor_type: ActorType
33
+ provider: str | None
34
+ provider_raw: str | None
35
+ model: str | None # lowercase(trim(model_raw)) — no registry (schema.md 10)
36
+ model_raw: str | None
37
+ tool: str | None
38
+ tool_raw: str | None
39
+ roles: tuple[Role, ...]
40
+ contribution_mode: ContributionMode | None
41
+ human_reviewed: bool | None
42
+ source: ProvenanceSource
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ParseWarning:
47
+ """Diagnostics-hygiene-safe warning (architecture.md section 10): `code`
48
+ and `trailer_key` are always safe to print; `local_detail` may quote
49
+ trailer values and is shown only under --verbose."""
50
+
51
+ code: str # unknown-role | malformed-mode | malformed-reviewed-by |
52
+ # incomplete-group | contradictory-group
53
+ trailer_key: str
54
+ local_detail: str | None = None
55
+
56
+
57
+ def parse_commit_trailers(
58
+ trailer_lines: Sequence[str],
59
+ ) -> tuple[list[ParticipationSpec], list[ParseWarning]]:
60
+ """Parse one commit's trailer block (ordered lines from
61
+ %(trailers:only,unfold)) into actor-presence specs.
62
+
63
+ Contract (implemented by work package B):
64
+ - Trailer lines split on the first ':'; keys matched case-insensitively;
65
+ non-trailer-shaped lines ignored.
66
+ - AI-* grouping: iterate AI-* trailers in order; a repeated key inside
67
+ the current group closes it and starts a new one (ADR-005).
68
+ - A group yields an AI spec when AI-Provider is present (registry
69
+ normalization; unrecognized → provider None + provider_raw kept) or
70
+ AI-Tool resolves via registry.resolve_tool (which supplies the
71
+ provider). Groups with neither are dropped: warning `incomplete-group`.
72
+ - `AI-Mode: Human-Only` in a group with NO AI-Provider/AI-Tool key →
73
+ one HUMAN spec (all identity fields None, evidence declared). With
74
+ any AI-Provider or AI-Tool key present (recognized by the registry
75
+ or not) → drop the whole group: warning `contradictory-group`.
76
+ - AI-Role: comma-separated tokens → Role vocabulary; unknown tokens
77
+ dropped with warning `unknown-role`; empty result = no roles.
78
+ - AI-Mode: vocabulary values matched case-insensitively with '-'/'_'
79
+ equivalence ("AI-Assisted" → ai_assisted); unparseable → None +
80
+ warning `malformed-mode`. Missing mode on an AI group → None (the
81
+ group itself declares AI; schema.md section 5).
82
+ - AI-Reviewed-By: "human" → True, "none" → False, else None + warning
83
+ `malformed-reviewed-by`.
84
+ - AI-Schema: informational only, never affects parsing.
85
+ - Co-authored-by: value shaped "Name <email>"; registry.match_coauthor
86
+ (exact email + optional name-prefix condition) → one AI spec with
87
+ source git_trailer_coauthor, model/model_raw None, provider_raw set
88
+ to the display name. Non-matching co-authors are ignored silently
89
+ (they are presumed human co-authors, not evidence of anything).
90
+ - Sources: git_trailer / git_trailer_coauthor, evidence declared,
91
+ source_reference = the matched trailer key (never a value).
92
+ - Never raises on message content; returns ([], []) for no evidence.
93
+ - Same provider+tool in several groups is fine — the scanner merges by
94
+ identity; the parser does not deduplicate.
95
+ """
96
+ specs: list[ParticipationSpec] = []
97
+ warnings: list[ParseWarning] = []
98
+
99
+ # `pending` holds, in message order, either an in-progress AI-* group
100
+ # or an already-resolved co-author ParticipationSpec. Co-author lines
101
+ # never depend on later lines so they are resolved immediately; AI-*
102
+ # groups are finalized only after the loop so a group left open at
103
+ # end-of-input is handled identically to one closed early by a repeated
104
+ # key (ADR-005). A group tracks VALUES and KEY PRESENCE separately
105
+ # (gate M-03): an empty-valued `AI-Provider:` contributes no value but
106
+ # its presence still matters to the Human-Only contradiction rule.
107
+ pending: list[_Group | ParticipationSpec] = []
108
+ current_group: _Group | None = None
109
+
110
+ for line in trailer_lines:
111
+ key, value = _split_trailer_line(line)
112
+ if key is None:
113
+ continue
114
+
115
+ if key == COAUTHOR_KEY:
116
+ if value:
117
+ coauthor_spec = _parse_coauthor(value)
118
+ if coauthor_spec is not None:
119
+ pending.append(coauthor_spec)
120
+ continue
121
+
122
+ if key not in AI_TRAILER_KEYS:
123
+ # Non-AI, non-co-author trailers are ignored without affecting
124
+ # grouping.
125
+ continue
126
+
127
+ if current_group is None:
128
+ current_group = _Group()
129
+ pending.append(current_group)
130
+ elif value and key in current_group.values:
131
+ # Only a NON-EMPTY repeat closes the group (ADR-005): an
132
+ # empty-valued repeat contributes presence without splitting.
133
+ current_group = _Group()
134
+ pending.append(current_group)
135
+ current_group.present.add(key)
136
+ if value:
137
+ current_group.values[key] = value
138
+
139
+ for item in pending:
140
+ if isinstance(item, _Group):
141
+ spec, group_warnings = _finalize_group(item)
142
+ warnings.extend(group_warnings)
143
+ if spec is not None:
144
+ specs.append(spec)
145
+ else:
146
+ specs.append(item)
147
+
148
+ return specs, warnings
149
+
150
+
151
+ class _Group:
152
+ """One AI-* trailer group: parsed non-empty values plus the set of
153
+ recognized keys that APPEARED (even with empty values — gate M-03)."""
154
+
155
+ __slots__ = ("values", "present")
156
+
157
+ def __init__(self) -> None:
158
+ self.values: dict[str, str] = {}
159
+ self.present: set[str] = set()
160
+
161
+
162
+ def _split_trailer_line(line: str) -> tuple[str | None, str]:
163
+ """Split one already-unfolded trailer line on the first ':'. A line with
164
+ no ':' yields (None, "") so the caller ignores it; the key is lowercased
165
+ and both sides are stripped (docstring contract)."""
166
+ key_part, sep, value_part = line.partition(":")
167
+ if not sep:
168
+ return None, ""
169
+ return key_part.strip().lower(), value_part.strip()
170
+
171
+
172
+ def _finalize_group(group: _Group) -> tuple[ParticipationSpec | None, list[ParseWarning]]:
173
+ """Turn one collected AI-* trailer group into a spec, or drop it with a
174
+ group-level warning (ADR-005 grouping rule + the two carved Human-Only
175
+ exceptions; schema.md section 5)."""
176
+ values = group.values
177
+ provider_raw_value = values.get("ai-provider")
178
+ tool_raw_value = values.get("ai-tool")
179
+ tool_resolved = resolve_tool(tool_raw_value) if tool_raw_value is not None else None
180
+ has_anchor = provider_raw_value is not None or tool_resolved is not None
181
+
182
+ mode_raw_value = values.get("ai-mode")
183
+ mode_candidate = _normalize_mode(mode_raw_value) if mode_raw_value is not None else None
184
+ is_human_only = mode_candidate is ContributionMode.HUMAN_ONLY
185
+
186
+ if is_human_only:
187
+ # Contradiction keys on KEY PRESENCE — even an empty-valued
188
+ # `AI-Provider:`/`AI-Tool:` line next to Human-Only is a
189
+ # contradictory declaration, never a clean human record
190
+ # (ADR-005; gate M-03).
191
+ if {"ai-provider", "ai-tool"} & group.present:
192
+ return None, [ParseWarning("contradictory-group", "ai-mode")]
193
+ return _build_human_spec(values)
194
+
195
+ if not has_anchor:
196
+ anchor_key = next(iter(values), None) or next(iter(sorted(group.present)))
197
+ return None, [ParseWarning("incomplete-group", anchor_key)]
198
+
199
+ return _build_ai_spec(values, provider_raw_value, tool_raw_value, tool_resolved)
200
+
201
+
202
+ def _build_human_spec(group: dict[str, str]) -> tuple[ParticipationSpec, list[ParseWarning]]:
203
+ """AI-Mode: Human-Only with no AI provider/tool anchor → one HUMAN spec,
204
+ all six identity fields None (schema.md section 2)."""
205
+ warnings: list[ParseWarning] = []
206
+ roles, role_warnings = _parse_roles(group.get("ai-role"))
207
+ warnings.extend(role_warnings)
208
+ human_reviewed, reviewed_warning = _parse_reviewed_by(group.get("ai-reviewed-by"))
209
+ if reviewed_warning is not None:
210
+ warnings.append(reviewed_warning)
211
+
212
+ spec = ParticipationSpec(
213
+ actor_type=ActorType.HUMAN,
214
+ provider=None,
215
+ provider_raw=None,
216
+ model=None,
217
+ model_raw=None,
218
+ tool=None,
219
+ tool_raw=None,
220
+ roles=roles,
221
+ contribution_mode=ContributionMode.HUMAN_ONLY,
222
+ human_reviewed=human_reviewed,
223
+ source=ProvenanceSource(
224
+ source_type=SourceType.GIT_TRAILER,
225
+ evidence_level=EvidenceLevel.DECLARED,
226
+ source_reference="ai-mode",
227
+ ),
228
+ )
229
+ return spec, warnings
230
+
231
+
232
+ def _build_ai_spec(
233
+ group: dict[str, str],
234
+ provider_raw_value: str | None,
235
+ tool_raw_value: str | None,
236
+ tool_resolved: tuple[str, str] | None,
237
+ ) -> tuple[ParticipationSpec, list[ParseWarning]]:
238
+ """A group anchored by AI-Provider and/or a resolvable AI-Tool → one AI
239
+ spec (schema.md section 10 normalization; explicit AI-Provider always
240
+ wins the provider fields over a tool's owning provider)."""
241
+ warnings: list[ParseWarning] = []
242
+
243
+ if provider_raw_value is not None:
244
+ provider = normalize_provider(provider_raw_value)
245
+ provider_raw = provider_raw_value
246
+ elif tool_resolved is not None:
247
+ provider = tool_resolved[1]
248
+ provider_raw = None
249
+ else:
250
+ provider = None
251
+ provider_raw = None
252
+
253
+ if tool_raw_value is not None:
254
+ tool = tool_resolved[0] if tool_resolved is not None else None
255
+ tool_raw = tool_raw_value
256
+ else:
257
+ tool = None
258
+ tool_raw = None
259
+
260
+ model_raw_value = group.get("ai-model")
261
+ if model_raw_value is not None:
262
+ model = model_raw_value.strip().lower()
263
+ model_raw = model_raw_value
264
+ else:
265
+ model = None
266
+ model_raw = None
267
+
268
+ roles, role_warnings = _parse_roles(group.get("ai-role"))
269
+ warnings.extend(role_warnings)
270
+
271
+ mode_raw_value = group.get("ai-mode")
272
+ mode: ContributionMode | None = None
273
+ if mode_raw_value is not None:
274
+ mode = _normalize_mode(mode_raw_value)
275
+ if mode is None:
276
+ warnings.append(ParseWarning("malformed-mode", "ai-mode", local_detail=mode_raw_value))
277
+
278
+ human_reviewed, reviewed_warning = _parse_reviewed_by(group.get("ai-reviewed-by"))
279
+ if reviewed_warning is not None:
280
+ warnings.append(reviewed_warning)
281
+
282
+ source_reference = "ai-provider" if "ai-provider" in group else "ai-tool"
283
+ spec = ParticipationSpec(
284
+ actor_type=ActorType.AI,
285
+ provider=provider,
286
+ provider_raw=provider_raw,
287
+ model=model,
288
+ model_raw=model_raw,
289
+ tool=tool,
290
+ tool_raw=tool_raw,
291
+ roles=roles,
292
+ contribution_mode=mode,
293
+ human_reviewed=human_reviewed,
294
+ source=ProvenanceSource(
295
+ source_type=SourceType.GIT_TRAILER,
296
+ evidence_level=EvidenceLevel.DECLARED,
297
+ source_reference=source_reference,
298
+ ),
299
+ )
300
+ return spec, warnings
301
+
302
+
303
+ def _normalize_mode(raw: str) -> ContributionMode | None:
304
+ """Lowercase, '-'/' ' -> '_', then ContributionMode lookup; None when the
305
+ normalized token is not a recognized mode (docstring contract)."""
306
+ normalized = raw.strip().lower().replace("-", "_").replace(" ", "_")
307
+ try:
308
+ return ContributionMode(normalized)
309
+ except ValueError:
310
+ return None
311
+
312
+
313
+ def _parse_roles(raw: str | None) -> tuple[tuple[Role, ...], list[ParseWarning]]:
314
+ """Comma-separated AI-Role tokens → Role vocabulary; unknown tokens are
315
+ dropped with an `unknown-role` warning; missing key or empty result is
316
+ `()` (docstring contract)."""
317
+ if raw is None:
318
+ return (), []
319
+ warnings: list[ParseWarning] = []
320
+ roles: list[Role] = []
321
+ for token in raw.split(","):
322
+ token = token.strip()
323
+ if not token:
324
+ continue
325
+ try:
326
+ role = Role(token.lower())
327
+ except ValueError:
328
+ warnings.append(ParseWarning("unknown-role", "ai-role", local_detail=token))
329
+ continue
330
+ roles.append(role)
331
+ return tuple(roles), warnings
332
+
333
+
334
+ def _parse_reviewed_by(raw: str | None) -> tuple[bool | None, ParseWarning | None]:
335
+ """"human" -> True, "none" -> False, anything else -> None + a
336
+ `malformed-reviewed-by` warning; a missing key -> None with no warning
337
+ (docstring contract)."""
338
+ if raw is None:
339
+ return None, None
340
+ normalized = raw.strip().lower()
341
+ if normalized == "human":
342
+ return True, None
343
+ if normalized == "none":
344
+ return False, None
345
+ return None, ParseWarning("malformed-reviewed-by", "ai-reviewed-by", local_detail=raw)
346
+
347
+
348
+ #: "Name <email>", tolerant of extra spaces around the name and brackets.
349
+ _COAUTHOR_RE = re.compile(r"^\s*(?P<name>.*?)\s*<\s*(?P<email>[^<>\s]+)\s*>\s*$")
350
+
351
+
352
+ def _parse_coauthor(value: str) -> ParticipationSpec | None:
353
+ """"Name <email>" -> AI spec via registry.match_coauthor; a malformed
354
+ shape (no "<email>") or no registry match is silently ignored
355
+ (docstring contract; ADR-013)."""
356
+ match = _COAUTHOR_RE.match(value)
357
+ if match is None:
358
+ return None
359
+ name = match.group("name").strip()
360
+ email = match.group("email").strip()
361
+ identity = match_coauthor(name, email)
362
+ if identity is None:
363
+ return None
364
+
365
+ return ParticipationSpec(
366
+ actor_type=ActorType.AI,
367
+ provider=identity.provider,
368
+ provider_raw=name,
369
+ model=None,
370
+ model_raw=None,
371
+ tool=identity.tool,
372
+ tool_raw=None,
373
+ roles=(),
374
+ contribution_mode=None,
375
+ human_reviewed=None,
376
+ source=ProvenanceSource(
377
+ source_type=SourceType.GIT_TRAILER_COAUTHOR,
378
+ evidence_level=EvidenceLevel.DECLARED,
379
+ source_reference=COAUTHOR_KEY,
380
+ ),
381
+ )