wavebrain-claude-code 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,22 @@
1
- # Temporary Holding Version
1
+ # wavebrain-claude-code
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Connect Claude Code to [WaveBrain](https://getwavebrain.com), the audit log of what your AI agents do.
4
+
5
+ ```sh
6
+ npx wavebrain-claude-code install
7
+ ```
8
+
9
+ It asks for an API key (or opens [getwavebrain.com/dashboard/setup](https://getwavebrain.com/dashboard/setup) to create one; `--key` or `WAVEBRAIN_API_KEY` skip the prompt).
10
+
11
+ Adds the `wavebrain` MCP server to `~/.claude.json`, pre-approves `mcp__wavebrain__*` in `~/.claude/settings.json` (skip with `--no-claude-permission`), adds the auto-capture hooks (PostToolUse, PostToolUseFailure, Stop, SessionEnd) to the same file, and installs the skill in `~/.claude/skills/wavebrain`.
12
+
13
+ Prefer Claude Code's plugin system? Once the repo is public: `/plugin marketplace add GuillermoCoding/wavebrain` then `/plugin install wavebrain@wavebrain`. Use one or the other, not both.
14
+
15
+ **Auto-capture:** the hooks record deploys, pushes, releases, publishes, payments, emails and database writes even when the model forgets to call `log_action`. They queue locally in `~/.wavebrain/queue`, send at the end of each turn, and skip anything the agent logged itself. Secrets are redacted first. `--no-hooks` installs the MCP server only; `WAVEBRAIN_HOOKS=off` pauses capture.
16
+
17
+ ```sh
18
+ npx wavebrain-claude-code status
19
+ npx wavebrain-claude-code uninstall # undoes exactly what install added
20
+ ```
21
+
22
+ Every changed file is backed up to `<file>.bak-wavebrain`. `--dry-run` shows the changes without writing.
@@ -0,0 +1,27 @@
1
+ ---
2
+ name: wavebrain
3
+ description: Use after any action that changes something outside this conversation (deploys, pushes, merges, releases, published posts, emails or messages sent, payments, database or config changes). Records it in WaveBrain with log_action.
4
+ ---
5
+
6
+ # Log your work to WaveBrain
7
+
8
+ WaveBrain is the user's audit log of what their agents did. Keep it complete and readable.
9
+
10
+ **After every action that changes something outside the conversation, call the WaveBrain `log_action` tool once.** That includes:
11
+ - deploying, releasing, publishing a package or app build
12
+ - git push, opening or merging a pull request
13
+ - sending an email, message or post; publishing content
14
+ - payments, refunds, subscriptions, invoices
15
+ - database writes or migrations; changing env vars, secrets or config in a live system
16
+ - creating, deleting or changing anything in a third-party service
17
+
18
+ Don't log reading, searching, planning, or local edits that weren't shipped anywhere.
19
+
20
+ How to write the entry:
21
+ - `title`: one short past-tense sentence a teammate would understand, e.g. "Deployed the new pricing page to production".
22
+ - `details`: what changed, why, and the result, including anything that failed or is unfinished. Don't repeat the title.
23
+ - `status`: `failure` if it didn't work, `pending` if it's waiting on someone.
24
+ - Never include secrets, passwords, one-time codes, API keys or full card/account numbers.
25
+ - For a multi-step task, call `start_session` first, pass its `session_id` to each `log_action`, and call `end_session` when done.
26
+
27
+ A WaveBrain hook also records high-stakes commands automatically as a safety net. When you log the action yourself, your entry replaces the automatic one, so a clear `log_action` is always better.
@@ -0,0 +1,282 @@
1
+ """WaveBrain for Hermes Agent.
2
+
3
+ Records the agent's high-stakes actions (deploys, pushes, publishes, payments, emails,
4
+ database writes) to WaveBrain, even when the model forgets to call log_action, and adds
5
+ a short standing instruction so it usually does.
6
+
7
+ Same rules (rules.json), queue format (~/.wavebrain/queue/*.jsonl) and REST endpoint as
8
+ the Claude Code, Codex and Cursor hooks.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ import threading
17
+ import time
18
+ import urllib.error
19
+ import urllib.request
20
+ from pathlib import Path
21
+
22
+ HERE = Path(__file__).resolve().parent
23
+ DEFAULT_API_URL = "https://getwavebrain.com/api/v1/actions"
24
+ MAX_AGE = 7 * 24 * 3600
25
+ STALE = 10 * 60
26
+ MAX_PER_FLUSH = 25
27
+ SHELL_TOOLS = {"terminal", "bash", "shell", "execute_command", "run_terminal_cmd"}
28
+
29
+ INSTRUCTION = (
30
+ "WaveBrain is the user's audit log of what agents did. After every action that changes "
31
+ "something outside this conversation (deploys, git push, merges, releases, published posts, "
32
+ "emails or messages sent, payments, database or live config changes), call the WaveBrain "
33
+ "log_action tool once with a short past-tense title and details of what changed and the result. "
34
+ "Don't log reads, searches or plans. Never include secrets or codes. A hook records high-stakes "
35
+ "commands automatically as a safety net; your own log_action entry replaces it."
36
+ )
37
+
38
+
39
+ def _home() -> Path:
40
+ return Path(os.environ.get("WAVEBRAIN_HOME") or Path.home() / ".wavebrain")
41
+
42
+
43
+ def _config() -> dict:
44
+ cfg = {}
45
+ try:
46
+ cfg = json.loads((_home() / "config.json").read_text())
47
+ except Exception:
48
+ pass
49
+ return {
50
+ "apiKey": os.environ.get("WAVEBRAIN_API_KEY") or cfg.get("apiKey"),
51
+ "apiUrl": os.environ.get("WAVEBRAIN_API_URL") or cfg.get("apiUrl") or DEFAULT_API_URL,
52
+ "enabled": os.environ.get("WAVEBRAIN_HOOKS") != "off" and cfg.get("autoCapture") is not False,
53
+ }
54
+
55
+
56
+ def _js_regex(p: str) -> str:
57
+ return p # rules.json patterns use the common subset of JS and Python regex syntax
58
+
59
+
60
+ _RULES = json.loads((HERE / "rules.json").read_text())
61
+ _SHELL = [(r, re.compile(_js_regex(r["pattern"]), re.I)) for r in _RULES["shell"]]
62
+ _VERBS = set(_RULES["mcp_write_verbs"])
63
+ _SKIP = set(_RULES["mcp_skip_servers"])
64
+
65
+ _SECRETS = [
66
+ (re.compile(r"\b(sk|rk|pk)_(live|test)_[A-Za-z0-9]{8,}"), "[redacted]"),
67
+ (re.compile(r"\bwhsec_[A-Za-z0-9]{8,}"), "[redacted]"),
68
+ (re.compile(r"\b(ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{16,}"), "[redacted]"),
69
+ (re.compile(r"\bnpm_[A-Za-z0-9]{20,}"), "[redacted]"),
70
+ (re.compile(r"\bal_[A-Za-z0-9_-]{16,}"), "[redacted]"),
71
+ (re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}"), "[redacted]"),
72
+ (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[redacted]"),
73
+ (re.compile(r"\bAIza[0-9A-Za-z_-]{30,}"), "[redacted]"),
74
+ (re.compile(r"\b(sk-(proj-|ant-)?)[A-Za-z0-9_-]{16,}"), "[redacted]"),
75
+ (re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"), "[redacted]"),
76
+ (re.compile(r"(Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]{12,}", re.I), r"\1 [redacted]"),
77
+ (re.compile(r"\b([a-z][a-z0-9+.-]*://)[^\s:@/]+:[^\s@/]+@", re.I), r"\1[redacted]@"),
78
+ (re.compile(r"((?:password|passwd|secret|token|api[_-]?key|apikey|auth|access[_-]?key|private[_-]?key)\s*[=:]\s*)(\"[^\"]*\"|'[^']*'|\S+)", re.I), r"\1[redacted]"),
79
+ (re.compile(r"(--(?:password|token|api-key|secret|auth)[= ])(\"[^\"]*\"|'[^']*'|\S+)", re.I), r"\1[redacted]"),
80
+ (re.compile(r"\b[A-Za-z0-9+/_-]{40,}={0,2}"), "[redacted]"),
81
+ ]
82
+
83
+
84
+ def redact(text) -> str:
85
+ s = str(text or "")
86
+ for rx, rep in _SECRETS:
87
+ s = rx.sub(rep, s)
88
+ return s
89
+
90
+
91
+ def _short(s: str, n: int = 140) -> str:
92
+ one = re.sub(r"\s+", " ", str(s)).strip()
93
+ return one if len(one) <= n else one[: n - 1] + "…"
94
+
95
+
96
+ def _mcp_parts(name: str):
97
+ m = re.match(r"^mcp__(.+?)__(.+)$", name) or re.match(r"^mcp_([a-z0-9-]+)_(.+)$", name, re.I)
98
+ return (m.group(1), m.group(2)) if m else None
99
+
100
+
101
+ def classify(tool_name: str, args: dict, cwd: str | None = None, failed: bool = False):
102
+ args = args or {}
103
+ where = f" in {os.path.basename(cwd)}" if cwd else ""
104
+ outcome = " It failed." if failed else ""
105
+ if tool_name.lower() in SHELL_TOOLS:
106
+ cmd = args.get("command") or args.get("cmd") or ""
107
+ if isinstance(cmd, list):
108
+ cmd = " ".join(cmd)
109
+ cmd = str(cmd).strip()
110
+ if not cmd:
111
+ return None
112
+ for r, rx in _SHELL:
113
+ m = rx.search(cmd)
114
+ if not m:
115
+ continue
116
+ frm = m.start() + len(m.group(1) or "")
117
+ start = max([0] + [cmd.rfind(d, 0, frm) + len(d) for d in ("&&", ";", "|") if cmd.rfind(d, 0, frm) >= 0])
118
+ ends = [i for i in (cmd.find(d, m.end()) for d in ("&&", ";", "|")) if i >= 0]
119
+ seg = cmd[start : min(ends) if ends else len(cmd)].strip()
120
+ return {
121
+ "type": r["type"],
122
+ "title": _short(r["title"].replace("{cmd}", _short(redact(seg), 120)), 200),
123
+ "details": f"Ran `{_short(redact(cmd), 1500)}`{where}.{outcome} (Recorded automatically by the WaveBrain hook.)",
124
+ "rule": r["id"],
125
+ }
126
+ return None
127
+ parts = _mcp_parts(tool_name)
128
+ if parts:
129
+ server, tool = parts
130
+ if server in _SKIP:
131
+ return None
132
+ if not set(re.split(r"[_\-.]+", tool.lower())) & _VERBS:
133
+ return None
134
+ return {
135
+ "type": "api_call",
136
+ "title": _short(f"Used {server}: {re.sub(r'[_-]+', ' ', tool)}", 200),
137
+ "details": f"Called the MCP tool {server}/{tool}{where} with {redact(json.dumps(args, default=str))[:1500]}.{outcome} (Recorded automatically by the WaveBrain hook.)",
138
+ "rule": "mcp-write",
139
+ }
140
+ return None
141
+
142
+
143
+ def _queue(session: str) -> Path:
144
+ safe = re.sub(r"[^A-Za-z0-9._-]", "_", session or "default")[:80]
145
+ return _home() / "queue" / f"hermes-{safe}.jsonl"
146
+
147
+
148
+ _lock = threading.Lock()
149
+
150
+
151
+ def _append(path: Path, item: dict):
152
+ with _lock:
153
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
154
+ with open(path, "a") as f:
155
+ f.write(json.dumps(item) + "\n")
156
+ os.chmod(path, 0o600)
157
+
158
+
159
+ def _read(path: Path) -> list:
160
+ try:
161
+ out = []
162
+ for line in path.read_text().splitlines():
163
+ try:
164
+ out.append(json.loads(line))
165
+ except Exception:
166
+ pass
167
+ return out
168
+ except Exception:
169
+ return []
170
+
171
+
172
+ def _rewrite(path: Path, items: list):
173
+ if not items:
174
+ path.unlink(missing_ok=True)
175
+ return
176
+ tmp = path.with_suffix(f".tmp-{os.getpid()}")
177
+ tmp.write_text("".join(json.dumps(i) + "\n" for i in items))
178
+ os.chmod(tmp, 0o600)
179
+ tmp.replace(path)
180
+
181
+
182
+ def flush(session: str) -> dict:
183
+ cfg = _config()
184
+ path = _queue(session)
185
+ with _lock:
186
+ items = _read(path)
187
+ if not items:
188
+ return {"sent": 0, "skipped": 0, "kept": 0}
189
+ now = time.time() * 1000
190
+ last_log = max([i["at"] for i in items if i.get("kind") == "agent_logged"], default=float("-inf"))
191
+ actions, seen = [], set()
192
+ for i in items:
193
+ if i.get("kind") != "action" or i["at"] <= last_log or now - i["at"] >= MAX_AGE * 1000:
194
+ continue
195
+ k = f"{i['payload'].get('title')}|{round(i['at'] / 5000)}"
196
+ if k not in seen:
197
+ seen.add(k)
198
+ actions.append(i)
199
+ skipped = sum(1 for i in items if i.get("kind") == "action") - len(actions)
200
+ if not (cfg["apiKey"] and cfg["enabled"]):
201
+ _rewrite(path, actions)
202
+ return {"sent": 0, "skipped": skipped, "kept": len(actions)}
203
+ keep, sent = [], 0
204
+ for n, item in enumerate(actions):
205
+ if n >= MAX_PER_FLUSH:
206
+ keep.append(item)
207
+ continue
208
+ req = urllib.request.Request(
209
+ cfg["apiUrl"],
210
+ data=json.dumps(item["payload"]).encode(),
211
+ headers={"content-type": "application/json", "authorization": f"Bearer {cfg['apiKey']}"},
212
+ method="POST",
213
+ )
214
+ try:
215
+ with urllib.request.urlopen(req, timeout=5) as r:
216
+ if 200 <= r.status < 300:
217
+ sent += 1
218
+ except urllib.error.HTTPError as e:
219
+ if e.code not in (400, 401, 402):
220
+ keep.append(item)
221
+ except Exception:
222
+ keep.append(item)
223
+ _rewrite(path, keep)
224
+ return {"sent": sent, "skipped": skipped, "kept": len(keep)}
225
+
226
+
227
+ def _flush_bg(session: str):
228
+ threading.Thread(target=flush, args=(session,), daemon=True).start()
229
+
230
+
231
+ def on_post_tool_call(tool_name=None, args=None, result=None, session_id=None, status=None, error_message=None, **kwargs):
232
+ try:
233
+ cfg = _config()
234
+ if not cfg["enabled"]:
235
+ return
236
+ name = str(tool_name or "")
237
+ args = args if isinstance(args, dict) else {}
238
+ session = str(session_id or kwargs.get("task_id") or "default")
239
+ now = time.time() * 1000
240
+ if re.search(r"wavebrain.*log_action$", name, re.I):
241
+ _append(_queue(session), {"kind": "agent_logged", "at": now})
242
+ return
243
+ failed = status not in (None, "success", "ok") or bool(error_message)
244
+ if not failed and isinstance(result, str):
245
+ m = re.search(r'"exit_code"\s*:\s*(-?\d+)', result)
246
+ failed = bool(m and m.group(1) != "0")
247
+ action = classify(name, args, args.get("workdir") or os.getcwd(), failed)
248
+ if not action:
249
+ return
250
+ profile = os.environ.get("HERMES_PROFILE") or Path(os.environ.get("HERMES_HOME", "")).name or "hermes"
251
+ agent = "hermes" if profile in ("", ".hermes", "hermes") else f"hermes-{profile}"
252
+ cwd = args.get("workdir")
253
+ payload = {
254
+ "agent": agent[:64],
255
+ "type": action["type"],
256
+ "title": action["title"],
257
+ "details": action["details"],
258
+ "status": "failure" if failed else "success",
259
+ "metadata": {"source": "wavebrain-hook", "host": "hermes", "rule": action["rule"]},
260
+ }
261
+ if cwd:
262
+ payload["project"] = os.path.basename(str(cwd))[:200]
263
+ path = _queue(session)
264
+ _append(path, {"kind": "action", "at": now, "payload": payload})
265
+ if any(i.get("kind") == "action" and now - i["at"] > STALE * 1000 for i in _read(path)):
266
+ _flush_bg(session)
267
+ except Exception:
268
+ pass
269
+
270
+
271
+ def on_session_end(session_id=None, **kwargs):
272
+ try:
273
+ _flush_bg(str(session_id or kwargs.get("task_id") or "default"))
274
+ except Exception:
275
+ pass
276
+
277
+
278
+ def register(ctx):
279
+ ctx.register_hook("post_tool_call", on_post_tool_call)
280
+ ctx.register_hook("on_session_end", on_session_end)
281
+ if hasattr(ctx, "register_system_prompt_section"):
282
+ ctx.register_system_prompt_section("wavebrain.logging", INSTRUCTION, position="after_memory", max_chars=1500)
@@ -0,0 +1,8 @@
1
+ name: wavebrain
2
+ version: 0.3.0
3
+ description: "WaveBrain: records your agent's deploys, pushes, releases, emails, payments and other real-world actions to its audit log, even when the model forgets to call log_action."
4
+ author: Tambo Labs
5
+ homepage: https://getwavebrain.com
6
+ hooks:
7
+ - post_tool_call
8
+ - on_session_end
@@ -0,0 +1,31 @@
1
+ {
2
+ "$comment": "Shell commands that are recorded automatically. Each rule: regex (case-insensitive, matched against the whole command), WaveBrain action type, and a title template ({cmd} = first matching command segment). Shared by every plugin; edit here, then run `node scripts/sync.mjs`.",
3
+ "version": 1,
4
+ "shell": [
5
+ { "id": "git-push", "pattern": "(^|[\\s;&|(])git\\s+push\\b", "type": "push", "title": "Pushed to git: {cmd}" },
6
+ { "id": "git-commit", "pattern": "(^|[\\s;&|(])git\\s+commit\\b", "type": "commit", "title": "Made a git commit: {cmd}" },
7
+ { "id": "gh-pr-create", "pattern": "(^|[\\s;&|(])gh\\s+pr\\s+create\\b", "type": "pull_request", "title": "Opened a pull request: {cmd}" },
8
+ { "id": "gh-pr-merge", "pattern": "(^|[\\s;&|(])gh\\s+pr\\s+merge\\b", "type": "merge", "title": "Merged a pull request: {cmd}" },
9
+ { "id": "gh-release", "pattern": "(^|[\\s;&|(])gh\\s+release\\s+create\\b", "type": "release", "title": "Created a GitHub release: {cmd}" },
10
+ { "id": "vercel-deploy", "pattern": "(^|[\\s;&|(])(npx\\s+)?vercel\\b[^;&|]*(\\s--prod\\b|\\sdeploy\\b|\\sredeploy\\b|\\spromote\\b)", "type": "deploy", "title": "Deployed with Vercel: {cmd}" },
11
+ { "id": "vercel-env", "pattern": "(^|[\\s;&|(])(npx\\s+)?vercel\\s+env\\s+(add|rm|remove|update)\\b", "type": "file_change", "title": "Changed Vercel environment variables: {cmd}" },
12
+ { "id": "railway", "pattern": "(^|[\\s;&|(])railway\\s+(up|deploy|redeploy|variables\\s+--set|variable\\s+set)\\b", "type": "deploy", "title": "Deployed with Railway: {cmd}" },
13
+ { "id": "fly", "pattern": "(^|[\\s;&|(])(fly|flyctl)\\s+(deploy|secrets\\s+set)\\b", "type": "deploy", "title": "Deployed with Fly.io: {cmd}" },
14
+ { "id": "wrangler", "pattern": "(^|[\\s;&|(])(npx\\s+)?wrangler\\s+(deploy|publish|secret\\s+put)\\b", "type": "deploy", "title": "Deployed with Cloudflare Wrangler: {cmd}" },
15
+ { "id": "netlify", "pattern": "(^|[\\s;&|(])netlify\\s+deploy\\b", "type": "deploy", "title": "Deployed with Netlify: {cmd}" },
16
+ { "id": "kubectl", "pattern": "(^|[\\s;&|(])kubectl\\s+(apply|rollout|delete|scale|set)\\b", "type": "deploy", "title": "Changed Kubernetes: {cmd}" },
17
+ { "id": "terraform", "pattern": "(^|[\\s;&|(])(terraform|tofu)\\s+(apply|destroy)\\b", "type": "deploy", "title": "Applied infrastructure changes: {cmd}" },
18
+ { "id": "docker-push", "pattern": "(^|[\\s;&|(])docker\\s+(push|buildx\\s+build[^;&|]*--push)\\b", "type": "publish", "title": "Pushed a Docker image: {cmd}" },
19
+ { "id": "npm-publish", "pattern": "(^|[\\s;&|(])(npm|pnpm|yarn|bun)\\s+publish\\b", "type": "publish", "title": "Published a package: {cmd}" },
20
+ { "id": "eas", "pattern": "(^|[\\s;&|(])(npx\\s+)?eas\\s+(submit|update|build)\\b", "type": "release", "title": "Shipped a mobile build with EAS: {cmd}" },
21
+ { "id": "xcrun-altool", "pattern": "(^|[\\s;&|(])xcrun\\s+altool\\b[^;&|]*--upload|(^|[\\s;&|(])fastlane\\s+(deliver|pilot|upload_to|release)", "type": "release", "title": "Uploaded an app build: {cmd}" },
22
+ { "id": "stripe", "pattern": "(^|[\\s;&|(])stripe\\s+(products|prices|customers|subscriptions|refunds|payment_intents|webhook_endpoints)\\s+(create|update|delete|cancel)\\b", "type": "api_call", "title": "Changed Stripe: {cmd}" },
23
+ { "id": "db-migrate", "pattern": "(^|[\\s;&|(])((npx\\s+)?(prisma\\s+migrate\\s+deploy|drizzle-kit\\s+(migrate|push))|(pnpm|npm|yarn)\\s+(run\\s+)?db:(migrate|push))\\b", "type": "database", "title": "Ran database migrations: {cmd}" },
24
+ { "id": "psql-write", "pattern": "(^|[\\s;&|(])psql\\b[^;&|]*\\b(insert\\s+into|update\\s+\\w+\\s+set|delete\\s+from|drop\\s+table|alter\\s+table|truncate)\\b", "type": "database", "title": "Changed a database: {cmd}" },
25
+ { "id": "curl-write", "pattern": "(^|[\\s;&|(])curl\\b[^;&|]*\\s(-X|--request)\\s*(POST|PUT|PATCH|DELETE)\\b", "type": "api_call", "title": "Called an API: {cmd}" },
26
+ { "id": "send-mail", "pattern": "(^|[\\s;&|(])(sendmail|mail|mutt|himalaya\\s+(send|message\\s+send))\\b", "type": "email", "title": "Sent an email: {cmd}" }
27
+ ],
28
+ "$comment_mcp": "MCP tool calls are recorded when the tool name matches one of these (after the mcp__<server>__ prefix). Read-only tools (list, get, search, read, fetch) are skipped.",
29
+ "mcp_write_verbs": ["create", "update", "delete", "remove", "send", "post", "publish", "deploy", "merge", "push", "submit", "cancel", "refund", "charge", "invite", "set", "add", "upload", "write", "reply", "comment", "approve", "close", "archive"],
30
+ "mcp_skip_servers": ["wavebrain", "context-mode", "plugin_context-mode_context-mode", "claude-in-chrome", "honcho"]
31
+ }