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
@@ -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) -> Path:
239
- """Create a minimal standalone module structure for testing."""
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
- (scripts / "merge-config.py").write_text("# merge-config\n")
263
- (scripts / "merge-help-csv.py").write_text("# merge-help-csv\n")
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 all(p.is_file() for p in required_files.values()):
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
- exclude_name = setup_dir.name if setup_dir else ""
206
- skill_folders = find_skill_folders(module_dir, exclude_name)
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 not in skill_folders and skill != setup_name:
219
- # Check if it's the setup skill itself (valid)
220
- if not (module_dir / skill / "SKILL.md").is_file():
221
- finding("high", "orphan-entry", f"CSV references skill '{skill}' which does not exist in the module folder")
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 ref and ref not in valid_refs:
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
- # Scalar: executed when the workflow reaches its terminal stage, after
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
  #