tandemetry 0.0.1__tar.gz

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,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: tandemetry
3
+ Version: 0.0.1
4
+ Summary: Self-hosted analytics for AI coding agents versus human-authored work.
5
+ Keywords: ai-agents,analytics,github,code-review,engineering-metrics
6
+ Author: Yevhenii Korniev
7
+ License-Expression: Apache-2.0
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Topic :: Software Development
13
+ Requires-Dist: pydantic-settings>=2.6
14
+ Requires-Dist: pyyaml>=6
15
+ Requires-Python: >=3.13
16
+ Project-URL: Homepage, https://github.com/tandemetry/tandemetry
17
+ Project-URL: Repository, https://github.com/tandemetry/tandemetry
18
+ Project-URL: Documentation, https://github.com/tandemetry/tandemetry/tree/main/docs
19
+ Project-URL: Issues, https://github.com/tandemetry/tandemetry/issues
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Tandemetry
23
+
24
+ **See how humans and AI agents ship code together.**
25
+
26
+ Open-source, self-hosted analytics for AI coding agents: compare agent vs human work in your
27
+ repositories — merge rate, reverts, rework, CI failures, review load and cost per PR.
28
+
29
+ > Status: early development (v0.1 in progress). Not ready for production use.
30
+
31
+ ## Why
32
+
33
+ AI agents now open a large share of pull requests, but teams can't tell whether that code is
34
+ merged, reverted or rewritten — or which agent works best in their codebase. Tandemetry answers
35
+ that from your own Git data, without sending code anywhere and without LLM calls.
36
+
37
+ ## What you will see
38
+
39
+ ![Dashboard mockup with illustrative data](https://raw.githubusercontent.com/tandemetry/tandemetry/main/docs/assets/dashboard-mockup.svg)
40
+
41
+ Every number is traceable: each PR is labelled `agent`, `human` or `mixed` by a transparent rule
42
+ (bot author, commit trailer, PR description, branch name), and the matched rule is stored with the
43
+ label. See [agent attribution](docs/attribution.md) and the
44
+ [signatures research](docs/research/signatures.md).
45
+
46
+ ## How it works
47
+
48
+ GitHub webhooks and a history backfill feed Kafka. The attribution engine labels each event,
49
+ ClickHouse computes the metrics, and Grafana and a metrics API read them. PostgreSQL holds the
50
+ configuration. Details: [architecture](docs/architecture.md).
51
+
52
+ ## Principles
53
+
54
+ - **Self-hosted.** Your code and data stay in your infrastructure.
55
+ - **Agent-agnostic.** Claude Code, Codex, Copilot coding agent, Devin, Cursor and more.
56
+ - **Outcomes, not activity.** Reverts and rework, not just PR counts.
57
+ - **No individual rankings.** Teams, repositories and agents only.
58
+ - **Deterministic.** Transparent attribution rules, no LLM in the core.
59
+
60
+ ## Docs
61
+
62
+ - [Product](docs/product.md)
63
+ - [Architecture](docs/architecture.md)
64
+ - [Agent attribution](docs/attribution.md)
65
+ - [Roadmap](docs/roadmap.md)
66
+ - [Releasing](docs/releasing.md)
67
+ - [Architecture decisions](docs/adr/)
68
+
69
+ ## License
70
+
71
+ [Apache-2.0](LICENSE)
72
+
73
+ ## Development
74
+
75
+ Requires [uv](https://docs.astral.sh/uv/).
76
+
77
+ ```bash
78
+ uv sync
79
+ uv run pre-commit install
80
+ uv run pytest
81
+ uv run ruff check . && uv run ruff format --check .
82
+ uv run mypy
83
+ uv run tandemetry --version
84
+ ```
85
+
86
+ Local service stack (Kafka, ClickHouse, PostgreSQL):
87
+
88
+ ```bash
89
+ cp deploy/.env.example deploy/.env
90
+ docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --wait
91
+ ```
92
+
93
+ See [deploy/README.md](deploy/README.md) for verification and troubleshooting.
@@ -0,0 +1,72 @@
1
+ # Tandemetry
2
+
3
+ **See how humans and AI agents ship code together.**
4
+
5
+ Open-source, self-hosted analytics for AI coding agents: compare agent vs human work in your
6
+ repositories — merge rate, reverts, rework, CI failures, review load and cost per PR.
7
+
8
+ > Status: early development (v0.1 in progress). Not ready for production use.
9
+
10
+ ## Why
11
+
12
+ AI agents now open a large share of pull requests, but teams can't tell whether that code is
13
+ merged, reverted or rewritten — or which agent works best in their codebase. Tandemetry answers
14
+ that from your own Git data, without sending code anywhere and without LLM calls.
15
+
16
+ ## What you will see
17
+
18
+ ![Dashboard mockup with illustrative data](https://raw.githubusercontent.com/tandemetry/tandemetry/main/docs/assets/dashboard-mockup.svg)
19
+
20
+ Every number is traceable: each PR is labelled `agent`, `human` or `mixed` by a transparent rule
21
+ (bot author, commit trailer, PR description, branch name), and the matched rule is stored with the
22
+ label. See [agent attribution](docs/attribution.md) and the
23
+ [signatures research](docs/research/signatures.md).
24
+
25
+ ## How it works
26
+
27
+ GitHub webhooks and a history backfill feed Kafka. The attribution engine labels each event,
28
+ ClickHouse computes the metrics, and Grafana and a metrics API read them. PostgreSQL holds the
29
+ configuration. Details: [architecture](docs/architecture.md).
30
+
31
+ ## Principles
32
+
33
+ - **Self-hosted.** Your code and data stay in your infrastructure.
34
+ - **Agent-agnostic.** Claude Code, Codex, Copilot coding agent, Devin, Cursor and more.
35
+ - **Outcomes, not activity.** Reverts and rework, not just PR counts.
36
+ - **No individual rankings.** Teams, repositories and agents only.
37
+ - **Deterministic.** Transparent attribution rules, no LLM in the core.
38
+
39
+ ## Docs
40
+
41
+ - [Product](docs/product.md)
42
+ - [Architecture](docs/architecture.md)
43
+ - [Agent attribution](docs/attribution.md)
44
+ - [Roadmap](docs/roadmap.md)
45
+ - [Releasing](docs/releasing.md)
46
+ - [Architecture decisions](docs/adr/)
47
+
48
+ ## License
49
+
50
+ [Apache-2.0](LICENSE)
51
+
52
+ ## Development
53
+
54
+ Requires [uv](https://docs.astral.sh/uv/).
55
+
56
+ ```bash
57
+ uv sync
58
+ uv run pre-commit install
59
+ uv run pytest
60
+ uv run ruff check . && uv run ruff format --check .
61
+ uv run mypy
62
+ uv run tandemetry --version
63
+ ```
64
+
65
+ Local service stack (Kafka, ClickHouse, PostgreSQL):
66
+
67
+ ```bash
68
+ cp deploy/.env.example deploy/.env
69
+ docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --wait
70
+ ```
71
+
72
+ See [deploy/README.md](deploy/README.md) for verification and troubleshooting.
@@ -0,0 +1,83 @@
1
+ [project]
2
+ name = "tandemetry"
3
+ version = "0.0.1"
4
+ description = "Self-hosted analytics for AI coding agents versus human-authored work."
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ keywords = [
8
+ "ai-agents",
9
+ "analytics",
10
+ "github",
11
+ "code-review",
12
+ "engineering-metrics",
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: Software Development",
20
+ ]
21
+ requires-python = ">=3.13"
22
+ dependencies = [
23
+ "pydantic-settings>=2.6",
24
+ "pyyaml>=6",
25
+ ]
26
+
27
+ [[project.authors]]
28
+ name = "Yevhenii Korniev"
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/tandemetry/tandemetry"
32
+ Repository = "https://github.com/tandemetry/tandemetry"
33
+ Documentation = "https://github.com/tandemetry/tandemetry/tree/main/docs"
34
+ Issues = "https://github.com/tandemetry/tandemetry/issues"
35
+
36
+ [project.scripts]
37
+ tandemetry = "tandemetry.cli:main"
38
+
39
+ [build-system]
40
+ requires = ["uv_build>=0.9.4,<0.10"]
41
+ build-backend = "uv_build"
42
+
43
+ [dependency-groups]
44
+ dev = [
45
+ "mypy>=1.13",
46
+ "pre-commit>=4.0",
47
+ "pytest>=8.3",
48
+ "ruff>=0.8",
49
+ "types-pyyaml>=6.0.12.20261103",
50
+ ]
51
+
52
+ [tool.ruff]
53
+ line-length = 100
54
+ target-version = "py313"
55
+
56
+ [tool.ruff.lint]
57
+ select = [
58
+ "E",
59
+ "F",
60
+ "I",
61
+ "UP",
62
+ "B",
63
+ "SIM",
64
+ "N",
65
+ "ANN",
66
+ "S",
67
+ "RUF",
68
+ ]
69
+
70
+ [tool.ruff.lint.per-file-ignores]
71
+ "tests/**" = ["S101"]
72
+
73
+ [tool.mypy]
74
+ strict = true
75
+ files = [
76
+ "src",
77
+ "tests",
78
+ ]
79
+ plugins = ["pydantic.mypy"]
80
+
81
+ [tool.pytest.ini_options]
82
+ testpaths = ["tests"]
83
+ addopts = "-ra --strict-markers"
@@ -0,0 +1,61 @@
1
+ [project]
2
+ name = "tandemetry"
3
+ version = "0.0.1"
4
+ description = "Self-hosted analytics for AI coding agents versus human-authored work."
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ authors = [{ name = "Yevhenii Korniev" }]
8
+ keywords = ["ai-agents", "analytics", "github", "code-review", "engineering-metrics"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "Operating System :: OS Independent",
13
+ "Programming Language :: Python :: 3.13",
14
+ "Topic :: Software Development",
15
+ ]
16
+ requires-python = ">=3.13"
17
+ dependencies = [
18
+ "pydantic-settings>=2.6",
19
+ "pyyaml>=6",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/tandemetry/tandemetry"
24
+ Repository = "https://github.com/tandemetry/tandemetry"
25
+ Documentation = "https://github.com/tandemetry/tandemetry/tree/main/docs"
26
+ Issues = "https://github.com/tandemetry/tandemetry/issues"
27
+
28
+ [project.scripts]
29
+ tandemetry = "tandemetry.cli:main"
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.9.4,<0.10"]
33
+ build-backend = "uv_build"
34
+
35
+ [dependency-groups]
36
+ dev = [
37
+ "mypy>=1.13",
38
+ "pre-commit>=4.0",
39
+ "pytest>=8.3",
40
+ "ruff>=0.8",
41
+ "types-pyyaml>=6.0.12.20261103",
42
+ ]
43
+
44
+ [tool.ruff]
45
+ line-length = 100
46
+ target-version = "py313"
47
+
48
+ [tool.ruff.lint]
49
+ select = ["E", "F", "I", "UP", "B", "SIM", "N", "ANN", "S", "RUF"]
50
+
51
+ [tool.ruff.lint.per-file-ignores]
52
+ "tests/**" = ["S101"]
53
+
54
+ [tool.mypy]
55
+ strict = true
56
+ files = ["src", "tests"]
57
+ plugins = ["pydantic.mypy"]
58
+
59
+ [tool.pytest.ini_options]
60
+ testpaths = ["tests"]
61
+ addopts = "-ra --strict-markers"
File without changes
File without changes
@@ -0,0 +1,85 @@
1
+ from collections.abc import Mapping
2
+ from enum import StrEnum
3
+ from pathlib import Path
4
+ from typing import Self
5
+
6
+ import yaml
7
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
8
+
9
+ DEFAULT_CATALOG_PATH = Path(__file__).with_name("signatures.yaml")
10
+
11
+
12
+ class Confidence(StrEnum):
13
+ HIGH = "high"
14
+ MEDIUM = "medium"
15
+
16
+
17
+ class SignalKind(StrEnum):
18
+ BOT_ACCOUNT = "bot_account"
19
+ COMMIT_TRAILER = "commit_trailer"
20
+ DESCRIPTION_PHRASE = "description_phrase"
21
+ BRANCH_PREFIX = "branch_prefix"
22
+ LABEL = "label"
23
+
24
+
25
+ RULE_CONFIDENCE: Mapping[SignalKind, Confidence] = {
26
+ SignalKind.BOT_ACCOUNT: Confidence.HIGH,
27
+ SignalKind.COMMIT_TRAILER: Confidence.HIGH,
28
+ SignalKind.DESCRIPTION_PHRASE: Confidence.MEDIUM,
29
+ SignalKind.BRANCH_PREFIX: Confidence.MEDIUM,
30
+ SignalKind.LABEL: Confidence.MEDIUM,
31
+ }
32
+
33
+
34
+ class CatalogError(Exception):
35
+ """Raised when a signatures catalog cannot be read or is invalid."""
36
+
37
+
38
+ class Signal(BaseModel):
39
+ model_config = ConfigDict(frozen=True, extra="forbid")
40
+
41
+ kind: SignalKind
42
+ value: str = Field(min_length=1)
43
+ confidence: Confidence
44
+
45
+ @model_validator(mode="after")
46
+ def _confidence_follows_rules_table(self) -> Self:
47
+ expected = RULE_CONFIDENCE[self.kind]
48
+ if self.confidence != expected:
49
+ raise ValueError(f"{self.kind} signals must have {expected} confidence")
50
+ return self
51
+
52
+
53
+ class AgentSignature(BaseModel):
54
+ model_config = ConfigDict(frozen=True, extra="forbid")
55
+
56
+ name: str = Field(min_length=1)
57
+ signals: tuple[Signal, ...] = Field(min_length=1)
58
+
59
+
60
+ class SignatureCatalog(BaseModel):
61
+ model_config = ConfigDict(frozen=True, extra="forbid")
62
+
63
+ agents: tuple[AgentSignature, ...] = Field(min_length=1)
64
+
65
+ @model_validator(mode="after")
66
+ def _agent_names_are_unique(self) -> Self:
67
+ names = [agent.name for agent in self.agents]
68
+ duplicates = sorted({name for name in names if names.count(name) > 1})
69
+ if duplicates:
70
+ raise ValueError(f"duplicate agent names: {', '.join(duplicates)}")
71
+ return self
72
+
73
+
74
+ class SignatureCatalogLoader:
75
+ """Loads and validates a signatures catalog from a YAML file."""
76
+
77
+ def __init__(self, path: Path = DEFAULT_CATALOG_PATH) -> None:
78
+ self._path = path
79
+
80
+ def load(self) -> SignatureCatalog:
81
+ try:
82
+ raw = yaml.safe_load(self._path.read_text(encoding="utf-8"))
83
+ return SignatureCatalog.model_validate(raw)
84
+ except (OSError, yaml.YAMLError, ValidationError) as error:
85
+ raise CatalogError(f"invalid signatures catalog {self._path}: {error}") from error
@@ -0,0 +1,31 @@
1
+ agents:
2
+ - name: Claude Code
3
+ signals:
4
+ - {kind: commit_trailer, value: "noreply@anthropic.com", confidence: high}
5
+ - {kind: description_phrase, value: "Generated with Claude Code", confidence: medium}
6
+ - {kind: branch_prefix, value: "claude/", confidence: medium}
7
+ - name: GitHub Copilot coding agent
8
+ signals:
9
+ - {kind: bot_account, value: "Copilot", confidence: high}
10
+ - {kind: bot_account, value: "copilot-swe-agent[bot]", confidence: high}
11
+ - {kind: commit_trailer, value: "198982749+Copilot@users.noreply.github.com", confidence: high}
12
+ - {kind: commit_trailer, value: "223556219+Copilot@users.noreply.github.com", confidence: high}
13
+ - name: Devin
14
+ signals:
15
+ - {kind: bot_account, value: "devin-ai-integration[bot]", confidence: high}
16
+ - name: Cursor
17
+ signals:
18
+ - {kind: bot_account, value: "cursor[bot]", confidence: high}
19
+ - {kind: commit_trailer, value: "cursoragent@cursor.com", confidence: high}
20
+ - {kind: branch_prefix, value: "cursor/", confidence: medium}
21
+ - name: OpenAI Codex
22
+ signals:
23
+ - {kind: commit_trailer, value: "noreply@openai.com", confidence: high}
24
+ - {kind: commit_trailer, value: "codex@openai.com", confidence: high}
25
+ - {kind: branch_prefix, value: "codex/", confidence: medium}
26
+ - name: Google Jules
27
+ signals:
28
+ - {kind: bot_account, value: "google-labs-jules[bot]", confidence: high}
29
+ - name: Open SWE
30
+ signals:
31
+ - {kind: commit_trailer, value: "open-swe@users.noreply.github.com", confidence: high}
@@ -0,0 +1,40 @@
1
+ import argparse
2
+ from collections.abc import Sequence
3
+ from importlib.metadata import version
4
+ from typing import Protocol
5
+
6
+ from tandemetry.settings import Settings
7
+
8
+
9
+ class VersionProvider(Protocol):
10
+ def get(self) -> str: ...
11
+
12
+
13
+ class PackageVersionProvider:
14
+ def __init__(self, package: str = "tandemetry") -> None:
15
+ self._package = package
16
+
17
+ def get(self) -> str:
18
+ return version(self._package)
19
+
20
+
21
+ class Cli:
22
+ def __init__(self, version_provider: VersionProvider, settings: Settings) -> None:
23
+ self._parser = argparse.ArgumentParser(prog=settings.app_name)
24
+ self._parser.add_argument(
25
+ "--version",
26
+ action="version",
27
+ version=f"{settings.app_name} {version_provider.get()}",
28
+ )
29
+
30
+ def run(self, argv: Sequence[str] | None = None) -> int:
31
+ try:
32
+ self._parser.parse_args(argv)
33
+ except SystemExit as exit_request:
34
+ return exit_request.code if isinstance(exit_request.code, int) else 0
35
+ return 0
36
+
37
+
38
+ def main() -> None:
39
+ cli = Cli(PackageVersionProvider(), Settings())
40
+ raise SystemExit(cli.run())
File without changes
File without changes
File without changes
@@ -0,0 +1,7 @@
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+
3
+
4
+ class Settings(BaseSettings):
5
+ model_config = SettingsConfigDict(env_prefix="TANDEMETRY_")
6
+
7
+ app_name: str = "tandemetry"
File without changes