diff --git a/.claude/skills/README.md b/.claude/skills/README.md new file mode 100644 index 0000000..da7cd16 --- /dev/null +++ b/.claude/skills/README.md @@ -0,0 +1,28 @@ +# Contributor agent skills + +These optional workflows help coding agents work on Gravity. They are not +required to build or run the app. Start with `CONTRIBUTING.md` for dependencies +and verification; use whatever terminal, browser or native-window tools your +agent environment provides. + +| Skill | Purpose | Requirements beyond the contributor setup | +| --- | --- | --- | +| `bus-live-test` | Exercise bot messaging and guardrails | Node 22+; real Claude sessions require consent and an authenticated CLI | +| `terminal-perf-test` | Exercise terminal replay and rendering | Node 22+, browser automation; synthetic runtime only | +| `marketing-screenshot` | Capture a staged native app window | macOS, Node 22+, window capture access; consent for billed sessions | +| `visual-regression` | Review and adopt CI screenshots | GitHub CLI access to the relevant workflow artifacts | +| `release` | Prepare and verify a release | Maintainer authorization and access to the intended repository's release configuration | + +Local drivers require an explicit disposable daemon home, a matching published +port and a loopback endpoint. They reject the standard installed daemon home +and port. These checks are guardrails, not a sandbox: use only a daemon started +for the current test. Keep credentials, real transcripts and local agent +settings out of commits and shared tool output. Leave cleanup of existing user +data to the user unless explicitly authorized. + +Shared driver checks live in `lib/` and run in CI and `pnpm run verify`. To run +just those checks: + +```bash +node --test .claude/skills/lib/*.test.mjs +``` diff --git a/.claude/skills/bus-live-test/SKILL.md b/.claude/skills/bus-live-test/SKILL.md index 68583f4..c8d02fb 100644 --- a/.claude/skills/bus-live-test/SKILL.md +++ b/.claude/skills/bus-live-test/SKILL.md @@ -21,46 +21,52 @@ end to end. Two modes, cheapest first: ```bash cargo build -p gravityd -H=/tmp/gravityd-bustest # throwaway home; pick a fresh path per run -rm -rf $H && mkdir -p $H -cat > $H/gravityd.toml < "$BUS_HOME/gravityd.toml" < $H/gravityd.log 2>&1 & -sleep 2 && curl -s http://127.0.0.1:49666/health # expect status ok +./target/debug/gravityd --config "$BUS_HOME/gravityd.toml" > "$BUS_HOME/gravityd.log" 2>&1 & +BUS_DAEMON_PID=$! ``` -For **real mode** add `claude_bin = "$(which claude)"` (absolute path — the +Wait for the published `$BUS_HOME/gravityd.port`, verify it is 49666, and +check `curl --fail http://127.0.0.1:49666/health`. Confirm the recorded PID +is still running; stop on startup failure. Never reuse the installed daemon +or stop another process to free this port. + +For **real mode** add `claude_bin = "$(command -v claude)"` (absolute path — the daemon's PATH may not include `~/.local/bin`) and set `runtime = "pty"`. Create a project and bots with the bundled driver (Node 22+, no deps): ```bash -node .claude/skills/bus-live-test/scripts/bus.mjs setup $H 49666 \ +node .claude/skills/bus-live-test/scripts/bus.mjs setup "$BUS_HOME" 49666 \ "lead:You coordinate work." "worker:You do small jobs." # prints {"project":..., "bots":{"lead":"","worker":""}} ``` -Credentials on disk: client token at `$H/secrets/client.token`, per-bot MCP -tokens at `$H/secrets/bot-.token`. +Credentials on disk: client token at `$BUS_HOME/secrets/client.token`, per-bot MCP +tokens at `$BUS_HOME/secrets/bot-.token`. ## Synthetic mode: act as the bots -Call bus tools directly with a bot's bearer token: +Set `LEAD` and `WORKER` to the IDs printed by setup. Call bus tools with +tokens loaded from disk; do not paste token values into tool calls, terminal +arguments, logs, issues, or chat. Review responses before sharing them: ```bash -mcp() { # mcp - curl -s http://127.0.0.1:49666/mcp \ - -H "Authorization: Bearer $(cat $H/secrets/bot-$1.token)" \ - -H 'Content-Type: application/json' \ - -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"$2\",\"arguments\":$3}}" \ - | python3 -c "import json,sys;r=json.load(sys.stdin)['result'];print(('REFUSED: ' if r.get('isError') else 'OK: ')+r['content'][0]['text'])" +mcp() { # mcp ; token is read only inside Node + node .claude/skills/bus-live-test/scripts/bus.mjs mcp "$BUS_HOME" 49666 "$@" } mcp $LEAD send_message '{"to":"worker","body":"do X","kind":"task"}' mcp $WORKER complete_task '{"task_id":"...","result":"done","artifacts":["/path"]}' @@ -81,7 +87,7 @@ Guardrail checklist — force each refusal and read its hint text: 5. **Expiry**: backdate a deadline and watch the sweep flip it and notify both ends exactly once: ```bash - sqlite3 $H/bus.sqlite "UPDATE task SET deadline_at='2020-01-01T00:00:00Z' WHERE id='';" + sqlite3 "$BUS_HOME/bus.sqlite" "UPDATE task SET deadline_at='2020-01-01T00:00:00Z' WHERE id='';" # within ~2 ticks: state='expired', WARN "task exceeded its deadline" in the log, # one system note per live end (assignee: stop work; requester: re-delegate) ``` @@ -101,7 +107,7 @@ Wait for boot (`active_bots` in `/health`, `turn complete` per bot in the log), then speak as the user: ```bash -node .claude/skills/bus-live-test/scripts/bus.mjs chat $H 49666 \ +node .claude/skills/bus-live-test/scripts/bus.mjs chat "$BUS_HOME" 49666 \ "Delegate this to worker: ... When you get the result, read the artifact and tell me ..." ``` @@ -117,8 +123,8 @@ Good scenario shapes (each verified to exercise the guardrails): - **Bus ledger** — the ground truth for chatter: ```bash - sqlite3 $H/bus.sqlite "SELECT num,sender_name,kind,substr(replace(body,char(10),' '),1,100) FROM message ORDER BY num;" - sqlite3 $H/bus.sqlite "SELECT substr(id,1,8),state,hop_count,reply_count,deadline_at IS NOT NULL FROM task;" + sqlite3 "$BUS_HOME/bus.sqlite" "SELECT num,sender_name,kind,substr(replace(body,char(10),' '),1,100) FROM message ORDER BY num;" + sqlite3 "$BUS_HOME/bus.sqlite" "SELECT substr(id,1,8),state,hop_count,reply_count,deadline_at IS NOT NULL FROM task;" ``` - **Daemon log** — bot state transitions, delivery, expiry warns. - **Session transcripts** — tool-by-tool behavior including refusals the bot @@ -145,12 +151,19 @@ Good scenario shapes (each verified to exercise the guardrails): note to filter out. - Real mode writes residue outside the throwaway home: transcript dirs in `~/.claude/projects/` and trust entries in `~/.claude.json` for the - workspaces. Tell the user; offer to prune. + workspaces. Tell the user; do not remove it without approval. ## Cleanup +Stop only the daemon started in this live shell: + ```bash -pkill -f "target/debug/gravityd --config $H" -pkill -f "claude.*$H" 2>/dev/null # real mode: orphaned sessions -rm -rf $H +kill "$BUS_DAEMON_PID" +wait "$BUS_DAEMON_PID" ``` + +If the shell/session was lost, verify process ownership again before signalling. +Never use broad process-name matching. Retain the run directory and logs for +review; ask before deleting its exact path. In real mode, identify any remaining +child sessions narrowly, and obtain approval before removing their exact +transcript directories or trust entries from the user's real home. diff --git a/.claude/skills/bus-live-test/scripts/bus.mjs b/.claude/skills/bus-live-test/scripts/bus.mjs index c561a1f..f133757 100644 --- a/.claude/skills/bus-live-test/scripts/bus.mjs +++ b/.claude/skills/bus-live-test/scripts/bus.mjs @@ -5,20 +5,57 @@ // // node bus.mjs setup ... // node bus.mjs chat +// node bus.mjs mcp // // `setup` prints {project, bots: {name: id}} as JSON. Bot MCP tokens are on // disk at /secrets/bot-.token. -import fs from 'fs'; +import fs from "node:fs"; +import { localDaemon } from "../../lib/local-daemon.mjs"; const [cmd, home, port, ...rest] = process.argv.slice(2); if (!cmd || !home || !port) { - console.error('usage: bus.mjs setup|chat ...'); + console.error("usage: bus.mjs setup|chat|mcp ..."); process.exit(2); } -const token = fs.readFileSync(`${home}/secrets/client.token`, 'utf8').trim(); -const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); +const daemon = localDaemon(home, `ws://127.0.0.1:${port}/ws`); +if (cmd === "mcp") { + const [botId, tool, rawArguments] = rest; + if ( + !botId || + !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(botId) || + !tool || + !rawArguments + ) { + throw new Error("mcp requires a bot UUID, tool name, and JSON arguments"); + } + const args = JSON.parse(rawArguments); + const botToken = fs.readFileSync(`${daemon.home}/secrets/bot-${botId}.token`, "utf8").trim(); + const response = await fetch(`http://127.0.0.1:${port}/mcp`, { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(10_000), + headers: { Authorization: `Bearer ${botToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: tool, arguments: args }, + }), + }); + if (!response.ok) { + throw new Error(`MCP request failed (HTTP ${response.status})`); + } + console.log(JSON.stringify(await response.json())); + process.exit(0); +} +const token = fs.readFileSync(`${daemon.home}/secrets/client.token`, "utf8").trim(); +const ws = new WebSocket(daemon.endpoint); +const watchdog = setTimeout(() => { + console.error("driver timed out"); + process.exit(1); +}, 30_000); let id = 0; const pending = new Map(); const req = (msg) => @@ -34,38 +71,44 @@ ws.onmessage = (e) => { pending.delete(v.req_id); } }; -await new Promise((r) => (ws.onopen = r)); -const hello = await req({ type: 'hello', protocol_version: 2, token, client: 'bus-live-test/0' }); -if (hello.type !== 'hello_ok') { - console.error('handshake failed', JSON.stringify(hello)); +await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = reject; +}); +const hello = await req({ type: "hello", protocol_version: 2, token, client: "bus-live-test/0" }); +if (hello.type !== "hello_ok") { + console.error("handshake failed"); process.exit(1); } -if (cmd === 'setup') { - const proj = await req({ type: 'create_project', name: 'bustest' }); +if (cmd === "setup") { + const proj = await req({ type: "create_project", name: "bustest" }); const out = { project: proj.project.id, bots: {} }; for (const spec of rest) { - const [name, instructions = ''] = spec.split(':'); + const separator = spec.indexOf(":"); + const name = separator < 0 ? spec : spec.slice(0, separator); + const instructions = separator < 0 ? "" : spec.slice(separator + 1); const b = await req({ - type: 'create_bot', + type: "create_bot", project_id: out.project, name, description: name, instructions, }); - if (b.type !== 'bot') { - console.error('create_bot failed', JSON.stringify(b)); + if (b.type !== "bot") { + console.error("create_bot failed", JSON.stringify(b)); process.exit(1); } out.bots[name] = b.bot.id; } console.log(JSON.stringify(out)); -} else if (cmd === 'chat') { +} else if (cmd === "chat") { const [botId, body] = rest; - const r = await req({ type: 'send_user_message', to_bot_id: botId, body }); + const r = await req({ type: "send_user_message", to_bot_id: botId, body }); console.log(JSON.stringify(r.message ? { sent: r.message.id } : r)); } else { console.error(`unknown command '${cmd}'`); process.exit(2); } +clearTimeout(watchdog); ws.close(); diff --git a/.claude/skills/lib/local-daemon.mjs b/.claude/skills/lib/local-daemon.mjs new file mode 100644 index 0000000..9e3aec0 --- /dev/null +++ b/.claude/skills/lib/local-daemon.mjs @@ -0,0 +1,47 @@ +import { readFileSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +/** + * Check a disposable daemon before reading credentials or opening a socket. + * @param {string | undefined} home + * @param {string} endpoint + * @returns {{ home: string, endpoint: URL }} + */ +export function localDaemon(home, endpoint) { + const url = new URL(endpoint); + if ( + url.protocol !== "ws:" || + url.hostname !== "127.0.0.1" || + url.pathname !== "/ws" || + url.username || + url.password || + url.search || + url.hash || + !url.port || + url.port === "49777" + ) { + throw new Error("Use ws://127.0.0.1:/ws; production port 49777 is forbidden"); + } + if (!home) { + throw new Error("Set GRAVITY_HOME to the disposable daemon home created for this run"); + } + const canonicalHome = realpathSync(home); + const installedHome = resolve(realpathSync(homedir()), ".gravity"); + let canonicalInstalledHome = installedHome; + try { + canonicalInstalledHome = realpathSync(installedHome); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") { + throw error; + } + } + if (canonicalHome === canonicalInstalledHome) { + throw new Error("The installed daemon home is forbidden; create a disposable home"); + } + const port = readFileSync(join(canonicalHome, "gravityd.port"), "utf8").trim(); + if (port !== url.port) { + throw new Error("Endpoint does not match the disposable daemon's published port"); + } + return { home: canonicalHome, endpoint: url }; +} diff --git a/.claude/skills/lib/local-daemon.test.mjs b/.claude/skills/lib/local-daemon.test.mjs new file mode 100644 index 0000000..a979acf --- /dev/null +++ b/.claude/skills/lib/local-daemon.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { localDaemon } from "./local-daemon.mjs"; + +test("reject unsafe endpoints before reading any home or credentials", () => { + for (const endpoint of [ + "ws://example.org:49555/ws", + "wss://127.0.0.1:49555/ws", + "ws://127.0.0.1:49777/ws", + "ws://user:password@127.0.0.1:49555/ws", + "ws://127.0.0.1:49555/ws?token=example", + "ws://127.0.0.1:49555/other", + ]) { + assert.throws(() => localDaemon(undefined, endpoint), /test-port/); + } +}); + +test("require an explicit home and match its published port", (context) => { + assert.throws(() => localDaemon(undefined, "ws://127.0.0.1:49555/ws"), /GRAVITY_HOME/); + const home = mkdtempSync(join(tmpdir(), "gravity-skill-test-")); + context.after(() => rmSync(home, { recursive: true })); + writeFileSync(join(home, "gravityd.port"), "49555\n"); + assert.equal(localDaemon(home, "ws://127.0.0.1:49555/ws").endpoint.port, "49555"); + assert.throws(() => localDaemon(home, "ws://127.0.0.1:49888/ws"), /published port/); +}); diff --git a/.claude/skills/marketing-screenshot/SKILL.md b/.claude/skills/marketing-screenshot/SKILL.md index c6fbf42..f883362 100644 --- a/.claude/skills/marketing-screenshot/SKILL.md +++ b/.claude/skills/marketing-screenshot/SKILL.md @@ -1,159 +1,127 @@ --- name: marketing-screenshot -description: Capture the hero screenshot of the Gravity desktop app for getgravity.build — runs the real Tauri app against a throwaway daemon with real (billed) Claude Code sessions, stages a believable project and team, and captures the window at retina resolution. Use when the marketing page needs a new or refreshed app screenshot, or when someone asks for a screenshot of the app showing bots at work. +description: Capture the real Gravity desktop app for the marketing site using a disposable daemon and staged example data. Real Claude Code sessions are billed and require explicit consent. --- # Marketing screenshot -Produces `/tmp/gravity-shot/shots/gravity-hero.png` — the real desktop app, real -Claude Code sessions, a project that looks like someone's actual working day. +Use the native macOS app with fictional project data. Confirm consent and a +small turn budget before starting `runtime = "pty"`: creating each bot starts +a real, billed Claude Code session. Use `double` for a free layout rehearsal; +it cannot demonstrate genuine agent behavior. -Two rules the whole setup exists to satisfy: - -- **The installed Gravity must not be touched.** The user runs a real daemon on - `~/.gravity` port 49777 with their own bots. Everything here is a throwaway - daemon on port 49888. -- **The bots must be real.** Faking terminal output with the `double` runtime - looks wrong the moment anyone reads it. Use `runtime = "pty"`, which spawns - actual `claude` sessions **billed to the user's account** — get consent, and - budget roughly one turn per bot plus a few for the prompts you send. +Keep the installed app and daemon untouched. Never assume their port or data +location; use a fresh daemon home and an unused test port. Requirements are in +`CONTRIBUTING.md`; the driver additionally requires Node 22+. A real run needs +an authenticated `claude` executable on PATH. Do not print credentials, raw +process command lines, or real session transcripts into shared reports. ## Setup -The daemon needs the *real* `$HOME` or Claude Code is not logged in; the app -needs a *sandbox* `$HOME` or it connects to the installed daemon. So they get -different homes. +Run from the repository root, in a dedicated terminal. These examples use +49888 for the daemon and 1420 for the frontend. Check both are free first; +choose other ports consistently if occupied, without stopping their owners. +Keep shell tracing off when loading credentials. ```bash +umask 077 +SHOT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gravity-shot.XXXXXX") +SHOT_DIR=$(cd "$SHOT_DIR" && pwd -P) # canonical paths also avoid transcript aliases +export GRAVITY_HOME="$SHOT_DIR/daemon" +export GRAVITY_WS=ws://127.0.0.1:49888/ws +mkdir -p "$GRAVITY_HOME" "$SHOT_DIR/shots" +CLAUDE_BIN=$(command -v claude) cargo build -p gravityd -./scripts/prepare-sidecar.sh # tauri dev needs the staged sidecar - -S=/tmp/gravity-shot; rm -rf $S; mkdir -p $S/daemon $S/apphome/.gravity/secrets $S/shots -cat > $S/daemon/gravityd.toml < "$GRAVITY_HOME/gravityd.toml" < $S/gravityd.log 2>&1 & -sleep 3 && curl -s http://127.0.0.1:49888/health # expect status ok -grep "runtime available" $S/gravityd.log # expect a Claude Code version - -# What the app reads to find "the local daemon": a port and a token. -cp $S/daemon/secrets/client.token $S/apphome/.gravity/secrets/client.token -printf 'port = 49888\n' > $S/apphome/.gravity/gravityd.toml -echo 49888 > $S/apphome/.gravity/gravityd.port +./target/debug/gravityd --config "$GRAVITY_HOME/gravityd.toml" > "$SHOT_DIR/gravityd.log" 2>&1 & +SHOT_DAEMON_PID=$! ``` -Then run the app. `CONDUCTOR_PORT` must be unset — `vite.config.ts` honours it -with `strictPort`, while `tauri.conf.json` waits on 1420 and the two never meet. - -```bash -cd apps/desktop && env -u CONDUCTOR_PORT \ - HOME=/tmp/gravity-shot/apphome \ - CARGO_HOME=$HOME/.cargo RUSTUP_HOME=$HOME/.rustup PNPM_HOME=$HOME/Library/pnpm \ - pnpm tauri dev > /tmp/gravity-shot/tauri.log 2>&1 & -``` - -The footer must read `127.0.0.1:49888 · connected`. If it says 49777, the app -found the installed daemon and everything below would edit the user's real data — -stop and fix the sandbox home first. - -## Staging the scene +Wait for `$GRAVITY_HOME/gravityd.port` and a successful +`curl --fail http://127.0.0.1:49888/health`. Confirm the recorded daemon PID +is still running and the published port is 49888. Do not continue on a startup +failure. Leave `$HOME` unchanged so Claude Code can use its existing login. -`scripts/drive.mjs` speaks the WS control plane (`list`, `project`, `bot`, `say`, -`peek`, `update`, `delete`). Create the project and **one** lead bot, then have -that bot create the rest — a team the user watched a bot build reads as real, -and a preconfigured roster does not. +Start the app in the same terminal; the dev token stays in the local process +environment, not a command argument or a pasted browser-tool call: ```bash -D=".claude/skills/marketing-screenshot/scripts/drive.mjs" -P=$(node $D project Work) -node $D bot $P Argus "Runs this project. Builds the team it needs, routes the day's work." \ - "You run this project. Create the bots you need, give each a clear charter, hand them work." icon:quartz -node $D say "Stand up the team. Create six bots with these names and charters, then tell me who they are and what each one owns. Forge — ships code … Sentry — reviews every PR … (one clause per bot)" +export VITE_GRAVITY_DEV_PORT=49888 +export VITE_GRAVITY_DEV_TOKEN="$(cat "$GRAVITY_HOME/secrets/client.token")" +(cd apps/desktop && env -u CONDUCTOR_PORT pnpm tauri dev \ + --config '{"identifier":"build.getgravity.screenshot","build":{"devUrl":"http://localhost:1420"}}') ``` -Give every bot a name that reads as a name (Argus, Forge, Sentry, Pulse, Echo, -Quill, Scout), not a job label. Watch it land: +The separate app identifier separates screenshot app storage from the official +app. A previous screenshot profile can still contain connection overrides: +the footer **must** read `127.0.0.1:49888 · connected` before any interaction. +If it differs, stop and reset only that disposable profile's connection settings. +Never install, restart, or update the managed production daemon from this app. +Do not expose the dev frontend to the network or deploy a build containing its +local test token. -```bash -sqlite3 /tmp/gravity-shot/daemon/bus.sqlite "select name from bot where deleted_at is null;" -grep "bot state" /tmp/gravity-shot/gravityd.log | tail -5 # all "ready — turn complete" -node $D peek # what a terminal shows right now -``` +## Staging the scene -Two fixes the sidebar needs before it looks right: +In another terminal, set `GRAVITY_HOME` to the exact disposable daemon home +from setup and `GRAVITY_WS` to its loopback URL. The bundled driver validates +that the endpoint matches the home's published port before reading credentials. ```bash -# 1. Previews read "system: You have just been cre…" because each bot's creation -# note is newer than its first turn. Backdate the notes so the turn wins. -sqlite3 /tmp/gravity-shot/daemon/bus.sqlite \ - "UPDATE message SET created_at='2026-09-01T08:12:00+00:00' WHERE sender_name='system';" - -# 2. The daemon looks for transcripts under the workspace path it stored (/tmp/…) -# while Claude Code writes them under the resolved one (/private/tmp/…). -cd ~/.claude/projects && for d in -private-tmp-gravity-shot-daemon-*-workspace; do - ln -sfn "$PWD/$d" "$PWD/${d#-private}" -done +DRIVER=.claude/skills/marketing-screenshot/scripts/drive.mjs +PROJECT_ID=$(node "$DRIVER" project Example) +node "$DRIVER" bot "$PROJECT_ID" Argus "Coordinates the example project." \ + "Use fictional example data only. Create no additional bots until asked." icon:quartz ``` -Both only take effect after the client refetches, which means restarting the app -(or `touch apps/desktop/src/main.tsx` for an HMR reload). A reload wipes anything -typed in the terminal input, so do this before the final composition. +The driver prints the bot ID. Use `say ` to ask for a small, +budgeted task, or additional bots within the approved budget. `list` shows IDs +and states; `peek ` prints terminal content, so review it locally for +sensitive information before sharing it. `update` changes a bot's charter. +`delete` archives a bot: identify the exact bot and obtain deletion approval +before using it. Every command must target this run's disposable home. -Finish by pinning the lead bot (right-click its row → Pin), selecting it, and -collapsing the right-hand info panel with the toggle in the bot header — the -screenshot wants the sidebar and the terminal, nothing else. +Let the work settle naturally. Do not backdate database messages or add symlinks +inside the user's Claude transcript directory to make the scene look busy. +Pin the lead bot, select it, and collapse the info panel if that suits the shot. ## Capturing -**Two processes are named `gravity-desktop`**: the installed app and the dev -build. Targeting by name grabs whichever AppleScript finds first, which has -already meant moving and capturing the user's real window. Always target the pid. +Use available native accessibility/window tools. Identify the dev app's exact +PID and executable path from the session you launched; do not target a process +by name alone. The installed app may have the same executable name. Inspect +only the chosen window, raise it, and read its current bounds. For example, +with the verified PID in `SHOT_APP_PID`: ```bash -PID=$(pgrep -f "target/debug/gravity-desktop") -osascript -e "tell application \"System Events\" to tell (first process whose unix id is $PID) to perform action \"AXRaise\" of window 1" -osascript -e "tell application \"System Events\" to tell (first process whose unix id is $PID) to get {position, size} of window 1" -# -> e.g. 62, 55, 1580, 925 — feed those four numbers straight to -R -sleep 2 && screencapture -o -x -R 62,55,1580,925 /tmp/gravity-shot/shots/gravity-hero.png -sips -g pixelWidth -g pixelHeight /tmp/gravity-shot/shots/gravity-hero.png +osascript -e "tell application \"System Events\" to tell (first process whose unix id is $SHOT_APP_PID) to perform action \"AXRaise\" of window 1" +osascript -e "tell application \"System Events\" to tell (first process whose unix id is $SHOT_APP_PID) to get {position, size} of window 1" +# Set SHOT_BOUNDS to those four numbers as x,y,width,height, then: +screencapture -o -x -R "$SHOT_BOUNDS" "$SHOT_DIR/shots/gravity-hero.png" +sips -g pixelWidth -g pixelHeight "$SHOT_DIR/shots/gravity-hero.png" ``` -`-R` takes points and writes retina pixels, so a 1580×925 window yields -3160×1850. That is the ceiling for this display — a bigger file means a bigger -window, not a flag. Read the PNG back before calling it done: `AXRaise` sometimes -loses the race with `screencapture` and you get Conductor instead. - -## Pitfalls learned the hard way - -- **A sandboxed `$HOME` breaks Claude Code auth** — every bot answers - `Not logged in · Please run /login`. Symlinking `.claude` does not help. The - daemon keeps the real home; only the app gets a fake one. -- **`say` needs the newline as a separate write.** One combined write reads as a - paste and the Enter just adds a line; the driver already splits it. -- **`term.data` is plain UTF-8**, despite looking like it should be base64. -- Deleted bots keep their workspace directory, so a recreated `Forge` gets - `forge-2` — re-run the transcript symlink loop after any reset. -- Long `say` prompts are worth a second look in `peek`: a bot mid-turn shows a - spinner, which is how you tell "still thinking" from "never submitted". -- The bots answer honestly, including about the staging. One that had its charter - rewritten mid-session said so in its summary. Either accept it or ask for a - clean restatement before capturing. +Resolution depends on the display scale and actual window size. Inspect the +saved PNG for overlapping windows, private paths, account information and real +project data before copying it into public marketing assets. Do not claim +synthetic output is a real agent session. ## Cleanup -Tell the user what is being removed; the transcripts are in their real home. - -```bash -kill $(pgrep -f "target/debug/gravity-desktop"); pkill -f "tauri dev" -pkill -f "gravityd --config /tmp/gravity-shot" -rm -rf /tmp/gravity-shot -rm -f ~/.claude/projects/-tmp-gravity-shot-* # the symlinks -rm -rf ~/.claude/projects/-private-tmp-gravity-shot-* # the real transcripts -``` +Stop the foreground `pnpm tauri dev` with Ctrl-C. Stop only the daemon PID +recorded in this live shell (`kill "$SHOT_DAEMON_PID"; wait "$SHOT_DAEMON_PID"`). +If the shell/session was lost, verify process ownership again before signalling; +never use broad `pkill` patterns. Unset the temporary Vite token environment. -`~/.claude.json` also gains a trust entry per bot workspace. Harmless, but say so. +Retain the screenshot and run directory for review. Ask before deleting the +exact directory. Real Claude sessions may also create transcript directories +under `~/.claude/projects` and trust entries in `~/.claude.json`. Report this +residue; do not delete globbed paths or rewrite the user's configuration. diff --git a/.claude/skills/marketing-screenshot/scripts/drive.mjs b/.claude/skills/marketing-screenshot/scripts/drive.mjs index 938cd18..0105b2d 100644 --- a/.claude/skills/marketing-screenshot/scripts/drive.mjs +++ b/.claude/skills/marketing-screenshot/scripts/drive.mjs @@ -11,15 +11,21 @@ // node drive.mjs delete // // Env: GRAVITY_WS (default ws://127.0.0.1:49888/ws), -// GRAVITY_HOME (default /tmp/gravity-shot/daemon). +// GRAVITY_HOME (required; use a fresh disposable home). import { readFileSync } from "node:fs"; +import { localDaemon } from "../../lib/local-daemon.mjs"; -const URL = process.env.GRAVITY_WS ?? "ws://127.0.0.1:49888/ws"; -const HOME = process.env.GRAVITY_HOME ?? "/tmp/gravity-shot/daemon"; -const TOKEN = readFileSync(`${HOME}/secrets/client.token`, "utf8").trim(); - -const ws = new WebSocket(URL); +const daemon = localDaemon( + process.env.GRAVITY_HOME, + process.env.GRAVITY_WS ?? "ws://127.0.0.1:49888/ws", +); +const token = readFileSync(`${daemon.home}/secrets/client.token`, "utf8").trim(); +const ws = new WebSocket(daemon.endpoint); +const watchdog = setTimeout(() => { + console.error("driver timed out"); + process.exit(1); +}, 360_000); let nextReq = 1; const pending = new Map(); const states = new Map(); @@ -52,10 +58,12 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function waitReady(id, seconds = 300) { for (let i = 0; i < seconds * 2; i++) { - if (states.get(id) === "ready") return true; + if (states.get(id) === "ready") { + return true; + } await sleep(500); } - return false; + throw new Error(`bot ${id} never became ready`); } await new Promise((resolve, reject) => { @@ -65,11 +73,11 @@ await new Promise((resolve, reject) => { const hello = await request({ type: "hello", protocol_version: 2, - token: TOKEN, + token, client: "shot-driver/0", }); if (hello.type !== "hello_ok") { - throw new Error(`handshake failed: ${JSON.stringify(hello)}`); + throw new Error("handshake failed"); } const [, , mode, ...rest] = process.argv; @@ -96,7 +104,9 @@ if (mode === "list") { instructions, ...(avatar ? { avatar } : {}), }); - if (!res.bot) throw new Error(JSON.stringify(res)); + if (!res.bot) { + throw new Error("create_bot failed"); + } console.log(`${res.bot.id} ${res.bot.workspace_path}`); console.log(`ready: ${await waitReady(res.bot.id)}`); } else if (mode === "say") { @@ -109,6 +119,8 @@ if (mode === "list") { } else if (mode === "peek") { fire({ type: "attach", bot_id: rest[0] }); await sleep(3000); + // ANSI escape sequences are intentionally stripped from terminal output. + /* eslint-disable no-control-regex */ console.log( term .join("") @@ -116,6 +128,7 @@ if (mode === "list") { .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "") .replace(/\x1b\][^\x07]*\x07/g, ""), ); + /* eslint-enable no-control-regex */ } else if (mode === "update") { const res = await request({ type: "update_bot", @@ -132,5 +145,6 @@ if (mode === "list") { process.exitCode = 1; } +clearTimeout(watchdog); ws.close(); process.exit(process.exitCode ?? 0); diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 33c5b79..d3cd310 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -5,7 +5,8 @@ description: Cut a new Gravity release — pick the version, land the version-bu # Releasing Gravity -A release is a signed `v*` tag on `main`. Pushing that tag runs +A release is an annotated `v*` tag on `main`; use the configured signing +identity for official tags. Pushing that tag runs `.github/workflows/release.yml` on a GitHub-hosted macOS ARM64 runner, which builds the desktop app, packages `gravityd`, and creates the GitHub release. Signing, notarization and updater artifacts are configurable; see @@ -14,6 +15,9 @@ configuration before releasing. For an existing distribution, preserve the updater signing pair. When changing download endpoints, retain a compatibility bridge for every endpoint embedded in existing installations. Publishing `latest.json` makes configured running apps offer the update. +Verify the intended repository and existing publication authorization before +pushing a tag; +do not infer access to official signing credentials in a contributor fork. ## The rule that governs everything here @@ -30,7 +34,9 @@ Look at what landed since the last tag — that is the whole changelog: ```bash git fetch origin --tags -git log $(git describe --tags --abbrev=0)..origin/main --oneline +git describe --tags --abbrev=0 origin/main +# Use that tag as ; if no tag exists, review origin/main in full. +git log ..origin/main --oneline ``` Pre-1.0, so: **minor** for anything user-visible — a new feature, a removed @@ -44,7 +50,7 @@ Never bump straight on `main` — it goes through a PR like any other change with a What/Why/How tested description. ```bash -git checkout -b release/v0.12.0 +git switch -c release/vX.Y.Z origin/main # edit the four manifests, then sync both lockfiles: cargo update --workspace --offline (cd apps/desktop/src-tauri && cargo update --workspace --offline) @@ -54,7 +60,11 @@ cargo update --workspace --offline `@xterm/addon-web-links` dependency, not ours. Leave it. Commit as `chore(release): bump version to X.Y.Z`, open the PR against `main` -with the usual What/Why/How tested body, and wait for `checks` and `tests`. +with the usual What/Why/How tested body and link the primary issue. Before +pushing, run `pnpm run verify` (typecheck, tests, lint, builds and visual checks). +Regenerate dependency notices with `pnpm notices:generate` when versions or +lockfiles change; follow `third-party/README.md` for the pinned toolchain. +Wait for **all required PR checks**, including visual and secret scanning. **Ask before merging** unless the user already said to — merging is what makes the tag possible. @@ -65,15 +75,18 @@ is enabled: ```bash git fetch origin -git tag -a -m "Gravity 0.12.0" v0.12.0 -git push origin v0.12.0 +git tag -a -m "Gravity X.Y.Z" vX.Y.Z +git push origin vX.Y.Z ``` -Tag the actual squash-merge commit on `origin/main`, not local `HEAD`, and +Tag the actual merged commit on `origin/main`, not local `HEAD`, and re-check the manifests in that commit first: ```bash -git show :Cargo.toml | sed -n '6p' +git show :Cargo.toml +git show :apps/desktop/src-tauri/Cargo.toml +git show :apps/desktop/package.json +git show :apps/desktop/src-tauri/tauri.conf.json ``` A pushed tag uploads all assets to a draft GitHub release, then publishes it as @@ -86,7 +99,7 @@ gh run list --workflow release.yml --limit 3 gh run watch --exit-status --interval 30 ``` -Expect **~25 minutes**. `gh run watch` sometimes dies mid-stream on a transient +Build and notarization times vary. `gh run watch` may stop on a transient `HTTP 404` from the jobs API — that says nothing about the run. Fall back to polling in the background rather than assuming failure: @@ -103,7 +116,7 @@ done ## 5. Verify the publish, don't trust the green check ```bash -gh release view v0.12.0 --json name,url,assets +gh release view vX.Y.Z --json name,url,assets # Fetch the updater endpoint configured for this distribution, if enabled. ``` diff --git a/.claude/skills/terminal-perf-test/SKILL.md b/.claude/skills/terminal-perf-test/SKILL.md index e536fa3..60903e4 100644 --- a/.claude/skills/terminal-perf-test/SKILL.md +++ b/.claude/skills/terminal-perf-test/SKILL.md @@ -19,46 +19,63 @@ Why the double runtime: it echoes every `input` byte back as terminal output ## Setup -1. Build and start the daemon on a throwaway home: - - ```bash - cargo build -p gravityd - mkdir -p /tmp/gravityd-uidev - cat > /tmp/gravityd-uidev/gravityd.toml <<'EOF' - home = "/tmp/gravityd-uidev" - port = 49555 - runtime = "double" - supervision_interval_ms = 500 - EOF - ./target/debug/gravityd --config /tmp/gravityd-uidev/gravityd.toml > /tmp/gravityd-uidev/gravityd.log 2>&1 & - ``` +Use Node 22+ for the driver and the prerequisites in `CONTRIBUTING.md`. +Run from the repository root in a dedicated terminal. Verify test ports 49555 +and 1420 are free first; choose unused ports consistently if needed. - The client token appears at `/tmp/gravityd-uidev/secrets/client.token`. +```bash +umask 077 +PERF_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gravity-perf.XXXXXX") +PERF_DIR=$(cd "$PERF_DIR" && pwd -P) +export GRAVITY_HOME="$PERF_DIR" +export GRAVITY_WS=ws://127.0.0.1:49555/ws +cargo build -p gravityd +cat > "$GRAVITY_HOME/gravityd.toml" < "$PERF_DIR/gravityd.log" 2>&1 & +PERF_DAEMON_PID=$! +``` -2. Start the frontend: `cd apps/desktop && pnpm dev` (port 1420, strict). +Wait for `$GRAVITY_HOME/gravityd.port`, confirm it is 49555 and the recorded +PID is still running, then check `curl --fail http://127.0.0.1:49555/health`. +Stop on startup failure. Do not connect the driver to an installed daemon or +use it with `pty`: its generated input belongs only in the echo runtime. -3. Open `http://localhost:1420/` in Chrome using the claude-in-chrome tools - (the `chrome-devtools-axi` bridge has proven flaky for eval/snapshot — use - the extension tools). Configure the app via the javascript tool, then - reload: +Start the frontend in this shell, with shell tracing off. The dev token stays +in the local process environment; do not print it or paste it into browser +commands or chat. - ```js - localStorage.setItem('gravity.connection', JSON.stringify({host:'127.0.0.1', port:49555})); - localStorage.setItem('gravity.device-token', ''); - localStorage.setItem('gravity.setup-complete', 'true'); - location.reload(); - ``` +```bash +export VITE_GRAVITY_DEV_PORT=49555 +export VITE_GRAVITY_DEV_TOKEN="$(cat "$GRAVITY_HOME/secrets/client.token")" +(cd apps/desktop && env -u CONDUCTOR_PORT pnpm dev) +``` -4. Seed bots and data: `node .claude/skills/terminal-perf-test/scripts/drive.mjs setup` - Creates project "perf" with bots turbo1–turbo5; turbo1 gets ~2.3 MiB - (forces the trimmed non-resumable replay), the others ~300 KiB. The script - prints the bot ids — keep them for stream mode. +Open `http://localhost:1420/` with available browser automation in a fresh test +profile. Stored connection settings override these defaults: confirm the footer +shows `127.0.0.1:49555 · connected` before interacting. Never expose or deploy +the dev frontend containing this token. + +In another terminal, set `GRAVITY_HOME` to this run's exact directory and +`GRAVITY_WS` to the matching loopback URL, then seed: + +```bash +node .claude/skills/terminal-perf-test/scripts/drive.mjs setup +``` + +This creates project "perf" and turbo1–turbo5. Turbo1 exceeds the 1 MiB ring; +the others receive smaller histories. Keep the printed bot IDs for stream mode. ## Verification checklist -Run each check; all must hold. Read console errors after every phase -(`read_console_messages` with `onlyErrors`) — zero exceptions expected -throughout. +Run each check; all must hold. Read browser console errors after every phase using the available tools; +zero exceptions are expected throughout. 1. **WebGL renderer active** — in the page: ```js @@ -107,8 +124,8 @@ throughout. .observe({ entryTypes: ['longtask'] }); ``` Read afterwards: avg fps ≈ display refresh rate, `longTasks` 0, worst - frame well under 100 ms. Baseline on a 120 Hz display (2026-08): 120 fps - avg, worst 28 ms, 0 long tasks over 52 s. + frame well under 100 ms. Record the display refresh rate, hardware and elapsed time; compare with + an unchanged build on the same machine. Reload afterwards to stop the meter. 6. **Rapid-switch storm** — 25 programmatic clicks across all bots at 120 ms intervals (dispatch mousedown/mouseup/click on the sidebar rows from the @@ -129,9 +146,11 @@ throughout. ## Cleanup -```bash -pkill -f "target/debug/gravityd"; pkill -f "node.*vite" -rm -rf /tmp/gravityd-uidev -``` +Stop the foreground Vite session with Ctrl-C. Stop only the daemon started in +this live shell (`kill "$PERF_DAEMON_PID"; wait "$PERF_DAEMON_PID"`). If the +session was lost, verify process ownership again before signalling. Do not use +broad process-name matching. Unset the temporary Vite token environment. -Close the Chrome tab, and revert any temporary A/B edits. +Close the test browser profile. Retain the run directory for review and ask +before deleting its exact path. Restore only your temporary A/B edits, preserving +any pre-existing changes. diff --git a/.claude/skills/terminal-perf-test/scripts/drive.mjs b/.claude/skills/terminal-perf-test/scripts/drive.mjs index 29481c8..0fd804b 100644 --- a/.claude/skills/terminal-perf-test/scripts/drive.mjs +++ b/.claude/skills/terminal-perf-test/scripts/drive.mjs @@ -10,17 +10,22 @@ // // Environment: // GRAVITY_WS WebSocket URL (default ws://127.0.0.1:49555/ws) -// GRAVITY_TOKEN client token, or // GRAVITY_HOME daemon home dir to read secrets/client.token from -// (default /tmp/gravityd-uidev) +// (required; use a fresh disposable home) import { readFileSync } from "node:fs"; +import { localDaemon } from "../../lib/local-daemon.mjs"; -const URL = process.env.GRAVITY_WS ?? "ws://127.0.0.1:49555/ws"; -const HOME = process.env.GRAVITY_HOME ?? "/tmp/gravityd-uidev"; -const TOKEN = process.env.GRAVITY_TOKEN ?? readFileSync(`${HOME}/secrets/client.token`, "utf8").trim(); - -const ws = new WebSocket(URL); +const daemon = localDaemon( + process.env.GRAVITY_HOME, + process.env.GRAVITY_WS ?? "ws://127.0.0.1:49555/ws", +); +const token = readFileSync(`${daemon.home}/secrets/client.token`, "utf8").trim(); +const ws = new WebSocket(daemon.endpoint); +const watchdog = setTimeout(() => { + console.error("driver timed out"); + process.exit(1); +}, 360_000); let nextReq = 1; const pending = new Map(); const states = new Map(); @@ -52,7 +57,9 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function waitReady(botId) { for (let i = 0; i < 100; i++) { - if (states.get(botId) === "ready") return; + if (states.get(botId) === "ready") { + return; + } await sleep(100); } throw new Error(`bot ${botId} never became ready`); @@ -75,11 +82,13 @@ await new Promise((resolve, reject) => { }); const hello = await request({ type: "hello", - protocol_version: 1, - token: TOKEN, + protocol_version: 2, + token, client: "perf-driver/0", }); -if (hello.type !== "hello_ok") throw new Error("handshake failed: " + JSON.stringify(hello)); +if (hello.type !== "hello_ok") { + throw new Error("handshake failed"); +} const mode = process.argv[2] ?? "setup"; @@ -92,7 +101,9 @@ if (mode === "setup") { bots[name] = created.bot.id; console.log(`created ${name} = ${created.bot.id}`); } - for (const name of Object.keys(bots)) await waitReady(bots[name]); + for (const name of Object.keys(bots)) { + await waitReady(bots[name]); + } console.log("all ready"); // turbo1 gets ~2.3 MiB — well past the daemon's 1 MiB scrollback ring, so @@ -102,7 +113,9 @@ if (mode === "setup") { for (const [name, chunks] of Object.entries(sizes)) { for (let i = 0; i < chunks; i++) { fire({ type: "input", bot_id: bots[name], data: chunk(name, i) }); - if (i % 100 === 0) await sleep(20); // let the socket drain + if (i % 100 === 0) { + await sleep(20); // let the socket drain + } } console.log(`${name}: ~${Math.round((chunks * chunk(name, 0).length) / 1024)} KiB pumped`); } @@ -110,8 +123,13 @@ if (mode === "setup") { console.log(JSON.stringify(bots)); } else if (mode === "stream") { const botId = process.argv[3]; - if (!botId) throw new Error("stream mode needs a bot id"); + if (!botId) { + throw new Error("stream mode needs a bot id"); + } const seconds = Number(process.argv[4] ?? 15); + if (!Number.isFinite(seconds) || seconds <= 0 || seconds > 300) { + throw new Error("stream duration must be between 0 and 300 seconds"); + } let i = 0; const end = Date.now() + seconds * 1000; while (Date.now() < end) { @@ -119,6 +137,9 @@ if (mode === "setup") { await sleep(25); } console.log(`streamed ${i} chunks`); +} else { + throw new Error("unknown mode; use setup or stream"); } +clearTimeout(watchdog); ws.close(); process.exit(0); diff --git a/.claude/skills/visual-regression/SKILL.md b/.claude/skills/visual-regression/SKILL.md index 3b23978..959bf26 100644 --- a/.claude/skills/visual-regression/SKILL.md +++ b/.claude/skills/visual-regression/SKILL.md @@ -11,7 +11,7 @@ byte against a committed baseline in ## The rule that governs everything here -**Never generate a baseline locally.** They are only reproducible inside the +**Adopt baselines from the pinned CI environment.** They are only reproducible inside the pinned Playwright container that CI uses (`mcr.microsoft.com/playwright` — the tag lives in `scripts/vr-ci.sh`). A macOS or bare-Linux checkout renders different glyphs and antialiasing, so a locally produced PNG is guaranteed to @@ -23,6 +23,12 @@ failure rather than a silent write. Do not work around either guard. ## Adopting new baselines +Before adopting, verify the repository, branch and exact run commit. Inspect +`git status --short -- apps/desktop/tests/visual/__screenshots__` for existing +edits and untracked files. Do not overwrite them. The acceptance script +replaces the entire directory: identify that target, preserve local work, and +obtain approval for replacement if it is not already authorized. + When `visual` fails, the job regenerates the complete baseline set inside the container and uploads it as the `visual-snapshots` artifact. Adopt it with: @@ -48,7 +54,8 @@ different tree. at what actually changed: ```bash -gh run download --name visual-report --dir /tmp/vr-report +VR_REPORT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gravity-visual.XXXXXX") +gh run download --name visual-report --dir "$VR_REPORT_DIR" ``` The Playwright HTML report has expected/actual/diff for every failure. Read diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index cd78e49..f7d5f41 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -38,6 +38,10 @@ jobs: - name: oxfmt run: pnpm format:check + - name: Agent skill safety checks + working-directory: . + run: node --test .claude/skills/lib/*.test.mjs + - name: Dependency notices working-directory: . run: | diff --git a/scripts/verify.sh b/scripts/verify.sh index fd1e91a..66e4194 100644 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -4,6 +4,7 @@ set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." +node --test .claude/skills/lib/*.test.mjs pnpm notices:check python3 -m unittest discover -s scripts -p 'test_notices.py' cargo fmt --all --check