ma-agents 3.13.2-skillname.0 → 3.14.0

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 (71) hide show
  1. package/README.md +1 -1
  2. package/lib/agents.js +13 -3
  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-extension/.claude-plugin/marketplace.json.template +1 -1
  47. package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +2 -2
  48. package/lib/bmad.js +6 -1
  49. package/lib/templates/instruction-block-git.template.md +25 -25
  50. package/lib/templates/instruction-block-onprem.template.md +86 -86
  51. package/lib/templates/instruction-block-universal.template.md +29 -29
  52. package/package.json +3 -3
  53. package/lib/.bmad-extension-plugin.build-37364-1782300865129/.claude-plugin/marketplace.json +0 -109
  54. package/lib/.bmad-extension-plugin.build-37364-1782300865129/skills/module-help.csv +0 -62
  55. package/lib/.bmad-extension-plugin.build-37364-1782300865129/skills/module.yaml +0 -20
  56. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.idx +0 -0
  57. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.rev +0 -0
  58. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/references/sample-init-sanctum.py +0 -274
  59. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/generate-html-report.py +0 -534
  60. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-execution-deps.py +0 -337
  61. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-prompt-metrics.py +0 -425
  62. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-sanctum-architecture.py +0 -385
  63. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-structure-capabilities.py +0 -482
  64. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/Dockerfile +0 -29
  65. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/docker_setup.py +0 -115
  66. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/generate_report.py +0 -184
  67. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/pty_runner.py +0 -171
  68. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/utils.py +0 -260
  69. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/extract-report-json.py +0 -287
  70. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/generate-html-report.py +0 -588
  71. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/prepass-execution-deps.py +0 -288
@@ -2,27 +2,53 @@
2
2
  # /// script
3
3
  # requires-python = ">=3.9"
4
4
  # ///
5
- """Run trigger evals: does the skill's description fire on each query?
6
-
7
- Adapted from Anthropic skill-creator's run_eval.py
8
- (https://github.com/anthropics/skills/tree/main/skills/skill-creator) with two
9
- adaptations:
10
-
11
- 1. Isolation. Each query runs in either a fresh Docker container off
12
- bmad-eval-runner:latest, or a fresh local tmp dir under ~/bmad-evals/<run-id>/
13
- with HOME overridden to a clean directory. This prevents the host's global
14
- CLAUDE.md and auto-memory from biasing whether the skill fires.
15
-
16
- 2. Output. Results are written to a run folder alongside the artifact eval
17
- run-folder layout (so triggers and artifacts can share a single report).
5
+ """Trigger evals: does a skill's description fire on each near-miss query?
6
+
7
+ A trigger query is a should/should-not user message that shares keywords with
8
+ the skill so the description has to discriminate. For each query the runner
9
+ stages a synthetic skill where the runtime looks for skills, sends the query
10
+ through the adapter, and detects whether the skill loaded. Each query runs
11
+ several times (runs-per-query) so the trigger rate is stable, not a coin flip.
12
+
13
+ Detection lives behind the adapter. "Did the skill load" is a runtime-specific
14
+ signal, so the adapter declares how skills are staged and how a load shows up in
15
+ the transcript. The adapter config (see references/platform-adapter.md) adds two
16
+ trigger-specific keys to the core ones:
17
+
18
+ invocation : argv template; "{prompt}" (or "{query}") is replaced with the
19
+ query text, "{cwd}" with the staging dir.
20
+ auth_env : auth env-var name, forwarded only when set non-empty on the
21
+ host. No model id.
22
+ skill_dir : path under the staging cwd where a skill is discovered, e.g.
23
+ ".claude/skills". The runner writes the synthetic skill there.
24
+ load_signal: which tool_use events count as a load:
25
+ {"skill_tool": "Skill", "read_tool": "Read"} (defaults)
26
+ A load is a tool_use of skill_tool whose input names the
27
+ synthetic skill, or a read_tool whose file_path falls inside
28
+ the synthetic skill's directory. Whole-transcript substring
29
+ matching is NOT supported: the runtime's init event lists
30
+ every discovered skill, so a substring match reports 100%
31
+ trigger rate regardless of the description.
32
+
33
+ Each query runs in a built-from-scratch environment (PATH, fresh empty HOME,
34
+ CLAUDE_CONFIG_DIR inside it, auth var only when set, adapter env_passthrough
35
+ keys) so the host's installed skills, memory, and config cannot bias firing.
36
+
37
+ If no adapter is configured the runner degrades gracefully: it stages each query
38
+ and records "skipped: no runtime adapter configured" rather than crashing.
18
39
 
