gitpulse-ai 0.1.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.
git_pulse/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from litellm import completion
6
+
7
+ from git_pulse.analyst.models import AnalystReport
8
+ from git_pulse.analyst.prompts import build_system_prompt, build_user_prompt
9
+
10
+
11
+ class AnalystEngine:
12
+ def __init__(self, model: str):
13
+ self.model = model
14
+
15
+ def analyze(self, report_dict: dict) -> AnalystReport:
16
+ """Send collector report to LLM and parse the response into an AnalystReport."""
17
+ system_prompt = build_system_prompt()
18
+ user_prompt = build_user_prompt(report_dict)
19
+
20
+ messages = [
21
+ {"role": "system", "content": system_prompt},
22
+ {"role": "user", "content": user_prompt},
23
+ ]
24
+
25
+ response = completion(model=self.model, messages=messages)
26
+ content = response.choices[0].message.content
27
+
28
+ try:
29
+ data = json.loads(content)
30
+ return AnalystReport.from_dict(data)
31
+ except (json.JSONDecodeError, KeyError):
32
+ return self._retry(messages, content)
33
+
34
+ def _retry(self, messages: list[dict], bad_content: str) -> AnalystReport:
35
+ """Retry with a correction prompt after malformed JSON."""
36
+ messages = messages + [
37
+ {"role": "assistant", "content": bad_content},
38
+ {
39
+ "role": "user",
40
+ "content": "Your response was not valid JSON. Please respond with ONLY valid JSON matching the schema from the system prompt. No markdown, no explanation.",
41
+ },
42
+ ]
43
+
44
+ response = completion(model=self.model, messages=messages)
45
+ content = response.choices[0].message.content
46
+
47
+ try:
48
+ data = json.loads(content)
49
+ return AnalystReport.from_dict(data)
50
+ except (json.JSONDecodeError, KeyError):
51
+ return AnalystReport(
52
+ summary="Failed to parse LLM response. Run with --verbose to see raw collector data.",
53
+ insights=[],
54
+ top_actions=[],
55
+ )
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+
4
+ @dataclass
5
+ class Insight:
6
+ category: str
7
+ title: str
8
+ severity: str
9
+ evidence: list[str]
10
+ recommendation: str
11
+
12
+ @dataclass
13
+ class AnalystReport:
14
+ summary: str
15
+ insights: list[Insight]
16
+ top_actions: list[str]
17
+
18
+ @classmethod
19
+ def from_dict(cls, data: dict) -> AnalystReport:
20
+ insights = [
21
+ Insight(
22
+ category=i["category"],
23
+ title=i["title"],
24
+ severity=i["severity"],
25
+ evidence=i["evidence"],
26
+ recommendation=i["recommendation"],
27
+ )
28
+ for i in data.get("insights", [])
29
+ ]
30
+ return cls(
31
+ summary=data["summary"],
32
+ insights=insights,
33
+ top_actions=data.get("top_actions", []),
34
+ )
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+ import json
3
+
4
+ SYSTEM_PROMPT = """You are GitPulse, a development workflow analyst. You receive a structured report of git repository activity and produce actionable insights.
5
+
6
+ For each category, produce 0-N insights ranked by impact. Each insight must include:
7
+ - "category": one of the 5 categories below
8
+ - "title": short descriptive title
9
+ - "severity": "high", "medium", or "low"
10
+ - "evidence": list of strings citing specific files, line ranges, commit patterns
11
+ - "recommendation": one concrete, actionable next step
12
+
13
+ Categories:
14
+ 1. REWORK_REDUCTION — Patterns where code was rewritten multiple times. What went wrong and how to get it right faster.
15
+ 2. AGENT_EFFECTIVENESS — If attribution data is present, how effectively are agents being used? Where do they struggle or excel? Skip if no attribution data.
16
+ 3. CODEBASE_HEALTH — Chronic hotspots, architectural issues causing repeated churn, files needing refactoring or decomposition.
17
+ 4. PROMPT_GUIDANCE — This is the MOST IMPORTANT category. Produce detailed, specific, and actionable prompt engineering advice. Skip if no attribution data. See detailed instructions below.
18
+ 5. WORKFLOW_OPTIMIZATION — Session patterns, productivity signals, process improvements.
19
+
20
+ ## PROMPT_GUIDANCE — Detailed Instructions
21
+
22
+ This category must be HIGHLY SPECIFIC and ACTIONABLE. For each insight:
23
+
24
+ 1. **Diagnose the root cause**: Look at the rework patterns and diff snippets. What was the developer likely asking the agent to do? What went wrong — was the prompt too vague, missing constraints, missing context, requesting too much at once, or missing acceptance criteria?
25
+
26
+ 2. **Show a BAD prompt example**: Based on the rework pattern, write an example of the kind of vague/incomplete prompt that likely caused the rework. Infer this from the file names, the nature of changes, and the iteration pattern.
27
+
28
+ 3. **Show a GOOD prompt example**: Write a realistic, natural prompt the developer SHOULD have used instead. CRITICAL RULES for the better prompt:
29
+ - It must sound like something a developer would ACTUALLY type to an agent — conversational, not a specification document
30
+ - Developers should NOT be dictating method names, class hierarchies, or full implementation details — that defeats the purpose of using an agent
31
+ - Instead, the better prompt should POINT THE AGENT to existing code to learn from: "Look at how UserProvider is structured and follow the same pattern"
32
+ - Tell the agent what context to read first: "Read through services/ to understand our service layer before starting"
33
+ - Tell the agent what to ASK about: "If you're unsure about the state shape, ask me before implementing"
34
+ - Set constraints and boundaries: "Don't add persistence yet", "Use our existing theme", "Keep it under one file"
35
+ - Break big asks into smaller ones: "First just create the basic screen with a list, we'll add the edit flow next"
36
+ - Give the agent permission to ask questions rather than guess
37
+
38
+ 4. **Explain WHY the good prompt works**: What specifically about the improved prompt would have prevented the rework? Focus on how context pointers, scope constraints, and question-asking prevent the agent from guessing wrong.
39
+
40
+ Format each PROMPT_GUIDANCE insight's "recommendation" field as a multi-line string with these sections:
41
+
42
+ "recommendation": "PROBLEM: [What the developer likely prompted]\n\nBAD PROMPT EXAMPLE:\n```\n[example of the vague prompt that caused rework]\n```\n\nBETTER PROMPT EXAMPLE:\n```\n[detailed, specific prompt that would have avoided the rework]\n```\n\nWHY THIS WORKS: [2-3 sentences explaining what specifically prevents rework]"
43
+
44
+ Real examples of the kind of detail expected:
45
+
46
+ Example 1 (UI rework pattern):
47
+ "recommendation": "PROBLEM: Developer asked agent to 'build the user list screen' without pointing it to existing patterns or setting scope boundaries.\n\nBAD PROMPT EXAMPLE:\n```\nCreate a user list screen for the app that shows users and lets them add/remove entries.\n```\n\nBETTER PROMPT EXAMPLE:\n```\nCreate a user list screen with swipe-to-delete. Before you start, look at how ProductListScreen is built — follow the same widget + provider pattern. Use our existing UserProvider for state. For the list item card, check widgets/user_card.dart — reuse it if it fits, otherwise ask me what the card should show. Don't add the 'add user' flow yet, just the list view with empty state. If you're unsure about theming, check how other screens use AppColors.\n```\n\nWHY THIS WORKS: Points agent to existing code to learn patterns from, explicitly scopes to just the list (not CRUD), tells agent where to look for answers, and gives permission to ask rather than guess."
48
+
49
+ Example 2 (API rework pattern):
50
+ "recommendation": "PROBLEM: Developer asked agent to 'add API endpoints' without pointing to existing service patterns or specifying which services to wire up.\n\nBAD PROMPT EXAMPLE:\n```\nAdd REST endpoints for the payment service that handle transactions.\n```\n\nBETTER PROMPT EXAMPLE:\n```\nAdd a POST /api/payments endpoint to server/main.py. Look at how the existing /api/orders endpoint is structured and follow the same pattern for error handling and response format. Wire it up to the payment_service.py we already have. For the request schema, I need amount (float) and currency (string). Ask me if you're unsure about what the response should look like — don't guess. Also read models/schemas.py to see how we define Pydantic models.\n```\n\nWHY THIS WORKS: Points to an existing endpoint as a reference pattern, names the specific service to integrate, gives partial schema but tells the agent to ask about the rest, preventing the agent from making wrong guesses that get reworked."
51
+
52
+ Produce at LEAST 2-3 PROMPT_GUIDANCE insights when attribution data shows agent rework. Each must have the full BAD/BETTER prompt structure. These should be specific to the actual files and patterns in the data, not generic advice.
53
+
54
+ ## Response Format
55
+
56
+ If the report shows no attribution data (has_attribution_data is false), skip AGENT_EFFECTIVENESS and PROMPT_GUIDANCE entirely.
57
+
58
+ Respond ONLY with valid JSON matching this schema:
59
+ {
60
+ "summary": "2-3 sentence executive summary",
61
+ "insights": [
62
+ {
63
+ "category": "CATEGORY_NAME",
64
+ "title": "Short title",
65
+ "severity": "high|medium|low",
66
+ "evidence": ["specific evidence strings"],
67
+ "recommendation": "Concrete action to take (see PROMPT_GUIDANCE instructions for that category's format)"
68
+ }
69
+ ],
70
+ "top_actions": ["Top 3 most impactful things to do right now"]
71
+ }
72
+
73
+ Be specific. Reference actual file names, line ranges, and commit patterns from the data. Do not give generic advice."""
74
+
75
+ def build_system_prompt() -> str:
76
+ return SYSTEM_PROMPT
77
+
78
+ def build_user_prompt(report_dict: dict) -> str:
79
+ return f"Analyze this repository activity report and provide insights:\n\n{json.dumps(report_dict, indent=2)}"
git_pulse/cli.py ADDED
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from git_pulse.config import load_config
11
+
12
+ app = typer.Typer(
13
+ name="git-pulse",
14
+ help="Analyze git repo history for development hotspots with LLM-powered insights.",
15
+ no_args_is_help=True,
16
+ )
17
+
18
+ console = Console()
19
+
20
+
21
+ @app.command()
22
+ def version():
23
+ """Show version information."""
24
+ from git_pulse import __version__
25
+
26
+ console.print(f"git-pulse version {__version__}")
27
+
28
+
29
+ @app.command()
30
+ def analyze(
31
+ path: str = typer.Argument(".", help="Path to a git repository"),
32
+ days: Optional[int] = typer.Option(None, help="Analyze last N days of history"),
33
+ commits: Optional[int] = typer.Option(None, help="Analyze last N commits"),
34
+ branch: Optional[str] = typer.Option(None, help="Branch to analyze (default: current)"),
35
+ include: Optional[list[str]] = typer.Option(None, help="Only analyze files matching glob"),
36
+ exclude: Optional[list[str]] = typer.Option(None, help="Skip files matching glob"),
37
+ max_hotspots: Optional[int] = typer.Option(None, help="Max hotspots to send to LLM"),
38
+ model: Optional[str] = typer.Option(None, help="LiteLLM model string"),
39
+ json_output: bool = typer.Option(False, "--json", help="Output JSON instead of terminal"),
40
+ output: Optional[str] = typer.Option(None, help="Write report to file"),
41
+ verbose: bool = typer.Option(False, help="Show collector metrics before LLM analysis"),
42
+ config: Optional[str] = typer.Option(None, help="Path to config file"),
43
+ ) -> None:
44
+ """Analyze a git repository for development hotspots and get LLM-powered insights."""
45
+ repo_path = Path(path).resolve()
46
+
47
+ # Validate repo
48
+ if not repo_path.exists():
49
+ console.print(f"[red]Error:[/red] Path does not exist: {repo_path}")
50
+ raise typer.Exit(1)
51
+ if not (repo_path / ".git").exists():
52
+ console.print(f"[red]Error:[/red] Not a git repository: {repo_path}")
53
+ raise typer.Exit(1)
54
+
55
+ # Load config
56
+ cfg = load_config(config_path=config, repo_path=str(repo_path))
57
+ effective_days = days or (None if commits else cfg.default_days)
58
+ effective_max_hotspots = max_hotspots or cfg.max_hotspots
59
+ effective_model = model or cfg.model
60
+ effective_exclude = (exclude or []) + cfg.exclude
61
+
62
+ # Collect
63
+ from git_pulse.collector.git_history import GitHistoryCollector
64
+ from git_pulse.collector.hotspot_detector import HotspotDetector
65
+ from git_pulse.collector.metrics import MetricsCalculator
66
+ from git_pulse.collector.models import CollectorReport
67
+
68
+ if not json_output:
69
+ console.print("[dim]Collecting git history...[/dim]")
70
+
71
+ collector = GitHistoryCollector(str(repo_path), branch=branch)
72
+ commit_data = collector.collect(
73
+ days=effective_days,
74
+ commits=commits,
75
+ include=include,
76
+ exclude=effective_exclude,
77
+ )
78
+
79
+ if not commit_data:
80
+ console.print("[yellow]No commits found in the specified range.[/yellow]")
81
+ raise typer.Exit(0)
82
+
83
+ # Detect hotspots
84
+ detector = HotspotDetector(commit_data, max_hotspots=effective_max_hotspots)
85
+ hotspots = detector.detect()
86
+
87
+ # Calculate metrics
88
+ calc = MetricsCalculator(commit_data)
89
+ file_churn = calc.file_churn()
90
+ change_velocity = calc.change_velocity()
91
+ agent_human_ratio = calc.agent_human_ratio()
92
+ rework_rate = calc.rework_rate()
93
+ sessions = calc.sessions()
94
+
95
+ from datetime import datetime
96
+
97
+ timestamps = [datetime.fromisoformat(c["timestamp"]) for c in commit_data]
98
+
99
+ collector_report = CollectorReport(
100
+ repo_path=str(repo_path),
101
+ branch=branch or collector.branch,
102
+ commit_range=(commit_data[-1]["hash"][:8], commit_data[0]["hash"][:8]),
103
+ time_range=(min(timestamps), max(timestamps)),
104
+ total_commits=len(commit_data),
105
+ total_files_changed=len(file_churn),
106
+ hotspots=hotspots,
107
+ file_churn=file_churn,
108
+ change_velocity=change_velocity,
109
+ agent_human_ratio=agent_human_ratio,
110
+ rework_rate=rework_rate,
111
+ sessions=sessions,
112
+ has_attribution_data=any(c["is_agent_attributed"] for c in commit_data),
113
+ attribution_source=next(
114
+ (c["attribution_source"] for c in commit_data if c["attribution_source"]),
115
+ None,
116
+ ),
117
+ )
118
+
119
+ # Analyze with LLM
120
+ if not json_output:
121
+ console.print("[dim]Analyzing with LLM...[/dim]")
122
+
123
+ from git_pulse.analyst.engine import AnalystEngine
124
+
125
+ engine = AnalystEngine(model=effective_model)
126
+
127
+ try:
128
+ analyst_report = engine.analyze(collector_report.to_dict())
129
+ except Exception as e:
130
+ console.print(f"[red]LLM analysis failed:[/red] {e}")
131
+ if verbose:
132
+ from git_pulse.renderer.terminal import _render_verbose
133
+ _render_verbose(console, collector_report)
134
+ raise typer.Exit(1)
135
+
136
+ # Render
137
+ if json_output:
138
+ from git_pulse.renderer.json_output import render_json
139
+
140
+ json_str = render_json(analyst_report)
141
+ if output:
142
+ Path(output).write_text(json_str)
143
+ console.print(f"[green]Report written to {output}[/green]")
144
+ else:
145
+ print(json_str)
146
+ else:
147
+ from git_pulse.renderer.terminal import render_terminal
148
+
149
+ render_terminal(
150
+ analyst_report=analyst_report,
151
+ collector_report=collector_report,
152
+ console=console,
153
+ verbose=verbose,
154
+ )
155
+ if output:
156
+ from git_pulse.renderer.json_output import render_json
157
+
158
+ Path(output).write_text(render_json(analyst_report))
159
+ console.print(f"\n[green]JSON report also written to {output}[/green]")
File without changes
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import datetime, timedelta, timezone
5
+
6
+ from git import Repo
7
+
8
+
9
+ # Patterns that indicate agent involvement
10
+ AGENT_PATTERNS = [
11
+ re.compile(r"Co-Authored-By:\s*(.+)", re.IGNORECASE),
12
+ re.compile(r"\[(?:claude|copilot|cursor|aider|codeium|codex)\b", re.IGNORECASE),
13
+ re.compile(r"(?:generated|authored)\s+(?:by|with)\s+(\w+)", re.IGNORECASE),
14
+ re.compile(r"^aider:\s", re.IGNORECASE | re.MULTILINE),
15
+ ]
16
+
17
+
18
+ class GitHistoryCollector:
19
+ def __init__(self, repo_path: str, branch: str | None = None):
20
+ self.repo = Repo(repo_path)
21
+ self.branch = branch or self.repo.active_branch.name
22
+
23
+ def collect(
24
+ self,
25
+ days: int | None = None,
26
+ commits: int | None = None,
27
+ include: list[str] | None = None,
28
+ exclude: list[str] | None = None,
29
+ ) -> list[dict]:
30
+ """Collect commit data from the repo. Returns list of commit dicts, most recent first."""
31
+ kwargs = {}
32
+ if days is not None:
33
+ since = datetime.now(timezone.utc) - timedelta(days=days)
34
+ kwargs["since"] = since.isoformat()
35
+ if commits is not None:
36
+ kwargs["max_count"] = commits
37
+
38
+ raw_commits = list(self.repo.iter_commits(self.branch, **kwargs))
39
+ result = []
40
+
41
+ for commit in raw_commits:
42
+ files = self._extract_files(commit, include, exclude)
43
+ is_agent, source = self._detect_attribution(commit.message)
44
+
45
+ result.append(
46
+ {
47
+ "hash": commit.hexsha,
48
+ "author": str(commit.author),
49
+ "timestamp": commit.committed_datetime.isoformat(),
50
+ "message": commit.message.strip(),
51
+ "files": files,
52
+ "is_agent_attributed": is_agent,
53
+ "attribution_source": source,
54
+ }
55
+ )
56
+
57
+ return result
58
+
59
+ def _extract_files(self, commit, include, exclude) -> list[dict]:
60
+ """Extract file change data from a commit."""
61
+ if not commit.parents:
62
+ diffs = commit.diff(None, create_patch=True, R=True)
63
+ else:
64
+ diffs = commit.parents[0].diff(commit, create_patch=True)
65
+
66
+ files = []
67
+ for diff in diffs:
68
+ path = diff.b_path or diff.a_path
69
+ if not path:
70
+ continue
71
+ if include and not self._matches_any(path, include):
72
+ continue
73
+ if exclude and self._matches_any(path, exclude):
74
+ continue
75
+
76
+ diff_text = diff.diff.decode("utf-8", errors="replace") if diff.diff else ""
77
+ insertions = sum(1 for line in diff_text.splitlines() if line.startswith("+") and not line.startswith("+++"))
78
+ deletions = sum(1 for line in diff_text.splitlines() if line.startswith("-") and not line.startswith("---"))
79
+
80
+ files.append(
81
+ {
82
+ "path": path,
83
+ "insertions": insertions,
84
+ "deletions": deletions,
85
+ "diff": diff_text[:3000],
86
+ }
87
+ )
88
+
89
+ return files
90
+
91
+ def _detect_attribution(self, message: str) -> tuple[bool, str | None]:
92
+ """Check commit message for agent attribution markers."""
93
+ for pattern in AGENT_PATTERNS:
94
+ match = pattern.search(message)
95
+ if match:
96
+ return True, match.group(0).strip()
97
+ return False, None
98
+
99
+ @staticmethod
100
+ def _matches_any(path: str, patterns: list[str]) -> bool:
101
+ from fnmatch import fnmatch
102
+ return any(fnmatch(path, p) for p in patterns)
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import defaultdict
5
+ from datetime import datetime, timezone
6
+
7
+ from git_pulse.collector.models import Hotspot
8
+
9
+
10
+ # Max lines apart to be considered same region
11
+ LINE_PROXIMITY = 5
12
+ # Max hours apart to be considered same work session for hotspot grouping
13
+ TIME_WINDOW_HOURS = 6
14
+
15
+
16
+ class HotspotDetector:
17
+ def __init__(self, commits: list[dict], max_hotspots: int = 20):
18
+ self.commits = commits
19
+ self.max_hotspots = max_hotspots
20
+
21
+ def detect(self) -> list[Hotspot]:
22
+ """Detect hotspots from commit data. Returns ranked list."""
23
+ file_mods = self._group_modifications()
24
+ hotspots = []
25
+ for file_path, mods in file_mods.items():
26
+ clusters = self._cluster_modifications(mods)
27
+ for cluster in clusters:
28
+ if len(cluster) < 2:
29
+ continue
30
+ hotspot = self._build_hotspot(file_path, cluster)
31
+ hotspots.append(hotspot)
32
+
33
+ hotspots.sort(key=lambda h: h.score, reverse=True)
34
+ return hotspots[: self.max_hotspots]
35
+
36
+ def _group_modifications(self) -> dict[str, list[dict]]:
37
+ """Group modifications by file path."""
38
+ file_mods: dict[str, list[dict]] = defaultdict(list)
39
+ for commit in self.commits:
40
+ ts = self._parse_timestamp(commit["timestamp"])
41
+ for f in commit["files"]:
42
+ line_start = self._parse_line_start(f["diff"])
43
+ file_mods[f["path"]].append(
44
+ {
45
+ "commit_hash": commit["hash"],
46
+ "timestamp": ts,
47
+ "line_start": line_start,
48
+ "lines": f["insertions"] + f["deletions"],
49
+ "diff_snippet": f["diff"][:500],
50
+ "is_agent": commit["is_agent_attributed"],
51
+ "attribution_source": commit.get("attribution_source"),
52
+ }
53
+ )
54
+ return file_mods
55
+
56
+ def _cluster_modifications(self, mods: list[dict]) -> list[list[dict]]:
57
+ """Cluster modifications by spatiotemporal proximity."""
58
+ if not mods:
59
+ return []
60
+ mods.sort(key=lambda m: m["timestamp"])
61
+ clusters: list[list[dict]] = [[mods[0]]]
62
+ for mod in mods[1:]:
63
+ merged = False
64
+ for cluster in clusters:
65
+ if self._is_nearby(mod, cluster):
66
+ cluster.append(mod)
67
+ merged = True
68
+ break
69
+ if not merged:
70
+ clusters.append([mod])
71
+ return clusters
72
+
73
+ def _is_nearby(self, mod: dict, cluster: list[dict]) -> bool:
74
+ """Check if mod is spatiotemporally close to any mod in the cluster."""
75
+ for existing in cluster:
76
+ time_diff = abs((mod["timestamp"] - existing["timestamp"]).total_seconds()) / 3600
77
+ line_diff = abs(mod["line_start"] - existing["line_start"])
78
+ if time_diff <= TIME_WINDOW_HOURS and line_diff <= LINE_PROXIMITY:
79
+ return True
80
+ return False
81
+
82
+ def _build_hotspot(self, file_path: str, cluster: list[dict]) -> Hotspot:
83
+ """Build a Hotspot from a cluster of modifications."""
84
+ timestamps = [m["timestamp"] for m in cluster]
85
+ time_span = (max(timestamps) - min(timestamps)).total_seconds() / 3600
86
+ time_span = max(time_span, 0.01)
87
+
88
+ line_starts = [m["line_start"] for m in cluster]
89
+ lines_affected = sum(m["lines"] for m in cluster)
90
+ mod_count = len(cluster)
91
+
92
+ score = (mod_count * 3) + (lines_affected * 0.5) + (1 / time_span * 10)
93
+ classification = self._classify(cluster)
94
+
95
+ return Hotspot(
96
+ file_path=file_path,
97
+ line_start=min(line_starts),
98
+ line_end=max(line_starts) + LINE_PROXIMITY,
99
+ modification_count=mod_count,
100
+ time_span_hours=round(time_span, 2),
101
+ classification=classification,
102
+ commit_hashes=[m["commit_hash"] for m in cluster],
103
+ diff_snippets=[m["diff_snippet"] for m in cluster],
104
+ score=round(score, 2),
105
+ )
106
+
107
+ def _classify(self, cluster: list[dict]) -> str:
108
+ """Classify the hotspot based on agent/human attribution patterns."""
109
+ agent_flags = [m["is_agent"] for m in cluster]
110
+ if not any(agent_flags):
111
+ if all(not f for f in agent_flags):
112
+ return "human-iteration"
113
+ return "unknown"
114
+
115
+ transitions = []
116
+ for i in range(len(agent_flags) - 1):
117
+ if agent_flags[i] and not agent_flags[i + 1]:
118
+ transitions.append("agent-to-human")
119
+ elif not agent_flags[i] and agent_flags[i + 1]:
120
+ transitions.append("human-to-agent")
121
+ elif agent_flags[i] and agent_flags[i + 1]:
122
+ transitions.append("agent-to-agent")
123
+
124
+ if not transitions:
125
+ return "unknown"
126
+
127
+ if transitions.count("agent-to-human") > len(transitions) / 2:
128
+ return "human-fixing-agent"
129
+ if transitions.count("agent-to-agent") > len(transitions) / 2:
130
+ return "repeated-agent"
131
+ if transitions.count("human-to-agent") > len(transitions) / 2:
132
+ return "agent-reworked"
133
+ return "unknown"
134
+
135
+ @staticmethod
136
+ def _parse_line_start(diff: str) -> int:
137
+ match = re.search(r"@@ -(\d+)", diff)
138
+ return int(match.group(1)) if match else 1
139
+
140
+ @staticmethod
141
+ def _parse_timestamp(ts: str) -> datetime:
142
+ return datetime.fromisoformat(ts)