Skip to content

Commit 1d07b0b

Browse files
denn-gubskyclaude
andcommitted
Add --assume-untagged AGENT_NAME for projects with stripped/missing trailers
Real-world git histories have systematic trailer gaps: - GitHub squash-merges strip Co-Authored-By trailers from individual branch commits and replace them with a synthesized merge message. - Some Claude Code workflows commit via shell after the AI helps draft, without invoking the trailer-adding commit flow. - Projects often started before the trailer convention existed; older commits look manual by inspection but were AI-paired by intent. Without a flag, the analyzer can only report what it can prove from trailers — so a project the developer "knows was end-to-end AI-assisted" might show 21% AI commits because the other 79% were squash-merged or pre-trailer. The 21% is technically correct but isn't the question the user wanted answered. The new flag closes that gap. `--assume-untagged "Claude Opus"` re-attributes every untagged non-merge commit to that agent (which must exist in the registry; merge commits are excluded because they almost never represent direct authorship). Also configurable in YAML at `agents.assume_untagged`. Implementation: - agent_detector.attribute_untagged() does the post-pass over the commits DataFrame, looking up the named agent in the registry and appending it to `agents`/`primary_agent`/`agent_vendors`. - git_extractor now records each commit's parent SHAs and an `is_merge` boolean so the post-pass can skip merges. - Config gains `agents.assume_untagged: str | None`. - CLI gains `--assume-untagged AGENT_NAME` with help text pointing users at `list-agents` for valid names. - Tests cover the happy path and the unknown-agent error. Verified: on a 3-commit fixture with one Claude trailer, baseline detection finds 1 AI commit; with `--assume-untagged "Claude Opus"`, all 3 are attributed to Claude Opus. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dffd05a commit 1d07b0b

6 files changed

Lines changed: 129 additions & 9 deletions

File tree

src/ai_dev_effectiveness/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ def analyze(
128128
)
129129
commits = agent_detector.detect_agents(commits, registry)
130130

131+
if cfg.agents.assume_untagged:
132+
commits = agent_detector.attribute_untagged(
133+
commits, cfg.agents.assume_untagged, registry,
134+
)
135+
131136
# 3. classify domains
132137
patterns = cfg.domain_patterns()
133138
if not patterns:

src/ai_dev_effectiveness/agent_detector.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,66 @@ def vendor_color_map(registry: list[AgentSignature]) -> dict[str, str]:
142142
return out
143143

144144

145+
def attribute_untagged(
146+
commits: pd.DataFrame,
147+
agent_name: str,
148+
registry: list[AgentSignature],
149+
) -> pd.DataFrame:
150+
"""Re-attribute commits with no detected agent to a known agent.
151+
152+
Use this when you KNOW the project was AI-assisted but commits don't
153+
carry the trailer (squash-merges strip them; some workflows commit via
154+
`git commit -m` without invoking the AI tool's commit flow; etc.).
155+
156+
The named agent must already exist in `registry`. Lookup is by exact
157+
`name` match; if no match, raises ValueError with the available names.
158+
159+
Merge commits are NOT re-attributed by default — they almost never
160+
represent direct authorship and would inflate the AI commit count.
161+
"""
162+
if commits.empty or "primary_agent" not in commits.columns:
163+
return commits
164+
165+
sig = next((s for s in registry if s.name == agent_name), None)
166+
if sig is None:
167+
names = sorted({s.name for s in registry})
168+
raise ValueError(
169+
f"Unknown agent {agent_name!r} in --assume-untagged. "
170+
f"Pick one of: {', '.join(names)}"
171+
)
172+
173+
out = commits.copy()
174+
untagged_mask = out["primary_agent"].isna()
175+
if "is_merge" in out.columns:
176+
untagged_mask &= ~out["is_merge"]
177+
178+
if not untagged_mask.any():
179+
return out
180+
181+
# Append the assumed agent to existing agent lists (rather than overwriting)
182+
# so a future merge-inheritance pass can still distinguish detected vs
183+
# inherited attributions. For untagged rows, agents starts empty.
184+
new_agents_col = out["agents"].copy()
185+
new_vendors_col = out["agent_vendors"].copy() if "agent_vendors" in out.columns else None
186+
187+
for idx in out.index[untagged_mask]:
188+
existing = list(new_agents_col.loc[idx] or [])
189+
if sig.name not in existing:
190+
existing.append(sig.name)
191+
new_agents_col.loc[idx] = existing
192+
if new_vendors_col is not None:
193+
existing_v = list(new_vendors_col.loc[idx] or [])
194+
if sig.vendor not in existing_v:
195+
existing_v.append(sig.vendor)
196+
new_vendors_col.loc[idx] = sorted(existing_v)
197+
198+
out["agents"] = new_agents_col
199+
if new_vendors_col is not None:
200+
out["agent_vendors"] = new_vendors_col
201+
out.loc[untagged_mask, "primary_agent"] = sig.name
202+
return out
203+
204+
145205
def write_user_settings_template(target: Path) -> None:
146206
"""Stub for future use; not called yet."""
147207
target.write_text("# User-defined agent signatures go here.\nagents:\n extend: []\n")