19
40
  Usage:
20
41
  python3 run_triggers.py \\
21
- --skill-path PATH \\
22
- --triggers-file PATH/triggers.json \\
23
- --output-dir PATH \\
24
- --isolation docker|local \\
25
- [--workers N] [--runs-per-query N] [--timeout SECS] [--threshold 0.5]
42
+ --skill-path SKILL_DIR \\
43
+ --queries QUERIES.json \\
44
+ --output-dir DIR \\
45
+ [--adapter ADAPTER.json] \\
46
+ [--runs-per-query N] [--threshold 0.5] [--timeout SECS] \\
47
+ [--workers N] [--quiet]
48
+
49
+ QUERIES.json is a list of {"query": "...", "should_trigger": true|false}.
50
+ SKILL_DIR contains the SKILL.md whose name + description are under test; the
51
+ description is what the synthetic skill advertises.
26
52
  """
27
53
 
28
54
  from __future__ import annotations
@@ -30,262 +56,292 @@ from __future__ import annotations
30
56
  import argparse
31
57
  import json
32
58
  import os
59
+ import re
33
60
  import shutil
34
61
  import subprocess
35
62
  import sys
36
- import time
37
63
  import uuid
38
64
  from concurrent.futures import ThreadPoolExecutor, as_completed
65
+ from datetime import datetime, timezone
39
66
  from pathlib import Path
40
67
 
41
- SCRIPT_DIR = Path(__file__).resolve().parent
42
- sys.path.insert(0, str(SCRIPT_DIR))
43
-
44
- from utils import ( # noqa: E402
45
- new_run_id,
46
- parse_skill_md,
47
- read_json,
48
- read_macos_keychain_credentials,
49
- stage_credentials,
50
- utc_now_iso,
51
- write_json,
52
- )
53
-
54
- DOCKER_IMAGE = "bmad-eval-runner:latest"
55
- _KEYCHAIN_CREDS: str | None = read_macos_keychain_credentials()
56
-
57
68
 
58
- def write_synthetic_skill(skills_dir: Path, skill_name: str, description: str, unique_id: str) -> tuple[Path, str]:
59
- """Place a synthetic skill at <skills_dir>/<clean_name>/SKILL.md.
60
-
61
- The Skill tool only fires for entries discovered as actual skills (frontmatter
62
- `name` + `description` under a `.claude/skills/<name>/SKILL.md`). Slash-commands
63
- under `.claude/commands/` do not auto-invoke the Skill tool, so the previous
64
- implementation could never observe a positive trigger. This places the synthetic
65
- skill where Claude Code looks for skills, with a unique name so the detector
66
- can disambiguate it from any pre-existing skill of the same display name.
69
+ # --- self-contained helpers -------------------------------------------------
70
+
71
+ def utc_now_iso() -> str:
72
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
73
+
74
+
75
+ def new_run_id(label: str) -> str:
76
+ return f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{label}"
77
+
78
+
79
+ def write_json(path: Path, data: object) -> None:
80
+ path.parent.mkdir(parents=True, exist_ok=True)
81
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
82
+
83
+
84
+ def read_json(path: Path) -> object:
85
+ return json.loads(path.read_text(encoding="utf-8"))
86
+
87
+
88
+ def parse_skill_md(skill_path: Path) -> tuple[str, str]:
89
+ """Return (name, description) from SKILL.md frontmatter."""
90
+ text = (skill_path / "SKILL.md").read_text(encoding="utf-8")
91
+ m = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
92
+ if not m:
93
+ raise ValueError(f"SKILL.md at {skill_path} is missing frontmatter")
94
+ frontmatter = m.group(1)
95
+ name = None
96
+ desc_lines: list[str] = []
97
+ in_desc = False
98
+ for line in frontmatter.splitlines():
99
+ if line.startswith("name:"):
100
+ name = line.split(":", 1)[1].strip()
101
+ in_desc = False
102
+ elif line.startswith("description:"):
103
+ value = line.split(":", 1)[1].strip()
104
+ if value in ("|", ">"):
105
+ in_desc = True
106
+ else:
107
+ desc_lines = [value]
108
+ in_desc = False
109
+ elif in_desc and line.startswith((" ", "\t")):
110
+ desc_lines.append(line.strip())
111
+ elif in_desc:
112
+ in_desc = False
113
+ if not name:
114
+ raise ValueError(f"SKILL.md at {skill_path} has no name")
115
+ return name, " ".join(desc_lines).strip()
116
+
117
+
118
+ # --- adapter ----------------------------------------------------------------
119
+
120
+ def find_adapter(explicit: Path | None, queries_file: Path) -> Path | None:
121
+ if explicit is not None:
122
+ return explicit if explicit.is_file() else None
123
+ env_path = os.environ.get("BMAD_EVAL_ADAPTER")
124
+ if env_path and Path(env_path).is_file():
125
+ return Path(env_path)
126
+ for candidate in (
127
+ queries_file.parent / "adapter.json",
128
+ queries_file.parent / ".bmad-eval-adapter.json",
129
+ ):
130
+ if candidate.is_file():
131
+ return candidate
132
+ return None
133
+
134
+
135
+ def load_adapter(path: Path) -> dict:
136
+ cfg = read_json(path)
137
+ if not isinstance(cfg, dict) or "invocation" not in cfg:
138
+ raise ValueError(f"adapter config missing 'invocation': {path}")
139
+ return cfg
140
+
141
+
142
+ def build_argv(invocation: list, query: str, cwd: str) -> list[str]:
143
+ out: list[str] = []
144
+ for tok in invocation:
145
+ tok = (str(tok).replace("{prompt}", query)
146
+ .replace("{query}", query)
147
+ .replace("{cwd}", cwd))
148
+ out.append(tok)
149
+ return out
150
+
151
+
152
+ def build_case_env(adapter: dict | None, home_dir: Path,
153
+ host_env: dict) -> dict[str, str]:
154
+ """Build the subprocess environment from scratch — never from os.environ.
155
+
156
+ Inheriting the host env would leak shell config, tokens, and runtime
157
+ state into the clean room. The env holds exactly: PATH, a fresh HOME,
158
+ CLAUDE_CONFIG_DIR inside it, the adapter's auth var ONLY when set
159
+ non-empty in the host (an empty-string auth var breaks the runtime's own
160
+ credential fallback), and any adapter env_passthrough keys present in
161
+ the host env.
67
162
  """
