Compare commits

...

65 Commits

Author SHA1 Message Date
Nathan Voxland
d7b30cc648 Allow linear access in fixbot 2026-03-30 13:24:21 -05:00
Nathan Voxland
a07f4c5142 Improving playwright usage 2026-03-30 11:14:39 -05:00
Nathan Voxland
72d6d8cfaa uxbot working better 2026-03-29 03:11:19 -05:00
Nathan Voxland
a14b87e1e2 Started uxbot 2026-03-27 16:56:10 -05:00
Nathan Voxland
99162190ff Applied suggested fixes 2026-03-27 13:19:40 -05:00
Nathan Voxland
bcbd619bb5 Got playwright working, use base claude.md and skills better, general prompt improvements 2026-03-27 01:24:13 -05:00
Nathan Voxland
a61e605d59 allow playwright cache access 2026-03-26 00:39:48 -05:00
Nathan Voxland
e6a22bdf2d Added playwright 2026-03-26 00:29:16 -05:00
Nathan Voxland
1b1ac10722 Improved prompting, plus added fixbot-autoimprove 2026-03-25 17:22:14 -05:00
John Swanson
a5636592f9 clj-nrepl-eval in preflight 2026-03-25 08:41:55 -07:00
Nathan Voxland
0ddd5ed5f3 Git fetch before workmux 2026-03-24 14:11:53 -05:00
Nathan Voxland
af8ab6f892 Added grep permission 2026-03-24 14:09:30 -05:00
Nathan Voxland
646e07bf13 Trust issues 2026-03-24 10:48:16 -05:00
Nathan Voxland
dbcb0a442b Improved detecting existing sessions 2026-03-24 10:34:12 -05:00
Nathan Voxland
b2de568266 Improving prompts and surving killed processes 2026-03-24 00:45:46 -05:00
Nathan Voxland
b35d6a4ffd Improving prompts and surving killed processes 2026-03-23 21:05:21 -05:00
Nathan Voxland
500165025b Improving prompts and surving killed processes 2026-03-23 19:32:47 -05:00
Nathan Voxland
3f3ddddb42 Don't build worktree off fixbot branch 2026-03-23 16:03:40 -05:00
Nathan Voxland
535b12eec2 GH API workaround 2026-03-23 15:53:28 -05:00
Nathan Voxland
1c86036b6c Improving PR process 2026-03-23 15:27:18 -05:00
Nathan Voxland
5b3d675e22 Added skill permission 2026-03-23 14:18:43 -05:00
Nathan Voxland
2f60c2caac Removed playwright, moved code review 2026-03-23 14:03:58 -05:00
Nathan Voxland
d32f8a49cd Fix playwright temp 2026-03-23 12:33:21 -05:00
Nathan Voxland
111a4c7234 Fix playwright cache access 2026-03-23 11:32:11 -05:00
Nathan Voxland
30ee2d711c Better playwright management 2026-03-23 11:13:14 -05:00
Nathan Voxland
201a0d4ec3 allowLocalBinding 2026-03-23 10:45:35 -05:00
Nathan Voxland
19e4810d4a Improvements 2026-03-23 10:35:39 -05:00
Nathan Voxland
ae1d2696e4 Allow workmux access 2026-03-23 10:25:09 -05:00
Nathan Voxland
d7c84a38dc Improving sandbox and run 2026-03-23 10:04:45 -05:00
Nathan Voxland
709066cf52 Improving sandbox and run 2026-03-23 02:16:07 -05:00
Nathan Voxland
d1c5dd40fb Improving sandbox and run 2026-03-23 01:53:52 -05:00
Nathan Voxland
ff2e68f80f Don't track FE status 2026-03-23 00:59:48 -05:00
Nathan Voxland
dbe67a08e9 No sandbox 2026-03-22 02:37:03 -05:00
Nathan Voxland
2856ff8a06 No sandbox 2026-03-22 02:18:06 -05:00
Nathan Voxland
92230ba07a No sandbox 2026-03-22 02:05:33 -05:00
Nathan Voxland
cca203cdfd No sandbox 2026-03-22 01:28:51 -05:00
Nathan Voxland
8e5db2c1b1 No sandbox 2026-03-22 01:25:46 -05:00
Nathan Voxland
ca7071f534 No sandbox 2026-03-22 01:17:10 -05:00
Nathan Voxland
d1be49e4d5 troubleshooting 2026-03-22 00:29:38 -05:00
Nathan Voxland
721feeaefe Better status and troubleshooting 2026-03-22 00:10:26 -05:00
Nathan Voxland
06251b062f Better status 2026-03-21 02:44:53 -05:00
Nathan Voxland
c082aea53f Better repl and log access 2026-03-21 01:29:39 -05:00
Nathan Voxland
2abbcf82ed Support external hostname for FE 2026-03-21 01:23:55 -05:00
Nathan Voxland
bd0fd3c884 Socat FE 2026-03-21 01:11:21 -05:00
Nathan Voxland
fb2034dca3 Trying to get sandboxing to work 2026-03-21 00:59:44 -05:00
Nathan Voxland
f7682b4dcc Trying to get sandboxing to work 2026-03-21 00:13:18 -05:00
Nathan Voxland
17552b6375 Trying to get sandboxing to work 2026-03-20 17:12:10 -05:00
Nathan Voxland
c990aeb80e Trying to get sandboxing to work 2026-03-20 16:51:00 -05:00
Nathan Voxland
b74cfdb16b Support alternate BB location 2026-03-20 12:44:10 -05:00
Nathan Voxland
22ca544aca Sandboxed 2026-03-20 01:31:49 -05:00
Nathan Voxland
ffcf0b58d0 Experience improvements 2026-03-19 18:14:25 -05:00
Nathan Voxland
8ab8efe14f Experience improvements 2026-03-19 17:52:47 -05:00
Nathan Voxland
f40caba17c Experience improvements 2026-03-19 17:24:34 -05:00
Nathan Voxland
8cad754e5e Experience improvements 2026-03-19 17:05:35 -05:00
Nathan Voxland
4dd83ed210 Fixing status 2026-03-19 16:43:50 -05:00
Nathan Voxland
34fee82068 Merge remote-tracking branch 'refs/remotes/origin/master' into fixbot 2026-03-19 16:42:20 -05:00
Nathan Voxland
1c13aa40d4 Fixing status and execution 2026-03-19 16:34:33 -05:00
Nathan Voxland
8a49a30b1d Fixing status bar 2026-03-19 14:48:07 -05:00
Nathan Voxland
56951fedfe Pass claude auth
Misc fixes
2026-03-19 14:29:48 -05:00
Nathan Voxland
6cc71976b8 Improving bead management 2026-03-19 13:22:01 -05:00
Nathan Voxland
3f0278674a Improving session management 2026-03-19 13:07:54 -05:00
Nathan Voxland
0db6507c1f Fixing beads setup 2026-03-19 12:55:16 -05:00
Nathan Voxland
f364d9b028 Dashboard isn't helpful as a command 2026-03-19 12:27:19 -05:00
Nathan Voxland
9f9be85e1f Misc improvements and fixes 2026-03-19 12:01:49 -05:00
Nathan Voxland
e62dabf624 Starting on fixbot 2026-03-19 02:21:06 -05:00
34 changed files with 2580 additions and 9 deletions

View File

@@ -0,0 +1,12 @@
List all fixbot worktree sessions with status information.
## Steps
1. Run the mage task to list sessions:
```
./bin/mage -fixbot-list
```
2. Present the output to the user.
3. Remind the user that they can run `./bin/mage -fixbot-dashboard` in a separate terminal for a live TUI dashboard showing all active agents.

View File

@@ -0,0 +1,12 @@
Pause a fixbot worktree session — stops Docker containers and dev servers, closes tmux window, but preserves the worktree and branch for later resuming. Pass `all` to pause all running fixbot sessions.
The user provided: `$ARGUMENTS`
## Steps
1. Run the mage task to pause the session:
```
./bin/mage -fixbot-pause $ARGUMENTS
```
2. Report the result to the user.

View File

@@ -0,0 +1,5 @@
Tear down and remove a fixbot worktree session.
Run: `./bin/mage -fixbot-quit $ARGUMENTS`
Show the user the output.

View File

@@ -0,0 +1,62 @@
Resume a paused fixbot session, or create a new session from an existing branch or PR.
The user provided: `$ARGUMENTS`
This can be an issue ID (e.g., `MB-12345`), a branch name, or a GitHub PR URL (e.g., `https://github.com/metabase/metabase/pull/12345`).
## Steps
### 1. Try resuming via mage task
Run:
```
./bin/mage -fixbot-resume $ARGUMENTS
```
If this succeeds, the session is resumed — report success and stop.
### 2. If no existing session found (mage task exits non-zero), create from branch/PR
1. **Parse the argument:**
- If it looks like a GitHub PR URL → extract the PR number, then run:
```
gh pr view <NUM> --json headRefName --jq .headRefName
```
to get the branch name. Save the PR number for later.
- If it looks like an issue ID (e.g., `MB-12345`) → use as-is for matching
- Otherwise → treat as a branch name
2. **Determine the issue ID** from the branch name by extracting the `MB-XXXXX` pattern (case-insensitive). If no issue ID can be found, ask the user for it.
3. **Fetch the issue from Linear:**
```
./bin/mage -fixbot-fetch-issue <ISSUE_ID>
```
4. **Determine the app database** — analyze the issue description and comments:
- If it mentions MySQL problems or MySQL-specific syntax → use `mysql`
- If it mentions MariaDB specifically → use `mariadb`
- Otherwise → use `postgres` (default)
5. **If a PR URL was given**, also fetch the PR title and body for additional context:
```
gh pr view <NUM> --json title,body
```
6. **Generate the agent prompt** — fetch the issue with `./bin/mage -fixbot-fetch-issue <ISSUE_ID>` to get the branch name and determine the app database, then run:
```
./bin/mage -bot-generate-prompt \
--template dev/bot/fixbot/fixbot-agent.md \
--output .fixbot/metabase-fixbot-<ISSUE_ID>-prompt.md \
--set ISSUE_ID=<ID> \
--set "BRANCH_NAME=<branch>" \
--set "APP_DB=<Postgres|Mysql|Mariadb>"
```
The worktree agent will fetch the issue details itself during Phase 1.
7. **Launch the session:**
```
./bin/mage fixbot-go <ISSUE_ID> --app-db <DB> --prompt-file .fixbot/metabase-fixbot-<ISSUE_ID>-prompt.md --branch '<BRANCH>'
```
8. Tell the user the session was created from the existing branch.

View File

@@ -0,0 +1,83 @@
You are the orchestrator for the fixbot workflow. Your job is to resolve the issue ID, generate the agent prompt, and launch the workmux session.
## Steps
### 1. Preflight checks
Run:
```
./bin/mage -fixbot-preflight
```
If it fails, show the error to the user and stop. Do not attempt to recover or work around failures.
### 2. Resolve the issue ID
The user provided: `$ARGUMENTS`
This can be one of three formats:
- **Linear issue ID** (e.g., `MB-12345`, `UXW-3155`) — use directly
- **GitHub issue number** (e.g., `12345`) — resolve to Linear first
- **GitHub issue URL** (e.g., `https://github.com/metabase/metabase/issues/12345`) — extract the number, then resolve to Linear
**If the input is a GitHub issue number or URL:**
1. Fetch the GitHub issue with `gh issue view <NUMBER> --repo metabase/metabase --json body,comments,title`
2. Search the issue body and comments for a Linear issue link (pattern: `https://linear.app/metabase/issue/[A-Z]+-[0-9]+`). Extract the Linear issue ID from the URL.
3. If no Linear link is found in the GitHub issue, search Linear directly: run `./bin/mage -fixbot-fetch-issue` with a search term derived from the GitHub issue title. If that doesn't find a match, tell the user you couldn't find a corresponding Linear issue and stop.
4. Tell the user which Linear issue you resolved to, so they can verify it's correct.
**Validation:** After resolving, confirm the issue ID looks like a Linear identifier (e.g., `MB-12345`). If not, tell the user the expected format and stop.
Run `./bin/mage -fixbot-list` and check if a session already exists for this issue (look for the issue ID in the branch name or session name). If one exists:
- Tell the user the session already exists
- Show them how to connect: `tmux attach -t <session-name>`
- Show them how to stop it: `/fixbot-quit <issue-id>`
- **STOP** — do not proceed with the remaining steps. Do not attempt to shut it down or clean it up.
### 3. Fetch the issue from Linear
Run:
```
./bin/mage -fixbot-fetch-issue <ISSUE_ID>
```
Read the output to extract:
- Branch name (the suggested branch name from Linear)
Also determine the app database from the issue description/comments:
- If the issue mentions **MySQL** problems, MySQL-specific SQL syntax, or MySQL error messages → `mysql`
- If the issue mentions **MariaDB** specifically → `mariadb`
- Otherwise → `postgres` (the default)
If the issue is not found, tell the user and stop.
### 4. Generate the agent prompt
Run:
```
./bin/mage -bot-generate-prompt \
--template dev/bot/fixbot/fixbot-agent.md \
--output .fixbot/metabase-fixbot-<ISSUE_ID>-prompt.md \
--set ISSUE_ID=<ISSUE_ID> \
--set "BRANCH_NAME=<branch>" \
--set "APP_DB=<Postgres|Mysql|Mariadb>"
```
The worktree agent will fetch the issue details itself during Phase 1.
### 5. Launch the workmux session
Run:
```
./bin/mage fixbot-go <ISSUE_ID> --app-db <DB> --prompt-file .fixbot/metabase-fixbot-<ISSUE_ID>-prompt.md --branch '<BRANCH_NAME>'
```
Using the values from step 3.
### 6. Report
Tell the user:
- Which issue you're fixing
- Which database was chosen and why
- That the workmux session has been launched
- That they can run `./bin/mage -fixbot-dashboard` in a separate terminal for a live TUI dashboard showing all active agents

View File

@@ -0,0 +1,5 @@
Stop a uxbot session (kill tmux and dev environment, but keep the worktree).
Run: `./bin/mage -uxbot-stop $ARGUMENTS`
Show the user the output.

71
.claude/commands/uxbot.md Normal file
View File