src/ai_dev_effectiveness/cli.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,18 @@ def main() -> None:
4141
@click.option("--judge-model", default=None, help="Override the judge model name.")
4242
@click.option("--no-ast-index", is_flag=True,
4343
help="Skip the `ast-index rebuild` step before the judge runs.")
44+
@click.option("--assume-untagged", "assume_untagged", default=None,
45+
metavar="AGENT_NAME",
46+
help="Attribute every untagged non-merge commit to AGENT_NAME "
47+
"(e.g. 'Claude Opus'). Use when you know the work was AI-"
48+
"assisted but the trailer is missing (squash-merges strip "
49+
"trailers; pre-trailer-convention commits look manual). "
50+
"Run `list-agents` to see valid names.")
4451
def analyze_cmd(target: str, config_path: str | None, out_dir: str | None,
4552
workspace: str | None, fmt: str,
4653
judge_provider: str | None, judge_all: bool, judge_dry_run: bool,
47-
judge_model: str | None, no_ast_index: bool) -> None:
54+
judge_model: str | None, no_ast_index: bool,
55+
assume_untagged: str | None) -> None:
4856
"""Analyze the git repo at TARGET (defaults to current directory).
4957
5058
The analyzer is read-only with respect to TARGET — no files are created
@@ -61,6 +69,8 @@ def analyze_cmd(target: str, config_path: str | None, out_dir: str | None,
6169
cfg.judge.judge_all = True
6270
if judge_model is not None:
6371
cfg.judge.model = judge_model
72+
if assume_untagged is not None:
73+
cfg.agents.assume_untagged = assume_untagged
6474

6575
if judge_dry_run:
6676
_run_judge_dry_run(target, cfg, workspace, out_dir)

src/ai_dev_effectiveness/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ class AgentExtension(BaseModel):
7171
class AgentsConfig(BaseModel):
7272
extend: list[AgentExtension] = Field(default_factory=list)
7373
override: list[AgentExtension] | None = None # if set, replaces the built-in registry
74+
# When the user knows the project was AI-assisted but commits don't carry
75+
# the trailer (squash-merges, pre-trailer-convention work, manual-commit
76+
# workflows), set this to a registry agent name (e.g. "Claude Opus") to
77+
# attribute every untagged non-merge commit to that agent.
78+
assume_untagged: str | None = None
7479

7580

7681
class JudgeConfig(BaseModel):

src/ai_dev_effectiveness/git_extractor.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
# Sentinel that's effectively impossible to appear in a real commit body.
1717
_DELIM = "<<<COMMIT::AIDE>>>"
1818
_BODY_END = "<<<BODY_END::AIDE>>>"
19-
_LOG_FORMAT = f"{_DELIM}%n%H%n%ai%n%an%n%ae%n%s%n%b%n{_BODY_END}"
19+
# %P = parent SHAs (space-separated, multiple parents → merge commit).
20+
_LOG_FORMAT = f"{_DELIM}%n%H%n%P%n%ai%n%an%n%ae%n%s%n%b%n{_BODY_END}"
2021

2122
# Trailer regex: matches "Token: value" lines at the end of a commit body
2223
# (RFC 5322-ish). git's own trailer parser is more permissive but this
@@ -93,23 +94,24 @@ def extract_commits(repo_dir: Path, branch: str | None = None) -> pd.DataFrame:
9394
continue
9495

9596
sha = lines[0].strip()
96-
date_str = lines[1].strip()
97-
author_name = lines[2].strip()
98-
author_email = lines[3].strip()
99-
subject = lines[4].strip()
97+
parents = [p for p in lines[1].strip().split() if p]
98+
date_str = lines[2].strip()
99+
author_name = lines[3].strip()
100+
author_email = lines[4].strip()
101+
subject = lines[5].strip()
100102

101103
# Find the body terminator and the numstat tail.
102104
body_end_idx = None
103-
for i, line in enumerate(lines[5:], start=5):
105+
for i, line in enumerate(lines[6:], start=6):
104106
if line.strip() == _BODY_END:
105107
body_end_idx = i
106108
break
107109

108110
if body_end_idx is None:
109-
body = "\n".join(lines[5:]).strip()
111+
body = "\n".join(lines[6:]).strip()
110112
numstat_lines: list[str] = []
111113
else:
112-
body = "\n".join(lines[5:body_end_idx]).strip()
114+
body = "\n".join(lines[6:body_end_idx]).strip()
113115
numstat_lines = lines[body_end_idx + 1:]
114116

115117
insertions = 0
@@ -130,6 +132,8 @@ def extract_commits(repo_dir: Path, branch: str | None = None) -> pd.DataFrame:
130132

131133
rows.append({
132134
"sha": sha,
135+
"parents": parents,
136+
"is_merge": len(parents) > 1,
133137
"date": pd.to_datetime(date_str, utc=True).tz_localize(None),
134138
"author_name": author_name,
135139
"author_email": author_email,

tests/test_smoke.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,39 @@ def test_target_equals_workspace_keeps_legacy_layout(git_fixture, tmp_path):
158158
], repo_dir=tmp_path / "single-repo")
159159
result = analyze(repo=repo, workspace=repo)
160160
assert result.out_dir == repo
161+
162+
163+
def test_assume_untagged_attributes_to_named_agent(git_fixture):
164+
"""`assume_untagged` re-attributes non-merge commits without a trailer."""
165+
repo = git_fixture(commits=[
166+
FakeCommit(files={"a.py": "1\n"}, subject="manual work"), # no trailer
167+
FakeCommit(files={"a.py": "2\n"}, subject="claude-paired",
168+
extra_trailers={"Co-Authored-By": "Claude Opus 4.7 <noreply@anthropic.com>"}),
169+
FakeCommit(files={"a.py": "3\n"}, subject="another manual"), # no trailer
170+
])
171+
172+
# Without the flag: 1/3 detected as AI-assisted.
173+
baseline = analyze(repo=repo)
174+
assert baseline.metrics.headline["n_ai_assisted"] == 1
175+
176+
# With assume_untagged="Claude Opus": all 3 attributed to Claude Opus.
177+
cfg = Config()
178+
cfg.agents.assume_untagged = "Claude Opus"
179+
result = analyze(repo=repo, config=cfg)
180+
assert result.metrics.headline["n_ai_assisted"] == 3
181+
by_agent = result.metrics.by_agent.set_index("agent")["commits"].to_dict()
182+
assert by_agent.get("Claude Opus", 0) == 3
183+
184+
185+
def test_assume_untagged_unknown_agent_raises(git_fixture):
186+
"""An unknown agent name raises with the list of valid names."""
187+
repo = git_fixture(commits=[FakeCommit(files={"a.py": "1\n"}, subject="x")])
188+
cfg = Config()
189+
cfg.agents.assume_untagged = "ImaginaryBot"
190+
try:
191+
analyze(repo=repo, config=cfg)
192+
except ValueError as e:
193+
assert "ImaginaryBot" in str(e)
194+
assert "Claude Opus" in str(e) # listed as a valid option
195+
else:
196+
raise AssertionError("expected ValueError")

0 commit comments

Comments
 (0)