68
- clean_name = f"{skill_name}-skill-{unique_id}"
69
- skill_root = skills_dir / clean_name
70
- skill_root.mkdir(parents=True, exist_ok=True)
71
- path = skill_root / "SKILL.md"
72
- indented_desc = "\n ".join(description.split("\n"))
73
- path.write_text(
163
+ adapter = adapter or {}
164
+ env = {
165
+ "PATH": host_env.get("PATH", ""),
166
+ "HOME": str(home_dir),
167
+ "CLAUDE_CONFIG_DIR": str(home_dir / ".claude"),
168
+ }
169
+ auth_env = adapter.get("auth_env")
170
+ if auth_env:
171
+ val = host_env.get(str(auth_env))
172
+ if val:
173
+ env[str(auth_env)] = val
174
+ for key in adapter.get("env_passthrough") or []:
175
+ val = host_env.get(str(key))
176
+ if val is not None:
177
+ env[str(key)] = val
178
+ return env
179
+
180
+
181
+ # --- synthetic skill staging ------------------------------------------------
182
+
183
+ def write_synthetic_skill(skills_dir: Path, skill_name: str,
184
+ description: str, unique: str) -> str:
185
+ """Write a synthetic skill the runtime can discover. Returns its unique name.
186
+
187
+ A unique suffix lets the detector tell this synthetic skill apart from any
188
+ real skill of the same display name.
189
+ """
190
+ clean_name = f"{skill_name}-trig-{unique}"
191
+ root = skills_dir / clean_name
192
+ root.mkdir(parents=True, exist_ok=True)
193
+ indented = "\n ".join(description.split("\n"))
194
+ (root / "SKILL.md").write_text(
74
195
  f"---\n"
75
196
  f"name: {clean_name}\n"
76
197
  f"description: |\n"
77
- f" {indented_desc}\n"
198
+ f" {indented}\n"
78
199
  f"---\n\n"
79
200
  f"# {skill_name}\n\n"
80
201
  f"This skill handles: {description}\n",
81
202
  encoding="utf-8",
82
203
  )
83
- return path, clean_name
204
+ return clean_name
205
+
206
+
207
+ # --- load detection (behind the adapter) ------------------------------------
208
+
209
+ def validate_load_signal(load_signal: dict | None) -> None:
210
+ """Reject substring-style load signals before any query runs."""
211
+ if (load_signal or {}).get("type") == "string":
212
+ raise ValueError(
213
+ "load_signal type 'string' is not supported: the runtime's init "
214
+ "event lists every discovered skill, so a whole-transcript "
215
+ "substring match reports 100% trigger rate regardless of the "
216
+ "description. Use tool-call detection "
217
+ '({"skill_tool": ..., "read_tool": ...}).'
218
+ )
84
219
 
85
220
 
86
- def parse_stream_for_trigger(buffer: str, clean_name: str) -> tuple[bool | None, str]:
87
- """Return (triggered_or_none, leftover_buffer). None means undecided yet."""
88
- triggered: bool | None = None
89
- pending_tool: str | None = None
90
- accumulated_json = ""
91
- leftover = ""
221
+ def detect_load(transcript_text: str, load_signal: dict, clean_name: str) -> bool:
222
+ """Did the synthetic skill load? Only tool_use events count.
92
223
 
93
- while "\n" in buffer:
94
- line, buffer = buffer.split("\n", 1)
95
- line = line.strip()
96
- if not line:
224
+ The init event of a stream-json transcript lists every discovered skill
225
+ by name, so the name appearing somewhere in the transcript proves
226
+ nothing. A load is a skill-invocation tool call naming the synthetic
227
+ skill, or a read of a file inside the synthetic skill's directory (its
228
+ SKILL.md) — the two ways a runtime actually pulls a skill into context.
229
+ """
230
+ validate_load_signal(load_signal)
231
+ sig = load_signal or {}
232
+ skill_tool = sig.get("skill_tool", "Skill")
233
+ read_tool = sig.get("read_tool", "Read")
234
+
235
+ for raw in transcript_text.splitlines():
236
+ raw = raw.strip()
237
+ if not raw:
97
238
  continue
98
239
  try:
99
- evt = json.loads(line)
240
+ evt = json.loads(raw)
100
241
  except json.JSONDecodeError:
101
242
  continue
102
-
103
- if evt.get("type") == "stream_event":
104
- se = evt.get("event", {})
105
- t = se.get("type", "")
106
- if t == "content_block_start":
107
- cb = se.get("content_block", {})
108
- if cb.get("type") == "tool_use":
109
- name = cb.get("name", "")
110
- if name in ("Skill", "Read"):
111
- pending_tool = name
112
- accumulated_json = ""
113
- else:
114
- return False, ""
115
- elif t == "content_block_delta" and pending_tool:
116
- delta = se.get("delta", {})
117
- if delta.get("type") == "input_json_delta":
118
- accumulated_json += delta.get("partial_json", "")
119
- if clean_name in accumulated_json:
120
- return True, ""
121
- elif t in ("content_block_stop", "message_stop"):
122
- if pending_tool:
123
- return clean_name in accumulated_json, ""
124
- if t == "message_stop":
125
- return False, ""
126
- elif evt.get("type") == "assistant":
127
- for item in evt.get("message", {}).get("content", []):
128
- if item.get("type") != "tool_use":
129
- continue
130
- tname = item.get("name", "")
131
- tinput = item.get("input", {})
132
- if tname == "Skill" and clean_name in tinput.get("skill", ""):
133
- return True, ""
134
- if tname == "Read" and clean_name in tinput.get("file_path", ""):
135
- return True, ""
136
- return False, ""
137
- elif evt.get("type") == "result":
138
- return triggered if triggered is not None else False, ""
139
- leftover = buffer
140
- return triggered, leftover
141
-
142
-
143
- def run_query_local(query: str, skill_name: str, description: str,
144
- workspace_root: Path, timeout: int) -> bool:
145
- workspace_root.mkdir(parents=True, exist_ok=True)
146
- home_dir = workspace_root / ".home"
147
- (home_dir / ".claude").mkdir(parents=True, exist_ok=True)
148
- stage_credentials(home_dir / ".claude", _KEYCHAIN_CREDS)
149
- project_dir = workspace_root / "project"
150
- skills_dir = project_dir / ".claude" / "skills"
151
- project_dir.mkdir(parents=True, exist_ok=True)
152
-
243
+ if not isinstance(evt, dict) or evt.get("type") != "assistant":
244
+ continue
245
+ msg = evt.get("message", {})
246
+ content = msg.get("content", []) if isinstance(msg, dict) else []
247
+ for item in content:
248
+ if not isinstance(item, dict) or item.get("type") != "tool_use":
249
+ continue
250
+ name = item.get("name")
251
+ inp = item.get("input", {})
252
+ if not isinstance(inp, dict):
253
+ inp = {}
254
+ if name == skill_tool and clean_name in json.dumps(inp):
255
+ return True
256
+ if name == read_tool and clean_name in str(inp.get("file_path", "")):
257
+ return True
258
+ return False
259
+
260
+
261
+ # --- per-query execution ----------------------------------------------------
262
+
263
+ def run_query_once(query: str, skill_name: str, description: str,
264
+ adapter: dict, stage_dir: Path, timeout: int) -> bool:
265
+ skill_subdir = adapter.get("skill_dir", ".claude/skills")
266
+ skills_dir = stage_dir / skill_subdir
267
+ skills_dir.mkdir(parents=True, exist_ok=True)
153
268
  unique = uuid.uuid4().hex[:8]
154
- cmd_file, clean_name = write_synthetic_skill(skills_dir, skill_name, description, unique)
155
-
156
- env = {
157
- "HOME": str(home_dir),
158
- "CLAUDE_CONFIG_DIR": str(home_dir / ".claude"),
159
- "PATH": os.environ.get("PATH", ""),
160
- "ANTHROPIC_API_KEY": os.environ.get("ANTHROPIC_API_KEY", ""),
161
- }
269
+ clean_name = write_synthetic_skill(skills_dir, skill_name, description, unique)
162
270
 
163
- cmd = [
164
- "claude", "-p", query,
165
- "--output-format", "stream-json",
166
- "--verbose",
167
- "--include-partial-messages",
168
- "--dangerously-skip-permissions",
169
- ]
271
+ home_dir = stage_dir / ".home"
272
+ (home_dir / ".claude").mkdir(parents=True, exist_ok=True)
273
+ env = build_case_env(adapter, home_dir, dict(os.environ))
170
274
 
275
+ argv = build_argv(adapter["invocation"], query, str(stage_dir))
171
276
  try:
172
- proc = subprocess.Popen(
173
- cmd,
277
+ proc = subprocess.run(
278
+ argv,
174
279
  stdout=subprocess.PIPE,
175
280
  stderr=subprocess.DEVNULL,
176
- cwd=str(project_dir),
281
+ cwd=str(stage_dir),
177
282
  env=env,
283
+ timeout=timeout,
178
284
  )
179
- buffer = ""
180
- triggered: bool | None = None
181
- start = time.time()
182
- try:
183
- while time.time() - start < timeout:
184
- if proc.poll() is not None:
185
- rest = proc.stdout.read()
186
- if rest:
187
- buffer += rest.decode("utf-8", errors="replace")
188
- break
189
- chunk = proc.stdout.read1(8192) if hasattr(proc.stdout, "read1") else proc.stdout.read(8192)
190
- if not chunk:
191
- time.sleep(0.05)
192
- continue
193
- buffer += chunk.decode("utf-8", errors="replace")
194
- decided, buffer = parse_stream_for_trigger(buffer, clean_name)
195
- if decided is not None:
196
- triggered = decided
197
- break
198
- finally:
199
- if proc.poll() is None:
200
- proc.kill()
201
- proc.wait()
202
- if triggered is None:
203
- decided, _ = parse_stream_for_trigger(buffer + "\n", clean_name)
204
- triggered = bool(decided)
205
- return bool(triggered)
206
- finally:
207
- try:
208
- shutil.rmtree(cmd_file.parent, ignore_errors=True)
209
- except OSError:
210
- pass
285
+ captured = proc.stdout or b""
286
+ except subprocess.TimeoutExpired as e:
287
+ captured = e.stdout or b""
288
+ except FileNotFoundError:
289
+ # invocation command absent; treat as undetected and let caller note it
290
+ raise
211
291
 
292
+ transcript_cfg = adapter.get("transcript", {"format": "stdout-jsonl"})
293
+ if transcript_cfg.get("format") == "file":
294
+ f = stage_dir / transcript_cfg.get("path", "transcript.jsonl")
295
+ text = f.read_text(encoding="utf-8", errors="replace") if f.is_file() else ""
296
+ else:
297
+ text = captured.decode("utf-8", errors="replace")
212
298
 
213
- def run_query_docker(query: str, skill_name: str, description: str,
214
- workspace_root: Path, timeout: int) -> bool:
215
- workspace_root.mkdir(parents=True, exist_ok=True)
216
- unique = uuid.uuid4().hex[:8]
217
- skills_in = workspace_root / "skills_in"
218
- skills_in.mkdir(parents=True, exist_ok=True)
219
- _, clean_name = write_synthetic_skill(skills_in, skill_name, description, unique)
220
-
221
- creds_dir: Path | None = None
222
- if _KEYCHAIN_CREDS:
223
- creds_dir = workspace_root / "creds_in"
224
- creds_dir.mkdir(parents=True, exist_ok=True)
225
- (creds_dir / ".credentials.json").write_text(_KEYCHAIN_CREDS, encoding="utf-8")
226
-
227
- container_script = f"""
228
- set -e
229
- mkdir -p /workspace/.claude/skills
230
- cp -R /skills/. /workspace/.claude/skills/ 2>/dev/null || true
231
- if [ -f /creds/.credentials.json ]; then
232
- mkdir -p /home/evaluator/.claude
233
- cp /creds/.credentials.json /home/evaluator/.claude/.credentials.json
234
- fi
235
- cd /workspace
236
- claude -p "$EVAL_QUERY" \\
237
- --output-format stream-json --verbose --include-partial-messages \\
238
- --dangerously-skip-permissions \\
239
- > /output/stream.jsonl 2>/dev/null || true
240
- """
299
+ return detect_load(text, adapter.get("load_signal", {}), clean_name)
241
300
 
242
- output_dir = workspace_root / "output"
243
- output_dir.mkdir(parents=True, exist_ok=True)
244
301
 
245
- cmd = [
246
- "docker", "run", "--rm",
247
- "-v", f"{skills_in}:/skills:ro",
248
- "-v", f"{output_dir}:/output",
249
- "-e", "ANTHROPIC_API_KEY",
250
- "-e", f"EVAL_QUERY={query}",
251
- ]
252
- if creds_dir:
253
- cmd += ["-v", f"{creds_dir}:/creds:ro"]
254
- cmd += [DOCKER_IMAGE, "bash", "-c", container_script]
302
+ # --- main -------------------------------------------------------------------
255
303
 
256
- try:
257
- subprocess.run(cmd, capture_output=True, timeout=timeout + 30)
258
- except subprocess.TimeoutExpired:
259
- pass
260
-
261
- stream_file = output_dir / "stream.jsonl"
262
- if not stream_file.is_file():
263
- return False
264
- decided, _ = parse_stream_for_trigger(stream_file.read_text(encoding="utf-8", errors="replace") + "\n", clean_name)
265
- return bool(decided)
266
-
267
-
268
- def main() -> int:
269
- parser = argparse.ArgumentParser(description="Run trigger evals in isolation")
270
- parser.add_argument("--skill-path", required=True, type=Path)
271
- parser.add_argument("--triggers-file", required=True, type=Path)
272
- parser.add_argument("--output-dir", required=True, type=Path)
273
- parser.add_argument("--isolation", choices=("docker", "local"), required=True)
274
- parser.add_argument("--workers", type=int, default=8)
275
- parser.add_argument("--runs-per-query", type=int, default=3)
276
- parser.add_argument("--timeout", type=int, default=45)
277
- parser.add_argument("--threshold", type=float, default=0.5)
278
- parser.add_argument("--quiet", action="store_true")
279
- args = parser.parse_args()
304
+ def main(argv: list[str] | None = None) -> int:
305
+ p = argparse.ArgumentParser(
306
+ description=__doc__,
307
+ formatter_class=argparse.RawDescriptionHelpFormatter,
308
+ )
309
+ p.add_argument("--skill-path", required=True, type=Path)
310
+ p.add_argument("--queries", required=True, type=Path)
311
+ p.add_argument("--output-dir", required=True, type=Path)
312
+ p.add_argument("--adapter", type=Path, default=None)
313
+ p.add_argument("--runs-per-query", type=int, default=3)
314
+ p.add_argument("--threshold", type=float, default=0.5)
315
+ p.add_argument("--timeout", type=int, default=60)
316
+ p.add_argument("--workers", type=int, default=4)
317
+ p.add_argument("--quiet", action="store_true")
318
+ args = p.parse_args(argv)
280
319
 
281
320
  skill_path = args.skill_path.resolve()
282
- triggers_file = args.triggers_file.resolve()
283
- if not triggers_file.is_file():
284
- print(f"triggers file not found: {triggers_file}", file=sys.stderr)
321
+ queries_file = args.queries.resolve()
322
+ if not queries_file.is_file():
323
+ print(f"queries file not found: {queries_file}", file=sys.stderr)
324
+ return 2
325
+
326
+ skill_name, description = parse_skill_md(skill_path)
327
+ queries = read_json(queries_file)
328
+ if not isinstance(queries, list):
329
+ print("queries file must be a JSON list", file=sys.stderr)
285
330
  return 2
286
331
 
287
- skill_name, description, _ = parse_skill_md(skill_path)
288
- queries = read_json(triggers_file)
332
+ adapter_path = find_adapter(args.adapter, queries_file)
333
+ adapter: dict | None = None
334
+ adapter_note = "none"
335
+ if adapter_path is not None:
336
+ try:
337
+ adapter = load_adapter(adapter_path)
338
+ validate_load_signal(adapter.get("load_signal"))
339
+ adapter_note = str(adapter_path)
340
+ except Exception as e:
341
+ print(f"adapter config invalid ({e}); degrading to skip-only",
342
+ file=sys.stderr)
343
+ adapter = None
344
+ adapter_note = f"invalid: {e}"
289
345
 
290
346
  run_id = new_run_id(f"{skill_name}-triggers")
291
347
  run_dir = (args.output_dir / run_id).resolve()
@@ -295,25 +351,54 @@ def main() -> int:
295
351
  "run_id": run_id,
296
352
  "skill_name": skill_name,
297
353
  "description": description,
298
- "isolation": args.isolation,
354
+ "adapter": adapter_note,
299
355
  "started_at": utc_now_iso(),
300
356
  "query_count": len(queries),
301
357
  "runs_per_query": args.runs_per_query,
302
358
  "threshold": args.threshold,
303
359
  })
