Files
github__gh-stack/evals/runner.py
Sameen Karim cc2a40c084 Improve agent skill evals organization
Reorganize the public eval corpus into one self-contained directory per
scenario. Keep the exact agent prompt separate from the JSON contract so each
case's fixture, timeout, network scope, required status, and objective
assertions can be reviewed without reading the harness.

Add support for evaluating `skills/gh-stack` from any local commit, tag, or
branch with `--skill-ref`, without changing the working tree. Record the
resolved skill commit and Git tree, skill-directory and gh-stack binary
hashes, case contract hash, fixture seed, model, CLI versions, platform, and
repository state in every result.

Make batches repeatable with optional fixture seed SHA validation,
deterministically shuffled run plans, and per-case timeouts. Expand telemetry
to distinguish cumulative model input, model calls, all tool calls,
VC-related shell calls, failed tools, and output bytes.

Classify incorrect results into actionable failure categories and report
infrastructure failures separately from scored agent failures. Harden cleanup
by explicitly targeting the disposable repository, considering all matching
PRs when dissolving Stack metadata, and emitting an audit that fails when
namespaced refs or open PRs remain.

Rewrite the eval README around the public scenario layout, local execution,
commit-pinned comparisons, provenance, metric definitions, result
publication, cleanup, and adding new cases. Keep the suite local-first rather
than coupling privileged, nondeterministic agent runs to Actions.

