prepass.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  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. Reads an agent skill directory and emits one compact JSON object that every
  8. lens and the analyze orchestrator consume. The pre-pass does the one thing the
  9. lenses should not each redo: it classifies the agent along the three-point
  10. gradient (stateless, memory, autonomous), counts tokens for SKILL.md and every
  11. in-tree file, and sets the gate that turns the conditional sanctum lens on.
  12. Detection rests on the sanctum, the built agent's runtime memory at
  13. `{project-root}/_bmad/memory/{skillName}/`. An agent that reloads a sanctum on
  14. waking is a memory agent; one that also carries live wake behavior (a PULSE
  15. file or a pulse/autonomous wake reference with named-task routing) is
  16. autonomous; one with no sanctum at all is stateless. This is the BUILT agent's
  17. memory, never the builder's process log (.memlog.md), and the two are kept
  18. apart here.
  19. Lengths come from tokens, never line counts. The count uses count_tokens.py
  20. (imported as a sibling, then shelled out, then a chars // 4 fallback) so the
  21. metric matches the rest of the builder and runs under a bare python3.
  22. Output contract (one line of JSON on stdout, the pinned prepass shape):
  23. {
  24. "agent_type": "stateless" | "memory" | "autonomous",
  25. "is_memory_agent": bool, # true for memory and autonomous
  26. "skill_md_tokens": int,
  27. "files": [{"path": str, "tokens": int}, ...]
  28. }
  29. Read-only over the target agent directory. It opens files to count and classify
  30. and writes nothing inside the agent tree.
  31. Usage:
  32. prepass.py <agent-dir> classify and count the agent at this directory
  33. """
  34. from __future__ import annotations
  35. import argparse
  36. import json
  37. import re
  38. import subprocess
  39. import sys
  40. from pathlib import Path
  41. SCRIPT_DIR = Path(__file__).resolve().parent
  42. # Directories we never descend into while counting agent files.
  43. SKIP_DIRS = {".git", "__pycache__", ".pytest_cache", "node_modules", ".venv", "venv"}
  44. # Extensions we treat as countable text. Binary or opaque assets are skipped.
  45. TEXT_SUFFIXES = {
  46. ".md", ".py", ".toml", ".yaml", ".yml", ".json", ".txt",
  47. ".csv", ".html", ".sh", ".cfg", ".ini",
  48. }
  49. # --- token counting ---------------------------------------------------------
  50. def _count_via_import(text: str):
  51. """Count tokens by importing the sibling count_tokens module."""
  52. if str(SCRIPT_DIR) not in sys.path:
  53. sys.path.insert(0, str(SCRIPT_DIR))
  54. try:
  55. import count_tokens # type: ignore
  56. except Exception:
  57. return None
  58. try:
  59. tokens, _method = count_tokens.count_tokens(text)
  60. return int(tokens)
  61. except Exception:
  62. return None
  63. def _count_via_shell(text: str):
  64. """Count tokens by shelling out to count_tokens.py with text on stdin."""
  65. script = SCRIPT_DIR / "count_tokens.py"
  66. if not script.exists():
  67. return None
  68. try:
  69. proc = subprocess.run(
  70. [sys.executable, str(script), "--stdin"],
  71. input=text,
  72. capture_output=True,
  73. text=True,
  74. timeout=60,
  75. )
  76. except Exception:
  77. return None
  78. if proc.returncode != 0:
  79. return None
  80. try:
  81. return int(json.loads(proc.stdout)["tokens"])
  82. except Exception:
  83. return None
  84. def count_tokens(text: str) -> int:
  85. """Token length of text via count_tokens.py, falling back to chars // 4.
  86. Prefers importing the vendored count_tokens module, then shelling out to it,
  87. then a bare character estimate so the pre-pass always produces a number.
  88. """
  89. for counter in (_count_via_import, _count_via_shell):
  90. result = counter(text)
  91. if result is not None:
  92. return result
  93. return len(text) // 4
  94. def read_text(path: Path) -> str:
  95. try:
  96. return path.read_text(encoding="utf-8")
  97. except (OSError, UnicodeDecodeError):
  98. return ""
  99. # --- agent classification ---------------------------------------------------
  100. def iter_files(root: Path):
  101. """Yield countable text files under root, skipping noise directories."""
  102. for path in sorted(root.rglob("*")):
  103. if not path.is_file():
  104. continue
  105. if any(part in SKIP_DIRS for part in path.relative_to(root).parts):
  106. continue
  107. if path.suffix.lower() in TEXT_SUFFIXES:
  108. yield path
  109. def has_sanctum(root: Path, skill_text: str) -> bool:
  110. """True when the agent reloads a runtime sanctum on waking (a memory agent).
  111. The sanctum is the built agent's memory at `_bmad/memory/{skillName}/`. We
  112. treat any of these as a sanctum signal: the SKILL referencing that memory
  113. path, the Sacred-Truth / waking bootloader language, a wake or init-sanctum
  114. scaffolder, or the sanctum template assets (PERSONA / CREED / BOND / MEMORY
  115. / INDEX / CAPABILITIES). This is the built agent's memory, distinct from the
  116. builder's .memlog.md, which is never a sanctum signal.
  117. """
  118. if re.search(r"_bmad/memory/", skill_text):
  119. return True
  120. if re.search(r"\bsanctum\b", skill_text, re.IGNORECASE):
  121. return True
  122. if "Sacred Truth" in skill_text and re.search(r"\b(waking|wake)\b", skill_text, re.IGNORECASE):
  123. return True
  124. for pattern in ("scripts/wake*", "scripts/init-sanctum*"):
  125. for script in root.glob(pattern):
  126. if script.is_file():
  127. return True
  128. sanctum_seed = re.compile(
  129. r"^(PERSONA|CREED|BOND|MEMORY|INDEX|CAPABILITIES)-template\.md$"
  130. )
  131. assets = root / "assets"
  132. if assets.is_dir():
  133. for asset in assets.iterdir():
  134. if asset.is_file() and sanctum_seed.match(asset.name):
  135. return True
  136. return False
  137. def has_autonomous_wake(root: Path, skill_text: str) -> bool:
  138. """True when a memory agent also carries live autonomous wake behavior.
  139. Autonomous is memory plus a PULSE-driven wake: a deployed PULSE.md, a
  140. pulse/autonomous-wake reference, or SKILL wake routing (named-task pulse
  141. routing, a default wake behavior, quiet hours, or a wake frequency).
  142. The standard memory bootloader already names a Pulse Mode (`--pulse`) path
  143. that loads PULSE.md, and ships a PULSE template asset, in every memory
  144. agent. Those are seeds, not live wake behavior, so neither the bootloader's
  145. Pulse-Mode line nor a PULSE template asset counts here. The wake behavior
  146. must be deployed: a real PULSE.md, a wake reference file, or SKILL routing
  147. that names tasks or schedules a recurring wake.
  148. """
  149. if (root / "PULSE.md").is_file():
  150. return True
  151. refs = root / "references"
  152. if refs.is_dir():
  153. for ref in refs.iterdir():
  154. name = ref.name.lower()
  155. if ref.is_file() and ("pulse-wake" in name or "autonomous-wake" in name):
  156. return True
  157. wake_signals = [
  158. r"--pulse:\{", # named-task pulse routing
  159. r"-p:\{", # short-flag named-task routing
  160. r"default pulse wake",
  161. r"default wake behavior",
  162. r"\bquiet hours\b",
  163. r"wake frequency",
  164. r"autonomous wake",
  165. ]
  166. for pattern in wake_signals:
  167. if re.search(pattern, skill_text, re.IGNORECASE):
  168. return True
  169. return False
  170. def classify(root: Path, skill_text: str) -> str:
  171. """Return the agent_type along the gradient."""
  172. if not has_sanctum(root, skill_text):
  173. return "stateless"
  174. if has_autonomous_wake(root, skill_text):
  175. return "autonomous"
  176. return "memory"
  177. # --- main -------------------------------------------------------------------
  178. def build_payload(root: Path) -> dict:
  179. skill_path = root / "SKILL.md"
  180. skill_text = read_text(skill_path) if skill_path.is_file() else ""
  181. agent_type = classify(root, skill_text)
  182. is_memory_agent = agent_type in ("memory", "autonomous")
  183. files = []
  184. skill_md_tokens = 0
  185. for path in iter_files(root):
  186. tokens = count_tokens(read_text(path))
  187. rel = path.relative_to(root).as_posix()
  188. files.append({"path": rel, "tokens": tokens})
  189. if path == skill_path:
  190. skill_md_tokens = tokens
  191. return {
  192. "agent_type": agent_type,
  193. "is_memory_agent": is_memory_agent,
  194. "skill_md_tokens": skill_md_tokens,
  195. "files": files,
  196. }
  197. def main(argv: list[str] | None = None) -> int:
  198. p = argparse.ArgumentParser(
  199. description=__doc__,
  200. formatter_class=argparse.RawDescriptionHelpFormatter,
  201. )
  202. p.add_argument("agent_dir", help="path to the agent skill directory to analyze")
  203. args = p.parse_args(argv)
  204. root = Path(args.agent_dir).expanduser().resolve()
  205. if not root.is_dir():
  206. p.error(f"not a directory: {root}")
  207. print(json.dumps(build_payload(root)))
  208. return 0
  209. if __name__ == "__main__":
  210. sys.exit(main())