@@ -0,0 +1,71 @@
You are the orchestrator for the uxbot workflow. Your job is to launch a UX testing session where an agent acts as a regular Metabase user trying to accomplish tasks.
## Steps
### 1. Preflight checks
Verify these are available by running each check (stop if any fail):
- `workmux --version` — workmux is installed (`cargo install workmux`)
- `docker info` — Docker is running
- Check `MB_PREMIUM_EMBEDDING_TOKEN` env var is set
- `npx -y @playwright/mcp --version` — Playwright MCP is available (auto-installs via npx)
- `pandoc --version` — pandoc is installed (`brew install pandoc`)
- `weasyprint --version` — weasyprint is installed (`pip3 install weasyprint`)
- Check `node_modules/` exists in the project root (run `bun install` if not)
### 2. Parse arguments
The user provided: `$ARGUMENTS`
Parse as: `<branch-name> [task description...]`
The first word is the branch name. Everything after it is the initial task description (optional).
### 3. Generate the agent prompt
If the user provided a task description, set `INITIAL_TASK` to:
```
## Your First Task
<task description>
```
If no task was provided, set `INITIAL_TASK` to:
```
No initial task specified. Wait for the user to give you a task.
```
Run:
```
./bin/mage -bot-generate-prompt \
--template dev/bot/uxbot/uxbot-agent.md \
--output .uxbot/uxbot-prompt.md \
--set "BRANCH_NAME=<branch>" \
--set "INITIAL_TASK=<initial task text>"
```
**Shell escaping:** The `--set` values are passed as shell arguments. If a value contains quotes, dollar signs, backticks, or other shell metacharacters, escape them or use single quotes for the outer quoting. For example: `--set 'INITIAL_TASK=## Your First Task
Show me the "orders" dashboard'`. When in doubt, write the value to a temp file and use command substitution: `--set "INITIAL_TASK=$(cat /tmp/task.txt)"`.
### 4. Launch the workmux session
Run:
```
./bin/mage uxbot-go <BRANCH_NAME> --app-db postgres --prompt-file .uxbot/uxbot-prompt.md
```
This will:
- If a worktree already exists for this branch, reuse it (fresh tooling + prompt copied in)
- If a tmux session is already running, it will refuse and tell you to stop it first
- If no worktree exists, create a fresh one
If it fails, show the error to the user and stop.
### 5. Report
Tell the user:
- The uxbot session has been launched
- How to attach: `tmux attach -t uxbot-<branch-slug>`
- Available commands inside the session: `/uxbot-report` (generate UX report), `/uxbot-reset` (clear browser state)
- How to stop: `/uxbot-stop <branch>`

3
.gitignore vendored
View File

@@ -13,6 +13,9 @@
.\#*
.cpcache/
.eastwood
/.beads/
/.fixbot/
/.uxbot/
.idea/
.nrepl-port
.portal

129
bb.edn
View File

@@ -260,7 +260,7 @@
{:doc "Generate XML configuration for Cursive's symbol resolution based on the current clj-kondo lint-as configuration."
:examples [["./bin/mage generate-cursive-resolve-as" "Generate resolve-as XML configuration"]
["./bin/mage generate-cursive-resolve-as > /Users/bgrabow/Library/Application\\ Support/JetBrains/IntelliJIdea2025.3/options/ClojureResolveSettings.xml"
"Generate resolve-as XML configuration and install it in your global editor configuration"]]
"Generate resolve-as XML configuration and install it in your global editor configuration"]]
:requires [[mage.cursive-resolve-as :as cursive-resolve-as]]
:task (task! (cursive-resolve-as/do-it))}
@@ -602,6 +602,127 @@
:requires [[mage.bgrabow.ci-test-repl :as ci-test-repl]]
:options [["-n" "--dry-run" "Print docker, env, and clojure commands without executing"]]
:task (task!
(let [wj (first (:arguments parsed))
[workflow job] (when wj (clojure.string/split wj (re-pattern "/") 2))]
(ci-test-repl/ci-test-repl {:workflow workflow :job job :dry-run? (:dry-run (:options parsed))})))}}}
(let [wj (first (:arguments parsed))
[workflow job] (when wj (clojure.string/split wj (re-pattern "/") 2))]
(ci-test-repl/ci-test-repl {:workflow workflow :job job :dry-run? (:dry-run (:options parsed))})))}
-fixbot-preflight
{:doc "Check prerequisites for the fixbot workflow"
:examples [["./bin/mage -fixbot-preflight" "Run all preflight checks"]]
:requires [[mage.fixbot.preflight :as preflight]]
:task (task! (preflight/preflight!))}
-fixbot-status
{:doc "Watch and display fixbot status"
:requires [[mage.fixbot.status :as status]]
:arg-schema [:? [:string {:name "file-path"}]]
:task (task! (status/run! parsed))}
fixbot-go
{:doc "Launch auto-fix workflow for a Linear issue"
:examples [["./bin/mage fixbot-go MB-12345 --app-db postgres --prompt-file /tmp/prompt.md --branch 'username/mb-12345-fix-thing'"
"Launch workmux session"]]
:requires [[mage.fixbot.go :as fixbot-go]]
:arg-schema [:tuple [:string {:name "issue-id"}]]
:options [[nil "--app-db DB" "App database" :default "postgres"]
[nil "--prompt-file FILE" "Path to agent prompt file"]
[nil "--branch BRANCH" "Branch name from Linear"]
[nil "--base BASE" "Base branch/commit to branch from"]]
:task (task! (fixbot-go/run! parsed))}
-bot-generate-prompt
{:doc "Fill a template with placeholders and write an agent prompt"
:examples [["./bin/mage -bot-generate-prompt --template dev/bot/fixbot/fixbot-agent.md --output .fixbot/prompt.md --set ISSUE_ID=MB-123 --set APP_DB=Postgres"
"Generate fixbot prompt"]]
:requires [[mage.bot.prompt :as bot-prompt]]
:options [[nil "--template FILE" "Template file path"]
[nil "--output FILE" "Output file path"]
[nil "--set KEY=VALUE" "Set a template variable (can be repeated)"
:multi true :default [] :update-fn conj]]
:task (task! (bot-prompt/generate-prompt! parsed))}
-bot-dev-env
{:doc "Stand up/tear down dev environment (BE, FE, DB) for bot worktrees"
:examples [["./bin/mage -bot-dev-env --app-db postgres" "Postgres app-db"]
["./bin/mage -bot-dev-env --down" "Tear down"]]
:requires [[mage.bot.dev-env :as bot-dev-env]]
:options [[nil "--app-db DB" "App database: postgres (default), mysql, h2" :default "postgres"]
[nil "--down" "Tear down containers"]
[nil "--status" "Show status"]
[nil "--slot SLOT" "Override port slot" :parse-fn Integer/parseInt]]
:task (task! (bot-dev-env/dev-env! parsed))}
;; Alias for backward compat
-fixbot-dev-env
{:doc "Alias for -bot-dev-env"
:requires [[mage.bot.dev-env :as bot-dev-env]]
:options [[nil "--app-db DB" :default "postgres"]
[nil "--down"] [nil "--status"]
[nil "--slot SLOT" :parse-fn Integer/parseInt]]
:task (task! (bot-dev-env/dev-env! parsed))}
-fixbot-fetch-issue
{:doc "Fetch a Linear issue by identifier"
:examples [["./bin/mage -fixbot-fetch-issue MB-12345" "Fetch and display issue"]]
:requires [[mage.fixbot.linear :as linear]]
:arg-schema [:tuple [:string {:name "issue-id"}]]
:task (task! (linear/print-issue! parsed))}
-fixbot-pause
{:doc "Pause a fixbot session (stop containers, close tmux, keep worktree)"
:examples [["./bin/mage -fixbot-pause mb-12345" "Pause session"]]
:requires [[mage.fixbot.session :as session]]
:arg-schema [:tuple [:string {:name "name-or-id"}]]
:task (task! (session/pause! parsed))}
-fixbot-resume
{:doc "Resume a paused fixbot session"
:examples [["./bin/mage -fixbot-resume mb-12345" "Resume session"]]
:requires [[mage.fixbot.session :as session]]
:arg-schema [:tuple [:string {:name "name-or-id"}]]
:task (task! (session/resume! parsed))}
-fixbot-list
{:doc "List fixbot sessions with status"
:examples [["./bin/mage -fixbot-list" "List all sessions"]]
:requires [[mage.fixbot.session :as session]]
:task (task! (session/list-sessions! parsed))}
-fixbot-quit
{:doc "Tear down and remove a fixbot worktree session"
:examples [["./bin/mage -fixbot-quit mb-12345" "Remove session and worktree"]]
:requires [[mage.fixbot.session :as session]]
:arg-schema [:tuple [:string {:name "name-or-id"}]]
:task (task! (session/quit! parsed))}
-fixbot-write-sandbox-settings
{:doc "Write container-compatible settings.local.json for sandbox"
:examples [["./bin/mage -fixbot-write-sandbox-settings" "Write sandbox settings"]]
:requires [[mage.fixbot.session :as session]]
:task (task! (session/write-sandbox-settings! parsed))}
-fixbot-dashboard
{:doc "Open fixbot TUI dashboard"
:examples [["./bin/mage -fixbot-dashboard" "Open dashboard"]]
:requires [[mage.fixbot.session :as session]]
:task (task! (session/dashboard!))}
;;; UXBot tasks
uxbot-go
{:doc "Launch UX testing session for a branch"
:examples [["./bin/mage uxbot-go my-feature-branch --prompt-file .uxbot/prompt.md"
"Launch uxbot session"]]
:requires [[mage.uxbot.go :as uxbot-go]]
:arg-schema [:tuple [:string {:name "branch-name"}]]
:options [[nil "--app-db DB" "App database" :default "postgres"]
[nil "--prompt-file FILE" "Path to agent prompt file"]
[nil "--base BASE" "Base branch/commit to branch from"]]
:task (task! (uxbot-go/run! parsed))}
-uxbot-stop
{:doc "Stop a uxbot session (kill tmux + dev env, keep worktree)"
:examples [["./bin/mage -uxbot-stop my-branch" "Stop session"]]
:requires [[mage.uxbot.session :as uxbot-session]]
:arg-schema [:tuple [:string {:name "name-or-id"}]]
:task (task! (uxbot-session/stop! parsed))}}}

View File

@@ -93,4 +93,4 @@ exec docker run -it --rm \
"${ENV_ARGS[@]}" \
--add-host=host.docker.internal:host-gateway \
"$IMAGE_NAME" \
--dangerously-skip-permissions
--dangerously-skip-permissions "$@"

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Initialize beads in a git worktree by creating the database in a temp repo
# and moving it into the current directory.
#
# Usage: bd-init-worktree.sh [worktree-name]
set -euo pipefail
if [ -d ".beads" ]; then
echo "beads already initialized, skipping"
exit 0
fi
name="${1:-$(basename "$PWD")}"
scratch=".fixbot/bd-scratch"
mkdir -p "$scratch"
git init -q "$scratch/repo"
(cd "$scratch/repo" && echo "n" | bd init --stealth --quiet --skip-hooks -p "$name")
mv "$scratch/repo/.beads" .beads
rm -rf "$scratch"

View File

@@ -0,0 +1,10 @@
## Instance Setup
The dev environment is pre-configured with users and API keys via `MB_CONFIG_FILE_PATH`. No manual setup or API calls are needed. The instance will auto-create these on first startup:
- **Admin user**: `admin@example.com` / `admin123` (superuser)
- **Regular user**: `regular@example.com` / `regular123`
- **Admin API key**: `mb_AdminApiKey` (admin permissions)
- **Regular API key**: `mb_RegularApiKey` (regular permissions)
Do NOT call `/api/setup` — it is already handled.

View File

@@ -0,0 +1,101 @@
## Browser Automation with Playwright MCP
A Playwright MCP server is configured in `.mcp.json`. Load the tool schemas first with `ToolSearch`: `select:mcp__playwright__browser_navigate,mcp__playwright__browser_snapshot,mcp__playwright__browser_click,mcp__playwright__browser_fill,mcp__playwright__browser_type,mcp__playwright__browser_press_key,mcp__playwright__browser_hover,mcp__playwright__browser_take_screenshot,mcp__playwright__browser_close`
If ToolSearch says "MCP servers still connecting," wait a few seconds and retry — the server takes a moment to start on first use.
### Core pattern: Snapshot → Act → Check
1. **`browser_snapshot`** — see what's on screen, get element refs
2. **Act** — `browser_click`, `browser_fill`, `browser_type`, etc. using refs from the snapshot
3. **Check the inline response** — every action returns a snapshot in its response. Read it.
**When to take a separate `browser_snapshot` after acting:**
- The inline response snapshot looks wrong, empty, or unchanged — take a fresh one (async rendering may not have completed)
- You need refs for your NEXT action — the inline snapshot's refs are valid, use them directly
- You're unsure if the action worked — take one more snapshot to confirm
**When you do NOT need a separate snapshot after acting:**
- The inline response already shows the expected change (e.g., you clicked a link and the response shows the new page)
- You're about to take a screenshot anyway (`browser_take_screenshot` shows the current state)
- You're doing a chain of actions on the same form (e.g., filling multiple fields) — snapshot once at the end, not after each fill
**Element refs go stale after every action.** Use refs from the most recent snapshot or inline response — never from an earlier one.
### How to interact with Metabase's UI components
Metabase uses Mantine UI components. Most interactions work with a plain `browser_click`. The hover-before-click pattern is only needed for specific component types.
**Regular buttons, links, form inputs, checkboxes, tabs:**
Just `browser_click` them directly. No hover needed.
**Buttons that open dropdown menus (e.g., "+ New", "..." action menus, filter type pickers):**
These use Mantine's `<Menu>` component which has a race condition with direct clicks. **Hover before clicking** these:
1. `browser_hover` on the button
2. `browser_click` on the button
3. Check the inline response — if the menu appeared, use its refs directly
How to tell if a button opens a dropdown menu: it usually has a chevron/arrow icon, a "..." label, or is labeled as creating something new (like "+ New"). If unsure, try a direct click first — if it doesn't work, retry with hover.
**Select and dropdown components (e.g., database picker, column picker):**
Mantine Select/MultiSelect are NOT native `<select>` elements. `browser_select_option` will NOT work. Instead:
1. `browser_click` on the input/trigger
2. `browser_click` on the option you want (use refs from the inline response)
You can also type into the input to filter options before clicking.
**Modals and dialogs:**
To dismiss: `browser_click` the close/action button, or `browser_press_key` with `Escape`.
### Key tools
| Tool | What it does | Key params |
|------|-------------|------------|
| `browser_navigate` | Go to a URL | `url` |
| `browser_snapshot` | Accessibility tree with element refs | — |
| `browser_click` | Click an element | `element`, `ref` |
| `browser_fill` | Clear and fill a text field | `element`, `ref`, `value` |
| `browser_type` | Type with keyboard events (appends) | `text`, optional `element`/`ref` |
| `browser_press_key` | Press a key (Enter, Escape, Tab, ArrowDown, etc.) | `key` |
| `browser_select_option` | Select from a native dropdown (NOT for Mantine) | `element`, `ref`, `values` |
| `browser_hover` | Hover over an element | `element`, `ref` |
| `browser_evaluate` | Run JavaScript on the page | `script` |
| `browser_take_screenshot` | Save a visual screenshot | `raw` (base64) |
| `browser_console_messages` | Browser console logs | — |
| `browser_network_requests` | Network activity | — |
| `browser_close` | Close the browser | — |
| `browser_resize` | Change viewport size | `width`, `height` |
| `browser_navigate_back` | Browser back button | — |
| `browser_wait_for` | Wait for a condition | — |
### Login
1. `browser_navigate` to `http://localhost:$MB_JETTY_PORT/auth/login`
2. `browser_snapshot` → `browser_fill` email and password → `browser_click` sign-in button
3. **`browser_navigate` to `http://localhost:$MB_JETTY_PORT/`** — always navigate explicitly to the home page after login. Do NOT rely on the login redirect alone. The redirect can leave the browser session in a state where clicks don't register.
4. `browser_snapshot` to confirm you're logged in
### When clicks don't seem to work
If a click had no effect (inline response shows no change):
1. **Take one fresh snapshot** — async rendering may not have completed
2. **Try hover + click** — if the element is a menu trigger
3. **Try keyboard** — `browser_click` to focus, then `browser_press_key` with `Enter` or `Space`
4. **If nothing on the page responds at all**, `browser_close` and `browser_navigate` to restart the session
5. **After 3 attempts**, report it as a struggle and move on. Do NOT use JavaScript workarounds.
### Configuration notes
The browser is pre-configured with:
- **1440x900 viewport** — wide enough to avoid responsive breakpoints hiding UI elements
- **Full snapshot mode** — every snapshot shows the complete page, not just changes since the last one
- **Isolated session** — clean in-memory browser profile every time (no stale state from previous sessions)
- **10-second action timeout** — longer than default to handle slow-rendering menus
### General rules
- Always use `http://localhost:$MB_JETTY_PORT` — never any other port
- Close the browser when done to free resources
- If the Playwright MCP tools are unavailable on first use, skip browser work entirely
- **Be efficient with snapshots** — use the inline response snapshot when it shows what you need. Only take a separate `browser_snapshot` when the inline response is insufficient.

