ma-agents 3.13.2 → 3.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +3 -3
  2. package/lib/agents.js +9 -2
  3. package/lib/bmad-cache/bmb/.claude-plugin/marketplace.json +3 -3
  4. package/lib/bmad-cache/bmb/_git_preserved/index +0 -0
  5. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.idx +0 -0
  6. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/{pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.pack → pack-b388526edf307cf56f8e65673466cff074105abf.pack} +0 -0
  7. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.rev +0 -0
  8. package/lib/bmad-cache/bmb/_git_preserved/packed-refs +1 -1
  9. package/lib/bmad-cache/bmb/_git_preserved/refs/heads/main +1 -1
  10. package/lib/bmad-cache/bmb/_git_preserved/shallow +1 -1
  11. package/lib/bmad-cache/bmb/package-lock.json +2 -2
  12. package/lib/bmad-cache/bmb/package.json +1 -1
  13. package/lib/bmad-cache/bmb/samples/bmad-agent-code-coach/scripts/wake.py +78 -0
  14. package/lib/bmad-cache/bmb/samples/bmad-agent-creative-muse/scripts/wake.py +78 -0
  15. package/lib/bmad-cache/bmb/samples/bmad-agent-sentinel/scripts/wake.py +78 -0
  16. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/customize-template.toml +3 -0
  17. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/init-sanctum-template.py +11 -5
  18. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/report-shell.html +1073 -0
  19. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/wake-template.py +78 -0
  20. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/customize.toml +48 -0
  21. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/count_tokens.py +78 -0
  22. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass.py +258 -0
  23. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/process-template.py +20 -0
  24. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/render_report.py +387 -0
  25. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/adapter-claude-code.json +9 -0
  26. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/aggregate_benchmark.py +236 -0
  27. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/memlog.py +197 -0
  28. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_evals.py +513 -390
  29. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_triggers.py +347 -251
  30. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_env_isolation.py +79 -0
  31. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_trigger_detection.py +123 -0
  32. package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/tests/test-validate-module.py +82 -4
  33. package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/validate-module.py +55 -11
  34. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/customize-template.toml +3 -5
  35. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/report-shell.html +860 -0
  36. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/customize.toml +59 -0
  37. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/count_tokens.py +77 -0
  38. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/init_skill.py +132 -0
  39. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/prepass-prompt-metrics.py +186 -221
  40. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/quick_validate.py +117 -0
  41. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/render_report.py +387 -0
  42. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_canon_sync.py +46 -0
  43. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_count_tokens.py +180 -0
  44. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_render_report.py +171 -0
  45. package/lib/bmad-cache/cache-manifest.json +4 -4
  46. package/lib/bmad-customize/bmm-qa.customize.yaml +3 -3
  47. package/lib/bmad-extension/.claude-plugin/marketplace.json.template +3 -2
  48. package/lib/bmad-extension/skills/bmad-dev-epic/SKILL.md +101 -0
  49. package/lib/bmad-extension/skills/bmad-dev-epic/bmad-skill-manifest.yaml +3 -0
  50. package/lib/bmad-extension/skills/bmad-dev-epic/checklist.md +44 -0
  51. package/lib/bmad-extension/skills/bmad-dev-epic/customize.toml +54 -0
  52. package/lib/bmad-extension/skills/bmad-dev-epic/references/story-pipeline.md +112 -0
  53. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-01-intake.md +59 -0
  54. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-02-plan-waves.md +56 -0
  55. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-03-epic-branch.md +35 -0
  56. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-04-run-waves.md +49 -0
  57. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-05-epic-adversarial.md +31 -0
  58. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-06-release.md +57 -0
  59. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-07-report.md +40 -0
  60. package/lib/bmad-extension/skills/ma-agent-sqa/SKILL.md +3 -3
  61. package/lib/bmad-extension/skills/ma-agent-sqa/bmad-skill-manifest.yaml +1 -1
  62. package/lib/bmad-extension/skills/module-help.csv +2 -1
  63. package/lib/bmad-extension/skills/module.yaml +1 -1
  64. package/lib/bmad-extension/skills/sqa-audit/SKILL.md +2 -2
  65. package/lib/bmad-extension/skills/sqa-ieee12207/SKILL.md +3 -3
  66. package/lib/bmad-extension/skills/sqa-requirements-quality/SKILL.md +2 -2
  67. package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +4 -3
  68. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/SKILL.md +101 -0
  69. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/bmad-skill-manifest.yaml +3 -0
  70. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/checklist.md +44 -0
  71. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/customize.toml +54 -0
  72. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/references/story-pipeline.md +112 -0
  73. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-01-intake.md +59 -0
  74. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-02-plan-waves.md +56 -0
  75. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-03-epic-branch.md +35 -0
  76. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-04-run-waves.md +49 -0
  77. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-05-epic-adversarial.md +31 -0
  78. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-06-release.md +57 -0
  79. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-07-report.md +40 -0
  80. package/lib/bmad-extension-plugin/skills/ma-agent-sqa/SKILL.md +3 -3
  81. package/lib/bmad-extension-plugin/skills/ma-agent-sqa/bmad-skill-manifest.yaml +1 -1
  82. package/lib/bmad-extension-plugin/skills/module-help.csv +2 -1
  83. package/lib/bmad-extension-plugin/skills/module.yaml +1 -1
  84. package/lib/bmad-extension-plugin/skills/sqa-audit/SKILL.md +2 -2
  85. package/lib/bmad-extension-plugin/skills/sqa-ieee12207/SKILL.md +3 -3
  86. package/lib/bmad-extension-plugin/skills/sqa-requirements-quality/SKILL.md +2 -2
  87. package/lib/bmad.js +6 -1
  88. package/package.json +3 -3
  89. package/lib/.bmad-extension-plugin.build-22612-1782454014750/.claude-plugin/marketplace.json +0 -109
  90. package/lib/.bmad-extension-plugin.build-22612-1782454014750/skills/module-help.csv +0 -62
  91. package/lib/.bmad-extension-plugin.build-22612-1782454014750/skills/module.yaml +0 -20
  92. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.idx +0 -0
  93. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.rev +0 -0
  94. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/references/sample-init-sanctum.py +0 -274
  95. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/generate-html-report.py +0 -534
  96. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-execution-deps.py +0 -337
  97. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-prompt-metrics.py +0 -425
  98. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-sanctum-architecture.py +0 -385
  99. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-structure-capabilities.py +0 -482
  100. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/Dockerfile +0 -29
  101. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/docker_setup.py +0 -115
  102. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/generate_report.py +0 -184
  103. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/pty_runner.py +0 -171
  104. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/utils.py +0 -260
  105. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/extract-report-json.py +0 -287
  106. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/generate-html-report.py +0 -588
  107. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/prepass-execution-deps.py +0 -288
@@ -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
- ]