304
360
 
305
- runner = run_query_docker if args.isolation == "docker" else run_query_local
361
+ if adapter is None:
362
+ if not args.quiet:
363
+ print("[run_triggers] no runtime adapter configured; staging only "
364
+ "(no crash).", file=sys.stderr)
365
+ output = {
366
+ "run_id": run_id,
367
+ "completed_at": utc_now_iso(),
368
+ "skill_name": skill_name,
369
+ "description": description,
370
+ "status": "skipped",
371
+ "reason": "no runtime adapter configured",
372
+ "results": [],
373
+ "summary": {"total": len(queries), "passed": 0, "failed": 0,
374
+ "skipped": len(queries)},
375
+ }
376
+ write_json(run_dir / "triggers-result.json", output)
377
+ print(json.dumps(output, indent=2))
378
+ return 0
379
+
380
+ adapter_missing = {"flag": False}
306
381
 
307
382
  def run_one(idx: int, q: dict, run_idx: int) -> tuple[int, bool]:
308
- ws = run_dir / "queries" / f"q{idx:03d}-r{run_idx}"
309
- triggered = runner(q["query"], skill_name, description, ws, args.timeout)
383
+ stage = run_dir / "queries" / f"q{idx:03d}-r{run_idx}"
384
+ stage.mkdir(parents=True, exist_ok=True)
385
+ try:
386
+ triggered = run_query_once(
387
+ q["query"], skill_name, description, adapter, stage, args.timeout)
388
+ except FileNotFoundError:
389
+ adapter_missing["flag"] = True
390
+ triggered = False
391
+ finally:
392
+ shutil.rmtree(stage / adapter.get("skill_dir", ".claude/skills").split("/")[0],
393
+ ignore_errors=True)
310
394
  return idx, triggered