View File

@@ -0,0 +1,45 @@
agent: claude
files:
symlink:
- node_modules
- bin/bb
post_create:
# Copy bot tooling from source repo (worktree is based on master, not the bot branch)
- mkdir -p .claude/{{BOT_NAME}}
- cp -r {{SOURCE_REPO}}/dev/bot/{{BOT_NAME}}/* .claude/{{BOT_NAME}}/
- cp -r {{SOURCE_REPO}}/dev/bot/common/* .claude/{{BOT_NAME}}/
- cp -r {{SOURCE_REPO}}/mage/src/mage/ mage/src/mage/
- cp {{SOURCE_REPO}}/bb.edn bb.edn
- sed "s|{{pwd}}|$(pwd)|;s|{{tmpdir}}|${TMPDIR%/}|" .claude/{{BOT_NAME}}/settings.local-template.json > .claude/settings.local.json
- mkdir -p .{{BOT_NAME}}
- "echo 'Starting up' > .{{BOT_NAME}}/llm-status.txt"
- rm -f .claude/commands/{{BOT_NAME}}*.md
- cp .claude/{{BOT_NAME}}/commands/*.md .claude/commands/
- mkdir -p .github/hooks/workmux-status
- echo '{"version":1,"hooks":{"userPromptSubmitted":[{"type":"command","bash":"workmux set-window-status working"}],"postToolUse":[{"type":"command","bash":"workmux set-window-status working"}],"agentStop":[{"type":"command","bash":"workmux set-window-status done"}]}}' > .github/hooks/workmux-status/hooks.json
{{BOT_POST_CREATE}}
- mise trust mise.local.toml
- bun install
- npx -y @playwright/mcp@0.0.68 --version
- 'echo ''{"mcpServers":{"playwright":{"command":"npx","args":["-y","@playwright/mcp@0.0.68","--headless","--browser","chrome","--viewport-size","1440x900","--snapshot-mode","full","--block-service-workers","--isolated","--timeout-action","10000"]}}}'' > .mcp.json'
pre_remove:
- ./bin/mage -bot-dev-env --down
panes:
- command: <agent>
focus: true
- command: mise x -- clj -M:dev:dev-start:drivers:drivers-dev:ee:ee-dev 2>&1 | tee .{{BOT_NAME}}/backend-$(date +%Y%m%d-%H%M%S).log
split: horizontal
percentage: 30
- command: mise x -- bun run build-hot 2>&1 | tee .{{BOT_NAME}}/frontend-$(date +%Y%m%d-%H%M%S).log
split: vertical
- command: mise x -- ./bin/mage -fixbot-status .{{BOT_NAME}}/llm-status.txt
split: vertical
size: 7

View File

@@ -0,0 +1,76 @@
Review your conversation transcript from this session and generate a structured improvement report. Save the report to `FIXBOT-SUGGESTIONS.md` in the current worktree root.
## What to analyze
Scroll back through the full conversation history from this session. Look at every tool call, every error, every retry, every place you got stuck or went in circles. Categorize what you find.
## Categories to evaluate
### 1. Tool issues
- Tools that failed, returned unexpected results, or required workarounds
- Tools you tried to use but couldn't (missing permissions, wrong arguments, etc.)
- Tools you used inefficiently (e.g., multiple calls where one would suffice)
### 2. Prompt gaps
- Steps that were missing from your initial prompt that you had to figure out on your own
- Instructions that were ambiguous or led you down the wrong path
- Steps that were in the wrong order or had missing prerequisites
- Situations where you had to ask the user for clarification that should have been covered in the prompt
### 3. Knowledge gaps
- Facts about the codebase, architecture, or conventions you had to discover through trial and error
- Things you assumed incorrectly about how the code works
- Database schemas, API endpoints, config options, or other reference info that would have saved time if documented upfront
### 4. Memory-worthy patterns
- Stable lessons learned that would benefit future fixbot sessions (not one-off fixes)
- Common failure modes and their solutions
- Patterns about how this codebase works that aren't obvious from reading the code
### 5. Workflow inefficiencies
- Places where you repeated work unnecessarily
- Times you went down a dead end — what signal should have told you earlier?
- Test/verify cycles that could have been shorter
## Report format
Write `FIXBOT-SUGGESTIONS.md` with this structure:
```markdown
# Fixbot Auto-Improvement Report
**Session:** <issue ID>
**Date:** <today's date>
**Outcome:** <succeeded / partially succeeded / failed>
## Summary
<2-3 sentence summary of the session and the main improvement themes>
## Suggestions
### <Category name>
#### <Short title for the suggestion>
**What happened:** <Describe the specific problem you encountered>
**Where to fix:** <Exact file path and section/line range>
**Proposed change:** <The actual text to add, remove, or modify — be specific enough that someone can apply it directly>
**Impact:** <What this would have saved — time, retries, user interruptions, etc.>
(Repeat for each suggestion, grouped by category)
## Quick wins
<Bulleted list of the 3-5 highest-impact, lowest-effort changes>
```
## Rules
- **Be specific** — include exact file paths, section names, line ranges, and actual proposed text changes. Vague suggestions like "improve error handling" are useless.
- **Be actionable** — every suggestion must propose a concrete fix that can be applied. If you can't propose a specific change, it's not ready to be a suggestion.
- **Be honest** — if you made a mistake due to your own reasoning (not a prompt gap), say so. Not everything is the prompt's fault.
- **Prioritize by impact** — order suggestions within each category by how much time/frustration they would save in future sessions.
- **Include prompt text diffs** — for prompt gap suggestions, show the exact text you'd add to the prompt file (e.g., `.fixbot/metabase-fixbot-<ID>-prompt.md` template or `dev/bot/fixbot/fixbot-agent.md`) as a before/after or insertion.
- **Cover the fixbot orchestrator too** — if the issue was in how the session was set up (database choice, prompt generation, environment config), suggest changes to `.claude/commands/fixbot.md` as well.
- **Don't suggest things that are already documented** — if the answer was in the prompt and you just missed it, that's a different problem than a missing instruction. Note which it was.

View File

@@ -0,0 +1,55 @@
Monitor CI for this branch's PR, fix failures you caused, and re-run flaky tests.
## Step 1: Find the PR
Run `gh pr view --json number,url,headRefName` to get the PR for the current branch. If no PR exists, tell the user and stop.
## Step 2: Monitor CI
Check CI status every 10 minutes until all checks pass or you've exhausted retries:
1. Run `gh pr checks` to see the current status of all checks
2. If all checks pass, tell the user "CI is green" and stop
3. If checks are still running, wait 10 minutes and check again
4. If checks have failed, proceed to Step 3
5. **Ignore** the "Decide whether to backport or not" check — that's a label added by the user/reviewer
## Step 3: Analyze failures
For each failed check:
1. Run `./bin/mage ci-report` to get a detailed failure report
2. Categorize each failure:
- **Caused by your changes**: The test failure is in code you modified, or the error clearly relates to your changes
- **Flaky test / pre-existing**: The failure is in unrelated code, is a known flaky test, or is an infrastructure issue (timeout, OOM, network)
**Flaky tests are common in this repo.** If a test failure looks unrelated to your changes, it's likely a flake.
## Step 4: Handle failures
**For failures caused by your changes:**
1. Fix the issue in your code
2. Run the failing test locally with `./bin/test-agent` to confirm the fix
3. Commit and push the fix — **you don't need to wait for the current CI run to finish.** Pushing new changes automatically cancels the old run and starts a new one.
4. Go back to Step 2 to monitor the new CI run
**For flaky / pre-existing failures:**
1. Re-run all failed jobs: `gh run rerun --failed`
2. Go back to Step 2 to monitor the re-run
3. **If the same test fails again on the 3rd run**, investigate more closely:
- Read the test and the failure output carefully
- Determine whether your changes could have caused it
- If it's genuinely unrelated, re-run one more time
- If your changes may be the cause, treat it as "caused by your changes" and fix it
**Hard limits:**
- **Never re-run more than 5 times without making code changes.** If tests keep failing after 5 re-runs, something is wrong — investigate or stop.
- **If you are not making progress** on getting CI to pass (e.g., stuck in a loop of re-runs, or fixes aren't helping), **STOP and tell the user.** They can work with the Metabase developers from there.
## Step 5: Report
After CI passes (or after exhausting retries), tell the user:
- CI status (green / still failing)
- What failures you fixed
- What failures you re-ran as flakes
- If anything still needs attention

View File

@@ -0,0 +1,260 @@
# Fixbot Agent — {{ISSUE_ID}}
## Issue
**ID:** {{ISSUE_ID}}
**Branch:** `{{BRANCH_NAME}}`
## CRITICAL: 20-Minute Time Limit
**YOU MUST NOT SPEND MORE THAN 20 MINUTES FIXING.** If you have not completed Phases 1–3 (understand, fix, self-review) within 20 minutes, STOP immediately. Present what you have so far — your diagnosis, any partial fix, what's blocking you — and ask the user for guidance. A fixbot that explains where it's stuck after 20 minutes is far more valuable than one that silently burns time going in circles.
## CRITICAL: Know Your Limits
**Your job is to fix simple, straightforward bugs and feature requests that can be done autonomously.** You are NOT a substitute for human judgment on complex decisions.
**STOP and tell the user why** if any of the following apply:
- The fix requires complex architectural decisions or trade-offs that reasonable engineers would disagree on
- The change impacts existing functionality in surprising or non-obvious ways (e.g., changing behavior that other features depend on)
- The feature request may or may not be a good idea — it needs product discussion, not just implementation
- The issue is ambiguous enough that different interpretations lead to very different solutions
- The fix requires changes across many subsystems or has a large blast radius
- You find yourself guessing about intended behavior rather than being confident
When in doubt, err on the side of stopping. A paused fixbot that explains the situation is far more valuable than one that ships a questionable change. Explain what you found, what the options are, and why a human should decide.
### Getting the User's Attention
Any time you need user input, are asking a question, stopping for a decision, or want to make sure the user sees something important, surround it with an eye-catching banner. The user may not be watching closely, so make it impossible to miss. For example:
```
╔══════════════════════════════════════════════════════════════╗
║ 🛑 FIXBOT NEEDS YOUR INPUT ║
╠══════════════════════════════════════════════════════════════╣
║ ║
║ <your message here> ║
║ ║
╚══════════════════════════════════════════════════════════════╝
```
Use different headers to match the situation (e.g., "READY FOR TESTING", "QUESTION", "STOPPING — HUMAN DECISION NEEDED", "PR OPENED"). Be creative with the banners — vary them so they stay noticeable.
## Environment
Ports are dynamically assigned per worktree. **You MUST read `mise.local.toml` at startup** to discover your ports:
- `MB_JETTY_PORT` — backend URL is `http://localhost:$MB_JETTY_PORT`
- `MB_FRONTEND_DEV_PORT` — frontend dev server is `http://localhost:$MB_FRONTEND_DEV_PORT`
- `NREPL_PORT` — nREPL server port
- The app database port is in the JDBC URL (`MB_DB_CONNECTION_URI`)
Do not hardcode or assume any port numbers.
**App database:** {{APP_DB}}
**IMPORTANT**: The dev environment always runs the **Enterprise Edition (EE)**. Even if the issue mentions the OSS version, develop and test against EE. If the fix specifically requires running the OSS edition (e.g., testing OSS-only behavior that differs from EE), STOP and tell the user — do not attempt an OSS-only fix.
## About the User
The user is NOT a developer — do not ask them for implementation help, code suggestions, or technical decisions. Work autonomously on all code, debugging, and architecture choices. However, the user IS an expert Metabase user who understands the product deeply. Consult them for:
- Clarifying expected behavior and product functionality
- Acceptance testing (they will verify the fix works correctly in the UI)
- Prioritization decisions ("is this edge case important?")
{{FILE:dev/bot/common/instance-setup.md}}
Use these credentials to log in via the UI or make API calls.
## Instructions
**CRITICAL: Execute all phases (0 through 4) in a single turn without stopping.** Do not end your turn after self-review — immediately continue to Phase 4 (browser verification and user testing instructions). Only stop and wait for user input after presenting the "READY FOR TESTING" banner in Phase 4.
#### Phase 0: Startup
Read `.fixbot/llm-status.txt` and `mise.local.toml` using the `Read` tool before doing anything else. You need the ports from `mise.local.toml`, and the `Write` tool requires a prior `Read` on any file before you can write to it.
#### Phase 1: Understand
1. Fetch the issue details by running `./bin/mage -fixbot-fetch-issue {{ISSUE_ID}}`. Read the title, description, and all comments carefully.
2. **CRITICAL: Before writing any code**, read `CLAUDE.md` in the project root — it contains essential test commands, skill references, and tool preferences. CLAUDE.md may not be auto-loaded in your session, so you MUST explicitly read it. Follow the skill references to find detailed guides (e.g., `.claude/skills/_shared/typescript-commands.md` for frontend test commands).
3. Search the codebase thoroughly — read enough files to understand the architecture around the bug before changing anything
4. Before writing code, think through: what is the root cause, which files need to change, what tests will verify the fix, and what could go wrong
5. If the issue involves UI behavior, use the Playwright MCP tools (`mcp__playwright__browser_navigate`, `browser_snapshot`, etc.) to reproduce it in the browser once the backend is ready — seeing what the user sees often reveals more than reading code alone
6. Only ask the user if the expected *product behavior* is genuinely ambiguous — they know Metabase well but don't want to hear about implementation details
7. Make all technical/implementation decisions yourself — do not ask the user about code
8. **Do not wait for servers to start.** The backend takes several minutes to boot. Start coding and writing tests immediately. Only wait when you actually need the servers to be available to run tests or investigate runtime functionality.
9. **Repro-bot guidance:** If the issue comments include a repro-bot investigation with a failing test (look for a patch or test code in the Linear comments), fetch it and apply it as a patch to use as your starting point for the "red" step in TDD. The repro-bot's analysis and root cause hypotheses can be helpful guidance, but don't assume they're always correct — verify against the actual code yourself.
#### Phase 2: Fix
1. ALWAYS use red/green TDD:
- Backend: Write a failing Clojure test first (`./bin/test-agent`), then implement until it passes
- Frontend: Write a failing test first (Jest unit test or Cypress E2E), then implement until it passes
- Never skip the "red" step — confirm the test fails before writing the fix
- **If you need to test an unexported function**, export it first, then write the test importing it. Do not copy the function into the test file — that tests a copy, not the real code.
2. Run all relevant tests:
- **Backend:** `./bin/test-agent`
- **Frontend unit tests:**
```
┌─────────────────────────────────────────────────────────────┐
│ bun run test-unit-keep-cljs path/to/file.unit.spec.ts │
│ bun run test-unit-keep-cljs -t "pattern" │
│ │
│ Do NOT use bare `npx jest` — wrong config │
│ Do NOT use `bun run test-unit` — build step fails sandbox │
└─────────────────────────────────────────────────────────────┘
```
- If you need to run a specific test file, always use the full command above — do not guess at jest config paths or flags
3. Report progress at each milestone with a clear status update
#### Phase 3: Self-Review
Before asking the user to test, review your own changes thoroughly:
1. Use `/clojure-review` on any changed Clojure files and `/typescript-review` on any changed TypeScript/JavaScript files
2. Address all findings — fix issues, not just acknowledge them
3. Re-run tests after making review-driven changes
4. **If the review led to significant changes, re-review those changes.** Repeat until the review is clean.
5. Only proceed to Phase 4 when the review is clean and all tests pass
6. **Do not stop here** — immediately continue to Phase 4
#### Phase 4: Verify
0. **Self-verify first (for UI-related fixes):** If the fix touches frontend code or UI behavior, use the Playwright MCP tools to navigate to the affected page and confirm the fix works in the browser before involving the user. Check that the UI renders correctly, interactions behave as expected, and there are no console errors. If self-verification fails, go back to Phase 2. For purely backend fixes, skip this step — automated tests are sufficient.
- If the Playwright MCP tools are not available or fail, skip browser verification and proceed to user testing — do not spend time debugging.
1. Tell the user EXACTLY what to test and how:
- Which URL to visit — **always use `http://localhost:$MB_JETTY_PORT/...`** (the backend port), never the frontend dev server port
- What steps to reproduce
- What the expected behavior should be now
- Remind them of login credentials: admin (`admin@example.com` / `admin123`) and regular user (`regular@example.com` / `regular123`), and API keys (`mb_AdminApiKey`, `mb_RegularApiKey`)
2. WAIT for the user to test and provide feedback
3. If they report issues, iterate (go back to Phase 2, then re-review in Phase 3 before asking the user again)
#### Phase 5: Open PR
When the user says they're happy (e.g., "looks good", "ship it", "done", "open the pr", "commit it"):
1. Stage and commit all fix-related changes:
- **NEVER commit changes under `.claude/`** — the worktree setup copies fixbot commands there, and those must not be committed
- **NEVER commit changes under `.fixbot/` or `.beads/` or `mage/`** — these are copied or generated files
- Stage files individually by name (`git add path/to/file.clj`) — do NOT use `git add .` or `git add -A`
- Only stage files that are part of the actual fix
- Do not include yourself as a co-author in the commit message
- **REMEMBER that the commit history is public and NO sensitive information should ever be stored in the git messages**
- **REMEMBER that the pull request is public and NO sensitive information should ever be stored in the pull request**
2. Push the branch to origin
3. Create the PR with `gh pr create`:
- Title: concise description of the fix
- **NEVER include Linear URLs or Linear issue IDs in the PR title, body, or commits** — Linear is internal
- Body should follow this template (do NOT include the backport/contributing sections from the repo's PR template):
```
### Description
<Describe the overall approach and the problem being solved>
### How to verify
<Step-by-step instructions to verify the fix>
### Checklist
- [x] Tests have been added/updated to cover changes in this PR
```
- Do NOT add any labels — that's up to the user
4. Tell the user the PR URL and a summary of what was fixed
#### Phase 6: Monitor PR
After submitting the pull request, monitor the pull request until it passes. NOTE: this may take a while and several attempts.
1. Run `/fixbot-ci` to monitor CI results and handle failures
2. **Ignore** the "Decide whether to backport or not" check failure — that's a label requirement handled by the user/reviewer, not something you can fix
3. **Small PRs:** For frontend-only changes (no backend files touched), the relevant CI checks are `frontend-tests/*` and `e2e-tests/*`. If those pass and only unrelated checks (SDK, driver tests, etc.) fail, re-run the failed jobs immediately rather than waiting for the full suite to complete.
### Status Bar
A status bar at the bottom of the tmux session shows issue info, service health, and your status message. The issue info and health indicators are managed automatically. You control only the status message by writing to `.fixbot/llm-status.txt`.
Write to `.fixbot/llm-status.txt` (overwrite the whole file each time) when something important changes — for example:
- Current phase of work (e.g., "Phase 1: Analyzing issue", "Phase 2: Writing tests", "Phase 3: Self-review", "Phase 4: Ready for user testing")
- A blocking question waiting on user input
- URLs the user needs (e.g., the page to test)
**Rules:**
- Keep it to **1-3 lines** — the pane is small and other info is displayed above your status
- Only update when the visible state meaningfully changes — don't spam updates
- Keep each line short and scannable
### Task Tracking with Beads
`bd` (beads) is installed and initialized in stealth mode for structured task tracking. Use it to break down complex work, track dependencies, and maintain context across phases.
**Key commands:**
- `bd create "Task title" -p 0` — create a task (lower priority number = higher priority)
- `bd ready` — list tasks that have no blockers and are ready to work on
- `bd update <id> --claim` — assign a task to yourself and mark it in-progress
- `bd update <id> --close` — mark a task as done
- `bd show <id>` — view task details and history
- `bd list` — list all tasks
**When to use:**
- When breaking a fix into multiple subtasks
- To track what's been done vs what remains
- To note blockers or dependencies between tasks
**Rules:**
- Beads is in stealth mode — it will not modify git state
- Don't overthink it — simple issues may not need task tracking at all
### nREPL
The backend runs an nREPL server. Connect using `localhost` and the nREPL port (read `NREPL_PORT` from `mise.local.toml`):
```bash
clj-nrepl-eval -H localhost -p $NREPL_PORT "(+ 1 2)"
```
Use nREPL for:
- Evaluating Clojure expressions against the running backend
- Requiring namespaces with `:reload` to pick up code changes
- Testing functions interactively
- Checking compilation
**REPL expression rules**: Send one expression per eval call. Multi-expression evals frequently timeout. Split into separate (parallel if independent) calls. Test/dev namespaces (`metabase.test`, `dev`, enterprise test namespaces) are available natively on the classpath.
{{FILE:dev/bot/common/playwright-guide.md}}
**This is optional for fixbot.** For purely backend fixes where the issue and verification are API-level or logic-level, you don't need the browser at all — automated tests and nREPL are sufficient. Use the Playwright MCP tools when the issue involves UI behavior, when you need to see what the user sees, or when the user reports something that doesn't match what you'd expect from the code.
**When to use:**
- **Phase 1 (Understand):** If the issue involves UI behavior, reproduce it in the browser to see exactly what the user sees
- **Phase 4 (Verify):** Before asking the user to test, self-verify the fix by navigating to the affected page and confirming it works. Still ask the user for final sign-off — your browser check supplements but does not replace user acceptance testing.
- **Troubleshooting:** When API responses look correct but the user reports UI problems, use the browser to see what's actually rendering
Alternatively, for quick login you can get a session token via API and use `browser_evaluate` to set a cookie:
```bash
curl -s -X POST http://localhost:$MB_JETTY_PORT/api/session \
-H 'Content-Type: application/json' \
-d '{"username":"admin@example.com","password":"admin123"}' | jq -r '.id'
```
Then use `browser_evaluate` with script: `document.cookie = 'metabase.SESSION=<token>;path=/'`
### Server Logs
Read server output from log files in `.fixbot/`:
- **Backend**: `ls -t .fixbot/backend-*.log | head -1` to find the latest, then `tail -200 <file>`
- **Frontend**: `ls -t .fixbot/frontend-*.log | head -1` to find the latest, then `tail -200 <file>`
Check these when:
- Debugging startup failures (check if backend/frontend started successfully)
- Looking for error messages or stack traces
- Verifying that your code changes were picked up by hot reload
### GitHub API Workaround
If `gh` commands fail with `tls: failed to verify certificate: x509` errors, switch to using `curl` with `gh auth token` for the rest of the session. For example, to create a PR:
```bash
curl -s -X POST https://api.github.com/repos/metabase/metabase/pulls \
-H "Authorization: token $(gh auth token)" \
-H "Accept: application/vnd.github+json" \
-d '{ "title": "...", "head": "branch-name", "base": "master", "body": "..." }'
```
Use the same pattern for any other `gh` operations (e.g., `gh pr view` → `curl -s https://api.github.com/repos/metabase/metabase/pulls/NUMBER ...`).
### Important Rules
- **Python in bash:** Never write Python code inline in bash heredocs — `!` and other characters get mangled by shell expansion. Instead, write Python scripts to a temp file (`$TMPDIR/script.py`) and run them with `python3 $TMPDIR/script.py`.
- Focus ONLY on the reported issue — no unrelated changes
- Always run tests before telling the user to verify
- Check backend readiness: `curl -s http://localhost:$MB_JETTY_PORT/api/health`
- Be patient — the backend takes several minutes to start on first launch. The status bar URL shows `error://` when the backend is down and `http://` when it's ready — no need to poll manually.
- Work autonomously — do not block on the user for technical questions. Research the codebase, read tests, and make your own decisions.
- Only involve the user for product/behavior questions and acceptance testing

View File

@@ -0,0 +1,30 @@
{
"sandbox": {
"enabled": true,
"autoAllowBashIfSandboxed": true,
"allowUnsandboxedCommands": true,
"filesystem": {
"allowWrite": [".", "{{pwd}}", "~/.m2", "~/.gitlibs", "~/.deps.clj", "~/.local/state/workmux", "~/.cache/workmux", "{{tmpdir}}", "/tmp", "/private/tmp", "~/Library/Caches/ms-playwright", "~/Library/Application Support/Google/Chrome"],
"allowRead": [".", "{{pwd}}", "~/.m2", "~/.gitlibs", "~/.deps.clj", "~/.local/state/workmux", "~/.cache/workmux", "{{tmpdir}}", "/tmp", "/private/tmp", "~/Library/Caches/ms-playwright", "~/Library/Application Support/Google/Chrome"]
},
"excludedCommands": ["docker"],
"network": {
"allowLocalBinding": true,
"allowAllUnixSockets": true,
"allowedDomains": ["github.com", "api.github.com", "*.blob.core.windows.net", "api.linear.app"]
}
},
"permissions": {
"allow": [
"Edit",
"Write",
"Skill",
"Grep",
"Bash(*)",
"mcp__playwright__*",
"WebFetch(domain:linear.app)",
"WebFetch(domain:metabase.com)"
]
},
"skipDangerousModePermissionPrompt": true
}

View File

@@ -0,0 +1,51 @@
Generate a UX testing report based on the tasks you've worked on in this session.
## Instructions
1. **Gather environment info for the report header**: Run these commands and include the results at the top of the report:
- `git branch --show-current` — the worktree branch name
- `git rev-parse --short HEAD` — the current commit hash
- `grep MB_DB_TYPE mise.local.toml` — the database type (postgres, mysql, etc.)
- Note the current time as the report end time. The start time is when the first task in the reporting window was given.
Format the header as:
```
**Date:** YYYY-MM-DD
**Branch:** <branch> (commit <hash>)
**Database:** <type>
**Session:** <start time> — <end time>
```
2. **Identify the reporting window**: Check for the most recent reset marker in `.uxbot/` (files named `reset-*.txt`). Only include activity that happened AFTER the last reset. If no reset file exists, include everything.
3. **For each task the user gave you** (do NOT include casual questions, non-task interactions, or general setup work like waiting for the backend to start — unless setup issues actually blocked you from completing a task):
- **Task**: What were you asked to do?
- **Approach**: What did you try first? What was your thinking?
- **Steps taken**: Walk through what you did, step by step
- **Struggles**: Where did you get confused, try multiple things, or feel unsure? This is the most important part — be honest and detailed about friction.
- **Resolution**: Did you complete the task? If not, what blocked you?
- **Screenshots**: Link to any screenshots taken during this task (from `.uxbot/screenshots/`)
- **Time spent**: Estimate how long this task took
4. **Summary section** at the end:
- Tasks completed vs. not completed
- Top friction points (ranked by severity)
- Features that were easy/intuitive to use
- Suggestions for improvement (from the user's perspective, not a developer's)
5. **Write the report** to `.uxbot/report-<timestamp>-<slug>.md` where:
- `<timestamp>` is `YYYYMMDD-HHMMSS`
- `<slug>` is a short kebab-case description of the tasks covered (e.g., `create-question-and-dashboard`, `filter-setup`, `admin-permissions`). Keep it under 40 characters.
- Example: `.uxbot/report-20260327-153000-create-question-and-dashboard.md`
- Use relative paths for screenshot references (e.g., `screenshots/filename.png` relative to `.uxbot/`).
6. **Generate PDF**: Run pandoc **from the `.uxbot/` directory** so relative screenshot paths resolve correctly:
```
cd .uxbot && pandoc report-<timestamp>-<slug>.md -o report-<timestamp>-<slug>.pdf --pdf-engine=weasyprint
```
7. **Present a brief summary** to the user with **full absolute paths** to both the markdown and PDF reports (use `pwd` to get the worktree root, e.g., `/Users/.../metabase-4__worktrees/uxbot-master/.uxbot/report-...-slug.pdf`).
## Tone
Write from the perspective of a user experience researcher observing a user. Be objective and specific. "I couldn't find the button" is better than "the UI is bad." Include what you expected to see vs. what you actually saw.

View File

@@ -0,0 +1,15 @@
Reset the UX testing session to a clean state.
## Instructions
1. **Close the browser**: Use `mcp__playwright__browser_close` to close any open browser
2. **Clear browser state**: Open a fresh browser and use `mcp__playwright__browser_evaluate` to clear cookies and storage:
```javascript
document.cookie.split(';').forEach(c => document.cookie = c.trim().split('=')[0] + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/');
localStorage.clear();
sessionStorage.clear();
```
3. **Close the browser again** after clearing
4. **Write a reset marker**: Write the current timestamp to `.uxbot/reset-<timestamp>.txt` where timestamp is `YYYYMMDD-HHMMSS`. This marks the boundary — future `/uxbot-report` calls will only include activity after this point.
5. **Update status**: Write "Waiting for task (reset)" to `.uxbot/llm-status.txt`
6. **Tell the user**: "Session reset. Browser cleared, ready for new tasks. Any future reports will only cover activity from this point forward."

View File

@@ -0,0 +1,26 @@
{
"sandbox": {
"enabled": true,
"autoAllowBashIfSandboxed": true,
"filesystem": {
"allowWrite": [".", "{{pwd}}", "~/.m2", "~/.local/state/workmux", "~/.cache/workmux", "{{tmpdir}}", "/tmp", "/private/tmp", "~/Library/Caches/ms-playwright"],
"allowRead": [".", "{{pwd}}", "~/.m2", "~/.local/state/workmux", "~/.cache/workmux", "{{tmpdir}}", "/tmp", "/private/tmp", "~/Library/Caches/ms-playwright"],
"denyRead": ["{{pwd}}/src", "{{pwd}}/test", "{{pwd}}/test_resources", "{{pwd}}/resources", "{{pwd}}/enterprise", "{{pwd}}/frontend/src", "{{pwd}}/frontend/test"]
},
"excludedCommands": ["docker"],
"network": {
"allowLocalBinding": true,
"allowAllUnixSockets": true
}
},
"permissions": {
"allow": [
"Read",
"Write",
"Bash(*)",
"mcp__playwright__*",
"WebFetch(domain:metabase.com)"
]
},
"skipDangerousModePermissionPrompt": true
}

View File

@@ -0,0 +1,124 @@
# UXBot Agent — {{BRANCH_NAME}}
{{INITIAL_TASK}}
## CRITICAL: You Are a Regular User
**You are a regular Metabase user.** You do NOT have access to the source code, server logs, developer tools, or any internal systems. You interact with Metabase exclusively through the web browser using the Playwright MCP tools.
Your job is to **try to accomplish tasks** the caller gives you, narrating your thought process as you go. The goal is to reveal what's easy and hard for a real user to figure out.
## CRITICAL: 20-Minute Time Limit Per Task
If you cannot complete a single task within 20 minutes, STOP. Explain what you've tried, where you're stuck, and what's confusing. This is valuable data — a stuck user is exactly what we want to detect.
## What You Know About Metabase
You have the general knowledge that someone would have after reading the "Getting Started" guide:
### Core Concepts
- **Questions**: Queries against your data — either visual (point-and-click) or native SQL. Questions produce tables, charts, and other visualizations.
- **Dashboards**: Collections of questions arranged as cards. Dashboards support filters that control multiple cards, text cards for context, and click behavior for drill-down.
- **Collections**: Folders for organizing questions, dashboards, and models. Collections have permissions controlling who can see what.
- **Models**: Curated datasets — like saved questions but treated as first-class data sources. Models can have custom metadata (descriptions, value formatting).
- **Metrics**: Defined measures (like "Total Revenue" or "Active Users") that can be reused across questions. Defined centrally so everyone uses the same calculation.
### Building Questions
- **Question Builder (Visual)**: Pick a table → add filters → choose columns → summarize (count, sum, average, etc.) → group by → sort → visualize. No SQL needed.
- **Native/SQL Queries**: Write SQL directly. Supports template variables (filters the user can change) and field filters for smart filter widgets.
- **Notebook Editor**: The visual query builder's interface — looks like a notebook with steps: pick data, filter, summarize, sort, limit.
### Navigation
- **Home page**: Shows recent items, saved questions, and collections
- **New button** (+ icon, top right): Create new question, dashboard, model, collection, etc.
- **Search** (magnifying glass, top bar): Search for questions, dashboards, collections by name
- **Collections sidebar**: Browse the organizational hierarchy
- **Admin panel** (gear icon → Admin): Database connections, user management, permissions, appearance settings
### Key UI Patterns
- **Saving**: When you create a question, you choose where to save it (which collection or dashboard)
- **Filters**: On dashboards, filters appear at the top. On questions, use the filter icon or the notebook editor.
- **Drill-down**: Click on chart elements to see detail, zoom in, or pivot
- **Sharing**: Questions and dashboards can be shared via links, embedded, or subscribed to (email/Slack alerts)
## How You Work
### Receiving Tasks
The caller will give you tasks like:
- "Create a question showing total orders by month"
- "Set up a dashboard with sales metrics"
- "Find where to change the database connection settings"
- "Share a dashboard with a specific user"
### Executing Tasks
1. **Think out loud**: Before each action, explain what you're trying to do and why
2. **Use the browser**: Navigate, click, fill forms, read what's on screen
3. **Take screenshots**: Use `mcp__playwright__browser_take_screenshot` at key moments:
- When you arrive at an important page
- Before and after significant actions
- When something unexpected happens
- When you're confused about what to do next
Save screenshots with descriptive names to `.uxbot/screenshots/`
4. **Narrate struggles**: If you're unsure what to click, say so. If you try something and it doesn't work, explain what you expected vs what happened.
5. **Report completion**: When you finish a task (or give up), summarize what happened
### When You're Stuck
- **First**: Try exploring the UI — look at menus, buttons, tooltips
- **Then**: Try the search function to find what you need
- **If still stuck**: You may consult the Metabase docs at `https://www.metabase.com/docs/latest/` using `WebFetch`. This simulates a real user turning to documentation.
- **After 20 minutes**: STOP and report what's blocking you
## Environment
Ports are dynamically assigned per worktree. **You MUST read `mise.local.toml` at startup** to discover your ports:
- `MB_JETTY_PORT` — the Metabase URL is `http://localhost:$MB_JETTY_PORT`
Always use `http://localhost:$MB_JETTY_PORT` for navigation.
**IMPORTANT: Wait for the backend before starting.** The backend takes several minutes to boot. Before attempting any task, poll `curl -s http://localhost:$MB_JETTY_PORT/api/health` until it returns `{"status":"ok"}`. Check every 30 seconds. Do not try to use the browser until the backend is healthy.
{{FILE:dev/bot/common/instance-setup.md}}
Unless the task specifies otherwise, log in as the **regular user** (this simulates a typical non-admin experience).
{{FILE:dev/bot/common/playwright-guide.md}}
**UXBot-specific screenshot rules:**
- Take screenshots frequently — they're the primary evidence in reports
- Take a screenshot BEFORE attempting a tricky interaction (so we can see the UI state)
- Take a screenshot AFTER a failure (so we can see what went wrong)
- Save screenshots with descriptive names like `screenshots/03-dropdown-wont-open.png`
## CRITICAL: No Cheating
You are simulating a real user. A real user cannot:
- Call APIs directly — **NEVER** use `browser_evaluate` to call `fetch()`, `XMLHttpRequest`, or any Metabase API
- Manipulate the DOM — **NEVER** use `browser_evaluate` to remove elements, force-click via JavaScript, dispatch synthetic events, or change element properties
- Type URLs — **NEVER** navigate to a URL you haven't discovered by clicking through the UI. No constructing URL hashes, no typing paths into the address bar, no URL manipulation of any kind
- The ONLY URLs you may navigate to directly are `http://localhost:$MB_JETTY_PORT/` (home) and `http://localhost:$MB_JETTY_PORT/auth/login` (login)
- The ONLY acceptable use of `browser_evaluate` is reading `window.location.href` to check where you are
- Every other page must be reached by clicking links, buttons, menu items, and breadcrumbs in the UI
If you cannot accomplish something through the UI, **that is the finding**. Report what you tried, what didn't work, and what you expected to happen. Do not work around UI problems — document them.
## What You Do NOT Have Access To
- Source code (no Read/Edit/Grep/Glob on code files)
- Server logs
- nREPL or any developer console
- Linear, GitHub, or any issue tracker
- Direct API access (no curl, no fetch, no browser console API calls)
- The ability to modify code or configuration files
- The ability to construct or guess URLs
You are purely a browser user clicking through the UI. If something requires developer access or URL knowledge to accomplish, say so — that's useful feedback.
## Status Tracking
Write to `.uxbot/llm-status.txt` when your status changes meaningfully:
- "Waiting for task"
- "Working on: <task description>"
- "Stuck: <what's blocking>"
- "Task complete"
Read `.uxbot/llm-status.txt` with the `Read` tool before writing to it (the Write tool requires a prior Read).

View File

@@ -0,0 +1,290 @@
(ns mage.bot.dev-env
(:require
[clojure.string :as str]
[mage.color :as c]
[mage.shell :as shell]
[mage.util :as u]
[table.core :as t]))
(set! *warn-on-reflection* true)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Port bases (same as edpaget's dev-env)
(def ^:private port-bases
{:jetty 3000
:frontend-dev 8080
:nrepl 50605
:socket-repl 50505
:postgres-app 15432
:mysql 13309
:mariadb 13306})
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Service specs
(def ^:private service-specs
{:postgres {:image "postgres:17"
:internal-ports [[:postgres-app 5432]]
:env {"POSTGRES_USER" "metabase"
"POSTGRES_DB" "metabase"
"POSTGRES_PASSWORD" "password"
"PGDATA" "/var/lib/postgresql/data"}}
:mysql {:image "mysql:8.4"
:internal-ports [[:mysql 3306]]
:env {"MYSQL_DATABASE" "metabase_test"
"MYSQL_ALLOW_EMPTY_PASSWORD" "yes"}}
:mariadb {:image "mariadb:11"
:internal-ports [[:mariadb 3306]]
:env {"MYSQL_DATABASE" "metabase_test"
"MYSQL_ALLOW_EMPTY_PASSWORD" "yes"}}})
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Helpers
(defn- worktree-name
"Last path component of the project root directory."
[]
(last (str/split u/project-root-directory #"/")))
(defn- compute-slot
"Deterministic slot 0-99 from worktree name, or override."
[slot-override]
(if slot-override
slot-override
(mod (Math/abs (.hashCode ^String (worktree-name))) 100)))
(defn- container-prefix []
(str "mb-" (worktree-name) "-"))
(defn- port-for [port-key slot]
(+ (get port-bases port-key) slot))
(defn- kill-container! [container-name]
(shell/sh* {:quiet? true} "docker" "kill" container-name)
(shell/sh* {:quiet? true} "docker" "rm" container-name))
(defn- check-docker! []
(when-not (u/can-run? "docker")
(println (c/red "Docker is not installed. Please install Docker to use dev-env."))
(u/exit 1))
(let [{:keys [exit]} (shell/sh* {:quiet? true} "docker" "info")]
(when-not (zero? exit)
(println (c/red "Docker daemon is not running. Please start Docker."))
(u/exit 1))))
(defn- build-docker-cmd [container-name image port-mappings env-map]
(into ["docker" "run" "-d"]
(concat
(mapcat (fn [[host-port internal-port]]
["-p" (str host-port ":" internal-port)])
port-mappings)
(mapcat (fn [[k v]]
["-e" (str k "=" v)])
env-map)
["--name" container-name
image])))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Core
(defn- start-service! [service-key slot]
(let [spec (get service-specs service-key)
container-name (str (container-prefix) (name service-key) "-app")
port-mappings (mapv (fn [[pk ip]] [(port-for pk slot) ip])
(:internal-ports spec))
cmd (build-docker-cmd container-name (:image spec) port-mappings (:env spec))]
(println (c/yellow "Starting " container-name "..."))
(kill-container! container-name)
(u/debug "Running: " (str/join " " cmd))
(apply shell/sh cmd)
(println (c/green " Started ") container-name)))
(defn- validate-app-db! [app-db]
(let [app-db-kw (keyword app-db)
valid-dbs #{:h2 :postgres :mysql :mariadb}]
(when-not (valid-dbs app-db-kw)
(println (c/red "Invalid --app-db: " app-db ". Must be one of: " (str/join ", " (map name valid-dbs))))
(u/exit 1))
app-db-kw))
(def ^:private config-file-content
"version: 1
config:
users:
- first_name: Admin
last_name: User
password: admin123
email: admin@example.com
is_superuser: true
- first_name: Regular
last_name: User
password: regular123
email: regular@example.com
is_superuser: false
api-keys:
- name: \"Admin API key\"
group: admin
description: \"API key with admin permissions.\"
creator: admin@example.com
key: mb_AdminApiKey
- name: \"Regular API key\"
group: all-users
description: \"API key with regular permissions.\"
creator: admin@example.com
key: mb_RegularApiKey
")
(defn- write-config-file!
"Write the Metabase config file for auto-setup of users and API keys."
[]
(let [path (str u/project-root-directory "/metabase.config.yml")]
(spit path config-file-content)
(println (c/green "Wrote " path))))
(defn- generate-mise-local! [slot app-db-kw]
(let [wt-name (worktree-name)
config-path (str u/project-root-directory "/metabase.config.yml")
;; Services run inside a container — use fixed base ports.
;; Only the DB port needs slot-based allocation (it runs on the host).
lines (cond-> [(str "# Auto-generated by ./bin/mage -bot-dev-env")
(str "# Worktree: " wt-name " (slot " slot ")")
(str "# Re-run `./bin/mage -bot-dev-env` to regenerate.")
""
"[env]"
(str "MB_CONFIG_FILE_PATH = \"" config-path "\"")
"MB_EDITION = \"ee\""
"DISABLE_BUILD_NOTIFICATIONS = \"1\""
(str "MB_PREMIUM_EMBEDDING_TOKEN = \"" (u/env "MB_PREMIUM_EMBEDDING_TOKEN" (constantly "")) "\"")
(str "LINEAR_API_KEY = \"" (u/env "LINEAR_API_KEY" (constantly "")) "\"")
(str "MB_JETTY_PORT = \"" (port-for :jetty slot) "\"")
(str "MB_FRONTEND_DEV_PORT = \"" (port-for :frontend-dev slot) "\"")
(str "NREPL_PORT = \"" (port-for :nrepl slot) "\"")
(str "SOCKET_REPL_PORT = \"" (port-for :socket-repl slot) "\"")
(str "PLAYWRIGHT_DAEMON_SESSION_DIR = \"" u/project-root-directory "/.fixbot/playwright/sessions\"")
(str "PLAYWRIGHT_DAEMON_SOCKETS_DIR = \"" u/project-root-directory "/.fixbot/playwright/sockets\"")]
(= app-db-kw :postgres)
(conj (str "MB_DB_TYPE = \"postgres\"")
(str "MB_DB_CONNECTION_URI = \"jdbc:postgresql://localhost:"
(port-for :postgres-app slot)
"/metabase?user=metabase&password=password\""))
(= app-db-kw :mysql)
(conj (str "MB_DB_TYPE = \"mysql\"")
(str "MB_DB_CONNECTION_URI = \"jdbc:mysql://localhost:"
(port-for :mysql slot)
"/metabase?user=root&password=\""))
(= app-db-kw :mariadb)
(conj (str "MB_DB_TYPE = \"mysql\"")
(str "MB_DB_CONNECTION_URI = \"jdbc:mysql://localhost:"
(port-for :mariadb slot)
"/metabase?user=root&password=\"")))
content (str (str/join "\n" lines) "\n")
path (str u/project-root-directory "/mise.local.toml")]
(spit path content)
(println (c/green "Wrote " path))))
(defn- write-status-file!
"Write initial LLM status to the fixbot status pane.
The status watch renders issue info and health separately;
this file only contains the agent's status message."
[_slot _app-db-kw]
(let [dir (str u/project-root-directory "/.fixbot")
_ (.mkdirs (java.io.File. dir))
path (str dir "/llm-status.txt")]
(spit path "Booting up...")
(println (c/green "Wrote " path))))
(defn- print-summary! [slot app-db-kw]
(let [wt-name (worktree-name)
rows (cond-> [{:service "Jetty backend" :port (port-for :jetty slot) :env-var "MB_JETTY_PORT"}
{:service "Frontend dev" :port (port-for :frontend-dev slot) :env-var "MB_FRONTEND_DEV_PORT"}
{:service "nREPL" :port (port-for :nrepl slot) :env-var "NREPL_PORT"}
{:service "Socket REPL" :port (port-for :socket-repl slot) :env-var "SOCKET_REPL_PORT"}]
(= app-db-kw :postgres)
(conj {:service "Postgres (app-db)" :port (port-for :postgres-app slot) :env-var "MB_DB_CONNECTION_URI"})
(= app-db-kw :mysql)
(conj {:service "MySQL (app-db)" :port (port-for :mysql slot) :env-var "MB_DB_CONNECTION_URI"})
(= app-db-kw :mariadb)
(conj {:service "MariaDB (app-db)" :port (port-for :mariadb slot) :env-var "MB_DB_CONNECTION_URI"}))]
(println)
(println (c/bold (c/green "Fixbot dev environment for ") (c/cyan wt-name) (c/green " (slot " slot ")")))
(println)
(t/table rows :style :unicode)
(println)
(println (c/cyan "Tear down with: ") (c/yellow "./bin/mage -bot-dev-env --down"))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Entry points
(defn- stand-up! [opts]
(let [slot (compute-slot (:slot opts))
app-db-kw (validate-app-db! (:app-db opts))]
(check-docker!)
(when (not= app-db-kw :h2)
(start-service! app-db-kw slot))
(write-config-file!)
(generate-mise-local! slot app-db-kw)
(write-status-file! slot app-db-kw)
(print-summary! slot app-db-kw)))
(defn- tear-down! []
(check-docker!)
(let [prefix (container-prefix)
{:keys [out]} (shell/sh* {:quiet? true}
"docker" "ps" "-a"
"--filter" (str "name=^" prefix)
"--format" "{{.Names}}")
containers (when (seq out)
(->> out (remove str/blank?) vec))]
(if (seq containers)
(do
(println (c/yellow "Stopping containers:"))
(doseq [cname containers]
(println (c/red " Killing " cname))
(kill-container! cname))
(println (c/green "All containers stopped.")))
(println (c/yellow "No containers found for prefix: " prefix)))
(let [toml-path (str u/project-root-directory "/mise.local.toml")]
(when (.exists (java.io.File. toml-path))
(.delete (java.io.File. toml-path))
(println (c/green "Removed " toml-path))))
(let [config-path (str u/project-root-directory "/metabase.config.yml")]
(when (.exists (java.io.File. config-path))
(.delete (java.io.File. config-path))
(println (c/green "Removed " config-path))))
(let [fixbot-dir (str u/project-root-directory "/.fixbot")]
(when (.isDirectory (java.io.File. fixbot-dir))
(doseq [f (.listFiles (java.io.File. fixbot-dir))]
(.delete ^java.io.File f))
(.delete (java.io.File. fixbot-dir))
(println (c/green "Removed " fixbot-dir))))))
(defn- print-status! []
(check-docker!)
(let [prefix (container-prefix)
{:keys [out]} (shell/sh* {:quiet? true}
"docker" "ps" "-a"
"--filter" (str "name=^" prefix)
"--format" "{{.Names}}\t{{.Status}}\t{{.Ports}}")
lines (when (seq out)
(->> out (remove str/blank?) vec))]
(if (seq lines)
(let [rows (mapv (fn [line]
(let [[cname status ports] (str/split line #"\t" 3)]
{:container cname
:status (or status "")
:ports (or ports "")}))
lines)]
(println (c/bold (c/green "Containers for " (worktree-name) ":")))
(println)
(t/table rows :style :unicode))
(println (c/yellow "No containers found for " (worktree-name))))))
(defn dev-env!
"Top-level dispatcher for fixbot dev-env command."
[{:keys [options]}]
(let [{:keys [down status]} options]
(cond
down (tear-down!)
status (print-status!)
:else (stand-up! (update options :app-db #(or % "postgres"))))))

View File

@@ -0,0 +1,178 @@
(ns mage.bot.launch
"Shared workmux session launch logic for fixbot, uxbot, and other bot types."
(:require
[clojure.string :as str]
[mage.color :as c]
[mage.shell :as shell]
[mage.util :as u]))
(set! *warn-on-reflection* true)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Fresh launch (workmux add — creates new worktree)
(defn launch-workmux-session!
"Launch a new workmux session (creates worktree from branch).
Options:
:session-name — tmux/workmux session name (e.g., \"fixbot-uxw-3330\")
:branch-name — git branch to create/use
:prompt-file — path to the agent prompt file (relative to main repo)
:workmux-config — rendered .workmux.yaml content (string)
:base-branch — base branch to create worktree from (default: \"origin/master\")
:display-info — map of {\"label\" value} pairs to print"
[{:keys [session-name branch-name prompt-file workmux-config base-branch display-info]}]
(let [workmux-path (str u/project-root-directory "/.workmux.yaml")
backup-path (str workmux-path ".bak")
had-backup? (.exists (java.io.File. workmux-path))
in-tmux? (not (str/blank? (u/env "TMUX" (constantly nil))))]
;; Write Claude Code hooks to main repo so workmux doesn't prompt
(let [hooks-dir (str u/project-root-directory "/.github/hooks/workmux-status")]
(.mkdirs (java.io.File. ^String hooks-dir))
(spit (str hooks-dir "/hooks.json")
"{\"version\":1,\"hooks\":{\"userPromptSubmitted\":[{\"type\":\"command\",\"bash\":\"workmux set-window-status working\"}],\"postToolUse\":[{\"type\":\"command\",\"bash\":\"workmux set-window-status working\"}],\"agentStop\":[{\"type\":\"command\",\"bash\":\"workmux set-window-status done\"}]}}"))
;; Back up existing .workmux.yaml if it exists
(when had-backup?
(println (c/yellow "Backing up existing .workmux.yaml..."))
(.renameTo (java.io.File. workmux-path) (java.io.File. backup-path)))
;; Write our workmux config
(println (c/yellow "Writing .workmux.yaml..."))
(spit workmux-path workmux-config)
(try
;; Fetch latest refs
(println (c/yellow "Fetching latest from remote..."))
(shell/sh "git" "fetch")
;; Launch workmux
(println)
(println (c/bold (c/green "Launching workmux session: ") (c/cyan session-name)))
(println (c/yellow "Branch: ") branch-name)
(doseq [[label value] display-info]
(println (c/yellow (str label ": ")) value))
(println (c/yellow "Prompt: ") prompt-file)
(println)
(let [effective-base (or base-branch "origin/master")
workmux-cmd (str "workmux add " branch-name
" --name " session-name
" -P " prompt-file
" --base " effective-base)]
(if in-tmux?
;; Already inside tmux — run workmux directly
(shell/sh "workmux" "add" branch-name
"--name" session-name
"-P" prompt-file
"--base" effective-base)
;; Not inside tmux — create a detached session
(do
(println (c/yellow "Not inside tmux. Creating detached tmux session..."))
(shell/sh "nohup" "bash" "-c"
(str "tmux new-session -d -s " session-name
" && tmux send-keys -t " session-name
" '" workmux-cmd "' Enter"))
(println)
(println (c/bold (c/green "Tmux session created: ") (c/cyan session-name)))
(println)
(println "Attach to it with:")
(println (str " tmux attach -t " session-name)))))
(finally
;; When not in tmux, workmux add was launched async via send-keys;
;; give it time to read .workmux.yaml before we restore/remove it
(when-not in-tmux?
(Thread/sleep 3000))
;; Restore .workmux.yaml
(if had-backup?
(do
(println (c/yellow "Restoring .workmux.yaml from backup..."))
(.renameTo (java.io.File. backup-path) (java.io.File. workmux-path)))
(do
(println (c/yellow "Removing .workmux.yaml..."))
(.delete (java.io.File. workmux-path))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Relaunch (workmux open — reuses existing worktree)
(defn relaunch-existing-session!
"Relaunch a bot session in an existing worktree.
Copies fresh tooling from source repo, regenerates config, starts dev env, and launches tmux.
Options:
:bot-name — \"fixbot\" or \"uxbot\"
:session-name — tmux session name
:wt-path — absolute path to existing worktree
:prompt-file — path to prompt file (relative to main repo)
:app-db — database type (\"postgres\", \"mysql\", etc.)
:workmux-config — rendered .workmux.yaml content (for panes section)
:env-vars — extra env vars for dev-env command (e.g., \"LINEAR_API_KEY=xxx\")"
[{:keys [bot-name session-name wt-path prompt-file app-db workmux-config env-vars]}]
(let [in-tmux? (not (str/blank? (u/env "TMUX" (constantly nil))))]
;; Copy fresh tooling into worktree
(println (c/yellow "Updating tooling in existing worktree..."))
(shell/sh "bash" "-c"
(str "mkdir -p " wt-path "/.claude/" bot-name
" && cp -r " u/project-root-directory "/dev/bot/" bot-name "/* " wt-path "/.claude/" bot-name "/"
" && cp -r " u/project-root-directory "/dev/bot/common/* " wt-path "/.claude/" bot-name "/"
" && cp -r " u/project-root-directory "/mage/src/mage/ " wt-path "/mage/src/mage/"
" && cp " u/project-root-directory "/bb.edn " wt-path "/bb.edn"
" && rm -f " wt-path "/.claude/commands/" bot-name "*.md"
" && cp " wt-path "/.claude/" bot-name "/commands/*.md " wt-path "/.claude/commands/"))
;; Regenerate settings
(let [settings-template (str wt-path "/.claude/" bot-name "/settings.local-template.json")]
(when (.exists (java.io.File. ^String settings-template))
(shell/sh "bash" "-c"
(str "sed 's|{{pwd}}|" wt-path "|;s|{{tmpdir}}|${TMPDIR%/}|' "
settings-template " > " wt-path "/.claude/settings.local.json"))))
;; Regenerate .mcp.json
(spit (str wt-path "/.mcp.json")
"{\"mcpServers\":{\"playwright\":{\"command\":\"npx\",\"args\":[\"-y\",\"@playwright/mcp@0.0.68\",\"--headless\",\"--browser\",\"chrome\",\"--viewport-size\",\"1440x900\",\"--snapshot-mode\",\"full\",\"--block-service-workers\",\"--isolated\",\"--timeout-action\",\"10000\"]}}}")
;; Start dev environment
(println (c/yellow "Starting dev environment..."))
(let [ee-token (u/env "MB_PREMIUM_EMBEDDING_TOKEN" (constantly ""))
env-str (str "MB_PREMIUM_EMBEDDING_TOKEN=" ee-token
(when env-vars (str " " env-vars)))]
(shell/sh {:dir wt-path} "bash" "-c"
(str env-str " ./bin/mage -bot-dev-env --app-db " app-db)))
(shell/sh {:dir wt-path} "mise" "trust" "mise.local.toml")
;; Copy prompt file into worktree as prompt.md
(let [prompt-src (if (.isAbsolute (java.io.File. ^String prompt-file))
prompt-file
(str u/project-root-directory "/" prompt-file))
prompt-dest (str wt-path "/prompt.md")]
(spit prompt-dest (slurp prompt-src))
(println (c/yellow "Copied prompt to " prompt-dest)))
;; Write .workmux.yaml for open (just panes section)
(let [panes-idx (str/index-of workmux-config "\npanes:")
panes-section (when panes-idx (subs workmux-config panes-idx))]
(spit (str wt-path "/.workmux.yaml")
(str "agent: claude\n"
panes-section)))
(println)
(println (c/bold (c/green "Relaunching session: ") (c/cyan session-name)))
(println)
(if in-tmux?
(shell/sh {:dir wt-path} "workmux" "open" session-name "--run-hooks"
"-P" "prompt.md")
(do
(println (c/yellow "Not inside tmux. Creating detached tmux session..."))
(shell/sh* {:quiet? true} "tmux" "kill-session" "-t" session-name)
(shell/sh "nohup" "bash" "-c"
(str "cd " wt-path
" && tmux new-session -d -s " session-name
" && tmux send-keys -t " session-name
" 'workmux open " session-name " --run-hooks"
" -P prompt.md' Enter"))
(println)
(println (c/bold (c/green "Tmux session created: ") (c/cyan session-name)))
(println)
(println "Attach to it with:")
(println (str " tmux attach -t " session-name))))))

View File

@@ -0,0 +1,64 @@
(ns mage.bot.prompt
"Fill template placeholders and write agent prompts.
Supports {{KEY}} for value replacement and {{FILE:path}} for file inclusion."
(:require
[clojure.string :as str]
[mage.color :as c]
[mage.util :as u]))
(set! *warn-on-reflection* true)
(defn- parse-set-args
"Parse --set KEY=VALUE arguments into a map of {\"KEY\" \"VALUE\"}."
[set-args]
(into {}
(map (fn [s]
(let [idx (str/index-of s "=")]
(when (and idx (pos? idx))
[(subs s 0 idx) (subs s (inc idx))])))
set-args)))
(defn- resolve-file-includes
"Replace all {{FILE:path}} placeholders with the contents of the referenced files.
Paths are relative to the project root."
[content]
(str/replace content
#"\{\{FILE:([^}]+)\}\}"
(fn [[_ path]]
(let [full-path (str u/project-root-directory "/" (str/trim path))]
(if (.exists (java.io.File. ^String full-path))
(slurp full-path)
(do
(println (c/yellow "Warning: include file not found: " full-path))
(str "<!-- FILE NOT FOUND: " path " -->")))))))
(defn generate-prompt!
"Fill a template with placeholders and write to output.
Expects --template, --output, and one or more --set KEY=VALUE options.
Also resolves {{FILE:path}} includes."
[{:keys [options]}]
(let [template-path (:template options)
output-path (:output options)
set-args (:set options)]
(when (str/blank? template-path)
(println (c/red "--template is required"))
(u/exit 1))
(when (str/blank? output-path)
(println (c/red "--output is required"))
(u/exit 1))
(when-not (.exists (java.io.File. ^String template-path))
(println (c/red "Template not found: " template-path))
(u/exit 1))
(let [replacements (parse-set-args (or set-args []))
template (slurp template-path)
;; First resolve {{FILE:...}} includes
with-files (resolve-file-includes template)
;; Then replace {{KEY}} placeholders
content (reduce-kv (fn [s k v]
(str/replace s (str "{{" k "}}") v))
with-files
replacements)]
(.mkdirs (.getParentFile (java.io.File. ^String output-path)))
(spit output-path content)
(println (c/green "Wrote prompt: ") output-path)
output-path)))

View File

@@ -0,0 +1,175 @@
(ns mage.bot.session
"Shared workmux session management for fixbot, uxbot, and other bot types."
(:require
[clojure.string :as str]
[mage.color :as c]
[mage.shell :as shell]
[mage.util :as u]))
(set! *warn-on-reflection* true)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Helpers
(defn workmux-list-raw
"Run `workmux list` and return the raw output lines."
[]
(let [{:keys [exit out]} (shell/sh* {:quiet? true} "workmux" "list")]
(if (zero? exit)
(vec (remove str/blank? out))
[])))
(defn parse-worktree-name
"Extract the worktree name from a workmux list output line."
[line]
(let [trimmed (str/trim line)]
(when-let [path (last (re-find #"(\S+)\s*$" trimmed))]
(cond
(= path "(here)") nil
(str/includes? path "__worktrees/")
(second (str/split path #"__worktrees/" 2))
:else (last (str/split path #"/"))))))
(defn find-session
"Find a workmux session matching the given name or ID (case-insensitive substring match).
Returns the worktree name (suitable for workmux commands) or nil."
[name-or-id]
(let [needle (str/lower-case (str/trim name-or-id))
lines (workmux-list-raw)
data (rest lines)]
(->> data
(keep (fn [line]
(when (str/includes? (str/lower-case line) needle)
(parse-worktree-name line))))
first)))
(defn print-available-sessions!
"Print the list of available workmux sessions."
[]
(let [sessions (workmux-list-raw)]
(if (seq sessions)
(do
(println (c/yellow "Available sessions:"))
(doseq [s sessions]
(println (str " " s))))
(println (c/yellow "No active sessions.")))))
(defn all-sessions-with-prefix
"Return a list of all worktree names matching the given prefix."
[prefix]
(let [lines (workmux-list-raw)
data (rest lines)]
(->> data
(keep parse-worktree-name)
(filter #(str/starts-with? % (str prefix "-")))
vec)))
(defn worktree-path
"Get the filesystem path for a workmux session."
[session]
(let [{:keys [exit out]} (shell/sh* {:quiet? true} "workmux" "path" session)]
(when (zero? exit)
(str/trim (str/join "" out)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Generic session operations
(defn tmux-session-running?
"Check if a tmux session with the given name is currently running."
[session-name]
(let [{:keys [exit]} (shell/sh* {:quiet? true} "tmux" "has-session" "-t" session-name)]
(zero? exit)))
(defn stop-session!
"Stop a session's tmux session and dev environment, but keep the worktree.
`bot-prefix` is used for usage messages."
[bot-prefix name-or-id]
(when (str/blank? name-or-id)
(println (c/red (str "Usage: ./bin/mage -" bot-prefix "-stop <name-or-id>")))
(u/exit 1))
(let [session (find-session name-or-id)]
(when-not session
(println (c/red "No session found matching: ") name-or-id)
(print-available-sessions!)
(u/exit 1))
;; Stop dev environment
(let [wt-path (worktree-path session)]
(when (and wt-path (seq wt-path))
(println (c/yellow "Stopping dev environment in " wt-path "..."))
(shell/sh* {:dir wt-path} "./bin/mage" "-bot-dev-env" "--down")))
;; Kill tmux session
(println (c/yellow "Stopping tmux session: " session "..."))
(shell/sh* {:quiet? true} "tmux" "kill-session" "-t" session)
(println)
(println (c/bold (c/green "Session stopped: ") (c/cyan session)))
(println (c/yellow "Worktree preserved. Run /uxbot again to restart."))))
(defn quit-session!
"Tear down and remove a workmux session by name or ID match.
`bot-prefix` is used for usage messages (e.g., \"fixbot\", \"uxbot\")."
[bot-prefix name-or-id]
(when (str/blank? name-or-id)
(println (c/red (str "Usage: ./bin/mage -" bot-prefix "-quit <name-or-id>")))
(u/exit 1))
(let [session (find-session name-or-id)]
(when-not session
(println (c/red "No session found matching: ") name-or-id)
(print-available-sessions!)
(u/exit 1))
(println (c/yellow "Removing worktree: " session "..."))
(shell/sh "workmux" "remove" "-f" session)
(shell/sh* {:quiet? true} "tmux" "kill-session" "-t" session)
(println)
(println (c/bold (c/green "Session removed: ") (c/cyan session)))))
(defn list-sessions!
"List all sessions matching the given prefix with status."
[bot-prefix label]
(println (c/bold (c/green (str label " Sessions"))))
(println)
(let [sessions (all-sessions-with-prefix bot-prefix)
filter-args (into [bot-prefix] sessions)]
(let [{:keys [exit out]} (apply shell/sh* {:quiet? true} "workmux" "list" "--pr" filter-args)]
(when (zero? exit)
(doseq [line out]
(println line))))
(println)
(let [{:keys [exit out]} (apply shell/sh* {:quiet? true} "workmux" "status" "--git" filter-args)]
(when (zero? exit)
(doseq [line out]
(println line))))))
(defn pause-one!
"Pause a single session by worktree name."
[bot-prefix session]
(let [{:keys [exit out]} (shell/sh* {:quiet? true} "workmux" "path" session)
wt-path (when (zero? exit) (str/trim (str/join "" out)))]
(when (and wt-path (seq wt-path))
(println (c/yellow "Stopping dev environment in " wt-path "..."))
(let [{:keys [exit]} (shell/sh* {:dir wt-path} "./bin/mage" "-bot-dev-env" "--down")]
(when-not (zero? exit)
(println (c/yellow "Warning: dev-env teardown returned non-zero exit code"))))))
(println (c/yellow "Closing tmux window for " session "..."))
(shell/sh* {:quiet? true} "workmux" "close" session)
(println)
(println (c/bold (c/green "Session paused: ") (c/cyan session)))
(println (c/yellow "Resume with: ") (str "./bin/mage -" bot-prefix "-resume " session)))
(defn pause-sessions!
"Pause one or all sessions matching the given prefix."
[bot-prefix name-or-id]
(when (str/blank? name-or-id)
(println (c/red (str "Usage: ./bin/mage -" bot-prefix "-pause <name-or-id|all>")))
(u/exit 1))
(if (= "all" (str/lower-case (str/trim name-or-id)))
(let [sessions (all-sessions-with-prefix bot-prefix)]
(if (empty? sessions)
(println (c/yellow (str "No active " bot-prefix " sessions to pause.")))
(doseq [session sessions]
(pause-one! bot-prefix session))))
(let [session (find-session name-or-id)]
(when-not session
(println (c/red "No session found matching: ") name-or-id)
(print-available-sessions!)
(u/exit 1))
(pause-one! bot-prefix session))))

View File

@@ -0,0 +1,64 @@
(ns mage.fixbot.go
(:require
[clojure.string :as str]
[mage.bot.launch :as launch]
[mage.color :as c]
[mage.util :as u]))
(set! *warn-on-reflection* true)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Workmux config
(defn- generate-workmux-config
"Generate the .workmux.yaml content from the common template."
[app-db]
(let [ee-token (u/env "MB_PREMIUM_EMBEDDING_TOKEN" (constantly ""))
linear-key (u/env "LINEAR_API_KEY" (constantly ""))]
(-> (slurp (str u/project-root-directory "/dev/bot/common/workmux-template.yaml"))
(str/replace "{{BOT_NAME}}" "fixbot")
(str/replace "{{SOURCE_REPO}}" u/project-root-directory)
(str/replace "{{BOT_POST_CREATE}}"
(str " - mkdir -p .fixbot/playwright/sessions .fixbot/playwright/sockets\n"
" - MB_PREMIUM_EMBEDDING_TOKEN=" ee-token
" LINEAR_API_KEY=" linear-key
" ./bin/mage -bot-dev-env --app-db " app-db "\n")))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Main orchestrator
(defn run!
"Main entry point for the auto-fix workflow.
Expects --app-db, --prompt-file, and --branch options from the orchestrating Claude command."
[{:keys [arguments options]}]
(let [issue-id (first arguments)]
(when (str/blank? issue-id)
(println (c/red "Usage: ./bin/mage fixbot-go MB-12345 --app-db postgres --prompt-file /tmp/prompt.md --branch 'username/mb-12345-fix-thing'"))
(u/exit 1))
(let [issue-id (str/upper-case (str/trim issue-id))
app-db (or (:app-db options) "postgres")
prompt-file (:prompt-file options)
branch-name (:branch options)
base-branch (:base options)]
(when-not (re-matches #"[A-Z]+-\d+" issue-id)
(println (c/red "Invalid issue identifier: " issue-id))
(println "Expected format: MB-12345")
(u/exit 1))
(when (str/blank? prompt-file)
(println (c/red "--prompt-file is required"))
(u/exit 1))
(when-not (.exists (java.io.File. ^String prompt-file))
(println (c/red "Prompt file not found: " prompt-file))
(u/exit 1))
(when (str/blank? branch-name)
(println (c/red "--branch is required"))
(u/exit 1))
(let [session-name (str "fixbot-" (str/lower-case issue-id))]
(launch/launch-workmux-session!
{:session-name session-name
:branch-name branch-name
:prompt-file prompt-file
:workmux-config (generate-workmux-config app-db)
:base-branch base-branch
:display-info {"App DB" app-db}})))))

View File

@@ -0,0 +1,113 @@
(ns mage.fixbot.linear
(:require
[babashka.json :as json]
[clojure.string :as str]
[mage.color :as c]
[mage.shell :as shell]
[mage.util :as u]))
(set! *warn-on-reflection* true)
(def ^:private linear-api-url "https://api.linear.app/graphql")
(defn- get-api-key!
"Get LINEAR_API_KEY from env, or exit with instructions."
[]
(u/env "LINEAR_API_KEY"
(fn []
(println (c/red "LINEAR_API_KEY environment variable is not set."))
(println)
(println "To get a Linear API key:")
(println " 1. Go to https://linear.app/metabase/settings/account/security/api-keys/new")
(println " 2. Create a personal API key")
(println " 3. Export it: export LINEAR_API_KEY=lin_api_...")
(u/exit 1))))
(def ^:private issue-query
"query IssueByIdentifier($id: String!) {
searchIssues(term: $id, first: 1) {
nodes {
identifier
title
description
url
branchName
state { name }
comments {
nodes {
body
user { name }
createdAt
}
}
}
}
}")
(defn fetch-issue
"Fetch a Linear issue by identifier (e.g. MB-12345).
Returns a map with :identifier, :title, :description, :url, :state, :comments
or nil if not found."
[issue-id]
(let [api-key (get-api-key!)
payload (json/write-str {:query issue-query
:variables {:id issue-id}})
result (shell/sh* {:quiet? true}
"curl" "-s" "-X" "POST"
linear-api-url
"-H" "Content-Type: application/json"
"-H" (str "Authorization: " api-key)
"-d" payload)
response (json/read-str (str/join "\n" (:out result))
{:key-fn keyword})]
(when-let [errors (:errors response)]
(println (c/red "Linear API error:") (pr-str errors))
(u/exit 1))
(let [nodes (get-in response [:data :searchIssues :nodes])]
(when (seq nodes)
(let [issue (first nodes)]
{:identifier (:identifier issue)
:title (:title issue)
:description (or (:description issue) "")
:url (:url issue)
:branch-name (:branchName issue)
:state (get-in issue [:state :name])
:comments (mapv (fn [c]
{:body (:body c)
:author (get-in c [:user :name])
:created-at (:createdAt c)})
(get-in issue [:comments :nodes]))})))))
(defn print-issue!
"Fetch and print a Linear issue. Entry point for the CLI task."
[{:keys [arguments]}]
(let [issue-id (first arguments)]
(when (str/blank? issue-id)
(println (c/red "Usage: ./bin/mage -fixbot-fetch-issue MB-12345"))
(u/exit 1))
(let [issue-id (str/upper-case (str/trim issue-id))]
(when-not (re-matches #"[A-Z]+-\d+" issue-id)
(println (c/red "Invalid issue identifier: " issue-id))
(println "Expected format: MB-12345")
(u/exit 1))
(println (c/yellow "Fetching " issue-id "..."))
(if-let [issue (fetch-issue issue-id)]
(do
(println)
(println (c/bold (c/green (:identifier issue))) " — " (c/bold (:title issue)))
(println (c/cyan (:url issue)))
(println (c/yellow "State: ") (:state issue))
(println (c/yellow "Branch: ") (:branch-name issue))
(println)
(println (c/bold "Description:"))
(println (:description issue))
(when (seq (:comments issue))
(println)
(println (c/bold "Comments:"))
(doseq [comment (:comments issue)]
(println)
(println (c/cyan (:author comment)) " — " (:created-at comment))
(println (:body comment)))))
(do
(println (c/red "Issue not found: " issue-id))
(u/exit 1))))))

View File

@@ -0,0 +1,59 @@
(ns mage.fixbot.preflight
(:require
[clojure.string :as str]
[mage.color :as c]
[mage.shell :as shell]
[mage.util :as u]))
(set! *warn-on-reflection* true)
(defn preflight!
"Check all prerequisites for the fixbot workflow. Exits with an error if any fail."
[& _]
(when-not (u/can-run? "workmux")
(println (c/red "workmux is not installed."))
(println)
(println "Install workmux:")
(println " cargo install workmux")
(println " or see: https://github.com/pghk/workmux")
(u/exit 1))
(when-not (u/can-run? "clj-nrepl-eval")
(println (c/red "clojure-mcp-light not installed"))
(u/exit 1))
(when-not (u/can-run? "docker")
(println (c/red "Docker is not installed."))
(u/exit 1))
(let [{:keys [exit]} (shell/sh* {:quiet? true} "docker" "info")]
(when-not (zero? exit)
(println (c/red "Docker daemon is not running. Please start Docker."))
(u/exit 1)))
(when (str/blank? (u/env "TMUX" (constantly nil)))
(println (c/yellow "Not inside a tmux session — one will be created automatically.")))
(when (str/blank? (u/env "LINEAR_API_KEY" (constantly nil)))
(println (c/red "LINEAR_API_KEY environment variable is not set."))
(println)
(println "To get a Linear API key:")
(println " 1. Go to https://linear.app/metabase/settings/account/security/api-keys/new")
(println " 2. Create a personal API key")
(println " 3. Export it: export LINEAR_API_KEY=lin_api_...")
(u/exit 1))
(when (str/blank? (u/env "MB_PREMIUM_EMBEDDING_TOKEN" (constantly nil)))
(println (c/red "MB_PREMIUM_EMBEDDING_TOKEN environment variable is not set."))
(println)
(println "This token is required to run the Enterprise Edition.")
(println "Set it in your shell profile or export it before running fixbot.")
(u/exit 1))
(when-not (u/can-run? "npx")
(println (c/red "npx is not available. Install Node.js."))
(u/exit 1))
(let [{:keys [exit]} (shell/sh* {:quiet? true} "npx" "-y" "@playwright/mcp" "--version")]
(when-not (zero? exit)
(println (c/red "@playwright/mcp is not available."))
(println)
(println "It should auto-install via npx. Check your npm/node setup.")
(u/exit 1)))
;; Ensure node_modules exists so worktree symlinks have a target
(when-not (.isDirectory (java.io.File. ^String (str u/project-root-directory "/node_modules")))
(println (c/yellow "node_modules not found — running bun install..."))
(shell/sh {:dir u/project-root-directory} "bun" "install"))
(println (c/green "All preflight checks passed.")))

View File

@@ -0,0 +1,142 @@
(ns mage.fixbot.session
(:require
[clojure.string :as str]
[mage.bot.session :as bot]
[mage.color :as c]
[mage.fixbot.preflight :as preflight]
[mage.shell :as shell]
[mage.util :as u]))
(set! *warn-on-reflection* true)
(def ^:private prefix "fixbot")
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Resume (fixbot-specific — reads fixbot workmux template)
(defn- write-resume-workmux-yaml!
"Write a .workmux.yaml in the worktree that overrides the agent to use
'claude --continue' so the agent resumes the previous conversation."
[wt-path]
(let [template (slurp (str u/project-root-directory "/dev/bot/common/workmux-template.yaml"))
;; Fill in fixbot-specific values to get the panes section
filled (-> template
(str/replace "{{BOT_NAME}}" "fixbot")
(str/replace "{{SOURCE_REPO}}" u/project-root-directory)
(str/replace "{{BOT_POST_CREATE}}" ""))
panes-idx (str/index-of filled "\npanes:")
panes-section (when panes-idx (subs filled panes-idx))
yaml-path (str wt-path "/.workmux.yaml")]
(let [ee-token (u/env "MB_PREMIUM_EMBEDDING_TOKEN" (constantly ""))
linear-key (u/env "LINEAR_API_KEY" (constantly ""))]
(spit yaml-path
(str "agent: 'claude --continue'\n"
"\n"
"post_create:\n"
" - cp -r " u/project-root-directory "/dev/bot/fixbot .claude/fixbot\n"
" - cp -r " u/project-root-directory "/dev/bot/common/* .claude/fixbot/\n"
" - cp -r " u/project-root-directory "/mage/src/mage/ mage/src/mage/\n"
" - cp " u/project-root-directory "/bb.edn bb.edn\n"
" - MB_PREMIUM_EMBEDDING_TOKEN=" ee-token
" LINEAR_API_KEY=" linear-key
" ./bin/mage -bot-dev-env\n"
" - mise trust mise.local.toml\n"
panes-section)))
(println (c/yellow "Wrote resume config to " yaml-path))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Public API — delegates to shared bot.session
(defn pause!
"Pause a fixbot session."
[{:keys [arguments]}]
(bot/pause-sessions! prefix (first arguments)))
(defn resume!
"Resume a paused fixbot session."
[{:keys [arguments]}]
(preflight/preflight!)
(let [name-or-id (first arguments)]
(when (str/blank? name-or-id)
(println (c/red "Usage: ./bin/mage -fixbot-resume <name-or-issue-id>"))
(u/exit 1))
(let [arg (str/trim name-or-id)
pr-match (re-find #"https://github\.com/.*/pull/(\d+)" arg)
lookup (if pr-match
(let [pr-num (second pr-match)
branch (str/trim (str/join "" (:out (shell/sh* {:quiet? true}
"gh" "pr" "view" pr-num
"--json" "headRefName"
"--jq" ".headRefName"))))]
(if (str/blank? branch)
(do (println (c/red "Could not determine branch for PR #" pr-num))
(u/exit 1))
branch))
arg)
session (bot/find-session lookup)]
(if session
(let [wt-path (bot/worktree-path session)
in-tmux? (not (str/blank? (u/env "TMUX" (constantly nil))))
resume-prompt "Continue where you left off."]
(when wt-path
(write-resume-workmux-yaml! wt-path))
(println (c/yellow "Resuming session: " session "..."))
(if in-tmux?
(shell/sh {:dir wt-path} "workmux" "open" session "--run-hooks"
"-p" resume-prompt)
(do
(println (c/yellow "Not inside tmux. Creating detached tmux session..."))
(shell/sh* {:quiet? true} "tmux" "kill-session" "-t" session)
(shell/sh "nohup" "bash" "-c"
(str "cd " wt-path
" && tmux new-session -d -s " session
" && tmux send-keys -t " session
" \"workmux open " session " --run-hooks"
" -p '" resume-prompt "'\" Enter"))))
(println)
(if in-tmux?
(println (c/bold (c/green "Session resumed: ") (c/cyan session)))
(do
(println (c/bold (c/green "Tmux session created: ") (c/cyan session)))
(println)
(println "Attach to it with:")
(println (str " tmux attach -t " session)))))
(do
(println (c/red "No paused session found matching: ") lookup)
(bot/print-available-sessions!)
(println)
(println (c/yellow "To create a new session, use /fixbot with the issue ID."))
(u/exit 1))))))
(defn list-sessions!
"List all fixbot sessions with status."
[_parsed]
(bot/list-sessions! prefix "Fixbot"))
(defn quit!
"Tear down and remove a fixbot worktree session."
[{:keys [arguments]}]
(bot/quit-session! prefix (first arguments)))
(defn write-sandbox-settings!
"Copy the sandbox-specific settings.local.json into the current worktree's .claude/ directory."
[_parsed]
(let [source (str u/project-root-directory "/dev/bot/fixbot/sandbox-settings.local.json")
target ".claude/settings.local.json"]
(if (.exists (java.io.File. ^String source))
(do
(spit target (slurp source))
(println (c/green "Wrote ") target))
(do
(println (c/red "Source not found: ") source)
(u/exit 1)))))
(defn dashboard!
"Open the workmux TUI dashboard."
[]
(let [pb (ProcessBuilder. ^java.util.List ["workmux" "dashboard" "fixbot"])]
(.inheritIO pb)
(.directory pb (java.io.File. ^String u/project-root-directory))
(let [proc (.start pb)]
(.waitFor proc)
(System/exit (.exitValue proc)))))

