from __future__ import annotations
import argparse
import json
import os
import random
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
QUESTIONS = REPO / "tests/fixtures/retrieval/questions.yaml"
SKILL = REPO / "docs/skill/SKILL.md"
ARMS = ["none", "skill", "hook", "both"]
SEMLITH_TOOL = "semlith"
RAW_TOOLS = {"Read", "Grep", "Glob", "Bash"}
def questions(limit: int, seed: int) -> list[dict]:
import yaml
every = yaml.safe_load(QUESTIONS.read_text())["questions"]
by_shape: dict[str, list[dict]] = {}
for q in every:
by_shape.setdefault(q.get("shape", "other"), []).append(q)
rng = random.Random(seed)
for group in by_shape.values():
rng.shuffle(group)
picked: list[dict] = []
shapes = sorted(by_shape)
while len(picked) < limit and any(by_shape[s] for s in shapes):
for shape in shapes:
if by_shape[shape] and len(picked) < limit:
picked.append(by_shape[shape].pop())
picked.sort(key=lambda q: q["id"])
return picked
def arm_flags(arm: str, root: Path, binary: Path, store_home: Path) -> list[str]:
where = root / arm
where.mkdir(parents=True, exist_ok=True)
flags: list[str] = []
if arm in ("skill", "both"):
plugin = where / "plugin"
(plugin / ".claude-plugin").mkdir(parents=True, exist_ok=True)
(plugin / "skills/semlith").mkdir(parents=True, exist_ok=True)
shutil.copy2(SKILL, plugin / "skills/semlith/SKILL.md")
(plugin / ".claude-plugin/plugin.json").write_text(
json.dumps(
{
"name": "semlith-skill",
"version": "0.24.0",
"description": "The semlith Agent Skill, as `semlith setup` installs it.",
},
indent=2,
)
+ "\n"
)
flags += ["--plugin-dir", str(plugin)]
settings = where / "settings.json"
if arm in ("hook", "both"):
settings.write_text(
json.dumps(
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Grep",
"hooks": [{"type": "command", "command": f"{binary} hook"}],
}
]
}
},
indent=2,
)
+ "\n"
)
else:
settings.write_text("{}\n")
flags += ["--settings", str(settings)]
mcp = where / "mcp.json"
mcp.write_text(
json.dumps(
{
"mcpServers": {
"semlith": {
"command": str(binary),
"args": ["mcp"],
"env": {"SEMLITH_HOME": str(store_home)},
}
}
},
indent=2,
)
+ "\n"
)
flags += ["--mcp-config", str(mcp), "--strict-mcp-config"]
return flags
def is_lookup(name: str) -> bool:
return SEMLITH_TOOL in name or name.split("__")[-1] in RAW_TOOLS
def first_tool(events: str) -> str | None:
for line in events.splitlines():
line = line.strip()
if not line.startswith("{"):
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
message = event.get("message") or {}
for block in message.get("content") or []:
if isinstance(block, dict) and block.get("type") == "tool_use":
name = block.get("name") or ""
if is_lookup(name):
return name
return None
def asked(question: dict) -> str:
if "query" in question:
return str(question["query"])
if "name" in question:
return f"where is {question['name']} defined, and what calls it"
if "from" in question and "to" in question:
return f"how does {question['from']} reach {question['to']}"
if "target" in question:
return f"what is {question['target']}"
return str(question["id"]).replace("-", " ")
def ask(question: dict, flags: list[str], store_home: Path, cwd: Path, timeout: int):
prompt = (
f"In this repository, answer: {asked(question)}. "
"Find the relevant code first, then answer in one sentence."
)
env = dict(os.environ)
env["SEMLITH_HOME"] = str(store_home)
env.pop("CLAUDE_CODE_SSE_PORT", None)
env.pop("CLAUDECODE", None)
try:
out = subprocess.run(
[
"claude",
"-p",
prompt,
"--output-format",
"stream-json",
"--verbose",
"--allowedTools",
"Read,Grep,Glob,mcp__semlith__semlith_search,mcp__semlith__semlith_brief,"
"mcp__semlith__semlith_read,mcp__semlith__semlith_symbol,"
"mcp__semlith__semlith_neighbors,mcp__semlith__semlith_files",
"--permission-mode",
"acceptEdits",
*flags,
],
cwd=cwd,
env=env,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
return None, "timeout"
return first_tool(out.stdout), (out.stderr or "").strip()[-200:]
def classify(tool: str | None) -> str:
if tool is None:
return "none"
if SEMLITH_TOOL in tool:
return "semlith"
base = tool.split("__")[-1]
return "raw" if base in RAW_TOOLS else "other"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--store", required=True, help="SEMLITH_HOME holding the store to search")
parser.add_argument("--binary", default=str(REPO / "target/release/semlith"))
parser.add_argument("--cwd", default=str(REPO), help="where the agent runs")
parser.add_argument("--questions", type=int, default=20)
parser.add_argument("--runs", type=int, default=3)
parser.add_argument("--seed", type=int, default=240)
parser.add_argument("--arms", default=",".join(ARMS))
parser.add_argument("--timeout", type=int, default=180)
parser.add_argument("--out", default="trigger-out")
args = parser.parse_args()
binary = Path(args.binary).resolve()
if not binary.exists():
print(f"no binary at {binary}", file=sys.stderr)
return 2
picked = questions(args.questions, args.seed)
arms = [a for a in args.arms.split(",") if a]
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
print(f"seed {args.seed} · {len(picked)} questions · arms {', '.join(arms)} · {args.runs} run(s)")
print("questions: " + ", ".join(q["id"] for q in picked))
results: dict[str, list[int]] = {arm: [] for arm in arms}
detail: list[dict] = []
with tempfile.TemporaryDirectory(prefix="semlith-trigger-") as tmp:
root = Path(tmp)
for run in range(args.runs):
for arm in arms:
flags = arm_flags(arm, root / f"run{run}", binary, Path(args.store))
hits = 0
for q in picked:
tool, err = ask(q, flags, Path(args.store), Path(args.cwd), args.timeout)
verdict = classify(tool)
hits += verdict == "semlith"
detail.append(
{"run": run, "arm": arm, "id": q["id"], "tool": tool, "verdict": verdict, "err": err}
)
print(f" run{run} {arm:5} {q['id']:38} {tool or '—'} · {verdict}", flush=True)
results[arm].append(hits)
print(f" run{run} {arm:5} → {hits} of {len(picked)}", flush=True)
print("\n trigger check · first code lookup is a semlith tool")
for arm in arms:
runs = sorted(results[arm])
median = runs[len(runs) // 2] if runs else 0
spread = f"{runs[0]}–{runs[-1]}" if len(runs) > 1 else str(median)
print(f" {arm:5} {median} of {len(picked)} (runs {spread})")
(out / "trigger.json").write_text(
json.dumps(
{
"seed": args.seed,
"questions": [q["id"] for q in picked],
"runs": args.runs,
"results": results,
"detail": detail,
},
indent=2,
)
+ "\n"
)
print(f"\n written to {out / 'trigger.json'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())