311
395
 
312
396
  per_query: dict[int, list[bool]] = {}
313
397
  if not args.quiet:
314
- print(f"[run_triggers] {len(queries)} queries × {args.runs_per_query} runs, isolation={args.isolation}", file=sys.stderr)
398
+ print(f"[run_triggers] {len(queries)} queries x {args.runs_per_query} "
399
+ f"runs", file=sys.stderr)
315
400
 
316
- with ThreadPoolExecutor(max_workers=args.workers) as pool:
401
+ with ThreadPoolExecutor(max_workers=max(1, args.workers)) as pool:
317
402
  futures = []
318
403
  for idx, q in enumerate(queries):
319
404
  for run_idx in range(args.runs_per_query):
@@ -322,25 +407,36 @@ def main() -> int:
322
407
  try:
323
408
  idx, triggered = fut.result()
324
409
  except Exception as e:
325
- print(f"Warning: query failed: {e}", file=sys.stderr)
410
+ print(f"Warning: query run failed: {e}", file=sys.stderr)
326
411
  continue
327
412
  per_query.setdefault(idx, []).append(triggered)
328
413
 
414
+ if adapter_missing["flag"]:
415
+ output = {
416
+ "run_id": run_id,
417
+ "completed_at": utc_now_iso(),
418
+ "skill_name": skill_name,
419
+ "status": "adapter-missing",
420
+ "reason": "adapter invocation command not found on PATH",
421
+ "results": [],
422
+ "summary": {"total": len(queries), "passed": 0, "failed": 0},
423
+ }
424
+ write_json(run_dir / "triggers-result.json", output)
425
+ print(json.dumps(output, indent=2))
426
+ return 0
427
+
329
428
  results = []
