ma-agents 3.13.2 → 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/lib/agents.js +7 -0
- 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-22612-1782454014750/.claude-plugin/marketplace.json +0 -109
- package/lib/.bmad-extension-plugin.build-22612-1782454014750/skills/module-help.csv +0 -62
- package/lib/.bmad-extension-plugin.build-22612-1782454014750/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
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# /// script
|
|
3
|
-
# requires-python = ">=3.9"
|
|
4
|
-
# ///
|
|
5
|
-
"""Generate an aggregate HTML report for a run folder.
|
|
6
|
-
|
|
7
|
-
Reads run.json, execution-summary.json, each <eval-id>/grading.json (if present),
|
|
8
|
-
and triggers-result.json (if present), then renders a single-file HTML report.
|
|
9
|
-
|
|
10
|
-
Usage:
|
|
11
|
-
python3 generate_report.py --run-dir PATH [-o report.html]
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
import argparse
|
|
17
|
-
import html as html_lib
|
|
18
|
-
import json
|
|
19
|
-
import sys
|
|
20
|
-
from pathlib import Path
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def esc(s: object) -> str:
|
|
24
|
-
return html_lib.escape(str(s), quote=True)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def load(path: Path) -> dict | list | None:
|
|
28
|
-
if not path.is_file():
|
|
29
|
-
return None
|
|
30
|
-
try:
|
|
31
|
-
return json.loads(path.read_text(encoding="utf-8"))
|
|
32
|
-
except json.JSONDecodeError:
|
|
33
|
-
return None
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def render(run_dir: Path) -> str:
|
|
37
|
-
run_meta = load(run_dir / "run.json") or {}
|
|
38
|
-
exec_summary = load(run_dir / "execution-summary.json") or {}
|
|
39
|
-
triggers = load(run_dir / "triggers-result.json")
|
|
40
|
-
|
|
41
|
-
eval_blocks: list[str] = []
|
|
42
|
-
grading_total = 0
|
|
43
|
-
grading_passed = 0
|
|
44
|
-
|
|
45
|
-
for res in exec_summary.get("results", []):
|
|
46
|
-
eval_id = str(res.get("eval_id", "?"))
|
|
47
|
-
eval_dir = run_dir / eval_id
|
|
48
|
-
grading = load(eval_dir / "grading.json")
|
|
49
|
-
metrics = res.get("metrics") or load(eval_dir / "metrics.json") or {}
|
|
50
|
-
rc = res.get("return_code")
|
|
51
|
-
|
|
52
|
-
rows: list[str] = []
|
|
53
|
-
if grading:
|
|
54
|
-
for exp in grading.get("expectations", []):
|
|
55
|
-
passed = bool(exp.get("passed"))
|
|
56
|
-
grading_total += 1
|
|
57
|
-
if passed:
|
|
58
|
-
grading_passed += 1
|
|
59
|
-
rows.append(
|
|
60
|
-
f'<tr class="{ "pass" if passed else "fail" }">'
|
|
61
|
-
f'<td>{ "✔" if passed else "✘" }</td>'
|
|
62
|
-
f'<td>{esc(exp.get("text", ""))}</td>'
|
|
63
|
-
f'<td>{esc(exp.get("evidence", ""))}</td></tr>'
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
feedback = (grading or {}).get("eval_feedback") or {}
|
|
67
|
-
feedback_html = ""
|
|
68
|
-
if feedback:
|
|
69
|
-
sugg = feedback.get("suggestions") or []
|
|
70
|
-
sugg_html = "".join(
|
|
71
|
-
f"<li><strong>{esc(s.get('assertion','(general)'))}</strong>: {esc(s.get('reason',''))}</li>"
|
|
72
|
-
for s in sugg
|
|
73
|
-
)
|
|
74
|
-
overall = esc(feedback.get("overall", ""))
|
|
75
|
-
feedback_html = (
|
|
76
|
-
f'<details class="feedback"><summary>Grader feedback on the evals</summary>'
|
|
77
|
-
f'<p>{overall}</p>'
|
|
78
|
-
f'{"<ul>" + sugg_html + "</ul>" if sugg_html else ""}'
|
|
79
|
-
f'</details>'
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
artifacts_listing = ""
|
|
83
|
-
artifacts_dir = eval_dir / "artifacts"
|
|
84
|
-
if artifacts_dir.is_dir():
|
|
85
|
-
files = sorted(p for p in artifacts_dir.rglob("*") if p.is_file())
|
|
86
|
-
if files:
|
|
87
|
-
artifacts_listing = "<ul>" + "".join(
|
|
88
|
-
f'<li><code>{esc(p.relative_to(eval_dir))}</code> '
|
|
89
|
-
f'<span class="muted">({p.stat().st_size}b)</span></li>'
|
|
90
|
-
for p in files
|
|
91
|
-
) + "</ul>"
|
|
92
|
-
|
|
93
|
-
tool_calls = metrics.get("tool_calls", {})
|
|
94
|
-
tool_summary = ", ".join(f"{k}={v}" for k, v in sorted(tool_calls.items())) or "—"
|
|
95
|
-
|
|
96
|
-
eval_blocks.append(f"""
|
|
97
|
-
<section class="eval">
|
|
98
|
-
<h3>Eval {esc(eval_id)} <span class="muted">rc={esc(rc)} · {esc(metrics.get('elapsed_s', '?'))}s</span></h3>
|
|
99
|
-
<p class="muted">Tool calls: {esc(tool_summary)} · output {esc(metrics.get('output_chars', 0))}b · transcript {esc(metrics.get('transcript_chars', 0))}b</p>
|
|
100
|
-
{ '<table><thead><tr><th></th><th>Expectation</th><th>Evidence</th></tr></thead><tbody>' + ''.join(rows) + '</tbody></table>' if rows else '<p class="muted">No grading.json yet.</p>' }
|
|
101
|
-
{feedback_html}
|
|
102
|
-
<details><summary>Artifacts</summary>{artifacts_listing or '<p class="muted">No artifacts captured.</p>'}</details>
|
|
103
|
-
</section>
|
|
104
|
-
""")
|
|
105
|
-
|
|
106
|
-
triggers_html = ""
|
|
107
|
-
if triggers:
|
|
108
|
-
rows = []
|
|
109
|
-
for r in triggers.get("results", []):
|
|
110
|
-
rows.append(
|
|
111
|
-
f'<tr class="{ "pass" if r["pass"] else "fail" }">'
|
|
112
|
-
f'<td>{ "✔" if r["pass"] else "✘" }</td>'
|
|
113
|
-
f'<td>{esc(r["query"])}</td>'
|
|
114
|
-
f'<td>{esc(r["should_trigger"])}</td>'
|
|
115
|
-
f'<td>{r["triggers"]}/{r["runs"]} ({r["trigger_rate"]:.2f})</td></tr>'
|
|
116
|
-
)
|
|
117
|
-
s = triggers.get("summary", {})
|
|
118
|
-
triggers_html = f"""
|
|
119
|
-
<section class="triggers">
|
|
120
|
-
<h2>Trigger Evals — {s.get('passed',0)}/{s.get('total',0)} pass</h2>
|
|
121
|
-
<table><thead><tr><th></th><th>Query</th><th>Should fire</th><th>Rate</th></tr></thead>
|
|
122
|
-
<tbody>{''.join(rows)}</tbody></table>
|
|
123
|
-
</section>
|
|
124
|
-
"""
|
|
125
|
-
|
|
126
|
-
artifact_summary = ""
|
|
127
|
-
if exec_summary:
|
|
128
|
-
artifact_summary = (
|
|
129
|
-
f"<p>Executed {exec_summary.get('executed', 0)} / {exec_summary.get('total', 0)} "
|
|
130
|
-
f"evals · {exec_summary.get('exec_failures', 0)} execution failures · "
|
|
131
|
-
f"grader: {grading_passed}/{grading_total} expectations passed</p>"
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
return f"""<!doctype html>
|
|
135
|
-
<html><head><meta charset="utf-8"><title>Eval Run — {esc(run_meta.get('skill_name','?'))}</title>
|
|
136
|
-
<style>
|
|
137
|
-
body {{ font: 14px/1.5 system-ui, sans-serif; max-width: 1080px; margin: 2em auto; color: #222; padding: 0 1em; }}
|
|
138
|
-
h1, h2, h3 {{ font-weight: 600; }}
|
|
139
|
-
h1 {{ font-size: 1.6em; margin-bottom: 0.2em; }}
|
|
140
|
-
.meta {{ color: #666; margin-bottom: 1.5em; }}
|
|
141
|
-
.muted {{ color: #888; font-weight: normal; }}
|
|
142
|
-
section.eval {{ border: 1px solid #ddd; border-radius: 6px; padding: 1em 1.2em; margin: 1em 0; background: #fafafa; }}
|
|
143
|
-
table {{ width: 100%; border-collapse: collapse; margin: 0.5em 0; font-size: 13px; }}
|
|
144
|
-
th, td {{ text-align: left; padding: 6px 8px; border-bottom: 1px solid #eee; vertical-align: top; }}
|
|
145
|
-
tr.pass td:first-child {{ color: #2c8a3a; font-weight: 700; }}
|
|
146
|
-
tr.fail td:first-child {{ color: #b3261e; font-weight: 700; }}
|
|
147
|
-
tr.fail {{ background: #fdf3f2; }}
|
|
148
|
-
details.feedback {{ margin-top: 0.6em; padding: 0.4em 0.7em; background: #fff8e1; border-radius: 4px; }}
|
|
149
|
-
details summary {{ cursor: pointer; font-weight: 600; }}
|
|
150
|
-
code {{ background: #eee; padding: 1px 4px; border-radius: 3px; }}
|
|
151
|
-
</style></head>
|
|
152
|
-
<body>
|
|
153
|
-
<h1>{esc(run_meta.get('skill_name','?'))} — eval run</h1>
|
|
154
|
-
<div class="meta">
|
|
155
|
-
Run id: <code>{esc(run_meta.get('run_id','?'))}</code> ·
|
|
156
|
-
isolation: {esc(run_meta.get('isolation','?'))} ·
|
|
157
|
-
started: {esc(run_meta.get('started_at','?'))}
|
|
158
|
-
</div>
|
|
159
|
-
{artifact_summary}
|
|
160
|
-
{''.join(eval_blocks)}
|
|
161
|
-
{triggers_html}
|
|
162
|
-
</body></html>
|
|
163
|
-
"""
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def main() -> int:
|
|
167
|
-
parser = argparse.ArgumentParser(description="Generate HTML report for an eval run folder")
|
|
168
|
-
parser.add_argument("--run-dir", required=True, type=Path)
|
|
169
|
-
parser.add_argument("-o", "--output", type=Path, default=None)
|
|
170
|
-
args = parser.parse_args()
|
|
171
|
-
|
|
172
|
-
run_dir = args.run_dir.resolve()
|
|
173
|
-
if not run_dir.is_dir():
|
|
174
|
-
print(f"run-dir not found: {run_dir}", file=sys.stderr)
|
|
175
|
-
return 2
|
|
176
|
-
|
|
177
|
-
out = args.output or (run_dir / "report.html")
|
|
178
|
-
out.write_text(render(run_dir), encoding="utf-8")
|
|
179
|
-
print(str(out))
|
|
180
|
-
return 0
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if __name__ == "__main__":
|
|
184
|
-
sys.exit(main())
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# /// script
|
|
3
|
-
# requires-python = ">=3.9"
|
|
4
|
-
# ///
|
|
5
|
-
"""Run claude interactively via PTY so the Skill tool is available.
|
|
6
|
-
|
|
7
|
-
In `claude -p` (print mode) the Skill tool is never offered — Claude handles
|
|
8
|
-
everything inline. Running `claude` in interactive mode activates the Skill
|
|
9
|
-
tool so dependency skills installed in .claude/skills/ can be properly invoked.
|
|
10
|
-
|
|
11
|
-
The PTY tricks claude into thinking it has a terminal (interactive mode) while
|
|
12
|
-
we capture its stream-json output programmatically.
|
|
13
|
-
|
|
14
|
-
Usage:
|
|
15
|
-
python3 pty_runner.py --prompt-file /path/to/prompt.txt \\
|
|
16
|
-
--output /path/to/transcript.jsonl \\
|
|
17
|
-
[--timeout 600]
|
|
18
|
-
python3 pty_runner.py --prompt "Run headless. ..." --output transcript.jsonl
|
|
19
|
-
"""
|
|
20
|
-
|
|
21
|
-
from __future__ import annotations
|
|
22
|
-
|
|
23
|
-
import argparse
|
|
24
|
-
import json
|
|
25
|
-
import os
|
|
26
|
-
import pty
|
|
27
|
-
import re
|
|
28
|
-
import select
|
|
29
|
-
import subprocess
|
|
30
|
-
import sys
|
|
31
|
-
import time
|
|
32
|
-
from pathlib import Path
|
|
33
|
-
|
|
34
|
-
ANSI_RE = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])|\r")
|
|
35
|
-
|
|
36
|
-
# How long to wait for claude to initialize before sending the prompt.
|
|
37
|
-
# Claude loads skill registry, checks credentials, etc. on startup.
|
|
38
|
-
INIT_WAIT_S = 5.0
|
|
39
|
-
|
|
40
|
-
# How long to wait after the stream-json 'result' event before killing claude.
|
|
41
|
-
# Trailing tool-result output sometimes follows the result event.
|
|
42
|
-
POST_RESULT_S = 4.0
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def _strip_ansi(text: str) -> str:
|
|
46
|
-
return ANSI_RE.sub("", text)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def run_interactive(prompt: str, output: Path, timeout: int = 600) -> None:
|
|
50
|
-
"""Spawn claude interactively via PTY, send one prompt, capture transcript."""
|
|
51
|
-
master, slave = pty.openpty()
|
|
52
|
-
|
|
53
|
-
proc = subprocess.Popen(
|
|
54
|
-
[
|
|
55
|
-
"claude",
|
|
56
|
-
"--output-format", "stream-json",
|
|
57
|
-
"--verbose",
|
|
58
|
-
"--dangerously-skip-permissions",
|
|
59
|
-
],
|
|
60
|
-
stdin=slave,
|
|
61
|
-
stdout=slave,
|
|
62
|
-
stderr=slave,
|
|
63
|
-
close_fds=True,
|
|
64
|
-
)
|
|
65
|
-
os.close(slave)
|
|
66
|
-
|
|
67
|
-
json_lines: list[str] = []
|
|
68
|
-
buf = b""
|
|
69
|
-
prompt_sent = False
|
|
70
|
-
done_at: float | None = None
|
|
71
|
-
start = time.time()
|
|
72
|
-
|
|
73
|
-
try:
|
|
74
|
-
while True:
|
|
75
|
-
elapsed = time.time() - start
|
|
76
|
-
if elapsed > timeout:
|
|
77
|
-
print(f"[pty_runner] timeout after {elapsed:.0f}s", file=sys.stderr)
|
|
78
|
-
break
|
|
79
|
-
if done_at is not None and (time.time() - done_at) > POST_RESULT_S:
|
|
80
|
-
break
|
|
81
|
-
|
|
82
|
-
# Short select so we stay responsive but don't spin.
|
|
83
|
-
r, _, _ = select.select([master], [], [], 0.3)
|
|
84
|
-
|
|
85
|
-
if r:
|
|
86
|
-
try:
|
|
87
|
-
chunk = os.read(master, 8192)
|
|
88
|
-
except OSError:
|
|
89
|
-
break # PTY closed — claude exited
|
|
90
|
-
buf += chunk
|
|
91
|
-
|
|
92
|
-
# Process all complete lines in buffer.
|
|
93
|
-
while b"\n" in buf:
|
|
94
|
-
raw, buf = buf.split(b"\n", 1)
|
|
95
|
-
line = _strip_ansi(raw.decode("utf-8", errors="replace")).strip()
|
|
96
|
-
if not line.startswith("{"):
|
|
97
|
-
continue
|
|
98
|
-
json_lines.append(line)
|
|
99
|
-
try:
|
|
100
|
-
obj = json.loads(line)
|
|
101
|
-
# 'result' marks end of a claude turn.
|
|
102
|
-
if obj.get("type") == "result" and done_at is None:
|
|
103
|
-
done_at = time.time()
|
|
104
|
-
print(
|
|
105
|
-
f"[pty_runner] result event at t={time.time()-start:.1f}s "
|
|
106
|
-
f"({len(json_lines)} lines so far)",
|
|
107
|
-
file=sys.stderr,
|
|
108
|
-
)
|
|
109
|
-
except json.JSONDecodeError:
|
|
110
|
-
pass
|
|
111
|
-
else:
|
|
112
|
-
# Silence window — send prompt once claude has had time to init.
|
|
113
|
-
if not prompt_sent and (time.time() - start) >= INIT_WAIT_S:
|
|
114
|
-
os.write(master, (prompt + "\n").encode())
|
|
115
|
-
prompt_sent = True
|
|
116
|
-
print(
|
|
117
|
-
f"[pty_runner] prompt sent at t={time.time()-start:.1f}s",
|
|
118
|
-
file=sys.stderr,
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
finally:
|
|
122
|
-
# Politely ask claude to exit, then hard-kill if needed.
|
|
123
|
-
try:
|
|
124
|
-
os.write(master, b"exit\n")
|
|
125
|
-
time.sleep(0.3)
|
|
126
|
-
except OSError:
|
|
127
|
-
pass
|
|
128
|
-
try:
|
|
129
|
-
proc.terminate()
|
|
130
|
-
proc.wait(timeout=5)
|
|
131
|
-
except Exception:
|
|
132
|
-
try:
|
|
133
|
-
proc.kill()
|
|
134
|
-
except Exception:
|
|
135
|
-
pass
|
|
136
|
-
try:
|
|
137
|
-
os.close(master)
|
|
138
|
-
except OSError:
|
|
139
|
-
pass
|
|
140
|
-
|
|
141
|
-
output.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
-
content = "\n".join(json_lines) + ("\n" if json_lines else "")
|
|
143
|
-
output.write_text(content, encoding="utf-8")
|
|
144
|
-
print(
|
|
145
|
-
f"[pty_runner] wrote {len(json_lines)} transcript lines → {output}",
|
|
146
|
-
file=sys.stderr,
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def main() -> int:
|
|
151
|
-
p = argparse.ArgumentParser(
|
|
152
|
-
description="Run claude interactively via PTY and capture stream-json transcript"
|
|
153
|
-
)
|
|
154
|
-
grp = p.add_mutually_exclusive_group(required=True)
|
|
155
|
-
grp.add_argument("--prompt", help="Prompt text")
|
|
156
|
-
grp.add_argument("--prompt-file", type=Path, help="File containing the prompt")
|
|
157
|
-
p.add_argument("--output", type=Path, required=True, help="Output .jsonl transcript file")
|
|
158
|
-
p.add_argument("--timeout", type=int, default=600, help="Hard timeout in seconds")
|
|
159
|
-
args = p.parse_args()
|
|
160
|
-
|
|
161
|
-
prompt = (
|
|
162
|
-
args.prompt_file.read_text(encoding="utf-8").strip()
|
|
163
|
-
if args.prompt_file
|
|
164
|
-
else args.prompt
|
|
165
|
-
)
|
|
166
|
-
run_interactive(prompt, args.output, args.timeout)
|
|
167
|
-
return 0
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if __name__ == "__main__":
|
|
171
|
-
sys.exit(main())
|
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# /// script
|
|
3
|
-
# requires-python = ">=3.9"
|
|
4
|
-
# ///
|
|
5
|
-
"""Shared helpers for the eval runner."""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import json
|
|
10
|
-
import re
|
|
11
|
-
import shutil
|
|
12
|
-
import subprocess
|
|
13
|
-
import sys
|
|
14
|
-
from datetime import datetime, timezone
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def parse_skill_md(skill_path: Path) -> tuple[str, str, str]:
|
|
19
|
-
"""Return (name, description, body) from the skill's SKILL.md frontmatter."""
|
|
20
|
-
text = (skill_path / "SKILL.md").read_text(encoding="utf-8")
|
|
21
|
-
fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)$", text, re.DOTALL)
|
|
22
|
-
if not fm_match:
|
|
23
|
-
raise ValueError(f"SKILL.md at {skill_path} is missing frontmatter")
|
|
24
|
-
frontmatter, body = fm_match.group(1), fm_match.group(2)
|
|
25
|
-
|
|
26
|
-
name = None
|
|
27
|
-
description_lines: list[str] = []
|
|
28
|
-
in_description = False
|
|
29
|
-
for line in frontmatter.splitlines():
|
|
30
|
-
if line.startswith("name:"):
|
|
31
|
-
name = line.split(":", 1)[1].strip()
|
|
32
|
-
in_description = False
|
|
33
|
-
elif line.startswith("description:"):
|
|
34
|
-
value = line.split(":", 1)[1].strip()
|
|
35
|
-
if value in ("|", ">"):
|
|
36
|
-
in_description = True
|
|
37
|
-
else:
|
|
38
|
-
description_lines = [value]
|
|
39
|
-
in_description = False
|
|
40
|
-
elif in_description and line.startswith((" ", "\t")):
|
|
41
|
-
description_lines.append(line.strip())
|
|
42
|
-
elif in_description:
|
|
43
|
-
in_description = False
|
|
44
|
-
|
|
45
|
-
if not name:
|
|
46
|
-
raise ValueError(f"SKILL.md at {skill_path} is missing a name")
|
|
47
|
-
return name, " ".join(description_lines).strip(), body
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def discover_project_root(skill_path: Path) -> Path:
|
|
51
|
-
"""Walk up from the skill looking for _bmad/ or .git; default to skill's grandparent."""
|
|
52
|
-
for parent in [skill_path, *skill_path.parents]:
|
|
53
|
-
if (parent / "_bmad").is_dir() or (parent / ".git").exists():
|
|
54
|
-
return parent
|
|
55
|
-
return skill_path.parent.parent
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def discover_evals(
|
|
59
|
-
skill_path: Path,
|
|
60
|
-
project_root: Path,
|
|
61
|
-
explicit: Path | None,
|
|
62
|
-
) -> dict[str, Path]:
|
|
63
|
-
"""Locate evals.json and triggers.json. Return dict with keys 'evals' and/or 'triggers'."""
|
|
64
|
-
found: dict[str, Path] = {}
|
|
65
|
-
|
|
66
|
-
def check_dir(d: Path) -> None:
|
|
67
|
-
if not d.is_dir():
|
|
68
|
-
return
|
|
69
|
-
for key, fname in (("evals", "evals.json"), ("triggers", "triggers.json")):
|
|
70
|
-
candidate = d / fname
|
|
71
|
-
if candidate.is_file() and key not in found:
|
|
72
|
-
found[key] = candidate
|
|
73
|
-
|
|
74
|
-
if explicit is not None:
|
|
75
|
-
explicit = explicit.resolve()
|
|
76
|
-
if explicit.is_file():
|
|
77
|
-
if explicit.name == "evals.json":
|
|
78
|
-
found["evals"] = explicit
|
|
79
|
-
elif explicit.name == "triggers.json":
|
|
80
|
-
found["triggers"] = explicit
|
|
81
|
-
elif explicit.is_dir():
|
|
82
|
-
check_dir(explicit)
|
|
83
|
-
return found
|
|
84
|
-
|
|
85
|
-
skill_name = skill_path.name
|
|
86
|
-
candidates: list[Path] = [
|
|
87
|
-
skill_path / "evals",
|
|
88
|
-
skill_path.parent.parent / "evals" / skill_name,
|
|
89
|
-
project_root / "evals" / skill_name,
|
|
90
|
-
]
|
|
91
|
-
for d in candidates:
|
|
92
|
-
check_dir(d)
|
|
93
|
-
if found:
|
|
94
|
-
break
|
|
95
|
-
|
|
96
|
-
if not found:
|
|
97
|
-
evals_root = project_root / "evals"
|
|
98
|
-
if evals_root.is_dir():
|
|
99
|
-
for sub in evals_root.rglob(skill_name):
|
|
100
|
-
if sub.is_dir():
|
|
101
|
-
check_dir(sub)
|
|
102
|
-
if found:
|
|
103
|
-
break
|
|
104
|
-
|
|
105
|
-
return found
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def utc_now_iso() -> str:
|
|
109
|
-
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def new_run_id(skill_name: str) -> str:
|
|
113
|
-
return f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{skill_name}"
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def have_docker() -> bool:
|
|
117
|
-
if shutil.which("docker") is None:
|
|
118
|
-
return False
|
|
119
|
-
try:
|
|
120
|
-
result = subprocess.run(
|
|
121
|
-
["docker", "info"],
|
|
122
|
-
stdout=subprocess.DEVNULL,
|
|
123
|
-
stderr=subprocess.DEVNULL,
|
|
124
|
-
timeout=5,
|
|
125
|
-
)
|
|
126
|
-
return result.returncode == 0
|
|
127
|
-
except Exception:
|
|
128
|
-
return False
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def docker_image_present(image: str = "bmad-eval-runner:latest") -> bool:
|
|
132
|
-
if not have_docker():
|
|
133
|
-
return False
|
|
134
|
-
try:
|
|
135
|
-
result = subprocess.run(
|
|
136
|
-
["docker", "image", "inspect", image],
|
|
137
|
-
stdout=subprocess.DEVNULL,
|
|
138
|
-
stderr=subprocess.DEVNULL,
|
|
139
|
-
timeout=10,
|
|
140
|
-
)
|
|
141
|
-
return result.returncode == 0
|
|
142
|
-
except Exception:
|
|
143
|
-
return False
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def read_macos_keychain_credentials() -> str | None:
|
|
147
|
-
"""Read the Claude Code OAuth credentials JSON from the macOS Keychain.
|
|
148
|
-
|
|
149
|
-
Returns the raw JSON string stored under service "Claude Code-credentials",
|
|
150
|
-
or None if unavailable (non-macOS, entry missing, or access denied).
|
|
151
|
-
|
|
152
|
-
Called in the parent process — which owns the Keychain ACL — so the credential
|
|
153
|
-
can be staged into each isolated workspace's `.claude/.credentials.json` before
|
|
154
|
-
`claude -p` is launched. Without this, an isolated subprocess with HOME pointed
|
|
155
|
-
at an empty dir has no auth and every eval fails with "Not logged in."
|
|
156
|
-
"""
|
|
157
|
-
if sys.platform != "darwin":
|
|
158
|
-
return None
|
|
159
|
-
try:
|
|
160
|
-
result = subprocess.run(
|
|
161
|
-
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
|
162
|
-
capture_output=True,
|
|
163
|
-
timeout=5,
|
|
164
|
-
)
|
|
165
|
-
if result.returncode != 0:
|
|
166
|
-
return None
|
|
167
|
-
val = result.stdout.decode("utf-8", errors="replace").strip()
|
|
168
|
-
return val if val else None
|
|
169
|
-
except Exception:
|
|
170
|
-
return None
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def stage_credentials(claude_dir: Path, credentials_json: str | None) -> None:
|
|
174
|
-
"""Write credentials_json to <claude_dir>/.credentials.json. No-op if None."""
|
|
175
|
-
if not credentials_json:
|
|
176
|
-
return
|
|
177
|
-
claude_dir.mkdir(parents=True, exist_ok=True)
|
|
178
|
-
(claude_dir / ".credentials.json").write_text(credentials_json, encoding="utf-8")
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def write_json(path: Path, data: object) -> None:
|
|
182
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
183
|
-
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def read_json(path: Path) -> object:
|
|
187
|
-
return json.loads(path.read_text(encoding="utf-8"))
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def parse_skill_dependencies(skill_path: Path) -> list[str]:
|
|
191
|
-
"""Return skill names declared under 'dependencies:' in SKILL.md frontmatter."""
|
|
192
|
-
try:
|
|
193
|
-
text = (skill_path / "SKILL.md").read_text(encoding="utf-8")
|
|
194
|
-
except (FileNotFoundError, OSError):
|
|
195
|
-
return []
|
|
196
|
-
fm = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
|
|
197
|
-
if not fm:
|
|
198
|
-
return []
|
|
199
|
-
deps: list[str] = []
|
|
200
|
-
in_deps = False
|
|
201
|
-
for line in fm.group(1).splitlines():
|
|
202
|
-
if re.match(r"^dependencies\s*:", line):
|
|
203
|
-
in_deps = True
|
|
204
|
-
elif in_deps:
|
|
205
|
-
m = re.match(r"^\s+-\s+(\S+)", line)
|
|
206
|
-
if m:
|
|
207
|
-
deps.append(m.group(1))
|
|
208
|
-
elif not line.startswith((" ", "\t")):
|
|
209
|
-
break
|
|
210
|
-
return deps
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
def discover_setup_dirs(evals_file: Path, eval_id: str | None = None) -> list[Path]:
|
|
214
|
-
"""Return ordered list of setup overlay dirs that exist.
|
|
215
|
-
|
|
216
|
-
base: <evals_dir>/setup/
|
|
217
|
-
per-eval: <evals_dir>/<eval_id>/setup/
|
|
218
|
-
|
|
219
|
-
Applied base-first so per-eval overlays win on conflict.
|
|
220
|
-
"""
|
|
221
|
-
evals_dir = evals_file.parent
|
|
222
|
-
dirs: list[Path] = []
|
|
223
|
-
base = evals_dir / "setup"
|
|
224
|
-
if base.is_dir():
|
|
225
|
-
dirs.append(base)
|
|
226
|
-
if eval_id:
|
|
227
|
-
per_eval = evals_dir / eval_id / "setup"
|
|
228
|
-
if per_eval.is_dir():
|
|
229
|
-
dirs.append(per_eval)
|
|
230
|
-
return dirs
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
def apply_setup_overlay(setup_dirs: list[Path], dest: Path) -> None:
|
|
234
|
-
"""Rsync each setup dir onto dest in order (base first, per-eval last)."""
|
|
235
|
-
dest.mkdir(parents=True, exist_ok=True)
|
|
236
|
-
for src in setup_dirs:
|
|
237
|
-
if not src.is_dir():
|
|
238
|
-
continue
|
|
239
|
-
subprocess.run(
|
|
240
|
-
["rsync", "-a", f"{src}/", f"{dest}/"],
|
|
241
|
-
check=False,
|
|
242
|
-
)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
__all__ = [
|
|
246
|
-
"parse_skill_md",
|
|
247
|
-
"discover_project_root",
|
|
248
|
-
"discover_evals",
|
|
249
|
-
"utc_now_iso",
|
|
250
|
-
"new_run_id",
|
|
251
|
-
"have_docker",
|
|
252
|
-
"docker_image_present",
|
|
253
|
-
"read_macos_keychain_credentials",
|
|
254
|
-
"stage_credentials",
|
|
255
|
-
"write_json",
|
|
256
|
-
"read_json",
|
|
257
|
-
"parse_skill_dependencies",
|
|
258
|
-
"discover_setup_dirs",
|
|
259
|
-
"apply_setup_overlay",
|
|
260
|
-
]
|