diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
new file mode 100644
index 0000000..3f69fe0
--- /dev/null
+++ b/.claude-plugin/marketplace.json
@@ -0,0 +1,35 @@
+{
+ "name": "browsergnome",
+ "description": "The autonomous JS web performance engineer, as a self-installable Claude Code plugin marketplace.",
+ "owner": {
+ "name": "Xavier",
+ "email": "xavi-999@users.noreply.github.com"
+ },
+ "plugins": [
+ {
+ "name": "browsergnome",
+ "source": "./",
+ "version": "0.2.1",
+ "description": "The autonomous JS web performance engineer — one propose -> measure -> keep/revert loop over chrome-devtools-mcp, with an LCP Attribution Map and a per-repo performance memory.",
+ "author": {
+ "name": "Xavier",
+ "email": "xavi-999@users.noreply.github.com"
+ },
+ "license": "MIT",
+ "keywords": [
+ "web",
+ "performance",
+ "chrome-devtools",
+ "lcp",
+ "claude-code",
+ "plugin"
+ ],
+ "category": "performance",
+ "tags": [
+ "web",
+ "performance",
+ "mcp"
+ ]
+ }
+ ]
+}
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
new file mode 100644
index 0000000..ff560b8
--- /dev/null
+++ b/.claude-plugin/plugin.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
+ "name": "browsergnome",
+ "version": "0.2.1",
+ "description": "The autonomous JS web performance engineer. Orchestrates chrome-devtools-mcp into one propose → measure → keep/revert loop — with an LCP Attribution Map, a live HTML run report, configurable commit mode, and per-repo performance memory.",
+ "author": {
+ "name": "Xavier",
+ "email": "xavi-999@users.noreply.github.com"
+ },
+ "license": "MIT",
+ "keywords": [
+ "claude-code",
+ "chrome-devtools",
+ "web-vitals",
+ "lcp",
+ "performance",
+ "plugin"
+ ]
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..bc48564
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,87 @@
+# CI covers only the offline chain:
+# - all self-tests (stats / playbook / doctor / trace / bundle / lcp-attribution)
+# - offline smoke chain: lcp_attribution → build_lcp_map, build_run_report from the sample fixture
+# - JSON validation of all plugin manifests
+# - official plugin manifest validation (claude plugin validate --strict)
+#
+# The live path (chrome-devtools-mcp / real browser / MCP runtime) cannot be CI-tested
+# because it requires a running Chrome instance and an active Claude Code session.
+
+name: CI
+
+on:
+ pull_request:
+ branches: [master]
+ push:
+ branches: [master]
+
+jobs:
+ ci:
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ node-version: [18.x, 20.x]
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: All self-tests
+ run: npm test
+
+ - name: Offline smoke chain — lcp_attribution
+ run: node skills/browsergnome/scripts/lcp_attribution.mjs skills/browsergnome/assets/trace.render-blocking-sample.json.gz > /tmp/attribution.json
+
+ - name: Offline smoke chain — build_lcp_map
+ run: node skills/browsergnome/scripts/build_lcp_map.mjs /tmp/attribution.json --out /tmp/lcp-map.html
+
+ - name: Offline smoke chain — build_run_report
+ run: node skills/browsergnome/scripts/build_run_report.mjs skills/browsergnome/assets/run-state.sample.json --out /tmp/report.html
+
+ # Assert each output file exists and is non-empty.
+ - name: Assert smoke-chain outputs are non-empty
+ run: |
+ test -s /tmp/attribution.json || (echo "attribution.json is missing or empty" && exit 1)
+ test -s /tmp/lcp-map.html || (echo "lcp-map.html is missing or empty" && exit 1)
+ test -s /tmp/report.html || (echo "report.html is missing or empty" && exit 1)
+
+ - name: Validate JSON files
+ run: |
+ node -e "
+ const fs = require('fs');
+ const files = [
+ '.claude-plugin/plugin.json',
+ '.claude-plugin/marketplace.json',
+ '.mcp.json',
+ 'package.json',
+ 'skills/browsergnome/assets/run-state.sample.json',
+ ];
+ let ok = true;
+ for (const f of files) {
+ try {
+ JSON.parse(fs.readFileSync(f, 'utf8'));
+ console.log(' ok ' + f);
+ } catch (e) {
+ console.error('FAIL ' + f + ': ' + e.message);
+ ok = false;
+ }
+ }
+ if (!ok) process.exit(1);
+ "
+
+ # Static manifest validation via the official Claude Code CLI.
+ # No auth needed — `plugin validate` is offline. --strict treats
+ # warnings (e.g. a missing marketplace description) as errors.
+ - name: Official plugin validation (strict)
+ run: |
+ npm install -g @anthropic-ai/claude-code@2.1.226
+ claude plugin validate . --strict
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..195c4c9
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,133 @@
+# GitHub Pages live demo.
+#
+# Builds the public demo at https://xavi-999.github.io/browsergnome/ :
+# - perf-map.html — a REAL static scan of excalidraw at a pinned SHA (the same commit
+# recorded in references/perf-map.md's calibration note — 527 modules, 16 hotspots).
+# - lcp-map.html — a REAL LCP attribution from the shipped trace fixture
+# (skills/browsergnome/assets/trace.render-blocking-sample.json.gz, a real nextjs.org capture).
+# - report.html — the live run report rendered from the bundled sample run-state
+# (skills/browsergnome/assets/run-state.sample.json).
+# - index.html — landing page (docs/pages/index.html) with real substituted stats.
+#
+# No generated HTML is committed to master — everything is built here and deployed as a
+# Pages artifact.
+#
+# Runs on every push to master, plus manual dispatch. On a free-tier private repo the deploy
+# step will fail (GitHub Pages requires a public repo, or a paid plan) — that's expected until
+# the repo goes public and Pages is enabled once in Settings > Pages. No other change needed
+# here after that; this workflow is otherwise ready to go.
+
+name: Pages
+
+on:
+ push:
+ branches: [master]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+env:
+ # excalidraw main, resolved 2026-08-08 — the calibration commit in references/perf-map.md.
+ # Bump deliberately; re-verify hotspot count against the calibration note if you do.
+ EXCALIDRAW_SHA: 4872083c044491b6d5c96ae134a75464f96d6831
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js 20
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Fetch excalidraw at pinned SHA
+ run: |
+ git init excalidraw
+ git -C excalidraw remote add origin https://github.com/excalidraw/excalidraw
+ git -C excalidraw fetch --depth 1 origin "$EXCALIDRAW_SHA"
+ git -C excalidraw checkout FETCH_HEAD
+
+ # Alias resolution is make-or-break for fan-in/centrality (13 @excalidraw/* aliases).
+ # If the scan prints "aliases (none found)" the graph is meaningless — fail loudly.
+ - name: Scan excalidraw (perf_scan)
+ run: |
+ set -o pipefail
+ node skills/browsergnome/scripts/perf_scan.mjs excalidraw --out /tmp/graph.json | tee /tmp/scan.log
+ if grep -qE 'aliases +\(none found\)' /tmp/scan.log; then
+ echo "::error::perf_scan resolved no import aliases on excalidraw — graph would be meaninglessly sparse"
+ exit 1
+ fi
+
+ - name: Build perf map
+ run: |
+ mkdir -p _site
+ node skills/browsergnome/scripts/build_perf_map.mjs /tmp/graph.json --out _site/perf-map.html
+
+ - name: Build LCP attribution map (shipped fixture)
+ run: |
+ node skills/browsergnome/scripts/lcp_attribution.mjs skills/browsergnome/assets/trace.render-blocking-sample.json.gz > /tmp/attribution.json
+ node skills/browsergnome/scripts/build_lcp_map.mjs /tmp/attribution.json --out _site/lcp-map.html
+
+ - name: Build run report (sample run-state)
+ run: node skills/browsergnome/scripts/build_run_report.mjs skills/browsergnome/assets/run-state.sample.json --out _site/report.html
+
+ - name: Assemble landing page
+ run: |
+ cp docs/pages/index.html _site/
+ cp docs/logo-badge.svg _site/
+ # Pull the hero numbers from the real scan and the real trace — the page never
+ # claims a figure the build didn't measure.
+ node -e "
+ const g = JSON.parse(require('fs').readFileSync('/tmp/graph.json', 'utf8'));
+ const attr = JSON.parse(require('fs').readFileSync('/tmp/attribution.json', 'utf8'));
+ const fmt = (n) => n.toLocaleString('en-US');
+ const out = {
+ MODULES: fmt(g.nodes.length),
+ HOTSPOTS: fmt(g.nodes.filter((n) => n.isHotspot).length),
+ FINDINGS: fmt(g.nodes.reduce((s, n) => s + (n.findings?.length || 0), 0)),
+ LCP_MS: attr.lcpMs != null ? Math.round(attr.lcpMs) : '?',
+ };
+ require('fs').writeFileSync('/tmp/stats.env', Object.entries(out).map(([k, v]) => k + '=' + v).join('\n'));
+ "
+ . /tmp/stats.env
+ sed -i \
+ -e "s/__EXCALIDRAW_SHA__/${EXCALIDRAW_SHA:0:8}/g" \
+ -e "s/__BUILD_DATE__/$(date -u +%Y-%m-%d)/g" \
+ -e "s/__STAT_MODULES__/${MODULES}/g" \
+ -e "s/__STAT_HOTSPOTS__/${HOTSPOTS}/g" \
+ -e "s/__STAT_FINDINGS__/${FINDINGS}/g" \
+ -e "s/__LCP_MS__/${LCP_MS}/g" \
+ _site/index.html
+ # nothing un-templated left behind
+ if grep -q '__' _site/index.html; then
+ echo "::error::unsubstituted placeholder left in index.html" && exit 1
+ fi
+
+ - name: Upload Pages artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: _site
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4d1972c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+node_modules/
+.DS_Store
+*.log
+
+# Runtime artifacts produced against a target web app (these live in the
+# target repo's own .bgn/, never in the plugin). Ignored here so a test run
+# inside this repo doesn't pollute it.
+.bgn/
+graph.json
+perf-map.html
+lcp-map.html
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 0000000..5f9b864
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "chrome-devtools": {
+ "command": "npx",
+ "args": ["chrome-devtools-mcp@1.6.0"]
+ }
+ }
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..5f8e4d2
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,72 @@
+# AGENTS.md
+
+Vendor-neutral instructions for coding agents working in this repo. Claude Code should read
+`CLAUDE.md` instead — it has the same facts plus Claude-specific wiring (skills, hooks, MCP setup).
+
+## What this repo is
+
+browsergnome is a **Claude Code plugin**, not an application. Most of its behavior is Markdown read at
+runtime by an LLM agent; the `.mjs` files are the deterministic, offline half.
+
+- `skills/browsergnome/SKILL.md` — the orchestrator spec: menu, propose→measure→keep/revert loop, gate
+ math, presets, `.bgn/config.json` defaults, reference index.
+- `skills/browsergnome/references/` — read-on-demand context: `tools.md`, `presets.md`,
+ `measurement.md`, `perf-map.md`, `senior-audit.md`, `what-if.md`, `knowledge/`, and three stack-axis
+ catalogs (`frameworks/`, `bundlers/`, `hosts/`).
+- `skills/browsergnome/scripts/*.mjs` — pure, offline, self-testing tools. No framework, no runtime
+ browser dependency, no shared util module. Each script is standalone except one ESM import edge
+ (`lcp_attribution.mjs` imports parser functions from `trace_metrics.mjs`/`bundle_stats.mjs`, guarded
+ by a `pathToFileURL(process.argv[1])` check so importing doesn't also fire the CLI).
+- `commands/`, `hooks/hooks.json`, `.mcp.json`, `.claude-plugin/` — Claude Code plugin wiring; not
+ relevant to a non-Claude agent working on the `.mjs` scripts or docs.
+- `templates/ci/` — GitHub Actions templates for running Autoresearch unattended in a *target* repo.
+
+**Two-repo model.** At runtime the working directory is the *target* web repo being optimized, not
+this one. Scripts resolve against `$CLAUDE_PLUGIN_ROOT`, and `.bgn/` (config, ledger, perf-memory,
+playbook) is written into the **target** repo, never this one.
+
+## Commands
+
+No build step, no linter, no test framework beyond the scripts' own self-tests.
+
+```bash
+npm install # @babel/parser + @babel/traverse, needed only by perf_scan.mjs
+npm test # all six self-tests, chained
+node skills/browsergnome/scripts/stats.mjs --self-test # a single self-test, directly
+```
+
+CI (`.github/workflows/ci.yml`) runs `npm test` plus an offline smoke chain (`lcp_attribution.mjs` →
+`build_lcp_map.mjs`, `build_run_report.mjs`) and `claude plugin validate . --strict`.
+
+## Cross-file invariants
+
+These have no single source of truth; an edit to one side silently breaks the other.
+
+- **chrome-devtools-mcp pin** appears in `.mcp.json`, `references/tools.md`, and `doctor.mjs`'s
+ self-test fixtures. Bump all three together; never use `@latest`.
+- **Stack-catalog filenames** under `references/frameworks|bundlers|hosts/` must match
+ `doctor.mjs`'s detector return strings verbatim.
+- **All product-facing docs** (`README.md`, `docs/pages/`, `SKILL.md`, `references/`) read as one
+ confident, unhedged voice — no validation-status narration, no dev-diary voice, no local-machine
+ specifics. Facts stay accurate; nothing editorializes about whether something was confirmed to work.
+- **Perf Map calibration** — `.github/workflows/pages.yml`'s `EXCALIDRAW_SHA` is pinned to the commit
+ `references/perf-map.md`'s calibration note (527 modules / 16 hotspots) was measured against.
+
+## Project rules
+
+- **Don't claim something works that doesn't.** `/bisect` is not built — say so plainly, don't guess
+ its behavior. State what's built and what isn't as plain fact, same unhedged voice as the docs.
+- **Never tune `perf_scan.mjs`'s `CONFIG` against a fixture** — it's circular by construction. Use a
+ real cloned OSS app.
+- **Never use `lighthouse_audit` for the gate** — it excludes performance. Route measurement through
+ `performance_start_trace`.
+- **Never `git add -A`** in the propose→measure→gate loop; stage only the touched paths.
+- **Knowledge-base status tiers** (`proven` / `documented` / `ungated hypothesis` / `dead end`) in
+ `references/knowledge/` require a real measured before/after to upgrade. A citation is not a
+ measurement.
+- Node ≥ 18, ESM throughout. The only runtime deps are `@babel/parser` + `@babel/traverse`.
+
+## Full detail
+
+`CLAUDE.md` in this same directory carries everything above plus the script pipeline diagram and
+Claude-Code-specific wiring (skills, hooks, MCP). Read it for anything this file doesn't cover.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..ca03e09
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,140 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## What this repo is
+
+browsergnome is a **Claude Code plugin**, not an application. Most of its behavior is Markdown that
+Claude reads at runtime; the `.mjs` files are the deterministic, offline half.
+
+- `skills/browsergnome/SKILL.md` — the orchestrator. Menu, propose→measure→keep/revert loop, gate
+ math, presets, `.bgn/config.json` defaults, reference index. **Read it before changing any runtime
+ behavior**; it is the spec, this file is not a summary of it.
+- `skills/browsergnome/references/` — read-on-demand context (~30 files: `tools.md`, `presets.md`,
+ `measurement.md`, `perf-map.md`, `senior-audit.md`, `what-if.md`, `knowledge/`, and three
+ stack-axis catalogs `frameworks/` `bundlers/` `hosts/`).
+- `skills/browsergnome/scripts/*.mjs` — pure, offline, self-testing tools. No framework, no runtime
+ browser dependency, no shared util module — each script is standalone except one ESM import edge
+ (`lcp_attribution.mjs` imports parser functions from `trace_metrics.mjs`/`bundle_stats.mjs`, guarded
+ by a `pathToFileURL(process.argv[1])` check so importing doesn't also fire the CLI).
+- `commands/` (`/browsergnome`, `/what-if`), `hooks/hooks.json`, `.mcp.json`,
+ `.claude-plugin/{plugin,marketplace}.json` — plugin wiring.
+- `templates/ci/` — two GitHub Actions templates (build-only, browser) for running Autoresearch
+ unattended in a *target* repo, with their own adoption README.
+
+**Two-repo model.** At runtime the cwd is the *target* web repo being optimized, not this one.
+Scripts resolve against `$CLAUDE_PLUGIN_ROOT`, and `.bgn/` (config, ledger, perf-memory, playbook) is
+written into the **target** repo. `.gitignore` here ignores `.bgn/`, `graph.json`, `perf-map.html`,
+`lcp-map.html` so a dev run inside this repo doesn't pollute it.
+
+## Commands
+
+No build step, no linter, no test framework.
+
+```bash
+npm install # @babel/parser + @babel/traverse, needed only by perf_scan.mjs
+npm test # the six self-tests, chained
+npm run stats:test # a single self-test (also: playbook:test doctor:test trace:test
+ # bundle:test lcp-attribution:test)
+node skills/browsergnome/scripts/stats.mjs --self-test # same thing, directly
+```
+
+Each testable script carries its own `selfTest()` with a local pass/fail-tally helper (`check()` in
+most; `build_playbook.mjs` names it `ok()`) behind `--self-test`, `process.exit(fail ? 1 : 0)`, no
+shared assert module. `trace_metrics.mjs`/`bundle_stats.mjs`/`lcp_attribution.mjs` assert against real
+gzipped captures in `assets/`; `stats.mjs`/`doctor.mjs`/`build_playbook.mjs` test pure functions with
+inline fixtures. Adding a test means extending the relevant `selfTest()`, not adding a file. Keep
+`package.json`'s `test` script in sync when a new `*:test` is added.
+
+CI (`.github/workflows/ci.yml`, Node 18 + 20) runs `npm test` plus two things `npm test` does not:
+
+```bash
+# offline smoke chain
+node skills/browsergnome/scripts/lcp_attribution.mjs skills/browsergnome/assets/trace.render-blocking-sample.json.gz > /tmp/attribution.json
+node skills/browsergnome/scripts/build_lcp_map.mjs /tmp/attribution.json --out /tmp/lcp-map.html
+node skills/browsergnome/scripts/build_run_report.mjs skills/browsergnome/assets/run-state.sample.json --out /tmp/report.html
+
+claude plugin validate . --strict # manifest validation; offline, no auth
+```
+
+## Script pipeline
+
+```
+target repo ──perf_scan.mjs──> graph.json ──build_perf_map.mjs──> perf-map.html
+
+trace.json.gz ──┬─ (imported) ─┐
+ │ ├──lcp_attribution.mjs──(stdout)──> attribution.json ──build_lcp_map.mjs──> lcp-map.html
+bundle stats ───┴─ (imported) ─┘
+
+trace.json.gz ──trace_metrics.mjs──(stdout)──> {lcp,fcp,ttfb,cls,inp,tbt,...} (agent feeds N values into stats.mjs by hand)
+bundler stats ──bundle_stats.mjs───(stdout)──> {bundler,totalBytes,chunks:[...]}
+
+run-state.json ──build_run_report.mjs──> report.html
+.bgn/ledger/*.md + assets/playbook.seed.json ──build_playbook.mjs──> .bgn/playbook.{md,json}
+stats.mjs gate arbiter (argv numbers in, JSON out): keep ⇔ improvement > max(minEffect, k·pooledStdDev)
+doctor.mjs preflight: MCP pin check, stack detection, git state, `--init` bootstraps .bgn/
+```
+
+**Not every arrow above is a file handoff** — `stats.mjs` only takes comma-separated numbers on argv,
+so `trace_metrics.mjs → stats.mjs` and `KEEP/REVERT → .bgn/ledger/*.md` are agent-mediated: no script
+writes a ledger entry or `run-state.json`, the agent does, from `assets/ledger.template.md` /
+`assets/run-state.sample.json`'s shape (`SKILL.md` steps 2 and 9).
+
+Two conventions hold across the real file/stdout handoffs:
+
+- **CLI guard.** `trace_metrics.mjs` and `bundle_stats.mjs` are imported by `lcp_attribution.mjs`,
+ so their CLI is gated on `import.meta.url === pathToFileURL(process.argv[1]).href`. Preserve it on
+ any script that becomes importable. `perf_scan.mjs` has zero exports — CLI-only by design.
+- **Marker injection.** `build_perf_map` / `build_lcp_map` / `build_run_report` produce standalone
+ HTML by `split().join()` on comment markers (`/*__GRAPH_DATA__*/`, `/*__LCP_DATA__*/`,
+ `/*__RUN_DATA__*/`, `/*__FORCE_GRAPH_LIB__*/`) in `assets/*.template.html`, deliberately not regex
+ (the 1.3 MB vendored `assets/3d-force-graph.min.js` could match one). Renaming a marker without
+ updating its builder fails loudly — keep it that way. Only `build_run_report.mjs` escapes
+ `` in injected data; the other two inject raw JSON/text into a `
+
+
+
+