ma-agents 3.13.2 → 3.14.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.
Files changed (107) hide show
  1. package/README.md +3 -3
  2. package/lib/agents.js +9 -2
  3. package/lib/bmad-cache/bmb/.claude-plugin/marketplace.json +3 -3
  4. package/lib/bmad-cache/bmb/_git_preserved/index +0 -0
  5. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.idx +0 -0
  6. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/{pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.pack → pack-b388526edf307cf56f8e65673466cff074105abf.pack} +0 -0
  7. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.rev +0 -0
  8. package/lib/bmad-cache/bmb/_git_preserved/packed-refs +1 -1
  9. package/lib/bmad-cache/bmb/_git_preserved/refs/heads/main +1 -1
  10. package/lib/bmad-cache/bmb/_git_preserved/shallow +1 -1
  11. package/lib/bmad-cache/bmb/package-lock.json +2 -2
  12. package/lib/bmad-cache/bmb/package.json +1 -1
  13. package/lib/bmad-cache/bmb/samples/bmad-agent-code-coach/scripts/wake.py +78 -0
  14. package/lib/bmad-cache/bmb/samples/bmad-agent-creative-muse/scripts/wake.py +78 -0
  15. package/lib/bmad-cache/bmb/samples/bmad-agent-sentinel/scripts/wake.py +78 -0
  16. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/customize-template.toml +3 -0
  17. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/init-sanctum-template.py +11 -5
  18. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/report-shell.html +1073 -0
  19. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/wake-template.py +78 -0
  20. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/customize.toml +48 -0
  21. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/count_tokens.py +78 -0
  22. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass.py +258 -0
  23. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/process-template.py +20 -0
  24. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/render_report.py +387 -0
  25. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/adapter-claude-code.json +9 -0
  26. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/aggregate_benchmark.py +236 -0
  27. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/memlog.py +197 -0
  28. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_evals.py +513 -390
  29. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_triggers.py +347 -251
  30. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_env_isolation.py +79 -0
  31. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_trigger_detection.py +123 -0
  32. package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/tests/test-validate-module.py +82 -4
  33. package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/validate-module.py +55 -11
  34. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/customize-template.toml +3 -5
  35. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/report-shell.html +860 -0
  36. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/customize.toml +59 -0
  37. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/count_tokens.py +77 -0
  38. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/init_skill.py +132 -0
  39. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/prepass-prompt-metrics.py +186 -221
  40. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/quick_validate.py +117 -0
  41. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/render_report.py +387 -0
  42. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_canon_sync.py +46 -0
  43. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_count_tokens.py +180 -0
  44. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_render_report.py +171 -0
  45. package/lib/bmad-cache/cache-manifest.json +4 -4
  46. package/lib/bmad-customize/bmm-qa.customize.yaml +3 -3
  47. package/lib/bmad-extension/.claude-plugin/marketplace.json.template +3 -2
  48. package/lib/bmad-extension/skills/bmad-dev-epic/SKILL.md +101 -0
  49. package/lib/bmad-extension/skills/bmad-dev-epic/bmad-skill-manifest.yaml +3 -0
  50. package/lib/bmad-extension/skills/bmad-dev-epic/checklist.md +44 -0
  51. package/lib/bmad-extension/skills/bmad-dev-epic/customize.toml +54 -0
  52. package/lib/bmad-extension/skills/bmad-dev-epic/references/story-pipeline.md +112 -0
  53. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-01-intake.md +59 -0
  54. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-02-plan-waves.md +56 -0
  55. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-03-epic-branch.md +35 -0
  56. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-04-run-waves.md +49 -0
  57. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-05-epic-adversarial.md +31 -0
  58. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-06-release.md +57 -0
  59. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-07-report.md +40 -0
  60. package/lib/bmad-extension/skills/ma-agent-sqa/SKILL.md +3 -3
  61. package/lib/bmad-extension/skills/ma-agent-sqa/bmad-skill-manifest.yaml +1 -1
  62. package/lib/bmad-extension/skills/module-help.csv +2 -1
  63. package/lib/bmad-extension/skills/module.yaml +1 -1
  64. package/lib/bmad-extension/skills/sqa-audit/SKILL.md +2 -2
  65. package/lib/bmad-extension/skills/sqa-ieee12207/SKILL.md +3 -3
  66. package/lib/bmad-extension/skills/sqa-requirements-quality/SKILL.md +2 -2
  67. package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +4 -3
  68. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/SKILL.md +101 -0
  69. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/bmad-skill-manifest.yaml +3 -0
  70. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/checklist.md +44 -0
  71. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/customize.toml +54 -0
  72. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/references/story-pipeline.md +112 -0
  73. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-01-intake.md +59 -0
  74. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-02-plan-waves.md +56 -0
  75. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-03-epic-branch.md +35 -0
  76. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-04-run-waves.md +49 -0
  77. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-05-epic-adversarial.md +31 -0
  78. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-06-release.md +57 -0
  79. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-07-report.md +40 -0
  80. package/lib/bmad-extension-plugin/skills/ma-agent-sqa/SKILL.md +3 -3
  81. package/lib/bmad-extension-plugin/skills/ma-agent-sqa/bmad-skill-manifest.yaml +1 -1
  82. package/lib/bmad-extension-plugin/skills/module-help.csv +2 -1
  83. package/lib/bmad-extension-plugin/skills/module.yaml +1 -1
  84. package/lib/bmad-extension-plugin/skills/sqa-audit/SKILL.md +2 -2
  85. package/lib/bmad-extension-plugin/skills/sqa-ieee12207/SKILL.md +3 -3
  86. package/lib/bmad-extension-plugin/skills/sqa-requirements-quality/SKILL.md +2 -2
  87. package/lib/bmad.js +6 -1
  88. package/package.json +3 -3
  89. package/lib/.bmad-extension-plugin.build-22612-1782454014750/.claude-plugin/marketplace.json +0 -109
  90. package/lib/.bmad-extension-plugin.build-22612-1782454014750/skills/module-help.csv +0 -62
  91. package/lib/.bmad-extension-plugin.build-22612-1782454014750/skills/module.yaml +0 -20
  92. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.idx +0 -0
  93. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.rev +0 -0
  94. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/references/sample-init-sanctum.py +0 -274
  95. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/generate-html-report.py +0 -534
  96. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-execution-deps.py +0 -337
  97. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-prompt-metrics.py +0 -425
  98. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-sanctum-architecture.py +0 -385
  99. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-structure-capabilities.py +0 -482
  100. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/Dockerfile +0 -29
  101. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/docker_setup.py +0 -115
  102. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/generate_report.py +0 -184
  103. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/pty_runner.py +0 -171
  104. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/utils.py +0 -260
  105. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/extract-report-json.py +0 -287
  106. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/generate-html-report.py +0 -588
  107. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/prepass-execution-deps.py +0 -288
