bigpowers 2.89.1 → 2.90.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.89.1",
3
+ "version": "2.90.1",
4
4
  "description": "80 skills — 73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "keywords": [
6
6
  "gemini-extension"
package/.pi/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.89.1",
3
+ "version": "2.90.1",
4
4
  "description": "80 skills — 73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "keywords": [
6
6
  "pi-package"
package/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ ## [2.90.1](https://github.com/danielvm-git/bigpowers/compare/v2.90.0...v2.90.1) (2026-11-03)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **omp:** keep the tool_call branch probe quiet outside a git repo ([#190](https://github.com/danielvm-git/bigpowers/issues/190)) ([bcacf33](https://github.com/danielvm-git/bigpowers/commit/bcacf33ec72a8705c6b0dbbcf91f2ea2bed4b406)), closes [#189](https://github.com/danielvm-git/bigpowers/issues/189)
7
+
8
+ # [2.90.0](https://github.com/danielvm-git/bigpowers/compare/v2.89.1...v2.90.0) (2026-11-03)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **scripts:** name the failing test when story verify goes red ([#188](https://github.com/danielvm-git/bigpowers/issues/188)) ([45fe450](https://github.com/danielvm-git/bigpowers/commit/45fe4505caea2522038951d5774c6a5b5ae5cb57))
14
+
15
+
16
+ ### Features
17
+
18
+ * **benchmarks:** run the A/B eval in a Docker sandbox (e58s07) ([#187](https://github.com/danielvm-git/bigpowers/issues/187)) ([9377f16](https://github.com/danielvm-git/bigpowers/commit/9377f1642682afcde5387afbfb9373f11b0ec40c))
19
+
1
20
  ## [2.89.1](https://github.com/danielvm-git/bigpowers/compare/v2.89.0...v2.89.1) (2026-11-03)
2
21
 
3
22
 
@@ -205,11 +205,19 @@ function inGitRepo(cwd?: string): boolean {
205
205
  }
206
206
 
207
207
  function currentBranch(cwd: string): string {
208
+ // Probing a directory that is not a repository makes git print
209
+ // "fatal: not a git repository (or any of the parent directories): .git".
210
+ // execSync inherits fd 2 when no stdio option is given, so that message is
211
+ // painted straight over the TUI input line and vanishes on the next redraw
212
+ // (BUG-2026-11-03-omp-hooks-stderr-leak, issue #189). Ask inGitRepo() first —
213
+ // it is silent — so a non-repo target spawns no git process at all.
214
+ if (!inGitRepo(cwd)) return "unknown";
208
215
  try {
209
216
  return execSync("git rev-parse --abbrev-ref HEAD", {
210
217
  encoding: "utf-8",
211
218
  timeout: 3000,
212
219
  cwd,
220
+ stdio: ["ignore", "pipe", "ignore"],
213
221
  }).trim();
214
222
  } catch {
215
223
  return "unknown";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.89.1",
3
+ "version": "2.90.1",
4
4
  "description": "73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bash
2
+ # story: e58s07
3
+ # ab-sandbox.sh — up|down|status for the A/B eval Docker sandbox.
4
+ #
5
+ # up builds the pinned agent and proxy images, creates an internal network (no route to the
6
+ # internet) plus a bridge only the proxy joins, and starts the proxy.
7
+ # down stops the proxy and removes the networks. Images stay cached.
8
+ # status exit 0 only when both images exist, both networks exist and the proxy is running.
9
+ set -euo pipefail
10
+
11
+ AGENT_IMAGE="bp-ab-agent:pi-1.5.0"
12
+ PROXY_IMAGE="bp-ab-proxy:tinyproxy-1"
13
+ PROXY_NAME="bp-ab-proxy"
14
+ INTERNAL_NET="bp-ab-internal"
15
+ EGRESS_NET="bp-ab-egress"
16
+ ASSETS="$(cd "$(dirname "${BASH_SOURCE[0]}")/lib/ab-sandbox" && pwd)"
17
+
18
+ die() { echo "ERROR: $*" >&2; exit 1; }
19
+
20
+ have_image() { docker image inspect "$1" >/dev/null 2>&1; }
21
+ have_net() { docker network inspect "$1" >/dev/null 2>&1; }
22
+ proxy_running() { [ "$(docker inspect -f '{{.State.Running}}' "$PROXY_NAME" 2>/dev/null)" = "true" ]; }
23
+
24
+ build() {
25
+ echo "Building $AGENT_IMAGE"
26
+ docker build -q -f "$ASSETS/agent.Dockerfile" -t "$AGENT_IMAGE" "$ASSETS" >/dev/null
27
+ echo "Building $PROXY_IMAGE"
28
+ docker build -q -f "$ASSETS/proxy.Dockerfile" -t "$PROXY_IMAGE" "$ASSETS" >/dev/null
29
+ }
30
+
31
+ ensure_networks() {
32
+ have_net "$INTERNAL_NET" || docker network create --internal "$INTERNAL_NET" >/dev/null
33
+ have_net "$EGRESS_NET" || docker network create "$EGRESS_NET" >/dev/null
34
+ }
35
+
36
+ wait_for_proxy() {
37
+ local i
38
+ for i in $(seq 1 30); do
39
+ if docker exec "$PROXY_NAME" sh -c 'nc -z 127.0.0.1 8888' >/dev/null 2>&1; then
40
+ echo "Proxy listening on $INTERNAL_NET:8888"
41
+ return 0
42
+ fi
43
+ sleep 1
44
+ done
45
+ docker logs "$PROXY_NAME" >&2 || true
46
+ die "proxy did not start listening"
47
+ }
48
+
49
+ cmd_up() {
50
+ build
51
+ ensure_networks
52
+ # The proxy joins the internal network first, then the bridge: the agent side can only see it.
53
+ docker rm -f "$PROXY_NAME" >/dev/null 2>&1 || true
54
+ docker run -d --name "$PROXY_NAME" --network "$INTERNAL_NET" --restart unless-stopped \
55
+ "$PROXY_IMAGE" >/dev/null
56
+ docker network connect "$EGRESS_NET" "$PROXY_NAME"
57
+ wait_for_proxy
58
+ echo "Sandbox up: $AGENT_IMAGE on $INTERNAL_NET (internet only via $PROXY_NAME, allowlist: opencode.ai)"
59
+ }
60
+
61
+ cmd_down() {
62
+ docker rm -f "$PROXY_NAME" >/dev/null 2>&1 || true
63
+ docker network rm "$INTERNAL_NET" >/dev/null 2>&1 || true
64
+ docker network rm "$EGRESS_NET" >/dev/null 2>&1 || true
65
+ echo "Sandbox down (images kept)"
66
+ }
67
+
68
+ cmd_status() {
69
+ local ok=0
70
+ for image in "$AGENT_IMAGE" "$PROXY_IMAGE"; do
71
+ if have_image "$image"; then echo "image $image"; else echo "MISSING $image"; ok=1; fi
72
+ done
73
+ for net in "$INTERNAL_NET" "$EGRESS_NET"; do
74
+ if have_net "$net"; then echo "network $net"; else echo "MISSING $net"; ok=1; fi
75
+ done
76
+ if proxy_running; then echo "proxy $PROXY_NAME running"; else echo "MISSING $PROXY_NAME running"; ok=1; fi
77
+ return "$ok"
78
+ }
79
+
80
+ case "${1:-status}" in
81
+ up) cmd_up ;;
82
+ down) cmd_down ;;
83
+ status) cmd_status ;;
84
+ *) die "usage: $(basename "$0") up|down|status" ;;
85
+ esac
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bash
2
+ # story: e58s01 e58s04
3
+ # ab-arm-bigpowers.sh — provision an A/B eval workdir the way a bigpowers user has it.
4
+ # Runs inside the workdir. $AB_BP_ROOT is the bigpowers checkout.
5
+ # Copies (never symlinks): the agent must not be able to walk back into the repo,
6
+ # where the hidden tests live.
7
+ #
8
+ # Ablation options (e58s04):
9
+ # AB_BP_SKILLS all (default) | none | comma list of skill names
10
+ # AB_BP_SEED seed file relative to $AB_BP_ROOT (default: the bigpowers arm seed) | none
11
+ # scripts/ is copied only when at least one skill is.
12
+ set -euo pipefail
13
+ : "${AB_BP_ROOT:?AB_BP_ROOT must point at the bigpowers checkout}"
14
+ SKILLS="${AB_BP_SKILLS:-all}"
15
+ SEED="${AB_BP_SEED:-specs/benchmarks/ab/arms/bigpowers/CLAUDE.md}"
16
+
17
+ skill_dirs() {
18
+ case "$SKILLS" in
19
+ all) for d in "$AB_BP_ROOT"/skills/*/; do echo "${d%/}"; done ;;
20
+ none) ;;
21
+ *) for name in ${SKILLS//,/ }; do
22
+ [[ -d "$AB_BP_ROOT/skills/$name" ]] || { echo "unknown skill: $name" >&2; exit 2; }
23
+ echo "$AB_BP_ROOT/skills/$name"
24
+ done ;;
25
+ esac
26
+ }
27
+
28
+ mkdir -p specs/bugs specs/verifications
29
+ dirs="$(skill_dirs)"
30
+ if [[ -n "$dirs" ]]; then
31
+ mkdir -p .claude/skills .agents/skills
32
+ while IFS= read -r skill_dir; do
33
+ name="$(basename "$skill_dir")"
34
+ cp -R "$skill_dir" ".claude/skills/$name"
35
+ cp -R "$skill_dir" ".agents/skills/$name"
36
+ done <<< "$dirs"
37
+ cp -R "$AB_BP_ROOT/scripts" scripts
38
+ fi
39
+ if [[ "$SEED" != "none" ]]; then
40
+ cp "$AB_BP_ROOT/$SEED" CLAUDE.md
41
+ cp CLAUDE.md AGENTS.md
42
+ fi
@@ -0,0 +1,285 @@
1
+ #!/usr/bin/env python3
2
+ # story: e58s01 e58s02 e58s03 e58s05 e58s06 e58s07
3
+ """A/B eval: run the same tasks with and without bigpowers and grade only the outcome.
4
+
5
+ Each run gets a fresh temp git workdir (outside the repo) copied from the task fixture.
6
+ The arm setup runs, the agent runs through an adapter command, then hidden tests are
7
+ copied in and the task's grade command decides pass/fail. Advisory: exit 0 after a
8
+ completed run, 2 on configuration errors.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import gzip
14
+ import json
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ import time
21
+ from datetime import datetime, timezone
22
+ from difflib import SequenceMatcher
23
+ from pathlib import Path
24
+
25
+ import yaml
26
+
27
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
28
+ from ab_metrics import parse_metrics, tap_score # type: ignore[import] # noqa: E402
29
+ from ab_report import ( # type: ignore[import] # noqa: E402
30
+ BOOTSTRAP_RESAMPLES, BOOTSTRAP_SEED, aggregate, bigpowers_sha, print_summary, write_reports,
31
+ )
32
+
33
+ REPO_ROOT = Path(__file__).resolve().parents[2]
34
+ AB_DIR = REPO_ROOT / "specs" / "benchmarks" / "ab"
35
+ DEFAULT_CONFIG = AB_DIR / "config.yaml"
36
+ DEFAULT_TASKS_DIR = AB_DIR / "tasks"
37
+ DEFAULT_OUT = REPO_ROOT / "specs" / "benchmarks" / "reports"
38
+ DEFAULT_ARMS = "vanilla,bigpowers"
39
+ GIT_ID = ["-c", "user.name=ab-eval", "-c", "user.email=ab-eval@localhost", "-c", "commit.gpgsign=false"]
40
+
41
+
42
+ class ConfigError(Exception):
43
+ pass
44
+
45
+
46
+ # ── loading ──────────────────────────────────────────────────────────────────
47
+
48
+ def load_yaml(path: Path) -> dict:
49
+ if not path.is_file():
50
+ raise ConfigError(f"missing file: {path}")
51
+ return yaml.safe_load(path.read_text()) or {}
52
+
53
+
54
+ def load_tasks(tasks_dir: Path, wanted: list[str] | None) -> list[dict]:
55
+ tasks = {}
56
+ for task_yaml in sorted(tasks_dir.glob("*/task.yaml")):
57
+ task = load_yaml(task_yaml)
58
+ task["dir"] = task_yaml.parent
59
+ tasks[task["id"]] = task
60
+ if not wanted:
61
+ return list(tasks.values())
62
+ unknown = [t for t in wanted if t not in tasks]
63
+ if unknown:
64
+ raise ConfigError(f"unknown task(s) {unknown}; valid: {sorted(tasks)}")
65
+ return [tasks[t] for t in wanted]
66
+
67
+
68
+ def pick(section: dict, names: list[str], kind: str) -> dict:
69
+ unknown = [n for n in names if n not in section]
70
+ if unknown:
71
+ raise ConfigError(f"unknown {kind}(s) {unknown}; valid: {sorted(section)}")
72
+ return {n: section[n] for n in names}
73
+
74
+
75
+ def resolve_adapter(config: dict, name: str, custom_cmd: str | None, parser: str | None) -> dict:
76
+ if custom_cmd:
77
+ return {"name": "custom", "command": custom_cmd, "parser": parser or "none"}
78
+ adapter = dict(pick(config.get("adapters", {}), [name], "adapter")[name])
79
+ adapter["name"] = name
80
+ if parser:
81
+ adapter["parser"] = parser
82
+ return adapter
83
+
84
+
85
+ # ── one run ──────────────────────────────────────────────────────────────────
86
+
87
+ def sh(cmd: str, cwd: Path, env: dict | None = None, timeout: float | None = None):
88
+ # stdin closed: `opencode run` otherwise waits on an open stdin forever (e58s02).
89
+ return subprocess.run(["bash", "-c", cmd], cwd=cwd, env=env, stdin=subprocess.DEVNULL,
90
+ capture_output=True, text=True, timeout=timeout)
91
+
92
+
93
+ def git(workdir: Path, *args: str) -> str:
94
+ res = subprocess.run(["git", *GIT_ID, *args], cwd=workdir, capture_output=True, text=True, check=True)
95
+ return res.stdout.strip()
96
+
97
+
98
+ def snapshot(workdir: Path, message: str) -> str:
99
+ git(workdir, "add", "-A")
100
+ git(workdir, "commit", "-q", "--allow-empty", "--no-verify", "-m", message)
101
+ return git(workdir, "rev-parse", "HEAD")
102
+
103
+
104
+ def file_lines(workdir: Path, paths: list[str]) -> dict:
105
+ return {rel: (workdir / rel).read_text().splitlines() if (workdir / rel).is_file() else None
106
+ for rel in paths}
107
+
108
+
109
+ def protected_changes(workdir: Path, before: dict) -> tuple[bool, int]:
110
+ """Tampered = an original line removed or changed. Added lines are counted, not flagged:
111
+ a TDD arm adds tests to the visible files, and hidden tests decide the grade (e58s02)."""
112
+ tampered, added = False, 0
113
+ for rel, now in file_lines(workdir, list(before)).items():
114
+ if before[rel] is None or now is None:
115
+ tampered = tampered or before[rel] is not None
116
+ continue
117
+ for op, _, _, j1, j2 in SequenceMatcher(None, before[rel], now, autojunk=False).get_opcodes():
118
+ added += j2 - j1 if op == "insert" else 0
119
+ tampered = tampered or op not in ("equal", "insert")
120
+ return tampered, added
121
+
122
+
123
+ def changed_lines(workdir: Path, base: str) -> int:
124
+ git(workdir, "add", "-A")
125
+ total = 0
126
+ for line in git(workdir, "diff", "--cached", "--numstat", base).splitlines():
127
+ added, deleted, _ = line.split("\t", 2)
128
+ total += (int(added) if added.isdigit() else 0) + (int(deleted) if deleted.isdigit() else 0)
129
+ return total
130
+
131
+
132
+ def save_trace(path: Path, stdout: str) -> None:
133
+ """Agent transcript for later process analysis (e58s05)."""
134
+ path.parent.mkdir(parents=True, exist_ok=True)
135
+ path.write_bytes(gzip.compress(stdout.encode()))
136
+
137
+
138
+ def env_for(prompt: str, model: str | None, workdir: Path, home: Path, timeout_s: int = 900) -> dict:
139
+ """$AB_HOME is a fresh empty dir per run: adapters point HOME at it to hide global skills.
140
+ $AB_TIMEOUT leaves 30 s for an in-container `timeout` to stop the agent first (e58s07)."""
141
+ env = dict(os.environ)
142
+ env.update({"AB_PROMPT": prompt, "AB_MODEL": model or "", "AB_WORKDIR": str(workdir),
143
+ "AB_HOME": str(home), "AB_BP_ROOT": str(REPO_ROOT), "AB_TIMEOUT": str(max(10, timeout_s - 30))})
144
+ return env
145
+
146
+
147
+ def grade_cmd(adapter: dict, cmd: str, env: dict) -> tuple[str, dict]:
148
+ """Runs grade/score through the adapter's grade_wrapper (e.g. a no-network container)."""
149
+ if not adapter.get("grade_wrapper"):
150
+ return cmd, env
151
+ return adapter["grade_wrapper"], {**env, "AB_CMD": cmd}
152
+
153
+
154
+ def run_once(task: dict, arm: str, setup: str, adapter: dict, model: str | None,
155
+ run_idx: int, keep: bool, trace_dir: Path | None = None) -> dict:
156
+ workdir = Path(tempfile.mkdtemp(prefix=f"bp-ab-{task['id']}-{arm}-"))
157
+ home = Path(tempfile.mkdtemp(prefix="bp-ab-home-"))
158
+ try:
159
+ shutil.copytree(task["dir"] / "fixture", workdir, dirs_exist_ok=True)
160
+ git(workdir, "init", "-q")
161
+ snapshot(workdir, "fixture")
162
+ env = env_for(task["prompt"], model, workdir, home, task.get("timeout_s", 900))
163
+ if sh(setup, workdir, env).returncode != 0:
164
+ raise ConfigError(f"arm '{arm}' setup failed in {workdir}")
165
+ base = snapshot(workdir, f"arm {arm}")
166
+ protected = task.get("protected", [])
167
+ before = file_lines(workdir, protected)
168
+
169
+ started = time.monotonic()
170
+ timed_out = False
171
+ try:
172
+ agent = sh(adapter["command"], workdir, env, timeout=task.get("timeout_s", 900))
173
+ exit_code, stdout = agent.returncode, agent.stdout
174
+ except subprocess.TimeoutExpired as exc:
175
+ timed_out, exit_code = True, None
176
+ stdout = exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "")
177
+ duration = round(time.monotonic() - started, 1)
178
+ if trace_dir is not None:
179
+ save_trace(trace_dir / f"{task['id']}-{arm}-{run_idx}.jsonl.gz", stdout)
180
+
181
+ tampered, protected_added = protected_changes(workdir, before)
182
+ diff = changed_lines(workdir, base)
183
+ shutil.copytree(task["dir"] / "hidden", workdir, dirs_exist_ok=True)
184
+ cmd, genv = grade_cmd(adapter, task["grade"], env)
185
+ passed = sh(cmd, workdir, genv, timeout=300).returncode == 0
186
+ score = None
187
+ if task.get("score"):
188
+ cmd, genv = grade_cmd(adapter, task["score"], env)
189
+ score = tap_score(sh(cmd, workdir, genv, timeout=300).stdout)
190
+ metrics = parse_metrics(adapter.get("parser", "none"), stdout)
191
+ return {
192
+ "task": task["id"], "arm": arm, "run": run_idx, "pass": passed, "score": score, "tampered": tampered,
193
+ "protected_lines_added": protected_added, "diff_lines": diff, "duration_s": duration, "agent_exit": exit_code,
194
+ "agent_error": timed_out or exit_code != 0 or bool(metrics.pop("agent_reported_error")),
195
+ "timed_out": timed_out, **metrics,
196
+ }
197
+ finally:
198
+ shutil.rmtree(home, ignore_errors=True)
199
+ if keep:
200
+ print(f" kept workdir: {workdir}")
201
+ else:
202
+ shutil.rmtree(workdir, ignore_errors=True)
203
+
204
+
205
+ # ── CLI ──────────────────────────────────────────────────────────────────────
206
+
207
+ def parse_args(argv: list[str] | None) -> argparse.Namespace:
208
+ p = argparse.ArgumentParser(description="A/B eval: vanilla agent vs bigpowers, outcome-graded")
209
+ p.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
210
+ p.add_argument("--tasks-dir", type=Path, default=DEFAULT_TASKS_DIR)
211
+ p.add_argument("--tasks", help="comma-separated task ids (default: all)")
212
+ p.add_argument("--arms", default=DEFAULT_ARMS)
213
+ p.add_argument("--k", type=int, help="runs per task per arm (default: config k)")
214
+ p.add_argument("--adapter", help="adapter name (default: config default_adapter, else claude)")
215
+ p.add_argument("--adapter-cmd", help="custom adapter command; prompt is in $AB_PROMPT")
216
+ p.add_argument("--parser", help="output parser: claude-json | none")
217
+ p.add_argument("--model", help="exported as $AB_MODEL for the adapter")
218
+ p.add_argument("--out", type=Path, default=DEFAULT_OUT)
219
+ p.add_argument("--keep", action="store_true", help="keep workdirs for inspection")
220
+ p.add_argument("--dry-run", action="store_true", help="print the run matrix; run nothing")
221
+ return p.parse_args(argv)
222
+
223
+
224
+ def main(argv: list[str] | None = None) -> int:
225
+ args = parse_args(argv)
226
+ try:
227
+ config = load_yaml(args.config)
228
+ arm_names = [a.strip() for a in args.arms.split(",") if a.strip()]
229
+ arms = pick(config.get("arms", {}), arm_names, "arm")
230
+ adapter_name = args.adapter or config.get("default_adapter", "claude")
231
+ adapter = resolve_adapter(config, adapter_name, args.adapter_cmd, args.parser)
232
+ tasks = load_tasks(args.tasks_dir, args.tasks.split(",") if args.tasks else None)
233
+ if not tasks:
234
+ raise ConfigError(f"no tasks found in {args.tasks_dir}")
235
+ except ConfigError as exc:
236
+ print(f"ERROR: {exc}")
237
+ return 2
238
+ k = args.k or int(config.get("k", 3))
239
+ model = args.model or adapter.get("default_model")
240
+
241
+ print(f"A/B eval: {len(tasks)} task(s) x {len(arms)} arm(s) x k={k} via adapter '{adapter['name']}'"
242
+ f"{f' model {model}' if model else ''}")
243
+ if args.dry_run:
244
+ for task in tasks:
245
+ for arm, spec in arms.items():
246
+ print(f" {task['id']:<8} {arm:<10} setup: {spec['setup']}")
247
+ print(f" adapter command: {adapter['command']}")
248
+ return 0
249
+
250
+ # Round-robin (e58s05): any prefix of the run covers every arm, and slow provider periods hit
251
+ # all arms alike. Each record is appended to ab-live.jsonl as soon as its run finishes.
252
+ records = []
253
+ sha = bigpowers_sha()
254
+ args.out.mkdir(parents=True, exist_ok=True)
255
+ live = args.out / "ab-live.jsonl"
256
+ try:
257
+ for i in range(1, k + 1):
258
+ for task in tasks:
259
+ for arm, spec in arms.items():
260
+ rec = run_once(task, arm, spec["setup"], adapter, model, i, args.keep, args.out / "traces")
261
+ records.append(rec)
262
+ with live.open("a") as f:
263
+ f.write(json.dumps({**rec, "bigpowers_sha": sha, "model": model}) + "\n")
264
+ print(f" {task['id']:<8} {arm:<10} run {i}: {'PASS' if rec['pass'] else 'FAIL'}"
265
+ f"{' TAMPERED' if rec['tampered'] else ''}{' AGENT-ERROR' if rec['agent_error'] else ''}")
266
+ except ConfigError as exc:
267
+ print(f"ERROR: {exc}")
268
+ return 2
269
+
270
+ result = aggregate(records, list(arms), [t["id"] for t in tasks])
271
+ meta = {
272
+ "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
273
+ "bigpowers_sha": sha, "adapter": adapter["name"], "model": model,
274
+ "k": k, "tasks": [t["id"] for t in tasks], "arms": list(arms),
275
+ "arm_setup": {a: s["setup"] for a, s in arms.items()},
276
+ "ci_method": f"bootstrap over runs, {BOOTSTRAP_RESAMPLES} resamples, seed {BOOTSTRAP_SEED}",
277
+ }
278
+ report = write_reports(args.out, meta, result, records)
279
+ print_summary(result)
280
+ print(f"Wrote {report}")
281
+ return 0
282
+
283
+
284
+ if __name__ == "__main__":
285
+ sys.exit(main())
@@ -0,0 +1,33 @@
1
+ # story: e58s07
2
+ # Agent image for the A/B eval sandbox. Nothing from the host is baked in: the runner mounts
3
+ # only the run's workdir and its fresh $AB_HOME. Egress is the internal network (no internet)
4
+ # plus the allowlisting proxy, so the only reachable host is the model provider.
5
+ FROM node@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c
6
+
7
+ ARG PI_VERSION=1.5.0
8
+
9
+ RUN apt-get update \
10
+ && apt-get install -y --no-install-recommends bash ca-certificates curl git python3 \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ RUN npm install -g --no-audit --no-fund "@earendil-works/pi-coding-agent@${PI_VERSION}" \
14
+ && npm cache clean --force
15
+
16
+ # Node 24 ignores HTTP(S)_PROXY in fetch() unless this is set; without it the container cannot
17
+ # reach the proxy at all. The grading container sets --network none, so the proxy is irrelevant there.
18
+ ENV NODE_USE_ENV_PROXY=1 \
19
+ HTTPS_PROXY=http://bp-ab-proxy:8888 \
20
+ HTTP_PROXY=http://bp-ab-proxy:8888 \
21
+ NO_PROXY=localhost,127.0.0.1
22
+
23
+ # node:24-bookworm-slim already ships uid 1000 as `node`; reuse it instead of creating a user.
24
+ RUN mkdir -p /work /home/node/.pi/agent \
25
+ && chown -R node:node /work /home/node
26
+
27
+ # The workdir comes from a host mount owned by another uid; without this, git in the container
28
+ # refuses to run against it ("dubious ownership").
29
+ RUN git config --system --add safe.directory '*'
30
+
31
+ USER node
32
+ WORKDIR /work
33
+ CMD ["bash"]
@@ -0,0 +1,6 @@
1
+ # story: e58s07
2
+ # Allowlist for the A/B eval proxy (FilterDefaultDeny Yes: everything else is refused).
3
+ # opencode.ai serves the OpenCode Go inference endpoints pi uses:
4
+ # https://opencode.ai/inference/go/openai/v1
5
+ ^opencode\.ai(:[0-9]+)?$
6
+ ^.*\.opencode\.ai(:[0-9]+)?$
@@ -0,0 +1,13 @@
1
+ # story: e58s07
2
+ # Allowlisting forward proxy: the agent container has no route to the internet, so its only way
3
+ # out is this proxy, which permits the model provider and nothing else.
4
+ FROM alpine@sha256:2e0b81d60597f449baa56dc25d355367869d2dca4cd57e866f1e2b52af3eb55f
5
+
6
+ RUN apk add --no-cache tinyproxy
7
+
8
+ COPY tinyproxy.conf /etc/tinyproxy/tinyproxy.conf
9
+ COPY filter /etc/tinyproxy/filter
10
+
11
+ EXPOSE 8888
12
+ USER tinyproxy
13
+ ENTRYPOINT ["tinyproxy", "-d", "-c", "/etc/tinyproxy/tinyproxy.conf"]
@@ -0,0 +1,19 @@
1
+ # story: e58s07
2
+ Port 8888
3
+ Listen 0.0.0.0
4
+ Timeout 900
5
+ MaxClients 200
6
+ StartServers 5
7
+ MinSpareServers 5
8
+ MaxSpareServers 20
9
+ # Every client is a container on the internal network, so admit all of them and deny by destination.
10
+ Allow 0.0.0.0/0
11
+ DisableViaHeader Yes
12
+ # CONNECT is the only method that matters (the provider is https).
13
+ ConnectPort 443
14
+ Filter "/etc/tinyproxy/filter"
15
+ FilterDefaultDeny Yes
16
+ FilterExtended On
17
+ FilterCaseSensitive Off
18
+ FilterURLs On
19
+ LogLevel Info