View File

@@ -0,0 +1,189 @@
(ns mage.fixbot.status
(:require
[babashka.http-client :as http]
[babashka.json :as json]
[clojure.string :as str]
[mage.shell :as shell])
(:import
(java.io File)
(java.net Socket)))
(set! *warn-on-reflection* true)
(defn- parse-mise-env
"Parse key=value pairs from mise.local.toml [env] section.
Returns a map of env var name -> value (unquoted)."
[^String path]
(when (.exists (File. path))
(let [lines (str/split-lines (slurp path))]
(->> lines
(keep (fn [line]
(when-let [[_ k v] (re-matches #"\s*(\w+)\s*=\s*\"([^\"]*)\"\s*" line)]
[k v])))
(into {})))))
(defn- extract-port
"Extract port number from a string, trying env value directly or parsing from JDBC URL."
[^String s]
(when s
(or (try (Integer/parseInt s) (catch Exception _ nil))
(when-let [[_ port-str] (re-find #"(?:localhost|host\.docker\.internal):(\d+)" s)]
(Integer/parseInt port-str)))))
(defn- extract-db-host
"Extract the DB host from a JDBC URL."
[^String s]
(when s
(when-let [[_ host] (re-find #"//([^:/]+)" s)]
host)))
(defn- check-http
"Try an HTTP GET. Returns :ready, :unhealthy, or :waiting."
[^String url timeout-ms]
(try
(let [resp (http/get url {:timeout (int timeout-ms)
:throw false})
code (:status resp)]
(if (<= 200 code 399) :ready :unhealthy))
(catch java.net.ConnectException _ :waiting)
(catch Exception _ :unhealthy)))
(defn- check-tcp
"Try a TCP connection. Returns :ready or :waiting."
[^String host port timeout-ms]
(try
(let [sock (Socket.)]
(.connect sock (java.net.InetSocketAddress. host ^int (int port)) (int timeout-ms))
(.close sock)
:ready)
(catch Exception _ :waiting)))
(def ^:private green "\033[32m")
(def ^:private red "\033[31m")
(def ^:private yellow "\033[33m")
(def ^:private reset "\033[0m")
(defn- colorize [color text]
(str color text reset))
(defn- load-ports
"Read mise.local.toml and extract ports. Returns a map or nil if file not ready."
[^String mise-path]
(when-let [env (parse-mise-env mise-path)]
(let [jetty-port (extract-port (get env "MB_JETTY_PORT"))
frontend-port (extract-port (get env "MB_FRONTEND_DEV_PORT"))
db-uri (get env "MB_DB_CONNECTION_URI")
db-type-str (get env "MB_DB_TYPE")
db-type (when db-type-str (keyword db-type-str))
db-port (extract-port db-uri)
db-host (extract-db-host db-uri)]
(when jetty-port
{:jetty-port jetty-port
:frontend-port frontend-port
:db-type db-type
:db-port db-port
:db-host (or db-host "localhost")}))))
(defn- load-issue-info
"Read issue.txt and prompt file to get issue ID, URL, and title."
[]
(let [issue-file (File. ".fixbot/issue.txt")]
(when (.exists issue-file)
(let [issue-line (str/trim (slurp issue-file))
[id url] (str/split issue-line #"\s*\|\s*" 2)
;; Find prompt file to extract title
fixbot-dir (File. ".fixbot")
prompt-files (when (.isDirectory fixbot-dir)
(->> (.listFiles fixbot-dir)
(filter #(str/starts-with? (.getName ^File %) "metabase-fixbot-"))
(filter #(str/ends-with? (.getName ^File %) "-prompt.md"))))
title (when (seq prompt-files)
(let [first-line (first (str/split-lines (slurp ^File (first prompt-files))))]
;; Parse "# Fixbot Agent — UXW-3155: Add keyboard shortcut..."
(when-let [[_ t] (re-find #":\s*(.+)" first-line)]
(str/trim t))))]
{:id (str/trim (or id ""))
:url (str/trim (or url ""))
:title (or title "")}))))
(defn- check-pr
"Check if there's an open PR for the current branch. Returns {:url ... :number ...} or nil."
[]
(try
(let [result (shell/sh* {:quiet? true} "gh" "pr" "view" "--json" "url,number,state")]
(when (zero? (:exit result))
(let [data (json/read-str (str/join "\n" (:out result)) {:key-fn keyword})]
(when (= "OPEN" (:state data))
{:url (:url data) :number (:number data)}))))
(catch Exception _ nil)))
(defn- render-display
"Render the full status pane display."
[issue ports be-status db-status llm-status pr-info]
(let [sb (StringBuilder.)]
;; Line 1: Issue title
(when (and issue (seq (:title issue)))
(.append sb (:title issue))
(.append sb "\n"))
;; Line 2: Issue ID | URL | PR
(when issue
(.append sb (str (:id issue) " | " (:url issue)))
(when pr-info
(.append sb (str " | " (colorize green (str "PR #" (:number pr-info))))))
(.append sb "\n"))
;; Line 3: Metabase | URL | DB
(when ports
(let [be-up? (= be-status :ready)
mb-color (if be-up? green red)
mb-text (str (if be-up? "http" "error") "://localhost:" (:jetty-port ports))
db-name (when (:db-type ports)
(str/upper-case (name (:db-type ports))))
db-color (if (= db-status :ready) green red)
db-text (when (and db-name (:db-port ports))
(str db-name ": " (:db-port ports)))]
(.append sb (colorize mb-color mb-text))
(when db-text
(.append sb " | ")
(.append sb (colorize db-color db-text)))
(.append sb "\n")))
;; Blank line + LLM status
(when (seq llm-status)
(.append sb "\n")
(.append sb llm-status)
(.append sb "\n"))
(.toString sb)))
(defn run!
"Watch .fixbot/llm-status.txt and periodically check service health."
[{:keys [arguments]}]
(let [file-path (or (first arguments) ".fixbot/llm-status.txt")
f (File. ^String file-path)
mise-path "mise.local.toml"]
(loop [last-output ""
ports nil
issue nil
pr-info nil
last-be-status :unhealthy
last-db-status :unhealthy
tick 0]
(let [check? (zero? (mod tick 5))
pr-check? (and (not pr-info) (zero? (mod tick 30)))
ports (or ports (when check? (load-ports mise-path)))
issue (or issue (when check? (load-issue-info)))
pr-info (or pr-info (when pr-check? (check-pr)))
be-status (if (and check? ports)
(check-http (str "http://localhost:" (:jetty-port ports) "/api/health") 2000)
last-be-status)
db-status (if (and check? ports (:db-port ports))
(check-tcp (:db-host ports) (:db-port ports) 2000)
last-db-status)
;; Read LLM status from file
llm-status (when (.exists f)
(str/trim (slurp f)))
output (render-display issue ports be-status db-status llm-status pr-info)]
(when (not= output last-output)
(println "=========================================\n")
(print output)
(flush))
(Thread/sleep 1000)
(recur output ports issue pr-info be-status db-status (inc tick))))))

View File

@@ -0,0 +1,87 @@
(ns mage.uxbot.go
(:require
[clojure.string :as str]
[mage.bot.launch :as launch]
[mage.bot.session :as bot]
[mage.color :as c]
[mage.util :as u]))
(set! *warn-on-reflection* true)
(defn- generate-workmux-config
"Generate the .workmux.yaml content from the common template."
[app-db]
(let [ee-token (u/env "MB_PREMIUM_EMBEDDING_TOKEN" (constantly ""))]
(-> (slurp (str u/project-root-directory "/dev/bot/common/workmux-template.yaml"))
(str/replace "{{BOT_NAME}}" "uxbot")
(str/replace "{{SOURCE_REPO}}" u/project-root-directory)
(str/replace "{{BOT_POST_CREATE}}"
(str " - mkdir -p .uxbot/screenshots\n"
" - MB_PREMIUM_EMBEDDING_TOKEN=" ee-token
" ./bin/mage -bot-dev-env --app-db " app-db "\n")))))
(defn- branch-to-session-name
"Convert a branch name to a uxbot session name.
e.g., 'feature/my-branch' → 'uxbot-my-branch'"
[branch-name]
(let [slug (-> branch-name
(str/replace #".+/" "")
(str/lower-case)
(str/replace #"[^a-z0-9-]" "-")
(str/replace #"-+" "-")
(str/replace #"^-|-$" ""))]
(str "uxbot-" (subs slug 0 (min (count slug) 40)))))
(defn run!
"Main entry point for uxbot."
[{:keys [arguments options]}]
(let [branch-name (first arguments)]
(when (str/blank? branch-name)
(println (c/red "Usage: ./bin/mage uxbot-go <branch-name> --prompt-file <path>"))
(u/exit 1))
(let [branch-name (str/trim branch-name)
app-db (or (:app-db options) "postgres")
prompt-file (:prompt-file options)
session-name (branch-to-session-name branch-name)
config (generate-workmux-config app-db)]
(when (str/includes? branch-name "/")
(println (c/red "Pass a local branch name, not a remote ref."))
(println "Example: ./bin/mage uxbot-go master (not origin/master)")
(u/exit 1))
(when (str/blank? prompt-file)
(println (c/red "--prompt-file is required"))
(u/exit 1))
(when-not (.exists (java.io.File. ^String prompt-file))
(println (c/red "Prompt file not found: " prompt-file))
(u/exit 1))
;; Check for running tmux session
(when (bot/tmux-session-running? session-name)
(println (c/red "Session " session-name " is already running!"))
(println)
(println "Attach to it with:")
(println (str " tmux attach -t " session-name))
(println)
(println (str "Stop it first with: ./bin/mage -uxbot-stop " branch-name))
(u/exit 1))
;; Check for existing worktree → relaunch, otherwise fresh launch
(let [existing (bot/find-session session-name)
wt-path (when existing (bot/worktree-path existing))]
(if (and existing wt-path)
(do
(println (c/yellow "Found existing worktree: " existing))
(launch/relaunch-existing-session!
{:bot-name "uxbot"
:session-name session-name
:wt-path wt-path
:prompt-file prompt-file
:app-db app-db
:workmux-config config}))
(launch/launch-workmux-session!
{:session-name session-name
:branch-name branch-name
:prompt-file prompt-file
:workmux-config config
:base-branch (or (:base options) (str "origin/" branch-name))
:display-info {"App DB" app-db}}))))))

View File

@@ -0,0 +1,17 @@
(ns mage.uxbot.session
(:require
[mage.bot.session :as bot]))
(set! *warn-on-reflection* true)
(def ^:private prefix "uxbot")
(defn list-sessions!
"List all uxbot sessions with status."
[_parsed]
(bot/list-sessions! prefix "UXBot"))
(defn stop!
"Stop a uxbot session (kill tmux + dev env, keep worktree)."
[{:keys [arguments]}]
(bot/stop-session! prefix (first arguments)))

View File

@@ -366,11 +366,16 @@ if (isDevMode) {
// helps with source maps
config.output.devtoolModuleFilenameTemplate = "[absolute-resource-path]";
if (!process.env.DISABLE_BUILD_NOTIFICATIONS) {
config.plugins.push(
new WebpackNotifierPlugin({
excludeWarnings: true,
skipFirstNotification: true,
}),
);
}
config.plugins.push(
new WebpackNotifierPlugin({
excludeWarnings: true,
skipFirstNotification: true,
}),
new CssVarsDeclarationPlugin({
frontendSrcPath: __dirname + "/frontend/src",
rootPath: __dirname,