330
429
  for idx, q in enumerate(queries):
331
- triggers = per_query.get(idx, [])
332
- rate = (sum(triggers) / len(triggers)) if triggers else 0.0
333
- should = bool(q["should_trigger"])
334
- if should:
335
- passed = rate >= args.threshold
336
- else:
337
- passed = rate < args.threshold
430
+ runs = per_query.get(idx, [])
431
+ rate = (sum(runs) / len(runs)) if runs else 0.0
432
+ should = bool(q.get("should_trigger", True))
433
+ passed = (rate >= args.threshold) if should else (rate < args.threshold)
338
434
  results.append({
339
435
  "query": q["query"],
340
436
  "should_trigger": should,
341
- "trigger_rate": rate,
342
- "triggers": int(sum(triggers)),
343
- "runs": len(triggers),
437
+ "trigger_rate": round(rate, 3),
438
+ "triggers": int(sum(runs)),
439
+ "runs": len(runs),
344
440
  "pass": passed,
345
441
  })
346
442
 
@@ -349,7 +445,7 @@ def main() -> int:
349
445
  "completed_at": utc_now_iso(),
350
446
  "skill_name": skill_name,
351
447
  "description": description,
352
- "isolation": args.isolation,
448
+ "adapter": adapter_note,
353
449
  "results": results,
354
450
  "summary": {
355
451
  "total": len(results),