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.
Files changed (109) hide show
  1. {aimpg-0.6.2 → aimpg-0.7.0}/PKG-INFO +5 -2
  2. {aimpg-0.6.2 → aimpg-0.7.0}/README.md +4 -1
  3. {aimpg-0.6.2 → aimpg-0.7.0}/action.yml +1 -1
  4. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/chain.py +2 -0
  5. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/cli.py +7 -1
  6. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/fake_agent.py +8 -0
  7. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/hints.py +5 -0
  8. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/proxy.py +7 -3
  9. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/run.py +39 -9
  10. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/sandbox.py +13 -1
  11. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/study.py +1 -1
  12. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/study_cli.py +63 -8
  13. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/workspace.py +40 -8
  14. {aimpg-0.6.2 → aimpg-0.7.0}/docs/FAQ.md +1 -1
  15. {aimpg-0.6.2 → aimpg-0.7.0}/docs/designs/launch-brief.md +2 -2
  16. aimpg-0.7.0/docs/study/report-1/PREREGISTRATION.md +124 -0
  17. aimpg-0.7.0/docs/study/report-1/selection/axios.manifest.json +57 -0
  18. aimpg-0.7.0/docs/study/report-1/selection/click.manifest.json +40 -0
  19. aimpg-0.7.0/docs/study/report-1/selection/homebridge.manifest.json +37 -0
  20. aimpg-0.7.0/docs/study/report-1/selection/lint-staged.manifest.json +49 -0
  21. aimpg-0.7.0/docs/study/report-1/selection/litestar.manifest.json +39 -0
  22. aimpg-0.7.0/docs/study/report-1/selection/pydantic.manifest.json +93 -0
  23. aimpg-0.7.0/docs/study/report-1/selection/pyinfra.manifest.json +39 -0
  24. aimpg-0.7.0/docs/study/report-1/selection/recharts.manifest.json +43 -0
  25. aimpg-0.7.0/docs/study/report-1/selection/starlette.manifest.json +37 -0
  26. aimpg-0.7.0/docs/study/report-1/selection.json +51 -0
  27. {aimpg-0.6.2 → aimpg-0.7.0}/pyproject.toml +1 -1
  28. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_replay_e2e.py +6 -0
  29. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_replay_sandbox.py +13 -0
  30. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_study.py +6 -0
  31. {aimpg-0.6.2 → aimpg-0.7.0}/uv.lock +1 -1
  32. aimpg-0.6.2/docs/study/report-1/PREREGISTRATION.md +0 -73
  33. {aimpg-0.6.2 → aimpg-0.7.0}/.github/workflows/publish.yml +0 -0
  34. {aimpg-0.6.2 → aimpg-0.7.0}/.github/workflows/test.yml +0 -0
  35. {aimpg-0.6.2 → aimpg-0.7.0}/.gitignore +0 -0
  36. {aimpg-0.6.2 → aimpg-0.7.0}/LICENSE +0 -0
  37. {aimpg-0.6.2 → aimpg-0.7.0}/TODOS.md +0 -0
  38. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/__init__.py +0 -0
  39. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/attribution.py +0 -0
  40. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/card.py +0 -0
  41. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/cli.py +0 -0
  42. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/coach.py +0 -0
  43. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/codex_logs.py +0 -0
  44. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/cost.py +0 -0
  45. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/cursor_usage.py +0 -0
  46. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/defaults.py +0 -0
  47. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/durable.py +0 -0
  48. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/energy.py +0 -0
  49. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/equivalence.py +0 -0
  50. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/equivalences.json +0 -0
  51. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/factors.json +0 -0
  52. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/gitkept.py +0 -0
  53. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/ledger.py +0 -0
  54. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/live.py +0 -0
  55. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/logs.py +0 -0
  56. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/model.py +0 -0
  57. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/prices.json +0 -0
  58. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/receipt.py +0 -0
  59. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/__init__.py +0 -0
  60. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/picker.py +0 -0
  61. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/select.py +0 -0
  62. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/setups.py +0 -0
  63. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/stats.py +0 -0
  64. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/replay/verify.py +0 -0
  65. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/sci.py +0 -0
  66. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/scoreboard.py +0 -0
  67. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/share.py +0 -0
  68. {aimpg-0.6.2 → aimpg-0.7.0}/aimpg/team.py +0 -0
  69. {aimpg-0.6.2 → aimpg-0.7.0}/docs/STRATEGY.md +0 -0
  70. {aimpg-0.6.2 → aimpg-0.7.0}/docs/designs/aimpg-design.md +0 -0
  71. {aimpg-0.6.2 → aimpg-0.7.0}/docs/designs/scoreboard-brief.md +0 -0
  72. {aimpg-0.6.2 → aimpg-0.7.0}/docs/receipt-card.png +0 -0
  73. {aimpg-0.6.2 → aimpg-0.7.0}/docs/study/report-1/plan.json +0 -0
  74. {aimpg-0.6.2 → aimpg-0.7.0}/evals/attribution_eval.py +0 -0
  75. {aimpg-0.6.2 → aimpg-0.7.0}/evals/label.py +0 -0
  76. {aimpg-0.6.2 → aimpg-0.7.0}/spikes/agent/RESULTS.md +0 -0
  77. {aimpg-0.6.2 → aimpg-0.7.0}/spikes/agent/allowlist_proxy.py +0 -0
  78. {aimpg-0.6.2 → aimpg-0.7.0}/spikes/agent/make_profile.py +0 -0
  79. {aimpg-0.6.2 → aimpg-0.7.0}/spikes/egress/RESULTS.md +0 -0
  80. {aimpg-0.6.2 → aimpg-0.7.0}/spikes/egress/allowlist_proxy.py +0 -0
  81. {aimpg-0.6.2 → aimpg-0.7.0}/spikes/egress/replay.sb +0 -0
  82. {aimpg-0.6.2 → aimpg-0.7.0}/tests/conftest.py +0 -0
  83. {aimpg-0.6.2 → aimpg-0.7.0}/tests/gitrepo.py +0 -0
  84. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_attribution.py +0 -0
  85. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_card.py +0 -0
  86. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_coach.py +0 -0
  87. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_codex_logs.py +0 -0
  88. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_cost_and_equivalence.py +0 -0
  89. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_cursor_usage.py +0 -0
  90. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_defaults.py +0 -0
  91. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_durable.py +0 -0
  92. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_energy.py +0 -0
  93. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_eval_scoring.py +0 -0
  94. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_gitkept.py +0 -0
  95. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_live.py +0 -0
  96. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_logs.py +0 -0
  97. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_perf.py +0 -0
  98. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_replay_cli.py +0 -0
  99. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_replay_hints.py +0 -0
  100. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_replay_picker.py +0 -0
  101. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_replay_proxy.py +0 -0
  102. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_replay_stats.py +0 -0
  103. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_sandbox_required.py +0 -0
  104. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_sci.py +0 -0
  105. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_scoreboard.py +0 -0
  106. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_security.py +0 -0
  107. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_share.py +0 -0
  108. {aimpg-0.6.2 → aimpg-0.7.0}/tests/test_team.py +0 -0
  109. {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.6.2
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.2 # or pin a commit SHA: it runs with your repo token
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.2 # or pin a commit SHA: it runs with your repo token
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.
@@ -1,4 +1,4 @@
1
- name: aimpg
1
+ name: AI Coding MPG
2
2
  description: AI cost and energy of pull requests and of your team (cost per working change), from aimpg git notes
3
3
  author: Kumar Ganduri
4
4
  branding:
@@ -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
- PYPI = frozenset({"pypi.org", "files.pythonhosted.org"})
26
- NPM = frozenset({"registry.npmjs.org"})
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", re.I)
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 _check_tokens(requests, result: dict) -> tuple[str, bool]:
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.per_run_budget_usd, cfgdir), *extra_args],
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
- cost = float(result.get("total_cost_usd") or 0.0)
267
- if since is not None:
268
- cost = sum(usage_cost(r.usage, r.model) or 0.0 for r in parsed.requests)
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.proxy_port is not None:
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 = min(5, c.repos) if c.repos >= 6 else c.repos
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
- for r in plan["repos"]:
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
- repos = plan["repos"][:1] if args.pilot else plan["repos"]
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
- print(f"Total cap ${cap:.2f}; Claude Code runs stop at ${plan.get('per_run_budget_usd', 2.0):.2f} each.")
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", "--all-groups", "--quiet"], proxy=proxy, timeout=PREPARE_TIMEOUT), "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", "--all-groups", "--quiet"],
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", "--all-groups", "--quiet"], proxy=None, timeout=300), "offline re-sync")
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
- return [".venv/bin/python", "-m", "pytest", "-q", "-p", "no:cacheprovider", *files]
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. A pilot (about $10) to size n, 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.
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;