From aff29d61ba13a75ffdc33be6b84aa00501350040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= Date: Fri, 16 Oct 2026 23:24:59 +0200 Subject: [PATCH 01/10] =?UTF-8?q?=E2=9C=A8=20Gremlins=20MVP:=20store,=20la?= =?UTF-8?q?unch,=20stream,=20call=20modules=20and=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/spire/skills/gremlins/grm/__init__.py | 0 plugins/spire/skills/gremlins/grm/call.py | 86 +++++++ plugins/spire/skills/gremlins/grm/launch.py | 182 +++++++++++++++ plugins/spire/skills/gremlins/grm/store.py | 116 ++++++++++ plugins/spire/skills/gremlins/grm/stream.py | 215 ++++++++++++++++++ plugins/spire/skills/gremlins/preamble.md | 22 ++ .../spire/skills/gremlins/templates/rebase.md | 17 ++ .../spire/skills/gremlins/templates/spawn.md | 19 ++ .../spire/skills/gremlins/templates/train.md | 11 + 9 files changed, 668 insertions(+) create mode 100644 plugins/spire/skills/gremlins/grm/__init__.py create mode 100644 plugins/spire/skills/gremlins/grm/call.py create mode 100644 plugins/spire/skills/gremlins/grm/launch.py create mode 100644 plugins/spire/skills/gremlins/grm/store.py create mode 100644 plugins/spire/skills/gremlins/grm/stream.py create mode 100644 plugins/spire/skills/gremlins/preamble.md create mode 100644 plugins/spire/skills/gremlins/templates/rebase.md create mode 100644 plugins/spire/skills/gremlins/templates/spawn.md create mode 100644 plugins/spire/skills/gremlins/templates/train.md diff --git a/plugins/spire/skills/gremlins/grm/__init__.py b/plugins/spire/skills/gremlins/grm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/spire/skills/gremlins/grm/call.py b/plugins/spire/skills/gremlins/grm/call.py new file mode 100644 index 0000000..7a8072d --- /dev/null +++ b/plugins/spire/skills/gremlins/grm/call.py @@ -0,0 +1,86 @@ +"""The only subprocess code: run one `claude -p` call in the foreground and tee its stream.""" +import json +import signal +import subprocess +import threading +import time +from pathlib import Path + +POLL_S = 0.2 +READER_JOIN_S = 10.0 # an orphaned grandchild can hold stdout open after claude exits +STOP_SIGNALS = tuple(s for s in (signal.SIGTERM, signal.SIGINT, getattr(signal, "SIGBREAK", None)) + if s is not None) + + +def _feed(stdin, data: bytes) -> None: + """Write the prompt and close stdin; a child that exits early is not an error here.""" + try: + stdin.write(data) + except (OSError, ValueError): + pass + try: + stdin.close() + except (OSError, ValueError): + pass + + +def _read(stdout, raw, ss) -> None: + """Tee every stream line to the raw file and feed each JSON event to `ss`.""" + try: + for line in iter(stdout.readline, b""): + raw.write(line) + raw.flush() + try: + ev = json.loads(line.decode("utf-8", "replace")) + except ValueError: + continue + ss.feed(ev, time.time()) + except (OSError, ValueError): + pass # the raw file or pipe was closed after READER_JOIN_S + + +def run_claude(argv, prompt: str, env, cwd, raw_path, on_start, ss=None): + """Run one call to its end. Returns (ss, exit_code, stopped).""" + if ss is None: + from grm.stream import StreamState + ss = StreamState() + raw_path = Path(raw_path) + with open(raw_path, "wb") as raw, open(raw_path.with_suffix(".err"), "wb") as err: + child = subprocess.Popen(argv, cwd=cwd, env=env, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=err) + stopped = False + + def _stop(signum, frame): + nonlocal stopped + try: + if stopped: # a second signal: stop waiting for a graceful exit + child.kill() + else: + child.terminate() + except OSError: + pass + stopped = True + + old = {} + if threading.current_thread() is threading.main_thread(): + old = {s: signal.signal(s, _stop) for s in STOP_SIGNALS} + try: + reader = threading.Thread(target=_read, args=(child.stdout, raw, ss), daemon=True) + reader.start() + threading.Thread(target=_feed, args=(child.stdin, prompt.encode("utf-8")), + daemon=True).start() + on_start(child.pid) + while child.poll() is None: # a bare wait() would block signal handlers on Windows + time.sleep(POLL_S) + except BaseException: + child.kill() + child.wait() + raise + finally: + for s, h in old.items(): + if h is not None: + signal.signal(s, h) + reader.join(READER_JOIN_S) + if not reader.is_alive(): + child.stdout.close() + return ss, child.returncode, stopped diff --git a/plugins/spire/skills/gremlins/grm/launch.py b/plugins/spire/skills/gremlins/grm/launch.py new file mode 100644 index 0000000..06bcaf7 --- /dev/null +++ b/plugins/spire/skills/gremlins/grm/launch.py @@ -0,0 +1,182 @@ +"""Launch fingerprint, env scrub, binary lookup and the `claude -p` argv (B1, B2, B7, B8).""" +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys + +MODEL_RE = re.compile(r"^claude-[a-z0-9-]+(\[1m\])?$") +EFFORTS = ("low", "medium", "high", "xhigh", "max") +PERMISSION_MODE = "dontAsk" +DEFAULT_TOOLS = ["Bash", "Read", "Edit", "Write", "Glob", "Grep", "Skill"] +DEFAULT_DISALLOWED = ["Bash(git push:*)", "Bash(git rebase:*)", "Bash(git reset --hard:*)", + "Bash(claude:*)"] + +# Env pinned into every launch; part of the fingerprint. +ENV_PINNED_LAUNCH = {"CLAUDE_CODE_PROMPT_CACHE_TTL": "1h", "DISABLE_AUTOUPDATER": "1"} + +# Host env that must not reach a child `claude -p`. +SCRUB_DROP = frozenset({ + # Desktop host vars + "CLAUDE_CODE_MESSAGING_SOCKET", "CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDE_CODE_HOST_SESSION_ID", + "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_DESKTOP_APP_VERSION", + "CLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH", "CLAUDE_CODE_TERMINAL_MCP_TOOLS", + "CLAUDE_CODE_REPORT_FINDINGS", "CLAUDE_CODE_SESSION_ID", "CLAUDE_AGENT_SDK_VERSION", + "CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL", "CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING", + # fingerprint and auth path + "CLAUDE_EFFORT", "ANTHROPIC_MODEL", "ANTHROPIC_SMALL_FAST_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL", + "ENABLE_PROMPT_CACHING_1H", "FORCE_PROMPT_CACHING_5M", "CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL", + "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", + # uv + "VIRTUAL_ENV", +}) +SCRUB_DROP_PATTERNS = (re.compile(r"^ANTHROPIC_DEFAULT_.+_MODEL$"), re.compile(r"^UV_")) +SCRUB_KEEP = frozenset({ + "CLAUDECODE", "CLAUDE_CODE_CHILD_SESSION", "AI_AGENT", "CLAUDE_PID", "CLAUDE_CODE_GIT_BASH_PATH", + "PATH", "HOME", "USERPROFILE", + "ANTHROPIC_BASE_URL", # kept: gateways need it +}) +SCRUB_PINNED = {**ENV_PINNED_LAUNCH, "SPIRE_GREMLIN_CHILD": "1"} +SCRUB_PINNED_BINARY = "CLAUDE_CODE_EXECPATH" # set to the stored absolute binary path + + +def canonical_json(obj) -> str: + return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def _sha256_bytes(b: bytes) -> str: + return hashlib.sha256(b).hexdigest() + + +def _sha256_file(path: str) -> str: + with open(path, "rb") as f: + return _sha256_bytes(f.read()) + + +def scrub_class(name: str) -> str | None: + """'drop', 'pinned', 'keep' or None (on no list; kept).""" + n = name.upper() + if n in SCRUB_DROP or any(p.match(n) for p in SCRUB_DROP_PATTERNS): + return "drop" + if n in SCRUB_PINNED or n == SCRUB_PINNED_BINARY: + return "pinned" + if n in SCRUB_KEEP: + return "keep" + return None + + +def _venv_prefixes(environ) -> list[str]: + out = [] + venv = environ.get("VIRTUAL_ENV") + if venv: + out.append(venv) + if os.path.normcase(os.path.abspath(sys.prefix)) != os.path.normcase(os.path.abspath(sys.base_prefix)): + out.append(sys.prefix) + return [os.path.normcase(os.path.normpath(os.path.abspath(x))) for x in out] + + +def _under(path: str, prefixes) -> bool: + try: + p = os.path.normcase(os.path.normpath(os.path.abspath(path))) + except ValueError: + return False + return any(p == px or p.startswith(px.rstrip("\\/") + os.sep) for px in prefixes) + + +def scrubbed_env(binary: str, environ=None) -> dict: + """The child env: host env minus the drop list, venv PATH entries removed, pinned vars on + top, `CLAUDE_CODE_EXECPATH` = the stored binary path.""" + if environ is None: + environ = os.environ + prefixes = _venv_prefixes(environ) + env = {} + for k, v in environ.items(): + if scrub_class(k) in ("drop", "pinned"): + continue + if k.upper() == "PATH": + v = os.pathsep.join(e for e in v.split(os.pathsep) if e and not _under(e, prefixes)) + env[k] = v + env.update(SCRUB_PINNED) + env[SCRUB_PINNED_BINARY] = binary + return env + + +def resolve_binary(explicit: str | None = None) -> str | None: + """Absolute path of `claude`: `explicit`, else $CLAUDE_CODE_EXECPATH, else PATH.""" + path = explicit or os.environ.get("CLAUDE_CODE_EXECPATH") or shutil.which("claude") + return os.path.abspath(path) if path else None + + +def binary_version(path: str) -> str | None: + """First token of ` --version`, or None on any failure.""" + try: + r = subprocess.run([path, "--version"], stdin=subprocess.DEVNULL, capture_output=True, + timeout=60) + except (OSError, subprocess.SubprocessError): + return None + toks = r.stdout.decode("utf-8", "replace").split() + return toks[0] if r.returncode == 0 and toks else None + + +def _argv(fp: dict) -> list[str]: + """The fingerprinted command line; its order must never change (cache hit).""" + a = [fp["binary"], "-p", "--output-format", "stream-json", "--verbose", + "--model", fp["model"], "--effort", fp["effort"], + "--permission-mode", fp["permission_mode"], "--tools", ",".join(fp["tools"])] + if fp.get("allowed_tools"): + a += ["--allowedTools", ",".join(fp["allowed_tools"])] + if fp.get("disallowed_tools"): + a += ["--disallowedTools", ",".join(fp["disallowed_tools"])] + a += ["--strict-mcp-config", "--mcp-config", fp["mcp_config"], + "--append-system-prompt-file", fp["append_prompt"]["path"]] + if fp.get("agent"): + a += ["--agent", fp["agent"]] + return a + + +def build_fingerprint(*, binary, model, effort, cwd, agent, preamble_path, mcp_config) -> dict: + """Validate the launch fields; return {"fingerprint", "argv", "fp_hash"}. Writes nothing.""" + if not MODEL_RE.match(model or ""): + raise ValueError("model must be a full id (aliases are refused): %r" % model) + if effort not in EFFORTS: + raise ValueError("unknown effort %r (one of %s)" % (effort, ", ".join(EFFORTS))) + if not os.path.isdir(cwd): + raise ValueError("cwd is not a directory: %s" % cwd) + if not os.path.isfile(preamble_path): + raise ValueError("preamble not found: %s" % preamble_path) + if not binary or not os.path.isfile(binary): + raise ValueError("claude binary not found: %s" % binary) + preamble_path = os.path.abspath(preamble_path) + fp = { + "binary": os.path.abspath(binary), + "binary_version": binary_version(binary), + "model": model, + "effort": effort, + "permission_mode": PERMISSION_MODE, + "tools": list(DEFAULT_TOOLS), + # under dontAsk every tool not allowed is denied + "allowed_tools": list(DEFAULT_TOOLS), + "disallowed_tools": list(DEFAULT_DISALLOWED), + "agent": agent or None, + "cwd": os.path.abspath(cwd), + "append_prompt": {"path": preamble_path, "sha256": _sha256_file(preamble_path)}, + "mcp_config": os.path.abspath(mcp_config), + "env_pinned": dict(ENV_PINNED_LAUNCH), + } + return {"fingerprint": fp, "argv": _argv(fp), + "fp_hash": _sha256_bytes(canonical_json(fp).encode("utf-8"))} + + +def train_argv(run: dict, sid: str) -> list[str]: + return list(run["argv"]) + ["--session-id", sid] + + +def fork_argv(run: dict, base_sid: str, sid: str) -> list[str]: + return list(run["argv"]) + ["--resume", base_sid, "--fork-session", "--session-id", sid] + + +def resume_argv(run: dict, base_sid: str) -> list[str]: + """No `--session-id`: claude refuses it on a resume without a fork.""" + return list(run["argv"]) + ["--resume", base_sid] diff --git a/plugins/spire/skills/gremlins/grm/store.py b/plugins/spire/skills/gremlins/grm/store.py new file mode 100644 index 0000000..3ff6491 --- /dev/null +++ b/plugins/spire/skills/gremlins/grm/store.py @@ -0,0 +1,116 @@ +"""State dir layout, atomic JSON files and the append-only ledger.""" +import contextlib +import json +import os +import threading +import time +import uuid +from pathlib import Path + +SUBDIRS = ("bases", "jobs", "raw", "notes", "logs", "briefs") +RETRY_TRIES = 40 +RETRY_SLEEP_S = 0.05 + +_ledger_lock = threading.Lock() + + +def state_dir(run) -> Path: + """`/run.md` -> `/run-gremlins` (a sibling of the run log).""" + p = os.path.abspath(str(run)) + if not p.lower().endswith(".md"): + raise ValueError("--run must name the run log .md file: %s" % p) + return Path(p[:-3] + "-gremlins") + + +def init_state(run) -> Path: + """Create the state dir and its subdirs (idempotent).""" + state = state_dir(run) + for d in SUBDIRS: + (state / d).mkdir(parents=True, exist_ok=True) + return state + + +def _retry(fn, tries: int = RETRY_TRIES, sleep_s: float = RETRY_SLEEP_S): + """Call fn(); retry PermissionError (Windows sharing violations).""" + for attempt in range(1, tries + 1): + try: + return fn() + except PermissionError: + if attempt == tries: + raise + time.sleep(sleep_s) + + +def atomic_write_bytes(path, data: bytes) -> None: + """Write to a temp file in the same dir, then `os.replace` with retries.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.parent / (".%s.%d.%s.tmp" % (path.name, os.getpid(), uuid.uuid4().hex[:8])) + with open(tmp, "wb") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + try: + _retry(lambda: os.replace(tmp, path)) + except BaseException: + with contextlib.suppress(OSError): + os.remove(tmp) + raise + + +def read_bytes_retry(path) -> bytes: + def _read(): + with open(path, "rb") as f: + return f.read() + return _retry(_read) + + +def read_json(p) -> dict | None: + """The parsed file, or None if it does not exist. Corrupt content raises ValueError.""" + try: + data = read_bytes_retry(p) + except FileNotFoundError: + return None + return json.loads(data.decode("utf-8")) + + +def write_json(p, obj) -> None: + atomic_write_bytes(p, (json.dumps(obj, indent=1, sort_keys=True) + "\n").encode("utf-8")) + + +def _ledger_path(run) -> Path: + return state_dir(run) / "ledger.jsonl" + + +def ledger_append(run, line: dict) -> None: + """Append one JSON line with a single write; repairs a torn last line first.""" + path = _ledger_path(run) + body = (json.dumps(line, sort_keys=True) + "\n").encode("utf-8") + + def _append(): + torn = False + with contextlib.suppress(FileNotFoundError), open(path, "rb") as r: + if r.seek(0, os.SEEK_END) > 0: + r.seek(-1, os.SEEK_END) + torn = r.read(1) != b"\n" + with open(path, "ab") as f: + f.write((b"\n" if torn else b"") + body) + with _ledger_lock: + _retry(_append) + + +def ledger_lines(run) -> list[dict]: + """Every parsable line of the ledger; [] when there is none yet.""" + try: + data = read_bytes_retry(_ledger_path(run)) + except FileNotFoundError: + return [] + out = [] + for ln in data.splitlines(): + try: + obj = json.loads(ln.decode("utf-8", "replace")) + except ValueError: + continue + if isinstance(obj, dict): + out.append(obj) + return out diff --git a/plugins/spire/skills/gremlins/grm/stream.py b/plugins/spire/skills/gremlins/grm/stream.py new file mode 100644 index 0000000..4df8da7 --- /dev/null +++ b/plugins/spire/skills/gremlins/grm/stream.py @@ -0,0 +1,215 @@ +"""Stream-json reader and accounting for one `claude -p` call: pure functions, no IO.""" +import datetime as _dt +import json +import re +import time + +TTL_1H, TTL_5M = 3600, 300 +CACHE_HIT_RATIO = 0.9 +MU_FIELDS = ("inputTokens", "outputTokens", "cacheReadInputTokens", "cacheCreationInputTokens") +TOKEN_KEYS = ("input", "output", "cache_creation", "cache_read", "cache_creation_1h", "cache_creation_5m") + + +def iso(t: float | None = None) -> str: + """ISO 8601 with the local offset, from epoch seconds (now if not given).""" + if t is None: + t = time.time() + return _dt.datetime.fromtimestamp(t).astimezone().isoformat(timespec="seconds") + + +def parse_iso(s) -> float | None: + """Epoch seconds of an ISO string written by `iso`, or None.""" + if not s or not isinstance(s, str): + return None + try: + return _dt.datetime.fromisoformat(s).timestamp() + except ValueError: + return None + + +class StreamState: + """What one `claude -p --output-format stream-json --verbose` stream told us so far.""" + + def __init__(self): + self.init = None # the system/init event + self.session_id = None + self.msg_ids: list = [] + self._ids: set = set() + self.first_usage = None # usage of the first assistant event (cache verdict) + self.first_at = None # its arrival time (the base prefix read) + self.last_usage = None # usage of the last assistant event (base context) + self.last_assistant_at = None + self.result = None # the result event + self.refused = None # text of a model-restricted warning + self.events = 0 + + def feed(self, ev: dict, t: float | None) -> None: + if not isinstance(ev, dict): + return + self.events += 1 + ty = ev.get("type") + if ty == "system": + st = ev.get("subtype") + if st == "init" and self.init is None: + self.init = ev + self.session_id = ev.get("session_id") or self.session_id + elif (st == "informational" and ev.get("level") == "warning" + and "is restricted" in str(ev.get("content", ""))): + self.refused = str(ev.get("content")) + elif ty == "assistant": + m = ev.get("message") or {} + mid = m.get("id") + if mid not in self._ids: + self._ids.add(mid) + self.msg_ids.append(mid) + u = m.get("usage") or {} + if self.first_usage is None: + self.first_usage, self.first_at = u, t + self.last_usage, self.last_assistant_at = u, t + elif ty == "result": + self.result = ev + self.session_id = ev.get("session_id") or self.session_id + + @property + def turns(self) -> int: + return len(self.msg_ids) + + +def context_tokens(usage: dict | None) -> int: + """Prompt-side tokens of one request: input + cache creation + cache read.""" + if not usage: + return 0 + return (int(usage.get("input_tokens") or 0) + int(usage.get("cache_creation_input_tokens") or 0) + + int(usage.get("cache_read_input_tokens") or 0)) + + +_API_ERROR_TEXT = ( # checked in this order, after the status code + ("prompt_too_long", re.compile(r"prompt is too long|prompt too long|prompt_too_long", re.I)), + ("usage_limit", re.compile(r"usage limit|rate[ _-]?limit|hit your limit|api error:?\s*429", re.I)), + ("overloaded", re.compile(r"overloaded|api error:?\s*529", re.I)), + ("api_error", re.compile(r"api error", re.I)), +) + + +def api_error_reason(res: dict | None) -> tuple[str, str] | None: + """(reason, detail) for a result that carries an API error, else None. `reason` is + `usage_limit` (429), `overloaded` (529), `prompt_too_long` or `api_error`.""" + if not res: + return None + status = res.get("api_error_status") + try: + status = int(status) if status not in (None, "") else None + except (TypeError, ValueError): + status = None + if not res.get("is_error") and status is None: + return None + parts = [res.get("result"), res.get("error"), res.get("errors")] + text = " | ".join(p if isinstance(p, str) else json.dumps(p) for p in parts if p) + detail = "api_error_status %s: %s" % (status, text[:200] if text else "(no text)") + if status == 429: + return "usage_limit", detail + if status == 529: + return "overloaded", detail + for reason, rx in _API_ERROR_TEXT: + if rx.search(text): + return reason, detail + if status is not None: + return "api_error", detail + return None + + +def classify(ss: StreamState, exit_code, stopped: bool) -> tuple[str, str | None]: + """(status, reason) of a finished call; the first matching case wins.""" + res = ss.result + match (bool(stopped), ss.refused, res, api_error_reason(res), exit_code): + case (True, _, _, _, _): + return "stopped", "stopped" + case (_, str(), _, _, _): + return "error", "model_refused" + case (_, _, None, _, code): + return "error", "exit_code" if code else "no_result" + case (_, _, _, (reason, _), _): + return "error", reason + case (_, _, dict() as r, _, _) if r.get("is_error"): + return "error", "is_error" + case (_, _, _, _, code) if code: + return "error", "exit_code" + case _: + return "ok", None + + +def usage_from_result(result: dict, before_mu: dict, cost_before) -> dict: + """Tokens (with the 1h/5m split) from `result.usage`; `models_used` = modelUsage entries that + grew against the session's totals before the call, largest first; cost delta against the + session's cost before the call. Costs are rounded here, once, at 6 decimals.""" + result = result or {} + usage = result.get("usage") or {} + cc = usage.get("cache_creation") or {} + tokens = { + "input": int(usage.get("input_tokens") or 0), + "output": int(usage.get("output_tokens") or 0), + "cache_creation": int(usage.get("cache_creation_input_tokens") or 0), + "cache_read": int(usage.get("cache_read_input_tokens") or 0), + "cache_creation_1h": int(cc.get("ephemeral_1h_input_tokens") or 0), + "cache_creation_5m": int(cc.get("ephemeral_5m_input_tokens") or 0), + } + after = result.get("modelUsage") or {} + before_mu = before_mu or {} + delta = {} + for m, v in after.items(): + b = before_mu.get(m) or {} + delta[m] = {f: int((v or {}).get(f) or 0) - int(b.get(f) or 0) for f in MU_FIELDS} + grew = [m for m, d in delta.items() if any(x > 0 for x in d.values())] + models_used = sorted(grew, key=lambda m: (-sum(delta[m].values()), m)) + sums = {f: sum(d[f] for d in delta.values()) for f in MU_FIELDS} + mismatch = (sums["inputTokens"], sums["outputTokens"], sums["cacheCreationInputTokens"], + sums["cacheReadInputTokens"]) != (tokens["input"], tokens["output"], + tokens["cache_creation"], tokens["cache_read"]) + total = result.get("total_cost_usd") + if total is None: + cost_delta = None + else: + cost_delta = round(float(total) - float(cost_before or 0.0), 6) + total = round(float(total), 6) + return {"tokens": tokens, "models_used": models_used, "mismatch": mismatch, + "cost_delta": cost_delta, "total": total, "model_usage": after} + + +def cache_verdict(first_usage: dict | None, base_ctx) -> str | None: + """The first request's cache read against the base's context tokens.""" + if first_usage is None: + return None + cr = int(first_usage.get("cache_read_input_tokens") or 0) + if base_ctx and cr >= CACHE_HIT_RATIO * float(base_ctx): + return "hit" + return "partial" if cr > 0 else "miss" + + +def infer_ttl(tokens: dict | None, prev): + """The TTL of a base's cache entries from one call's tokens: 1h written -> 3600, only 5m + written -> 300, otherwise (read only, or nothing) `prev`.""" + if not tokens: + return prev + if int(tokens.get("cache_creation_1h") or 0) > 0: + return TTL_1H + if int(tokens.get("cache_creation_5m") or 0) > 0: + return TTL_5M + return prev + + +def wrote_only_5m(tokens: dict | None) -> bool: + """The call wrote 5-minute cache entries and no 1-hour ones.""" + return (bool(tokens) and int(tokens.get("cache_creation_5m") or 0) > 0 + and int(tokens.get("cache_creation_1h") or 0) == 0) + + +def warmth(last_hit_ts: float | None, ttl_s, now: float) -> dict: + """warm until `margin` before last_hit + ttl, expiring until last_hit + ttl, then cold; + margin = max(120, ttl/10). No hit at all is cold.""" + if last_hit_ts is None: + return {"base_state": "cold", "warm_until": None, "ttl_left_s": None} + ttl = float(ttl_s or TTL_1H) + margin = max(120.0, ttl / 10.0) + wu = float(last_hit_ts) + ttl + state = "warm" if now < wu - margin else ("expiring" if now < wu else "cold") + return {"base_state": state, "warm_until": iso(wu), "ttl_left_s": int(max(0.0, wu - now))} diff --git a/plugins/spire/skills/gremlins/preamble.md b/plugins/spire/skills/gremlins/preamble.md new file mode 100644 index 0000000..c1dbbdc --- /dev/null +++ b/plugins/spire/skills/gremlins/preamble.md @@ -0,0 +1,22 @@ +# Headless implementer (spire:gremlins) + +You run headless under `claude -p` in a Spire build: first as the trained base, then as gremlins spawned from it once per block (each a fork of the base's session). Any session of yours may later be resumed or forked again. Nobody can answer a question mid-run: decide, act, and record what you decided. + +- Do the work yourself. Never dispatch agents, and never start `claude` or the gremlins helper (`gremlins.py`). +- Never use `run_in_background` on Bash. Run every command in the foreground. +- Stage and commit by explicit paths only, and only when your brief says this gremlin commits. Never push, rebase or reset. +- Write gate and test output to `.txt` files under the state dir's `logs/` folder (the brief names the state dir). +- When you finish a block, write the handoff note to the path the brief names, in this format, and stamp your exact model id: + + ```markdown + ### Block — + + - Model: <exact model id> + - Start / end: <`date` output> / <`date` output> + - Done: <what changed, in a few lines> + - Deviations from plan: <what, and why> — or "none" + - Flagged for review: <anything the reviewer should look at> + - Left for later: <work deferred to a later block or phase> + - Expected broken: <areas that do not build or pass yet, and which block fixes them> + - Commits: <hashes> — or "none" when your brief says you do not commit + ``` diff --git a/plugins/spire/skills/gremlins/templates/rebase.md b/plugins/spire/skills/gremlins/templates/rebase.md new file mode 100644 index 0000000..4076b82 --- /dev/null +++ b/plugins/spire/skills/gremlins/templates/rebase.md @@ -0,0 +1,17 @@ +# Rebase (spire:gremlins) + +This session is a fork of base `${base}` (run log `${run}`). When you finish, this session becomes the new base `${new_base}` (phase ${phase}): every later gremlin of this build starts from what you know at the end of it. Your job is to absorb what changed since `${base}` was trained or last rebased. + +Your system prompt was snapshotted when the base was trained: the git status, the CLAUDE.md files and the environment it shows are **stale**. The delta header below lists what changed in the repository since then. Trust it and the files on disk over the snapshot and over what you remember. + +- Read the handoff notes and the diffs the brief lists (`git --no-optional-locks diff`, `git --no-optional-locks log -p`), and re-read the files they touch. Handoff notes live under `${notes_dir}`. +- Do not change code and do not commit. If the brief names a trunk-notes path, update that file; it is the only file you write. +- Finish with one line `READY`, then a short summary of what changed and anything the brief asked for that you could not do. + +## Delta since the base commit + +${delta} + +## Brief + +${brief} diff --git a/plugins/spire/skills/gremlins/templates/spawn.md b/plugins/spire/skills/gremlins/templates/spawn.md new file mode 100644 index 0000000..f6008d1 --- /dev/null +++ b/plugins/spire/skills/gremlins/templates/spawn.md @@ -0,0 +1,19 @@ +# Block ${block} (spire:gremlins) + +You are a gremlin spawned from base `${base}` (a fork of its session; run log `${run}`), now working on block `${block}`. + +Your system prompt was snapshotted when the base was trained: the git status, the CLAUDE.md files and the environment it shows are **stale**. The delta header below lists what changed in the repository since then. Trust it and the files on disk over the snapshot and over what you remember from training; re-read every file before you change it. + +## Delta since training + +${delta} + +## Rules for this gremlin + +- Commit only if the brief below says so. +- Write your handoff note to `${note_path}`. +- Write gate and test output to `.txt` files under `${logs_dir}`. + +## Brief + +${brief} diff --git a/plugins/spire/skills/gremlins/templates/train.md b/plugins/spire/skills/gremlins/templates/train.md new file mode 100644 index 0000000..df92d9e --- /dev/null +++ b/plugins/spire/skills/gremlins/templates/train.md @@ -0,0 +1,11 @@ +# Training (spire:gremlins) + +You are base `${base}` of the build whose run log is `${run}`. This session is trained once; then one gremlin per block is spawned from it by forking it, and every gremlin starts from what you know at the end of this session. Build an accurate picture of the code the brief below points you at: read it, trace the seams, note the invariants and the traps. + +- Do not change code in this session. If the brief asks for trunk notes, write them to the path it names; that is the only file you write. +- The helper's state dir is `${state_dir}`. Gremlins write handoff notes under `${notes_dir}` and gate and test output under `${logs_dir}`. +- Finish with one line `READY`, then a short summary of what you read and anything the brief asked for that you could not do. + +## Brief + +${brief} From 9209e7d6f69c39e5696504a06ad189caa8f3a88e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= <gab@g4b.org> Date: Fri, 16 Oct 2026 23:29:09 +0200 Subject: [PATCH 02/10] =?UTF-8?q?=E2=9C=A8=20Gremlins=20MVP:=20cli=20and?= =?UTF-8?q?=20entry=20point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/spire/skills/gremlins/gremlins.py | 14 + plugins/spire/skills/gremlins/grm/cli.py | 359 ++++++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 plugins/spire/skills/gremlins/gremlins.py create mode 100644 plugins/spire/skills/gremlins/grm/cli.py diff --git a/plugins/spire/skills/gremlins/gremlins.py b/plugins/spire/skills/gremlins/gremlins.py new file mode 100644 index 0000000..041b2e5 --- /dev/null +++ b/plugins/spire/skills/gremlins/gremlins.py @@ -0,0 +1,14 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""spire:gremlins helper: train a headless base, fork gremlins from it. See SKILL.md.""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from grm import cli # noqa: E402 + +if __name__ == "__main__": + sys.exit(cli.main(sys.argv[1:])) diff --git a/plugins/spire/skills/gremlins/grm/cli.py b/plugins/spire/skills/gremlins/grm/cli.py new file mode 100644 index 0000000..4d01c93 --- /dev/null +++ b/plugins/spire/skills/gremlins/grm/cli.py @@ -0,0 +1,359 @@ +"""argparse and the commands; each launch is record -> one `claude -p` call -> record.""" +import argparse +import json +import os +import re +import string +import subprocess +import sys +import time +import uuid +from pathlib import Path + +from grm import call, launch, store, stream + +SKILL_DIR = Path(__file__).resolve().parent.parent +NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") +NOTE_SLACK_S = 2.0 # coarse file times: a note written right at the start still counts +EXCERPT_CHARS = 500 +LAUNCHES = ("train", "spawn", "rebase") + + +class Refusal(Exception): + """Refused before or instead of a launch: exit 2, payload printed as the JSON object.""" + + def __init__(self, error: str, **fields): + super().__init__(error) + self.payload = {"error": error, **fields} + + +class _Parser(argparse.ArgumentParser): + def error(self, message): + raise Refusal("usage", detail=message) + + +def _emit(obj: dict) -> None: + sys.stdout.write(json.dumps(obj) + "\n") + sys.stdout.flush() + + +def render_template(name: str, values: dict) -> str: + with open(SKILL_DIR / "templates" / name, "r", encoding="utf-8") as f: + return string.Template(f.read()).safe_substitute(values) + + +def git(args, cwd=None, timeout=60) -> subprocess.CompletedProcess: + """Every helper git call: `git --no-optional-locks …`, output captured.""" + return subprocess.run(["git", "--no-optional-locks", *args], cwd=cwd, stdin=subprocess.DEVNULL, + capture_output=True, timeout=timeout) + + +def _git_out(args, cwd) -> str | None: + try: + r = git(args, cwd=cwd) + except (OSError, subprocess.SubprocessError): + return None + out = r.stdout.decode("utf-8", "replace").strip() + return out if r.returncode == 0 and out else None + + +def git_head(cwd) -> str | None: + return _git_out(["rev-parse", "HEAD"], cwd) + + +def delta_header(cwd, base_commit: str | None) -> str: + """Commits since the base's commit plus the working tree's shortstat against it.""" + if not base_commit: + return ("No base commit is recorded (the base's cwd was not a git repository, or git failed " + "at training). Treat everything on disk as possibly changed since training.") + parts = [] + try: + lg = git(["log", "--oneline", "%s..HEAD" % base_commit], cwd=cwd) + st = git(["diff", "--shortstat", base_commit], cwd=cwd) + log_txt = lg.stdout.decode("utf-8", "replace").strip() if lg.returncode == 0 else \ + "(git log failed: %s)" % lg.stderr.decode("utf-8", "replace").strip() + st_txt = st.stdout.decode("utf-8", "replace").strip() if st.returncode == 0 else \ + "(git diff failed: %s)" % st.stderr.decode("utf-8", "replace").strip() + except (OSError, subprocess.SubprocessError) as e: + log_txt = st_txt = "(git failed: %s)" % e + parts.append("Base commit: `%s`." % base_commit) + parts.append("Commits since then (`git log --oneline %s..HEAD`):\n\n```\n%s\n```" + % (base_commit[:12], log_txt or "(none)")) + parts.append("Working tree against the base commit (`git diff --shortstat %s`):\n\n```\n%s\n```" + % (base_commit[:12], st_txt or "(no changes)")) + return "\n\n".join(parts) + + +def _name(kind: str, value: str) -> str: + if not NAME_RE.match(value or ""): + raise Refusal("bad %s name" % kind, **{kind: value}) + return value + + +def _read_brief(path: str) -> str: + try: + with open(path, "r", encoding="utf-8") as f: + return f.read() + except OSError as e: + raise Refusal("cannot read brief", brief=path, detail=str(e)) + + +def _load_run(run: str) -> tuple[Path, dict]: + """State dir and run.json of a launch; the stored binary must still exist (no job file yet).""" + state = store.state_dir(run) + r = store.read_json(state / "run.json") + if r is None: + raise Refusal("no run.json for this run; call init first", state=str(state)) + if not os.path.isfile(r["argv"][0]): + raise Refusal("claude binary missing", binary=r["argv"][0]) + return state, r + + +def _load_base(state: Path, b: str) -> dict: + base = store.read_json(state / "bases" / (b + ".json")) + if base is None: + raise Refusal("unknown base", base=b, bases=sorted(p.stem for p in (state / "bases").glob("*.json"))) + return base + + +def _warmth(lines: list, base: dict, now: float) -> dict: + """B6: from the ledger line of this base whose call read the base prefix last.""" + hits = [ln for ln in lines if ln.get("base") == base["base"] and stream.parse_iso(ln.get("hit_at"))] + if not hits: + return {**stream.warmth(None, None, now), "ttl_s": base.get("ttl_s")} + last = max(hits, key=lambda ln: stream.parse_iso(ln["hit_at"])) + ttl = stream.infer_ttl(last.get("tokens"), base.get("ttl_s")) or stream.TTL_1H + return {**stream.warmth(stream.parse_iso(last["hit_at"]), ttl, now), "ttl_s": ttl} + + +def _execute(run: str, state: Path, r: dict, *, job, kind, base, sid, argv, prompt, + before=(None, 0.0), base_ctx=None): + """One call: job file `running` -> run_claude -> job file final + one ledger line. + Returns (job record, StreamState|None, usage|None, ledger line|None).""" + fp = r["fingerprint"] + jpath = state / "jobs" / (job + ".json") + raw = state / "raw" / (job + ".jsonl") + store.atomic_write_bytes(state / "briefs" / (job + ".md"), prompt.encode("utf-8")) + t0 = time.time() + rec = {"job": job, "kind": kind, "base": base, "session_id": sid, "status": "running", + "reason": None, "started": stream.iso(t0), "ended": None, "child_pid": None, + "raw": str(raw), "note": None, "cache": None, "result_excerpt": None} + store.write_json(jpath, rec) + + def on_start(pid): + rec["child_pid"] = pid + store.write_json(jpath, rec) + try: + ss, code, stopped = call.run_claude(argv, prompt, launch.scrubbed_env(fp["binary"]), + fp["cwd"], raw, on_start) + except OSError as e: + rec.update(status="error", reason="launch_error", ended=stream.iso(), + result_excerpt=str(e)[:EXCERPT_CHARS]) + store.write_json(jpath, rec) + return rec, None, None, None + wall = time.time() - t0 + status, reason = stream.classify(ss, code, stopped) + u = stream.usage_from_result(ss.result, *before) if ss.result else None + cache = stream.cache_verdict(ss.first_usage, base_ctx) if kind != "train" else None + hit_t = None + if kind in ("train", "rebase") and status == "ok": + hit_t = ss.last_assistant_at + elif kind == "spawn" and cache in ("hit", "partial"): + hit_t = ss.first_at + note = None + if kind == "spawn": + p = state / "notes" / (job + ".md") + if p.is_file() and p.stat().st_mtime >= t0 - NOTE_SLACK_S: + note = str(p) + text = (ss.result or {}).get("result") + rec.update(session_id=ss.session_id or sid, status=status, reason=reason, ended=stream.iso(), + cache=cache, note=note, + result_excerpt=text[:EXCERPT_CHARS] if isinstance(text, str) else None) + store.write_json(jpath, rec) + models = u["models_used"] if u else [] + line = {"ts": rec["ended"], "run": r["run"], "job": job, "kind": kind, "base": base, + "session_id": rec["session_id"], "model": models[0] if models else None, + "models_used": models, + "tokens": u["tokens"] if u else {k: None for k in stream.TOKEN_KEYS}, + "cost_delta_usd": u["cost_delta"] if u else None, + "cost_cumulative_usd": u["total"] if u else None, "cache": cache, "status": status, + "wall_s": round(wall, 1), "hit_at": stream.iso(hit_t) if hit_t else None} + store.ledger_append(run, line) + return rec, ss, u, line + + +def _cost_basis(ss) -> float | None: + """The base's cost basis stays unrounded: deltas against a rounded basis drift by 1e-6.""" + total = (ss.result or {}).get("total_cost_usd") + return None if total is None else float(total) + + +def _out(rec: dict, line: dict | None) -> dict: + out = dict(rec) + if line: + out.update(model=line["model"], models_used=line["models_used"], tokens=line["tokens"], + cost=line["cost_delta_usd"], cost_cumulative_usd=line["cost_cumulative_usd"]) + return out + + +def cmd_init(args) -> dict: + """B1: validate, then write the frozen preamble copy, the empty MCP config and run.json once.""" + state = store.state_dir(args.run) + if (state / "run.json").exists(): + raise Refusal("run.json exists; init never overwrites a run", state=str(state)) + binary = launch.resolve_binary(args.binary) + if not binary or not os.path.isfile(binary): + raise Refusal("no claude binary (no --binary, no $CLAUDE_CODE_EXECPATH, none on PATH)", + binary=binary) + cwd = os.path.abspath(args.cwd) if args.cwd else _git_out(["rev-parse", "--show-toplevel"], os.getcwd()) + if not cwd or not os.path.isdir(cwd): + raise Refusal("no usable cwd (pass --cwd, or run inside a git repository)", cwd=cwd) + if not launch.MODEL_RE.match(args.model): # before anything is written + raise Refusal("model must be a full id (aliases are refused)", model=args.model) + state = store.init_state(args.run) + pre = state / "preamble.md" + store.atomic_write_bytes(pre, (SKILL_DIR / "preamble.md").read_bytes()) + mcp = state / "mcp-empty.json" + store.write_json(mcp, {"mcpServers": {}}) + fpd = launch.build_fingerprint(binary=binary, model=args.model, effort=args.effort, + cwd=os.path.abspath(cwd), agent=args.agent, + preamble_path=str(pre), mcp_config=str(mcp)) + run = {"run": os.path.abspath(args.run), "created": stream.iso(), **fpd} + store.write_json(state / "run.json", run) + return {"state": str(state), **run} + + +def cmd_train(args) -> dict: + """B1: one blocking call with `--session-id`; the base file only on `ok`.""" + b = _name("base", args.base) + state, r = _load_run(args.run) + bpath = state / "bases" / (b + ".json") + if bpath.exists(): + raise Refusal("base exists", base=b) + brief = _read_brief(args.brief) + sid, job = str(uuid.uuid4()), b + "-train" + prompt = render_template("train.md", { + "base": b, "run": r["run"], "state_dir": str(state), "notes_dir": str(state / "notes"), + "logs_dir": str(state / "logs"), "brief": brief}) + rec, ss, u, line = _execute(args.run, state, r, job=job, kind="train", base=b, sid=sid, + argv=launch.train_argv(r, sid), prompt=prompt) + if rec["status"] == "ok": + store.write_json(bpath, { + "base": b, "session_id": rec["session_id"], "train_job": job, + "commit": git_head(r["fingerprint"]["cwd"]), "trained_at": rec["ended"], + "context_tokens": stream.context_tokens(ss.last_usage), "model_usage": u["model_usage"], + "cost_cumulative_usd": _cost_basis(ss), "ttl_s": stream.infer_ttl(u["tokens"], None), + "rebases": []}) + return _out(rec, line) + + +def cmd_spawn(args) -> dict: + """B2, B3, B5: one blocking fork of the base; never writes the base file.""" + b, j = _name("base", args.base), _name("job", args.job) + state, r = _load_run(args.run) + base = _load_base(state, b) + if (state / "jobs" / (j + ".json")).exists(): + raise Refusal("job exists; pick a new --job", job=j) + brief = _read_brief(args.brief) + warm = _warmth(store.ledger_lines(args.run), base, time.time()) + sid = str(uuid.uuid4()) + prompt = render_template("spawn.md", { + "block": j, "base": b, "run": r["run"], + "delta": delta_header(r["fingerprint"]["cwd"], base.get("commit")), + "note_path": str(state / "notes" / (j + ".md")), "logs_dir": str(state / "logs"), + "brief": brief}) + rec, _, _, line = _execute(args.run, state, r, job=j, kind="spawn", base=b, sid=sid, + argv=launch.fork_argv(r, base["session_id"], sid), prompt=prompt, + before=(base.get("model_usage"), base.get("cost_cumulative_usd")), + base_ctx=base.get("context_tokens")) + return {**_out(rec, line), "base_state": warm["base_state"]} + + +def cmd_rebase(args) -> dict: + """B7: resume the base without a fork; on `ok` the base takes the result's session id.""" + b = _name("base", args.base) + state, r = _load_run(args.run) + base = _load_base(state, b) + brief = _read_brief(args.brief) + running = sorted(rec["job"] for rec in map(store.read_json, (state / "jobs").glob("*.json")) + if rec and rec.get("base") == b and rec.get("status") == "running") + warnings = ["jobs of %s still marked running: %s" % (b, ", ".join(running))] if running else [] + job = "%s-rebase%d" % (b, len(base.get("rebases") or []) + 1) + cwd = r["fingerprint"]["cwd"] + prompt = render_template("rebase.md", { + "base": b, "new_base": b, "phase": "n/a", "run": r["run"], "notes_dir": str(state / "notes"), + "delta": delta_header(cwd, base.get("commit")), "brief": brief}) + rec, ss, u, line = _execute(args.run, state, r, job=job, kind="rebase", base=b, sid=None, + argv=launch.resume_argv(r, base["session_id"]), prompt=prompt, + before=(base.get("model_usage"), base.get("cost_cumulative_usd")), + base_ctx=base.get("context_tokens")) + if rec["status"] == "ok": + base["rebases"] = list(base.get("rebases") or []) + [{ + "job": job, "at": rec["ended"], "from_commit": base.get("commit"), + "from_session": base["session_id"]}] + base.update(session_id=rec["session_id"] or base["session_id"], commit=git_head(cwd), + context_tokens=stream.context_tokens(ss.last_usage), model_usage=u["model_usage"], + cost_cumulative_usd=_cost_basis(ss), ttl_s=stream.infer_ttl(u["tokens"], base.get("ttl_s"))) + store.write_json(state / "bases" / (b + ".json"), base) + return {**_out(rec, line), "warnings": warnings} + + +def cmd_status(args) -> dict: + """B6 (warmth per base) and B3 (re-reading a job's result).""" + state = store.state_dir(args.run) + if not (state / "run.json").exists(): + raise Refusal("no run.json for this run; call init first", state=str(state)) + if args.job: + rec = store.read_json(state / "jobs" / (_name("job", args.job) + ".json")) + if rec is None: + raise Refusal("unknown job", job=args.job) + return rec + lines, now = store.ledger_lines(args.run), time.time() + bases = {} + for p in sorted((state / "bases").glob("*.json")): + base = store.read_json(p) + bases[base["base"]] = {**{k: base.get(k) for k in ("session_id", "commit", "context_tokens")}, + **_warmth(lines, base, now)} + jobs = [store.read_json(p) for p in sorted((state / "jobs").glob("*.json"))] + return {"state": str(state), "bases": bases, "jobs": jobs} + + +COMMANDS = {"init": cmd_init, "train": cmd_train, "spawn": cmd_spawn, "rebase": cmd_rebase, + "status": cmd_status} + + +def _parser() -> argparse.ArgumentParser: + p = _Parser(prog="gremlins.py", description="spire:gremlins helper; prints one JSON object.") + sub = p.add_subparsers(dest="cmd", required=True) + s = sub.add_parser("init") + s.add_argument("--model", required=True) + s.add_argument("--effort", default="high", choices=launch.EFFORTS) + s.add_argument("--cwd") + s.add_argument("--agent") + s.add_argument("--binary") + for name in LAUNCHES: + s2 = sub.add_parser(name) + s2.add_argument("--base", required=True) + if name == "spawn": + s2.add_argument("--job", required=True) + s2.add_argument("--brief", required=True) + sst = sub.add_parser("status") + sst.add_argument("--job") + for sp in sub.choices.values(): + sp.add_argument("--run", required=True) + return p + + +def main(argv=None) -> int: + """Exit 0 ok, 1 a call that ended `error`/`stopped`, 2 refused or a usage error.""" + try: + args = _parser().parse_args(argv) + out = COMMANDS[args.cmd](args) + except Refusal as e: + _emit(e.payload) + return 2 + except (ValueError, OSError) as e: + _emit({"error": str(e), "type": type(e).__name__}) + return 2 + _emit(out) + return 1 if args.cmd in LAUNCHES and out.get("status") != "ok" else 0 From fd757776288b8fdf560c31edd6cab39e45b6bfd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= <gab@g4b.org> Date: Fri, 16 Oct 2026 23:29:09 +0200 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=A4=96=20Gremlins=20MVP:=20SKILL.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/spire/skills/gremlins/SKILL.md | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 plugins/spire/skills/gremlins/SKILL.md diff --git a/plugins/spire/skills/gremlins/SKILL.md b/plugins/spire/skills/gremlins/SKILL.md new file mode 100644 index 0000000..1c1c20a --- /dev/null +++ b/plugins/spire/skills/gremlins/SKILL.md @@ -0,0 +1,54 @@ +--- +name: gremlins +description: Trains one headless implementer (the base) per build with `claude -p` and forks a gremlin from it per block, so each gremlin starts from the base's reading at prompt-cache prices. +allowed-tools: Bash(uv:*) +--- + +# Gremlins + +A **base** is a headless `claude -p` session that reads the code a build touches, writes the trunk notes and changes nothing. A **gremlin** is a fork of the base's session (`--resume <base> --fork-session`) that works on one block with a brief. + +## Commands + +`G` = `uv run --script "${CLAUDE_SKILL_DIR}/gremlins.py"`. Every command prints one JSON object on stdout. Exit codes: 0 ok, 1 the call ended `error` or `stopped`, 2 refused or a usage error. + +| Command | Does | Boxes | +|---|---|---| +| `G init --run R --model M [--effort E] [--cwd D] [--agent A] [--binary P]` | Writes `run.json`: the launch fingerprint (binary, full model id, flags, cwd, appended prompt) and the argv, stored once. Refuses to overwrite an existing run. | B1 | +| `G train --run R --base B --brief F` | One blocking call with `--session-id`; on `ok` it writes `bases/B.json`. Prints `{status, session_id, cost, …}`. | B1 | +| `G spawn --run R --base B --job J --brief F` | One blocking fork of B, reusing the stored argv verbatim. The prompt is the spawn template plus a delta header (git changes since the base's commit) plus the brief. Prints `{status, cache, note, cost, …}`. | B2, B3, B5 | +| `G rebase --run R --base B --brief F` | Resumes B without a fork, with the delta header and the brief, then updates `bases/B.json` (the session id comes from the result). Warns when jobs of B are still `running`. | B7 | +| `G status --run R [--job J]` | Bases with their warmth (`warm`/`expiring`/`cold`, `warm_until`, `ttl_left_s`), and the job records. | B6, B3 | + +`R` is the run log `.md`; `M` is a full model id (for example `claude-opus-5-5[1m]`; aliases are refused). The helper has no `kill`, `wait`, `ping` or `continue` command. + +## Call sequence + +1. `G init …` (foreground). +2. `G train …` as a blocking Bash call with `timeout: 600000`. If it runs past the timeout, the harness moves it to the background, and the completion notice carries the result. +3. Each `G spawn …` as its own Bash call with `run_in_background: true`. Several spawns run at once as several background calls. Read the result JSON from each completion notice; do not poll. +4. `G status …` to see warmth before spawning again, or to re-read a job record. + +Every call is reported in `ledger.jsonl` with the real model (from `modelUsage`), four token columns and the cost delta against the base (B5). Warmth is reported only; nothing keeps a base warm (B6). + +## Stopping a gremlin + +Use `TaskStop` on the backgrounded Bash call (B4). That kills `claude` and its bash wrappers. Known leftover (spec §7): a command the gremlin had in flight (for example a long test run through its Bash) can survive as an orphan and run to its own end. After a hard stop, the job file may stay `running` with no ledger line. You know which calls you stopped, so `status` shows that record as it was written. + +## Briefs carry the rules + +The spawn template sets no commit rule. Each brief states whether that gremlin commits, which paths it may touch, and what its gate is. For gremlins that run in parallel, say "do not commit" and list the paths. + +## State dir + +`<run log without .md>-gremlins/`, next to the run log: + +- `run.json` (written once by `init`), `mcp-empty.json`, `preamble.md` (a frozen copy, so the system prompt and the cache stay stable) +- `bases/<B>.json` (written by `train` and `rebase` only); `jobs/<J>.json` (each written by its own command) +- `ledger.jsonl` (one appended line per finished or stopped call) +- `raw/<J>.jsonl` (the stream) and `raw/<J>.err`; `briefs/<J>.md` (the full prompt sent) +- `notes/<J>.md` (the gremlins' handoff notes); `logs/` (their gate and test output) + +## Cache hits + +A fork hits the base's cache only with the same argv, the same pinned env, the same cwd and an unchanged system prompt. `spawn` reports `cache: hit|partial|miss`; a miss is reported, not an error. The env scrub removes Desktop, SDK and uv variables from every launch. The helper uses no platform-specific process code, and it runs on Windows Git Bash, WSL, Linux and macOS (B8). From d662790f19731bd18aadf73630ba0e7f75e184a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= <gab@g4b.org> Date: Fri, 16 Oct 2026 23:33:02 +0200 Subject: [PATCH 04/10] =?UTF-8?q?=E2=9C=85=20Gremlins=20MVP:=20unit=20and?= =?UTF-8?q?=20CLI=20tests=20with=20a=20fake=20claude?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/gremlins/tests/fake_claude.py | 64 ++++++ .../spire/skills/gremlins/tests/test_call.py | 57 +++++ .../spire/skills/gremlins/tests/test_cli.py | 211 ++++++++++++++++++ .../skills/gremlins/tests/test_launch.py | 51 +++++ .../spire/skills/gremlins/tests/test_store.py | 36 +++ .../skills/gremlins/tests/test_stream.py | 74 ++++++ 6 files changed, 493 insertions(+) create mode 100644 plugins/spire/skills/gremlins/tests/fake_claude.py create mode 100644 plugins/spire/skills/gremlins/tests/test_call.py create mode 100644 plugins/spire/skills/gremlins/tests/test_cli.py create mode 100644 plugins/spire/skills/gremlins/tests/test_launch.py create mode 100644 plugins/spire/skills/gremlins/tests/test_store.py create mode 100644 plugins/spire/skills/gremlins/tests/test_stream.py diff --git a/plugins/spire/skills/gremlins/tests/fake_claude.py b/plugins/spire/skills/gremlins/tests/fake_claude.py new file mode 100644 index 0000000..401a9f8 --- /dev/null +++ b/plugins/spire/skills/gremlins/tests/fake_claude.py @@ -0,0 +1,64 @@ +"""Fake `claude -p` for the CLI tests. $FAKE_CLAUDE_SCENARIO: ok (default), miss, api_error, +noresult, two_models, no_cost, sleep. $FAKE_CLAUDE_LOG: one JSON line per call (argv, pid).""" +import json +import os +import sys +import time +import uuid + +TRAIN_MU = {"inputTokens": 10, "outputTokens": 20, "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 1000} +TRAIN_COST, CALL_COST = 0.05, 0.02 + + +def opt(name): + return sys.argv[sys.argv.index(name) + 1] if name in sys.argv else None + + +def emit(ev): + print(json.dumps(ev), flush=True) + + +def main(): + prompt = sys.stdin.buffer.read().decode("utf-8") + scen = os.environ.get("FAKE_CLAUDE_SCENARIO", "ok") + if os.environ.get("FAKE_CLAUDE_LOG"): + with open(os.environ["FAKE_CLAUDE_LOG"], "a", encoding="utf-8") as f: + f.write(json.dumps({"argv": sys.argv, "pid": os.getpid(), "prompt_chars": len(prompt)}) + "\n") + resume, model = opt("--resume"), opt("--model") + # a plain resume (no fork, no --session-id) gets a new id, as `-p` may give one + sid = opt("--session-id") or ("resumed-" + uuid.uuid4().hex if resume else str(uuid.uuid4())) + read = 1000 if resume and scen != "miss" else 0 + created = 100 if resume else 1000 + usage = {"input_tokens": 10, "output_tokens": 20, "cache_creation_input_tokens": created, + "cache_read_input_tokens": read, + "cache_creation": {"ephemeral_1h_input_tokens": created, "ephemeral_5m_input_tokens": 0}} + emit({"type": "system", "subtype": "init", "session_id": sid, "model": model}) + emit({"type": "assistant", "session_id": sid, "message": {"id": "m1", "usage": usage}}) + if scen == "sleep": + time.sleep(60) + if scen == "noresult": + sys.exit(1) + mu = {"inputTokens": 10, "outputTokens": 20, "cacheReadInputTokens": read, + "cacheCreationInputTokens": created} + cost = CALL_COST if resume else TRAIN_COST + if resume: # the session's totals include the base's training call + mu = {k: v + TRAIN_MU[k] for k, v in mu.items()} + cost += TRAIN_COST + res = {"type": "result", "subtype": "success", "is_error": False, "session_id": sid, + "result": "done", "num_turns": 1, "usage": usage, "modelUsage": {model: mu}, + "total_cost_usd": cost} + if scen == "two_models": + res["modelUsage"]["claude-haiku-5-5"] = {"inputTokens": 5, "outputTokens": 1, + "cacheReadInputTokens": 0, "cacheCreationInputTokens": 0} + if scen == "no_cost": + del res["total_cost_usd"] + if scen == "api_error": + res.update(subtype="error_during_execution", is_error=True, api_error_status=429, + result="API Error: 429 rate limit") + emit(res) + sys.exit(1 if scen == "api_error" else 0) + + +if __name__ == "__main__": + main() diff --git a/plugins/spire/skills/gremlins/tests/test_call.py b/plugins/spire/skills/gremlins/tests/test_call.py new file mode 100644 index 0000000..1d6f091 --- /dev/null +++ b/plugins/spire/skills/gremlins/tests/test_call.py @@ -0,0 +1,57 @@ +import os +import signal +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from grm import call # noqa: E402 + +CHILD = ("import json, sys, time\n" + "p = sys.stdin.buffer.read().decode('utf-8')\n" + "print(json.dumps({'type': 'system', 'subtype': 'init', 'session_id': p}), flush=True)\n" + "if p == 'sleep': time.sleep(30)\n" + "print(json.dumps({'type': 'result', 'session_id': p}), flush=True)\n") + + +class RunClaudeTest(unittest.TestCase): + def _run(self, d, prompt): + pids = [] + raw = Path(d) / "j.jsonl" + out = call.run_claude([sys.executable, "-c", CHILD], prompt, dict(os.environ), d, + raw, pids.append) + return out, raw, pids + + def test_tee_and_parse(self): + """B1: prompt goes in on stdin; the stream is parsed and teed to the raw file.""" + with tempfile.TemporaryDirectory() as d: + (ss, code, stopped), raw, pids = self._run(d, "héllo") + self.assertEqual((code, stopped, len(pids)), (0, False, 1)) + self.assertEqual(ss.session_id, "héllo") + self.assertIsNotNone(ss.result) + self.assertEqual(len(raw.read_bytes().splitlines()), 2) + + def test_sigterm_stops_child(self): + """B4: SIGTERM stops the child; stopped is set and the partial raw file is kept.""" + with tempfile.TemporaryDirectory() as d: + timer = threading.Timer(1.5, signal.raise_signal, (signal.SIGTERM,)) + t0 = time.time() + timer.start() + try: + (ss, code, stopped), raw, pids = self._run(d, "sleep") + finally: + timer.cancel() + self.assertTrue(stopped) + self.assertIsNotNone(code) # the child has exited + self.assertLess(time.time() - t0, 20) + self.assertIsNone(ss.result) + self.assertEqual(ss.session_id, "sleep") + self.assertIn(b'"init"', raw.read_bytes()) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/spire/skills/gremlins/tests/test_cli.py b/plugins/spire/skills/gremlins/tests/test_cli.py new file mode 100644 index 0000000..91f42f2 --- /dev/null +++ b/plugins/spire/skills/gremlins/tests/test_cli.py @@ -0,0 +1,211 @@ +import contextlib +import io +import json +import os +import shutil +import signal +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path +from unittest import mock + +SKILL = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(SKILL)) + +from grm import cli # noqa: E402 + +FAKE = str(SKILL / "tests" / "fake_claude.py") +MODEL = "claude-opus-5-5" + + +class CliTest(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + repo = self.tmp / "repo" + repo.mkdir() + for a in (["init", "-q"], ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", + "--allow-empty", "-m", "x"]): + subprocess.run(["git", *a], cwd=repo, check=True, capture_output=True) + self.run_md = str(self.tmp / "run.md") + self.state = self.tmp / "run-gremlins" + self.log = self.tmp / "fake.jsonl" + self.brief = self.tmp / "brief.md" + self.brief.write_text("do the thing\n", encoding="utf-8") + env = mock.patch.dict(os.environ, {"FAKE_CLAUDE_LOG": str(self.log), "FAKE_CLAUDE_SCENARIO": "ok"}) + env.start() + self.addCleanup(env.stop) + code, out = self.g("init", "--run", self.run_md, "--model", MODEL, "--cwd", str(repo), "--binary", sys.executable) + self.assertEqual(code, 0, out) + run = json.loads((self.state / "run.json").read_text(encoding="utf-8")) + run["argv"] = [sys.executable, FAKE] + run["argv"][1:] + (self.state / "run.json").write_text(json.dumps(run), encoding="utf-8") + self.stored = run["argv"] + + def g(self, *args, scenario=None): + if scenario: + os.environ["FAKE_CLAUDE_SCENARIO"] = scenario + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + code = cli.main(list(args)) + os.environ["FAKE_CLAUDE_SCENARIO"] = "ok" + return code, json.loads(buf.getvalue().splitlines()[-1]) + + def train(self, scenario=None): + return self.g("train", "--run", self.run_md, "--base", "B1", "--brief", str(self.brief), + scenario=scenario) + + def spawn(self, job, base="B1", scenario=None): + return self.g("spawn", "--run", self.run_md, "--base", base, "--job", job, + "--brief", str(self.brief), scenario=scenario) + + def calls(self): + if not self.log.exists(): + return [] + return [json.loads(ln) for ln in self.log.read_text(encoding="utf-8").splitlines()] + + def ledger(self): + return [json.loads(ln) for ln in (self.state / "ledger.jsonl").read_text(encoding="utf-8").splitlines()] + + def base_rec(self): + p = self.state / "bases" / "B1.json" + return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None + + def test_train_spawn_happy(self): + """B1, B2, B5: base keeps the --session-id; fork argv = stored argv + resume/fork; ledger.""" + code, out = self.train() + self.assertEqual((code, out["status"]), (0, "ok")) + sid = self.calls()[0]["argv"][-1] + # the fake logs its sys.argv: the stored argv without the interpreter + self.assertEqual(self.calls()[0]["argv"], self.stored[1:] + ["--session-id", sid]) + self.assertEqual(self.base_rec()["session_id"], sid) + code, out = self.spawn("1.1") + self.assertEqual((code, out["status"], out["cache"]), (0, "ok", "hit")) + argv = self.calls()[1]["argv"] + n = len(self.stored) - 1 + self.assertEqual(argv[:n], self.stored[1:]) + self.assertEqual(argv[n:n + 3], ["--resume", sid, "--fork-session"]) + line = self.ledger()[-1] + self.assertEqual((line["model"], line["job"], line["cache"]), (MODEL, "1.1", "hit")) + for k in ("input", "output", "cache_creation", "cache_read"): + self.assertIsInstance(line["tokens"][k], int) + self.assertAlmostEqual(line["cost_delta_usd"], + line["cost_cumulative_usd"] - self.base_rec()["cost_cumulative_usd"], places=6) + self.assertAlmostEqual(line["cost_delta_usd"], 0.02, places=6) + + def test_missing_binary(self): + """B1: a missing binary is refused before launch; no job file.""" + run = json.loads((self.state / "run.json").read_text(encoding="utf-8")) + run["argv"][0] = str(self.tmp / "nope.exe") + (self.state / "run.json").write_text(json.dumps(run), encoding="utf-8") + code, out = self.train() + self.assertEqual(code, 2) + self.assertIn("binary", out["error"]) + self.assertEqual(list((self.state / "jobs").iterdir()), []) + self.assertEqual(self.calls(), []) + + def test_train_failures(self): + """B1: no result + exit 1, and an API error result: status error, no base file.""" + for scenario, reason in (("noresult", "exit_code"), ("api_error", "usage_limit")): + code, out = self.train(scenario) + self.assertEqual((code, out["status"], out["reason"]), (1, "error", reason), scenario) + self.assertIsNone(self.base_rec()) + + def test_unknown_base(self): + """B2: an unknown base is refused with no launch.""" + code, out = self.spawn("1.1", base="NOPE") + self.assertEqual((code, out["error"]), (2, "unknown base")) + self.assertEqual(self.calls(), []) + self.assertFalse((self.state / "jobs" / "1.1.json").exists()) + + def test_cache_miss_is_reported(self): + """B2: first-usage cache_read 0 is `cache: miss`, not an error.""" + self.train() + code, out = self.spawn("1.1", scenario="miss") + self.assertEqual((code, out["status"], out["cache"]), (0, "ok", "miss")) + + def test_parallel_spawns(self): + """B3: two spawns in parallel threads give two job files and two whole ledger lines.""" + self.train() + ts = [threading.Thread(target=cli.main, args=(["spawn", "--run", self.run_md, "--base", "B1", + "--job", j, "--brief", str(self.brief)],)) + for j in ("p1", "p2")] + with contextlib.redirect_stdout(io.StringIO()): + for t in ts: + t.start() + for t in ts: + t.join(60) + for j in ("p1", "p2"): + rec = json.loads((self.state / "jobs" / (j + ".json")).read_text(encoding="utf-8")) + self.assertEqual(rec["status"], "ok") + lines = self.ledger() + self.assertEqual(len(lines), 3) + self.assertEqual(sorted(ln["job"] for ln in lines[1:]), ["p1", "p2"]) + + def test_stop_signal(self): + """B4: a stop signal to the helper ends the child, marks the job stopped, keeps raw.""" + self.train() + env = dict(os.environ, FAKE_CLAUDE_SCENARIO="sleep") + kw = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt" else {} + h = subprocess.Popen([sys.executable, str(SKILL / "gremlins.py"), "spawn", "--run", self.run_md, + "--base", "B1", "--job", "s1", "--brief", str(self.brief)], + env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw) + jpath, raw = self.state / "jobs" / "s1.json", self.state / "raw" / "s1.jsonl" + deadline = time.time() + 30 + while time.time() < deadline and not ( + jpath.exists() and json.loads(jpath.read_text(encoding="utf-8")).get("child_pid") + and raw.exists() and b'"assistant"' in raw.read_bytes()): + time.sleep(0.2) + pid = json.loads(jpath.read_text(encoding="utf-8"))["child_pid"] + os.kill(h.pid, signal.CTRL_BREAK_EVENT if os.name == "nt" else signal.SIGTERM) + out, _ = h.communicate(timeout=30) + self.assertEqual(h.returncode, 1) + self.assertEqual(json.loads(out.decode("utf-8").splitlines()[-1])["status"], "stopped") + self.assertEqual(json.loads(jpath.read_text(encoding="utf-8"))["status"], "stopped") + self.assertIn(b'"init"', raw.read_bytes()) + if os.name == "nt": + r = subprocess.run(["tasklist", "/FI", "PID eq %d" % pid, "/NH"], capture_output=True) + self.assertNotIn(str(pid).encode(), r.stdout) + else: + with self.assertRaises(ProcessLookupError): + os.kill(pid, 0) + + def test_usage_models_and_cost(self): + """B5: two models both in models_used; no total_cost_usd gives a null cost delta.""" + self.train("two_models") + line = self.ledger()[-1] + self.assertEqual(sorted(line["models_used"]), ["claude-haiku-5-5", MODEL]) + self.assertEqual(line["model"], MODEL) + (self.state / "bases" / "B1.json").unlink() + self.train("no_cost") + self.assertIsNone(self.ledger()[-1]["cost_delta_usd"]) + + def test_status_cold_without_hit(self): + """B6: a base with no hit in the ledger is cold (and warm right after training).""" + self.train() + code, out = self.g("status", "--run", self.run_md) + self.assertEqual((code, out["bases"]["B1"]["base_state"]), (0, "warm")) + (self.state / "ledger.jsonl").write_bytes(b"") + code, out = self.g("status", "--run", self.run_md) + self.assertEqual(out["bases"]["B1"]["base_state"], "cold") + + def test_rebase(self): + """B7: rebase resumes without a fork; the base takes the result's session id.""" + self.train() + old = self.base_rec()["session_id"] + code, out = self.g("rebase", "--run", self.run_md, "--base", "B1", "--brief", str(self.brief)) + self.assertEqual((code, out["status"]), (0, "ok")) + argv = self.calls()[-1]["argv"] + self.assertEqual(argv[-2:], ["--resume", old]) + self.assertNotIn("--fork-session", argv) + new = self.base_rec()["session_id"] + self.assertTrue(new.startswith("resumed-")) + self.assertEqual(new, out["session_id"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/spire/skills/gremlins/tests/test_launch.py b/plugins/spire/skills/gremlins/tests/test_launch.py new file mode 100644 index 0000000..95ddea1 --- /dev/null +++ b/plugins/spire/skills/gremlins/tests/test_launch.py @@ -0,0 +1,51 @@ +import os +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from grm import launch # noqa: E402 + +RUN = {"argv": ["/x/claude", "-p", "--model", "claude-opus-5-5[1m]", "--tools", "Bash,Read"]} + + +class ArgvTest(unittest.TestCase): + def test_fork_argv_extends_stored_argv(self): + """B2: the stored argv byte for byte, then --resume <base> --fork-session.""" + a = launch.fork_argv(RUN, "base-sid", "new-sid") + n = len(RUN["argv"]) + self.assertEqual(a[:n], RUN["argv"]) + self.assertEqual(a[n:n + 3], ["--resume", "base-sid", "--fork-session"]) + self.assertIn("new-sid", a[n + 3:]) + self.assertEqual(len(RUN["argv"]), n) # the stored list is not mutated + + def test_resume_argv_has_no_fork(self): + """B7: resume the base without forking.""" + a = launch.resume_argv(RUN, "base-sid") + self.assertEqual(a[:len(RUN["argv"])], RUN["argv"]) + self.assertEqual(a[len(RUN["argv"]):], ["--resume", "base-sid"]) + self.assertNotIn("--fork-session", a) + + +class ScrubTest(unittest.TestCase): + def test_scrub_drops_pins_and_cleans_path(self): + """B8: SCRUB_DROP and UV_* dropped, pinned vars set, venv PATH entries removed.""" + venv = os.path.abspath(os.path.join(os.sep, "tmp", "venv")) + keep = os.path.abspath(os.path.join(os.sep, "usr", "bin")) + environ = {"PATH": os.pathsep.join([os.path.join(venv, "bin"), keep]), + "VIRTUAL_ENV": venv, "UV_CACHE_DIR": "c", "ANTHROPIC_API_KEY": "k", + "CLAUDE_CODE_SESSION_ID": "s", "DISABLE_AUTOUPDATER": "0", "OTHER": "o"} + env = launch.scrubbed_env("/bin/claude", environ) + for k in launch.SCRUB_DROP: + self.assertNotIn(k, env) + self.assertNotIn("UV_CACHE_DIR", env) + for k, v in launch.SCRUB_PINNED.items(): + self.assertEqual(env[k], v) + self.assertEqual(env[launch.SCRUB_PINNED_BINARY], "/bin/claude") + self.assertEqual(env["PATH"], keep) + self.assertEqual(env["OTHER"], "o") + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/spire/skills/gremlins/tests/test_store.py b/plugins/spire/skills/gremlins/tests/test_store.py new file mode 100644 index 0000000..0f0e2af --- /dev/null +++ b/plugins/spire/skills/gremlins/tests/test_store.py @@ -0,0 +1,36 @@ +import sys +import tempfile +import threading +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from grm import store # noqa: E402 + + +class LedgerTest(unittest.TestCase): + def test_parallel_appends_give_whole_lines(self): + """B3: two threads appending at once leave only whole, parsable lines.""" + with tempfile.TemporaryDirectory() as d: + run = str(Path(d) / "run.md") + store.init_state(run) + + def append(job): + for i in range(50): + store.ledger_append(run, {"job": job, "i": i, "pad": "x" * 500}) + threads = [threading.Thread(target=append, args=(j,)) for j in ("a", "b")] + for t in threads: + t.start() + for t in threads: + t.join() + raw = (store.state_dir(run) / "ledger.jsonl").read_bytes().splitlines() + lines = store.ledger_lines(run) + self.assertEqual(len(raw), 100) + self.assertEqual(len(lines), 100) + self.assertEqual(sorted((x["job"], x["i"]) for x in lines), + sorted((j, i) for j in ("a", "b") for i in range(50))) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/spire/skills/gremlins/tests/test_stream.py b/plugins/spire/skills/gremlins/tests/test_stream.py new file mode 100644 index 0000000..a00b31a --- /dev/null +++ b/plugins/spire/skills/gremlins/tests/test_stream.py @@ -0,0 +1,74 @@ +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from grm import stream # noqa: E402 + + +def _ss(result=None): + ss = stream.StreamState() + if result is not None: + ss.feed(dict(result, type="result"), 1.0) + return ss + + +class ClassifyTest(unittest.TestCase): + def test_api_errors_map_to_reasons(self): + """B1: an API error result gives status error with its mapped reason.""" + cases = [({"is_error": True, "api_error_status": 429}, "usage_limit"), + ({"is_error": True, "api_error_status": 529}, "overloaded"), + ({"is_error": True, "result": "Prompt is too long"}, "prompt_too_long")] + for res, reason in cases: + self.assertEqual(stream.api_error_reason(res)[0], reason) + self.assertEqual(stream.classify(_ss(res), 1, False), ("error", reason)) + + def test_no_result(self): + """B1: no result event is an error, by exit code or no_result.""" + self.assertEqual(stream.classify(_ss(), 0, False), ("error", "no_result")) + self.assertEqual(stream.classify(_ss(), 1, False), ("error", "exit_code")) + self.assertEqual(stream.classify(_ss({"is_error": False}), 0, False), ("ok", None)) + + +class UsageTest(unittest.TestCase): + def test_cache_miss(self): + """B2: a first-request cache_read of 0 is a miss (reported, not an error).""" + self.assertEqual(stream.cache_verdict({"cache_read_input_tokens": 0}, 50000), "miss") + + def test_two_models_and_cost_delta(self): + """B5: both models in models_used; cost delta against the base, rounded at 6.""" + before = {"claude-opus-5-5": {"inputTokens": 10, "outputTokens": 5}} + res = {"usage": {"input_tokens": 3, "output_tokens": 7}, + "modelUsage": {"claude-opus-5-5": {"inputTokens": 12, "outputTokens": 10}, + "claude-haiku-5-5": {"inputTokens": 1, "outputTokens": 1}}, + "total_cost_usd": 0.12345678} + u = stream.usage_from_result(res, before, 0.1) + self.assertEqual(u["models_used"], ["claude-opus-5-5", "claude-haiku-5-5"]) + self.assertEqual(u["cost_delta"], round(0.12345678 - 0.1, 6)) + self.assertEqual(u["total"], 0.123457) + + def test_missing_cost(self): + """B5: no total_cost_usd gives cost_delta None.""" + self.assertIsNone(stream.usage_from_result({"usage": {}}, {}, 0.5)["cost_delta"]) + + +class WarmthTest(unittest.TestCase): + def test_no_hit_is_cold(self): + """B6""" + self.assertEqual(stream.warmth(None, 3600, 1000.0)["base_state"], "cold") + + def test_5m_only_ttl(self): + """B6""" + self.assertEqual(stream.infer_ttl({"cache_creation_5m": 10, "cache_creation_1h": 0}, None), 300) + + def test_expiring(self): + """B6: a hit at now - ttl + 60 is expiring.""" + now = 1_000_000.0 + w = stream.warmth(now - 3600 + 60, 3600, now) + self.assertEqual(w["base_state"], "expiring") + self.assertEqual(w["ttl_left_s"], 60) + + +if __name__ == "__main__": + unittest.main() From 79df026d8c9b922ec5a068ef07162c210e4fe12e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= <gab@g4b.org> Date: Fri, 16 Oct 2026 23:33:02 +0200 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=A9=B9=20Gremlins=20MVP:=20rebase?= =?UTF-8?q?=20template=20says=20resume,=20not=20fork?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/spire/skills/gremlins/grm/cli.py | 2 +- plugins/spire/skills/gremlins/templates/rebase.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/spire/skills/gremlins/grm/cli.py b/plugins/spire/skills/gremlins/grm/cli.py index 4d01c93..5052421 100644 --- a/plugins/spire/skills/gremlins/grm/cli.py +++ b/plugins/spire/skills/gremlins/grm/cli.py @@ -281,7 +281,7 @@ def cmd_rebase(args) -> dict: job = "%s-rebase%d" % (b, len(base.get("rebases") or []) + 1) cwd = r["fingerprint"]["cwd"] prompt = render_template("rebase.md", { - "base": b, "new_base": b, "phase": "n/a", "run": r["run"], "notes_dir": str(state / "notes"), + "base": b, "run": r["run"], "notes_dir": str(state / "notes"), "delta": delta_header(cwd, base.get("commit")), "brief": brief}) rec, ss, u, line = _execute(args.run, state, r, job=job, kind="rebase", base=b, sid=None, argv=launch.resume_argv(r, base["session_id"]), prompt=prompt, diff --git a/plugins/spire/skills/gremlins/templates/rebase.md b/plugins/spire/skills/gremlins/templates/rebase.md index 4076b82..21ed206 100644 --- a/plugins/spire/skills/gremlins/templates/rebase.md +++ b/plugins/spire/skills/gremlins/templates/rebase.md @@ -1,6 +1,6 @@ # Rebase (spire:gremlins) -This session is a fork of base `${base}` (run log `${run}`). When you finish, this session becomes the new base `${new_base}` (phase ${phase}): every later gremlin of this build starts from what you know at the end of it. Your job is to absorb what changed since `${base}` was trained or last rebased. +This session is base `${base}` itself, resumed (run log `${run}`). When you finish, every later gremlin of this build starts from what you know at the end of it. Your job is to absorb what changed since `${base}` was trained or last rebased. Your system prompt was snapshotted when the base was trained: the git status, the CLAUDE.md files and the environment it shows are **stale**. The delta header below lists what changed in the repository since then. Trust it and the files on disk over the snapshot and over what you remember. From 0493f03c984354786b9c7af70c8c68b04c4c8a8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= <gab@g4b.org> Date: Fri, 16 Oct 2026 23:34:18 +0200 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=93=9D=20Gremlins=20MVP=20build=20r?= =?UTF-8?q?eport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reports/2026-10-17-gremlins-mvp-build.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/workpad/reports/2026-10-17-gremlins-mvp-build.md diff --git a/docs/workpad/reports/2026-10-17-gremlins-mvp-build.md b/docs/workpad/reports/2026-10-17-gremlins-mvp-build.md new file mode 100644 index 0000000..5719c02 --- /dev/null +++ b/docs/workpad/reports/2026-10-17-gremlins-mvp-build.md @@ -0,0 +1,97 @@ +# Gremlins MVP: build report + +- Date: 2026-10-16/17 · Orchestrator model: claude-opus-5-5 · Gremlin model: claude-opus-5-5 (all calls) +- Branch `gremlins-mvp` (worktree `.claude/worktrees/gremlins-mvp`), from `main` at `b8affd8`. Not pushed, not merged. +- Spec: [`2026-10-17-00-gremlins-mvp-spec.md`](../plans/2026-10-17-00-gremlins-mvp-spec.md). Tool: the prototype at `.claude/worktrees/headless-forks/plugins/spire/skills/gremlins/gremlins.py`, unmodified. +- Wall time: about 21 minutes, 23:12:58 to 23:33:35, against a 90-minute timebox. Fallback: **not needed**; the prototype did not misbehave once. + +## How it ran + +One base (B1) was trained on the spec, the verdict and the prototype source. It wrote no code. It wrote 70 lines of trunk notes, which pinned every cross-module seam: signatures, return shapes, the state-dir layout, the ledger and record keys, and its own decisions where the spec was open. Then eight gremlins were forked from B1, at most two running at a time (`spawn --mode parallel --paths …`, then `wait --any` / `--all` with Bash timeout 600000): + +1. Wave 1: `stream` + `launch`, then `store` + `call`, each pair started as soon as a slot freed up. Committed in `aff29d6`. +2. Wave 2: `cli` + `entry`. `entry` is `gremlins.py` plus SKILL.md. Committed in `9209e7d` and `fd75777`. +3. Wave 3: `tests-unit` (started in `entry`'s freed slot, since it does not depend on cli), then `tests-cli`. Committed in `d662790`. +4. Integration fix: `79df026`. + +Every gremlin finished `ok` on its first attempt. No gremlin needed a follow-up turn, and none touched another gremlin's files. Each one wrote a note that listed every function with its box. + +## Gremlin metrics (from the prototype's results and ledger) + +| Gremlin | Module | Wall s | Cost delta $ | Cache | Cache read | Lines written | +|---|---|---|---|---|---|---| +| base B1 (train) | trunk notes only | 407.8 | 1.9725 | n/a | 1,085,902 | 0 code (70 notes) | +| stream | `grm/stream.py` (+`__init__.py`) | 104.3 | 0.5175 | hit | 924,301 | 215 | +| launch | `grm/launch.py` | 66.7 | 0.4196 | hit | 783,210 | 182 | +| store | `grm/store.py` | 62.2 | 0.3815 | hit | 903,383 | 116 | +| call | `grm/call.py` | 80.0 | 0.4198 | hit | 908,838 | 86 | +| cli | `grm/cli.py` | 230.2 | 1.2211 | hit | 1,667,879 | 359 | +| entry | `gremlins.py`, `SKILL.md` | 52.2 | 0.3836 | hit | 916,241 | 14 + 54 | +| tests-unit | `tests/test_{store,launch,stream,call}.py` | 80.0 | 0.5441 | hit | 834,361 | 218 | +| tests-cli | `tests/fake_claude.py`, `tests/test_cli.py` | 197.3 | 1.1388 | hit | 1,982,799 | 275 | + +- Base cost: $1.9725. The eight spawns together cost $5.0260. Build total (prototype `usage`): **$6.9985**, 9 calls, 0 cold starts, 8 of 8 cache hits. +- The live smoke of the MVP cost another $0.2204 (4 calls). That brings the grand total to about **$7.22**. + +## Final line counts against the spec's estimates + +| Module | Lines | Estimate | Reason for the difference | +|---|---|---|---| +| `grm/store.py` | 116 | 70 | docstrings; `init_state` and `SUBDIRS` added (trunk decision) | +| `grm/launch.py` | 182 | 110 | the kept scrub constants and the tool lists; `binary_version` added | +| `grm/stream.py` | 215 | 170 | `iso`/`parse_iso` moved here; `TOKEN_KEYS`; `mismatch` kept (4 lines, not used) | +| `grm/call.py` | 86 | 60 | feeder and reader threads; the child is killed if `on_start` raises; a second signal escalates to `kill()` | +| `grm/cli.py` | 359 | 200 | `delta_header` verbatim (~25); JSON argparse errors (~30); explicit job, ledger and base dicts (~40); status; docstrings | +| `gremlins.py` | 14 | (in cli) | | +| **Code total** | **972** | **610** | The verdict estimated 600–800. No failure path was dropped, and nothing outside spec §4 was built. | +| `tests/` | 493 | about 300 | per-file boilerplate; the fake has 7 scenarios; B4 needs a subprocess and per-platform signalling; one extra test (below) | +| `SKILL.md` | 54 | not in the spec | see Deviations | + +## Test and smoke results + +- **Unit tests:** `uv run --no-project --python 3.12 python -m unittest discover -s tests`, run from the skill dir: **24 tests, OK, 7.0 s.** That is 14 module tests and 10 CLI tests, which cover every §6 case from B1 to B8. +- **Live smoke** (MVP; real `claude.exe` 2.1.299; scratch git repo; model `claude-opus-5-5`): + - `init`: `run.json` was written with the fingerprint, the argv and `fp_hash`, and a frozen preamble copy was made. + - `train B1`: `ok`, 5.0 s, $0.0969, session id taken from `--session-id`. + - `spawn s1`: `ok`, **`cache: hit`** (69,651 tokens read, 1,664 written), 36.5 s, cost delta $0.0431, `base_state: warm`. The note was written and the repo was left untouched. + - The ledger had **two lines**, each with `model`, `models_used`, six token columns, `cost_delta_usd`, `cost_cumulative_usd` and `hit_at`. + - `status`: B1 `warm`, `ttl_s` 3600, `ttl_left_s` 3566. +- **Parallel test (B3):** two `spawn`s, p1 and p2, ran as separate Bash `run_in_background` calls. Both started at 23:30:33 and ended at 23:30:44 and 23:30:45. Both completion notices arrived with the result JSON and exit 0, and both reported `cache: hit`. The run ended with two new job files, two new whole ledger lines (four in total) and two notes. +- **Processes afterwards:** `Get-CimInstance Win32_Process` shows no `claude -p`, `python` or `uv` process left. The only `claude.exe` processes are the Desktop app and its own sessions. The prototype's `list` showed nothing running, and `expire --all` retired its base B1, so no keep-warm ping can fire later. +- **Not run live:** the B4 `TaskStop` on a backgrounded `spawn` (spec §5 step 4), a B7 `rebase`, and the B8 gate on WSL. These paths are covered only by the fake-claude tests: CTRL_BREAK to the helper subprocess, and rebase argv and session id. + +## Deviations from the spec, each with its reason + +1. **`SKILL.md` was added**, although it is not in the module table. Reason: B3 and B4 only work if the orchestrator is told to run `spawn` via `run_in_background` and to stop it with `TaskStop`, and the skill file is where it is told. +2. **`templates/rebase.md` was reworded** ("a fork … becomes the new base `${new_base}` (phase …)" became "base `${base}` itself, resumed"). cli stopped passing `new_base` and `phase`. Reason: §3 said to use the templates as is, but the old text contradicts B7, which resumes without a fork and keeps the same base. The cli gremlin flagged it, and the fix is in `79df026`. +3. **`spawn.md`:** `${commit_rule}` became the fixed line "Commit only if the brief below says so", and `${mode}` was removed. This is per spec §3; the fixed line is so that the rule is still visible. +4. **Trunk-note decisions where the spec was open:** + - `build_fingerprint` takes `mcp_config` and returns `{fingerprint, argv, fp_hash}`. + - The argv builders take the stored run dict, so that a test's fake `argv` is honoured. + - `resume_argv` has no `--session-id`, because claude refuses it on a resume without a fork. + - The fingerprint adds `allowed_tools` and `disallowed_tools`, because `dontAsk` denies every tool that is not allowed. + - `resolve_binary` returns a str, not the prototype's tuple. +5. **`models_used` is ordered by usage, largest first**, not by name. Reason: by name, a haiku side-call could become `model` (= `models_used[0]`). +6. **The ledger line adds `hit_at`.** Reason: B6 derives warmth from the ledger, and the line's `ts` is the end of the call, not the time the cache was read. +7. **Job ids:** + - train is `<B>-train`; it can be re-run only while there is no base file. + - rebase is `<B>-rebase<n>`; a failed rebase re-run overwrites its own job and raw files. + - spawn takes `--job J`, and an existing J is refused. +8. **The base's `cost_cumulative_usd` is stored unrounded.** Ledger and printed costs are still rounded once, at 6 decimals. Reason: a rounded basis made the deltas drift by 1e-6 (0.020001 instead of 0.02). +9. **`StreamState` prefers the result's `session_id` over the init event's.** Reason: spec §7, B7. +10. **Exit codes are 0 / 1 / 2:** 0 ok, 1 a call that ended `error` or `stopped`, 2 refused. `status` refuses a run with no `run.json`. +11. **No `SPIRE_GREMLIN_CHILD` refusal.** Reason: no box needs it, and it would break the helper's own tests when they run inside a gremlin. +12. **One extra test**, `test_call.test_tee_and_parse`. Reason: it is the only check that the prompt goes in on stdin as UTF-8 and that the stream is teed to the raw file and parsed. + +## Observations on the dogfooding + +- The prototype raised `rebase_due: phase_boundary` on every wave-2 and wave-3 spawn, because `--phase` was used as a wave label. The flag is advisory, so it was ignored. The delta header gave the forks the new commits instead. +- The trunk notes were what made the parallel work fit together: four modules written blind against each other imported cleanly on the first try. The only integration fix was the template wording. +- Each fork read 0.8 to 2.0 M tokens from cache, at about $0.38 to $1.22 per module. The base's $1.97 was spent once. + +## Open items + +- Live B4 check: a `TaskStop` on a backgrounded MVP `spawn`. +- Live B7 check: does a plain `--resume` report a cumulative `total_cost_usd`? The cost basis assumes it does. +- The B8 gate on WSL. +- Cross-process ledger appends on Windows are not strictly atomic (CRT seek + write). This is accepted per the trunk notes. The live parallel run gave whole lines. From cfeb1bf111f23c85c6af7ba4e76ae74bbfaeeb61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= <gab@g4b.org> Date: Fri, 16 Oct 2026 23:49:03 +0200 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=93=9D=20Gremlins=20MVP=20phase=20r?= =?UTF-8?q?eview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reports/2026-10-17-gremlins-mvp-review.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/workpad/reports/2026-10-17-gremlins-mvp-review.md diff --git a/docs/workpad/reports/2026-10-17-gremlins-mvp-review.md b/docs/workpad/reports/2026-10-17-gremlins-mvp-review.md new file mode 100644 index 0000000..76a3b85 --- /dev/null +++ b/docs/workpad/reports/2026-10-17-gremlins-mvp-review.md @@ -0,0 +1,82 @@ +# Gremlins MVP: phase review + +- Reviewer model: **claude-opus-5-5[1m]** · Date: 2026-10-16 · Branch `gremlins-mvp` @ `0493f03` · Timebox 15 min +- Scope: `plugins/spire/skills/gremlins/` against the [spec](../plans/2026-10-17-00-gremlins-mvp-spec.md) and the [build report](2026-10-17-gremlins-mvp-build.md) +- Gate: `uv run --no-project --python 3.12 python -m unittest discover -s tests` gave **24 tests, OK, 7.0 s**, the same as the build report. No live `claude` calls in this review. + +## 1. Boxes + +| Box | Verdict | Evidence | Verified by | +|---|---|---|---| +| B1 | ticked | `cli.py:226-247` train with `--session-id` (`launch.py:172`); fingerprint stored once in `run.json`, overwrite refused `cli.py:202`; `launch.py:139-169` | live smoke + `test_cli` happy/missing_binary/train_failures, `test_stream` | +| B2 | ticked | `launch.py:176-177` stored argv + `--resume --fork-session --session-id`; `cli.py:250-269`; `cache_verdict` `stream.py:178` | live smoke (`cache: hit`) + `test_launch`, `test_cli` happy/unknown_base/cache_miss | +| B3 | ticked | no detach/supervisor code anywhere; one job file per job `cli.py:134`; ledger append `store.py:85` | live (two `run_in_background` spawns) + `test_parallel_spawns`, `test_store` | +| B4 | partly | no `kill` command; handler `call.py:53-66` (SIGTERM/SIGINT/SIGBREAK, second signal kills) | unit only (`test_call` SIGTERM, `test_cli` CTRL_BREAK). Spec §5 step 4 live `TaskStop` on an MVP spawn **not run** | +| B5 | ticked | `stream.py:141-175` (models by usage, six token columns, delta rounded once); ledger line `cli.py:174-181` | live smoke (two ledger lines) + `test_stream`, `test_usage_models_and_cost` | +| B6 | ticked | `_warmth` `cli.py:119-126` derived from ledger `hit_at`; `stream.py:206-215`; no ping code | live `status` (warm, ttl_left 3566) + `test_stream` cold/5m/expiring, `test_status_cold_without_hit` | +| B7 | partly | `cli.py:272-298`, `launch.py:180-182` (no fork, no `--session-id`), session id from result `cli.py:294`, running-jobs warning `cli.py:278-280` | unit only (fake). Not run live; the cumulative-cost basis after a plain resume is an open assumption | +| B8 | partly | only portable `Popen.terminate/kill` + `getattr(signal,"SIGBREAK")` `call.py:11`; scrub `launch.py:88-103` | `test_launch` scrub test. Spec §5 step 6 non-Windows gate **not run** | + +## 2. Unrequested surface (list only; J = the build report justifies it) + +| # | Where | What | J | +|---|---|---|---| +| 1 | `store.py:10,25-30` | `SUBDIRS` + `init_state` | J | +| 2 | `store.py:10`, `cli.py:136` | `briefs/<J>.md`: full prompt written per job | - | +| 3 | `store.py:91-97` | ledger torn-last-line repair before append | - | +| 4 | `store.py:14,98` | in-process `threading.Lock` around ledger append (spec: "without locks") | - | +| 5 | `launch.py:10`, `cli.py:211` | `MODEL_RE`: aliases refused at `init` | - | +| 6 | `launch.py:11`, `cli.py:330` | `EFFORTS` choices; default effort `high` | - | +| 7 | `launch.py:13-15,158-161` | fixed tool list, `allowed_tools`, `disallowed_tools` (incl. `Bash(claude:*)`, `git push/rebase/reset --hard`) | J (fields); deny list itself not | +| 8 | `launch.py:41` | `SPIRE_GREMLIN_CHILD=1` still pinned into the child env although the refusal was dropped | partly (dev. 11) | +| 9 | `launch.py:36-40,65` | `SCRUB_KEEP`/`"keep"` class: no behavioural effect (keep and unlisted both pass) | ported per §3 | +| 10 | `stream.py:13-27` | `iso`/`parse_iso` in stream | J | +| 11 | `stream.py:43,56-58,127-128` | `refused` + `model_refused` status case (not in spec's `classify` tuple) | - | +| 12 | `stream.py:34,36-37,44,73-75` | `init`, `msg_ids`, `_ids`, `events`, `turns`: tracked, never read | - | +| 13 | `stream.py:165-167,174` | `mismatch` computed, never read | J | +| 14 | `stream.py:200-203` | `wrote_only_5m`: listed in §3, never called | (spec) | +| 15 | `cli.py:180` | ledger field `hit_at` | J | +| 16 | `cli.py:16,87-90` | `NAME_RE` validation of base/job names | - | +| 17 | `cli.py:17,163-167` | note detection by file mtime with 2 s slack | - | +| 18 | `cli.py:22-32,355-357` | `Refusal` / JSON argparse errors; `ValueError`/`OSError` mapped to exit 2 | J | +| 19 | `cli.py:359` | exit codes 0/1/2 | J | +| 20 | `cli.py:231,255` | train refuses existing base; spawn refuses existing job | J | +| 21 | `cli.py:281,291-293` | rebase job id `<B>-rebase<n>`; `rebases[]` entries `{job, at, from_commit, from_session}` | J (ids) | +| 22 | `cli.py:258,269` | spawn output `base_state`, computed before the call | - | +| 23 | `cli.py:208` | `init` defaults cwd to the git toplevel | - | +| 24 | `cli.py:214-217` | frozen preamble copy + `mcp-empty.json` in the state dir | J (mcp) | +| 25 | `call.py:48` | stderr to `raw/<J>.err` | - | +| 26 | `call.py:10,83-85` | reader join timeout 10 s (orphan holding stdout) | - | +| 27 | `call.py:56-57` | second signal escalates to `kill()` | J | +| 28 | `call.py:42` | `ss=None` injection parameter, used by no caller | - | +| 29 | `SKILL.md` | the skill file | J | +| 30 | `tests/test_call.py:24` | `test_tee_and_parse` | J | + +**30 items, 15 justified (J or partly) by the build report, 15 not.** None looks like a bug. Most are a few lines; #2, #3, #11, #12, #17 are the ones that add real behaviour. + +## 3. The 972 vs 610 gap + +Measured with `ast` (docstrings), blank, comment and import lines: + +| Module | Est. | Actual | Blank | Doc | Cmt | Import | Logic | Where the logic surplus goes | +|---|---|---|---|---|---|---|---|---| +| `store.py` | 70 | 116 | 23 | 8 | 0 | 7 | 78 | `init_state`, torn-line repair, lock (#1, #3, #4) | +| `launch.py` | 110 | 182 | 31 | 9 | 6 | 7 | 129 | scrub constants as estimated; plus tool lists, `binary_version`, `build_fingerprint` re-validating what `cmd_init` already checks (`launch.py:141-150` vs `cli.py:205-212`) | +| `stream.py` | 170 | 215 | 28 | 17 | 0 | 4 | 166 | on estimate; dead `wrote_only_5m`/`turns`/`mismatch` (~12) offset by a compact `classify` | +| `call.py` | 60 | 86 | 9 | 4 | 0 | 6 | 67 | two threads, kill escalation, join timeout | +| `cli.py` | 200 | 359 | 52 | 15 | 0 | 11 | 281 | multi-line dict literals for job/ledger/base (~35); `delta_header` + git helpers (~35, spec counted it "as is"); `_execute` hit/note/excerpt logic (~15); duplicated init validation (~8); `Refusal`/`_Parser` (~12, not the ~30 the build report says) | +| `gremlins.py` | (cli) | 14 | 3 | 1 | 4 | 3 | 3 | PEP 723 header | +| **Total** | **610** | **972** | 146 | 54 | 10 | 38 | **724** | | + +Form (blank, docstring, comment, import) is **248** of the 362 extra lines. Logic is 724 against 610, so +114 (+19%), and 81 of that is in `cli.py`. That surplus is mostly verbose layout (dicts written over several lines, duplicated validation) plus the small additions in §2. Only `call.py`'s threads and `_execute`'s hit/note logic are real logic the spec underestimated. **Verdict: mostly form.** The substance is within the verdict's 600–800 band. + +## Handoff flags answered + +- Dev. 1 (SKILL.md): accepted; B3/B4 need the orchestrator to be told. Dev. 2 (rebase.md wording): correct, the old text contradicted B7. Dev. 3: per §3. +- Dev. 4–6, 8, 9: consistent with the code and tests (`test_cli` happy checks the unrounded basis to 6 places). Dev. 7, 10: as implemented. Dev. 11: the refusal is gone but the env var is still set (#8). Dev. 12: justified, and the only test that covers stdin and the tee. +- Open items (live B4/B7/B8, Windows append atomicity): still open. These three boxes are "partly", not failures. +- Weakened checks / tests that cannot fail: none found. On Windows `test_stop_signal` sends CTRL_BREAK to a process group that includes the fake child, so it cannot tell the helper's `terminate()` apart from the child dying on its own. `test_call.test_sigterm_stops_child` (in-process `raise_signal`) does isolate the handler path. + +## Bug (one line) + +No real bug noticed. The nearest is harmless duplication: `init` validates model, binary and cwd twice (`cli.py:205-212`, `launch.py:141-150`). From 82ef04c11815aaf4e95e873f1f218cbc52ce07fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= <gab@g4b.org> Date: Sat, 17 Oct 2026 00:11:06 +0200 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=93=9D=20Gremlins=20MVP:=20record?= =?UTF-8?q?=20keep/cut=20calls=20on=20unrequested=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-17-00-gremlins-mvp-cuts.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/workpad/reports/2026-10-17-00-gremlins-mvp-cuts.md diff --git a/docs/workpad/reports/2026-10-17-00-gremlins-mvp-cuts.md b/docs/workpad/reports/2026-10-17-00-gremlins-mvp-cuts.md new file mode 100644 index 0000000..97b8e61 --- /dev/null +++ b/docs/workpad/reports/2026-10-17-00-gremlins-mvp-cuts.md @@ -0,0 +1,25 @@ +# Gremlins MVP: unrequested-surface calls + +- Author model: **claude-sonnet-5-5** · Date: 2026-10-17 · Branch `gremlins-mvp` @ `cfeb1bf` +- Source: [phase review](2026-10-17-gremlins-mvp-review.md) §2. The user ruled on the rows below: keep the useful ones, cut the rest, one-line reason each. This commit records the calls; the cuts follow in the next commit. + +| # | Item | Call | Reason | +|---|---|---|---| +| 2 | `briefs/<J>.md`: full prompt per job | keep | Debugging aid. | +| 3 | Ledger torn-last-line repair | keep | Protects the ledger after a hard stop. | +| 4 | In-process `threading.Lock` around ledger append | cut | Each spawn is its own process, so it guards nothing; the spec says "without locks". | +| 5 | `MODEL_RE`, aliases refused at `init` | keep | An alias would change the fingerprint and break cache hits; B1 asks for a full id. | +| 6 | `EFFORTS` choices, default `high` | keep | Trivial argparse guard. | +| 7 | Tool deny list (`Bash(claude:*)`, `git push/rebase/reset --hard`) | keep | Headless unattended gremlins must not recurse or rewrite history. | +| 8 | `SPIRE_GREMLIN_CHILD=1` in the child env | cut | Rider: the refusal it served was dropped, so it is leftover. | +| 11 | `refused` / `model_refused` status case | cut | Not in the spec's classify; the harness substitutes unlisted models silently, so `error` covers it. | +| 12 | Stream fields tracked and never read (`init`, `msg_ids`, `_ids`, `events`, `turns`) | cut | Never read. | +| 13 | `mismatch` in `usage_from_result` | cut | Rider: computed and never read. | +| 14 | `wrote_only_5m` | cut | Rider: never called. | +| 16 | `NAME_RE` on base/job names | keep | Names become file paths. | +| 17 | Note detection by mtime with 2 s slack | cut | Replace with a plain existence check of `notes/<J>.md`, since `spawn` refuses an existing job name (#20). | +| 22 | `base_state` in spawn output | cut | `status` reports warmth and `cache` reports the outcome. | +| 23 | `init` defaults cwd to the git toplevel | keep | cwd is in the fingerprint; a stable default avoids cache misses. | +| 25 | stderr to `raw/<J>.err` | keep | Cheap debugging aid. | +| 26 | Reader join timeout 10 s | keep | Without it an orphan holding stdout hangs the helper (spec §7 leftover). | +| 28 | Unused `ss=None` injection parameter in `call.py` | cut | Used by no caller. | From e1884248e89067669937abdb35357eb4be13106a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= <gab@g4b.org> Date: Sat, 17 Oct 2026 00:12:33 +0200 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=94=A5=20Gremlins=20MVP:=20cut=20un?= =?UTF-8?q?requested=20surface=20per=20keep/cut=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-17-00-gremlins-mvp-cuts.md | 26 ++++++++++ plugins/spire/skills/gremlins/grm/call.py | 8 ++-- plugins/spire/skills/gremlins/grm/cli.py | 6 +-- plugins/spire/skills/gremlins/grm/launch.py | 2 +- plugins/spire/skills/gremlins/grm/stream.py | 48 ++++--------------- 5 files changed, 42 insertions(+), 48 deletions(-) diff --git a/docs/workpad/reports/2026-10-17-00-gremlins-mvp-cuts.md b/docs/workpad/reports/2026-10-17-00-gremlins-mvp-cuts.md index 97b8e61..eee16cf 100644 --- a/docs/workpad/reports/2026-10-17-00-gremlins-mvp-cuts.md +++ b/docs/workpad/reports/2026-10-17-00-gremlins-mvp-cuts.md @@ -23,3 +23,29 @@ | 25 | stderr to `raw/<J>.err` | keep | Cheap debugging aid. | | 26 | Reader join timeout 10 s | keep | Without it an orphan holding stdout hangs the helper (spec §7 leftover). | | 28 | Unused `ss=None` injection parameter in `call.py` | cut | Used by no caller. | + +## Result + +Premises checked in the code before each cut. Tests: `uv run --no-project --python 3.12 python -m unittest discover -s tests` gave **24 tests, OK** (run twice). + +| File | Added | Removed | +|---|---|---| +| `grm/stream.py` | 9 | 39 | +| `grm/call.py` | 4 | 4 | +| `grm/cli.py` | 2 | 4 | +| `grm/launch.py` | 1 | 1 | +| **Total** | **16** | **48** | + +Net code: 32 lines removed (16 added, 48 deleted); no test was removed or changed (none existed only for a cut item). + +## Kept against the call + +- **#4 `threading.Lock` around ledger append: kept.** Premise half true: in production each spawn is its own process, so no two threads of one process append. But with the lock removed `test_store.test_parallel_appends_give_whole_lines` (96 of 100 lines) and `test_cli.test_parallel_spawns` (2 of 3 ledger lines) fail, on every run. Appends from separate handles on Windows lose lines. The lock only makes those B3 tests pass in-process; across real processes the same race is unguarded either way (the review's open "Windows append atomicity" item). Cutting it would mean weakening two B3 tests, which the call forbids. Needs a decision: either keep the lock as is, or replace it with a cross-process append (for example a lock file or an `msvcrt` byte-range lock) in a separate change. + +## Notes on the cuts + +- #12: `session_id` from the `init` event is now taken only while unset (`self.session_id is None`), which matches the old "first init wins" behaviour. +- #28: `StreamState` is now imported at the top of `call.py` instead of lazily inside the `ss is None` branch. +- #17: premise holds. `spawn` refuses an existing `jobs/<J>.json` (`cli.py:255`) and `_execute` writes that file before the call starts, so a note that exists afterwards was written by this job. +- #11: nothing outside `stream.py` read `refused` or `model_refused` (code, tests, SKILL.md, templates). +- #8, #13, #14, #22: no reader in code, tests, SKILL.md or templates. SKILL.md and templates mentioned none of the cut items, so they are unchanged. diff --git a/plugins/spire/skills/gremlins/grm/call.py b/plugins/spire/skills/gremlins/grm/call.py index 7a8072d..d09c40d 100644 --- a/plugins/spire/skills/gremlins/grm/call.py +++ b/plugins/spire/skills/gremlins/grm/call.py @@ -6,6 +6,8 @@ import threading import time from pathlib import Path +from grm.stream import StreamState + POLL_S = 0.2 READER_JOIN_S = 10.0 # an orphaned grandchild can hold stdout open after claude exits STOP_SIGNALS = tuple(s for s in (signal.SIGTERM, signal.SIGINT, getattr(signal, "SIGBREAK", None)) @@ -39,11 +41,9 @@ def _read(stdout, raw, ss) -> None: pass # the raw file or pipe was closed after READER_JOIN_S -def run_claude(argv, prompt: str, env, cwd, raw_path, on_start, ss=None): +def run_claude(argv, prompt: str, env, cwd, raw_path, on_start): """Run one call to its end. Returns (ss, exit_code, stopped).""" - if ss is None: - from grm.stream import StreamState - ss = StreamState() + ss = StreamState() raw_path = Path(raw_path) with open(raw_path, "wb") as raw, open(raw_path.with_suffix(".err"), "wb") as err: child = subprocess.Popen(argv, cwd=cwd, env=env, stdin=subprocess.PIPE, diff --git a/plugins/spire/skills/gremlins/grm/cli.py b/plugins/spire/skills/gremlins/grm/cli.py index 5052421..d3bd9f1 100644 --- a/plugins/spire/skills/gremlins/grm/cli.py +++ b/plugins/spire/skills/gremlins/grm/cli.py @@ -14,7 +14,6 @@ from grm import call, launch, store, stream SKILL_DIR = Path(__file__).resolve().parent.parent NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") -NOTE_SLACK_S = 2.0 # coarse file times: a note written right at the start still counts EXCERPT_CHARS = 500 LAUNCHES = ("train", "spawn", "rebase") @@ -163,7 +162,7 @@ def _execute(run: str, state: Path, r: dict, *, job, kind, base, sid, argv, prom note = None if kind == "spawn": p = state / "notes" / (job + ".md") - if p.is_file() and p.stat().st_mtime >= t0 - NOTE_SLACK_S: + if p.is_file(): note = str(p) text = (ss.result or {}).get("result") rec.update(session_id=ss.session_id or sid, status=status, reason=reason, ended=stream.iso(), @@ -255,7 +254,6 @@ def cmd_spawn(args) -> dict: if (state / "jobs" / (j + ".json")).exists(): raise Refusal("job exists; pick a new --job", job=j) brief = _read_brief(args.brief) - warm = _warmth(store.ledger_lines(args.run), base, time.time()) sid = str(uuid.uuid4()) prompt = render_template("spawn.md", { "block": j, "base": b, "run": r["run"], @@ -266,7 +264,7 @@ def cmd_spawn(args) -> dict: argv=launch.fork_argv(r, base["session_id"], sid), prompt=prompt, before=(base.get("model_usage"), base.get("cost_cumulative_usd")), base_ctx=base.get("context_tokens")) - return {**_out(rec, line), "base_state": warm["base_state"]} + return _out(rec, line) def cmd_rebase(args) -> dict: diff --git a/plugins/spire/skills/gremlins/grm/launch.py b/plugins/spire/skills/gremlins/grm/launch.py index 06bcaf7..17102cd 100644 --- a/plugins/spire/skills/gremlins/grm/launch.py +++ b/plugins/spire/skills/gremlins/grm/launch.py @@ -38,7 +38,7 @@ SCRUB_KEEP = frozenset({ "PATH", "HOME", "USERPROFILE", "ANTHROPIC_BASE_URL", # kept: gateways need it }) -SCRUB_PINNED = {**ENV_PINNED_LAUNCH, "SPIRE_GREMLIN_CHILD": "1"} +SCRUB_PINNED = ENV_PINNED_LAUNCH SCRUB_PINNED_BINARY = "CLAUDE_CODE_EXECPATH" # set to the stored absolute binary path diff --git a/plugins/spire/skills/gremlins/grm/stream.py b/plugins/spire/skills/gremlins/grm/stream.py index 4df8da7..27c3aea 100644 --- a/plugins/spire/skills/gremlins/grm/stream.py +++ b/plugins/spire/skills/gremlins/grm/stream.py @@ -31,37 +31,23 @@ class StreamState: """What one `claude -p --output-format stream-json --verbose` stream told us so far.""" def __init__(self): - self.init = None # the system/init event self.session_id = None - self.msg_ids: list = [] - self._ids: set = set() self.first_usage = None # usage of the first assistant event (cache verdict) self.first_at = None # its arrival time (the base prefix read) self.last_usage = None # usage of the last assistant event (base context) self.last_assistant_at = None self.result = None # the result event - self.refused = None # text of a model-restricted warning - self.events = 0 def feed(self, ev: dict, t: float | None) -> None: if not isinstance(ev, dict): return - self.events += 1 ty = ev.get("type") if ty == "system": st = ev.get("subtype") - if st == "init" and self.init is None: - self.init = ev - self.session_id = ev.get("session_id") or self.session_id - elif (st == "informational" and ev.get("level") == "warning" - and "is restricted" in str(ev.get("content", ""))): - self.refused = str(ev.get("content")) + if st == "init" and self.session_id is None: + self.session_id = ev.get("session_id") elif ty == "assistant": m = ev.get("message") or {} - mid = m.get("id") - if mid not in self._ids: - self._ids.add(mid) - self.msg_ids.append(mid) u = m.get("usage") or {} if self.first_usage is None: self.first_usage, self.first_at = u, t @@ -70,10 +56,6 @@ class StreamState: self.result = ev self.session_id = ev.get("session_id") or self.session_id - @property - def turns(self) -> int: - return len(self.msg_ids) - def context_tokens(usage: dict | None) -> int: """Prompt-side tokens of one request: input + cache creation + cache read.""" @@ -121,18 +103,16 @@ def api_error_reason(res: dict | None) -> tuple[str, str] | None: def classify(ss: StreamState, exit_code, stopped: bool) -> tuple[str, str | None]: """(status, reason) of a finished call; the first matching case wins.""" res = ss.result - match (bool(stopped), ss.refused, res, api_error_reason(res), exit_code): - case (True, _, _, _, _): + match (bool(stopped), res, api_error_reason(res), exit_code): + case (True, _, _, _): return "stopped", "stopped" - case (_, str(), _, _, _): - return "error", "model_refused" - case (_, _, None, _, code): + case (_, None, _, code): return "error", "exit_code" if code else "no_result" - case (_, _, _, (reason, _), _): + case (_, _, (reason, _), _): return "error", reason - case (_, _, dict() as r, _, _) if r.get("is_error"): + case (_, dict() as r, _, _) if r.get("is_error"): return "error", "is_error" - case (_, _, _, _, code) if code: + case (_, _, _, code) if code: return "error", "exit_code" case _: return "ok", None @@ -161,17 +141,13 @@ def usage_from_result(result: dict, before_mu: dict, cost_before) -> dict: delta[m] = {f: int((v or {}).get(f) or 0) - int(b.get(f) or 0) for f in MU_FIELDS} grew = [m for m, d in delta.items() if any(x > 0 for x in d.values())] models_used = sorted(grew, key=lambda m: (-sum(delta[m].values()), m)) - sums = {f: sum(d[f] for d in delta.values()) for f in MU_FIELDS} - mismatch = (sums["inputTokens"], sums["outputTokens"], sums["cacheCreationInputTokens"], - sums["cacheReadInputTokens"]) != (tokens["input"], tokens["output"], - tokens["cache_creation"], tokens["cache_read"]) total = result.get("total_cost_usd") if total is None: cost_delta = None else: cost_delta = round(float(total) - float(cost_before or 0.0), 6) total = round(float(total), 6) - return {"tokens": tokens, "models_used": models_used, "mismatch": mismatch, + return {"tokens": tokens, "models_used": models_used, "cost_delta": cost_delta, "total": total, "model_usage": after} @@ -197,12 +173,6 @@ def infer_ttl(tokens: dict | None, prev): return prev -def wrote_only_5m(tokens: dict | None) -> bool: - """The call wrote 5-minute cache entries and no 1-hour ones.""" - return (bool(tokens) and int(tokens.get("cache_creation_5m") or 0) > 0 - and int(tokens.get("cache_creation_1h") or 0) == 0) - - def warmth(last_hit_ts: float | None, ttl_s, now: float) -> dict: """warm until `margin` before last_hit + ttl, expiring until last_hit + ttl, then cold; margin = max(120, ttl/10). No hit at all is cold.""" From 8bf9184ba54d934b1e2b1a172aa3d4406628c654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= <gab@g4b.org> Date: Sat, 17 Oct 2026 00:16:31 +0200 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=93=9D=20Gremlins=20MVP:=20B4=20Tas?= =?UTF-8?q?kStop=20live=20check=20report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-17-00-gremlins-taskstop-check.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 docs/workpad/reports/2026-10-17-00-gremlins-taskstop-check.md diff --git a/docs/workpad/reports/2026-10-17-00-gremlins-taskstop-check.md b/docs/workpad/reports/2026-10-17-00-gremlins-taskstop-check.md new file mode 100644 index 0000000..059dddf --- /dev/null +++ b/docs/workpad/reports/2026-10-17-00-gremlins-taskstop-check.md @@ -0,0 +1,36 @@ +# Gremlins B4 live check: TaskStop on a backgrounded spawn + +Date: Sat 2026-10-17 00:13-00:16 (local). Run by: claude-sonnet-5-5. Gremlin model: claude-haiku-5-5 (effort low). Binary 2.1.299, Windows 10 Git Bash. +Worktree HEAD e188424. Scratch: throwaway git repo in the session scratchpad `taskstop-check/`. + +## Commands +- `G init --run run.md --model claude-haiku-5-5 --effort low --cwd <scratch>` +- `G train --run run.md --base b1 --brief train.md` (blocking, ok, $0.0042) +- j1: `G spawn ... --job j1` brief "sleep 300": haiku refused (harness blocks a standalone `sleep`), ended ok in 15 s, $0.0019. Not usable for the check. +- j2: `G spawn ... --job j2 --brief spawn2.md` (`run_in_background: true`). Brief: run `until [ -e /tmp/never-exists-flag ]; do sleep 2; done`. Heartbeat in raw/j2.jsonl showed the gremlin's Bash running at 30 s. +- `TaskStop` on the background call (task btfr2jl7t) at about 00:15:30, ~46 s after spawn. Returned "Successfully stopped". +- PIDs found by diffing `Win32_Process` before/after the spawn and following parent links. No process outside this chain was touched. + +## Process tree (j2) and liveness after stop +| PID | name | parent | after ~5-10 s | after ~40 s | +|---|---|---|---|---| +| 22836 / 10324 / 18496 | bash.exe (harness wrappers of the spawn call) | 13436 / 22836 / 10324 | gone | gone | +| 1464 | uv.exe | 18496 | gone | gone | +| 36336 -> 22192 | python.exe (gremlins.py, 2 procs) | 1464 / 36336 | gone | gone | +| 19200 | claude.exe (gremlin, = child_pid in j2.json) | 22192 | gone | gone | +| 19580 -> 35212 | bash.exe (gremlin's Bash tool wrappers, 35212 ran the loop) | 19200 / 19580 | gone | gone | +| 25392 | conhost.exe | 35212 | gone | gone | +| sleep.exe (transient, `sleep 2`, MSYS-forked parent) | - | - | none found | none found | + +No `sleep.exe` and no process with the gremlin's command line existed at either check. Nothing to clean up; no kill issued by me. + +## State after the stop (`run-gremlins/`) +- `jobs/j2.json`: `status: running`, `ended: null`, `child_pid: 19200`, no note, no cost. Matches the documented hard-stop behaviour. +- `ledger.jsonl`: 2 lines (b1-train, j1). No j2 line. Matches the documented behaviour. +- `notes/`: only j1.md; the gremlin wrote no note. + +## Cost +About $0.008 total (train 0.0042, j1 0.0019, j2 unrecorded, estimated <= 0.002). + +## Verdict +B4 ticked for the claude process and all bash/python/uv wrappers: TaskStop killed the whole tree on Windows Git Bash. Not exercised: the spec §7 orphan case, a long single child (the loop's `sleep 2` children were too short-lived and the loop's bash died with the tree); a `sleep 300` brief is refused by the gremlin's own harness, so that case needs a different long command (for example `python -c "import time; time.sleep(300)"`).