Add Bmad-Method files and some brand-design artifacts!

This commit is contained in:
2026-07-06 18:00:53 +01:00
parent 87bff9c251
commit 2b4a6a413c
1806 changed files with 417144 additions and 0 deletions
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
# vendored from bmad-workflow-builder/scripts; canonical source there
# /// script
# requires-python = ">=3.9"
# dependencies = ["tiktoken"]
# ///
"""count_tokens — the single length metric for skill authoring.
Token counts replace line counts everywhere in the builder and eval-runner.
This script reports the token length of a file or of text piped on stdin, using
the tiktoken cl100k_base encoding. When tiktoken is not installed it falls back
to a character-based estimate (len(text) // 4) and says so, so the script always
runs under a bare python3 even with no third-party packages present.
Usage:
count_tokens.py <file> count the tokens in a file
count_tokens.py --stdin count the tokens read from stdin
Output (one line of JSON on stdout):
{"tokens": <int>, "method": "tiktoken"} when tiktoken loaded
{"tokens": <int>, "method": "fallback"} when it fell back to chars // 4
Budgets this feeds: SKILL.md ~1500-2500, multi-branch reference ~4500,
single-purpose reference ~9000.
"""
import argparse
import json
import sys
ENCODING = "cl100k_base"
def count_tokens(text: str) -> tuple[int, str]:
"""Return (token_count, method).
Tries tiktoken's cl100k_base encoding first. If tiktoken cannot be imported
or initialized, estimates with len(text) // 4 and reports method "fallback".
"""
try:
import tiktoken
except Exception:
return len(text) // 4, "fallback"
try:
enc = tiktoken.get_encoding(ENCODING)
except Exception:
return len(text) // 4, "fallback"
return len(enc.encode(text)), "tiktoken"
def read_input(args) -> str:
if args.stdin:
return sys.stdin.read()
with open(args.file, encoding="utf-8") as f:
return f.read()
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("file", nargs="?", help="path to the file to count")
p.add_argument("--stdin", action="store_true", help="read text from stdin instead of a file")
args = p.parse_args(argv)
if not args.stdin and not args.file:
p.error("provide a file path or --stdin")
if args.stdin and args.file:
p.error("provide either a file path or --stdin, not both")
text = read_input(args)
tokens, method = count_tokens(text)
print(json.dumps({"tokens": tokens, "method": method}))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,258 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.9"
# dependencies = ["tiktoken"]
# ///
"""prepass — the Analyze pre-pass for the agent builder.
Reads an agent skill directory and emits one compact JSON object that every
lens and the analyze orchestrator consume. The pre-pass does the one thing the
lenses should not each redo: it classifies the agent along the three-point
gradient (stateless, memory, autonomous), counts tokens for SKILL.md and every
in-tree file, and sets the gate that turns the conditional sanctum lens on.
Detection rests on the sanctum, the built agent's runtime memory at
`{project-root}/_bmad/memory/{skillName}/`. An agent that reloads a sanctum on
waking is a memory agent; one that also carries live wake behavior (a PULSE
file or a pulse/autonomous wake reference with named-task routing) is
autonomous; one with no sanctum at all is stateless. This is the BUILT agent's
memory, never the builder's process log (.memlog.md), and the two are kept
apart here.
Lengths come from tokens, never line counts. The count uses count_tokens.py
(imported as a sibling, then shelled out, then a chars // 4 fallback) so the
metric matches the rest of the builder and runs under a bare python3.
Output contract (one line of JSON on stdout, the pinned prepass shape):
{
"agent_type": "stateless" | "memory" | "autonomous",
"is_memory_agent": bool, # true for memory and autonomous
"skill_md_tokens": int,
"files": [{"path": str, "tokens": int}, ...]
}
Read-only over the target agent directory. It opens files to count and classify
and writes nothing inside the agent tree.
Usage:
prepass.py <agent-dir> classify and count the agent at this directory
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
# Directories we never descend into while counting agent files.
SKIP_DIRS = {".git", "__pycache__", ".pytest_cache", "node_modules", ".venv", "venv"}
# Extensions we treat as countable text. Binary or opaque assets are skipped.
TEXT_SUFFIXES = {
".md", ".py", ".toml", ".yaml", ".yml", ".json", ".txt",
".csv", ".html", ".sh", ".cfg", ".ini",
}
# --- token counting ---------------------------------------------------------
def _count_via_import(text: str):
"""Count tokens by importing the sibling count_tokens module."""
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
try:
import count_tokens # type: ignore
except Exception:
return None
try:
tokens, _method = count_tokens.count_tokens(text)
return int(tokens)
except Exception:
return None
def _count_via_shell(text: str):
"""Count tokens by shelling out to count_tokens.py with text on stdin."""
script = SCRIPT_DIR / "count_tokens.py"
if not script.exists():
return None
try:
proc = subprocess.run(
[sys.executable, str(script), "--stdin"],
input=text,
capture_output=True,
text=True,
timeout=60,
)
except Exception:
return None
if proc.returncode != 0:
return None
try:
return int(json.loads(proc.stdout)["tokens"])
except Exception:
return None
def count_tokens(text: str) -> int:
"""Token length of text via count_tokens.py, falling back to chars // 4.
Prefers importing the vendored count_tokens module, then shelling out to it,
then a bare character estimate so the pre-pass always produces a number.
"""
for counter in (_count_via_import, _count_via_shell):
result = counter(text)
if result is not None:
return result
return len(text) // 4
def read_text(path: Path) -> str:
try:
return path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return ""
# --- agent classification ---------------------------------------------------
def iter_files(root: Path):
"""Yield countable text files under root, skipping noise directories."""
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
if any(part in SKIP_DIRS for part in path.relative_to(root).parts):
continue
if path.suffix.lower() in TEXT_SUFFIXES:
yield path
def has_sanctum(root: Path, skill_text: str) -> bool:
"""True when the agent reloads a runtime sanctum on waking (a memory agent).
The sanctum is the built agent's memory at `_bmad/memory/{skillName}/`. We
treat any of these as a sanctum signal: the SKILL referencing that memory
path, the Sacred-Truth / waking bootloader language, a wake or init-sanctum
scaffolder, or the sanctum template assets (PERSONA / CREED / BOND / MEMORY
/ INDEX / CAPABILITIES). This is the built agent's memory, distinct from the
builder's .memlog.md, which is never a sanctum signal.
"""
if re.search(r"_bmad/memory/", skill_text):
return True
if re.search(r"\bsanctum\b", skill_text, re.IGNORECASE):
return True
if "Sacred Truth" in skill_text and re.search(r"\b(waking|wake)\b", skill_text, re.IGNORECASE):
return True
for pattern in ("scripts/wake*", "scripts/init-sanctum*"):
for script in root.glob(pattern):
if script.is_file():
return True
sanctum_seed = re.compile(
r"^(PERSONA|CREED|BOND|MEMORY|INDEX|CAPABILITIES)-template\.md$"
)
assets = root / "assets"
if assets.is_dir():
for asset in assets.iterdir():
if asset.is_file() and sanctum_seed.match(asset.name):
return True
return False
def has_autonomous_wake(root: Path, skill_text: str) -> bool:
"""True when a memory agent also carries live autonomous wake behavior.
Autonomous is memory plus a PULSE-driven wake: a deployed PULSE.md, a
pulse/autonomous-wake reference, or SKILL wake routing (named-task pulse
routing, a default wake behavior, quiet hours, or a wake frequency).
The standard memory bootloader already names a Pulse Mode (`--pulse`) path
that loads PULSE.md, and ships a PULSE template asset, in every memory
agent. Those are seeds, not live wake behavior, so neither the bootloader's
Pulse-Mode line nor a PULSE template asset counts here. The wake behavior
must be deployed: a real PULSE.md, a wake reference file, or SKILL routing
that names tasks or schedules a recurring wake.
"""
if (root / "PULSE.md").is_file():
return True
refs = root / "references"
if refs.is_dir():
for ref in refs.iterdir():
name = ref.name.lower()
if ref.is_file() and ("pulse-wake" in name or "autonomous-wake" in name):
return True
wake_signals = [
r"--pulse:\{", # named-task pulse routing
r"-p:\{", # short-flag named-task routing
r"default pulse wake",
r"default wake behavior",
r"\bquiet hours\b",
r"wake frequency",
r"autonomous wake",
]
for pattern in wake_signals:
if re.search(pattern, skill_text, re.IGNORECASE):
return True
return False
def classify(root: Path, skill_text: str) -> str:
"""Return the agent_type along the gradient."""
if not has_sanctum(root, skill_text):
return "stateless"
if has_autonomous_wake(root, skill_text):
return "autonomous"
return "memory"
# --- main -------------------------------------------------------------------
def build_payload(root: Path) -> dict:
skill_path = root / "SKILL.md"
skill_text = read_text(skill_path) if skill_path.is_file() else ""
agent_type = classify(root, skill_text)
is_memory_agent = agent_type in ("memory", "autonomous")
files = []
skill_md_tokens = 0
for path in iter_files(root):
tokens = count_tokens(read_text(path))
rel = path.relative_to(root).as_posix()
files.append({"path": rel, "tokens": tokens})
if path == skill_path:
skill_md_tokens = tokens
return {
"agent_type": agent_type,
"is_memory_agent": is_memory_agent,
"skill_md_tokens": skill_md_tokens,
"files": files,
}
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("agent_dir", help="path to the agent skill directory to analyze")
args = p.parse_args(argv)
root = Path(args.agent_dir).expanduser().resolve()
if not root.is_dir():
p.error(f"not a directory: {root}")
print(json.dumps(build_payload(root)))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Process BMad agent template files.
Performs deterministic variable substitution and conditional block processing
on template files from assets/. Replaces {varName} placeholders with provided
values and evaluates {if-X}...{/if-X} conditional blocks, keeping content
when the condition is in the --true list and removing the entire block otherwise.
Any {if-X} or {/if-X} marker still present after processing is a defect (a
malformed or mismatched block the emitted agent would ship verbatim): the
script exits 3 and names the markers. Remaining {token} placeholders are
reported in the --json metadata as tokens_remaining, not failed, because they
may be runtime-resolution tokens such as {project-root} or {agent.<name>} —
the builder judges that list against the build-time token set.
"""
# /// script
# requires-python = ">=3.9"
# ///
from __future__ import annotations
import argparse
import json
import re
import sys
def process_conditionals(text: str, true_conditions: set[str]) -> tuple[str, list[str], list[str]]:
"""Process {if-X}...{/if-X} conditional blocks, innermost first.
Returns (processed_text, conditions_true, conditions_false).
"""
conditions_true: list[str] = []
conditions_false: list[str] = []
# Process innermost blocks first to handle nesting
pattern = re.compile(
r'\{if-([a-zA-Z0-9_-]+)\}(.*?)\{/if-\1\}',
re.DOTALL,
)
changed = True
while changed:
changed = False
match = pattern.search(text)
if match:
changed = True
condition = match.group(1)
inner = match.group(2)
if condition in true_conditions:
# Keep the inner content, strip the markers
# Remove a leading newline if the opening tag was on its own line
replacement = inner
if condition not in conditions_true:
conditions_true.append(condition)
else:
# Remove the entire block
replacement = ''
if condition not in conditions_false:
conditions_false.append(condition)
text = text[:match.start()] + replacement + text[match.end():]
# Clean up blank lines left by removed blocks: collapse 3+ consecutive
# newlines down to 2 (one blank line)
text = re.sub(r'\n{3,}', '\n\n', text)
return text, conditions_true, conditions_false
def process_variables(text: str, variables: dict[str, str]) -> tuple[str, list[str]]:
"""Replace {varName} placeholders with provided values.
Only replaces variables that are in the provided mapping.
Leaves unmatched {variables} untouched (they may be runtime config).
Returns (processed_text, list_of_substituted_var_names).
"""
substituted: list[str] = []
for name, value in variables.items():
placeholder = '{' + name + '}'
if placeholder in text:
text = text.replace(placeholder, value)
if name not in substituted:
substituted.append(name)
return text, substituted
def parse_var(s: str) -> tuple[str, str]:
"""Parse a key=value string. Raises argparse error on bad format."""
if '=' not in s:
raise argparse.ArgumentTypeError(
f"Invalid variable format: '{s}' (expected key=value)"
)
key, _, value = s.partition('=')
if not key:
raise argparse.ArgumentTypeError(
f"Invalid variable format: '{s}' (empty key)"
)
return key, value
def main() -> int:
parser = argparse.ArgumentParser(
description='Process BMad agent template files with variable substitution and conditional blocks.',
)
parser.add_argument(
'template',
help='Path to the template file to process',
)
parser.add_argument(
'-o', '--output',
help='Write processed output to file (default: stdout)',
)
parser.add_argument(
'--var',
action='append',
default=[],
metavar='key=value',
help='Variable substitution (repeatable). Example: --var skillName=my-agent',
)
parser.add_argument(
'--true',
action='append',
default=[],
dest='true_conditions',
metavar='CONDITION',
help='Condition name to treat as true (repeatable). Example: --true pulse --true evolvable',
)
parser.add_argument(
'--json',
action='store_true',
dest='json_output',
help='Output processing metadata as JSON to stderr',
)
args = parser.parse_args()
# Parse variables
variables: dict[str, str] = {}
for v in args.var:
try:
key, value = parse_var(v)
except argparse.ArgumentTypeError as e:
print(f"Error: {e}", file=sys.stderr)
return 2
variables[key] = value
true_conditions = set(args.true_conditions)
# Read template
try:
with open(args.template, encoding='utf-8') as f:
content = f.read()
except FileNotFoundError:
print(f"Error: Template file not found: {args.template}", file=sys.stderr)
return 2
except OSError as e:
print(f"Error reading template: {e}", file=sys.stderr)
return 1
# Process: conditionals first, then variables
content, conds_true, conds_false = process_conditionals(content, true_conditions)
content, vars_substituted = process_variables(content, variables)
# Leftover conditional markers mean a malformed/mismatched block that
# would ship verbatim in the emitted agent.
leftover_markers = sorted(set(re.findall(r'\{/?if-[a-zA-Z0-9_-]+\}', content)))
if leftover_markers:
print(
f"Error: leftover conditional markers after processing: {', '.join(leftover_markers)}",
file=sys.stderr,
)
return 3
tokens_remaining = sorted(set(re.findall(r'\{[a-zA-Z][a-zA-Z0-9_.-]*\}', content)))
# Write output
output_file = args.output
try:
if output_file:
with open(output_file, 'w', encoding='utf-8') as f:
f.write(content)
else:
sys.stdout.write(content)
except OSError as e:
print(f"Error writing output: {e}", file=sys.stderr)
return 1
# JSON metadata to stderr
if args.json_output:
metadata = {
'processed': True,
'output_file': output_file or '<stdout>',
'vars_substituted': vars_substituted,
'conditions_true': conds_true,
'conditions_false': conds_false,
'tokens_remaining': tokens_remaining,
}
print(json.dumps(metadata, indent=2), file=sys.stderr)
return 0
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,387 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# ///
"""Render the analysis report deterministically from findings JSON.
Injects a validated findings JSON object into the report shell's
report-data island and writes the self-contained HTML atomically.
With --md, also writes a markdown rendering of the same data as the
archival artifact.
Refuses (non-zero exit, message on stderr) when the JSON does not
parse, fails shape validation, or still carries the shell's
placeholder subject — a refused render means fix the findings file
and re-run, never hand-edit the HTML.
Usage:
uv run render_report.py <findings.json> --shell <report-shell.html> \
-o <out.html> [--md <out.md>]
On success prints one JSON line: output paths, grade, and severity
counts derived from the findings array.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import tempfile
from pathlib import Path
SEVERITIES = ("critical", "high", "medium", "low")
GRADES = ("excellent", "good", "fair", "poor")
PLACEHOLDER_SUBJECT = "__PLACEHOLDER__"
ISLAND_RE = re.compile(
r'(<script[^>]*\bid="report-data"[^>]*>)(.*?)(</script>)', re.DOTALL
)
def fail(message: str) -> None:
print(f"render_report: {message}", file=sys.stderr)
sys.exit(1)
def validate(data: object) -> list[str]:
"""Return a list of shape errors; empty list means valid."""
if not isinstance(data, dict):
return ["top level must be a JSON object"]
errors: list[str] = []
subject = data.get("subject")
if not isinstance(subject, str) or not subject.strip():
errors.append('"subject" must be a non-empty string')
elif PLACEHOLDER_SUBJECT in subject:
errors.append(
f'"subject" still carries the placeholder {PLACEHOLDER_SUBJECT}; '
"this is the unfilled shell sample, not real findings"
)
findings = data.get("findings")
if not isinstance(findings, list):
errors.append('"findings" must be an array (use [] for a clean pass)')
else:
for i, finding in enumerate(findings):
if not isinstance(finding, dict):
errors.append(f"findings[{i}] must be an object")
grade = data.get("grade")
if grade is not None and grade not in GRADES:
errors.append(f'"grade" must be one of: {", ".join(GRADES)}')
for key in ("themes", "recommendations"):
value = data.get(key)
if value is not None and (
not isinstance(value, list)
or any(not isinstance(item, dict) for item in value)
):
errors.append(f'"{key}" must be an array of objects')
strengths = data.get("strengths")
if strengths is not None and (
not isinstance(strengths, list)
or any(not isinstance(item, str) for item in strengths)
):
errors.append('"strengths" must be an array of strings')
return errors
def severity_counts(findings: list[dict]) -> dict[str, int]:
counts = {sev: 0 for sev in SEVERITIES}
for finding in findings:
sev = finding.get("severity")
counts[sev if sev in counts else "low"] += 1
return counts
def inject(shell_html: str, data: dict) -> str:
payload = json.dumps(data, ensure_ascii=False, indent=2)
# A "</" sequence inside a JSON string would close the script tag
# early in the browser; "<\/" is the same string to JSON.parse.
payload = payload.replace("</", "<\\/")
def replace(match: re.Match) -> str:
return match.group(1) + "\n" + payload + "\n" + match.group(3)
new_html, count = ISLAND_RE.subn(replace, shell_html, count=1)
if count != 1:
fail('shell has no <script id="report-data"> island to fill')
return new_html
def atomic_write(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(
dir=path.parent, prefix=path.name + ".", suffix=".tmp"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _finding_lines(finding: dict, heading_level: str) -> list[str]:
fid = str(finding.get("id", ""))
title = str(finding.get("title", "(untitled finding)"))
lines = [f"{heading_level} {fid}{title}" if fid else f"{heading_level} {title}", ""]
for key, label in (
("lens", "Lens"),
("location", "Location"),
("evidence", "Evidence"),
("recommendation", "Recommendation"),
("proposed_smallest", "Proposed smallest"),
("predicted_delta", "Predicted delta"),
):
value = finding.get(key)
if value:
value = f"`{value}`" if key == "location" else str(value)
lines.append(f"- {label}: {value}")
lines.append("")
return lines
def render_md(data: dict) -> str:
findings = [f for f in data.get("findings", []) if isinstance(f, dict)]
by_id = {str(f.get("id")): f for f in findings if f.get("id") is not None}
counts = severity_counts(findings)
lines: list[str] = []
lines.append(f"# Analysis Report: {data.get('subject', '')}")
lines.append("")
meta = []
if data.get("generated"):
meta.append(f"Generated: {data['generated']}")
if data.get("schema_version") is not None:
meta.append(f"Schema: {data['schema_version']}")
if meta:
lines.append(" · ".join(meta))
lines.append("")
if data.get("grade"):
lines.append(f"**Grade: {str(data['grade']).capitalize()}**")
lines.append("")
if data.get("verdict"):
lines.append(f"> {data['verdict']}")
lines.append("")
summary = data.get("summary")
if isinstance(summary, str) and summary:
lines.append(summary)
lines.append("")
lines.append("| Severity | Count |")
lines.append("| --- | --- |")
for sev in SEVERITIES:
lines.append(f"| {sev.capitalize()} | {counts[sev]} |")
lines.append("")
themes = data.get("themes") or []
if themes:
lines.append("## Themes")
lines.append("")
for i, theme in enumerate(themes, 1):
lines.append(f"### {i}. {theme.get('title', '(untitled theme)')}")
lines.append("")
if theme.get("root_cause"):
lines.append(f"- Root cause: {theme['root_cause']}")
if theme.get("action"):
lines.append(f"- Fix: {theme['action']}")
ids = theme.get("finding_ids") or []
if ids:
lines.append("- Findings:")
for fid in ids:
finding = by_id.get(str(fid))
if finding:
loc = finding.get("location")
suffix = f" — `{loc}`" if loc else ""
lines.append(
f" - `{fid}` {finding.get('title', '')}{suffix}"
)
else:
lines.append(f" - `{fid}`")
lines.append("")
strengths = data.get("strengths") or []
if strengths:
lines.append("## Strengths")
lines.append("")
for strength in strengths:
lines.append(f"- {strength}")
lines.append("")
recommendations = data.get("recommendations") or []
if recommendations:
lines.append("## Recommendations")
lines.append("")
for i, rec in enumerate(recommendations, 1):
rank = rec.get("rank", i)
resolves = rec.get("resolves")
if isinstance(resolves, list) and resolves:
suffix = " (resolves: " + ", ".join(map(str, resolves)) + ")"
elif isinstance(resolves, (int, float)):
suffix = f" (resolves {int(resolves)} findings)"
else:
suffix = ""
lines.append(f"{rank}. {rec.get('action', '')}{suffix}")
lines.append("")
# Optional agent blocks: rendered only when present so the same
# renderer serves both the workflow and agent schemas.
profile = data.get("agent_profile")
if isinstance(profile, dict) and any(profile.values()):
lines.append("## Agent Profile")
lines.append("")
for key, label in (
("name", "Name"),
("title", "Title"),
("agent_type", "Type"),
("mission", "Mission"),
):
if profile.get(key):
lines.append(f"- {label}: {profile[key]}")
lines.append("")
capabilities = data.get("capabilities")
if isinstance(capabilities, list) and capabilities:
lines.append("## Capabilities")
lines.append("")
for cap in capabilities:
if not isinstance(cap, dict) or not cap.get("name"):
continue
kind = f" ({cap['kind']})" if cap.get("kind") else ""
note = f"{cap['note']}" if cap.get("note") else ""
lines.append(f"- **{cap['name']}**{kind}{note}")
lines.append("")
detailed = data.get("detailed_analysis")
if isinstance(detailed, dict) and detailed:
lines.append("## Per-Lens Verdicts")
lines.append("")
for lens, verdict in detailed.items():
if verdict:
lines.append(f"- **{lens}**: {verdict}")
lines.append("")
sanctum = data.get("sanctum")
if isinstance(sanctum, dict) and sanctum.get("present") is not False:
rows = []
if sanctum.get("location"):
rows.append(f"- Location: `{sanctum['location']}`")
files = sanctum.get("files") or []
if files:
rows.append("- Files: " + ", ".join(f"`{f}`" for f in files))
if sanctum.get("note"):
rows.append(f"- Note: {sanctum['note']}")
if rows:
lines.append("## Sanctum (runtime memory)")
lines.append("")
lines.extend(rows)
lines.append("")
experience = data.get("experience")
if isinstance(experience, dict):
journeys = [
j for j in experience.get("journeys") or [] if isinstance(j, dict)
]
headless = experience.get("headless")
if journeys or headless:
lines.append("## Experience")
lines.append("")
for journey in journeys:
steps = f"{journey['steps']}" if journey.get("steps") else ""
lines.append(f"- **{journey.get('name', '(unnamed journey)')}**{steps}")
if headless:
lines.append(f"- Headless: {headless}")
lines.append("")
lines.append("## Findings")
lines.append("")
if not findings:
lines.append("No findings: the scanners returned a clean pass.")
lines.append("")
else:
for sev in SEVERITIES:
group = [
f
for f in findings
if (f.get("severity") if f.get("severity") in SEVERITIES else "low")
== sev
]
if not group:
continue
lines.append(f"### {sev.capitalize()} ({len(group)})")
lines.append("")
for finding in group:
lines.extend(_finding_lines(finding, "####"))
return "\n".join(lines).rstrip() + "\n"
def main() -> int:
parser = argparse.ArgumentParser(
description="Inject findings JSON into the report shell and render HTML (+ optional markdown)."
)
parser.add_argument("findings", type=Path, help="path to findings.json")
parser.add_argument(
"--shell", type=Path, required=True, help="path to report-shell.html"
)
parser.add_argument(
"-o", "--output", type=Path, required=True, help="output HTML path"
)
parser.add_argument(
"--md", type=Path, help="also write a markdown rendering to this path"
)
args = parser.parse_args()
try:
raw = args.findings.read_text(encoding="utf-8")
except OSError as err:
fail(f"cannot read {args.findings}: {err}")
try:
data = json.loads(raw)
except json.JSONDecodeError as err:
fail(f"{args.findings} is not valid JSON: {err}")
errors = validate(data)
if errors:
fail(
f"{args.findings} failed shape validation:\n - "
+ "\n - ".join(errors)
)
try:
shell_html = args.shell.read_text(encoding="utf-8")
except OSError as err:
fail(f"cannot read shell {args.shell}: {err}")
atomic_write(args.output, inject(shell_html, data))
if args.md:
atomic_write(args.md, render_md(data))
findings = [f for f in data.get("findings", []) if isinstance(f, dict)]
print(
json.dumps(
{
"html_report": str(args.output),
"md_report": str(args.md) if args.md else None,
"grade": data.get("grade"),
"counts": severity_counts(findings),
"findings": len(findings),
}
)
)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,324 @@
#!/usr/bin/env python3
"""Deterministic path standards scanner for BMad skills.
Validates all .md and .json files against BMad path conventions:
1. {project-root} for any project-scope path (not just _bmad)
2. Bare _bmad references must have {project-root} prefix
3. Config variables used directly — no double-prefix with {project-root}
4. ./ only for same-folder references — never ./subdir/ cross-directory
5. No ../ parent directory references
6. No absolute paths
7. Memory paths must use {project-root}/_bmad/memory/{skillName}/
8. Frontmatter allows only name and description
9. No .md files at skill root except SKILL.md
"""
# /// script
# requires-python = ">=3.9"
# ///
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
# Patterns to detect
# Double-prefix: {project-root}/{config-variable} — config vars already contain project-root
DOUBLE_PREFIX_RE = re.compile(r'\{project-root\}/\{[^}]+\}')
# Bare _bmad without {project-root} prefix — match _bmad at word boundary
# but not when preceded by {project-root}/
BARE_BMAD_RE = re.compile(r'(?<!\{project-root\}/)_bmad[/\s]')
# Absolute paths
ABSOLUTE_PATH_RE = re.compile(r'(?:^|[\s"`\'(])(/(?:Users|home|opt|var|tmp|etc|usr)/\S+)', re.MULTILINE)
HOME_PATH_RE = re.compile(r'(?:^|[\s"`\'(])(~/\S+)', re.MULTILINE)
# Parent directory reference (still invalid)
RELATIVE_DOT_RE = re.compile(r'(?:^|[\s"`\'(])(\.\./\S+)', re.MULTILINE)
# Cross-directory ./ — ./subdir/ is wrong because ./ means same folder only
CROSS_DIR_DOT_SLASH_RE = re.compile(r'(?:^|[\s"`\'(])\./(?:references|scripts|assets)/\S+', re.MULTILINE)
# Memory path pattern: should use {project-root}/_bmad/memory/
MEMORY_PATH_RE = re.compile(r'_bmad/memory/\S+')
VALID_MEMORY_PATH_RE = re.compile(r'\{project-root\}/_bmad/memory/[\w-]+/')
# Fenced code block detection (to skip examples showing wrong patterns)
FENCE_RE = re.compile(r'^```', re.MULTILINE)
# Valid frontmatter keys
VALID_FRONTMATTER_KEYS = {'name', 'description'}
def is_in_fenced_block(content: str, pos: int) -> bool:
"""Check if a position is inside a fenced code block."""
fences = [m.start() for m in FENCE_RE.finditer(content[:pos])]
# Odd number of fences before pos means we're inside a block
return len(fences) % 2 == 1
def get_line_number(content: str, pos: int) -> int:
"""Get 1-based line number for a position in content."""
return content[:pos].count('\n') + 1
def check_frontmatter(content: str, filepath: Path) -> list[dict]:
"""Validate SKILL.md frontmatter contains only allowed keys."""
findings = []
if filepath.name != 'SKILL.md':
return findings
if not content.startswith('---'):
findings.append({
'file': filepath.name,
'line': 1,
'severity': 'critical',
'category': 'frontmatter',
'title': 'SKILL.md missing frontmatter block',
'detail': 'SKILL.md must start with --- frontmatter containing name and description',
'action': 'Add frontmatter with name and description fields',
})
return findings
# Find closing ---
end = content.find('\n---', 3)
if end == -1:
findings.append({
'file': filepath.name,
'line': 1,
'severity': 'critical',
'category': 'frontmatter',
'title': 'SKILL.md frontmatter block not closed',
'detail': 'Missing closing --- for frontmatter',
'action': 'Add closing --- after frontmatter fields',
})
return findings
frontmatter = content[4:end]
for i, line in enumerate(frontmatter.split('\n'), start=2):
line = line.strip()
if not line or line.startswith('#'):
continue
if ':' in line:
key = line.split(':', 1)[0].strip()
if key not in VALID_FRONTMATTER_KEYS:
findings.append({
'file': filepath.name,
'line': i,
'severity': 'high',
'category': 'frontmatter',
'title': f'Invalid frontmatter key: {key}',
'detail': f'Only {", ".join(sorted(VALID_FRONTMATTER_KEYS))} are allowed in frontmatter',
'action': f'Remove {key} from frontmatter — use as content field in SKILL.md body instead',
})
return findings
def check_root_md_files(skill_path: Path) -> list[dict]:
"""Check that no .md files exist at skill root except SKILL.md."""
findings = []
for md_file in skill_path.glob('*.md'):
if md_file.name != 'SKILL.md':
findings.append({
'file': md_file.name,
'line': 0,
'severity': 'high',
'category': 'structure',
'title': f'Prompt file at skill root: {md_file.name}',
'detail': 'All progressive disclosure content must be in ./references/ — only SKILL.md belongs at root',
'action': f'Move {md_file.name} to references/{md_file.name}',
})
return findings
def scan_file(filepath: Path, skip_fenced: bool = True) -> list[dict]:
"""Scan a single file for path standard violations."""
findings = []
content = filepath.read_text(encoding='utf-8')
rel_path = filepath.name
checks = [
(DOUBLE_PREFIX_RE, 'double-prefix', 'critical',
'Double-prefix: {project-root}/{variable} — config variables already contain {project-root} at runtime'),
(ABSOLUTE_PATH_RE, 'absolute-path', 'high',
'Absolute path found — not portable across machines'),
(HOME_PATH_RE, 'absolute-path', 'high',
'Home directory path (~/) found — environment-specific'),
(RELATIVE_DOT_RE, 'relative-prefix', 'high',
'Parent directory reference (../) found — fragile, breaks with reorganization'),
(CROSS_DIR_DOT_SLASH_RE, 'cross-dir-dot-slash', 'high',
'Cross-directory ./ reference — ./ means same folder only; use bare skill-root relative path (e.g., references/foo.md not ./references/foo.md)'),
]
for pattern, category, severity, message in checks:
for match in pattern.finditer(content):
pos = match.start()
if skip_fenced and is_in_fenced_block(content, pos):
continue
line_num = get_line_number(content, pos)
line_content = content.split('\n')[line_num - 1].strip()
findings.append({
'file': rel_path,
'line': line_num,
'severity': severity,
'category': category,
'title': message,
'detail': line_content[:120],
'action': '',
})
# Bare _bmad check — more nuanced, need to avoid false positives
# inside {project-root}/_bmad which is correct
for match in BARE_BMAD_RE.finditer(content):
pos = match.start()
if skip_fenced and is_in_fenced_block(content, pos):
continue
start = max(0, pos - 30)
before = content[start:pos]
if '{project-root}/' in before:
continue
line_num = get_line_number(content, pos)
line_content = content.split('\n')[line_num - 1].strip()
findings.append({
'file': rel_path,
'line': line_num,
'severity': 'high',
'category': 'bare-bmad',
'title': 'Bare _bmad reference without {project-root} prefix',
'detail': line_content[:120],
'action': '',
})
# Memory path check — memory paths should use {project-root}/_bmad/memory/{skillName}/
for match in MEMORY_PATH_RE.finditer(content):
pos = match.start()
if skip_fenced and is_in_fenced_block(content, pos):
continue
start = max(0, pos - 20)
before = content[start:pos]
if '{project-root}/' not in before:
line_num = get_line_number(content, pos)
line_content = content.split('\n')[line_num - 1].strip()
findings.append({
'file': rel_path,
'line': line_num,
'severity': 'high',
'category': 'memory-path',
'title': 'Memory path missing {project-root} prefix — use {project-root}/_bmad/memory/',
'detail': line_content[:120],
'action': '',
})
return findings
def scan_skill(skill_path: Path, skip_fenced: bool = True) -> dict:
"""Scan all .md and .json files in a skill directory."""
all_findings = []
# Check for .md files at root that aren't SKILL.md
all_findings.extend(check_root_md_files(skill_path))
# Check SKILL.md frontmatter
skill_md = skill_path / 'SKILL.md'
if skill_md.exists():
content = skill_md.read_text(encoding='utf-8')
all_findings.extend(check_frontmatter(content, skill_md))
# Find all .md and .json files
md_files = sorted(list(skill_path.rglob('*.md')) + list(skill_path.rglob('*.json')))
if not md_files:
print(f"Warning: No .md or .json files found in {skill_path}", file=sys.stderr)
files_scanned = []
for md_file in md_files:
rel = md_file.relative_to(skill_path)
files_scanned.append(str(rel))
file_findings = scan_file(md_file, skip_fenced)
for f in file_findings:
f['file'] = str(rel)
all_findings.extend(file_findings)
# Build summary
by_severity = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
by_category = {
'double_prefix': 0,
'bare_bmad': 0,
'absolute_path': 0,
'relative_prefix': 0,
'cross_dir_dot_slash': 0,
'memory_path': 0,
'frontmatter': 0,
'structure': 0,
}
for f in all_findings:
sev = f['severity']
if sev in by_severity:
by_severity[sev] += 1
cat = f['category'].replace('-', '_')
if cat in by_category:
by_category[cat] += 1
return {
'scanner': 'path-standards',
'script': 'scan-path-standards.py',
'version': '3.0.0',
'skill_path': str(skill_path),
'timestamp': datetime.now(timezone.utc).isoformat(),
'files_scanned': files_scanned,
'status': 'pass' if not all_findings else 'fail',
'findings': all_findings,
'assessments': {},
'summary': {
'total_findings': len(all_findings),
'by_severity': by_severity,
'by_category': by_category,
'assessment': 'Path standards scan complete',
},
}
def main() -> int:
parser = argparse.ArgumentParser(
description='Scan BMad skill for path standard violations',
)
parser.add_argument(
'skill_path',
type=Path,
help='Path to the skill directory to scan',
)
parser.add_argument(
'--output', '-o',
type=Path,
help='Write JSON output to file instead of stdout',
)
parser.add_argument(
'--include-fenced',
action='store_true',
help='Also check inside fenced code blocks (by default they are skipped)',
)
args = parser.parse_args()
if not args.skill_path.is_dir():
print(f"Error: {args.skill_path} is not a directory", file=sys.stderr)
return 2
result = scan_skill(args.skill_path, skip_fenced=not args.include_fenced)
output = json.dumps(result, indent=2)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(output)
print(f"Results written to {args.output}", file=sys.stderr)
else:
print(output)
return 0 if result['status'] == 'pass' else 1
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,747 @@
#!/usr/bin/env python3
"""Deterministic scripts scanner for BMad skills.
Validates scripts in a skill's scripts/ folder for:
- PEP 723 inline dependencies (Python)
- Shebang, set -e, portability (Shell)
- Version pinning for npx/uvx
- Agentic design: no input(), has argparse/--help, JSON output, exit codes
- Unit test existence
- Over-engineering signals (line count, simple-op imports)
- External lint: ruff (Python), shellcheck (Bash), biome (JS/TS)
"""
# /// script
# requires-python = ">=3.9"
# ///
from __future__ import annotations
import argparse
import ast
import json
import re
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
# =============================================================================
# External Linter Integration
# =============================================================================
def _run_command(cmd: list[str], timeout: int = 30) -> tuple[int, str, str]:
"""Run a command and return (returncode, stdout, stderr)."""
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout,
)
return result.returncode, result.stdout, result.stderr
except FileNotFoundError:
return -1, '', f'Command not found: {cmd[0]}'
except subprocess.TimeoutExpired:
return -2, '', f'Command timed out after {timeout}s: {" ".join(cmd)}'
def _find_uv() -> str | None:
"""Find uv binary on PATH."""
return shutil.which('uv')
def _find_npx() -> str | None:
"""Find npx binary on PATH."""
return shutil.which('npx')
def lint_python_ruff(filepath: Path, rel_path: str) -> list[dict]:
"""Run ruff on a Python file via uv. Returns lint findings."""
uv = _find_uv()
if not uv:
return [{
'file': rel_path, 'line': 0,
'severity': 'high', 'category': 'lint-setup',
'title': 'uv not found on PATH — cannot run ruff for Python linting',
'detail': '',
'action': 'Install uv: https://docs.astral.sh/uv/getting-started/installation/',
}]
rc, stdout, stderr = _run_command([
uv, 'run', 'ruff', 'check', '--output-format', 'json', str(filepath),
])
if rc == -1:
return [{
'file': rel_path, 'line': 0,
'severity': 'high', 'category': 'lint-setup',
'title': f'Failed to run ruff via uv: {stderr.strip()}',
'detail': '',
'action': 'Ensure uv can install and run ruff: uv run ruff --version',
}]
if rc == -2:
return [{
'file': rel_path, 'line': 0,
'severity': 'medium', 'category': 'lint',
'title': f'ruff timed out on {rel_path}',
'detail': '',
'action': '',
}]
# ruff outputs JSON array on stdout (even on rc=1 when issues found)
findings = []
try:
issues = json.loads(stdout) if stdout.strip() else []
except json.JSONDecodeError:
return [{
'file': rel_path, 'line': 0,
'severity': 'medium', 'category': 'lint',
'title': f'Failed to parse ruff output for {rel_path}',
'detail': '',
'action': '',
}]
for issue in issues:
fix_msg = issue.get('fix', {}).get('message', '') if issue.get('fix') else ''
findings.append({
'file': rel_path,
'line': issue.get('location', {}).get('row', 0),
'severity': 'high',
'category': 'lint',
'title': f'[{issue.get("code", "?")}] {issue.get("message", "")}',
'detail': '',
'action': fix_msg or f'See https://docs.astral.sh/ruff/rules/{issue.get("code", "")}',
})
return findings
def lint_shell_shellcheck(filepath: Path, rel_path: str) -> list[dict]:
"""Run shellcheck on a shell script via uv. Returns lint findings."""
uv = _find_uv()
if not uv:
return [{
'file': rel_path, 'line': 0,
'severity': 'high', 'category': 'lint-setup',
'title': 'uv not found on PATH — cannot run shellcheck for shell linting',
'detail': '',
'action': 'Install uv: https://docs.astral.sh/uv/getting-started/installation/',
}]
rc, stdout, stderr = _run_command([
uv, 'run', '--with', 'shellcheck-py',
'shellcheck', '--format', 'json', str(filepath),
])
if rc == -1:
return [{
'file': rel_path, 'line': 0,
'severity': 'high', 'category': 'lint-setup',
'title': f'Failed to run shellcheck via uv: {stderr.strip()}',
'detail': '',
'action': 'Ensure uv can install shellcheck-py: uv run --with shellcheck-py shellcheck --version',
}]
if rc == -2:
return [{
'file': rel_path, 'line': 0,
'severity': 'medium', 'category': 'lint',
'title': f'shellcheck timed out on {rel_path}',
'detail': '',
'action': '',
}]
findings = []
# shellcheck outputs JSON on stdout (rc=1 when issues found)
raw = stdout.strip() or stderr.strip()
try:
issues = json.loads(raw) if raw else []
except json.JSONDecodeError:
return [{
'file': rel_path, 'line': 0,
'severity': 'medium', 'category': 'lint',
'title': f'Failed to parse shellcheck output for {rel_path}',
'detail': '',
'action': '',
}]
# Map shellcheck levels to our severity
level_map = {'error': 'high', 'warning': 'high', 'info': 'high', 'style': 'medium'}
for issue in issues:
sc_code = issue.get('code', '')
findings.append({
'file': rel_path,
'line': issue.get('line', 0),
'severity': level_map.get(issue.get('level', ''), 'high'),
'category': 'lint',
'title': f'[SC{sc_code}] {issue.get("message", "")}',
'detail': '',
'action': f'See https://www.shellcheck.net/wiki/SC{sc_code}',
})
return findings
def lint_node_biome(filepath: Path, rel_path: str) -> list[dict]:
"""Run biome on a JS/TS file via npx. Returns lint findings."""
npx = _find_npx()
if not npx:
return [{
'file': rel_path, 'line': 0,
'severity': 'high', 'category': 'lint-setup',
'title': 'npx not found on PATH — cannot run biome for JS/TS linting',
'detail': '',
'action': 'Install Node.js 20+: https://nodejs.org/',
}]
rc, stdout, stderr = _run_command([
npx, '--yes', '@biomejs/biome', 'lint', '--reporter', 'json', str(filepath),
], timeout=60)
if rc == -1:
return [{
'file': rel_path, 'line': 0,
'severity': 'high', 'category': 'lint-setup',
'title': f'Failed to run biome via npx: {stderr.strip()}',
'detail': '',
'action': 'Ensure npx can run biome: npx @biomejs/biome --version',
}]
if rc == -2:
return [{
'file': rel_path, 'line': 0,
'severity': 'medium', 'category': 'lint',
'title': f'biome timed out on {rel_path}',
'detail': '',
'action': '',
}]
findings = []
# biome outputs JSON on stdout
raw = stdout.strip()
try:
result = json.loads(raw) if raw else {}
except json.JSONDecodeError:
return [{
'file': rel_path, 'line': 0,
'severity': 'medium', 'category': 'lint',
'title': f'Failed to parse biome output for {rel_path}',
'detail': '',
'action': '',
}]
for diag in result.get('diagnostics', []):
loc = diag.get('location', {})
start = loc.get('start', {})
findings.append({
'file': rel_path,
'line': start.get('line', 0),
'severity': 'high',
'category': 'lint',
'title': f'[{diag.get("category", "?")}] {diag.get("message", "")}',
'detail': '',
'action': diag.get('advices', [{}])[0].get('message', '') if diag.get('advices') else '',
})
return findings
# =============================================================================
# BMad Pattern Checks (Existing)
# =============================================================================
def scan_python_script(filepath: Path, rel_path: str) -> list[dict]:
"""Check a Python script for standards compliance."""
findings = []
content = filepath.read_text(encoding='utf-8')
lines = content.split('\n')
line_count = len(lines)
# PEP 723 check
if '# /// script' not in content:
# Only flag if the script has imports (not a trivial script)
if 'import ' in content:
findings.append({
'file': rel_path, 'line': 1,
'severity': 'medium', 'category': 'dependencies',
'title': 'No PEP 723 inline dependency block (# /// script)',
'detail': '',
'action': 'Add PEP 723 block with requires-python and dependencies',
})
else:
# Check requires-python is present
if 'requires-python' not in content:
findings.append({
'file': rel_path, 'line': 1,
'severity': 'low', 'category': 'dependencies',
'title': 'PEP 723 block exists but missing requires-python constraint',
'detail': '',
'action': 'Add requires-python = ">=3.9" or appropriate version',
})
# Legacy dep-management reference (use concatenation to avoid self-detection)
req_marker = 'requirements' + '.txt'
pip_marker = 'pip ' + 'install'
if req_marker in content or pip_marker in content:
findings.append({
'file': rel_path, 'line': 1,
'severity': 'high', 'category': 'dependencies',
'title': f'References {req_marker} or {pip_marker} — use PEP 723 inline deps',
'detail': '',
'action': 'Replace with PEP 723 inline dependency block',
})
# Agentic design checks via AST
try:
tree = ast.parse(content)
except SyntaxError:
findings.append({
'file': rel_path, 'line': 1,
'severity': 'critical', 'category': 'error-handling',
'title': 'Python syntax error — script cannot be parsed',
'detail': '',
'action': '',
})
return findings
has_argparse = False
has_json_dumps = False
has_sys_exit = False
imports = set()
for node in ast.walk(tree):
# Track imports
if isinstance(node, ast.Import):
for alias in node.names:
imports.add(alias.name)
elif isinstance(node, ast.ImportFrom):
if node.module:
imports.add(node.module)
# input() calls
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id == 'input':
findings.append({
'file': rel_path, 'line': node.lineno,
'severity': 'critical', 'category': 'agentic-design',
'title': 'input() call found — blocks in non-interactive agent execution',
'detail': '',
'action': 'Use argparse with required flags instead of interactive prompts',
})
# json.dumps
if isinstance(func, ast.Attribute) and func.attr == 'dumps':
has_json_dumps = True
# sys.exit
if isinstance(func, ast.Attribute) and func.attr == 'exit':
has_sys_exit = True
if isinstance(func, ast.Name) and func.id == 'exit':
has_sys_exit = True
# argparse
if isinstance(node, ast.Attribute) and node.attr == 'ArgumentParser':
has_argparse = True
if not has_argparse and line_count > 20:
findings.append({
'file': rel_path, 'line': 1,
'severity': 'medium', 'category': 'agentic-design',
'title': 'No argparse found — script lacks --help self-documentation',
'detail': '',
'action': 'Add argparse with description and argument help text',
})
if not has_json_dumps and line_count > 20:
findings.append({
'file': rel_path, 'line': 1,
'severity': 'medium', 'category': 'agentic-design',
'title': 'No json.dumps found — output may not be structured JSON',
'detail': '',
'action': 'Use json.dumps for structured output parseable by workflows',
})
if not has_sys_exit and line_count > 20:
findings.append({
'file': rel_path, 'line': 1,
'severity': 'low', 'category': 'agentic-design',
'title': 'No sys.exit() calls — may not return meaningful exit codes',
'detail': '',
'action': 'Return 0=success, 1=fail, 2=error via sys.exit()',
})
# Over-engineering: simple file ops in Python
simple_op_imports = {'shutil', 'glob', 'fnmatch'}
over_eng = imports & simple_op_imports
if over_eng and line_count < 30:
findings.append({
'file': rel_path, 'line': 1,
'severity': 'low', 'category': 'over-engineered',
'title': f'Short script ({line_count} lines) imports {", ".join(over_eng)} — may be simpler as bash',
'detail': '',
'action': 'Consider if cp/mv/find shell commands would suffice',
})
# Very short script
if line_count < 5:
findings.append({
'file': rel_path, 'line': 1,
'severity': 'medium', 'category': 'over-engineered',
'title': f'Script is only {line_count} lines — could be an inline command',
'detail': '',
'action': 'Consider inlining this command directly in the prompt',
})
return findings
def scan_shell_script(filepath: Path, rel_path: str) -> list[dict]:
"""Check a shell script for standards compliance."""
findings = []
content = filepath.read_text(encoding='utf-8')
lines = content.split('\n')
line_count = len(lines)
# Shebang
if not lines[0].startswith('#!'):
findings.append({
'file': rel_path, 'line': 1,
'severity': 'high', 'category': 'portability',
'title': 'Missing shebang line',
'detail': '',
'action': 'Add #!/usr/bin/env bash or #!/usr/bin/env sh',
})
elif '/usr/bin/env' not in lines[0]:
findings.append({
'file': rel_path, 'line': 1,
'severity': 'medium', 'category': 'portability',
'title': f'Shebang uses hardcoded path: {lines[0].strip()}',
'detail': '',
'action': 'Use #!/usr/bin/env bash for cross-platform compatibility',
})
# set -e
if 'set -e' not in content and 'set -euo' not in content:
findings.append({
'file': rel_path, 'line': 1,
'severity': 'medium', 'category': 'error-handling',
'title': 'Missing set -e — errors will be silently ignored',
'detail': '',
'action': 'Add set -e (or set -euo pipefail) near the top',
})
# Hardcoded interpreter paths
hardcoded_re = re.compile(r'/usr/bin/(python|ruby|node|perl)\b')
for i, line in enumerate(lines, 1):
if hardcoded_re.search(line):
findings.append({
'file': rel_path, 'line': i,
'severity': 'medium', 'category': 'portability',
'title': f'Hardcoded interpreter path: {line.strip()}',
'detail': '',
'action': 'Use /usr/bin/env or PATH-based lookup',
})
# GNU-only tools
gnu_re = re.compile(r'\b(gsed|gawk|ggrep|gfind)\b')
for i, line in enumerate(lines, 1):
m = gnu_re.search(line)
if m:
findings.append({
'file': rel_path, 'line': i,
'severity': 'medium', 'category': 'portability',
'title': f'GNU-only tool: {m.group()} — not available on all platforms',
'detail': '',
'action': 'Use POSIX-compatible equivalent',
})
# Unquoted variables (basic check)
unquoted_re = re.compile(r'(?<!")\$\w+(?!")')
for i, line in enumerate(lines, 1):
if line.strip().startswith('#'):
continue
for m in unquoted_re.finditer(line):
# Skip inside double-quoted strings (rough heuristic)
before = line[:m.start()]
if before.count('"') % 2 == 1:
continue
findings.append({
'file': rel_path, 'line': i,
'severity': 'low', 'category': 'portability',
'title': f'Potentially unquoted variable: {m.group()} — breaks with spaces in paths',
'detail': '',
'action': f'Use "{m.group()}" with double quotes',
})
# npx/uvx without version pinning
no_pin_re = re.compile(r'\b(npx|uvx)\s+([a-zA-Z][\w-]+)(?!\S*@)')
for i, line in enumerate(lines, 1):
if line.strip().startswith('#'):
continue
m = no_pin_re.search(line)
if m:
findings.append({
'file': rel_path, 'line': i,
'severity': 'medium', 'category': 'dependencies',
'title': f'{m.group(1)} {m.group(2)} without version pinning',
'detail': '',
'action': f'Pin version: {m.group(1)} {m.group(2)}@<version>',
})
# Very short script
if line_count < 5:
findings.append({
'file': rel_path, 'line': 1,
'severity': 'medium', 'category': 'over-engineered',
'title': f'Script is only {line_count} lines — could be an inline command',
'detail': '',
'action': 'Consider inlining this command directly in the prompt',
})
return findings
def scan_node_script(filepath: Path, rel_path: str) -> list[dict]:
"""Check a JS/TS script for standards compliance."""
findings = []
content = filepath.read_text(encoding='utf-8')
lines = content.split('\n')
line_count = len(lines)
# npx/uvx without version pinning
no_pin = re.compile(r'\b(npx|uvx)\s+([a-zA-Z][\w-]+)(?!\S*@)')
for i, line in enumerate(lines, 1):
m = no_pin.search(line)
if m:
findings.append({
'file': rel_path, 'line': i,
'severity': 'medium', 'category': 'dependencies',
'title': f'{m.group(1)} {m.group(2)} without version pinning',
'detail': '',
'action': f'Pin version: {m.group(1)} {m.group(2)}@<version>',
})
# Very short script
if line_count < 5:
findings.append({
'file': rel_path, 'line': 1,
'severity': 'medium', 'category': 'over-engineered',
'title': f'Script is only {line_count} lines — could be an inline command',
'detail': '',
'action': 'Consider inlining this command directly in the prompt',
})
return findings
# =============================================================================
# Main Scanner
# =============================================================================
def scan_skill_scripts(skill_path: Path) -> dict:
"""Scan all scripts in a skill directory."""
scripts_dir = skill_path / 'scripts'
all_findings = []
lint_findings = []
script_inventory = {'python': [], 'shell': [], 'node': [], 'other': []}
missing_tests = []
if not scripts_dir.exists():
return {
'scanner': 'scripts',
'script': 'scan-scripts.py',
'version': '2.0.0',
'skill_path': str(skill_path),
'timestamp': datetime.now(timezone.utc).isoformat(),
'status': 'pass',
'findings': [{
'file': 'scripts/',
'severity': 'info',
'category': 'none',
'title': 'No scripts/ directory found — nothing to scan',
'detail': '',
'action': '',
}],
'assessments': {
'lint_summary': {
'tools_used': [],
'files_linted': 0,
'lint_issues': 0,
},
'script_summary': {
'total_scripts': 0,
'by_type': script_inventory,
'missing_tests': [],
},
},
'summary': {
'total_findings': 0,
'by_severity': {'critical': 0, 'high': 0, 'medium': 0, 'low': 0},
'assessment': '',
},
}
# Find all script files (exclude tests/ and __pycache__)
script_files = []
for f in sorted(scripts_dir.iterdir()):
if f.is_file() and f.suffix in ('.py', '.sh', '.bash', '.js', '.ts', '.mjs'):
script_files.append(f)
tests_dir = scripts_dir / 'tests'
lint_tools_used = set()
for script_file in script_files:
rel_path = f'scripts/{script_file.name}'
ext = script_file.suffix
if ext == '.py':
script_inventory['python'].append(script_file.name)
findings = scan_python_script(script_file, rel_path)
lf = lint_python_ruff(script_file, rel_path)
lint_findings.extend(lf)
if lf and not any(f['category'] == 'lint-setup' for f in lf):
lint_tools_used.add('ruff')
elif ext in ('.sh', '.bash'):
script_inventory['shell'].append(script_file.name)
findings = scan_shell_script(script_file, rel_path)
lf = lint_shell_shellcheck(script_file, rel_path)
lint_findings.extend(lf)
if lf and not any(f['category'] == 'lint-setup' for f in lf):
lint_tools_used.add('shellcheck')
elif ext in ('.js', '.ts', '.mjs'):
script_inventory['node'].append(script_file.name)
findings = scan_node_script(script_file, rel_path)
lf = lint_node_biome(script_file, rel_path)
lint_findings.extend(lf)
if lf and not any(f['category'] == 'lint-setup' for f in lf):
lint_tools_used.add('biome')
else:
script_inventory['other'].append(script_file.name)
findings = []
# Check for unit tests
if tests_dir.exists():
stem = script_file.stem
test_patterns = [
f'test_{stem}{ext}', f'test-{stem}{ext}',
f'{stem}_test{ext}', f'{stem}-test{ext}',
f'test_{stem}.py', f'test-{stem}.py',
]
has_test = any((tests_dir / t).exists() for t in test_patterns)
else:
has_test = False
if not has_test:
missing_tests.append(script_file.name)
findings.append({
'file': rel_path, 'line': 1,
'severity': 'medium', 'category': 'tests',
'title': f'No unit test found for {script_file.name}',
'detail': '',
'action': f'Create scripts/tests/test-{script_file.stem}{ext} with test cases',
})
all_findings.extend(findings)
# Check if tests/ directory exists at all
if script_files and not tests_dir.exists():
all_findings.append({
'file': 'scripts/tests/',
'line': 0,
'severity': 'high',
'category': 'tests',
'title': 'scripts/tests/ directory does not exist — no unit tests',
'detail': '',
'action': 'Create scripts/tests/ with test files for each script',
})
# Merge lint findings into all findings
all_findings.extend(lint_findings)
# Build summary
by_severity = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
by_category: dict[str, int] = {}
for f in all_findings:
sev = f['severity']
if sev in by_severity:
by_severity[sev] += 1
cat = f['category']
by_category[cat] = by_category.get(cat, 0) + 1
total_scripts = sum(len(v) for v in script_inventory.values())
status = 'pass'
if by_severity['critical'] > 0:
status = 'fail'
elif by_severity['high'] > 0:
status = 'warning'
elif total_scripts == 0:
status = 'pass'
lint_issue_count = sum(1 for f in lint_findings if f['category'] == 'lint')
return {
'scanner': 'scripts',
'script': 'scan-scripts.py',
'version': '2.0.0',
'skill_path': str(skill_path),
'timestamp': datetime.now(timezone.utc).isoformat(),
'status': status,
'findings': all_findings,
'assessments': {
'lint_summary': {
'tools_used': sorted(lint_tools_used),
'files_linted': total_scripts,
'lint_issues': lint_issue_count,
},
'script_summary': {
'total_scripts': total_scripts,
'by_type': {k: len(v) for k, v in script_inventory.items()},
'scripts': {k: v for k, v in script_inventory.items() if v},
'missing_tests': missing_tests,
},
},
'summary': {
'total_findings': len(all_findings),
'by_severity': by_severity,
'by_category': by_category,
'assessment': '',
},
}
def main() -> int:
parser = argparse.ArgumentParser(
description='Scan BMad skill scripts for quality, portability, agentic design, and lint issues',
)
parser.add_argument(
'skill_path',
type=Path,
help='Path to the skill directory to scan',
)
parser.add_argument(
'--output', '-o',
type=Path,
help='Write JSON output to file instead of stdout',
)
args = parser.parse_args()
if not args.skill_path.is_dir():
print(f"Error: {args.skill_path} is not a directory", file=sys.stderr)
return 2
result = scan_skill_scripts(args.skill_path)
output = json.dumps(result, indent=2)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(output)
print(f"Results written to {args.output}", file=sys.stderr)
else:
print(output)
return 0 if result['status'] == 'pass' else 1
if __name__ == '__main__':
sys.exit(main())