Validation:
- python3 -m py_compile evals/*.py evals/tests/*.py
- python3 -m unittest discover -s evals/tests -v
- go vet ./...
- go test -race -count=1 ./...
- complete 12-case Mini suite from a fresh-context auditor
- commit-pinned smoke run with --skill-ref 14fc42e
- deterministic matrix plan, aggregation, and cleanup audit checks
- reran submit-prs after fixing origin/HEAD inheritance

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 03701648-d64b-477c-872a-84d2bd68c36d
2026-08-10 16:23:40 -04:00

1503 lines
50 KiB
Python

#!/usr/bin/env python3
"""Run one isolated gh-stack skill evaluation."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import platform
import re
import shutil
import signal
import sqlite3
import subprocess
import sys
import time
import uuid
from pathlib import Path
from typing import Any
from case_loader import load_cases
EVAL_ROOT = Path(__file__).resolve().parent
REPO_ROOT = EVAL_ROOT.parent
USER_HOME = Path.home()
SOURCE_REPO = Path(
os.environ.get("GH_STACK_EVAL_SOURCE_REPO", str(USER_HOME / "test"))
).expanduser().resolve()
REMOTE_URL = os.environ.get("GH_STACK_EVAL_REMOTE_URL", "")
REPO_SLUG = os.environ.get("GH_STACK_EVAL_REPO", "")
DEFAULT_BRANCH = os.environ.get("GH_STACK_EVAL_DEFAULT_BRANCH", "main")
SEED_REF = os.environ.get("GH_STACK_EVAL_SEED_REF", DEFAULT_BRANCH)
EXPECTED_SEED_SHA = os.environ.get("GH_STACK_EVAL_SEED_SHA", "")
RESULTS_ROOT = Path(
os.environ.get("GH_STACK_EVAL_RESULTS_DIR", str(EVAL_ROOT / "results"))
).expanduser().resolve()
CASES = load_cases()
MODELS = {
"sonnet": ("claude-sonnet-4.5", []),
"mini": ("gpt-5.4-mini", ["--effort", "low"]),
}
ARMS = {"current", "none"}
def shell(
args: list[str],
*,
cwd: Path | None = None,
env: dict[str, str] | None = None,
check: bool = True,
timeout: int = 180,
) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
args,
cwd=cwd,
env=env,
text=True,
capture_output=True,
timeout=timeout,
)
if check and result.returncode:
raise RuntimeError(
f"command failed ({result.returncode}): {' '.join(args)}\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
return result
def git(repo: Path, *args: str, check: bool = True) -> str:
return shell(["git", *args], cwd=repo, check=check).stdout.strip()
def gh(repo: Path, *args: str, check: bool = True, timeout: int = 180) -> str:
return shell(["gh", *args], cwd=repo, check=check, timeout=timeout).stdout.strip()
def configure() -> None:
global REPO_SLUG
if not (REPO_ROOT / "gh-stack").is_file():
raise RuntimeError("build the extension first: go build -o gh-stack .")
eval_token = os.environ.get("GH_STACK_EVAL_GITHUB_TOKEN")
if eval_token and not os.environ.get("GH_TOKEN"):
os.environ["GH_TOKEN"] = eval_token
if not REPO_SLUG:
REPO_SLUG = gh(
SOURCE_REPO,
"repo",
"view",
"--json",
"nameWithOwner",
"--jq",
".nameWithOwner",
)
def write(repo: Path, relative: str, content: str) -> None:
path = repo / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
def commit(repo: Path, message: str, *paths: str) -> str:
git(repo, "add", "--", *paths)
git(repo, "commit", "-m", message)
return git(repo, "rev-parse", "HEAD")
def export_skill(ref: str, destination: Path) -> Path:
paths = git(
REPO_ROOT,
"ls-tree",
"-r",
"--name-only",
ref,
"--",
"skills/gh-stack",
).splitlines()
if not paths:
raise RuntimeError(
f"{ref!r} does not contain skills/gh-stack; fetch the commit first"
)
skill = destination / "gh-stack"
for path in paths:
relative = Path(path).relative_to("skills/gh-stack")
target = skill / relative
target.parent.mkdir(parents=True, exist_ok=True)
result = subprocess.run(
["git", "show", f"{ref}:{path}"],
cwd=REPO_ROOT,
capture_output=True,
check=True,
)
target.write_bytes(result.stdout)
return skill
def directory_sha256(root: Path) -> str:
digest = hashlib.sha256()
for path in sorted(item for item in root.rglob("*") if item.is_file()):
digest.update(str(path.relative_to(root)).encode())
digest.update(b"\0")
digest.update(path.read_bytes())
digest.update(b"\0")
return digest.hexdigest()
def file_sha256(path: Path) -> str | None:
if not path.is_file():
return None
return hashlib.sha256(path.read_bytes()).hexdigest()
def command_version(command: list[str]) -> str:
result = shell(command, check=False)
output = (result.stdout or result.stderr).strip()
return output.splitlines()[0] if output else "unknown"
def provenance(
case_name: str,
skill: Path | None,
skill_ref: str | None,
skill_source: str,
model_key: str,
) -> dict[str, Any]:
binary = REPO_ROOT / "gh-stack"
return {
"case_contract_sha256": hashlib.sha256(
Path(CASES[case_name]["contract_path"]).read_bytes()
+ CASES[case_name]["prompt"].encode()
).hexdigest(),
"skill_source": skill_source,
"skill_commit": (
git(REPO_ROOT, "rev-parse", skill_ref) if skill_ref else None
),
"skill_tree": (
git(REPO_ROOT, "rev-parse", f"{skill_ref}:skills/gh-stack")
if skill_ref
else None
),
"skill_sha256": directory_sha256(skill) if skill else None,
"repository_commit": git(REPO_ROOT, "rev-parse", "HEAD"),
"repository_dirty": bool(git(REPO_ROOT, "status", "--porcelain")),
"fixture_seed_ref": SEED_REF,
"fixture_seed_sha": EXPECTED_SEED_SHA or None,
"gh_stack_binary_sha256": file_sha256(binary),
"gh_stack_cli": command_version([str(binary), "--version"]),
"model_alias": model_key,
"model": MODELS[model_key][0],
"copilot_cli": command_version(["copilot", "--version"]),
"gh_cli": command_version(["gh", "--version"]),
"git": command_version(["git", "--version"]),
"python": platform.python_version(),
"platform": platform.platform(),
}
def prepare_repo(
run_dir: Path,
arm: str,
skill_path: Path | None = None,
skill_ref: str | None = None,
) -> Path:
if not SOURCE_REPO.is_dir():
raise RuntimeError(
f"test repository not found: {SOURCE_REPO}; "
"set GH_STACK_EVAL_SOURCE_REPO"
)
repo = run_dir / "repo"
shell(["git", "clone", "--quiet", "--no-hardlinks", str(SOURCE_REPO), str(repo)])
remote_url = REMOTE_URL or git(SOURCE_REPO, "remote", "get-url", "origin")
git(repo, "remote", "set-url", "origin", remote_url)
git(
repo,
"fetch",
"--quiet",
"origin",
f"+refs/heads/{DEFAULT_BRANCH}:refs/remotes/origin/{DEFAULT_BRANCH}",
)
git(repo, "remote", "set-head", "origin", DEFAULT_BRANCH)
if SEED_REF == DEFAULT_BRANCH:
seed_sha = git(repo, "rev-parse", f"origin/{DEFAULT_BRANCH}")
else:
git(repo, "fetch", "--quiet", "origin", SEED_REF)
seed_sha = git(repo, "rev-parse", "FETCH_HEAD")
if EXPECTED_SEED_SHA and seed_sha != EXPECTED_SEED_SHA:
raise RuntimeError(
f"fixture seed mismatch: expected {EXPECTED_SEED_SHA}, got {seed_sha}"
)
git(
repo,
"checkout",
"-q",
"-B",
DEFAULT_BRANCH,
seed_sha,
)
git(repo, "config", "user.name", "gh-stack skill eval")
git(repo, "config", "user.email", "gh-stack-eval@example.com")
git(repo, "config", "rerere.enabled", "true")
git(
repo,
"config",
"credential.https://github.com.helper",
"!gh auth git-credential",
)
git(repo, "config", "--local", "--unset-all", "remote.pushDefault", check=False)
tracked_skill = ".agents/skills/gh-stack/SKILL.md"
if (repo / tracked_skill).exists():
git(repo, "update-index", "--skip-worktree", tracked_skill)
shutil.rmtree(repo / ".agents/skills/gh-stack", ignore_errors=True)
info_exclude = repo / ".git/info/exclude"
with info_exclude.open("a") as handle:
handle.write("\n.agents/skills/gh-stack/\n")
if arm != "none":
if skill_ref:
source = export_skill(skill_ref, run_dir / "skill-source")
elif skill_path:
source = skill_path.resolve()
else:
source = REPO_ROOT / "skills/gh-stack"
if not (source / "SKILL.md").is_file():
raise RuntimeError(f"skill snapshot missing SKILL.md: {source}")
target = repo / ".agents/skills/gh-stack"
shutil.copytree(source, target)
return repo
def create_eval_base(repo: Path, run_id: str) -> str:
branch = f"eval-base/{run_id}"
git(repo, "checkout", "-q", "-b", branch, "HEAD")
git(repo, "push", "-q", "origin", branch)
return branch
def prepare_home(run_dir: Path) -> Path:
home = run_dir / "home"
home.mkdir()
ssh = USER_HOME / ".ssh"
if ssh.exists():
(home / ".ssh").symlink_to(ssh)
(home / ".gitconfig").write_text(
'[credential "https://github.com"]\n'
"\thelper = !gh auth git-credential\n"
)
extensions = home / ".local/share/gh/extensions"
extensions.mkdir(parents=True)
(extensions / "gh-stack").symlink_to(REPO_ROOT)
return home
def prepare_three_layer(
repo: Path, run_id: str, prefix: str, base_branch: str
) -> dict[str, Any]:
root = f"eval/{run_id}"
branches = [f"{prefix}/model", f"{prefix}/api", f"{prefix}/ui"]
gh(repo, "stack", "init", "--base", base_branch, branches[0])
write(
repo,
f"{root}/user.js",
"module.exports = { id: 1, name: 'Ada', createdAt: '2026-08-03T00:00:00Z' };\n",
)
model_sha = commit(repo, f"{run_id}: add user model", f"{root}/user.js")
gh(repo, "stack", "add", branches[1])
write(
repo,
f"{root}/serializer.js",
"const user = require('./user');\n"
"module.exports = () => ({ id: user.id, name: user.name });\n",
)
api_sha = commit(repo, f"{run_id}: add user serializer", f"{root}/serializer.js")
gh(repo, "stack", "add", branches[2])
write(
repo,
f"{root}/page.js",
"const serialize = require('./serializer');\n"
"module.exports = () => `<p>${serialize().name}</p>`;\n",
)
ui_sha = commit(repo, f"{run_id}: add user page", f"{root}/page.js")
return {
"root": root,
"branches": branches,
"fixture_shas": [model_sha, api_sha, ui_sha],
"fixture_head": ui_sha,
}
def prepare_reorder(
repo: Path, run_id: str, prefix: str, base_branch: str
) -> dict[str, Any]:
root = f"eval/{run_id}"
branches = [f"{prefix}/models", f"{prefix}/migration", f"{prefix}/ui"]
gh(repo, "stack", "init", "--base", base_branch, branches[0])
write(repo, f"{root}/models.js", "module.exports = { table: 'users' };\n")
model_sha = commit(repo, f"{run_id}: add models", f"{root}/models.js")
gh(repo, "stack", "add", branches[1])
write(repo, f"{root}/migration.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);\n")
migration_sha = commit(repo, f"{run_id}: add migration", f"{root}/migration.sql")
gh(repo, "stack", "add", branches[2])
write(
repo,
f"{root}/ui.js",
"const model = require('./models');\nmodule.exports = () => model.table;\n",
)
ui_sha = commit(repo, f"{run_id}: add ui", f"{root}/ui.js")
return {
"root": root,
"branches": branches,
"target_branches": [branches[1], branches[0], branches[2]],
"fixture_shas": [model_sha, migration_sha, ui_sha],
"fixture_head": ui_sha,
}
def prepare_merge(
repo: Path, run_id: str, prefix: str, base_branch: str
) -> dict[str, Any]:
root = f"eval/{run_id}"
branches = [f"{prefix}/base", f"{prefix}/top"]
git(repo, "config", "remote.pushDefault", "origin")
gh(repo, "stack", "init", "--base", base_branch, branches[0])
write(repo, f"{root}/base.txt", f"{run_id} base\n")
commit(repo, f"{run_id}: add base layer", f"{root}/base.txt")
gh(repo, "stack", "add", branches[1])
write(repo, f"{root}/top.txt", f"{run_id} top\n")
commit(repo, f"{run_id}: add top layer", f"{root}/top.txt")
gh(repo, "stack", "submit", "--auto", "--open", "--remote", "origin", timeout=180)
prs = [list_pr(repo, branch) for branch in branches]
if not all(prs):
raise RuntimeError(f"failed to create merge fixture PRs: {prs}")
return {
"root": root,
"branches": branches,
"prs": prs,
"bottom_pr": int(prs[0]["number"]),
"top_pr": int(prs[1]["number"]),
"fixture_head": git(repo, "rev-parse", "HEAD"),
}
def prepare_link(
repo: Path, run_id: str, prefix: str, base_branch: str
) -> dict[str, Any]:
root = f"eval/{run_id}"
branches = [f"{prefix}/base", f"{prefix}/top"]
git(repo, "checkout", "-q", "-b", branches[0])
write(repo, f"{root}/base.js", "module.exports = { value: 42 };\n")
base_sha = commit(repo, f"{run_id}: add link base", f"{root}/base.js")
git(repo, "checkout", "-q", "-b", branches[1])
write(
repo,
f"{root}/top.js",
"const base = require('./base');\nmodule.exports = () => base.value;\n",
)
top_sha = commit(repo, f"{run_id}: add link top", f"{root}/top.js")
return {
"root": root,
"branches": branches,
"fixture_shas": [base_sha, top_sha],
"fixture_head": top_sha,
}
def prepare_split(
repo: Path,
run_id: str,
prefix: str,
source: str,
base_branch: str,
) -> dict[str, Any]:
"""Build one large multi-concern diff to be split into a stack.
Layers must be model -> api -> ui by import dependency so ordering is gradable.
"""
root = f"eval/{run_id}"
# Base commit contains only the legacy helper the work will later delete.
write(repo, f"{root}/legacy.js", "module.exports = { old: true };\n")
git(repo, "add", "--", root)
git(repo, "commit", "-m", f"{run_id}: add legacy helper")
base_sha = git(repo, "rev-parse", "HEAD")
# The large change: three dependent modules, a new note, and the legacy deletion.
write(
repo,
f"{root}/model.js",
"module.exports = { normalizeUser: u => ({ ...u, name: String(u.name).trim() }) };\n",
)
write(
repo,
f"{root}/api.js",
"const m = require('./model');\n"
"module.exports = { serializeUser: u => JSON.stringify(m.normalizeUser(u)) };\n",
)
write(
repo,
f"{root}/page.js",
"const a = require('./api');\n"
"module.exports = { renderUser: u => `<div>${a.serializeUser(u)}</div>` };\n",
)
write(repo, f"{root}/NOTES.md", f"{run_id} billing notes\n")
(repo / root / "legacy.js").unlink()
metadata: dict[str, Any] = {
"root": root,
"base_sha": base_sha,
"base_branch": base_branch,
"source": source,
"expected_files": [
f"{root}/model.js",
f"{root}/api.js",
f"{root}/page.js",
f"{root}/NOTES.md",
f"{root}/legacy.js",
],
}
if source == "worktree":
# Leave everything uncommitted on trunk.
metadata["source_sha"] = None
metadata["fixture_head"] = base_sha
metadata["branches"] = []
return metadata
branch = f"{prefix}/big"
git(repo, "checkout", "-q", "-b", branch)
git(repo, "add", "-A", "--", root)
git(repo, "commit", "-m", f"{run_id}: add user model, api, page; drop legacy")
source_sha = git(repo, "rev-parse", "HEAD")
metadata["source_sha"] = source_sha
metadata["source_branch"] = branch
metadata["fixture_head"] = source_sha
metadata["branches"] = [branch]
if source == "pr":
git(repo, "config", "remote.pushDefault", "origin")
git(repo, "push", "-q", "origin", branch)
shell(
[
"gh", "pr", "create", "--repo", REPO_SLUG,
"--base", base_branch, "--head", branch,
"--title", f"{run_id}: large billing change",
"--body", "Large single PR that should be split into a stack.",
],
cwd=repo,
timeout=120,
)
pr = list_pr(repo, branch)
if not pr:
raise RuntimeError("failed to create split fixture PR")
metadata["pr"] = pr
metadata["source_pr"] = int(pr["number"])
return metadata
def prepare_fixture(
repo: Path,
case_name: str,
run_id: str,
prefix: str,
base_branch: str,
) -> dict[str, Any]:
fixture = CASES[case_name]["fixture"]
if fixture == "clean":
return {
"root": f"eval/{run_id}",
"base_branch": base_branch,
"branches": [],
"fixture_head": git(repo, "rev-parse", "HEAD"),
}
if fixture == "reorder":
return prepare_reorder(repo, run_id, prefix, base_branch)
if fixture == "merge":
return prepare_merge(repo, run_id, prefix, base_branch)
if fixture == "link":
return prepare_link(repo, run_id, prefix, base_branch)
if fixture.startswith("split-"):
return prepare_split(
repo,
run_id,
prefix,
fixture.split("-", 1)[1],
base_branch,
)
metadata = prepare_three_layer(repo, run_id, prefix, base_branch)
if fixture == "multi-remote":
git(repo, "remote", "add", "backup", git(repo, "remote", "get-url", "origin"))
git(repo, "config", "--local", "--unset-all", "remote.pushDefault", check=False)
metadata["fixture_head"] = git(repo, "rev-parse", "HEAD")
return metadata
def render_prompt(
case_name: str,
run_id: str,
prefix: str,
metadata: dict[str, Any] | None = None,
) -> str:
values = metadata or {}
return CASES[case_name]["prompt"].format(
run_id=run_id,
branch_prefix=prefix,
top_pr=values.get("top_pr", ""),
source_pr=values.get("source_pr", ""),
default_branch=values.get("base_branch", DEFAULT_BRANCH),
)
def run_agent(
run_dir: Path,
repo: Path,
home: Path,
model_key: str,
prompt: str,
timeout_seconds: int,
) -> dict[str, Any]:
model, model_flags = MODELS[model_key]
events = run_dir / "events.jsonl"
stderr = run_dir / "copilot.stderr"
gh_token = (
os.environ.get("GH_STACK_EVAL_GITHUB_TOKEN")
or os.environ.get("GH_TOKEN")
or shell(["gh", "auth", "token"]).stdout.strip()
)
copilot_token = os.environ.get("COPILOT_GITHUB_TOKEN") or gh_token
env = {
**os.environ,
"HOME": str(home),
"COPILOT_GITHUB_TOKEN": copilot_token,
"GH_TOKEN": gh_token,
"GITHUB_TOKEN": gh_token,
"GIT_TERMINAL_PROMPT": "0",
"GH_PROMPT_DISABLED": "1",
"CI": "1",
"NO_COLOR": "1",
}
command = [
"copilot",
"-C",
str(repo),
"--no-custom-instructions",
"--disable-builtin-mcps",
"--allow-all-tools",
"--allow-all-paths",
"--allow-all-urls",
"--no-ask-user",
"--autopilot",
"--max-autopilot-continues",
"10",
"--model",
model,
*model_flags,
"--output-format",
"json",
"-p",
prompt,
]
started = time.time()
timed_out = False
with events.open("w") as out, stderr.open("w") as err:
process = subprocess.Popen(
command,
cwd=repo,
env=env,
text=True,
stdout=out,
stderr=err,
start_new_session=True,
)
try:
return_code = process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
timed_out = True
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
return_code = 124
return {
"return_code": return_code,
"timed_out": timed_out,
"duration_seconds": round(time.time() - started, 3),
}
def load_events(path: Path) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
if not path.exists():
return events
for line in path.read_text(errors="replace").splitlines():
try:
events.append(json.loads(line))
except json.JSONDecodeError:
pass
return events
def telemetry(run_dir: Path, home: Path) -> dict[str, Any]:
events = load_events(run_dir / "events.jsonl")
requests: list[dict[str, Any]] = []
final_messages: list[str] = []
completed_tools: list[dict[str, Any]] = []
for event in events:
event_type = event.get("type")
data = event.get("data", {})
if event_type == "assistant.message":
requests.extend(data.get("toolRequests") or [])
if data.get("content"):
final_messages.append(data["content"])
elif event_type == "tool.execution_complete":
completed_tools.append(data)
bash_commands = [
request.get("arguments", {}).get("command", "")
for request in requests
if request.get("name") == "bash"
]
vc_shell_calls = sum(
bool(re.search(r"(^|[;&|]\s*)(git|gh\s+(stack|pr))\b", command))
for command in bash_commands
)
skill_invoked = any(
request.get("name") == "skill"
and request.get("arguments", {}).get("skill") == "gh-stack"
for request in requests
)
references: set[str] = set()
for request in requests:
raw = json.dumps(request.get("arguments", {}))
for name in ("commands.md", "stack-design.md", "troubleshooting.md"):
if name in raw:
references.add(name)
input_tokens = 0
output_tokens = 0
model_calls = 0
db = home / ".copilot/session-store.db"
if db.exists():
connection = sqlite3.connect(db)
try:
row = connection.execute(
"SELECT COUNT(*), COALESCE(SUM(input_tokens),0), "
"COALESCE(SUM(output_tokens),0) "
"FROM assistant_usage_events"
).fetchone()
model_calls = int(row[0])
input_tokens, output_tokens = int(row[1]), int(row[2])
finally:
connection.close()
tool_output_bytes = sum(
len(
str(data.get("result", {}).get("content", "")).encode(
errors="replace"
)
)
for data in completed_tools
)
return {
"tool_calls": len(requests),
"bash_calls": len(bash_commands),
"vc_shell_calls": vc_shell_calls,
"bash_commands": bash_commands,
"failed_tool_calls": sum(
data.get("success") is False for data in completed_tools
),
"tool_output_bytes": tool_output_bytes,
"model_calls": model_calls,
"skill_invoked": skill_invoked,
"references_opened": sorted(references),
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"final_response": "\n".join(final_messages[-2:]),
}
def safe_stack_view(repo: Path) -> dict[str, Any] | None:
result = shell(
["gh", "stack", "view", "--json"],
cwd=repo,
check=False,
timeout=90,
)
if result.returncode:
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return None
def file_intro_commit(repo: Path, relative: str) -> str | None:
result = shell(
["git", "log", "--diff-filter=A", "--format=%H", "--", relative],
cwd=repo,
check=False,
)
lines = result.stdout.splitlines()
return lines[-1] if lines else None
def branch_index_for_commit(repo: Path, branches: list[str], sha: str | None) -> int | None:
if not sha:
return None
for index, branch in enumerate(branches):
if shell(
["git", "merge-base", "--is-ancestor", sha, branch],
cwd=repo,
check=False,
).returncode == 0:
return index
return None
def command_contains(commands: list[str], *needles: str) -> bool:
return any(all(needle in command for needle in needles) for command in commands)
def classify_failure(failed: list[str]) -> str | None:
if not failed:
return None
joined = " ".join(failed)
if "original_pr" in joined:
return "PR_IDENTITY_LOST"
if any(
token in joined
for token in ("pr_", "github_stack", "remote_", "branches_pushed")
):
return "REMOTE_STATE_WRONG"
if any(
token in joined
for token in (
"parity",
"files_covered",
"deletion",
"created_at",
"serializer",
"layer_contains",
)
):
return "CONTENT_WRONG"
if any(
token in joined
for token in (
"layer",
"stack_order",
"dependency_order",
"rebase",
"ancestor",
)
):
return "GRAPH_WRONG"
if any(
token in joined
for token in ("clean_worktree", "repository_unchanged", "unchanged")
):
return "DIRTY_STATE_WRONG"
return "WORKFLOW_WRONG"
def list_pr(repo: Path, branch: str) -> dict[str, Any] | None:
result = shell(
[
"gh",
"pr",
"list",
"--repo",
REPO_SLUG,
"--state",
"open",
"--head",
branch,
"--json",
"number,headRefName,baseRefName,isDraft,url",
"--limit",
"10",
],
cwd=repo,
check=False,
)
try:
values = json.loads(result.stdout)
except json.JSONDecodeError:
return None
return values[0] if values else None
def stack_contains(repo: Path, pr_numbers: set[int]) -> bool:
result = shell(
["gh", "api", f"repos/{REPO_SLUG}/stacks", "--paginate"],
cwd=repo,
check=False,
timeout=120,
)
try:
stacks = json.loads(result.stdout)
except json.JSONDecodeError:
return False
for stack in stacks:
numbers = {
int(item["number"])
for item in stack.get("pull_requests", [])
if "number" in item
}
if pr_numbers.issubset(numbers):
return True
return False
def grade(
repo: Path,
case_name: str,
metadata: dict[str, Any],
telemetry_data: dict[str, Any],
) -> dict[str, Any]:
assertions: dict[str, bool] = {}
details: dict[str, Any] = {}
status = git(repo, "status", "--porcelain")
current = git(repo, "branch", "--show-current")
commands = telemetry_data["bash_commands"]
view = safe_stack_view(repo)
if case_name == "preemptive-feature":
branches = [item["name"] for item in (view or {}).get("branches", [])]
root = metadata["root"]
introductions = [
file_intro_commit(repo, f"{root}/model.js"),
file_intro_commit(repo, f"{root}/api.js"),
file_intro_commit(repo, f"{root}/page.js"),
]
indexes = [branch_index_for_commit(repo, branches, sha) for sha in introductions]
assertions["skill_triggered"] = telemetry_data["skill_invoked"]
assertions["stack_has_three_layers"] = len(branches) >= 3
assertions["files_in_dependency_order"] = (
all(index is not None for index in indexes)
and len(set(indexes)) == 3
and indexes == sorted(indexes)
)
assertions["clean_worktree"] = not status
remote_result = shell(
["git", "ls-remote", "--heads", "origin", f"{metadata['prefix']}/*"],
cwd=repo,
check=False,
timeout=90,
)
assertions["nothing_pushed"] = not remote_result.stdout.strip()
details.update({"branches": branches, "intro_commits": introductions, "indexes": indexes})
elif case_name == "read-state":
assertions["repository_unchanged"] = (
not status and git(repo, "rev-parse", "HEAD") == metadata["fixture_head"]
)
assertions["stayed_on_top"] = current == metadata["branches"][-1]
assertions["used_view_json"] = command_contains(commands, "gh stack view", "--json")
elif case_name == "lower-layer-edit":
root = metadata["root"]
api_branch = metadata["branches"][1]
top_branch = metadata["branches"][2]
api_file = shell(
["git", "show", f"{api_branch}:{root}/serializer.js"],
cwd=repo,
check=False,
).stdout
top_only_diff = shell(
["git", "diff", f"{api_branch}..{top_branch}", "--", f"{root}/serializer.js"],
cwd=repo,
check=False,
).stdout
assertions["api_layer_contains_created_at"] = bool(
re.search(r"\bcreatedAt\s*:", api_file)
)
assertions["serializer_not_only_in_top"] = not top_only_diff
assertions["top_rebased_on_api"] = (
shell(
["git", "merge-base", "--is-ancestor", api_branch, top_branch],
cwd=repo,
check=False,
).returncode
== 0
)
assertions["finished_on_top"] = current == top_branch
assertions["clean_worktree"] = not status
details.update({"api_file": api_file, "top_only_diff": top_only_diff})
elif case_name == "multi-remote-push":
remote_heads: dict[str, bool] = {}
for branch in metadata["branches"]:
result = shell(
["git", "ls-remote", "--exit-code", "--heads", "origin", branch],
cwd=repo,
check=False,
timeout=90,
)
remote_heads[branch] = result.returncode == 0
assertions["all_branches_pushed"] = all(remote_heads.values())
assertions["remote_disambiguated"] = (
command_contains(commands, "gh stack push", "--remote", "origin")
or git(repo, "config", "--get", "remote.pushDefault", check=False) == "origin"
)
assertions["clean_worktree"] = not status
details["remote_heads"] = remote_heads
elif case_name == "submit-prs":
branches = [f"{metadata['prefix']}/data", f"{metadata['prefix']}/consumer"]
prs = [list_pr(repo, branch) for branch in branches]
assertions["requested_branches_exist"] = all(
shell(["git", "show-ref", "--verify", f"refs/heads/{branch}"], cwd=repo, check=False).returncode
== 0
for branch in branches
)
assertions["both_prs_open_ready"] = all(pr and not pr["isDraft"] for pr in prs)
assertions["dependent_pr_bases"] = bool(
prs[0]
and prs[1]
and prs[0]["baseRefName"] == metadata["base_branch"]
and prs[1]["baseRefName"] == branches[0]
)
numbers = {int(pr["number"]) for pr in prs if pr}
assertions["one_github_stack"] = len(numbers) == 2 and stack_contains(repo, numbers)
assertions["clean_worktree"] = not status
details.update({"branches": branches, "prs": prs})
elif case_name == "continue-stack":
original = metadata["branches"]
added = f"{metadata['prefix']}/audit"
actual = [item["name"] for item in (view or {}).get("branches", [])]
audit_path = f"{metadata['root']}/audit.js"
assertions["new_top_layer"] = actual == [*original, added]
assertions["audit_file_in_new_layer"] = (
shell(
["git", "cat-file", "-e", f"{added}:{audit_path}"],
cwd=repo,
check=False,
).returncode
== 0
and shell(
["git", "cat-file", "-e", f"{original[-1]}:{audit_path}"],
cwd=repo,
check=False,
).returncode
!= 0
)
assertions["new_layer_depends_on_previous_top"] = (
shell(
["git", "merge-base", "--is-ancestor", original[-1], added],
cwd=repo,
check=False,
).returncode
== 0
)
assertions["existing_layers_unchanged"] = all(
git(repo, "rev-parse", branch) == sha
for branch, sha in zip(original, metadata["fixture_shas"])
)
assertions["finished_on_new_top"] = current == added
assertions["clean_worktree"] = not status
details.update({"expected_order": [*original, added], "actual_order": actual})
elif case_name == "link-stack":
branches = metadata["branches"]
prs = [list_pr(repo, branch) for branch in branches]
assertions["both_prs_open_ready"] = all(pr and not pr["isDraft"] for pr in prs)
assertions["dependent_pr_bases"] = bool(
prs[0]
and prs[1]
and prs[0]["baseRefName"] == metadata["base_branch"]
and prs[1]["baseRefName"] == branches[0]
)
numbers = {int(pr["number"]) for pr in prs if pr}
assertions["one_github_stack"] = len(numbers) == 2 and stack_contains(repo, numbers)
assertions["no_local_tracking"] = safe_stack_view(repo) is None
assertions["clean_worktree"] = not status
details.update({"branches": branches, "prs": prs})
elif case_name == "reorder-stack":
root = metadata["root"]
target = metadata["target_branches"]
actual = [item["name"] for item in (view or {}).get("branches", [])]
def exists(branch: str, relative: str) -> bool:
return (
shell(
["git", "cat-file", "-e", f"{branch}:{root}/{relative}"],
cwd=repo,
check=False,
).returncode
== 0
)
migration, models, ui = target
assertions["stack_order"] = actual == target
assertions["migration_layer_isolated"] = (
exists(migration, "migration.sql")
and not exists(migration, "models.js")
and not exists(migration, "ui.js")
)
assertions["models_layer_correct"] = (
exists(models, "migration.sql")
and exists(models, "models.js")
and not exists(models, "ui.js")
)
assertions["ui_layer_complete"] = (
exists(ui, "migration.sql")
and exists(ui, "models.js")
and exists(ui, "ui.js")
)
assertions["finished_on_top"] = current == ui
assertions["clean_worktree"] = not status
details.update({"target_order": target, "actual_order": actual})
elif case_name == "merge-stack":
def pr_state(number: int) -> str:
result = shell(
[
"gh",
"pr",
"view",
str(number),
"--repo",
REPO_SLUG,
"--json",
"state",
"--jq",
".state",
],
cwd=repo,
check=False,
timeout=90,
)
return result.stdout.strip()
states = {
"bottom": pr_state(metadata["bottom_pr"]),
"top": pr_state(metadata["top_pr"]),
}
assertions["bottom_pr_merged"] = states["bottom"] == "MERGED"
assertions["top_pr_merged"] = states["top"] == "MERGED"
assertions["used_stack_merge"] = command_contains(commands, "gh stack merge")
assertions["used_yes"] = command_contains(commands, "gh stack merge", "--yes")
details["pr_states"] = states
elif case_name.startswith("split-"):
root = metadata["root"]
base_sha = metadata["base_sha"]
src = metadata.get("source_sha")
branches = [item["name"] for item in (view or {}).get("branches", [])]
top = branches[-1] if branches else None
def has_file(ref: str, path: str) -> bool:
return shell(
["git", "cat-file", "-e", f"{ref}:{path}"], cwd=repo, check=False
).returncode == 0
def changed_files(a: str, b: str) -> set[str]:
out = shell(
["git", "diff", "--name-only", a, b], cwd=repo, check=False
).stdout
return {line for line in out.splitlines() if line.strip()}
def introducing_layer(path: str) -> int | None:
"""Index of the lowest layer whose tree contains path."""
for index, branch in enumerate(branches):
if has_file(branch, path):
return index
return None
assertions["stack_has_layers"] = len(branches) >= 3
details["branches"] = branches
# 1. Parity: the top of the stack reproduces the original work exactly.
if not top:
assertions["parity_with_original"] = False
elif src:
parity = shell(
["git", "diff", "--name-status", src, top], cwd=repo, check=False
).stdout.strip()
assertions["parity_with_original"] = parity == ""
details["parity_diff"] = parity
else:
# Worktree source has no commit to compare against; assert final content.
mismatches = [
path
for path in metadata["expected_files"]
if has_file(top, path) != (not path.endswith("legacy.js"))
]
assertions["parity_with_original"] = not mismatches
details["parity_mismatches"] = mismatches
# 2. Coverage: every added/modified file is present, and the deletion happened.
if top:
missing = [
path
for path in metadata["expected_files"]
if not path.endswith("legacy.js") and not has_file(top, path)
]
assertions["all_files_covered"] = not missing
assertions["deletion_applied"] = not has_file(top, f"{root}/legacy.js")
details["missing_files"] = missing
else:
assertions["all_files_covered"] = False
assertions["deletion_applied"] = False
# 3. Ordering: model at or below api, api at or below page, and truly split.
order = [
introducing_layer(f"{root}/model.js"),
introducing_layer(f"{root}/api.js"),
introducing_layer(f"{root}/page.js"),
]
assertions["dependency_order"] = (
all(v is not None for v in order)
and order[0] <= order[1] <= order[2]
and len(set(order)) >= 2
)
details["layer_indexes"] = order
# 4. Real layering: every layer adds something over its parent.
nonempty = [
bool(changed_files(base_sha if i == 0 else branches[i - 1], branch))
for i, branch in enumerate(branches)
]
assertions["no_empty_layers"] = bool(nonempty) and all(nonempty)
details["layer_nonempty"] = nonempty
assertions["clean_worktree"] = not status
if case_name == "split-open-pr":
state = shell(
[
"gh", "pr", "view", str(metadata["source_pr"]), "--repo", REPO_SLUG,
"--json", "state", "--jq", ".state",
],
cwd=repo,
check=False,
timeout=90,
).stdout.strip()
reused = metadata.get("source_branch") in branches
assertions["original_pr_resolved"] = state == "CLOSED" or reused
details["source_pr_state"] = state
details["source_branch_reused"] = reused
core_assertions = dict(assertions)
if metadata["arm"] == "none":
core_assertions.pop("skill_triggered", None)
passed = bool(core_assertions) and all(core_assertions.values())
failed = [name for name, value in core_assertions.items() if not value]
return {
"passed": passed,
"failure_class": classify_failure(failed),
"failed_assertions": failed,
"assertions": assertions,
"details": details,
"current_branch": current,
"git_status": status,
"stack_view": view,
}
def cleanup(repo: Path, case_name: str, metadata: dict[str, Any]) -> dict[str, Any]:
errors: list[str] = []
branches = metadata.get("branches", [])
def delete_remote(branch: str) -> None:
outcome = shell(
["git", "push", "origin", "--delete", branch],
cwd=repo,
check=False,
timeout=90,
)
if (
outcome.returncode
and "remote ref does not exist" not in outcome.stderr
and "unable to delete" not in outcome.stderr
):
errors.append(outcome.stderr.strip())
if case_name.startswith("split-"):
# Close any PR the agent opened, plus the fixture PR, then drop remote branches.
numbers: set[int] = set()
if metadata.get("source_pr"):
numbers.add(int(metadata["source_pr"]))
listed = shell(
[
"gh", "pr", "list", "--repo", REPO_SLUG, "--state", "open",
"--search", metadata["prefix"], "--json", "number,headRefName",
"--limit", "50",
],
cwd=repo,
check=False,
timeout=120,
).stdout
try:
for item in json.loads(listed or "[]"):
if str(item.get("headRefName", "")).startswith(metadata["prefix"]):
numbers.add(int(item["number"]))
except json.JSONDecodeError:
pass
for number in sorted(numbers):
outcome = shell(
["gh", "pr", "close", str(number), "--repo", REPO_SLUG],
cwd=repo,
check=False,
timeout=90,
)
if outcome.returncode and "already closed" not in outcome.stderr.lower():
errors.append(outcome.stderr.strip())
remote_refs = shell(
["git", "ls-remote", "--heads", "origin", f"{metadata['prefix']}/*"],
cwd=repo,
check=False,
timeout=90,
).stdout
for line in remote_refs.splitlines():
ref = line.split("refs/heads/")[-1].strip()
if ref:
delete_remote(ref)
delete_remote(metadata["base_branch"])
return {"cleanup_errors": [e for e in errors if e]}
if case_name in {"submit-prs", "merge-stack", "link-stack"}:
branches = [f"{metadata['prefix']}/data", f"{metadata['prefix']}/consumer"]
if case_name == "merge-stack":
branches = metadata["branches"]
if case_name == "link-stack":
branches = metadata["branches"]
prs = [list_pr(repo, branch) for branch in branches]
if case_name == "merge-stack":
prs = metadata["prs"]
numbers = {int(pr["number"]) for pr in prs if pr}
if numbers and case_name != "merge-stack":
result = shell(
["gh", "api", f"repos/{REPO_SLUG}/stacks", "--paginate"],
cwd=repo,
check=False,
)
try:
stacks = json.loads(result.stdout)
except json.JSONDecodeError:
stacks = []
for stack in stacks:
stack_prs = {
int(item["number"])
for item in stack.get("pull_requests", [])
if "number" in item
}
if numbers.issubset(stack_prs):
outcome = shell(
["gh", "stack", "unstack", str(stack["number"])],
cwd=repo,
check=False,
timeout=120,
)
if outcome.returncode:
errors.append(outcome.stderr.strip())
break
for number in sorted(numbers):
state = shell(
[
"gh",
"pr",
"view",
str(number),
"--repo",
REPO_SLUG,
"--json",
"state",
"--jq",
".state",
],
cwd=repo,
check=False,
).stdout.strip()
if state == "MERGED":
continue
outcome = shell(
["gh", "pr", "close", str(number), "--repo", REPO_SLUG],
cwd=repo,
check=False,
timeout=90,
)
if outcome.returncode:
errors.append(outcome.stderr.strip())
if case_name in {"multi-remote-push", "submit-prs", "merge-stack", "link-stack"}:
for branch in reversed(branches):
delete_remote(branch)
delete_remote(metadata["base_branch"])
return {"cleanup_errors": [error for error in errors if error]}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--case", choices=sorted(CASES))
parser.add_argument("--arm", choices=sorted(ARMS))
parser.add_argument("--model", choices=sorted(MODELS))
parser.add_argument("--iteration", default="1", help="Run ID prefix")
parser.add_argument(
"--skill-path",
type=Path,
help="Override the skill directory for the current configuration",
)
parser.add_argument(
"--skill-ref",
help="Load skills/gh-stack from a git commit, tag, or branch",
)
parser.add_argument("--list", action="store_true", help="List eval cases and exit")
parser.add_argument(
"--keep-remote",
action="store_true",
help="Skip cleanup for debugging",
)
args = parser.parse_args()
if args.list:
for case in CASES.values():
print(f"{case['name']}\t{case['tier']}\t{case['fixture']}")
return 0
if not args.case:
parser.error("--case is required unless --list is used")
if not args.arm or not args.model:
parser.error("--arm and --model are required")
if args.skill_path and args.skill_ref:
parser.error("--skill-path and --skill-ref are mutually exclusive")
configure()
suffix = uuid.uuid4().hex[:6]
run_id = f"{args.iteration}-{args.case[:6]}-{args.arm[:3]}-{args.model}-{suffix}"
branch_prefix = f"eval/{run_id}"
run_dir = RESULTS_ROOT / run_id
run_dir.mkdir(parents=True)
metadata: dict[str, Any] = {
"run_id": run_id,
"case": args.case,
"tier": CASES[args.case]["tier"],
"network": CASES[args.case]["network"],
"arm": args.arm,
"model": args.model,
"skill_path": str(args.skill_path) if args.skill_path else None,
"skill_ref": args.skill_ref,
"prefix": branch_prefix,
}
(run_dir / "metadata.json").write_text(json.dumps(metadata, indent=2) + "\n")
repo: Path | None = None
try:
repo = prepare_repo(
run_dir,
args.arm,
args.skill_path,
args.skill_ref,
)
metadata["fixture_seed_commit"] = git(repo, "rev-parse", DEFAULT_BRANCH)
installed_skill = (
repo / ".agents/skills/gh-stack"
if args.arm != "none"
else None
)
skill_source = (
f"git:{args.skill_ref}"
if args.skill_ref
else f"path:{args.skill_path.resolve()}"
if args.skill_path
else "working-tree"
if args.arm == "current"
else "none"
)
metadata["provenance"] = provenance(
args.case,
installed_skill,
args.skill_ref,
skill_source,
args.model,
)
home = prepare_home(run_dir)
base_branch = create_eval_base(repo, run_id)
metadata["base_branch"] = base_branch
fixture = prepare_fixture(
repo,
args.case,
run_id,
branch_prefix,
base_branch,
)
metadata.update(fixture)
metadata["prompt"] = render_prompt(
args.case,
run_id,
branch_prefix,
metadata,
)
(run_dir / "metadata.json").write_text(json.dumps(metadata, indent=2) + "\n")
execution = run_agent(
run_dir,
repo,
home,
args.model,
metadata["prompt"],
int(CASES[args.case].get("timeout_seconds", 360)),
)
telemetry_data = telemetry(run_dir, home)
grade_data = grade(repo, args.case, metadata, telemetry_data)
cleanup_data = (
{"cleanup_errors": []}
if args.keep_remote
else cleanup(repo, args.case, metadata)
)
result = {
**metadata,
"status": "PASS" if grade_data["passed"] else "FAIL",
"execution": execution,
"telemetry": telemetry_data,
"grade": grade_data,
"cleanup": cleanup_data,
}
except Exception as error:
cleanup_data = {"cleanup_errors": []}
if repo and metadata.get("base_branch") and not args.keep_remote:
try:
cleanup_data = cleanup(repo, args.case, metadata)
except Exception as cleanup_error:
cleanup_data = {
"cleanup_errors": [
f"{type(cleanup_error).__name__}: {cleanup_error}"
]
}
result = {
**metadata,
"status": "INFRA_FAILURE",
"fatal_error": f"{type(error).__name__}: {error}",
"cleanup": cleanup_data,
}
(run_dir / "result.json").write_text(json.dumps(result, indent=2) + "\n")
print(json.dumps(result, indent=2))
return 0 if result.get("grade", {}).get("passed") else 1
if __name__ == "__main__":
sys.exit(main())