@@ -2,31 +2,83 @@
2
2
  # /// script
3
3
  # requires-python = ">=3.9"
4
4
  # ///
5
- """Run a skill's artifact evals in isolated workspaces.
6
-
7
- For each eval, the runner:
8
- 1. Stages a fresh workspace (Docker container or local tmp dir under ~/bmad-evals).
9
- 2. Applies the setup overlay (base then per-eval) so _bmad/ config and dependency
10
- skills land in the workspace BEFORE the skill is staged the skill's own copy
11
- always wins over overlay content.
12
- 3. Copies the skill into .claude/skills/ so it is discoverable by claude.
13
- 4. Stages any fixture files declared in the eval's `files` list.
14
- 5. Runs `claude -p '<prompt>' --output-format stream-json --verbose`, capturing
15
- the transcript. The Skill tool is available in -p mode and fires for installed
16
- skills, so dependency skills provided by the setup overlay are properly invokable.
17
- 6. Rsyncs any files claude wrote into `<run-dir>/<eval-id>/artifacts/`.
18
- 7. Writes `metrics.json` (tool-call counts, timing, output sizes).
19
-
20
- Grading is performed separately by the parent skill's grader subagents.
5
+ """Run eval cases through the configured platform adapter.
6
+
7
+ A case is `input + rubric + optional state_prefix + optional files`. This
8
+ runner does the runtime-specific part of an eval: it stages the skill under
9
+ test and the case's fixture files into a clean working directory, builds the
10
+ prompt the adapter understands, runs it, and records the transcript plus
11
+ timing and token usage. Grading happens elsewhere; the grader subagent reads
12
+ the transcript and artifacts this runner leaves behind.
13
+
14
+ What this runner deliberately does NOT do:
15
+ - No Docker, no PTY, no keychain staging, no dual-isolation strategy.
16
+ - No hardcoded model. Everything runtime-specific comes from the adapter.
17
+
18
+ Modes (--mode) decide which configs each case runs under:
19
+
20
+ quality : one config, "skill" the skill staged in the cwd.
21
+ baseline : two configs per case — "skill" (skill staged) and "bare"
22
+ (nothing staged), same input, so the bare-model floor is
23
+ measured under identical conditions.
24
+ variant : two configs — "skill" (--skill-path) and "variant"
25
+ (--variant-path, the stripped or prior-version skill).
26
+
27
+ Run layout: <run-dir>/<config>/<case-id>/ (plus /run-N/ when --runs > 1),
28
+ so `aggregate_benchmark.py --baseline <run-dir>/bare --variant
29
+ <run-dir>/skill` compares configs directly from the timing.json files.
30
+
31
+ Skill staging: the skill directory is copied (symlink where possible) into
32
+ <case-cwd>/<skill_dir>/<skill-name>/ before the adapter is invoked, where
33
+ skill_dir comes from the adapter (default ".claude/skills"). Without this
34
+ every config would measure the bare model.
35
+
36
+ Fixtures: each path in a case's `files` list is staged into the case cwd at
37
+ its own relative path. Sources resolve against --project-root, then the cases
38
+ file's directory, then as absolute paths.
39
+
40
+ Isolation: the subprocess env is built from scratch, never inherited. It
41
+ holds PATH, a fresh empty HOME at <case>/.home, CLAUDE_CONFIG_DIR inside
42
+ that HOME, the adapter's auth_env var ONLY if set non-empty in the host env
43
+ (setting it to "" would break the runtime's own credential fallback), and any
44
+ adapter `env_passthrough` keys present in the host env. Nothing else crosses.
45
+
46
+ The adapter config file (JSON) — schema and discovery rules in
47
+ references/platform-adapter.md, working example in
48
+ assets/adapter-claude-code.json:
49
+
50
+ invocation : argv template. "{prompt}" -> composed case prompt,
51
+ "{cwd}" -> clean working directory.
52
+ auth_env : env var name carrying auth (e.g. "ANTHROPIC_API_KEY").
53
+ transcript : {"format": "stdout-jsonl"} or
54
+ {"format": "file", "path": "transcript.jsonl"}.
55
+ skill_dir : where the runtime discovers skills under the cwd.
56
+ env_passthrough : optional list of extra host env vars to forward.
57
+
58
+ If no adapter config is found, the runner degrades gracefully: it stages every
59
+ case (clean cwd, skill, fixtures, prompt with state_prefix applied) and writes
60
+ a manifest, but records each result as "skipped: no runtime adapter
61
+ configured" instead of crashing. A human or a configured runtime can then
62
+ complete the run.
63
+
64
+ state_prefix handling: when a case carries a state_prefix, it is PREPENDED to
65
+ the input to place the skill mid-workflow in one shot. The composed prompt is
66
+ recorded so the grader sees exactly what ran.
21
67
 
22
68
  Usage:
23
69
  python3 run_evals.py \\
24
- --skill-path PATH \\
25
- --evals-file PATH/evals.json \\
26
- --project-root PATH \\
27
- --output-dir PATH \\
28
- --isolation docker|local \\
29
- [--workers N] [--timeout SECS] [--eval-ids A1,B3] [--quiet]
70
+ --cases CASES.json \\
71
+ --skill-path SKILL_DIR \\
72
+ --output-dir DIR \\
73
+ [--mode quality|baseline|variant] \\
74
+ [--variant-path SKILL_DIR] \\
75
+ [--project-root DIR] \\
76
+ [--adapter ADAPTER.json] \\
77
+ [--case-ids A1,B3] [--runs N] [--timeout SECS] [--workers N] [--quiet]
78
+
79
+ CASES.json is either a list of cases or {"cases": [...]}. Each case:
80
+ {"id": "...", "input": "...", "rubric": [...],
81
+ "state_prefix": "..."?, "files": ["..."]?}
30
82
  """
31
83
 
32
84
  from __future__ import annotations
@@ -38,448 +90,519 @@ import shutil
38
90
  import subprocess
39
91
  import sys
40
92
  import time
93
+ from collections.abc import Mapping
41
94
  from concurrent.futures import ThreadPoolExecutor, as_completed
95
+ from datetime import datetime, timezone
42
96
  from pathlib import Path
43
97
 
44
- SCRIPT_DIR = Path(__file__).resolve().parent
45
- sys.path.insert(0, str(SCRIPT_DIR))
46
-
47
- from utils import ( # noqa: E402
48
- apply_setup_overlay,
49
- discover_setup_dirs,
50
- new_run_id,
51
- parse_skill_md,
52
- read_json,
53
- read_macos_keychain_credentials,
54
- stage_credentials,
55
- utc_now_iso,
56
- write_json,
57
- )
58
-
59
- DOCKER_IMAGE = "bmad-eval-runner:latest"
60
- _KEYCHAIN_CREDS: str | None = read_macos_keychain_credentials()
61
- RSYNC_EXCLUDES = (
62
- ".git", ".bare", "node_modules", ".venv", "__pycache__",
63
- ".pytest_cache", ".next", "dist", "build", ".cache",
64
- ".DS_Store", "*.pyc",
65
- )
66
-
67
-
68
- def stage_workspace_local(
69
- workspace: Path,
70
- project_root: Path,
71
- skill_path: Path,
72
- fixtures: list[tuple[Path, str]],
73
- setup_dirs: list[Path] | None = None,
74
- ) -> Path:
75
- """Build a clean local workspace. Returns the project root inside workspace."""
76
- workspace.mkdir(parents=True, exist_ok=True)
77
- project_dest = workspace / "project"
78
- home_dir = workspace / ".home"
79
- (home_dir / ".claude").mkdir(parents=True, exist_ok=True)
80
98
 
81
- excludes: list[str] = []
82
- for pat in RSYNC_EXCLUDES:
83
- excludes.extend(["--exclude", pat])
99
+ # --- small self-contained helpers (no Docker/keychain imports) -------------
100
+
101
+ def utc_now_iso() -> str:
102
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
103
+
104
+
105
+ def new_run_id(label: str) -> str:
106
+ return f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{label}"
107
+
108
+
109
+ def write_json(path: Path, data: object) -> None:
110
+ path.parent.mkdir(parents=True, exist_ok=True)
111
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
112
+
113
+
114
+ def read_json(path: Path) -> object:
115
+ return json.loads(path.read_text(encoding="utf-8"))
116
+
117
+
118
+ # --- adapter ----------------------------------------------------------------
119
+
120
+ def find_adapter(explicit: Path | None, cases_file: Path) -> Path | None:
121
+ """Locate the adapter config. Returns None when none is configured."""
122
+ if explicit is not None:
123
+ return explicit if explicit.is_file() else None
124
+ env_path = os.environ.get("BMAD_EVAL_ADAPTER")
125
+ if env_path and Path(env_path).is_file():
126
+ return Path(env_path)
127
+ for candidate in (
128
+ cases_file.parent / "adapter.json",
129
+ cases_file.parent / ".bmad-eval-adapter.json",
130
+ ):
131
+ if candidate.is_file():
132
+ return candidate
133
+ return None
84
134
 
85
- if shutil.which("rsync"):
86
- subprocess.run(
87
- ["rsync", "-a", *excludes, f"{project_root}/", f"{project_dest}/"],
88
- check=True,
89
- )
90
- else:
91
- shutil.copytree(project_root, project_dest, dirs_exist_ok=True,
92
- ignore=shutil.ignore_patterns(*RSYNC_EXCLUDES))
93
135
 
94
- # Apply setup overlay before staging the skill — the skill's own copy wins.
95
- if setup_dirs:
96
- apply_setup_overlay(setup_dirs, project_dest)
136
+ def load_adapter(path: Path) -> dict:
137
+ cfg = read_json(path)
138
+ if not isinstance(cfg, dict):
139
+ raise ValueError(f"adapter config must be a JSON object: {path}")
140
+ if "invocation" not in cfg or not isinstance(cfg["invocation"], list):
141
+ raise ValueError("adapter config missing 'invocation' argv list")
142
+ return cfg
97
143
 
98
- skill_link_dir = project_dest / ".claude" / "skills"
99
- skill_link_dir.mkdir(parents=True, exist_ok=True)
100
- skill_dest = skill_link_dir / skill_path.name
101
- if not skill_dest.exists():
144
+
145
+ def build_argv(invocation: list, prompt: str, cwd: str) -> list[str]:
146
+ argv: list[str] = []
147
+ for tok in invocation:
148
+ tok = str(tok)
149
+ tok = (tok.replace("{prompt}", prompt)
150
+ .replace("{query}", prompt)
151
+ .replace("{cwd}", cwd))
152
+ argv.append(tok)
153
+ return argv
154
+
155
+
156
+ def build_case_env(adapter: Mapping | None, home_dir: Path,
157
+ host_env: Mapping[str, str]) -> dict[str, str]:
158
+ """Build the subprocess environment from scratch — never from os.environ.
159
+
160
+ Inheriting the host env would leak shell config, tokens, and runtime
161
+ state into the clean room. The env holds exactly: PATH, a fresh HOME,
162
+ CLAUDE_CONFIG_DIR inside it, the adapter's auth var ONLY when set
163
+ non-empty in the host (an empty-string auth var breaks the runtime's own
164
+ credential fallback), and any adapter env_passthrough keys present in
165
+ the host env.
166
+ """
167
+ adapter = adapter or {}
168
+ env = {
169
+ "PATH": host_env.get("PATH", ""),
170
+ "HOME": str(home_dir),
171
+ "CLAUDE_CONFIG_DIR": str(home_dir / ".claude"),
172
+ }
173
+ auth_env = adapter.get("auth_env")
174
+ if auth_env:
175
+ val = host_env.get(str(auth_env))
176
+ if val:
177
+ env[str(auth_env)] = val
178
+ for key in adapter.get("env_passthrough") or []:
179
+ val = host_env.get(str(key))
180
+ if val is not None:
181
+ env[str(key)] = val
182
+ return env
183
+
184
+
185
+ # --- staging: skill under test + fixtures ------------------------------------
186
+
187
+ def stage_skill(skill_path: Path, cwd: Path, skills_subdir: str) -> Path:
188
+ """Place the skill where the runtime discovers skills inside the cwd.
189
+
190
+ Symlink when possible (cheap, and the skill is read-only to the run);
191
+ copy as the fallback.
192
+ """
193
+ dest_root = cwd / skills_subdir
194
+ dest_root.mkdir(parents=True, exist_ok=True)
195
+ dest = dest_root / skill_path.name
196
+ if not dest.exists():
102
197
  try:
103
- os.symlink(skill_path, skill_dest)
198
+ os.symlink(skill_path, dest)
104
199
  except OSError:
105
- shutil.copytree(skill_path, skill_dest, dirs_exist_ok=True)
200
+ shutil.copytree(skill_path, dest, dirs_exist_ok=True)
201
+ return dest
202
+
203
+
204
+ def resolve_fixtures(files: list, project_root: Path,
205
+ cases_dir: Path) -> list[tuple[Path, str]]:
206
+ """Map each `files` entry to (source, dest-relative-path).
207
+
208
+ The entry's own relative path is preserved inside the cwd, so a bare
209
+ filename lands at the workspace root and a nested path keeps its
210
+ directory structure — matching the path the case input references.
211
+ """
212
+ out: list[tuple[Path, str]] = []
213
+ for entry in files or []:
214
+ entry = str(entry)
215
+ for candidate in (
216
+ (project_root / entry).resolve(),
217
+ (cases_dir / entry).resolve(),
218
+ Path(entry).resolve(),
219
+ ):
220
+ if candidate.is_file():
221
+ out.append((candidate, entry))
222
+ break
223
+ else:
224
+ print(f"Warning: fixture not found: {entry}", file=sys.stderr)
225
+ return out
226
+
106
227
 
228
+ def stage_fixtures(fixtures: list[tuple[Path, str]], cwd: Path) -> None:
107
229
  for src, dest_rel in fixtures:
108
- dest = project_dest / dest_rel
230
+ dest = cwd / dest_rel
109
231
  dest.parent.mkdir(parents=True, exist_ok=True)
110
232
  shutil.copy2(src, dest)
111
233
 
112
- return project_dest
113
-
114
-
115
- def run_eval_local(
116
- eval_item: dict,
117
- run_dir: Path,
118
- skill_path: Path,
119
- project_root: Path,
120
- timeout: int,
121
- setup_dirs: list[Path] | None = None,
122
- ) -> dict:
123
- eval_id = str(eval_item.get("id", "unnamed"))
124
- eval_dir = run_dir / eval_id
125
- workspace_root = eval_dir / "workspace"
126
- artifacts_dir = eval_dir / "artifacts"
127
- transcript_path = eval_dir / "transcript.jsonl"
128
-
129
- eval_dir.mkdir(parents=True, exist_ok=True)
130
- artifacts_dir.mkdir(parents=True, exist_ok=True)
131
-
132
- fixtures = resolve_fixtures(eval_item.get("files", []), project_root)
133
- workspace_project = stage_workspace_local(
134
- workspace_root, project_root, skill_path, fixtures, setup_dirs
135
- )
136
234
 
137
- (eval_dir / "prompt.txt").write_text(eval_item["prompt"], encoding="utf-8")
138
- workspace_snapshot_before = snapshot_files(workspace_project)
235
+ # --- case composition -------------------------------------------------------
139
236
 
140
- home_dir = workspace_root / ".home"
141
- stage_credentials(home_dir / ".claude", _KEYCHAIN_CREDS)
142
- env = {
143
- "HOME": str(home_dir),
144
- "CLAUDE_CONFIG_DIR": str(home_dir / ".claude"),
145
- "PATH": os.environ.get("PATH", ""),
146
- "ANTHROPIC_API_KEY": os.environ.get("ANTHROPIC_API_KEY", ""),
147
- }
237
+ def compose_prompt(case: dict) -> str:
238
+ """Apply state_prefix by prepending it to the input.
148
239
 
149
- cmd = [
150
- "claude",
151
- "-p", eval_item["prompt"],
152
- "--output-format", "stream-json",
153
- "--verbose",
154
- "--dangerously-skip-permissions",
155
- ]
240
+ The state_prefix is a bracketed prime that places the skill mid-workflow in
241
+ one shot. Prepending keeps the input intact and visible to the grader.
242
+ """
243
+ input_text = str(case.get("input", ""))
244
+ prefix = case.get("state_prefix")
245
+ if prefix:
246
+ return f"{str(prefix).rstrip()}\n\n{input_text}"
247
+ return input_text
156
248
 
157
- start = time.time()
158
- try:
159
- with transcript_path.open("wb") as out:
160
- proc = subprocess.run(
161
- cmd,
162
- stdout=out,
163
- stderr=subprocess.PIPE,
164
- cwd=str(workspace_project),
165
- env=env,
166
- timeout=timeout,
167
- )
168
- elapsed = time.time() - start
169
- return_code = proc.returncode
170
- stderr_tail = (proc.stderr or b"").decode("utf-8", errors="replace")[-2000:]
171
- except subprocess.TimeoutExpired as e:
172
- elapsed = time.time() - start
173
- return_code = -1
174
- stderr_tail = f"TIMEOUT after {timeout}s"
175
- if e.stderr:
176
- stderr_tail += "\n" + e.stderr.decode("utf-8", errors="replace")[-2000:]
177
249
 
178
- new_files = diff_workspace(workspace_project, workspace_snapshot_before)
179
- sync_artifacts(workspace_project, new_files, artifacts_dir)
250
+ # --- transcript + token accounting -----------------------------------------
251
+
252
+ def read_transcript(transcript_cfg: dict, captured_stdout: bytes,
253
+ cwd: Path) -> tuple[str, str]:
254
+ """Return (transcript_text, source). Source names where it came from."""
255
+ fmt = (transcript_cfg or {}).get("format", "stdout-jsonl")
256
+ if fmt == "file":
257
+ rel = (transcript_cfg or {}).get("path", "transcript.jsonl")
258
+ f = cwd / rel
259
+ if f.is_file():
260
+ return f.read_text(encoding="utf-8", errors="replace"), f"file:{rel}"
261
+ return "", f"file:{rel} (missing)"
262
+ return captured_stdout.decode("utf-8", errors="replace"), "stdout"
263
+
264
+
265
+ def account_transcript(transcript_text: str) -> dict:
266
+ """Pull timing/token usage from a JSONL transcript when present.
267
+
268
+ Reads usage out of the completion notification immediately, so tokens are
269
+ captured at run time rather than recomputed later. Recognizes the common
270
+ `result` event with a usage block and per-message usage blocks; unknown
271
+ shapes degrade to zero counts without failing.
272
+ """
273
+ input_tokens = 0
274
+ output_tokens = 0
275
+ total_steps = 0
276
+ tool_calls: dict[str, int] = {}
277
+ found_usage = False
180
278
 
181
- metrics = compute_metrics(transcript_path, artifacts_dir, elapsed, return_code, stderr_tail)
182
- write_json(eval_dir / "metrics.json", metrics)
279
+ for raw in transcript_text.splitlines():
280
+ raw = raw.strip()
281
+ if not raw:
282
+ continue
283
+ try:
284
+ evt = json.loads(raw)
285
+ except json.JSONDecodeError:
286
+ continue
287
+ if not isinstance(evt, dict):
288
+ continue
289
+ etype = evt.get("type")
290
+ if etype == "assistant":
291
+ total_steps += 1
292
+ msg = evt.get("message", {})
293
+ usage = msg.get("usage") if isinstance(msg, dict) else None
294
+ if isinstance(usage, dict):
295
+ found_usage = True
296
+ input_tokens += int(usage.get("input_tokens", 0) or 0)
297
+ output_tokens += int(usage.get("output_tokens", 0) or 0)
298
+ for item in (msg.get("content", []) if isinstance(msg, dict) else []):
299
+ if isinstance(item, dict) and item.get("type") == "tool_use":
300
+ name = item.get("name", "?")
301
+ tool_calls[name] = tool_calls.get(name, 0) + 1
302
+ elif etype == "result":
303
+ usage = evt.get("usage")
304
+ if isinstance(usage, dict):
305
+ found_usage = True
306
+ # result usage is authoritative; prefer it over the running sum
307
+ input_tokens = int(usage.get("input_tokens", input_tokens) or input_tokens)
308
+ output_tokens = int(usage.get("output_tokens", output_tokens) or output_tokens)
183
309
 
184
310
  return {
185
- "eval_id": eval_id,
186
- "elapsed_s": elapsed,
187
- "return_code": return_code,
188
- "transcript": str(transcript_path.relative_to(run_dir)),
189
- "artifacts_dir": str(artifacts_dir.relative_to(run_dir)),
190
- "metrics": metrics,
311
+ "input_tokens": input_tokens,
312
+ "output_tokens": output_tokens,
313
+ "total_tokens": input_tokens + output_tokens,
314
+ "tokens_reported": found_usage,
315
+ "total_steps": total_steps,
316
+ "tool_calls": tool_calls,
317
+ "total_tool_calls": sum(tool_calls.values()),
191
318
  }
192
319
 
193
320
 
194
- def run_eval_docker(
195
- eval_item: dict,
196
- run_dir: Path,
197
- skill_path: Path,
198
- project_root: Path,
199
- timeout: int,
200
- setup_dirs: list[Path] | None = None,
201
- ) -> dict:
202
- eval_id = str(eval_item.get("id", "unnamed"))
203
- eval_dir = run_dir / eval_id
204
- artifacts_dir = eval_dir / "artifacts"
205
- transcript_path = eval_dir / "transcript.jsonl"
206
-
207
- eval_dir.mkdir(parents=True, exist_ok=True)
208
- artifacts_dir.mkdir(parents=True, exist_ok=True)
209
- fixtures_staging = eval_dir / "fixtures_in"
210
- fixtures_staging.mkdir(parents=True, exist_ok=True)
211
-
212
- fixtures = resolve_fixtures(eval_item.get("files", []), project_root)
213
- for src, dest_rel in fixtures:
214
- dest = fixtures_staging / dest_rel
215
- dest.parent.mkdir(parents=True, exist_ok=True)
216
- shutil.copy2(src, dest)
321
+ # --- per-case execution -----------------------------------------------------
322
+
323
+ def run_case(case: dict, case_dir: Path, run_dir: Path,
324
+ adapter: dict | None, timeout: int, config: str,
325
+ skill_path: Path | None,
326
+ fixtures: list[tuple[Path, str]]) -> dict:
327
+ case_id = str(case.get("id", "unnamed"))
328
+ cwd = case_dir / "cwd"
329
+ cwd.mkdir(parents=True, exist_ok=True)
330
+
331
+ stage_fixtures(fixtures, cwd)
332
+ if skill_path is not None:
333
+ skills_subdir = (adapter or {}).get("skill_dir", ".claude/skills")
334
+ stage_skill(skill_path, cwd, skills_subdir)
335
+
336
+ prompt = compose_prompt(case)
337
+ (case_dir / "prompt.txt").write_text(prompt, encoding="utf-8")
338
+ write_json(case_dir / "case.json", case)
339
+
340
+ if adapter is None:
341
+ result = {
342
+ "case_id": case_id,
343
+ "config": config,
344
+ "status": "skipped",
345
+ "reason": "no runtime adapter configured",
346
+ "prompt_chars": len(prompt),
347
+ "cwd": str(cwd.relative_to(run_dir)),
348
+ }
349
+ write_json(case_dir / "timing.json", {
350
+ "case_id": case_id, "config": config, "status": "skipped",
351
+ "captured_at": utc_now_iso(),
352
+ })
353
+ return result
217
354
 
218
- (eval_dir / "prompt.txt").write_text(eval_item["prompt"], encoding="utf-8")
219
-
220
- # Pre-merge setup overlay dirs on the host; mount as /setup:ro in the container.
221
- setup_merged: Path | None = None
222
- if setup_dirs:
223
- setup_merged = eval_dir / "setup_merged"
224
- apply_setup_overlay(setup_dirs, setup_merged)
225
- if not any(setup_merged.iterdir()):
226
- setup_merged = None
227
-
228
- creds_dir: Path | None = None
229
- if _KEYCHAIN_CREDS:
230
- creds_dir = eval_dir / "creds"
231
- creds_dir.mkdir(parents=True, exist_ok=True)
232
- (creds_dir / ".credentials.json").write_text(_KEYCHAIN_CREDS, encoding="utf-8")
233
-
234
- container_script = r"""
235
- set -e
236
- mkdir -p /workspace
237
- rsync -a \
238
- --exclude=.git --exclude=.bare --exclude=node_modules --exclude=.venv \
239
- --exclude=__pycache__ --exclude=.pytest_cache --exclude=.next \
240
- --exclude=dist --exclude=build --exclude=.cache --exclude=.DS_Store \
241
- /project/ /workspace/
242
- if [ -d /setup ]; then
243
- rsync -a /setup/ /workspace/
244
- fi
245
- mkdir -p /workspace/.claude/skills
246
- cp -R "$SKILL_SRC" "/workspace/.claude/skills/$SKILL_NAME"
247
- if [ -d /fixtures ]; then
248
- cp -R /fixtures/. /workspace/
249
- fi
250
- if [ -f /creds/.credentials.json ]; then
251
- mkdir -p /home/evaluator/.claude
252
- cp /creds/.credentials.json /home/evaluator/.claude/.credentials.json
253
- fi
254
- cd /workspace
255
- claude -p "$EVAL_PROMPT" \
256
- --output-format stream-json --verbose \
257
- --dangerously-skip-permissions \
258
- > /output/transcript.jsonl 2> /output/stderr.log || true
259
- mkdir -p /output/artifacts
260
- rsync -a --exclude=.claude --exclude=node_modules --exclude=.git \
261
- --filter='+ */' --filter='+ *' \
262
- /workspace/ /output/artifacts/
263
- """
355
+ transcript_path = case_dir / "transcript.jsonl"
356
+ argv = build_argv(adapter["invocation"], prompt, str(cwd))
264
357
 
265
- skill_name = skill_path.name
266
- cmd = [
267
- "docker", "run", "--rm",
268
- "-v", f"{project_root}:/project:ro",
269
- "-v", f"{skill_path}:/skill_src:ro",
270
- "-v", f"{eval_dir}:/output",
271
- "-e", "ANTHROPIC_API_KEY",
272
- "-e", f"EVAL_PROMPT={eval_item['prompt']}",
273
- "-e", f"SKILL_SRC=/skill_src",
274
- "-e", f"SKILL_NAME={skill_name}",
275
- ]
276
- if creds_dir:
277
- cmd += ["-v", f"{creds_dir}:/creds:ro"]
278
- if fixtures:
279
- cmd += ["-v", f"{fixtures_staging}:/fixtures:ro"]
280
- if setup_merged:
281
- cmd += ["-v", f"{setup_merged}:/setup:ro"]
282
- cmd += [DOCKER_IMAGE, "bash", "-c", container_script]
358
+ home_dir = case_dir / ".home"
359
+ (home_dir / ".claude").mkdir(parents=True, exist_ok=True)
360
+ env = build_case_env(adapter, home_dir, os.environ)
283
361
 
284
362
  start = time.time()
363
+ captured = b""
364
+ return_code = 0
365
+ error_tail = ""
366
+ status = "ok"
285
367
  try:
286
368
  proc = subprocess.run(
287
- cmd,
288
- capture_output=True,
289
- timeout=timeout + 30,
369
+ argv,
370
+ stdout=subprocess.PIPE,
371
+ stderr=subprocess.PIPE,
372
+ cwd=str(cwd),
373
+ env=env,
374
+ timeout=timeout,
290
375
  )
291
- elapsed = time.time() - start
376
+ captured = proc.stdout or b""
292
377
  return_code = proc.returncode
293
- stderr_tail = proc.stderr.decode("utf-8", errors="replace")[-2000:]
294
- if proc.stdout:
295
- (eval_dir / "docker.stdout.log").write_bytes(proc.stdout)
296
- except subprocess.TimeoutExpired as e:
378
+ error_tail = (proc.stderr or b"").decode("utf-8", errors="replace")[-2000:]
379
+ if return_code != 0:
380
+ status = "error"
381
+ except FileNotFoundError as e:
382
+ # Adapter invocation command is not on PATH: degrade, do not crash.
297
383
  elapsed = time.time() - start
384
+ write_json(case_dir / "timing.json", {
385
+ "case_id": case_id, "config": config, "status": "adapter-missing",
386
+ "elapsed_s": round(elapsed, 3), "captured_at": utc_now_iso(),
387
+ })
388
+ return {
389
+ "case_id": case_id,
390
+ "config": config,
391
+ "status": "adapter-missing",
392
+ "reason": f"invocation command not found: {e}",
393
+ "cwd": str(cwd.relative_to(run_dir)),
394
+ }
395
+ except subprocess.TimeoutExpired as e:
396
+ captured = e.stdout or b""
298
397
  return_code = -1
299
- stderr_tail = f"TIMEOUT after {timeout}s"
300
- if e.stderr:
301
- stderr_tail += "\n" + e.stderr.decode("utf-8", errors="replace")[-2000:]
398
+ status = "timeout"
399
+ error_tail = f"TIMEOUT after {timeout}s"
400
+ elapsed = time.time() - start
401
+
402
+ transcript_text, source = read_transcript(
403
+ adapter.get("transcript", {}), captured, cwd
404
+ )
405
+ transcript_path.write_text(transcript_text, encoding="utf-8")
406
+
407
+ accounting = account_transcript(transcript_text)
302
408
 
303
- metrics = compute_metrics(transcript_path, artifacts_dir, elapsed, return_code, stderr_tail)
304
- write_json(eval_dir / "metrics.json", metrics)
305
- shutil.rmtree(fixtures_staging, ignore_errors=True)
409
+ # Capture timing/tokens immediately to timing.json (run-time snapshot).
410
+ timing = {
411
+ "case_id": case_id,
412
+ "config": config,
413
+ "status": status,
414
+ "elapsed_s": round(elapsed, 3),
415
+ "return_code": return_code,
416
+ "transcript_source": source,
417
+ "input_tokens": accounting["input_tokens"],
418
+ "output_tokens": accounting["output_tokens"],
419
+ "total_tokens": accounting["total_tokens"],
420
+ "tokens_reported": accounting["tokens_reported"],
421
+ "total_steps": accounting["total_steps"],
422
+ "total_tool_calls": accounting["total_tool_calls"],
423
+ "captured_at": utc_now_iso(),
424
+ }
425
+ write_json(case_dir / "timing.json", timing)
306
426
 
307
427
  return {
308
- "eval_id": eval_id,
309
- "elapsed_s": elapsed,
428
+ "case_id": case_id,
429
+ "config": config,
430
+ "status": status,
431
+ "elapsed_s": round(elapsed, 3),
310
432
  "return_code": return_code,
311
433
  "transcript": str(transcript_path.relative_to(run_dir)),
312
- "artifacts_dir": str(artifacts_dir.relative_to(run_dir)),
313
- "metrics": metrics,
434
+ "cwd": str(cwd.relative_to(run_dir)),
435
+ "tokens": accounting["total_tokens"],
436
+ "tool_calls": accounting["tool_calls"],
437
+ "error_tail": error_tail,
314
438
  }
315
439
 
316
440
 
317
- def resolve_fixtures(files: list[str], project_root: Path) -> list[tuple[Path, str]]:
318
- out: list[tuple[Path, str]] = []
319
- for entry in files:
320
- candidate = (project_root / entry).resolve()
321
- if not candidate.is_file():
322
- alt = Path(entry).resolve()
323
- if alt.is_file():
324
- candidate = alt
325
- else:
326
- print(f"Warning: fixture not found: {entry}", file=sys.stderr)
327
- continue
328
- out.append((candidate, entry))
329
- return out
330
-
331
-
332
- def snapshot_files(root: Path) -> set[str]:
333
- snap: set[str] = set()
334
- for p in root.rglob("*"):
335
- if p.is_file():
336
- snap.add(str(p.relative_to(root)))
337
- return snap
338
-
441
+ # --- main -------------------------------------------------------------------
339
442
 
340
- def diff_workspace(root: Path, before: set[str]) -> list[str]:
341
- after = snapshot_files(root)
342
- return sorted(after - before)
343
-
344
-
345
- def sync_artifacts(workspace: Path, new_files: list[str], dest: Path) -> None:
346
- for rel in new_files:
347
- src = workspace / rel
348
- if not src.is_file():
349
- continue
350
- if any(part in (".claude", "node_modules", ".git", ".venv") for part in src.parts):
351
- continue
352
- target = dest / rel
353
- target.parent.mkdir(parents=True, exist_ok=True)
354
- shutil.copy2(src, target)
355
-
356
-
357
- def compute_metrics(transcript: Path, artifacts: Path, elapsed: float,
358
- rc: int, stderr_tail: str) -> dict:
359
- tool_calls: dict[str, int] = {}
360
- total_steps = 0
361
- if transcript.is_file():
362
- for raw in transcript.read_text(encoding="utf-8", errors="replace").splitlines():
363
- raw = raw.strip()
364
- if not raw:
365
- continue
366
- try:
367
- evt = json.loads(raw)
368
- except json.JSONDecodeError:
369
- continue
370
- if evt.get("type") == "assistant":
371
- total_steps += 1
372
- for item in evt.get("message", {}).get("content", []):
373
- if item.get("type") == "tool_use":
374
- name = item.get("name", "?")
375
- tool_calls[name] = tool_calls.get(name, 0) + 1
376
-
377
- output_chars = 0
378
- for f in artifacts.rglob("*"):
379
- if f.is_file():
380
- try:
381
- output_chars += f.stat().st_size
382
- except OSError:
383
- pass
384
-
385
- return {
386
- "elapsed_s": round(elapsed, 2),
387
- "return_code": rc,
388
- "tool_calls": tool_calls,
389
- "total_tool_calls": sum(tool_calls.values()),
390
- "total_steps": total_steps,
391
- "output_chars": output_chars,
392
- "transcript_chars": transcript.stat().st_size if transcript.is_file() else 0,
393
- "stderr_tail": stderr_tail,
394
- }
443
+ def load_cases(cases_file: Path) -> list[dict]:
444
+ data = read_json(cases_file)
445
+ if isinstance(data, dict) and "cases" in data:
446
+ cases = data["cases"]
447
+ elif isinstance(data, list):
448
+ cases = data
449
+ else:
450
+ raise ValueError("cases file must be a list or {'cases': [...]}")
451
+ if not isinstance(cases, list):
452
+ raise ValueError("'cases' must be a list")
453
+ return cases
395
454
 
396
455
 
397
- def main() -> int:
398
- parser = argparse.ArgumentParser(description="Run a skill's artifact evals in isolation")
399
- parser.add_argument("--skill-path", required=True, type=Path)
400
- parser.add_argument("--evals-file", required=True, type=Path)
401
- parser.add_argument("--project-root", required=True, type=Path)
402
- parser.add_argument("--output-dir", required=True, type=Path)
403
- parser.add_argument("--isolation", choices=("docker", "local"), required=True)
404
- parser.add_argument("--workers", type=int, default=8)
405
- parser.add_argument("--timeout", type=int, default=600)
406
- parser.add_argument("--eval-ids", default=None, help="Comma-separated subset of eval ids to run")
407
- parser.add_argument("--quiet", action="store_true")
408
- args = parser.parse_args()
456
+ def main(argv: list[str] | None = None) -> int:
457
+ p = argparse.ArgumentParser(
458
+ description=__doc__,
459
+ formatter_class=argparse.RawDescriptionHelpFormatter,
460
+ )
461
+ p.add_argument("--cases", required=True, type=Path)
462
+ p.add_argument("--skill-path", required=True, type=Path,
463
+ help="directory of the skill under test (contains SKILL.md)")
464
+ p.add_argument("--output-dir", required=True, type=Path)
465
+ p.add_argument("--mode", choices=("quality", "baseline", "variant"),
466
+ default="quality")
467
+ p.add_argument("--variant-path", type=Path, default=None,
468
+ help="variant mode: the stripped or prior-version skill")
469
+ p.add_argument("--project-root", type=Path, default=None,
470
+ help="base for resolving fixture paths; defaults to the "
471
+ "cases file's directory")
472
+ p.add_argument("--adapter", type=Path, default=None,
473
+ help="adapter config JSON; defaults to BMAD_EVAL_ADAPTER env "
474
+ "or adapter.json beside the cases file")
475
+ p.add_argument("--case-ids", default=None,
476
+ help="comma-separated subset of case ids to run")
477
+ p.add_argument("--runs", type=int, default=1,
478
+ help="repeats per case per config for the variance benchmark")
479
+ p.add_argument("--timeout", type=int, default=600)
480
+ p.add_argument("--workers", type=int, default=4)
481
+ p.add_argument("--label", default="evals", help="label for the run id")
482
+ p.add_argument("--quiet", action="store_true")
483
+ args = p.parse_args(argv)
484
+
485
+ cases_file = args.cases.resolve()
486
+ if not cases_file.is_file():
487
+ print(f"cases file not found: {cases_file}", file=sys.stderr)
488
+ return 2
409
489
 
410
490
  skill_path = args.skill_path.resolve()
411
- project_root = args.project_root.resolve()
412
- evals_file = args.evals_file.resolve()
413
- if not evals_file.is_file():
414
- print(f"evals file not found: {evals_file}", file=sys.stderr)
491
+ if not (skill_path / "SKILL.md").is_file():
492
+ print(f"skill path has no SKILL.md: {skill_path}", file=sys.stderr)
415
493
  return 2
416
494
 
417
- skill_name, _, _ = parse_skill_md(skill_path)
418
- data = read_json(evals_file)
419
- evals = data["evals"] if isinstance(data, dict) and "evals" in data else data
495
+ if args.mode == "variant":
496
+ if args.variant_path is None:
497
+ print("--mode variant requires --variant-path", file=sys.stderr)
498
+ return 2
499
+ variant_path = args.variant_path.resolve()
500
+ if not (variant_path / "SKILL.md").is_file():
501
+ print(f"variant path has no SKILL.md: {variant_path}",
502
+ file=sys.stderr)
503
+ return 2
504
+ else:
505
+ variant_path = None
506
+
507
+ project_root = (args.project_root.resolve() if args.project_root
508
+ else cases_file.parent)
509
+
510
+ # Each config is (name, skill-to-stage-or-None). Baseline runs every case
511
+ # twice — skill staged and bare — so the floor is measured under
512
+ # identical conditions.
513
+ if args.mode == "baseline":
514
+ configs: list[tuple[str, Path | None]] = [
515
+ ("skill", skill_path), ("bare", None)]
516
+ elif args.mode == "variant":
517
+ configs = [("skill", skill_path), ("variant", variant_path)]
518
+ else:
519
+ configs = [("skill", skill_path)]
420
520
 
421
- if args.eval_ids:
422
- wanted = {x.strip() for x in args.eval_ids.split(",") if x.strip()}
423
- evals = [e for e in evals if str(e.get("id")) in wanted]
521
+ cases = load_cases(cases_file)
522
+ if args.case_ids:
523
+ wanted = {x.strip() for x in args.case_ids.split(",") if x.strip()}
524
+ cases = [c for c in cases if str(c.get("id")) in wanted]
424
525
 
425
- run_id = new_run_id(skill_name)
526
+ adapter_path = find_adapter(args.adapter, cases_file)
527
+ adapter: dict | None = None
528
+ adapter_note = "none"
529
+ if adapter_path is not None:
530
+ try:
531
+ adapter = load_adapter(adapter_path)
532
+ adapter_note = str(adapter_path)
533
+ except Exception as e:
534
+ print(f"adapter config invalid ({e}); degrading to skip-only",
535
+ file=sys.stderr)
536
+ adapter = None
537
+ adapter_note = f"invalid: {e}"
538
+
539
+ run_id = new_run_id(args.label)
426
540
  run_dir = (args.output_dir / run_id).resolve()
427
541
  run_dir.mkdir(parents=True, exist_ok=True)
428
542
 
429
543
  write_json(run_dir / "run.json", {
430
544
  "run_id": run_id,
431
- "skill_name": skill_name,
545
+ "cases_file": str(cases_file),
432
546
  "skill_path": str(skill_path),
433
- "project_root": str(project_root),
434
- "evals_file": str(evals_file),
435
- "isolation": args.isolation,
547
+ "variant_path": str(variant_path) if variant_path else None,
548
+ "mode": args.mode,
549
+ "configs": [name for name, _ in configs],
550
+ "runs_per_case": args.runs,
551
+ "adapter": adapter_note,
436
552
  "started_at": utc_now_iso(),
437
- "eval_count": len(evals),
553
+ "case_count": len(cases),
438
554
  })
439
555
 
440
- runner = run_eval_docker if args.isolation == "docker" else run_eval_local
556
+ if adapter is None and not args.quiet:
557
+ print("[run_evals] no runtime adapter configured; staging cases only "
558
+ "(no crash). Configure an adapter to execute.", file=sys.stderr)
441
559
 
442
560
  results: list[dict] = []
443
561
  if not args.quiet:
444
- print(
445
- f"[run_evals] {len(evals)} evals, isolation={args.isolation}, run_dir={run_dir}",
446
- file=sys.stderr,
447
- )
448
-
449
- with ThreadPoolExecutor(max_workers=args.workers) as pool:
450
- future_to_eval = {
451
- pool.submit(
452
- runner,
453
- item,
454
- run_dir,
455
- skill_path,
456
- project_root,
457
- int(item.get("timeout", args.timeout)),
458
- discover_setup_dirs(evals_file, str(item.get("id", ""))),
459
- ): item
460
- for item in evals
562
+ print(f"[run_evals] {len(cases)} cases x {len(configs)} configs x "
563
+ f"{args.runs} runs, mode={args.mode}, run_dir={run_dir}",
564
+ file=sys.stderr)
565
+
566
+ jobs: list[tuple[str, dict, Path, Path | None]] = []
567
+ for config_name, config_skill in configs:
568
+ for c in cases:
569
+ base = run_dir / config_name / str(c.get("id", "unnamed"))
570
+ for i in range(max(1, args.runs)):
571
+ case_dir = base / f"run-{i + 1}" if args.runs > 1 else base
572
+ jobs.append((config_name, c, case_dir, config_skill))
573
+
574
+ with ThreadPoolExecutor(max_workers=max(1, args.workers)) as pool:
575
+ fut_to_case = {
576
+ pool.submit(run_case, c, case_dir, run_dir, adapter,
577
+ int(c.get("timeout", args.timeout)), config_name,
578
+ config_skill,
579
+ resolve_fixtures(c.get("files", []), project_root,
580
+ cases_file.parent)): c
581
+ for config_name, c, case_dir, config_skill in jobs
461
582
  }
462
- for fut in as_completed(future_to_eval):
463
- item = future_to_eval[fut]
583
+ for fut in as_completed(fut_to_case):
584
+ c = fut_to_case[fut]
464
585
  try:
465
586
  res = fut.result()
466
587
  except Exception as e:
467
- res = {"eval_id": str(item.get("id")), "error": str(e), "return_code": -1}
588
+ res = {"case_id": str(c.get("id")), "status": "exception",
589
+ "reason": str(e)}
468
590
  results.append(res)
469
591
  if not args.quiet:
470
- rc = res.get("return_code")
471
- status = "ok" if rc == 0 else f"rc={rc}"
472
- print(
473
- f" [{status}] eval {res.get('eval_id')} ({res.get('elapsed_s', 0):.1f}s)",
474
- file=sys.stderr,
475
- )
592
+ print(f" [{res.get('status')}] {res.get('config', '?')}/"
593
+ f"{res.get('case_id')} ({res.get('elapsed_s', 0)}s)",
594
+ file=sys.stderr)
476
595
 
477
596
  summary = {
478
597
  "run_id": run_id,
479
598
  "completed_at": utc_now_iso(),
480
- "total": len(evals),
481
- "executed": len(results),
482
- "exec_failures": sum(1 for r in results if r.get("return_code") != 0),
599
+ "mode": args.mode,
600
+ "total": len(jobs),
601
+ "executed": sum(1 for r in results if r.get("status") == "ok"),
602
+ "skipped": sum(1 for r in results if r.get("status") == "skipped"),
603
+ "failures": sum(1 for r in results
604
+ if r.get("status") in ("error", "timeout", "exception",
605
+ "adapter-missing")),
483
606
  "run_dir": str(run_dir),
484
607
  "results": results,
485
608
  }