aimpg 0.6.2__tar.gz → 0.7.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.
- {aimpg-0.6.2 → aimpg-0.7.0}/PKG-INFO +5 -2
- {aimpg-0.6.2 → aimpg-0.7.0}/README.md +4 -1
- {aimpg-0.6.2 → aimpg-0.7.0}/action.yml +1 -1
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/chain.py +2 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/cli.py +7 -1
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/fake_agent.py +8 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/hints.py +5 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/proxy.py +7 -3
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/run.py +39 -9
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/sandbox.py +13 -1
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/study.py +1 -1
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/study_cli.py +63 -8
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/workspace.py +40 -8
- {aimpg-0.6.2 → aimpg-0.7.0}/docs/FAQ.md +1 -1
- {aimpg-0.6.2 → aimpg-0.7.0}/docs/designs/launch-brief.md +2 -2
- aimpg-0.7.0/docs/study/report-1/PREREGISTRATION.md +124 -0
- aimpg-0.7.0/docs/study/report-1/selection/axios.manifest.json +57 -0
- aimpg-0.7.0/docs/study/report-1/selection/click.manifest.json +40 -0
- aimpg-0.7.0/docs/study/report-1/selection/homebridge.manifest.json +37 -0
- aimpg-0.7.0/docs/study/report-1/selection/lint-staged.manifest.json +49 -0
- aimpg-0.7.0/docs/study/report-1/selection/litestar.manifest.json +39 -0
- aimpg-0.7.0/docs/study/report-1/selection/pydantic.manifest.json +93 -0
- aimpg-0.7.0/docs/study/report-1/selection/pyinfra.manifest.json +39 -0
- aimpg-0.7.0/docs/study/report-1/selection/recharts.manifest.json +43 -0
- aimpg-0.7.0/docs/study/report-1/selection/starlette.manifest.json +37 -0
- aimpg-0.7.0/docs/study/report-1/selection.json +51 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/pyproject.toml +1 -1
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_replay_e2e.py +6 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_replay_sandbox.py +13 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_study.py +6 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/uv.lock +1 -1
- aimpg-0.6.2/docs/study/report-1/PREREGISTRATION.md +0 -73
- {aimpg-0.6.2 → aimpg-0.7.0}/.github/workflows/publish.yml +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/.github/workflows/test.yml +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/.gitignore +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/LICENSE +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/TODOS.md +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/__init__.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/attribution.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/card.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/cli.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/coach.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/codex_logs.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/cost.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/cursor_usage.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/defaults.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/durable.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/energy.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/equivalence.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/equivalences.json +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/factors.json +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/gitkept.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/ledger.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/live.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/logs.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/model.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/prices.json +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/receipt.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/__init__.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/picker.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/select.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/setups.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/stats.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/verify.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/sci.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/scoreboard.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/share.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/team.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/docs/STRATEGY.md +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/docs/designs/aimpg-design.md +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/docs/designs/scoreboard-brief.md +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/docs/receipt-card.png +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/docs/study/report-1/plan.json +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/evals/attribution_eval.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/evals/label.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/spikes/agent/RESULTS.md +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/spikes/agent/allowlist_proxy.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/spikes/agent/make_profile.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/spikes/egress/RESULTS.md +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/spikes/egress/allowlist_proxy.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/spikes/egress/replay.sb +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/conftest.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/gitrepo.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_attribution.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_card.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_coach.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_codex_logs.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_cost_and_equivalence.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_cursor_usage.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_defaults.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_durable.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_energy.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_eval_scoring.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_gitkept.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_live.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_logs.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_perf.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_replay_cli.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_replay_hints.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_replay_picker.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_replay_proxy.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_replay_stats.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_sandbox_required.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_sci.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_scoreboard.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_security.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_share.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_team.py +0 -0
- {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_verify.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: aimpg
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Real-world energy per solved task for AI coding agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/kumarganduri/aimpg
|
|
6
6
|
Project-URL: Issues, https://github.com/kumarganduri/aimpg/issues
|
|
@@ -28,6 +28,8 @@ uvx aimpg report
|
|
|
28
28
|
|
|
29
29
|
**New here? [aimpg in 5 minutes](https://kumarganduri.github.io/aimpg-scoreboard/start/)** walks you from install to your first numbers, the live status line, and your team's GitHub Action, with copy buttons for every command.
|
|
30
30
|
|
|
31
|
+
**Using it as a GitHub Action?** Add `uses: kumarganduri/aimpg@v0.6.3` to a workflow and every pull request shows its AI cost, and every week your team's cost per working change. Copy the workflow from [Team numbers, without a server](#6-team-numbers-without-a-server).
|
|
32
|
+
|
|
31
33
|
```
|
|
32
34
|
YOUR AI CODING
|
|
33
35
|
Energy 17.31 kWh – 156.95 kWh
|
|
@@ -153,7 +155,7 @@ jobs:
|
|
|
153
155
|
steps:
|
|
154
156
|
- uses: actions/checkout@v4
|
|
155
157
|
with: {fetch-depth: 0}
|
|
156
|
-
- uses: kumarganduri/aimpg@v0.6.
|
|
158
|
+
- uses: kumarganduri/aimpg@v0.6.3 # or pin a commit SHA: it runs with your repo token
|
|
157
159
|
with:
|
|
158
160
|
mode: ${{ github.event_name == 'pull_request' && 'pr' || 'rollup' }}
|
|
159
161
|
comment: "true"
|
|
@@ -193,6 +195,7 @@ Questions people ask (is it sponsored, are the energy numbers guesses, is it saf
|
|
|
193
195
|
|
|
194
196
|
- Claude Code and Codex CLI logs are read automatically. Cursor keeps token usage on its servers, so it needs its usage export (below), and that export doesn't say which folder the work was in: give `--cursor-repo` and requests are matched to your next own commit there by time alone (shown as "by time only"). Replays and `verify` need macOS or Linux and an API key (about $0.10–0.30 per run). On Linux install bubblewrap (`sudo apt install bubblewrap`); Ubuntu 24.04+ also needs a one-time AppArmor rule for bwrap, which aimpg prints if it's missing.
|
|
195
197
|
- Windows: `report`, `live` and the team commands are untested there; replays and `verify` need macOS or Linux.
|
|
198
|
+
- Replays let code in the sandbox use localhost, because tests start local servers. On macOS that includes services already listening on your machine's localhost (a local database, for example). On Linux each run gets its own private localhost. The internet stays blocked except the model's API.
|
|
196
199
|
- Energy is an estimate with a wide range (model sizes are guesses within stated bounds); dollars are close (within about 7% of Claude Code's own session totals on the author's logs).
|
|
197
200
|
- Tips are upper bounds and overlap; they can't be added together.
|
|
198
201
|
- Replays from commit messages alone are hard (12% solved on the author's repo); `--task-mode tests` shows the agent the tests, which makes tasks easier than real work but keeps comparisons fair.
|
|
@@ -8,6 +8,8 @@ uvx aimpg report
|
|
|
8
8
|
|
|
9
9
|
**New here? [aimpg in 5 minutes](https://kumarganduri.github.io/aimpg-scoreboard/start/)** walks you from install to your first numbers, the live status line, and your team's GitHub Action, with copy buttons for every command.
|
|
10
10
|
|
|
11
|
+
**Using it as a GitHub Action?** Add `uses: kumarganduri/aimpg@v0.6.3` to a workflow and every pull request shows its AI cost, and every week your team's cost per working change. Copy the workflow from [Team numbers, without a server](#6-team-numbers-without-a-server).
|
|
12
|
+
|
|
11
13
|
```
|
|
12
14
|
YOUR AI CODING
|
|
13
15
|
Energy 17.31 kWh – 156.95 kWh
|
|
@@ -133,7 +135,7 @@ jobs:
|
|
|
133
135
|
steps:
|
|
134
136
|
- uses: actions/checkout@v4
|
|
135
137
|
with: {fetch-depth: 0}
|
|
136
|
-
- uses: kumarganduri/aimpg@v0.6.
|
|
138
|
+
- uses: kumarganduri/aimpg@v0.6.3 # or pin a commit SHA: it runs with your repo token
|
|
137
139
|
with:
|
|
138
140
|
mode: ${{ github.event_name == 'pull_request' && 'pr' || 'rollup' }}
|
|
139
141
|
comment: "true"
|
|
@@ -173,6 +175,7 @@ Questions people ask (is it sponsored, are the energy numbers guesses, is it saf
|
|
|
173
175
|
|
|
174
176
|
- Claude Code and Codex CLI logs are read automatically. Cursor keeps token usage on its servers, so it needs its usage export (below), and that export doesn't say which folder the work was in: give `--cursor-repo` and requests are matched to your next own commit there by time alone (shown as "by time only"). Replays and `verify` need macOS or Linux and an API key (about $0.10–0.30 per run). On Linux install bubblewrap (`sudo apt install bubblewrap`); Ubuntu 24.04+ also needs a one-time AppArmor rule for bwrap, which aimpg prints if it's missing.
|
|
175
177
|
- Windows: `report`, `live` and the team commands are untested there; replays and `verify` need macOS or Linux.
|
|
178
|
+
- Replays let code in the sandbox use localhost, because tests start local servers. On macOS that includes services already listening on your machine's localhost (a local database, for example). On Linux each run gets its own private localhost. The internet stays blocked except the model's API.
|
|
176
179
|
- Energy is an estimate with a wide range (model sizes are guesses within stated bounds); dollars are close (within about 7% of Claude Code's own session totals on the author's logs).
|
|
177
180
|
- Tips are upper bounds and overlap; they can't be added together.
|
|
178
181
|
- Replays from commit messages alone are hard (12% solved on the author's repo); `--task-mode tests` shows the agent the tests, which makes tasks easier than real work but keeps comparisons fair.
|
|
@@ -56,6 +56,8 @@ def run_chain(c1: Commit, c2: Commit, repeat: int, layout: Layout, cfg: Config,
|
|
|
56
56
|
if not session:
|
|
57
57
|
first.outcome, first.passed, first.note = "harness_error", False, "task 1 left no session id to resume"
|
|
58
58
|
return [first]
|
|
59
|
+
# keep task 1's totals: the resumed session's own totals include them
|
|
60
|
+
(run_dir / "prior-result.json").write_text((run_dir / "result.json").read_text())
|
|
59
61
|
cont = run_one(c2, CONTINUE, repeat, layout, cfg, attempt=attempt, solution=solutions.get(c2.sha),
|
|
60
62
|
run_id=f"{tag}-chain-continue-r{repeat}-a{attempt}", extra_args=("--resume", session),
|
|
61
63
|
resume_dir=run_dir, price_from_tokens=True)
|
|
@@ -45,6 +45,7 @@ def secure_root(root: Path | None = None) -> Path:
|
|
|
45
45
|
return root
|
|
46
46
|
STATE = Path.home() / ".aimpg" / "replay" # selections + results, unreadable to agents
|
|
47
47
|
DEFAULT_MODEL = "claude-sonnet-5-5"
|
|
48
|
+
SETUP_GIVE_UP = 5 # the first N candidates all fail to install: the repo can't be set up offline
|
|
48
49
|
DEFAULT_SETUPS = "claude-code,claude-code+terse,claude-code+rtk"
|
|
49
50
|
|
|
50
51
|
|
|
@@ -106,7 +107,7 @@ def _select(repo: Path, args) -> int:
|
|
|
106
107
|
commits, skipped = select.candidates(str(repo), time.time() - args.days * DAY, cutoff, any_author=any_author)
|
|
107
108
|
print(f"{len(commits)} candidates after cheap filters; skipped: {skipped or 'none'}")
|
|
108
109
|
layout = Layout(secure_root())
|
|
109
|
-
good = []
|
|
110
|
+
good, setup_failures = [], 0
|
|
110
111
|
with AllowlistProxy() as proxy:
|
|
111
112
|
for c in commits:
|
|
112
113
|
if len(good) >= args.max:
|
|
@@ -116,6 +117,11 @@ def _select(repo: Path, args) -> int:
|
|
|
116
117
|
print(f" {mark} {c.sha[:8]} {c.subject[:60]:<60} {'' if check.ok else check.reason}")
|
|
117
118
|
if check.ok:
|
|
118
119
|
good.append(c)
|
|
120
|
+
elif check.reason.startswith("setup failed") and not good:
|
|
121
|
+
setup_failures += 1
|
|
122
|
+
if setup_failures >= SETUP_GIVE_UP:
|
|
123
|
+
print(f" The first {SETUP_GIVE_UP} commits all fail to install here: giving up on this repo.")
|
|
124
|
+
break
|
|
119
125
|
out = _state(repo) / "selected.jsonl"
|
|
120
126
|
select.save(good, out)
|
|
121
127
|
checked = commits[: commits.index(good[-1]) + 1] if good else commits
|
|
@@ -109,6 +109,14 @@ def other_agent(mode: str, args: list[str]) -> int:
|
|
|
109
109
|
if mode == "nocredit":
|
|
110
110
|
print('{"type":"error","message":"You exceeded your current quota (insufficient_quota)"}')
|
|
111
111
|
return 1
|
|
112
|
+
if mode == "quota-in-log": # what the real Codex did in the main run: the error only in its session log
|
|
113
|
+
folder = cfg / "sessions" / "x"
|
|
114
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
(folder / "rollout-q.jsonl").write_text(json.dumps({"type": "event_msg", "payload": {
|
|
116
|
+
"type": "task_complete", "error": {"message": "Quota exceeded. Check your plan and billing details.",
|
|
117
|
+
"codex_error_info": "usage_limit_exceeded"}}}) + "\n")
|
|
118
|
+
print("Reading additional input from stdin...", file=sys.stderr)
|
|
119
|
+
return 1
|
|
112
120
|
model = args[args.index("-m") + 1] if "-m" in args else "gpt-fake"
|
|
113
121
|
codex_rollout(cfg, cwd, model, 3)
|
|
114
122
|
# its output can quote error text from the code it writes: never an account error
|
|
@@ -157,6 +157,11 @@ def write_stubs(hint: Hint, work, kind: str) -> None:
|
|
|
157
157
|
"""Create every hinted name with an empty value: tests must still FAIL on this."""
|
|
158
158
|
for path, names in hint.names.items():
|
|
159
159
|
target = work / path
|
|
160
|
+
if target.is_dir(): # `from "./src"` imports the folder's index file
|
|
161
|
+
index = [target / f"index{ext}" for ext in (".ts", ".tsx", ".mts", ".js", ".mjs", ".py")]
|
|
162
|
+
target = next((p for p in index if p.is_file()), None) or (target / "__init__.py" if kind == "python" else None)
|
|
163
|
+
if target is None:
|
|
164
|
+
continue
|
|
160
165
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
161
166
|
existing = target.read_text() if target.exists() else ""
|
|
162
167
|
if kind == "js":
|
|
@@ -7,7 +7,8 @@ host the agent wants to reach.
|
|
|
7
7
|
|
|
8
8
|
The allowlist changes per phase:
|
|
9
9
|
|
|
10
|
-
Phase A (prepare) pypi.org, files.pythonhosted.org / registry.npmjs.org
|
|
10
|
+
Phase A (prepare) pypi.org, files.pythonhosted.org / registry.npmjs.org, plus github.com
|
|
11
|
+
and codeload.github.com for dependencies pinned to a commit
|
|
11
12
|
Phase B (agent) the agent's API only (api.anthropic.com, api.openai.com, ...)
|
|
12
13
|
Phase C (judge) nothing
|
|
13
14
|
"""
|
|
@@ -22,8 +23,11 @@ import threading
|
|
|
22
23
|
from pathlib import Path
|
|
23
24
|
from dataclasses import dataclass, field
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
# Lockfiles may pin dependencies to exact commits on GitHub (litestar's do), so installs
|
|
27
|
+
# may also reach github.com and its tarball host. Install phase only; the agent never can.
|
|
28
|
+
GIT_HOSTS = frozenset({"github.com", "codeload.github.com"})
|
|
29
|
+
PYPI = frozenset({"pypi.org", "files.pythonhosted.org"}) | GIT_HOSTS
|
|
30
|
+
NPM = frozenset({"registry.npmjs.org"}) | GIT_HOSTS
|
|
27
31
|
ANTHROPIC = frozenset({"api.anthropic.com"})
|
|
28
32
|
OPENAI = frozenset({"api.openai.com"})
|
|
29
33
|
NOTHING: frozenset[str] = frozenset()
|
|
@@ -43,7 +43,8 @@ NOT_PASSED = ("tests_failed", "timeout", "budget_hit", "agent_error")
|
|
|
43
43
|
_ACCOUNT_ERROR = re.compile(r"credit balance|invalid x-api-key|invalid api key|authentication_error|permission_error|billing", re.I)
|
|
44
44
|
_ACCOUNT_STATUS = {401, 402, 403} # Claude Code's result carries api_error_status
|
|
45
45
|
# The same for OpenAI (Codex): no quota, bad key.
|
|
46
|
-
_OPENAI_ACCOUNT_ERROR = re.compile(r"insufficient_quota|invalid_api_key|incorrect api key|401 unauthorized|exceeded your current quota"
|
|
46
|
+
_OPENAI_ACCOUNT_ERROR = re.compile(r"insufficient_quota|invalid_api_key|incorrect api key|401 unauthorized|exceeded your current quota|"
|
|
47
|
+
r"quota exceeded|usage_limit_exceeded", re.I)
|
|
47
48
|
|
|
48
49
|
|
|
49
50
|
@dataclass
|
|
@@ -55,6 +56,9 @@ class Config:
|
|
|
55
56
|
parallel: int = 3
|
|
56
57
|
timeout: float = AGENT_TIMEOUT
|
|
57
58
|
task_mode: str = "hint" # "hint": message + interface hint, tests hidden | "tests": tests shown
|
|
59
|
+
# Claude Code's own --max-budget-usd uses Claude Code's price list, which the pilot found ~27x too
|
|
60
|
+
# high for Haiku 5.5; a cap at the agent's price would stop Haiku runs early. 0 = per_run_budget_usd.
|
|
61
|
+
agent_budget_flag_usd: float = 0.0
|
|
58
62
|
keys: dict[str, str] = field(default_factory=dict) # other agents' keys by env var (from the user's shell)
|
|
59
63
|
|
|
60
64
|
def key_for(self, env_name: str) -> str:
|
|
@@ -141,7 +145,15 @@ def _agent_env(work: Path, cfg: Path, proxy: AllowlistProxy, api_key: str, key_e
|
|
|
141
145
|
return env
|
|
142
146
|
|
|
143
147
|
|
|
144
|
-
def
|
|
148
|
+
def _prior_usage(cfgdir: Path) -> dict | None:
|
|
149
|
+
"""modelUsage of the step before a resumed one (its result.json is kept as prior-result.json)."""
|
|
150
|
+
try:
|
|
151
|
+
return json.loads((cfgdir.parent / "prior-result.json").read_text()).get("modelUsage")
|
|
152
|
+
except (OSError, ValueError):
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _check_tokens(requests, result: dict, offset: dict | None = None) -> tuple[str, bool]:
|
|
145
157
|
"""(tokens_check, is_harness_error). Is the transcript this run's, and complete?
|
|
146
158
|
|
|
147
159
|
Learned from the first paid calibration runs: Claude Code's `usage` field
|
|
@@ -157,6 +169,8 @@ def _check_tokens(requests, result: dict) -> tuple[str, bool]:
|
|
|
157
169
|
if isinstance(totals, dict) and totals:
|
|
158
170
|
keys = ("inputTokens", "cacheCreationInputTokens", "cacheReadInputTokens", "outputTokens", "thinkingTokens")
|
|
159
171
|
theirs = sum(int(m.get(k) or 0) for m in totals.values() if isinstance(m, dict) for k in keys)
|
|
172
|
+
if offset: # a resumed session's totals include the earlier step (found in the pilot)
|
|
173
|
+
theirs -= sum(int(m.get(k) or 0) for m in offset.values() if isinstance(m, dict) for k in keys)
|
|
160
174
|
if theirs == 0:
|
|
161
175
|
return "unverified (modelUsage is zero)", False
|
|
162
176
|
if abs(ours - theirs) / theirs > TOKEN_TOLERANCE:
|
|
@@ -239,7 +253,8 @@ def _run_agent(commit: Commit, setup: Setup, layout: Layout, cfg: Config, work:
|
|
|
239
253
|
proxy_socket=proxy.socket_path,
|
|
240
254
|
)
|
|
241
255
|
res = sandbox.run(
|
|
242
|
-
[*setup.argv(task or task_text(commit, cfg.task_mode), cfg.model, cfg.
|
|
256
|
+
[*setup.argv(task or task_text(commit, cfg.task_mode), cfg.model, cfg.agent_budget_flag_usd or cfg.per_run_budget_usd,
|
|
257
|
+
cfgdir), *extra_args],
|
|
243
258
|
profile=profile,
|
|
244
259
|
profile_path=layout.profile_path(run_id),
|
|
245
260
|
env=_agent_env(work, cfgdir, proxy, key, setup.key_env),
|
|
@@ -263,9 +278,13 @@ def _run_agent(commit: Commit, setup: Setup, layout: Layout, cfg: Config, work:
|
|
|
263
278
|
if since is not None: # only this step's requests (a resumed session's transcript holds the earlier step too)
|
|
264
279
|
parsed.requests = [r for r in parsed.requests if r.ts >= since]
|
|
265
280
|
usages = [(r.usage.fresh_in, r.usage.cache_write, r.usage.cache_read, r.usage.output) for r in parsed.requests]
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
281
|
+
# Every run is priced from its own tokens at the published price table, the same way for every
|
|
282
|
+
# setup. Claude Code's own total is kept for the record: the pilot found it ~27x too high for
|
|
283
|
+
# Haiku 5.5 while matching the table exactly for Sonnet.
|
|
284
|
+
reported = float(result.get("total_cost_usd") or 0.0)
|
|
285
|
+
priced = [usage_cost(r.usage, r.model) for r in parsed.requests]
|
|
286
|
+
cost = sum(p for p in priced if p is not None) if parsed.requests and all(p is not None for p in priced) else reported
|
|
287
|
+
measured["agent_reported_cost_usd"] = round(reported, 6)
|
|
269
288
|
note = f"blocked: {blocked[:5]}" if blocked else ""
|
|
270
289
|
if result.get("escapes"):
|
|
271
290
|
note += f" escapes: {result['escapes']}"
|
|
@@ -274,7 +293,7 @@ def _run_agent(commit: Commit, setup: Setup, layout: Layout, cfg: Config, work:
|
|
|
274
293
|
|
|
275
294
|
if res.timed_out:
|
|
276
295
|
return record("timeout", usages=usages, cost=cost, check="n/a (timed out)", note=note)
|
|
277
|
-
check, harness_error = _check_tokens(parsed.requests, result)
|
|
296
|
+
check, harness_error = _check_tokens(parsed.requests, result, offset=_prior_usage(cfgdir) if since is not None else None)
|
|
278
297
|
subtype = str(result.get("subtype", ""))
|
|
279
298
|
if "budget" in subtype:
|
|
280
299
|
return record("budget_hit", usages=usages, cost=cost, check=check, note=note)
|
|
@@ -309,7 +328,7 @@ def _finish_other(commit: Commit, setup: Setup, layout: Layout, work: Path, cfgd
|
|
|
309
328
|
cost = sum(p for p in prices if p is not None) if cost_known else 0.0
|
|
310
329
|
check = "unverified (codex rollout log)" if requests else "empty transcript"
|
|
311
330
|
note = f"blocked: {blocked[:5]}" if blocked else ""
|
|
312
|
-
out = _codex_errors(res.stdout or "", res.stderr or "") if setup.agent == "codex" else ""
|
|
331
|
+
out = _codex_errors(res.stdout or "", res.stderr or "", cfgdir / "sessions") if setup.agent == "codex" else ""
|
|
313
332
|
if res.timed_out:
|
|
314
333
|
return record("timeout", usages=usages, cost=cost, check=check, note=note, model=model, cost_known=cost_known)
|
|
315
334
|
if setup.agent == "codex" and _OPENAI_ACCOUNT_ERROR.search(out):
|
|
@@ -329,7 +348,7 @@ def _finish_other(commit: Commit, setup: Setup, layout: Layout, work: Path, cfgd
|
|
|
329
348
|
return rec
|
|
330
349
|
|
|
331
350
|
|
|
332
|
-
def _codex_errors(stdout: str, stderr: str) -> str:
|
|
351
|
+
def _codex_errors(stdout: str, stderr: str, sessions: Path | None = None) -> str:
|
|
333
352
|
"""Only Codex's own error events, never the code or command output it prints.
|
|
334
353
|
|
|
335
354
|
Found in the first real Codex run: the task was about handling OpenAI's
|
|
@@ -345,6 +364,17 @@ def _codex_errors(stdout: str, stderr: str) -> str:
|
|
|
345
364
|
if isinstance(event, dict) and event.get("type") in ("error", "turn.failed"):
|
|
346
365
|
lines.append(json.dumps(event.get("message") or event.get("error")))
|
|
347
366
|
lines += [l for l in stderr.splitlines() if " ERROR " in l or l.startswith("Error")]
|
|
367
|
+
# Codex can also end a turn with an error that only its session log records (found in the
|
|
368
|
+
# main run: "Quota exceeded ... usage_limit_exceeded" looked like a harness error)
|
|
369
|
+
for path in sorted(sessions.rglob("*.jsonl")) if sessions is not None and sessions.is_dir() else []:
|
|
370
|
+
for line in path.read_text(errors="replace").splitlines():
|
|
371
|
+
try:
|
|
372
|
+
event = json.loads(line)
|
|
373
|
+
except ValueError:
|
|
374
|
+
continue
|
|
375
|
+
payload = event.get("payload") if isinstance(event, dict) else None
|
|
376
|
+
if isinstance(payload, dict) and payload.get("type") in ("task_complete", "error", "stream_error") and payload.get("error"):
|
|
377
|
+
lines.append(json.dumps(payload.get("error")))
|
|
348
378
|
return "\n".join(lines)
|
|
349
379
|
|
|
350
380
|
|
|
@@ -161,6 +161,7 @@ class Profile:
|
|
|
161
161
|
deny_roots: list[Path] = field(default_factory=list)
|
|
162
162
|
proxy_port: int | None = None # None: no network at all
|
|
163
163
|
proxy_socket: Path | None = None # Linux: the proxy's Unix socket, relayed to 127.0.0.1:proxy_port inside
|
|
164
|
+
loopback: bool = True # localhost networking for tests' own servers (never the internet)
|
|
164
165
|
|
|
165
166
|
def render(self) -> str:
|
|
166
167
|
home = str(Path.home().resolve())
|
|
@@ -187,6 +188,9 @@ class Profile:
|
|
|
187
188
|
# other users' homes. This run's own folders are re-allowed below.
|
|
188
189
|
for root in [*shared, *deny_roots]:
|
|
189
190
|
lines.append(f'(deny file-read* file-write* (subpath "{root}"))')
|
|
191
|
+
# macOS's /usr/bin/git, clang etc. are xcrun launchers that keep a cache file in the user's
|
|
192
|
+
# temp folder; allow exactly those files, the rest of the folder stays hidden
|
|
193
|
+
lines.append('(allow file-read* file-write* (regex #"^/private/var/folders/[^/]+/[^/]+/T/xcrun_db"))')
|
|
190
194
|
for d in readable:
|
|
191
195
|
lines.append(f'(allow file-read* (subpath "{d}"))')
|
|
192
196
|
for d in writable:
|
|
@@ -194,7 +198,15 @@ class Profile:
|
|
|
194
198
|
for ancestor in sorted(_ancestors([*writable, *readable], [home, *shared, *deny_roots])):
|
|
195
199
|
lines.append(f'(allow file-read-metadata (literal "{ancestor}"))')
|
|
196
200
|
lines.append('(allow file-write-data (literal "/dev/null") (literal "/dev/zero"))')
|
|
197
|
-
if self.
|
|
201
|
+
if self.loopback:
|
|
202
|
+
# Tests and test plugins start servers on localhost (pytest-rerunfailures binds a socket at
|
|
203
|
+
# startup; axios's tests run local HTTP servers), and agents run those tests while working.
|
|
204
|
+
# The internet stays unreachable; the proxy port is on localhost too. macOS can't give a
|
|
205
|
+
# sandbox its own loopback, so services already listening on this machine's localhost are
|
|
206
|
+
# reachable (Linux runs get a private loopback).
|
|
207
|
+
lines += ["(allow system-socket)", '(allow network-bind (local ip "localhost:*"))',
|
|
208
|
+
'(allow network-inbound (local ip "localhost:*"))', '(allow network-outbound (remote ip "localhost:*"))']
|
|
209
|
+
elif self.proxy_port is not None:
|
|
198
210
|
lines += ["(allow system-socket)", f'(allow network-outbound (remote ip "localhost:{self.proxy_port}"))']
|
|
199
211
|
return "\n".join(lines) + "\n"
|
|
200
212
|
|
|
@@ -187,7 +187,7 @@ def holm(comparisons: list[Comparison], *, alpha: float = 0.05) -> None:
|
|
|
187
187
|
running = max(running, min(1.0, (m - i) * c.p_per_solved))
|
|
188
188
|
c.p_holm = running
|
|
189
189
|
for c in comparisons:
|
|
190
|
-
need =
|
|
190
|
+
need = 5 if c.repos >= 6 else c.repos + 1 # pre-registered: >= 6 repos, direction holding in >= 5 (pilot caught 1 repo passing)
|
|
191
191
|
ratio = {"per_solved": c.ratio_per_solved, "tokens": c.ratio_tokens, "per_attempt": c.ratio_per_attempt}[c.primary]
|
|
192
192
|
direction = c.cheaper_in_repos if (ratio or 1) < 1 else c.repos - c.cheaper_in_repos
|
|
193
193
|
c.headline = bool(c.p_holm is not None and c.p_holm < alpha and c.repos and direction >= need)
|
|
@@ -32,6 +32,7 @@ import os
|
|
|
32
32
|
import time
|
|
33
33
|
from pathlib import Path
|
|
34
34
|
|
|
35
|
+
from aimpg.gitkept import GitError
|
|
35
36
|
from aimpg.replay import chain, select, study
|
|
36
37
|
from aimpg.replay import run as runner
|
|
37
38
|
from aimpg.replay.cli import _select, _state, secure_root
|
|
@@ -72,16 +73,60 @@ def _out(plan: dict) -> Path:
|
|
|
72
73
|
def main(args) -> int:
|
|
73
74
|
plan = load_plan(args.plan)
|
|
74
75
|
if args.study_command == "select":
|
|
75
|
-
|
|
76
|
-
print(f"\n== {r['path']}")
|
|
77
|
-
_select(Path(r["path"]), argparse.Namespace(days=365, cutoff=plan["cutoff"], max=plan["commits_per_repo"] * 2,
|
|
78
|
-
any_author=True))
|
|
79
|
-
return 0
|
|
76
|
+
return _select_all(plan)
|
|
80
77
|
if args.study_command == "report":
|
|
81
78
|
return _report(plan)
|
|
82
79
|
return _run(plan, args)
|
|
83
80
|
|
|
84
81
|
|
|
82
|
+
MIN_QUALIFYING = 5 # pre-registered: fewer qualifying commits than this and the repo is replaced by the next reserve
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _qualified(repo: str) -> int:
|
|
86
|
+
path = _state(Path(repo)) / "selected.jsonl"
|
|
87
|
+
return len(select.load(path)) if path.exists() and path.read_text().strip() else 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _select_all(plan: dict) -> int:
|
|
91
|
+
"""Select each repo; apply the pre-registered replacement rule; write selection.json for `run`."""
|
|
92
|
+
reserves = [dict(r, path=str(Path(r["path"]).expanduser().resolve())) for r in plan.get("reserve_repos", [])]
|
|
93
|
+
summary = {"repos": [], "replaced": []}
|
|
94
|
+
queue = list(plan["repos"])
|
|
95
|
+
while queue:
|
|
96
|
+
r = queue.pop(0)
|
|
97
|
+
print(f"\n== {r['path']}")
|
|
98
|
+
if not Path(r["path"]).is_dir():
|
|
99
|
+
print(f" not cloned: git clone --filter=blob:none --single-branch {r.get('url')} {r['path']}")
|
|
100
|
+
n = 0
|
|
101
|
+
elif (_state(Path(r["path"])) / "selected.manifest.json").exists():
|
|
102
|
+
n = _qualified(r["path"]) # finished in an earlier run (the manifest is written last)
|
|
103
|
+
print(f" already selected: {n} qualifying (delete {_state(Path(r['path']))} to redo)")
|
|
104
|
+
else:
|
|
105
|
+
for attempt in range(3): # blobless clones fetch file contents on demand; GitHub sometimes drops one
|
|
106
|
+
try:
|
|
107
|
+
_select(Path(r["path"]), argparse.Namespace(days=365, cutoff=plan["cutoff"],
|
|
108
|
+
max=plan["commits_per_repo"] * 2, any_author=True))
|
|
109
|
+
break
|
|
110
|
+
except GitError as exc:
|
|
111
|
+
print(f" git failed ({str(exc)[:120]}); retrying in 30 s" if attempt < 2 else f" git failed 3 times: {exc}")
|
|
112
|
+
time.sleep(30)
|
|
113
|
+
n = _qualified(r["path"])
|
|
114
|
+
if n >= MIN_QUALIFYING:
|
|
115
|
+
summary["repos"].append({"path": r["path"], "url": r.get("url"), "qualifying": n})
|
|
116
|
+
elif reserves:
|
|
117
|
+
nxt = reserves.pop(0)
|
|
118
|
+
summary["replaced"].append({"repo": r.get("url") or r["path"], "qualifying": n, "replaced_by": nxt.get("url")})
|
|
119
|
+
print(f" {n} qualifying (< {MIN_QUALIFYING}): replaced by the next reserve, {nxt.get('url')}")
|
|
120
|
+
queue.insert(0, nxt)
|
|
121
|
+
else:
|
|
122
|
+
summary["replaced"].append({"repo": r.get("url") or r["path"], "qualifying": n, "replaced_by": None})
|
|
123
|
+
print(f" {n} qualifying (< {MIN_QUALIFYING}) and no reserves left: dropped")
|
|
124
|
+
(_out(plan) / "selection.json").write_text(json.dumps(summary, indent=1))
|
|
125
|
+
print(f"\nIn the study: {len(summary['repos'])} repos. Replaced: {len(summary['replaced'])}. "
|
|
126
|
+
f"Summary: {_out(plan) / 'selection.json'}")
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
85
130
|
def _selected(plan: dict, repo: str, n: int):
|
|
86
131
|
path = _state(Path(repo)) / "selected.jsonl"
|
|
87
132
|
if not path.exists():
|
|
@@ -96,7 +141,11 @@ def _setups(plan: dict):
|
|
|
96
141
|
|
|
97
142
|
def _run(plan: dict, args) -> int:
|
|
98
143
|
setups = _setups(plan)
|
|
99
|
-
|
|
144
|
+
chosen = _out(plan) / "selection.json"
|
|
145
|
+
if not chosen.exists():
|
|
146
|
+
raise SystemExit("run `aimpg study select` first")
|
|
147
|
+
active = json.loads(chosen.read_text())["repos"]
|
|
148
|
+
repos = active[:1] if args.pilot else active
|
|
100
149
|
n, repeats = (2, 1) if args.pilot else (plan["commits_per_repo"], plan["repeats"])
|
|
101
150
|
chains = 1 if args.pilot else plan.get("chains_per_repo", 0)
|
|
102
151
|
keys = {}
|
|
@@ -111,14 +160,20 @@ def _run(plan: dict, args) -> int:
|
|
|
111
160
|
cap = (min(plan["cap_usd"], 15.0) if args.pilot else plan["cap_usd"])
|
|
112
161
|
print(f"Study {plan['name']}{' (PILOT)' if args.pilot else ''}: {len(repos)} repos, {n_runs} agent runs, "
|
|
113
162
|
f"setups {', '.join(s.name for s in setups)}, {chains} fresh-session pairs per repo, task mode {plan.get('task_mode', 'hint')}")
|
|
114
|
-
|
|
163
|
+
out_dir = _out(plan) / ("pilot" if args.pilot else "runs")
|
|
164
|
+
done_runs = sum(len(runner.completed(out_dir / f"{Path(r['path']).name}.jsonl")) for r in repos) if out_dir.exists() else 0
|
|
165
|
+
if done_runs:
|
|
166
|
+
print(f"{done_runs} of those runs are already done (kept); only the rest will run.")
|
|
167
|
+
print(f"Total cap ${cap:.2f}, counted at the published API prices (each run reserves "
|
|
168
|
+
f"${plan.get('per_run_budget_usd', 2.0):.2f} until its real cost is known).")
|
|
115
169
|
if not args.yes and input("Spend up to that on your API keys? [y/N] ").strip().lower() != "y":
|
|
116
170
|
print("Stopped. Nothing was spent.")
|
|
117
171
|
return 1
|
|
118
172
|
out = _out(plan) / ("pilot" if args.pilot else "runs")
|
|
119
173
|
out.mkdir(exist_ok=True)
|
|
120
174
|
cfg = runner.Config(model="claude-sonnet-5-5", per_run_budget_usd=plan.get("per_run_budget_usd", 2.0), total_cap_usd=cap,
|
|
121
|
-
api_key=keys.get("ANTHROPIC_API_KEY", ""), task_mode=plan.get("task_mode", "hint"), keys=keys
|
|
175
|
+
api_key=keys.get("ANTHROPIC_API_KEY", ""), task_mode=plan.get("task_mode", "hint"), keys=keys,
|
|
176
|
+
agent_budget_flag_usd=plan.get("agent_budget_flag_usd", 50.0))
|
|
122
177
|
layout, spent = Layout(secure_root()), 0.0
|
|
123
178
|
for repo, commits in selected.items():
|
|
124
179
|
results = out / f"{Path(repo).name}.jsonl"
|
|
@@ -134,6 +134,10 @@ def _tool_env(work: Path) -> dict[str, str]:
|
|
|
134
134
|
npm_config_update_notifier="false",
|
|
135
135
|
npm_config_fund="false",
|
|
136
136
|
npm_config_audit="false",
|
|
137
|
+
# The workspace is a history-free export (no .git), so build backends that read the version
|
|
138
|
+
# from git (setuptools-scm, hatch-vcs: pyinfra) fail; give them a placeholder, the same for every setup
|
|
139
|
+
SETUPTOOLS_SCM_PRETEND_VERSION="0.0.0",
|
|
140
|
+
UV_DYNAMIC_VERSIONING_BYPASS="0.0.0", # uv-dynamic-versioning (pyinfra)
|
|
137
141
|
)
|
|
138
142
|
return env
|
|
139
143
|
|
|
@@ -161,6 +165,31 @@ def _check(result: sandbox.Result, what: str) -> None:
|
|
|
161
165
|
raise WorkspaceError(f"{what} failed: {(result.stderr or result.stdout)[-800:]}")
|
|
162
166
|
|
|
163
167
|
|
|
168
|
+
_GROUP = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9_.-]*)\s*=\s*\[", re.M)
|
|
169
|
+
_TEST_GROUP = re.compile(r"(^|[-_])(dev|tests?|testing)($|[-_])")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_groups(project: Path) -> list[str]:
|
|
173
|
+
"""uv flags for the dependency groups tests need: those named like dev/test/testing.
|
|
174
|
+
|
|
175
|
+
Installing every group pulled in docs tooling fetched straight from GitHub
|
|
176
|
+
(pydantic), which the install sandbox rightly can't reach. Falls back to all
|
|
177
|
+
groups when a project has none of those names.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
text = (project / "pyproject.toml").read_text()
|
|
181
|
+
except OSError:
|
|
182
|
+
return ["--all-groups"]
|
|
183
|
+
section = re.search(r"^\[dependency-groups\]\s*$(.*?)(?=^\[|\Z)", text, re.M | re.S)
|
|
184
|
+
names = _GROUP.findall(section.group(1)) if section else []
|
|
185
|
+
wanted = [n for n in names if _TEST_GROUP.search(n)]
|
|
186
|
+
if not names:
|
|
187
|
+
return []
|
|
188
|
+
if not wanted:
|
|
189
|
+
return ["--all-groups"]
|
|
190
|
+
return [f for n in wanted for f in ("--group", n)]
|
|
191
|
+
|
|
192
|
+
|
|
164
193
|
def prepare(commit: Commit, layout: Layout, proxy: AllowlistProxy) -> Path:
|
|
165
194
|
"""Phase A, once per commit: parent tree + dependencies from the commit's lockfile (R17)."""
|
|
166
195
|
dest = layout.prepared(commit.sha)
|
|
@@ -172,7 +201,7 @@ def prepare(commit: Commit, layout: Layout, proxy: AllowlistProxy) -> Path:
|
|
|
172
201
|
if commit.kind == "python":
|
|
173
202
|
proxy.set_allow(PYPI)
|
|
174
203
|
reqs = _commit_requirements(commit, layout)
|
|
175
|
-
_check(_sandboxed(layout, "prepare", dest, ["uv", "sync",
|
|
204
|
+
_check(_sandboxed(layout, "prepare", dest, ["uv", "sync", *test_groups(dest), "--quiet"], proxy=proxy, timeout=PREPARE_TIMEOUT), "uv sync")
|
|
176
205
|
if reqs:
|
|
177
206
|
(dest / ".tmp").mkdir(exist_ok=True)
|
|
178
207
|
(dest / ".tmp" / "commit-requirements.txt").write_text(reqs)
|
|
@@ -203,7 +232,7 @@ def _commit_requirements(commit: Commit, layout: Layout) -> str:
|
|
|
203
232
|
(tmp / "uv.lock").write_bytes(lock)
|
|
204
233
|
(tmp / "pyproject.toml").write_bytes(pyproject)
|
|
205
234
|
proc = subprocess.run(
|
|
206
|
-
["uv", "export", "--frozen", "--no-hashes", "--no-emit-project",
|
|
235
|
+
["uv", "export", "--frozen", "--no-hashes", "--no-emit-project", *test_groups(tmp), "--quiet"],
|
|
207
236
|
cwd=tmp,
|
|
208
237
|
capture_output=True,
|
|
209
238
|
text=True,
|
|
@@ -232,7 +261,7 @@ def clone_for_run(commit: Commit, layout: Layout, prepared: Path, run_id: str, *
|
|
|
232
261
|
subprocess.run([*clone, str(prepared), str(work)], check=True)
|
|
233
262
|
(work / ".prepared").unlink(missing_ok=True)
|
|
234
263
|
if commit.kind == "python":
|
|
235
|
-
_check(_sandboxed(layout, run_id, work, ["uv", "sync", "--offline",
|
|
264
|
+
_check(_sandboxed(layout, run_id, work, ["uv", "sync", "--offline", *test_groups(work), "--quiet"], proxy=None, timeout=300), "offline re-sync")
|
|
236
265
|
reqs = work / ".tmp" / "commit-requirements.txt"
|
|
237
266
|
if reqs.exists():
|
|
238
267
|
_check(_sandboxed(layout, run_id, work, ["uv", "pip", "install", "--offline", "--quiet", "-r", ".tmp/commit-requirements.txt"], proxy=None, timeout=300), "offline commit deps")
|
|
@@ -313,18 +342,21 @@ def overlay_commit_tree(commit: Commit, work: Path) -> None:
|
|
|
313
342
|
os.symlink(m.linkname, link)
|
|
314
343
|
|
|
315
344
|
|
|
316
|
-
def test_argv(commit: Commit) -> list[str]:
|
|
345
|
+
def test_argv(commit: Commit, work: Path | None = None) -> list[str]:
|
|
346
|
+
"""Run only the commit's test files, with coverage thresholds off: a repo that demands 100%
|
|
347
|
+
coverage (lint-staged) fails any subset run even when every test passes."""
|
|
317
348
|
files = [f for f in commit.test_files if f.endswith((".py", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".mts", ".cts"))]
|
|
318
349
|
if commit.runner == "pytest":
|
|
319
|
-
|
|
350
|
+
has_cov = work is not None and any((work / ".venv").glob("lib/python*/site-packages/pytest_cov"))
|
|
351
|
+
return [".venv/bin/python", "-m", "pytest", "-q", "-p", "no:cacheprovider", *(["--no-cov"] if has_cov else []), *files]
|
|
320
352
|
if commit.runner == "vitest":
|
|
321
|
-
return ["node_modules/.bin/vitest", "run", *files]
|
|
322
|
-
return ["node_modules/.bin/jest", "--ci", *files]
|
|
353
|
+
return ["node_modules/.bin/vitest", "run", "--coverage.enabled=false", *files]
|
|
354
|
+
return ["node_modules/.bin/jest", "--ci", "--coverage=false", *files]
|
|
323
355
|
|
|
324
356
|
|
|
325
357
|
def judge(commit: Commit, layout: Layout, work: Path, name: str) -> tuple[bool, str]:
|
|
326
358
|
"""Phase C: run only the commit's test files, no network. (passed, tail of output)."""
|
|
327
|
-
result = _sandboxed(layout, name, work, test_argv(commit), proxy=None, timeout=JUDGE_TIMEOUT, extra_env={"CI": "1"})
|
|
359
|
+
result = _sandboxed(layout, name, work, test_argv(commit, work), proxy=None, timeout=JUDGE_TIMEOUT, extra_env={"CI": "1"})
|
|
328
360
|
tail = (result.stdout + result.stderr)[-600:]
|
|
329
361
|
return (not result.timed_out and result.returncode == 0), tail
|
|
330
362
|
|
|
@@ -25,7 +25,7 @@ A bill tells you the total. aimpg tells you what each change that shipped and la
|
|
|
25
25
|
Maybe not. aimpg tells you how much of your agents' tokens were conversation carried over from before your last commit (57% for the author). It doesn't claim clearing saves all of that: a fresh session has to rebuild some context. Report No. 1 includes an experiment that measures the real difference on real commits.
|
|
26
26
|
|
|
27
27
|
**Is it safe to run?**
|
|
28
|
-
`report`, `live`, `sci` and `defaults` only read local logs and git. Replays run agents in a sandbox: Seatbelt on macOS, bubblewrap on Linux. The sandbox hides your home folder, temp folders and other users' files, and only reaches the model's API through an allowlisting proxy. A pre-launch security review found and fixed three high-severity issues (in 0.6.1), each with a regression test. `--rerun` of someone else's record shows every command, hook and prompt it would run, and asks first.
|
|
28
|
+
`report`, `live`, `sci` and `defaults` only read local logs and git. Replays run agents in a sandbox: Seatbelt on macOS, bubblewrap on Linux. The sandbox hides your home folder, temp folders and other users' files, and only reaches the model's API through an allowlisting proxy. A pre-launch security review found and fixed three high-severity issues (in 0.6.1), each with a regression test. `--rerun` of someone else's record shows every command, hook and prompt it would run, and asks first. One limit: tests start local servers, so the sandbox allows localhost. On macOS that means services already listening on your machine's localhost are reachable from inside; on Linux each run gets its own private localhost.
|
|
29
29
|
|
|
30
30
|
**What leaves my machine?**
|
|
31
31
|
Nothing, unless you choose:
|
|
@@ -61,9 +61,9 @@ One maintainer; no budget beyond a few hundred dollars of API credit; must stay
|
|
|
61
61
|
- the stopping rule and Holm correction;
|
|
62
62
|
- the RTK config approved by its maintainers (asked 2026-10-11: https://github.com/rtk-ai/rtk/discussions/4511; proceed with the stated config if no reply by 2026-10-18 and say so);
|
|
63
63
|
- headline only if the direction holds in at least 5 of 6 repos.
|
|
64
|
-
- [ ] D.
|
|
64
|
+
- [ ] D. Pilot done 2026-10-13 ($1.95 total; found and fixed Claude Code's ~27x Haiku 5.5 price, the resumed-session check and the headline rule). Then the full study: about 55 commits × 3 repeats × (Haiku, Sonnet, Opus, Codex, RTK), plus the fresh-session chains. $80–250, approved by the user in the terminal.
|
|
65
65
|
- [ ] E. At least 2–3 reruns by unaffiliated people, so "Reproduced" exists; friends' reruns are labeled as reruns. Get one team quote if possible.
|
|
66
|
-
- [ ] F. Assets:
|
|
66
|
+
- [ ] F. Assets (done 2026-10-11: share card, PR footer, Marketplace listing https://github.com/marketplace/actions/ai-coding-mpg, FAQ, Bring Your Claim rules; to do once results exist: report page, clip, post drafts):
|
|
67
67
|
- the Report No. 1 page on the scoreboard site, with charts and a forest plot;
|
|
68
68
|
- a 20-second clip;
|
|
69
69
|
- `aimpg report --share` card;
|