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.
Files changed (70) hide show
  1. package/lib/agents.js +7 -0
  2. package/lib/bmad-cache/bmb/.claude-plugin/marketplace.json +3 -3
  3. package/lib/bmad-cache/bmb/_git_preserved/index +0 -0
  4. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.idx +0 -0
  5. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/{pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.pack → pack-b388526edf307cf56f8e65673466cff074105abf.pack} +0 -0
  6. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.rev +0 -0
  7. package/lib/bmad-cache/bmb/_git_preserved/packed-refs +1 -1
  8. package/lib/bmad-cache/bmb/_git_preserved/refs/heads/main +1 -1
  9. package/lib/bmad-cache/bmb/_git_preserved/shallow +1 -1
  10. package/lib/bmad-cache/bmb/package-lock.json +2 -2
  11. package/lib/bmad-cache/bmb/package.json +1 -1
  12. package/lib/bmad-cache/bmb/samples/bmad-agent-code-coach/scripts/wake.py +78 -0
  13. package/lib/bmad-cache/bmb/samples/bmad-agent-creative-muse/scripts/wake.py +78 -0
  14. package/lib/bmad-cache/bmb/samples/bmad-agent-sentinel/scripts/wake.py +78 -0
  15. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/customize-template.toml +3 -0
  16. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/init-sanctum-template.py +11 -5
  17. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/report-shell.html +1073 -0
  18. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/wake-template.py +78 -0
  19. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/customize.toml +48 -0
  20. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/count_tokens.py +78 -0
  21. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass.py +258 -0
  22. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/process-template.py +20 -0
  23. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/render_report.py +387 -0
  24. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/adapter-claude-code.json +9 -0
  25. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/aggregate_benchmark.py +236 -0
  26. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/memlog.py +197 -0
  27. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_evals.py +513 -390
  28. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_triggers.py +347 -251
  29. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_env_isolation.py +79 -0
  30. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_trigger_detection.py +123 -0
  31. package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/tests/test-validate-module.py +82 -4
  32. package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/validate-module.py +55 -11
  33. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/customize-template.toml +3 -5
  34. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/report-shell.html +860 -0
  35. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/customize.toml +59 -0
  36. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/count_tokens.py +77 -0
  37. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/init_skill.py +132 -0
  38. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/prepass-prompt-metrics.py +186 -221
  39. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/quick_validate.py +117 -0
  40. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/render_report.py +387 -0
  41. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_canon_sync.py +46 -0
  42. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_count_tokens.py +180 -0
  43. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_render_report.py +171 -0
  44. package/lib/bmad-cache/cache-manifest.json +4 -4
  45. package/lib/bmad-extension/.claude-plugin/marketplace.json.template +1 -1
  46. package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +2 -2
  47. package/lib/bmad.js +6 -1
  48. package/lib/templates/instruction-block-git.template.md +25 -25
  49. package/lib/templates/instruction-block-onprem.template.md +86 -86
  50. package/lib/templates/instruction-block-universal.template.md +29 -29
  51. package/package.json +3 -3
  52. package/lib/.bmad-extension-plugin.build-22612-1782454014750/.claude-plugin/marketplace.json +0 -109
  53. package/lib/.bmad-extension-plugin.build-22612-1782454014750/skills/module-help.csv +0 -62
  54. package/lib/.bmad-extension-plugin.build-22612-1782454014750/skills/module.yaml +0 -20
  55. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.idx +0 -0
  56. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.rev +0 -0
  57. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/references/sample-init-sanctum.py +0 -274
  58. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/generate-html-report.py +0 -534
  59. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-execution-deps.py +0 -337
  60. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-prompt-metrics.py +0 -425
  61. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-sanctum-architecture.py +0 -385
  62. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-structure-capabilities.py +0 -482
  63. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/Dockerfile +0 -29
  64. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/docker_setup.py +0 -115
  65. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/generate_report.py +0 -184
  66. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/pty_runner.py +0 -171
  67. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/utils.py +0 -260
  68. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/extract-report-json.py +0 -287
  69. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/generate-html-report.py +0 -588
  70. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/prepass-execution-deps.py +0 -288
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # ///
5
+ """
6
+ Waking — load the agent's sanctum in one pass, or route to First Breath.
7
+
8
+ Run on activation. Determines the mode from the filesystem (and the --pulse
9
+ flag) and, when the sanctum exists, prints the full identity in a single read
10
+ (INDEX, PERSONA, CREED, BOND, MEMORY, CAPABILITIES) so the agent becomes itself
11
+ in one shot instead of six. In --pulse mode it also appends PULSE.md. When no
12
+ sanctum exists, it prints a directive to run First Breath.
13
+
14
+ This loads runtime memory only. It never reads or writes config or customize.toml.
15
+
16
+ Usage:
17
+ uv run wake.py <project-root> [--pulse]
18
+
19
+ project-root: The root of the project (where _bmad/ lives)
20
+ """
21
+
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ SKILL_NAME = "{skillName}"
26
+
27
+ # Load order — the "become yourself" set.
28
+ IDENTITY_FILES = [
29
+ "INDEX.md",
30
+ "PERSONA.md",
31
+ "CREED.md",
32
+ "BOND.md",
33
+ "MEMORY.md",
34
+ "CAPABILITIES.md",
35
+ ]
36
+
37
+
38
+ def emit(path: Path) -> None:
39
+ print(f"\n===== {path.name} =====")
40
+ try:
41
+ print(path.read_text(encoding="utf-8").rstrip())
42
+ except FileNotFoundError:
43
+ print(f"(missing: {path.name})")
44
+
45
+
46
+ def main() -> int:
47
+ args = sys.argv[1:]
48
+ pulse = "--pulse" in args
49
+ positional = [a for a in args if not a.startswith("--")]
50
+ if not positional:
51
+ print("Usage: wake.py <project-root> [--pulse]", file=sys.stderr)
52
+ return 2
53
+
54
+ project_root = Path(positional[0]).resolve()
55
+ sanctum = project_root / "_bmad" / "memory" / SKILL_NAME
56
+
57
+ core_ok = (
58
+ sanctum.is_dir()
59
+ and (sanctum / "CREED.md").is_file()
60
+ and (sanctum / "MEMORY.md").is_file()
61
+ )
62
+ if not core_ok:
63
+ print("MODE: FIRST_BREATH")
64
+ print(f"NO SANCTUM at {sanctum}")
65
+ print("This is your one birth. Load references/first-breath.md and follow it.")
66
+ return 0
67
+
68
+ print("MODE: PULSE" if pulse else "MODE: WAKING")
69
+ print(f"Sanctum: {sanctum}")
70
+ for name in IDENTITY_FILES:
71
+ emit(sanctum / name)
72
+ if pulse:
73
+ emit(sanctum / "PULSE.md")
74
+ return 0
75
+
76
+
77
+ if __name__ == "__main__":
78
+ raise SystemExit(main())
@@ -0,0 +1,48 @@
1
+ # DO NOT EDIT -- overwritten on every update.
2
+ #
3
+ # Customization surface for bmad-agent-builder. This governs how the builder
4
+ # builds: the org-wide context, standards, and gates applied to every agent it
5
+ # produces. It is distinct from the per-built-agent customize.toml the builder
6
+ # emits during an individual build.
7
+ #
8
+ # Override files (not edited here):
9
+ # {project-root}/_bmad/custom/bmad-agent-builder.toml (team)
10
+ # {project-root}/_bmad/custom/bmad-agent-builder.user.toml (personal)
11
+
12
+ [agent]
13
+
14
+ # --- Configurable below. Overrides merge per BMad structural rules: ---
15
+ # scalars: override wins • arrays: append
16
+
17
+ # Steps to run before standard activation (config load, greet).
18
+ # Use for org pre-flight loads or compliance checks.
19
+ activation_steps_prepend = []
20
+
21
+ # Steps to run after intent routing, before the build/analyze loop begins.
22
+ activation_steps_append = []
23
+
24
+ # Standards the builder keeps in mind for the whole session, loaded as context
25
+ # into every build and analyze. Each entry is a literal sentence, a `skill:`
26
+ # skill, or a `file:` path/glob whose contents load as facts. Use for house
27
+ # conventions you want present but not hard-gated (for gates, see build_standards).
28
+ # "Every agent persona names its owner relationship explicitly."
29
+ # "file:{project-root}/_bmad/standards/agent-house-style.md"
30
+ persistent_facts = ["file:{project-root}/**/project-context.md"]
31
+
32
+ # Executed when a build or analyze run completes, after the user has been told
33
+ # the artifact is ready. String scalar (one instruction) or array (in order).
34
+ on_complete = ""
35
+
36
+ # --- Builder gates ---
37
+
38
+ # Hard standards every BUILT agent must satisfy. Unlike persistent_facts
39
+ # (context), these are enforced: applied as build criteria and checked again as
40
+ # a conformance pass during Analyze. Each entry is a `skill:`, `file:`, or
41
+ # plain-text directive. Append-only. Empty by default (no org gates).
42
+ build_standards = []
43
+
44
+ # Eval requirement for a build to be declared done. Empty (default) keeps evals
45
+ # opt-in, offered at the eval beat but never forced.
46
+ # "baseline" -- require a passing baseline run (agent beats the bare model)
47
+ # "any" -- require at least one eval case to exist and pass
48
+ evals_required = ""
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+ # vendored from bmad-workflow-builder/scripts; canonical source there
3
+ # /// script
4
+ # requires-python = ">=3.9"
5
+ # dependencies = ["tiktoken"]
6
+ # ///
7
+ """count_tokens — the single length metric for skill authoring.
8
+
9
+ Token counts replace line counts everywhere in the builder and eval-runner.
10
+ This script reports the token length of a file or of text piped on stdin, using
11
+ the tiktoken cl100k_base encoding. When tiktoken is not installed it falls back
12
+ to a character-based estimate (len(text) // 4) and says so, so the script always
13
+ runs under a bare python3 even with no third-party packages present.
14
+
15
+ Usage:
16
+ count_tokens.py <file> count the tokens in a file
17
+ count_tokens.py --stdin count the tokens read from stdin
18
+
19
+ Output (one line of JSON on stdout):
20
+ {"tokens": <int>, "method": "tiktoken"} when tiktoken loaded
21
+ {"tokens": <int>, "method": "fallback"} when it fell back to chars // 4
22
+
23
+ Budgets this feeds: SKILL.md ~1500-2500, multi-branch reference ~4500,
24
+ single-purpose reference ~9000.
25
+ """
26
+ import argparse
27
+ import json
28
+ import sys
29
+
30
+ ENCODING = "cl100k_base"
31
+
32
+
33
+ def count_tokens(text: str) -> tuple[int, str]:
34
+ """Return (token_count, method).
35
+
36
+ Tries tiktoken's cl100k_base encoding first. If tiktoken cannot be imported
37
+ or initialized, estimates with len(text) // 4 and reports method "fallback".
38
+ """
39
+ try:
40
+ import tiktoken
41
+ except Exception:
42
+ return len(text) // 4, "fallback"
43
+ try:
44
+ enc = tiktoken.get_encoding(ENCODING)
45
+ except Exception:
46
+ return len(text) // 4, "fallback"
47
+ return len(enc.encode(text)), "tiktoken"
48
+
49
+
50
+ def read_input(args) -> str:
51
+ if args.stdin:
52
+ return sys.stdin.read()
53
+ with open(args.file, encoding="utf-8") as f:
54
+ return f.read()
55
+
56
+
57
+ def main(argv: list[str] | None = None) -> int:
58
+ p = argparse.ArgumentParser(
59
+ description=__doc__,
60
+ formatter_class=argparse.RawDescriptionHelpFormatter,
61
+ )
62
+ p.add_argument("file", nargs="?", help="path to the file to count")
63
+ p.add_argument("--stdin", action="store_true", help="read text from stdin instead of a file")
64
+ args = p.parse_args(argv)
65
+
66
+ if not args.stdin and not args.file:
67
+ p.error("provide a file path or --stdin")
68
+ if args.stdin and args.file:
69
+ p.error("provide either a file path or --stdin, not both")
70
+
71
+ text = read_input(args)
72
+ tokens, method = count_tokens(text)
73
+ print(json.dumps({"tokens": tokens, "method": method}))
74
+ return 0
75
+
76
+
77
+ if __name__ == "__main__":
78
+ sys.exit(main())
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.9"
4
+ # dependencies = ["tiktoken"]
5
+ # ///
6
+ """prepass — the Analyze pre-pass for the agent builder.
7
+
8
+ Reads an agent skill directory and emits one compact JSON object that every
9
+ lens and the analyze orchestrator consume. The pre-pass does the one thing the
10
+ lenses should not each redo: it classifies the agent along the three-point
11
+ gradient (stateless, memory, autonomous), counts tokens for SKILL.md and every
12
+ in-tree file, and sets the gate that turns the conditional sanctum lens on.
13
+
14
+ Detection rests on the sanctum, the built agent's runtime memory at
15
+ `{project-root}/_bmad/memory/{skillName}/`. An agent that reloads a sanctum on
16
+ waking is a memory agent; one that also carries live wake behavior (a PULSE
17
+ file or a pulse/autonomous wake reference with named-task routing) is
18
+ autonomous; one with no sanctum at all is stateless. This is the BUILT agent's
19
+ memory, never the builder's process log (.memlog.md), and the two are kept
20
+ apart here.
21
+
22
+ Lengths come from tokens, never line counts. The count uses count_tokens.py
23
+ (imported as a sibling, then shelled out, then a chars // 4 fallback) so the
24
+ metric matches the rest of the builder and runs under a bare python3.
25
+
26
+ Output contract (one line of JSON on stdout, the pinned prepass shape):
27
+ {
28
+ "agent_type": "stateless" | "memory" | "autonomous",
29
+ "is_memory_agent": bool, # true for memory and autonomous
30
+ "skill_md_tokens": int,
31
+ "files": [{"path": str, "tokens": int}, ...]
32
+ }
33
+
34
+ Read-only over the target agent directory. It opens files to count and classify
35
+ and writes nothing inside the agent tree.
36
+
37
+ Usage:
38
+ prepass.py <agent-dir> classify and count the agent at this directory
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import argparse
43
+ import json
44
+ import re
45
+ import subprocess
46
+ import sys
47
+ from pathlib import Path
48
+
49
+ SCRIPT_DIR = Path(__file__).resolve().parent
50
+
51
+ # Directories we never descend into while counting agent files.
52
+ SKIP_DIRS = {".git", "__pycache__", ".pytest_cache", "node_modules", ".venv", "venv"}
53
+
54
+ # Extensions we treat as countable text. Binary or opaque assets are skipped.
55
+ TEXT_SUFFIXES = {
56
+ ".md", ".py", ".toml", ".yaml", ".yml", ".json", ".txt",
57
+ ".csv", ".html", ".sh", ".cfg", ".ini",
58
+ }
59
+
60
+
61
+ # --- token counting ---------------------------------------------------------
62
+
63
+ def _count_via_import(text: str):
64
+ """Count tokens by importing the sibling count_tokens module."""
65
+ if str(SCRIPT_DIR) not in sys.path:
66
+ sys.path.insert(0, str(SCRIPT_DIR))
67
+ try:
68
+ import count_tokens # type: ignore
69
+ except Exception:
70
+ return None
71
+ try:
72
+ tokens, _method = count_tokens.count_tokens(text)
73
+ return int(tokens)
74
+ except Exception:
75
+ return None
76
+
77
+
78
+ def _count_via_shell(text: str):
79
+ """Count tokens by shelling out to count_tokens.py with text on stdin."""
80
+ script = SCRIPT_DIR / "count_tokens.py"
81
+ if not script.exists():
82
+ return None
83
+ try:
84
+ proc = subprocess.run(
85
+ [sys.executable, str(script), "--stdin"],
86
+ input=text,
87
+ capture_output=True,
88
+ text=True,
89
+ timeout=60,
90
+ )
91
+ except Exception:
92
+ return None
93
+ if proc.returncode != 0:
94
+ return None
95
+ try:
96
+ return int(json.loads(proc.stdout)["tokens"])
97
+ except Exception:
98
+ return None
99
+
100
+
101
+ def count_tokens(text: str) -> int:
102
+ """Token length of text via count_tokens.py, falling back to chars // 4.
103
+
104
+ Prefers importing the vendored count_tokens module, then shelling out to it,
105
+ then a bare character estimate so the pre-pass always produces a number.
106
+ """
107
+ for counter in (_count_via_import, _count_via_shell):
108
+ result = counter(text)
109
+ if result is not None:
110
+ return result
111
+ return len(text) // 4
112
+
113
+
114
+ def read_text(path: Path) -> str:
115
+ try:
116
+ return path.read_text(encoding="utf-8")
117
+ except (OSError, UnicodeDecodeError):
118
+ return ""
119
+
120
+
121
+ # --- agent classification ---------------------------------------------------
122
+
123
+ def iter_files(root: Path):
124
+ """Yield countable text files under root, skipping noise directories."""
125
+ for path in sorted(root.rglob("*")):
126
+ if not path.is_file():
127
+ continue
128
+ if any(part in SKIP_DIRS for part in path.relative_to(root).parts):
129
+ continue
130
+ if path.suffix.lower() in TEXT_SUFFIXES:
131
+ yield path
132
+
133
+
134
+ def has_sanctum(root: Path, skill_text: str) -> bool:
135
+ """True when the agent reloads a runtime sanctum on waking (a memory agent).
136
+
137
+ The sanctum is the built agent's memory at `_bmad/memory/{skillName}/`. We
138
+ treat any of these as a sanctum signal: the SKILL referencing that memory
139
+ path, the Sacred-Truth / waking bootloader language, a wake or init-sanctum
140
+ scaffolder, or the sanctum template assets (PERSONA / CREED / BOND / MEMORY
141
+ / INDEX / CAPABILITIES). This is the built agent's memory, distinct from the
142
+ builder's .memlog.md, which is never a sanctum signal.
143
+ """
144
+ if re.search(r"_bmad/memory/", skill_text):
145
+ return True
146
+ if re.search(r"\bsanctum\b", skill_text, re.IGNORECASE):
147
+ return True
148
+ if "Sacred Truth" in skill_text and re.search(r"\b(waking|wake)\b", skill_text, re.IGNORECASE):
149
+ return True
150
+
151
+ for pattern in ("scripts/wake*", "scripts/init-sanctum*"):
152
+ for script in root.glob(pattern):
153
+ if script.is_file():
154
+ return True
155
+
156
+ sanctum_seed = re.compile(
157
+ r"^(PERSONA|CREED|BOND|MEMORY|INDEX|CAPABILITIES)-template\.md$"
158
+ )
159
+ assets = root / "assets"
160
+ if assets.is_dir():
161
+ for asset in assets.iterdir():
162
+ if asset.is_file() and sanctum_seed.match(asset.name):
163
+ return True
164
+ return False
165
+
166
+
167
+ def has_autonomous_wake(root: Path, skill_text: str) -> bool:
168
+ """True when a memory agent also carries live autonomous wake behavior.
169
+
170
+ Autonomous is memory plus a PULSE-driven wake: a deployed PULSE.md, a
171
+ pulse/autonomous-wake reference, or SKILL wake routing (named-task pulse
172
+ routing, a default wake behavior, quiet hours, or a wake frequency).
173
+
174
+ The standard memory bootloader already names a Pulse Mode (`--pulse`) path
175
+ that loads PULSE.md, and ships a PULSE template asset, in every memory
176
+ agent. Those are seeds, not live wake behavior, so neither the bootloader's
177
+ Pulse-Mode line nor a PULSE template asset counts here. The wake behavior
178
+ must be deployed: a real PULSE.md, a wake reference file, or SKILL routing
179
+ that names tasks or schedules a recurring wake.
180
+ """
181
+ if (root / "PULSE.md").is_file():
182
+ return True
183
+
184
+ refs = root / "references"
185
+ if refs.is_dir():
186
+ for ref in refs.iterdir():
187
+ name = ref.name.lower()
188
+ if ref.is_file() and ("pulse-wake" in name or "autonomous-wake" in name):
189
+ return True
190
+
191
+ wake_signals = [
192
+ r"--pulse:\{", # named-task pulse routing
193
+ r"-p:\{", # short-flag named-task routing
194
+ r"default pulse wake",
195
+ r"default wake behavior",
196
+ r"\bquiet hours\b",
197
+ r"wake frequency",
198
+ r"autonomous wake",
199
+ ]
200
+ for pattern in wake_signals:
201
+ if re.search(pattern, skill_text, re.IGNORECASE):
202
+ return True
203
+ return False
204
+
205
+
206
+ def classify(root: Path, skill_text: str) -> str:
207
+ """Return the agent_type along the gradient."""
208
+ if not has_sanctum(root, skill_text):
209
+ return "stateless"
210
+ if has_autonomous_wake(root, skill_text):
211
+ return "autonomous"
212
+ return "memory"
213
+
214
+
215
+ # --- main -------------------------------------------------------------------
216
+
217
+ def build_payload(root: Path) -> dict:
218
+ skill_path = root / "SKILL.md"
219
+ skill_text = read_text(skill_path) if skill_path.is_file() else ""
220
+
221
+ agent_type = classify(root, skill_text)
222
+ is_memory_agent = agent_type in ("memory", "autonomous")
223
+
224
+ files = []
225
+ skill_md_tokens = 0
226
+ for path in iter_files(root):
227
+ tokens = count_tokens(read_text(path))
228
+ rel = path.relative_to(root).as_posix()
229
+ files.append({"path": rel, "tokens": tokens})
230
+ if path == skill_path:
231
+ skill_md_tokens = tokens
232
+
233
+ return {
234
+ "agent_type": agent_type,
235
+ "is_memory_agent": is_memory_agent,
236
+ "skill_md_tokens": skill_md_tokens,
237
+ "files": files,
238
+ }
239
+
240
+
241
+ def main(argv: list[str] | None = None) -> int:
242
+ p = argparse.ArgumentParser(
243
+ description=__doc__,
244
+ formatter_class=argparse.RawDescriptionHelpFormatter,
245
+ )
246
+ p.add_argument("agent_dir", help="path to the agent skill directory to analyze")
247
+ args = p.parse_args(argv)
248
+
249
+ root = Path(args.agent_dir).expanduser().resolve()
250
+ if not root.is_dir():
251
+ p.error(f"not a directory: {root}")
252
+
253
+ print(json.dumps(build_payload(root)))
254
+ return 0
255
+
256
+
257
+ if __name__ == "__main__":
258
+ sys.exit(main())
@@ -5,6 +5,13 @@ Performs deterministic variable substitution and conditional block processing
5
5
  on template files from assets/. Replaces {varName} placeholders with provided
6
6
  values and evaluates {if-X}...{/if-X} conditional blocks, keeping content
7
7
  when the condition is in the --true list and removing the entire block otherwise.
8
+
9
+ Any {if-X} or {/if-X} marker still present after processing is a defect (a
10
+ malformed or mismatched block the emitted agent would ship verbatim): the
11
+ script exits 3 and names the markers. Remaining {token} placeholders are
12
+ reported in the --json metadata as tokens_remaining, not failed, because they
13
+ may be runtime-resolution tokens such as {project-root} or {agent.<name>} —
14
+ the builder judges that list against the build-time token set.
8
15
  """
