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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Guard the clean-room env contract in run_evals.py and run_triggers.py.
|
|
3
|
+
|
|
4
|
+
The eval result is only honest if nothing from the host shell leaks into the
|
|
5
|
+
subprocess. Both scripts carry their own build_case_env (they are
|
|
6
|
+
deliberately self-contained); this test pins the contract on both copies:
|
|
7
|
+
exactly PATH + fresh HOME + CLAUDE_CONFIG_DIR + auth-var-only-when-set +
|
|
8
|
+
declared passthrough keys, nothing else.
|
|
9
|
+
Run with: python3 -m pytest test_env_isolation.py
|
|
10
|
+
(or plain `python3 test_env_isolation.py` for a lightweight self-check).
|
|
11
|
+
"""
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
|
16
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
17
|
+
|
|
18
|
+
import run_evals # noqa: E402
|
|
19
|
+
import run_triggers # noqa: E402
|
|
20
|
+
|
|
21
|
+
BUILDERS = [run_evals.build_case_env, run_triggers.build_case_env]
|
|
22
|
+
|
|
23
|
+
HOST_ENV = {
|
|
24
|
+
"PATH": "/usr/bin:/bin",
|
|
25
|
+
"HOME": "/Users/host",
|
|
26
|
+
"ANTHROPIC_API_KEY": "sk-test-123",
|
|
27
|
+
"AWS_SECRET_ACCESS_KEY": "host-secret-must-not-leak",
|
|
28
|
+
"CLAUDE_CONFIG_DIR": "/Users/host/.claude",
|
|
29
|
+
"EXTRA_VAR": "extra",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
HOME = Path("/tmp/eval-case/.home")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_minimal_env_keys():
|
|
36
|
+
adapter = {"auth_env": "ANTHROPIC_API_KEY"}
|
|
37
|
+
for build in BUILDERS:
|
|
38
|
+
env = build(adapter, HOME, HOST_ENV)
|
|
39
|
+
assert set(env) == {"PATH", "HOME", "CLAUDE_CONFIG_DIR",
|
|
40
|
+
"ANTHROPIC_API_KEY"}, (build.__module__, env)
|
|
41
|
+
assert env["PATH"] == HOST_ENV["PATH"]
|
|
42
|
+
assert env["HOME"] == str(HOME), "HOME must be the fresh case home"
|
|
43
|
+
assert env["CLAUDE_CONFIG_DIR"] == str(HOME / ".claude")
|
|
44
|
+
assert env["ANTHROPIC_API_KEY"] == "sk-test-123"
|
|
45
|
+
assert "AWS_SECRET_ACCESS_KEY" not in env, "host secrets leaked"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_auth_var_absent_when_unset():
|
|
49
|
+
# Setting auth to "" breaks the runtime's OAuth fallback — the key must
|
|
50
|
+
# be absent, never empty.
|
|
51
|
+
adapter = {"auth_env": "ANTHROPIC_API_KEY"}
|
|
52
|
+
for host in ({}, {"ANTHROPIC_API_KEY": ""}):
|
|
53
|
+
for build in BUILDERS:
|
|
54
|
+
env = build(adapter, HOME, {"PATH": "/bin", **host})
|
|
55
|
+
assert "ANTHROPIC_API_KEY" not in env, (build.__module__, env)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_no_adapter_still_minimal():
|
|
59
|
+
for build in BUILDERS:
|
|
60
|
+
env = build(None, HOME, HOST_ENV)
|
|
61
|
+
assert set(env) == {"PATH", "HOME", "CLAUDE_CONFIG_DIR"}, env
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_env_passthrough_only_declared_and_present():
|
|
65
|
+
adapter = {"auth_env": "ANTHROPIC_API_KEY",
|
|
66
|
+
"env_passthrough": ["EXTRA_VAR", "NOT_SET_ON_HOST"]}
|
|
67
|
+
for build in BUILDERS:
|
|
68
|
+
env = build(adapter, HOME, HOST_ENV)
|
|
69
|
+
assert env.get("EXTRA_VAR") == "extra"
|
|
70
|
+
assert "NOT_SET_ON_HOST" not in env
|
|
71
|
+
assert "AWS_SECRET_ACCESS_KEY" not in env
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
test_minimal_env_keys()
|
|
76
|
+
test_auth_var_absent_when_unset()
|
|
77
|
+
test_no_adapter_still_minimal()
|
|
78
|
+
test_env_passthrough_only_declared_and_present()
|
|
79
|
+
print("ok: build_case_env contract holds in run_evals and run_triggers")
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Guard trigger detection in run_triggers.py.
|
|
3
|
+
|
|
4
|
+
The stream-json init event lists every discovered skill by name, so any
|
|
5
|
+
detection that substring-matches the whole transcript reports a 100% trigger
|
|
6
|
+
rate. These tests pin the rule: only tool_use events (a Skill call naming the
|
|
7
|
+
synthetic skill, or a Read inside its directory) count as a load, and
|
|
8
|
+
substring-style load signals are rejected outright.
|
|
9
|
+
Run with: python3 -m pytest test_trigger_detection.py
|
|
10
|
+
(or plain `python3 test_trigger_detection.py` for a lightweight self-check).
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
|
17
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
18
|
+
|
|
19
|
+
from run_triggers import detect_load, validate_load_signal # noqa: E402
|
|
20
|
+
|
|
21
|
+
NAME = "my-skill-trig-abc12345"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def line(obj) -> str:
|
|
25
|
+
return json.dumps(obj)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def init_event() -> str:
|
|
29
|
+
# Claude Code's init event advertises every discovered skill.
|
|
30
|
+
return line({"type": "system", "subtype": "init",
|
|
31
|
+
"tools": ["Skill", "Read", "Bash"],
|
|
32
|
+
"slash_commands": [], "skills": [NAME, "other-skill"]})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def assistant(content) -> str:
|
|
36
|
+
return line({"type": "assistant", "message": {"content": content}})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_init_event_alone_is_not_a_load():
|
|
40
|
+
transcript = "\n".join([
|
|
41
|
+
init_event(),
|
|
42
|
+
assistant([{"type": "text", "text": "I can't help with that."}]),
|
|
43
|
+
line({"type": "result", "usage": {}}),
|
|
44
|
+
])
|
|
45
|
+
assert detect_load(transcript, {}, NAME) is False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_text_mention_is_not_a_load():
|
|
49
|
+
transcript = assistant(
|
|
50
|
+
[{"type": "text", "text": f"There is a skill called {NAME} available."}])
|
|
51
|
+
assert detect_load(transcript, {}, NAME) is False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_skill_tool_call_is_a_load():
|
|
55
|
+
transcript = "\n".join([
|
|
56
|
+
init_event(),
|
|
57
|
+
assistant([{"type": "tool_use", "name": "Skill",
|
|
58
|
+
"input": {"skill": NAME}}]),
|
|
59
|
+
])
|
|
60
|
+
assert detect_load(transcript, {}, NAME) is True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_read_of_synthetic_skill_md_is_a_load():
|
|
64
|
+
transcript = "\n".join([
|
|
65
|
+
init_event(),
|
|
66
|
+
assistant([{"type": "tool_use", "name": "Read",
|
|
67
|
+
"input": {"file_path":
|
|
68
|
+
f"/tmp/stage/.claude/skills/{NAME}/SKILL.md"}}]),
|
|
69
|
+
])
|
|
70
|
+
assert detect_load(transcript, {}, NAME) is True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_unrelated_tool_calls_are_not_a_load():
|
|
74
|
+
transcript = "\n".join([
|
|
75
|
+
init_event(),
|
|
76
|
+
assistant([{"type": "tool_use", "name": "Read",
|
|
77
|
+
"input": {"file_path": "/tmp/stage/notes.md"}}]),
|
|
78
|
+
assistant([{"type": "tool_use", "name": "Skill",
|
|
79
|
+
"input": {"skill": "other-skill"}}]),
|
|
80
|
+
assistant([{"type": "tool_use", "name": "Bash",
|
|
81
|
+
"input": {"command": f"echo {NAME}"}}]),
|
|
82
|
+
])
|
|
83
|
+
assert detect_load(transcript, {}, NAME) is False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_custom_tool_names_from_load_signal():
|
|
87
|
+
sig = {"skill_tool": "InvokeSkill", "read_tool": "OpenFile"}
|
|
88
|
+
hit = assistant([{"type": "tool_use", "name": "InvokeSkill",
|
|
89
|
+
"input": {"name": NAME}}])
|
|
90
|
+
miss = assistant([{"type": "tool_use", "name": "Skill",
|
|
91
|
+
"input": {"skill": NAME}}])
|
|
92
|
+
assert detect_load(hit, sig, NAME) is True
|
|
93
|
+
assert detect_load(miss, sig, NAME) is False, \
|
|
94
|
+
"default tool name must not fire when the adapter renames it"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_garbage_lines_do_not_crash():
|
|
98
|
+
transcript = "not json\n\n{\"type\": 42}\n[1,2,3]\n"
|
|
99
|
+
assert detect_load(transcript, {}, NAME) is False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_string_load_signal_rejected():
|
|
103
|
+
for fn, args in ((validate_load_signal, ({"type": "string"},)),
|
|
104
|
+
(detect_load, ("", {"type": "string"}, NAME))):
|
|
105
|
+
try:
|
|
106
|
+
fn(*args)
|
|
107
|
+
except ValueError:
|
|
108
|
+
pass
|
|
109
|
+
else:
|
|
110
|
+
raise AssertionError(
|
|
111
|
+
f"{fn.__name__} accepted a substring load_signal")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
test_init_event_alone_is_not_a_load()
|
|
116
|
+
test_text_mention_is_not_a_load()
|
|
117
|
+
test_skill_tool_call_is_a_load()
|
|
118
|
+
test_read_of_synthetic_skill_md_is_a_load()
|
|
119
|
+
test_unrelated_tool_calls_are_not_a_load()
|
|
120
|
+
test_custom_tool_names_from_load_signal()
|
|
121
|
+
test_garbage_lines_do_not_crash()
|
|
122
|
+
test_string_load_signal_rejected()
|
|
123
|
+
print("ok: trigger detection counts tool calls only; substring rejected")
|
|
@@ -235,8 +235,14 @@ def test_short_row_does_not_crash():
|
|
|
235
235
|
def create_standalone_module(tmp: Path, skill_name: str = "my-skill",
|
|
236
236
|
csv_rows: str = "", yaml_content: str = "",
|
|
237
237
|
include_setup_md: bool = True,
|
|
238
|
-
include_merge_scripts: bool = True
|
|
239
|
-
|
|
238
|
+
include_merge_scripts: bool = True,
|
|
239
|
+
merge_script_style: str = "dash") -> Path:
|
|
240
|
+
"""Create a minimal standalone module structure for testing.
|
|
241
|
+
|
|
242
|
+
``merge_script_style`` selects the merge-script naming form: "dash" for the
|
|
243
|
+
scaffolder default (merge-config.py) or "underscore" for the importable form
|
|
244
|
+
(merge_config.py). Both are valid.
|
|
245
|
+
"""
|
|
240
246
|
module_dir = tmp / "module"
|
|
241
247
|
module_dir.mkdir()
|
|
242
248
|
|
|
@@ -259,8 +265,12 @@ def create_standalone_module(tmp: Path, skill_name: str = "my-skill",
|
|
|
259
265
|
if include_merge_scripts:
|
|
260
266
|
scripts = skill / "scripts"
|
|
261
267
|
scripts.mkdir()
|
|
262
|
-
|
|
263
|
-
|
|
268
|
+
if merge_script_style == "underscore":
|
|
269
|
+
(scripts / "merge_config.py").write_text("# merge_config\n")
|
|
270
|
+
(scripts / "merge_help_csv.py").write_text("# merge_help_csv\n")
|
|
271
|
+
else:
|
|
272
|
+
(scripts / "merge-config.py").write_text("# merge-config\n")
|
|
273
|
+
(scripts / "merge-help-csv.py").write_text("# merge-help-csv\n")
|
|
264
274
|
|
|
265
275
|
return module_dir
|
|
266
276
|
|
|
@@ -319,6 +329,70 @@ def test_standalone_csv_validation():
|
|
|
319
329
|
assert "DT" in dupes[0]["message"]
|
|
320
330
|
|
|
321
331
|
|
|
332
|
+
def test_standalone_underscore_merge_scripts():
|
|
333
|
+
"""Importable underscore-named merge scripts (merge_config.py) should pass."""
|
|
334
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
335
|
+
tmp = Path(tmp)
|
|
336
|
+
module_dir = create_standalone_module(tmp, merge_script_style="underscore")
|
|
337
|
+
|
|
338
|
+
code, data = run_validate(module_dir)
|
|
339
|
+
assert code == 0, f"Expected pass: {data}"
|
|
340
|
+
assert data["status"] == "pass"
|
|
341
|
+
assert data["info"].get("standalone") is True
|
|
342
|
+
assert data["summary"]["total_findings"] == 0
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_standalone_cross_module_before_after_ref():
|
|
346
|
+
"""Bare (colon-less) preceded-by/followed-by refs are cross-module positional, not flagged."""
|
|
347
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
348
|
+
tmp = Path(tmp)
|
|
349
|
+
csv_rows = ('Test Module,my-skill,Do Thing,DT,Does thing,,,anytime,'
|
|
350
|
+
'bmad-sprint-planning,bmad-retrospective,false,output_folder,artifact\n')
|
|
351
|
+
module_dir = create_standalone_module(tmp, csv_rows=csv_rows)
|
|
352
|
+
|
|
353
|
+
code, data = run_validate(module_dir)
|
|
354
|
+
assert code == 0, f"Expected pass: {data}"
|
|
355
|
+
refs = [f for f in data["findings"] if f["category"] == "invalid-ref"]
|
|
356
|
+
assert refs == [], f"Cross-module bare refs should not be flagged: {refs}"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def test_standalone_given_skill_dir_directly():
|
|
360
|
+
"""Passing the standalone skill directory itself (not its parent) should work."""
|
|
361
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
362
|
+
tmp = Path(tmp)
|
|
363
|
+
module_dir = create_standalone_module(tmp, skill_name="my-skill")
|
|
364
|
+
skill_dir = module_dir / "my-skill"
|
|
365
|
+
|
|
366
|
+
code, data = run_validate(skill_dir)
|
|
367
|
+
assert code == 0, f"Expected pass: {data}"
|
|
368
|
+
assert data["status"] == "pass"
|
|
369
|
+
assert data["info"].get("standalone") is True
|
|
370
|
+
assert data["info"].get("skill_dir") == "my-skill"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_standalone_skill_dir_orphan_not_masked_by_sibling():
|
|
374
|
+
"""Validating a skill dir directly must still flag a CSV skill that only
|
|
375
|
+
exists as an unrelated sibling directory (not part of this standalone module)."""
|
|
376
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
377
|
+
tmp = Path(tmp)
|
|
378
|
+
csv_rows = (
|
|
379
|
+
'Test Module,my-skill,Do Thing,DT,Does thing,run,,anytime,,,false,output_folder,artifact\n'
|
|
380
|
+
'Test Module,other-skill,Other,OT,Other thing,run,,anytime,,,false,output_folder,report\n'
|
|
381
|
+
)
|
|
382
|
+
module_dir = create_standalone_module(tmp, skill_name="my-skill", csv_rows=csv_rows)
|
|
383
|
+
# A sibling skill dir next to the standalone skill (a different module).
|
|
384
|
+
sibling = module_dir / "other-skill"
|
|
385
|
+
sibling.mkdir()
|
|
386
|
+
(sibling / "SKILL.md").write_text("---\nname: other-skill\n---\n# other-skill\n")
|
|
387
|
+
|
|
388
|
+
skill_dir = module_dir / "my-skill"
|
|
389
|
+
code, data = run_validate(skill_dir)
|
|
390
|
+
assert code == 1, f"Orphan entry should fail validation: {data}"
|
|
391
|
+
orphans = [f for f in data["findings"] if f["category"] == "orphan-entry"]
|
|
392
|
+
assert any("other-skill" in f["message"] for f in orphans), \
|
|
393
|
+
f"Sibling skill must not mask the orphan: {data['findings']}"
|
|
394
|
+
|
|
395
|
+
|
|
322
396
|
def test_multi_skill_not_detected_as_standalone():
|
|
323
397
|
"""A folder with two skills and no setup skill should fail (not detected as standalone)."""
|
|
324
398
|
with tempfile.TemporaryDirectory() as tmp:
|
|
@@ -367,6 +441,10 @@ if __name__ == "__main__":
|
|
|
367
441
|
test_standalone_missing_module_setup_md,
|
|
368
442
|
test_standalone_missing_merge_scripts,
|
|
369
443
|
test_standalone_csv_validation,
|
|
444
|
+
test_standalone_underscore_merge_scripts,
|
|
445
|
+
test_standalone_cross_module_before_after_ref,
|
|
446
|
+
test_standalone_given_skill_dir_directly,
|
|
447
|
+
test_standalone_skill_dir_orphan_not_masked_by_sibling,
|
|
370
448
|
test_multi_skill_not_detected_as_standalone,
|
|
371
449
|
test_nonexistent_directory,
|
|
372
450
|
]
|
|
@@ -51,7 +51,16 @@ def find_skill_folders(module_dir: Path, exclude_name: str = "") -> list[str]:
|
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
def detect_standalone_module(module_dir: Path) -> Path | None:
|
|
54
|
-
"""Detect a standalone module: single skill folder with assets/module.yaml.
|
|
54
|
+
"""Detect a standalone module: a single skill folder with assets/module.yaml.
|
|
55
|
+
|
|
56
|
+
Works whether ``module_dir`` is the parent folder that contains the skill, or
|
|
57
|
+
the standalone skill directory itself (the path a user is most likely to hand
|
|
58
|
+
over for a single-skill module).
|
|
59
|
+
"""
|
|
60
|
+
# Given the skill directory directly.
|
|
61
|
+
if (module_dir / "SKILL.md").is_file() and (module_dir / "assets" / "module.yaml").is_file():
|
|
62
|
+
return module_dir
|
|
63
|
+
# Given the parent folder containing exactly one skill.
|
|
55
64
|
skill_dirs = [
|
|
56
65
|
d for d in module_dir.iterdir()
|
|
57
66
|
if d.is_dir() and (d / "SKILL.md").is_file()
|
|
@@ -130,14 +139,32 @@ def validate(module_dir: Path, verbose: bool = False) -> dict:
|
|
|
130
139
|
"assets/module.yaml": skill_dir / "assets" / "module.yaml",
|
|
131
140
|
"assets/module-help.csv": skill_dir / "assets" / "module-help.csv",
|
|
132
141
|
"assets/module-setup.md": skill_dir / "assets" / "module-setup.md",
|
|
133
|
-
"scripts/merge-config.py": skill_dir / "scripts" / "merge-config.py",
|
|
134
|
-
"scripts/merge-help-csv.py": skill_dir / "scripts" / "merge-help-csv.py",
|
|
135
142
|
}
|
|
143
|
+
# Merge scripts: accept either the dash form the scaffolder emits
|
|
144
|
+
# (merge-config.py) or the importable underscore form (merge_config.py).
|
|
145
|
+
# Both are valid — a module may rename them to be importable from
|
|
146
|
+
# module-setup.md without that being a structural defect.
|
|
147
|
+
required_any = {
|
|
148
|
+
"scripts/merge-config.py (or merge_config.py)": [
|
|
149
|
+
skill_dir / "scripts" / "merge-config.py",
|
|
150
|
+
skill_dir / "scripts" / "merge_config.py",
|
|
151
|
+
],
|
|
152
|
+
"scripts/merge-help-csv.py (or merge_help_csv.py)": [
|
|
153
|
+
skill_dir / "scripts" / "merge-help-csv.py",
|
|
154
|
+
skill_dir / "scripts" / "merge_help_csv.py",
|
|
155
|
+
],
|
|
156
|
+
}
|
|
157
|
+
ok = True
|
|
136
158
|
for label, path in required_files.items():
|
|
137
159
|
if not path.is_file():
|
|
138
160
|
finding("critical", "structure", f"Missing required file: {label}")
|
|
161
|
+
ok = False
|
|
162
|
+
for label, candidates in required_any.items():
|
|
163
|
+
if not any(p.is_file() for p in candidates):
|
|
164
|
+
finding("critical", "structure", f"Missing required file: {label}")
|
|
165
|
+
ok = False
|
|
139
166
|
|
|
140
|
-
if not
|
|
167
|
+
if not ok:
|
|
141
168
|
return {"status": "fail", "findings": findings, "info": info}
|
|
142
169
|
|
|
143
170
|
yaml_dir = skill_dir
|
|
@@ -202,8 +229,12 @@ def validate(module_dir: Path, verbose: bool = False) -> dict:
|
|
|
202
229
|
|
|
203
230
|
# 6. Collect skills from CSV and filesystem
|
|
204
231
|
csv_skills = {row.get("skill", "") for row in rows}
|
|
205
|
-
|
|
206
|
-
|
|
232
|
+
if standalone_dir:
|
|
233
|
+
# The only valid skill is the standalone skill itself, whether we were
|
|
234
|
+
# handed the module's parent folder or the skill directory directly.
|
|
235
|
+
skill_folders = [standalone_dir.name]
|
|
236
|
+
else:
|
|
237
|
+
skill_folders = find_skill_folders(module_dir, setup_dir.name)
|
|
207
238
|
info["skill_folders"] = skill_folders
|
|
208
239
|
info["csv_skills"] = sorted(csv_skills)
|
|
209
240
|
|
|
@@ -215,10 +246,15 @@ def validate(module_dir: Path, verbose: bool = False) -> dict:
|
|
|
215
246
|
# 8. Orphan CSV entries
|
|
216
247
|
setup_name = setup_dir.name if setup_dir else ""
|
|
217
248
|
for skill in csv_skills:
|
|
218
|
-
if skill
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
249
|
+
if skill in skill_folders or skill == setup_name:
|
|
250
|
+
continue
|
|
251
|
+
# For a standalone module, skill_folders already enumerates every valid
|
|
252
|
+
# skill, so any other CSV skill is an orphan — never look at the parent
|
|
253
|
+
# folder (which may hold unrelated sibling skills when validating a skill
|
|
254
|
+
# dir directly). For a multi-skill module, re-check the filesystem: the
|
|
255
|
+
# setup skill lives alongside the others and is excluded from skill_folders.
|
|
256
|
+
if standalone_dir or not (module_dir / skill / "SKILL.md").is_file():
|
|
257
|
+
finding("high", "orphan-entry", f"CSV references skill '{skill}' which does not exist in the module folder")
|
|
222
258
|
|
|
223
259
|
# 9. Unique menu codes
|
|
224
260
|
menu_codes: dict[str, list[str]] = {}
|
|
@@ -249,7 +285,15 @@ def validate(module_dir: Path, verbose: bool = False) -> dict:
|
|
|
249
285
|
# Can be comma-separated
|
|
250
286
|
for ref in value.split(","):
|
|
251
287
|
ref = ref.strip()
|
|
252
|
-
if
|
|
288
|
+
if not ref:
|
|
289
|
+
continue
|
|
290
|
+
# A colon-less ref is a cross-module positional reference (a bare
|
|
291
|
+
# sibling-module skill name, e.g. "bmad-sprint-planning"). Other
|
|
292
|
+
# installed modules aren't visible here, so it can't be resolved
|
|
293
|
+
# and isn't a defect — only validate intra-module skill:action refs.
|
|
294
|
+
if ":" not in ref:
|
|
295
|
+
continue
|
|
296
|
+
if ref not in valid_refs:
|
|
253
297
|
finding("medium", "invalid-ref",
|
|
254
298
|
f"'{display}' {field} references '{ref}' which is not a valid capability",
|
|
255
299
|
"Expected format: skill-name:action-name")
|
|
@@ -31,15 +31,13 @@ activation_steps_append = []
|
|
|
31
31
|
# - a file reference prefixed with `file:`, e.g. "file:{project-root}/docs/standards.md"
|
|
32
32
|
# (glob patterns are supported; the file's contents are loaded and treated as facts).
|
|
33
33
|
|
|
34
|
-
persistent_facts = [
|
|
35
|
-
"file:{project-root}/**/project-context.md",
|
|
36
|
-
]
|
|
34
|
+
persistent_facts = []
|
|
37
35
|
|
|
38
|
-
#
|
|
36
|
+
# Items that are executed when the workflow reaches its terminal stage, after
|
|
39
37
|
# the main output has been delivered. Override wins. Leave empty for
|
|
40
38
|
# no custom post-completion behavior.
|
|
41
39
|
|
|
42
|
-
on_complete =
|
|
40
|
+
on_complete = []
|
|
43
41
|
|
|
44
42
|
# --- Workflow-specific configurables (lifted during Configurability Discovery) ---
|
|
45
43
|
#
|