From ee313139db665657c018185565ec16828b028a8b Mon Sep 17 00:00:00 2001 From: Pawel Mikolajczuk Date: Tue, 8 Sep 2026 14:27:41 +1000 Subject: [PATCH] fix(skills): make contributor workflows safe and portable Replace shared temporary paths and broad cleanup with isolated sessions. Keep local tokens out of process arguments, validate driver endpoints, and update release verification guidance. --- .claude/skills/README.md | 28 +++ .claude/skills/bus-live-test/SKILL.md | 65 +++--- .claude/skills/bus-live-test/scripts/bus.mjs | 75 +++++-- .claude/skills/lib/local-daemon.mjs | 47 ++++ .claude/skills/lib/local-daemon.test.mjs | 28 +++ .claude/skills/marketing-screenshot/SKILL.md | 200 ++++++++---------- .../marketing-screenshot/scripts/drive.mjs | 36 +++- .claude/skills/release/SKILL.md | 33 ++- .claude/skills/terminal-perf-test/SKILL.md | 97 +++++---- .../terminal-perf-test/scripts/drive.mjs | 49 +++-- .claude/skills/visual-regression/SKILL.md | 11 +- .github/workflows/checks.yml | 4 + scripts/verify.sh | 1 + 13 files changed, 440 insertions(+), 234 deletions(-) create mode 100644 .claude/skills/README.md create mode 100644 .claude/skills/lib/local-daemon.mjs create mode 100644 .claude/skills/lib/local-daemon.test.mjs 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