clocwork 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.
clocwork/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """clocwork: lines, agents and tokens over a repository's whole history."""
2
+
3
+ import sys as _sys
4
+
5
+ # Every way in (the console script, the clone's shim, the zipapp, python -m)
6
+ # imports this package before any module that needs 3.11, such as config's
7
+ # tomllib. pip checks requires-python; the others would stop in a traceback.
8
+ # Kept to syntax an old interpreter can parse.
9
+ if _sys.version_info < (3, 11):
10
+ _v = _sys.version_info
11
+ _sys.stderr.write("clocwork: needs Python 3.11 or later; this is Python %d.%d.%d (%s)\n"
12
+ % (_v[0], _v[1], _v[2], _sys.executable))
13
+ raise SystemExit(2)
14
+
15
+ __version__ = "0.1.0"
clocwork/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from clocwork.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
clocwork/agents.py ADDED
@@ -0,0 +1,113 @@
1
+ """Which AI agent, if any, a commit credits.
2
+
3
+ Attribution is read from "Co-Authored-By:" trailer lines only, so a human
4
+ commit that merely mentions CLAUDE.md or a claude-* branch name is not
5
+ counted as AI-assisted.
6
+
7
+ Claude model names are parsed generically rather than listed one by one, so
8
+ any Claude model - past, present, or future - is recognised without a code
9
+ change. Two naming schemes are handled:
10
+
11
+ family-first (Claude 4+): "Claude Opus 4.6", "Claude Fable 5.1",
12
+ "Claude Opus 5 (1M context)"
13
+ version-first (Claude 3.x): "Claude 3.5 Sonnet", "Claude 3 Opus"
14
+
15
+ Both normalise to "Claude <Family> <version>", with " (1M)" appended for the
16
+ 1M-context variants, so the dashboard sees one consistent naming scheme. The
17
+ version is captured greedily, which is what keeps "Fable 5.1" from being read
18
+ as "Fable 5".
19
+
20
+ Other agents are matched by a substring of the trailer, from a vendor table a
21
+ workspace can extend. An unrecognised trailer stays unmatched rather than
22
+ being guessed at: a wrong attribution is worse than a missing one.
23
+ """
24
+
25
+ import re
26
+
27
+ CLAUDE_FAMILIES = r"(?:Fable|Opus|Sonnet|Haiku|Mythos)"
28
+ CLAUDE_VERSION = r"\d+(?:\.\d+)?"
29
+ CLAUDE_CONTEXT = r"(?:\s*\((\d+[KM]) context\))?"
30
+
31
+ MODEL_FAMILY_FIRST = re.compile(
32
+ rf"Claude\s+({CLAUDE_FAMILIES})\s+({CLAUDE_VERSION}){CLAUDE_CONTEXT}",
33
+ re.IGNORECASE,
34
+ )
35
+ MODEL_VERSION_FIRST = re.compile(
36
+ rf"Claude\s+({CLAUDE_VERSION})\s+({CLAUDE_FAMILIES}){CLAUDE_CONTEXT}",
37
+ re.IGNORECASE,
38
+ )
39
+ COAUTHOR_TRAILER = re.compile(r"^\s*Co-Authored-By:\s*(.+)$", re.IGNORECASE | re.MULTILINE)
40
+
41
+ UNKNOWN_CLAUDE = "Claude (unknown version)"
42
+
43
+ # (substring of the trailer, reported name)
44
+ VENDORS = (
45
+ ("Copilot", "Copilot"),
46
+ ("Cursor", "Cursor"),
47
+ ("Codex", "Codex"),
48
+ ("Devin", "Devin"),
49
+ ("aider", "aider"),
50
+ # GitHub credits accepted review suggestions to gemini-code-assist[bot]. It
51
+ # comes before the next row, which would otherwise claim it.
52
+ ("gemini-code-assist", "Gemini Code Assist"),
53
+ # Gemini CLI adds no trailer of its own; this catches the one a person adds.
54
+ ("Gemini", "Gemini"),
55
+ )
56
+
57
+
58
+ def normalise_model(family, version, context):
59
+ name = f"Claude {family.capitalize()} {version}"
60
+ if context:
61
+ name += f" ({context.upper()})"
62
+ return name
63
+
64
+
65
+ def parse_claude_model(text):
66
+ """Return the normalised Claude model name found in a co-author trailer, or None."""
67
+ m = MODEL_FAMILY_FIRST.search(text)
68
+ if m:
69
+ return normalise_model(m.group(1), m.group(2), m.group(3))
70
+ m = MODEL_VERSION_FIRST.search(text)
71
+ if m:
72
+ return normalise_model(m.group(2), m.group(1), m.group(3))
73
+ if re.search(r"\bClaude\b", text, re.IGNORECASE):
74
+ return UNKNOWN_CLAUDE
75
+ return None
76
+
77
+
78
+ class AgentTable:
79
+ """The vendor table plus a workspace's [agents].extra rows."""
80
+
81
+ def __init__(self, extra=()):
82
+ self.rules = list(VENDORS) + [(e["match"], e["name"]) for e in extra]
83
+
84
+ def match(self, trailer):
85
+ model = parse_claude_model(trailer)
86
+ if model:
87
+ return model
88
+ lowered = trailer.lower()
89
+ for needle, name in self.rules:
90
+ if needle.lower() in lowered:
91
+ return name
92
+ return None
93
+
94
+ def detect(self, body):
95
+ """The agent credited in a commit body via its Co-Authored-By trailers.
96
+
97
+ The first recognised trailer wins, matching the previous first-match
98
+ behaviour for commits that credit more than one model.
99
+ """
100
+ if not body:
101
+ return None
102
+ for trailer in COAUTHOR_TRAILER.findall(body):
103
+ name = self.match(trailer)
104
+ if name:
105
+ return name
106
+ return None
107
+
108
+
109
+ DEFAULT_AGENTS = AgentTable()
110
+
111
+
112
+ def detect_agent(body, table=DEFAULT_AGENTS):
113
+ return table.detect(body)