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.
- package/README.md +1 -1
- package/lib/agents.js +13 -3
- package/lib/bmad-cache/bmb/.claude-plugin/marketplace.json +3 -3
- package/lib/bmad-cache/bmb/_git_preserved/index +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.idx +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/{pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.pack → pack-b388526edf307cf56f8e65673466cff074105abf.pack} +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.rev +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/packed-refs +1 -1
- package/lib/bmad-cache/bmb/_git_preserved/refs/heads/main +1 -1
- package/lib/bmad-cache/bmb/_git_preserved/shallow +1 -1
- package/lib/bmad-cache/bmb/package-lock.json +2 -2
- package/lib/bmad-cache/bmb/package.json +1 -1
- package/lib/bmad-cache/bmb/samples/bmad-agent-code-coach/scripts/wake.py +78 -0
- package/lib/bmad-cache/bmb/samples/bmad-agent-creative-muse/scripts/wake.py +78 -0
- package/lib/bmad-cache/bmb/samples/bmad-agent-sentinel/scripts/wake.py +78 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/customize-template.toml +3 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/init-sanctum-template.py +11 -5
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/report-shell.html +1073 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/wake-template.py +78 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/customize.toml +48 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/count_tokens.py +78 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass.py +258 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/process-template.py +20 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/render_report.py +387 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/adapter-claude-code.json +9 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/aggregate_benchmark.py +236 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/memlog.py +197 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_evals.py +513 -390
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_triggers.py +347 -251
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_env_isolation.py +79 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_trigger_detection.py +123 -0
- package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/tests/test-validate-module.py +82 -4
- package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/validate-module.py +55 -11
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/customize-template.toml +3 -5
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/report-shell.html +860 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/customize.toml +59 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/count_tokens.py +77 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/init_skill.py +132 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/prepass-prompt-metrics.py +186 -221
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/quick_validate.py +117 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/render_report.py +387 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_canon_sync.py +46 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_count_tokens.py +180 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_render_report.py +171 -0
- package/lib/bmad-cache/cache-manifest.json +4 -4
- package/lib/bmad-extension/.claude-plugin/marketplace.json.template +1 -1
- package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +2 -2
- package/lib/bmad.js +6 -1
- package/lib/templates/instruction-block-git.template.md +25 -25
- package/lib/templates/instruction-block-onprem.template.md +86 -86
- package/lib/templates/instruction-block-universal.template.md +29 -29
- package/package.json +3 -3
- package/lib/.bmad-extension-plugin.build-37364-1782300865129/.claude-plugin/marketplace.json +0 -109
- package/lib/.bmad-extension-plugin.build-37364-1782300865129/skills/module-help.csv +0 -62
- package/lib/.bmad-extension-plugin.build-37364-1782300865129/skills/module.yaml +0 -20
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.idx +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.rev +0 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/references/sample-init-sanctum.py +0 -274
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/generate-html-report.py +0 -534
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-execution-deps.py +0 -337
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-prompt-metrics.py +0 -425
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-sanctum-architecture.py +0 -385
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-structure-capabilities.py +0 -482
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/Dockerfile +0 -29
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/docker_setup.py +0 -115
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/generate_report.py +0 -184
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/pty_runner.py +0 -171
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/utils.py +0 -260
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/extract-report-json.py +0 -287
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/generate-html-report.py +0 -588
- 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
|
-
"""
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
22
|
-
--
|
|
23
|
-
--output-dir
|
|
24
|
-
--
|
|
25
|
-
[--
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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" {
|
|
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
|
|
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
|
|
87
|
-
"""
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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(
|
|
240
|
+
evt = json.loads(raw)
|
|
100
241
|
except json.JSONDecodeError:
|
|
101
242
|
continue
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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.
|
|
173
|
-
|
|
277
|
+
proc = subprocess.run(
|
|
278
|
+
argv,
|
|
174
279
|
stdout=subprocess.PIPE,
|
|
175
280
|
stderr=subprocess.DEVNULL,
|
|
176
|
-
cwd=str(
|
|
281
|
+
cwd=str(stage_dir),
|
|
177
282
|
env=env,
|
|
283
|
+
timeout=timeout,
|
|
178
284
|
)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
283
|
-
if not
|
|
284
|
-
print(f"
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
|
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
|
-
|
|
332
|
-
rate = (sum(
|
|
333
|
-
should = bool(q
|
|
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(
|
|
343
|
-
"runs": len(
|
|
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
|
-
"
|
|
448
|
+
"adapter": adapter_note,
|
|
353
449
|
"results": results,
|
|
354
450
|
"summary": {
|
|
355
451
|
"total": len(results),
|