9
16
 
10
17
  # /// script
@@ -160,6 +167,18 @@ def main() -> int:
160
167
  content, conds_true, conds_false = process_conditionals(content, true_conditions)
161
168
  content, vars_substituted = process_variables(content, variables)
162
169
 
170
+ # Leftover conditional markers mean a malformed/mismatched block that
171
+ # would ship verbatim in the emitted agent.
172
+ leftover_markers = sorted(set(re.findall(r'\{/?if-[a-zA-Z0-9_-]+\}', content)))
173
+ if leftover_markers:
174
+ print(
175
+ f"Error: leftover conditional markers after processing: {', '.join(leftover_markers)}",
176
+ file=sys.stderr,
177
+ )
178
+ return 3
179
+
180
+ tokens_remaining = sorted(set(re.findall(r'\{[a-zA-Z][a-zA-Z0-9_.-]*\}', content)))
181
+
163
182
  # Write output
164
183
  output_file = args.output
165
184
  try:
@@ -180,6 +199,7 @@ def main() -> int:
180
199
  'vars_substituted': vars_substituted,
181
200
  'conditions_true': conds_true,
182
201
  'conditions_false': conds_false,
202
+ 'tokens_remaining': tokens_remaining,
183
203
  }
184
204
  print(json.dumps(metadata, indent=2), file=sys.stderr)
185
205