agent-overwrite-grader 0.1.0__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.
- agent_overwrite_grader-0.1.0/.gitignore +18 -0
- agent_overwrite_grader-0.1.0/LICENSE +21 -0
- agent_overwrite_grader-0.1.0/PKG-INFO +59 -0
- agent_overwrite_grader-0.1.0/README.md +40 -0
- agent_overwrite_grader-0.1.0/pyproject.toml +33 -0
- agent_overwrite_grader-0.1.0/src/agent_overwrite_grader/__init__.py +5 -0
- agent_overwrite_grader-0.1.0/src/agent_overwrite_grader/__main__.py +5 -0
- agent_overwrite_grader-0.1.0/src/agent_overwrite_grader/cli.py +90 -0
- agent_overwrite_grader-0.1.0/src/agent_overwrite_grader/grade.py +85 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 memento
|
|
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.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agent-overwrite-grader
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Which files did two of your coding agents write within the same hour? Read from your local git log.
|
|
5
|
+
Project-URL: Homepage, https://overflow.games/memento/grader/?ref=pypi
|
|
6
|
+
Project-URL: memento Team, https://overflow.games/memento/?ref=pypi
|
|
7
|
+
Author: memento
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-agents,claude-code,codex,git,merge-conflicts,parallel-agents
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# agent-overwrite-grader
|
|
21
|
+
|
|
22
|
+
**Did two of your coding agents write the same file within the hour?** Run this in your repository:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
pipx run agent-overwrite-grader
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
It reads your local `git log` for the last month and lists every file two sessions (two authors, or two branches) changed less than an hour apart: the edits most likely to overwrite each other. Nothing is sent anywhere; it is standard-library Python 3.9+, with no dependencies.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
5 files written by two sessions within an hour, in 2 days of history.
|
|
32
|
+
17 commits by 4 sessions read, on this machine only.
|
|
33
|
+
|
|
34
|
+
3 src/routes.ts
|
|
35
|
+
c11c653 claude-code on agent/auth, Oct 5 09:14, then 8 min later 83f4ab6 codex on agent/billing (3 such pairs)
|
|
36
|
+
2 src/db/schema.sql
|
|
37
|
+
c73c1bd codex on agent/billing, Oct 6 09:00, then 12 min later 9a9532b claude-code on agent/auth (2 such pairs)
|
|
38
|
+
2 package.json
|
|
39
|
+
271b801 claude-code-2 on agent/search, Oct 5 09:47, then 8 min later 75e6e7b dev on main (2 such pairs)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Options
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
agent-overwrite-grader [REPO] [--since "2 weeks ago"] [--top 12] [--json]
|
|
46
|
+
agent-overwrite-grader --log saved.log # grade a saved log; '-' reads stdin
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`--log` takes the output of `git log --all --source --name-only --format='%H|%an|%aI|%S'`.
|
|
50
|
+
|
|
51
|
+
## The rule
|
|
52
|
+
|
|
53
|
+
Two commits are two sessions when their authors differ, or both carry a branch name and the names differ. A file collides when two sessions wrote it 60 minutes apart or less. Files rank by how many such pairs they have. The same rule runs in the browser at https://overflow.games/memento/grader/?ref=pypi.
|
|
54
|
+
|
|
55
|
+
## The fix
|
|
56
|
+
|
|
57
|
+
memento Team gives each file one owner at a time across machines: a session claims a path before it edits, and the next one waits. Pre-order at https://overflow.games/memento/?ref=pypi (10 USD per agent a month).
|
|
58
|
+
|
|
59
|
+
MIT licensed.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# agent-overwrite-grader
|
|
2
|
+
|
|
3
|
+
**Did two of your coding agents write the same file within the hour?** Run this in your repository:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pipx run agent-overwrite-grader
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
It reads your local `git log` for the last month and lists every file two sessions (two authors, or two branches) changed less than an hour apart: the edits most likely to overwrite each other. Nothing is sent anywhere; it is standard-library Python 3.9+, with no dependencies.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
5 files written by two sessions within an hour, in 2 days of history.
|
|
13
|
+
17 commits by 4 sessions read, on this machine only.
|
|
14
|
+
|
|
15
|
+
3 src/routes.ts
|
|
16
|
+
c11c653 claude-code on agent/auth, Oct 5 09:14, then 8 min later 83f4ab6 codex on agent/billing (3 such pairs)
|
|
17
|
+
2 src/db/schema.sql
|
|
18
|
+
c73c1bd codex on agent/billing, Oct 6 09:00, then 12 min later 9a9532b claude-code on agent/auth (2 such pairs)
|
|
19
|
+
2 package.json
|
|
20
|
+
271b801 claude-code-2 on agent/search, Oct 5 09:47, then 8 min later 75e6e7b dev on main (2 such pairs)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Options
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
agent-overwrite-grader [REPO] [--since "2 weeks ago"] [--top 12] [--json]
|
|
27
|
+
agent-overwrite-grader --log saved.log # grade a saved log; '-' reads stdin
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`--log` takes the output of `git log --all --source --name-only --format='%H|%an|%aI|%S'`.
|
|
31
|
+
|
|
32
|
+
## The rule
|
|
33
|
+
|
|
34
|
+
Two commits are two sessions when their authors differ, or both carry a branch name and the names differ. A file collides when two sessions wrote it 60 minutes apart or less. Files rank by how many such pairs they have. The same rule runs in the browser at https://overflow.games/memento/grader/?ref=pypi.
|
|
35
|
+
|
|
36
|
+
## The fix
|
|
37
|
+
|
|
38
|
+
memento Team gives each file one owner at a time across machines: a session claims a path before it edits, and the next one waits. Pre-order at https://overflow.games/memento/?ref=pypi (10 USD per agent a month).
|
|
39
|
+
|
|
40
|
+
MIT licensed.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agent-overwrite-grader"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Which files did two of your coding agents write within the same hour? Read from your local git log."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = []
|
|
14
|
+
authors = [{ name = "memento" }]
|
|
15
|
+
keywords = ["git", "ai-agents", "claude-code", "codex", "merge-conflicts", "parallel-agents"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: Software Development :: Version Control :: Git",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://overflow.games/memento/grader/?ref=pypi"
|
|
27
|
+
"memento Team" = "https://overflow.games/memento/?ref=pypi"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
agent-overwrite-grader = "agent_overwrite_grader.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/agent_overwrite_grader"]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""agent-overwrite-grader: which files two coding agent sessions wrote within the same hour, read from git log."""
|
|
2
|
+
from .grade import COMMAND, WINDOW_MIN, grade, parse, score_line
|
|
3
|
+
|
|
4
|
+
__version__ = "0.1.0"
|
|
5
|
+
__all__ = ["COMMAND", "WINDOW_MIN", "grade", "parse", "score_line", "__version__"]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""The command: run git log here, grade it, print the files most likely overwritten. Nothing leaves this machine."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .grade import grade, score_line
|
|
10
|
+
|
|
11
|
+
PREORDER = "https://overflow.games/memento/?ref=pypi"
|
|
12
|
+
FIX = ("memento Team gives each file one owner at a time across machines: "
|
|
13
|
+
"a session claims a path before it edits, and the next one waits.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def git_log(repo, since):
|
|
17
|
+
cmd = ["git", "-C", repo, "log", "--all", "--source", "--name-only",
|
|
18
|
+
"--since=" + since, "--format=%H|%an|%aI|%S"]
|
|
19
|
+
try:
|
|
20
|
+
r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
21
|
+
except OSError as e:
|
|
22
|
+
raise SystemExit("agent-overwrite-grader: git is not on the PATH (%s)" % e)
|
|
23
|
+
if r.returncode:
|
|
24
|
+
raise SystemExit("agent-overwrite-grader: git log failed in %s: %s"
|
|
25
|
+
% (repo, r.stderr.decode("utf-8", "replace").strip()))
|
|
26
|
+
return r.stdout.decode("utf-8", "replace")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def when(ms):
|
|
30
|
+
t = datetime.fromtimestamp(ms / 1000)
|
|
31
|
+
return "%s %d %s" % (t.strftime("%b"), t.day, t.strftime("%H:%M"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def who(c):
|
|
35
|
+
return c["hash"][:7] + " " + c["author"] + (" on " + c["ref"] if c["ref"] else "")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def text(report, top):
|
|
39
|
+
out = [score_line(report), "%d commits by %d sessions read, on this machine only." % (report["commits"], report["sessions"]), ""]
|
|
40
|
+
for f in report["files"][:top]:
|
|
41
|
+
p = f["pairs"][0]
|
|
42
|
+
line = " %d %s" % (f["count"], f["file"])
|
|
43
|
+
pair = " %s, %s, then %d min later %s" % (who(p["a"]), when(p["a"]["when"]), p["minutes"], who(p["b"]))
|
|
44
|
+
if f["count"] > 1:
|
|
45
|
+
pair += " (%d such pairs)" % f["count"]
|
|
46
|
+
out += [line, pair]
|
|
47
|
+
n = len(report["files"])
|
|
48
|
+
if n > top:
|
|
49
|
+
out.append("And %d more %s." % (n - top, "file" if n - top == 1 else "files"))
|
|
50
|
+
elif n == 0:
|
|
51
|
+
out.append("Nothing collided in this history. The more agents run at once, the sooner it does.")
|
|
52
|
+
out += ["", FIX, "Pre-order memento Team, 10 USD per agent a month: " + PREORDER]
|
|
53
|
+
return "\n".join(out)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv=None):
|
|
57
|
+
ap = argparse.ArgumentParser(
|
|
58
|
+
prog="agent-overwrite-grader",
|
|
59
|
+
description="List every file two sessions (two authors, or two branches) changed less than an hour apart: "
|
|
60
|
+
"the edits most likely to overwrite each other. It reads the local git log; nothing is sent anywhere.")
|
|
61
|
+
ap.add_argument("repo", nargs="?", default=".", help="the repository to read (default: here)")
|
|
62
|
+
ap.add_argument("--since", default="1 month ago", help="how far back to read (default: 1 month ago)")
|
|
63
|
+
ap.add_argument("--log", metavar="FILE", help="grade a saved log instead ('-' for stdin), in the format of: "
|
|
64
|
+
"git log --all --source --name-only --format='%%H|%%an|%%aI|%%S'")
|
|
65
|
+
ap.add_argument("--top", type=int, default=12, help="files to show (default: 12)")
|
|
66
|
+
ap.add_argument("--json", action="store_true", help="print the report as JSON")
|
|
67
|
+
ap.add_argument("--version", action="version", version="%(prog)s " + __version__)
|
|
68
|
+
a = ap.parse_args(argv)
|
|
69
|
+
try:
|
|
70
|
+
if a.log == "-":
|
|
71
|
+
log = sys.stdin.read()
|
|
72
|
+
elif a.log:
|
|
73
|
+
with open(a.log, encoding="utf-8", errors="replace") as f:
|
|
74
|
+
log = f.read()
|
|
75
|
+
else:
|
|
76
|
+
log = git_log(a.repo, a.since)
|
|
77
|
+
except SystemExit as e:
|
|
78
|
+
print(e, file=sys.stderr)
|
|
79
|
+
return 2
|
|
80
|
+
report = grade(log)
|
|
81
|
+
if not report["commits"] and a.log:
|
|
82
|
+
print("agent-overwrite-grader: no commit in that text; save the output of the git log command in --help.",
|
|
83
|
+
file=sys.stderr)
|
|
84
|
+
return 1
|
|
85
|
+
if a.json:
|
|
86
|
+
report["preorder"] = PREORDER
|
|
87
|
+
print(json.dumps(report, indent=2))
|
|
88
|
+
else:
|
|
89
|
+
print(text(report, a.top))
|
|
90
|
+
return 0
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""The grading rule: which files two sessions wrote within an hour, read from a git log.
|
|
2
|
+
|
|
3
|
+
A line-for-line port of the web grader's grade.js; a shared fixture test keeps the two verdicts equal.
|
|
4
|
+
"""
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
COMMAND = "git log --all --source --name-only --since='1 month ago' --format='%H|%an|%aI|%S'"
|
|
10
|
+
WINDOW_MIN = 60
|
|
11
|
+
HEAD = re.compile(r"^[0-9a-f]{7,64}\|")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def branch(ref):
|
|
15
|
+
"""A ref as git prints it (%S, or %D's decorations) to a branch name; "" when the commit carries none."""
|
|
16
|
+
names = [s.strip() for s in (ref or "").split(",")]
|
|
17
|
+
names = [re.sub(r"^HEAD -> ", "", s) for s in names]
|
|
18
|
+
names = [s for s in names if s and s != "HEAD" and not s.startswith("tag: ") and not s.startswith("refs/tags/")]
|
|
19
|
+
if not names:
|
|
20
|
+
return ""
|
|
21
|
+
return re.sub(r"^refs/remotes/[^/]+/", "", re.sub(r"^refs/heads/", "", names[0]))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def millis(text):
|
|
25
|
+
"""An ISO 8601 date with its offset to epoch milliseconds; None when it does not read as one."""
|
|
26
|
+
try:
|
|
27
|
+
when = datetime.fromisoformat(text.strip().replace("Z", "+00:00"))
|
|
28
|
+
except ValueError:
|
|
29
|
+
return None
|
|
30
|
+
return int(round(when.timestamp() * 1000))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse(text):
|
|
34
|
+
commits, cur = [], None
|
|
35
|
+
for line in re.split(r"\r?\n", str(text or "")):
|
|
36
|
+
if HEAD.match(line):
|
|
37
|
+
p = line.split("|")
|
|
38
|
+
when = millis(p[-2]) if len(p) >= 4 else None
|
|
39
|
+
if when is not None:
|
|
40
|
+
cur = {"hash": p[0], "author": "|".join(p[1:-2]), "when": when, "ref": branch(p[-1]), "files": []}
|
|
41
|
+
commits.append(cur)
|
|
42
|
+
continue
|
|
43
|
+
f = line.strip()
|
|
44
|
+
if cur is not None and f:
|
|
45
|
+
cur["files"].append(f)
|
|
46
|
+
return commits
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def two_sessions(a, b):
|
|
50
|
+
"""Two commits are two sessions when their authors differ, or both carry a branch and the branches differ."""
|
|
51
|
+
return a["author"] != b["author"] or bool(a["ref"] and b["ref"] and a["ref"] != b["ref"])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def grade(text):
|
|
55
|
+
commits, by_file = parse(text), {}
|
|
56
|
+
for c in commits:
|
|
57
|
+
for f in c["files"]:
|
|
58
|
+
by_file.setdefault(f, []).append(c)
|
|
59
|
+
files = []
|
|
60
|
+
for name, writes in by_file.items():
|
|
61
|
+
writes = sorted(writes, key=lambda c: c["when"])
|
|
62
|
+
pairs, latest = [], 0
|
|
63
|
+
for i in range(1, len(writes)):
|
|
64
|
+
j = i - 1
|
|
65
|
+
while j >= 0 and writes[i]["when"] - writes[j]["when"] <= WINDOW_MIN * 60000:
|
|
66
|
+
if two_sessions(writes[i], writes[j]):
|
|
67
|
+
minutes = int(math.floor((writes[i]["when"] - writes[j]["when"]) / 60000 + 0.5))
|
|
68
|
+
pairs.append({"a": writes[j], "b": writes[i], "minutes": minutes})
|
|
69
|
+
latest = max(latest, writes[i]["when"])
|
|
70
|
+
j -= 1
|
|
71
|
+
if pairs:
|
|
72
|
+
pairs.sort(key=lambda p: p["minutes"])
|
|
73
|
+
files.append({"file": name, "count": len(pairs), "latest": latest, "pairs": pairs[:3]})
|
|
74
|
+
files.sort(key=lambda f: (-f["count"], -f["latest"], f["file"]))
|
|
75
|
+
times = [c["when"] for c in commits]
|
|
76
|
+
days = max(1, math.ceil((max(times) - min(times)) / 864e5)) if times else 0
|
|
77
|
+
sessions = {(c["author"], c["ref"]) for c in commits}
|
|
78
|
+
return {"commits": len(commits), "sessions": len(sessions), "days": days, "files": files}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def score_line(report):
|
|
82
|
+
n = len(report["files"])
|
|
83
|
+
head = "No file" if n == 0 else "1 file" if n == 1 else "%d files" % n
|
|
84
|
+
return "%s written by two sessions within an hour, in %d %s of history." % (
|
|
85
|
+
head, report["days"], "day" if report["days"] == 1 else "days")
|