diff --git a/.claude/skills/gstack/.env.example b/.claude/skills/gstack/.env.example new file mode 100644 index 0000000..04c8f01 --- /dev/null +++ b/.claude/skills/gstack/.env.example @@ -0,0 +1,5 @@ +# Copy to .env and fill in values +# bun auto-loads .env — no dotenv needed + +# Required for LLM-as-judge evals (bun run test:eval) +ANTHROPIC_API_KEY=sk-ant-your-key-here diff --git a/.claude/skills/gstack/.github/actionlint.yaml b/.claude/skills/gstack/.github/actionlint.yaml new file mode 100644 index 0000000..cdd601c --- /dev/null +++ b/.claude/skills/gstack/.github/actionlint.yaml @@ -0,0 +1,4 @@ +self-hosted-runner: + labels: + - ubicloud-standard-2 + - ubicloud-standard-8 diff --git a/.claude/skills/gstack/.github/docker/Dockerfile.ci b/.claude/skills/gstack/.github/docker/Dockerfile.ci new file mode 100644 index 0000000..1bb0ffb --- /dev/null +++ b/.claude/skills/gstack/.github/docker/Dockerfile.ci @@ -0,0 +1,63 @@ +# gstack CI eval runner — pre-baked toolchain + deps +# Rebuild weekly via ci-image.yml, on Dockerfile changes, or on lockfile changes +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# System deps +RUN apt-get update && apt-get install -y --no-install-recommends \ + git curl unzip ca-certificates jq bc gpg \ + && rm -rf /var/lib/apt/lists/* + +# GitHub CLI +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && apt-get update && apt-get install -y --no-install-recommends gh \ + && rm -rf /var/lib/apt/lists/* + +# Node.js 22 LTS (needed for claude CLI) +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Bun (install to /usr/local so non-root users can access it) +ENV BUN_INSTALL="/usr/local" +RUN curl -fsSL https://bun.sh/install | bash + +# Claude CLI +RUN npm i -g @anthropic-ai/claude-code + +# Playwright system deps (Chromium) — needed for browse E2E tests +RUN npx playwright install-deps chromium + +# Pre-install dependencies (cached layer — only rebuilds when package.json changes) +COPY package.json /workspace/ +WORKDIR /workspace +RUN bun install && rm -rf /tmp/* + +# Install Playwright Chromium to a shared location accessible by all users +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers +RUN npx playwright install chromium \ + && chmod -R a+rX /opt/playwright-browsers + +# Verify everything works +RUN bun --version && node --version && claude --version && jq --version && gh --version \ + && npx playwright --version + +# At runtime: checkout overwrites /workspace, but node_modules persists +# if we move it out of the way and symlink back +# Save node_modules + package.json snapshot for cache validation at runtime +RUN mv /workspace/node_modules /opt/node_modules_cache \ + && cp /workspace/package.json /opt/node_modules_cache/.package.json + +# Claude CLI refuses --dangerously-skip-permissions as root. +# Create a non-root user for eval runs (GH Actions overrides USER, so +# the workflow must set options.user or use gosu/su-exec at runtime). +RUN useradd -m -s /bin/bash runner \ + && chmod -R a+rX /opt/node_modules_cache \ + && mkdir -p /home/runner/.gstack && chown -R runner:runner /home/runner/.gstack \ + && chmod 1777 /tmp \ + && mkdir -p /home/runner/.bun && chown -R runner:runner /home/runner/.bun \ + && chmod -R 1777 /tmp diff --git a/.claude/skills/gstack/.github/workflows/actionlint.yml b/.claude/skills/gstack/.github/workflows/actionlint.yml new file mode 100644 index 0000000..32ae448 --- /dev/null +++ b/.claude/skills/gstack/.github/workflows/actionlint.yml @@ -0,0 +1,8 @@ +name: Workflow Lint +on: [push, pull_request] +jobs: + actionlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: rhysd/actionlint@v1.7.11 diff --git a/.claude/skills/gstack/.github/workflows/ci-image.yml b/.claude/skills/gstack/.github/workflows/ci-image.yml new file mode 100644 index 0000000..00d3863 --- /dev/null +++ b/.claude/skills/gstack/.github/workflows/ci-image.yml @@ -0,0 +1,40 @@ +name: Build CI Image +on: + # Rebuild weekly (Monday 6am UTC) to pick up CLI updates + schedule: + - cron: '0 6 * * 1' + # Rebuild on Dockerfile or lockfile changes + push: + branches: [main] + paths: + - '.github/docker/Dockerfile.ci' + - 'package.json' + # Manual trigger + workflow_dispatch: + +jobs: + build: + runs-on: ubicloud-standard-2 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + # Copy lockfile + package.json into Docker build context + - run: cp package.json .github/docker/ + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: .github/docker + file: .github/docker/Dockerfile.ci + push: true + tags: | + ghcr.io/${{ github.repository }}/ci:latest + ghcr.io/${{ github.repository }}/ci:${{ github.sha }} diff --git a/.claude/skills/gstack/.github/workflows/evals.yml b/.claude/skills/gstack/.github/workflows/evals.yml new file mode 100644 index 0000000..caa6f82 --- /dev/null +++ b/.claude/skills/gstack/.github/workflows/evals.yml @@ -0,0 +1,242 @@ +name: E2E Evals +on: + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: evals-${{ github.head_ref }} + cancel-in-progress: true + +env: + IMAGE: ghcr.io/${{ github.repository }}/ci + +jobs: + # Build Docker image with pre-baked toolchain (cached — only rebuilds on Dockerfile/lockfile change) + build-image: + runs-on: ubicloud-standard-2 + permissions: + contents: read + packages: write + outputs: + image-tag: ${{ steps.meta.outputs.tag }} + steps: + - uses: actions/checkout@v4 + + - id: meta + run: echo "tag=${{ env.IMAGE }}:${{ hashFiles('.github/docker/Dockerfile.ci', 'package.json') }}" >> "$GITHUB_OUTPUT" + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Check if image exists + id: check + run: | + if docker manifest inspect ${{ steps.meta.outputs.tag }} > /dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - if: steps.check.outputs.exists == 'false' + run: cp package.json .github/docker/ + + - if: steps.check.outputs.exists == 'false' + uses: docker/build-push-action@v6 + with: + context: .github/docker + file: .github/docker/Dockerfile.ci + push: true + tags: | + ${{ steps.meta.outputs.tag }} + ${{ env.IMAGE }}:latest + + evals: + runs-on: ${{ matrix.suite.runner || 'ubicloud-standard-2' }} + needs: build-image + container: + image: ${{ needs.build-image.outputs.image-tag }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + options: --user runner + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + suite: + - name: llm-judge + file: test/skill-llm-eval.test.ts + - name: e2e-browse + file: test/skill-e2e-bws.test.ts + runner: ubicloud-standard-8 + - name: e2e-plan + file: test/skill-e2e-plan.test.ts + - name: e2e-deploy + file: test/skill-e2e-deploy.test.ts + - name: e2e-design + file: test/skill-e2e-design.test.ts + - name: e2e-qa-bugs + file: test/skill-e2e-qa-bugs.test.ts + - name: e2e-qa-workflow + file: test/skill-e2e-qa-workflow.test.ts + - name: e2e-review + file: test/skill-e2e-review.test.ts + - name: e2e-workflow + file: test/skill-e2e-workflow.test.ts + allow_failure: true # /ship + /setup-browser-cookies are env-dependent + - name: e2e-routing + file: test/skill-routing-e2e.test.ts + allow_failure: true # LLM routing is non-deterministic + - name: e2e-codex + file: test/codex-e2e.test.ts + - name: e2e-gemini + file: test/gemini-e2e.test.ts + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Bun creates root-owned temp dirs during Docker build. GH Actions runs as + # runner user with HOME=/github/home. Redirect bun's cache to a writable dir. + - name: Fix bun temp + run: | + mkdir -p /home/runner/.cache/bun + { + echo "BUN_INSTALL_CACHE_DIR=/home/runner/.cache/bun" + echo "BUN_TMPDIR=/home/runner/.cache/bun" + echo "TMPDIR=/home/runner/.cache" + } >> "$GITHUB_ENV" + + # Restore pre-installed node_modules from Docker image via symlink (~0s vs ~15s install) + - name: Restore deps + run: | + if [ -d /opt/node_modules_cache ] && diff -q /opt/node_modules_cache/.package.json package.json >/dev/null 2>&1; then + ln -s /opt/node_modules_cache node_modules + else + bun install + fi + + - run: bun run build + + # Verify Playwright can launch Chromium (fails fast if sandbox/deps are broken) + - name: Verify Chromium + if: matrix.suite.name == 'e2e-browse' + run: | + echo "whoami=$(whoami) HOME=$HOME TMPDIR=${TMPDIR:-unset}" + touch /tmp/.bun-test && rm /tmp/.bun-test && echo "/tmp writable" + bun -e "import {chromium} from 'playwright';const b=await chromium.launch({args:['--no-sandbox']});console.log('Chromium OK');await b.close()" + + - name: Run ${{ matrix.suite.name }} + continue-on-error: ${{ matrix.suite.allow_failure || false }} + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + EVALS_CONCURRENCY: "40" + PLAYWRIGHT_BROWSERS_PATH: /opt/playwright-browsers + run: EVALS=1 bun test --retry 2 --concurrent --max-concurrency 40 ${{ matrix.suite.file }} + + - name: Upload eval results + if: always() + uses: actions/upload-artifact@v4 + with: + name: eval-${{ matrix.suite.name }} + path: ~/.gstack-dev/evals/*.json + retention-days: 90 + + report: + runs-on: ubicloud-standard-2 + needs: evals + if: always() && github.event_name == 'pull_request' + timeout-minutes: 5 + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Download all eval artifacts + uses: actions/download-artifact@v4 + with: + pattern: eval-* + path: /tmp/eval-results + merge-multiple: true + + - name: Post PR comment + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # shellcheck disable=SC2086,SC2059 + RESULTS=$(find /tmp/eval-results -name '*.json' 2>/dev/null | sort) + if [ -z "$RESULTS" ]; then + echo "No eval results found" + exit 0 + fi + + TOTAL=0; PASSED=0; FAILED=0; COST="0" + SUITE_LINES="" + for f in $RESULTS; do + if ! jq -e '.total_tests' "$f" >/dev/null 2>&1; then + echo "Skipping malformed JSON: $f" + continue + fi + T=$(jq -r '.total_tests // 0' "$f") + P=$(jq -r '.passed // 0' "$f") + F=$(jq -r '.failed // 0' "$f") + C=$(jq -r '.total_cost_usd // 0' "$f") + TIER=$(jq -r '.tier // "unknown"' "$f") + [ "$T" -eq 0 ] && continue + TOTAL=$((TOTAL + T)) + PASSED=$((PASSED + P)) + FAILED=$((FAILED + F)) + COST=$(echo "$COST + $C" | bc) + STATUS_ICON="✅" + [ "$F" -gt 0 ] && STATUS_ICON="❌" + SUITE_LINES="${SUITE_LINES}| ${TIER} | ${P}/${T} | ${STATUS_ICON} | \$${C} |\n" + done + + STATUS="✅ PASS" + [ "$FAILED" -gt 0 ] && STATUS="❌ FAIL" + + BODY="## E2E Evals: ${STATUS} + + **${PASSED}/${TOTAL}** tests passed | **\$${COST}** total cost | **12 parallel runners** + + | Suite | Result | Status | Cost | + |-------|--------|--------|------| + $(echo -e "$SUITE_LINES") + + --- + *12x ubicloud-standard-2 (Docker: pre-baked toolchain + deps) | wall clock ≈ slowest suite*" + + if [ "$FAILED" -gt 0 ]; then + FAILURES="" + for f in $RESULTS; do + if ! jq -e '.failed' "$f" >/dev/null 2>&1; then continue; fi + F=$(jq -r '.failed // 0' "$f") + [ "$F" -eq 0 ] && continue + FAILS=$(jq -r '.tests[] | select(.passed == false) | "- ❌ \(.name): \(.exit_reason // "unknown")"' "$f" 2>/dev/null || echo "- ⚠️ $(basename "$f"): parse error") + FAILURES="${FAILURES}${FAILS}\n" + done + BODY="${BODY} + + ### Failures + $(echo -e "$FAILURES")" + fi + + # Update existing comment or create new one + COMMENT_ID=$(gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ + --jq '.[] | select(.body | startswith("## E2E Evals")) | .id' | tail -1) + + if [ -n "$COMMENT_ID" ]; then + gh api "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \ + -X PATCH -f body="$BODY" + else + gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" + fi diff --git a/.claude/skills/gstack/.github/workflows/skill-docs.yml b/.claude/skills/gstack/.github/workflows/skill-docs.yml new file mode 100644 index 0000000..e222603 --- /dev/null +++ b/.claude/skills/gstack/.github/workflows/skill-docs.yml @@ -0,0 +1,25 @@ +name: Skill Docs Freshness +on: [push, pull_request] +jobs: + check-freshness: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install + - name: Check Claude host freshness + run: bun run gen:skill-docs + - name: Verify Claude skill docs are fresh + run: | + git diff --exit-code || { + echo "Generated SKILL.md files are stale. Run: bun run gen:skill-docs" + exit 1 + } + - name: Check Codex host freshness + run: bun run gen:skill-docs --host codex + - name: Verify Codex skill docs are fresh + run: | + git diff --exit-code -- .agents/ || { + echo "Generated Codex SKILL.md files are stale. Run: bun run gen:skill-docs --host codex" + exit 1 + } diff --git a/.claude/skills/gstack/.gitignore b/.claude/skills/gstack/.gitignore new file mode 100644 index 0000000..189276f --- /dev/null +++ b/.claude/skills/gstack/.gitignore @@ -0,0 +1,17 @@ +.env +node_modules/ +browse/dist/ +bin/gstack-global-discover +.gstack/ +.claude/skills/ +.agents/ +.context/ +.gstack-worktrees/ +/tmp/ +*.log +bun.lock +*.bun-build +.env +.env.local +.env.* +!.env.example diff --git a/.claude/skills/gstack/AGENTS.md b/.claude/skills/gstack/AGENTS.md new file mode 100644 index 0000000..d872174 --- /dev/null +++ b/.claude/skills/gstack/AGENTS.md @@ -0,0 +1,49 @@ +# gstack — AI Engineering Workflow + +gstack is a collection of SKILL.md files that give AI agents structured roles for +software development. Each skill is a specialist: CEO reviewer, eng manager, +designer, QA lead, release engineer, debugger, and more. + +## Available skills + +Skills live in `.agents/skills/`. Invoke them by name (e.g., `/office-hours`). + +| Skill | What it does | +|-------|-------------| +| `/office-hours` | Start here. Reframes your product idea before you write code. | +| `/plan-ceo-review` | CEO-level review: find the 10-star product in the request. | +| `/plan-eng-review` | Lock architecture, data flow, edge cases, and tests. | +| `/plan-design-review` | Rate each design dimension 0-10, explain what a 10 looks like. | +| `/design-consultation` | Build a complete design system from scratch. | +| `/review` | Pre-landing PR review. Finds bugs that pass CI but break in prod. | +| `/debug` | Systematic root-cause debugging. No fixes without investigation. | +| `/design-review` | Design audit + fix loop with atomic commits. | +| `/qa` | Open a real browser, find bugs, fix them, re-verify. | +| `/qa-only` | Same as /qa but report only — no code changes. | +| `/ship` | Run tests, review, push, open PR. One command. | +| `/document-release` | Update all docs to match what you just shipped. | +| `/retro` | Weekly retro with per-person breakdowns and shipping streaks. | +| `/browse` | Headless browser — real Chromium, real clicks, ~100ms/command. | +| `/setup-browser-cookies` | Import cookies from your real browser for authenticated testing. | +| `/careful` | Warn before destructive commands (rm -rf, DROP TABLE, force-push). | +| `/freeze` | Lock edits to one directory. Hard block, not just a warning. | +| `/guard` | Activate both careful + freeze at once. | +| `/unfreeze` | Remove directory edit restrictions. | +| `/gstack-upgrade` | Update gstack to the latest version. | + +## Build commands + +```bash +bun install # install dependencies +bun test # run tests (free, <5s) +bun run build # generate docs + compile binaries +bun run gen:skill-docs # regenerate SKILL.md files from templates +bun run skill:check # health dashboard for all skills +``` + +## Key conventions + +- SKILL.md files are **generated** from `.tmpl` templates. Edit the template, not the output. +- Run `bun run gen:skill-docs --host codex` to regenerate Codex-specific output. +- The browse binary provides headless browser access. Use `$B ` in skills. +- Safety skills (careful, freeze, guard) use inline advisory prose — always confirm before destructive operations. diff --git a/.claude/skills/gstack/ARCHITECTURE.md b/.claude/skills/gstack/ARCHITECTURE.md new file mode 100644 index 0000000..3908a2c --- /dev/null +++ b/.claude/skills/gstack/ARCHITECTURE.md @@ -0,0 +1,360 @@ +# Architecture + +This document explains **why** gstack is built the way it is. For setup and commands, see CLAUDE.md. For contributing, see CONTRIBUTING.md. + +## The core idea + +gstack gives Claude Code a persistent browser and a set of opinionated workflow skills. The browser is the hard part — everything else is Markdown. + +The key insight: an AI agent interacting with a browser needs **sub-second latency** and **persistent state**. If every command cold-starts a browser, you're waiting 3-5 seconds per tool call. If the browser dies between commands, you lose cookies, tabs, and login sessions. So gstack runs a long-lived Chromium daemon that the CLI talks to over localhost HTTP. + +``` +Claude Code gstack +───────── ────── + ┌──────────────────────┐ + Tool call: $B snapshot -i │ CLI (compiled binary)│ + ─────────────────────────→ │ • reads state file │ + │ • POST /command │ + │ to localhost:PORT │ + └──────────┬───────────┘ + │ HTTP + ┌──────────▼───────────┐ + │ Server (Bun.serve) │ + │ • dispatches command │ + │ • talks to Chromium │ + │ • returns plain text │ + └──────────┬───────────┘ + │ CDP + ┌──────────▼───────────┐ + │ Chromium (headless) │ + │ • persistent tabs │ + │ • cookies carry over │ + │ • 30min idle timeout │ + └───────────────────────┘ +``` + +First call starts everything (~3s). Every call after: ~100-200ms. + +## Why Bun + +Node.js would work. Bun is better here for three reasons: + +1. **Compiled binaries.** `bun build --compile` produces a single ~58MB executable. No `node_modules` at runtime, no `npx`, no PATH configuration. The binary just runs. This matters because gstack installs into `~/.claude/skills/` where users don't expect to manage a Node.js project. + +2. **Native SQLite.** Cookie decryption reads Chromium's SQLite cookie database directly. Bun has `new Database()` built in — no `better-sqlite3`, no native addon compilation, no gyp. One less thing that breaks on different machines. + +3. **Native TypeScript.** The server runs as `bun run server.ts` during development. No compilation step, no `ts-node`, no source maps to debug. The compiled binary is for deployment; source files are for development. + +4. **Built-in HTTP server.** `Bun.serve()` is fast, simple, and doesn't need Express or Fastify. The server handles ~10 routes total. A framework would be overhead. + +The bottleneck is always Chromium, not the CLI or server. Bun's startup speed (~1ms for the compiled binary vs ~100ms for Node) is nice but not the reason we chose it. The compiled binary and native SQLite are. + +## The daemon model + +### Why not start a browser per command? + +Playwright can launch Chromium in ~2-3 seconds. For a single screenshot, that's fine. For a QA session with 20+ commands, it's 40+ seconds of browser startup overhead. Worse: you lose all state between commands. Cookies, localStorage, login sessions, open tabs — all gone. + +The daemon model means: + +- **Persistent state.** Log in once, stay logged in. Open a tab, it stays open. localStorage persists across commands. +- **Sub-second commands.** After the first call, every command is just an HTTP POST. ~100-200ms round-trip including Chromium's work. +- **Automatic lifecycle.** The server auto-starts on first use, auto-shuts down after 30 minutes idle. No process management needed. + +### State file + +The server writes `.gstack/browse.json` (atomic write via tmp + rename, mode 0o600): + +```json +{ "pid": 12345, "port": 34567, "token": "uuid-v4", "startedAt": "...", "binaryVersion": "abc123" } +``` + +The CLI reads this file to find the server. If the file is missing or the server fails an HTTP health check, the CLI spawns a new server. On Windows, PID-based process detection is unreliable in Bun binaries, so the health check (GET /health) is the primary liveness signal on all platforms. + +### Port selection + +Random port between 10000-60000 (retry up to 5 on collision). This means 10 Conductor workspaces can each run their own browse daemon with zero configuration and zero port conflicts. The old approach (scanning 9400-9409) broke constantly in multi-workspace setups. + +### Version auto-restart + +The build writes `git rev-parse HEAD` to `browse/dist/.version`. On each CLI invocation, if the binary's version doesn't match the running server's `binaryVersion`, the CLI kills the old server and starts a new one. This prevents the "stale binary" class of bugs entirely — rebuild the binary, next command picks it up automatically. + +## Security model + +### Localhost only + +The HTTP server binds to `localhost`, not `0.0.0.0`. It's not reachable from the network. + +### Bearer token auth + +Every server session generates a random UUID token, written to the state file with mode 0o600 (owner-only read). Every HTTP request must include `Authorization: Bearer `. If the token doesn't match, the server returns 401. + +This prevents other processes on the same machine from talking to your browse server. The cookie picker UI (`/cookie-picker`) and health check (`/health`) are exempt — they're localhost-only and don't execute commands. + +### Cookie security + +Cookies are the most sensitive data gstack handles. The design: + +1. **Keychain access requires user approval.** First cookie import per browser triggers a macOS Keychain dialog. The user must click "Allow" or "Always Allow." gstack never silently accesses credentials. + +2. **Decryption happens in-process.** Cookie values are decrypted in memory (PBKDF2 + AES-128-CBC), loaded into the Playwright context, and never written to disk in plaintext. The cookie picker UI never displays cookie values — only domain names and counts. + +3. **Database is read-only.** gstack copies the Chromium cookie DB to a temp file (to avoid SQLite lock conflicts with the running browser) and opens it read-only. It never modifies your real browser's cookie database. + +4. **Key caching is per-session.** The Keychain password + derived AES key are cached in memory for the server's lifetime. When the server shuts down (idle timeout or explicit stop), the cache is gone. + +5. **No cookie values in logs.** Console, network, and dialog logs never contain cookie values. The `cookies` command outputs cookie metadata (domain, name, expiry) but values are truncated. + +### Shell injection prevention + +The browser registry (Comet, Chrome, Arc, Brave, Edge) is hardcoded. Database paths are constructed from known constants, never from user input. Keychain access uses `Bun.spawn()` with explicit argument arrays, not shell string interpolation. + +## The ref system + +Refs (`@e1`, `@e2`, `@c1`) are how the agent addresses page elements without writing CSS selectors or XPath. + +### How it works + +``` +1. Agent runs: $B snapshot -i +2. Server calls Playwright's page.accessibility.snapshot() +3. Parser walks the ARIA tree, assigns sequential refs: @e1, @e2, @e3... +4. For each ref, builds a Playwright Locator: getByRole(role, { name }).nth(index) +5. Stores Map on the BrowserManager instance (role + name + Locator) +6. Returns the annotated tree as plain text + +Later: +7. Agent runs: $B click @e3 +8. Server resolves @e3 → Locator → locator.click() +``` + +### Why Locators, not DOM mutation + +The obvious approach is to inject `data-ref="@e1"` attributes into the DOM. This breaks on: + +- **CSP (Content Security Policy).** Many production sites block DOM modification from scripts. +- **React/Vue/Svelte hydration.** Framework reconciliation can strip injected attributes. +- **Shadow DOM.** Can't reach inside shadow roots from the outside. + +Playwright Locators are external to the DOM. They use the accessibility tree (which Chromium maintains internally) and `getByRole()` queries. No DOM mutation, no CSP issues, no framework conflicts. + +### Ref lifecycle + +Refs are cleared on navigation (the `framenavigated` event on the main frame). This is correct — after navigation, all locators are stale. The agent must run `snapshot` again to get fresh refs. This is by design: stale refs should fail loudly, not click the wrong element. + +### Ref staleness detection + +SPAs can mutate the DOM without triggering `framenavigated` (e.g. React router transitions, tab switches, modal opens). This makes refs stale even though the page URL didn't change. To catch this, `resolveRef()` performs an async `count()` check before using any ref: + +``` +resolveRef(@e3) → entry = refMap.get("e3") + → count = await entry.locator.count() + → if count === 0: throw "Ref @e3 is stale — element no longer exists. Run 'snapshot' to get fresh refs." + → if count > 0: return { locator } +``` + +This fails fast (~5ms overhead) instead of letting Playwright's 30-second action timeout expire on a missing element. The `RefEntry` stores `role` and `name` metadata alongside the Locator so the error message can tell the agent what the element was. + +### Cursor-interactive refs (@c) + +The `-C` flag finds elements that are clickable but not in the ARIA tree — things styled with `cursor: pointer`, elements with `onclick` attributes, or custom `tabindex`. These get `@c1`, `@c2` refs in a separate namespace. This catches custom components that frameworks render as `
` but are actually buttons. + +## Logging architecture + +Three ring buffers (50,000 entries each, O(1) push): + +``` +Browser events → CircularBuffer (in-memory) → Async flush to .gstack/*.log +``` + +Console messages, network requests, and dialog events each have their own buffer. Flushing happens every 1 second — the server appends only new entries since the last flush. This means: + +- HTTP request handling is never blocked by disk I/O +- Logs survive server crashes (up to 1 second of data loss) +- Memory is bounded (50K entries × 3 buffers) +- Disk files are append-only, readable by external tools + +The `console`, `network`, and `dialog` commands read from the in-memory buffers, not disk. Disk files are for post-mortem debugging. + +## SKILL.md template system + +### The problem + +SKILL.md files tell Claude how to use the browse commands. If the docs list a flag that doesn't exist, or miss a command that was added, the agent hits errors. Hand-maintained docs always drift from code. + +### The solution + +``` +SKILL.md.tmpl (human-written prose + placeholders) + ↓ +gen-skill-docs.ts (reads source code metadata) + ↓ +SKILL.md (committed, auto-generated sections) +``` + +Templates contain the workflows, tips, and examples that require human judgment. Placeholders are filled from source code at build time: + +| Placeholder | Source | What it generates | +|-------------|--------|-------------------| +| `{{COMMAND_REFERENCE}}` | `commands.ts` | Categorized command table | +| `{{SNAPSHOT_FLAGS}}` | `snapshot.ts` | Flag reference with examples | +| `{{PREAMBLE}}` | `gen-skill-docs.ts` | Startup block: update check, session tracking, contributor mode, AskUserQuestion format | +| `{{BROWSE_SETUP}}` | `gen-skill-docs.ts` | Binary discovery + setup instructions | +| `{{BASE_BRANCH_DETECT}}` | `gen-skill-docs.ts` | Dynamic base branch detection for PR-targeting skills (ship, review, qa, plan-ceo-review) | +| `{{QA_METHODOLOGY}}` | `gen-skill-docs.ts` | Shared QA methodology block for /qa and /qa-only | +| `{{DESIGN_METHODOLOGY}}` | `gen-skill-docs.ts` | Shared design audit methodology for /plan-design-review and /design-review | +| `{{REVIEW_DASHBOARD}}` | `gen-skill-docs.ts` | Review Readiness Dashboard for /ship pre-flight | +| `{{TEST_BOOTSTRAP}}` | `gen-skill-docs.ts` | Test framework detection, bootstrap, CI/CD setup for /qa, /ship, /design-review | +| `{{CODEX_PLAN_REVIEW}}` | `gen-skill-docs.ts` | Optional cross-model plan review (Codex or Claude subagent fallback) for /plan-ceo-review and /plan-eng-review | + +This is structurally sound — if a command exists in code, it appears in docs. If it doesn't exist, it can't appear. + +### The preamble + +Every skill starts with a `{{PREAMBLE}}` block that runs before the skill's own logic. It handles five things in a single bash command: + +1. **Update check** — calls `gstack-update-check`, reports if an upgrade is available. +2. **Session tracking** — touches `~/.gstack/sessions/$PPID` and counts active sessions (files modified in the last 2 hours). When 3+ sessions are running, all skills enter "ELI16 mode" — every question re-grounds the user on context because they're juggling windows. +3. **Contributor mode** — reads `gstack_contributor` from config. When true, the agent files casual field reports to `~/.gstack/contributor-logs/` when gstack itself misbehaves. +4. **AskUserQuestion format** — universal format: context, question, `RECOMMENDATION: Choose X because ___`, lettered options. Consistent across all skills. +5. **Search Before Building** — before building infrastructure or unfamiliar patterns, search first. Three layers of knowledge: tried-and-true (Layer 1), new-and-popular (Layer 2), first-principles (Layer 3). When first-principles reasoning reveals conventional wisdom is wrong, the agent names the "eureka moment" and logs it. See `ETHOS.md` for the full builder philosophy. + +### Why committed, not generated at runtime? + +Three reasons: + +1. **Claude reads SKILL.md at skill load time.** There's no build step when a user invokes `/browse`. The file must already exist and be correct. +2. **CI can validate freshness.** `gen:skill-docs --dry-run` + `git diff --exit-code` catches stale docs before merge. +3. **Git blame works.** You can see when a command was added and in which commit. + +### Template test tiers + +| Tier | What | Cost | Speed | +|------|------|------|-------| +| 1 — Static validation | Parse every `$B` command in SKILL.md, validate against registry | Free | <2s | +| 2 — E2E via `claude -p` | Spawn real Claude session, run each skill, check for errors | ~$3.85 | ~20min | +| 3 — LLM-as-judge | Sonnet scores docs on clarity/completeness/actionability | ~$0.15 | ~30s | + +Tier 1 runs on every `bun test`. Tiers 2+3 are gated behind `EVALS=1`. The idea is: catch 95% of issues for free, use LLMs only for judgment calls. + +## Command dispatch + +Commands are categorized by side effects: + +- **READ** (text, html, links, console, cookies, ...): No mutations. Safe to retry. Returns page state. +- **WRITE** (goto, click, fill, press, ...): Mutates page state. Not idempotent. +- **META** (snapshot, screenshot, tabs, chain, ...): Server-level operations that don't fit neatly into read/write. + +This isn't just organizational. The server uses it for dispatch: + +```typescript +if (READ_COMMANDS.has(cmd)) → handleReadCommand(cmd, args, bm) +if (WRITE_COMMANDS.has(cmd)) → handleWriteCommand(cmd, args, bm) +if (META_COMMANDS.has(cmd)) → handleMetaCommand(cmd, args, bm, shutdown) +``` + +The `help` command returns all three sets so agents can self-discover available commands. + +## Error philosophy + +Errors are for AI agents, not humans. Every error message must be actionable: + +- "Element not found" → "Element not found or not interactable. Run `snapshot -i` to see available elements." +- "Selector matched multiple elements" → "Selector matched multiple elements. Use @refs from `snapshot` instead." +- Timeout → "Navigation timed out after 30s. The page may be slow or the URL may be wrong." + +Playwright's native errors are rewritten through `wrapError()` to strip internal stack traces and add guidance. The agent should be able to read the error and know what to do next without human intervention. + +### Crash recovery + +The server doesn't try to self-heal. If Chromium crashes (`browser.on('disconnected')`), the server exits immediately. The CLI detects the dead server on the next command and auto-restarts. This is simpler and more reliable than trying to reconnect to a half-dead browser process. + +## E2E test infrastructure + +### Session runner (`test/helpers/session-runner.ts`) + +E2E tests spawn `claude -p` as a completely independent subprocess — not via the Agent SDK, which can't nest inside Claude Code sessions. The runner: + +1. Writes the prompt to a temp file (avoids shell escaping issues) +2. Spawns `sh -c 'cat prompt | claude -p --output-format stream-json --verbose'` +3. Streams NDJSON from stdout for real-time progress +4. Races against a configurable timeout +5. Parses the full NDJSON transcript into structured results + +The `parseNDJSON()` function is pure — no I/O, no side effects — making it independently testable. + +### Observability data flow + +``` + skill-e2e-*.test.ts + │ + │ generates runId, passes testName + runId to each call + │ + ┌─────┼──────────────────────────────┐ + │ │ │ + │ runSkillTest() evalCollector + │ (session-runner.ts) (eval-store.ts) + │ │ │ + │ per tool call: per addTest(): + │ ┌──┼──────────┐ savePartial() + │ │ │ │ │ + │ ▼ ▼ ▼ ▼ + │ [HB] [PL] [NJ] _partial-e2e.json + │ │ │ │ (atomic overwrite) + │ │ │ │ + │ ▼ ▼ ▼ + │ e2e- prog- {name} + │ live ress .ndjson + │ .json .log + │ + │ on failure: + │ {name}-failure.json + │ + │ ALL files in ~/.gstack-dev/ + │ Run dir: e2e-runs/{runId}/ + │ + │ eval-watch.ts + │ │ + │ ┌─────┴─────┐ + │ read HB read partial + │ └─────┬─────┘ + │ ▼ + │ render dashboard + │ (stale >10min? warn) +``` + +**Split ownership:** session-runner owns the heartbeat (current test state), eval-store owns partial results (completed test state). The watcher reads both. Neither component knows about the other — they share data only through the filesystem. + +**Non-fatal everything:** All observability I/O is wrapped in try/catch. A write failure never causes a test to fail. The tests themselves are the source of truth; observability is best-effort. + +**Machine-readable diagnostics:** Each test result includes `exit_reason` (success, timeout, error_max_turns, error_api, exit_code_N), `timeout_at_turn`, and `last_tool_call`. This enables `jq` queries like: +```bash +jq '.tests[] | select(.exit_reason == "timeout") | .last_tool_call' ~/.gstack-dev/evals/_partial-e2e.json +``` + +### Eval persistence (`test/helpers/eval-store.ts`) + +The `EvalCollector` accumulates test results and writes them in two ways: + +1. **Incremental:** `savePartial()` writes `_partial-e2e.json` after each test (atomic: write `.tmp`, `fs.renameSync`). Survives kills. +2. **Final:** `finalize()` writes a timestamped eval file (e.g. `e2e-20260314-143022.json`). The partial file is never cleaned up — it persists alongside the final file for observability. + +`eval:compare` diffs two eval runs. `eval:summary` aggregates stats across all runs in `~/.gstack-dev/evals/`. + +### Test tiers + +| Tier | What | Cost | Speed | +|------|------|------|-------| +| 1 — Static validation | Parse `$B` commands, validate against registry, observability unit tests | Free | <5s | +| 2 — E2E via `claude -p` | Spawn real Claude session, run each skill, scan for errors | ~$3.85 | ~20min | +| 3 — LLM-as-judge | Sonnet scores docs on clarity/completeness/actionability | ~$0.15 | ~30s | + +Tier 1 runs on every `bun test`. Tiers 2+3 are gated behind `EVALS=1`. The idea: catch 95% of issues for free, use LLMs only for judgment calls and integration testing. + +## What's intentionally not here + +- **No WebSocket streaming.** HTTP request/response is simpler, debuggable with curl, and fast enough. Streaming would add complexity for marginal benefit. +- **No MCP protocol.** MCP adds JSON schema overhead per request and requires a persistent connection. Plain HTTP + plain text output is lighter on tokens and easier to debug. +- **No multi-user support.** One server per workspace, one user. The token auth is defense-in-depth, not multi-tenancy. +- **No Windows/Linux cookie decryption.** macOS Keychain is the only supported credential store. Linux (GNOME Keyring/kwallet) and Windows (DPAPI) are architecturally possible but not implemented. +- **No iframe support.** Playwright can handle iframes but the ref system doesn't cross frame boundaries yet. This is the most-requested missing feature. diff --git a/.claude/skills/gstack/BROWSER.md b/.claude/skills/gstack/BROWSER.md new file mode 100644 index 0000000..086d227 --- /dev/null +++ b/.claude/skills/gstack/BROWSER.md @@ -0,0 +1,271 @@ +# Browser — technical details + +This document covers the command reference and internals of gstack's headless browser. + +## Command reference + +| Category | Commands | What for | +|----------|----------|----------| +| Navigate | `goto`, `back`, `forward`, `reload`, `url` | Get to a page | +| Read | `text`, `html`, `links`, `forms`, `accessibility` | Extract content | +| Snapshot | `snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o] [-C]` | Get refs, diff, annotate | +| Interact | `click`, `fill`, `select`, `hover`, `type`, `press`, `scroll`, `wait`, `viewport`, `upload` | Use the page | +| Inspect | `js`, `eval`, `css`, `attrs`, `is`, `console`, `network`, `dialog`, `cookies`, `storage`, `perf` | Debug and verify | +| Visual | `screenshot [--viewport] [--clip x,y,w,h] [sel\|@ref] [path]`, `pdf`, `responsive` | See what Claude sees | +| Compare | `diff ` | Spot differences between environments | +| Dialogs | `dialog-accept [text]`, `dialog-dismiss` | Control alert/confirm/prompt handling | +| Tabs | `tabs`, `tab`, `newtab`, `closetab` | Multi-page workflows | +| Cookies | `cookie-import`, `cookie-import-browser` | Import cookies from file or real browser | +| Multi-step | `chain` (JSON from stdin) | Batch commands in one call | +| Handoff | `handoff [reason]`, `resume` | Switch to visible Chrome for user takeover | + +All selector arguments accept CSS selectors, `@e` refs after `snapshot`, or `@c` refs after `snapshot -C`. 50+ commands total plus cookie import. + +## How it works + +gstack's browser is a compiled CLI binary that talks to a persistent local Chromium daemon over HTTP. The CLI is a thin client — it reads a state file, sends a command, and prints the response to stdout. The server does the real work via [Playwright](https://playwright.dev/). + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Claude Code │ +│ │ +│ "browse goto https://staging.myapp.com" │ +│ │ │ +│ ▼ │ +│ ┌──────────┐ HTTP POST ┌──────────────┐ │ +│ │ browse │ ──────────────── │ Bun HTTP │ │ +│ │ CLI │ localhost:rand │ server │ │ +│ │ │ Bearer token │ │ │ +│ │ compiled │ ◄────────────── │ Playwright │──── Chromium │ +│ │ binary │ plain text │ API calls │ (headless) │ +│ └──────────┘ └──────────────┘ │ +│ ~1ms startup persistent daemon │ +│ auto-starts on first call │ +│ auto-stops after 30 min idle │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Lifecycle + +1. **First call**: CLI checks `.gstack/browse.json` (in the project root) for a running server. None found — it spawns `bun run browse/src/server.ts` in the background. The server launches headless Chromium via Playwright, picks a random port (10000-60000), generates a bearer token, writes the state file, and starts accepting HTTP requests. This takes ~3 seconds. + +2. **Subsequent calls**: CLI reads the state file, sends an HTTP POST with the bearer token, prints the response. ~100-200ms round trip. + +3. **Idle shutdown**: After 30 minutes with no commands, the server shuts down and cleans up the state file. Next call restarts it automatically. + +4. **Crash recovery**: If Chromium crashes, the server exits immediately (no self-healing — don't hide failure). The CLI detects the dead server on the next call and starts a fresh one. + +### Key components + +``` +browse/ +├── src/ +│ ├── cli.ts # Thin client — reads state file, sends HTTP, prints response +│ ├── server.ts # Bun.serve HTTP server — routes commands to Playwright +│ ├── browser-manager.ts # Chromium lifecycle — launch, tabs, ref map, crash handling +│ ├── snapshot.ts # Accessibility tree → @ref assignment → Locator map + diff/annotate/-C +│ ├── read-commands.ts # Non-mutating commands (text, html, links, js, css, is, dialog, etc.) +│ ├── write-commands.ts # Mutating commands (click, fill, select, upload, dialog-accept, etc.) +│ ├── meta-commands.ts # Server management, chain, diff, snapshot routing +│ ├── cookie-import-browser.ts # Decrypt + import cookies from real Chromium browsers +│ ├── cookie-picker-routes.ts # HTTP routes for interactive cookie picker UI +│ ├── cookie-picker-ui.ts # Self-contained HTML/CSS/JS for cookie picker +│ └── buffers.ts # CircularBuffer + console/network/dialog capture +├── test/ # Integration tests + HTML fixtures +└── dist/ + └── browse # Compiled binary (~58MB, Bun --compile) +``` + +### The snapshot system + +The browser's key innovation is ref-based element selection, built on Playwright's accessibility tree API: + +1. `page.locator(scope).ariaSnapshot()` returns a YAML-like accessibility tree +2. The snapshot parser assigns refs (`@e1`, `@e2`, ...) to each element +3. For each ref, it builds a Playwright `Locator` (using `getByRole` + nth-child) +4. The ref-to-Locator map is stored on `BrowserManager` +5. Later commands like `click @e3` look up the Locator and call `locator.click()` + +No DOM mutation. No injected scripts. Just Playwright's native accessibility API. + +**Ref staleness detection:** SPAs can mutate the DOM without navigation (React router, tab switches, modals). When this happens, refs collected from a previous `snapshot` may point to elements that no longer exist. To handle this, `resolveRef()` runs an async `count()` check before using any ref — if the element count is 0, it throws immediately with a message telling the agent to re-run `snapshot`. This fails fast (~5ms) instead of waiting for Playwright's 30-second action timeout. + +**Extended snapshot features:** +- `--diff` (`-D`): Stores each snapshot as a baseline. On the next `-D` call, returns a unified diff showing what changed. Use this to verify that an action (click, fill, etc.) actually worked. +- `--annotate` (`-a`): Injects temporary overlay divs at each ref's bounding box, takes a screenshot with ref labels visible, then removes the overlays. Use `-o ` to control the output path. +- `--cursor-interactive` (`-C`): Scans for non-ARIA interactive elements (divs with `cursor:pointer`, `onclick`, `tabindex>=0`) using `page.evaluate`. Assigns `@c1`, `@c2`... refs with deterministic `nth-child` CSS selectors. These are elements the ARIA tree misses but users can still click. + +### Screenshot modes + +The `screenshot` command supports four modes: + +| Mode | Syntax | Playwright API | +|------|--------|----------------| +| Full page (default) | `screenshot [path]` | `page.screenshot({ fullPage: true })` | +| Viewport only | `screenshot --viewport [path]` | `page.screenshot({ fullPage: false })` | +| Element crop | `screenshot "#sel" [path]` or `screenshot @e3 [path]` | `locator.screenshot()` | +| Region clip | `screenshot --clip x,y,w,h [path]` | `page.screenshot({ clip })` | + +Element crop accepts CSS selectors (`.class`, `#id`, `[attr]`) or `@e`/`@c` refs from `snapshot`. Auto-detection: `@e`/`@c` prefix = ref, `.`/`#`/`[` prefix = CSS selector, `--` prefix = flag, everything else = output path. + +Mutual exclusion: `--clip` + selector and `--viewport` + `--clip` both throw errors. Unknown flags (e.g. `--bogus`) also throw. + +### Authentication + +Each server session generates a random UUID as a bearer token. The token is written to the state file (`.gstack/browse.json`) with chmod 600. Every HTTP request must include `Authorization: Bearer `. This prevents other processes on the machine from controlling the browser. + +### Console, network, and dialog capture + +The server hooks into Playwright's `page.on('console')`, `page.on('response')`, and `page.on('dialog')` events. All entries are kept in O(1) circular buffers (50,000 capacity each) and flushed to disk asynchronously via `Bun.write()`: + +- Console: `.gstack/browse-console.log` +- Network: `.gstack/browse-network.log` +- Dialog: `.gstack/browse-dialog.log` + +The `console`, `network`, and `dialog` commands read from the in-memory buffers, not disk. + +### User handoff + +When the headless browser can't proceed (CAPTCHA, MFA, complex auth), `handoff` opens a visible Chrome window at the exact same page with all cookies, localStorage, and tabs preserved. The user solves the problem manually, then `resume` returns control to the agent with a fresh snapshot. + +```bash +$B handoff "Stuck on CAPTCHA at login page" # opens visible Chrome +# User solves CAPTCHA... +$B resume # returns to headless with fresh snapshot +``` + +The browser auto-suggests `handoff` after 3 consecutive failures. State is fully preserved across the switch — no re-login needed. + +### Dialog handling + +Dialogs (alert, confirm, prompt) are auto-accepted by default to prevent browser lockup. The `dialog-accept` and `dialog-dismiss` commands control this behavior. For prompts, `dialog-accept ` provides the response text. All dialogs are logged to the dialog buffer with type, message, and action taken. + +### JavaScript execution (`js` and `eval`) + +`js` runs a single expression, `eval` runs a JS file. Both support `await` — expressions containing `await` are automatically wrapped in an async context: + +```bash +$B js "await fetch('/api/data').then(r => r.json())" # works +$B js "document.title" # also works (no wrapping needed) +$B eval my-script.js # file with await works too +``` + +For `eval` files, single-line files return the expression value directly. Multi-line files need explicit `return` when using `await`. Comments containing "await" don't trigger wrapping. + +### Multi-workspace support + +Each workspace gets its own isolated browser instance with its own Chromium process, tabs, cookies, and logs. State is stored in `.gstack/` inside the project root (detected via `git rev-parse --show-toplevel`). + +| Workspace | State file | Port | +|-----------|------------|------| +| `/code/project-a` | `/code/project-a/.gstack/browse.json` | random (10000-60000) | +| `/code/project-b` | `/code/project-b/.gstack/browse.json` | random (10000-60000) | + +No port collisions. No shared state. Each project is fully isolated. + +### Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `BROWSE_PORT` | 0 (random 10000-60000) | Fixed port for the HTTP server (debug override) | +| `BROWSE_IDLE_TIMEOUT` | 1800000 (30 min) | Idle shutdown timeout in ms | +| `BROWSE_STATE_FILE` | `.gstack/browse.json` | Path to state file (CLI passes to server) | +| `BROWSE_SERVER_SCRIPT` | auto-detected | Path to server.ts | + +### Performance + +| Tool | First call | Subsequent calls | Context overhead per call | +|------|-----------|-----------------|--------------------------| +| Chrome MCP | ~5s | ~2-5s | ~2000 tokens (schema + protocol) | +| Playwright MCP | ~3s | ~1-3s | ~1500 tokens (schema + protocol) | +| **gstack browse** | **~3s** | **~100-200ms** | **0 tokens** (plain text stdout) | + +The context overhead difference compounds fast. In a 20-command browser session, MCP tools burn 30,000-40,000 tokens on protocol framing alone. gstack burns zero. + +### Why CLI over MCP? + +MCP (Model Context Protocol) works well for remote services, but for local browser automation it adds pure overhead: + +- **Context bloat**: every MCP call includes full JSON schemas and protocol framing. A simple "get the page text" costs 10x more context tokens than it should. +- **Connection fragility**: persistent WebSocket/stdio connections drop and fail to reconnect. +- **Unnecessary abstraction**: Claude Code already has a Bash tool. A CLI that prints to stdout is the simplest possible interface. + +gstack skips all of this. Compiled binary. Plain text in, plain text out. No protocol. No schema. No connection management. + +## Acknowledgments + +The browser automation layer is built on [Playwright](https://playwright.dev/) by Microsoft. Playwright's accessibility tree API, locator system, and headless Chromium management are what make ref-based interaction possible. The snapshot system — assigning `@ref` labels to accessibility tree nodes and mapping them back to Playwright Locators — is built entirely on top of Playwright's primitives. Thank you to the Playwright team for building such a solid foundation. + +## Development + +### Prerequisites + +- [Bun](https://bun.sh/) v1.0+ +- Playwright's Chromium (installed automatically by `bun install`) + +### Quick start + +```bash +bun install # install dependencies + Playwright Chromium +bun test # run integration tests (~3s) +bun run dev # run CLI from source (no compile) +bun run build # compile to browse/dist/browse +``` + +### Dev mode vs compiled binary + +During development, use `bun run dev` instead of the compiled binary. It runs `browse/src/cli.ts` directly with Bun, so you get instant feedback without a compile step: + +```bash +bun run dev goto https://example.com +bun run dev text +bun run dev snapshot -i +bun run dev click @e3 +``` + +The compiled binary (`bun run build`) is only needed for distribution. It produces a single ~58MB executable at `browse/dist/browse` using Bun's `--compile` flag. + +### Running tests + +```bash +bun test # run all tests +bun test browse/test/commands # run command integration tests only +bun test browse/test/snapshot # run snapshot tests only +bun test browse/test/cookie-import-browser # run cookie import unit tests only +``` + +Tests spin up a local HTTP server (`browse/test/test-server.ts`) serving HTML fixtures from `browse/test/fixtures/`, then exercise the CLI commands against those pages. 203 tests across 3 files, ~15 seconds total. + +### Source map + +| File | Role | +|------|------| +| `browse/src/cli.ts` | Entry point. Reads `.gstack/browse.json`, sends HTTP to the server, prints response. | +| `browse/src/server.ts` | Bun HTTP server. Routes commands to the right handler. Manages idle timeout. | +| `browse/src/browser-manager.ts` | Chromium lifecycle — launch, tab management, ref map, crash detection. | +| `browse/src/snapshot.ts` | Parses accessibility tree, assigns `@e`/`@c` refs, builds Locator map. Handles `--diff`, `--annotate`, `-C`. | +| `browse/src/read-commands.ts` | Non-mutating commands: `text`, `html`, `links`, `js`, `css`, `is`, `dialog`, `forms`, etc. Exports `getCleanText()`. | +| `browse/src/write-commands.ts` | Mutating commands: `goto`, `click`, `fill`, `upload`, `dialog-accept`, `useragent` (with context recreation), etc. | +| `browse/src/meta-commands.ts` | Server management, chain routing, diff (DRY via `getCleanText`), snapshot delegation. | +| `browse/src/cookie-import-browser.ts` | Decrypt Chromium cookies from macOS and Linux browser profiles using platform-specific safe-storage key lookup. Auto-detects installed browsers. | +| `browse/src/cookie-picker-routes.ts` | HTTP routes for `/cookie-picker/*` — browser list, domain search, import, remove. | +| `browse/src/cookie-picker-ui.ts` | Self-contained HTML generator for the interactive cookie picker (dark theme, no frameworks). | +| `browse/src/buffers.ts` | `CircularBuffer` (O(1) ring buffer) + console/network/dialog capture with async disk flush. | + +### Deploying to the active skill + +The active skill lives at `~/.claude/skills/gstack/`. After making changes: + +1. Push your branch +2. Pull in the skill directory: `cd ~/.claude/skills/gstack && git pull` +3. Rebuild: `cd ~/.claude/skills/gstack && bun run build` + +Or copy the binary directly: `cp browse/dist/browse ~/.claude/skills/gstack/browse/dist/browse` + +### Adding a new command + +1. Add the handler in `read-commands.ts` (non-mutating) or `write-commands.ts` (mutating) +2. Register the route in `server.ts` +3. Add a test case in `browse/test/commands.test.ts` with an HTML fixture if needed +4. Run `bun test` to verify +5. Run `bun run build` to compile diff --git a/.claude/skills/gstack/CHANGELOG.md b/.claude/skills/gstack/CHANGELOG.md new file mode 100644 index 0000000..654b1b8 --- /dev/null +++ b/.claude/skills/gstack/CHANGELOG.md @@ -0,0 +1,1049 @@ +# Changelog + +## [0.11.16.0] - 2026-03-24 — Telemetry Security Hardening + +### Fixed + +- **Telemetry RLS policies tightened.** Row-level security policies on all telemetry tables now deny direct access via the anon key. All reads and writes go through validated edge functions with schema checks, event type allowlists, and field length limits. +- **Community dashboard is faster and server-cached.** Dashboard stats are now served from a single edge function with 1-hour server-side caching, replacing multiple direct queries. + +### Changed + +- **Telemetry sync uses `GSTACK_SUPABASE_URL` instead of `GSTACK_TELEMETRY_ENDPOINT`.** Edge functions need the base URL, not the REST API path. The old variable is removed from `config.sh`. +- **Cursor advancement is now safe.** The sync script checks the edge function's `inserted` count before advancing — if zero events were inserted, the cursor holds and retries next run. + +### For contributors + +- New migration: `supabase/migrations/002_tighten_rls.sql` +- New smoke test: `supabase/verify-rls.sh` (9 checks: 5 reads + 4 writes) +- Extended `test/telemetry.test.ts` with field name verification +- Untracked `browse/dist/` binaries from git (arm64-only, rebuilt by `./setup`) + +## [0.11.15.0] - 2026-03-24 — E2E Test Coverage for Plan Reviews & Codex + +### Added + +- **E2E tests verify plan review reports appear at the bottom of plans.** The `/plan-eng-review` review report is now tested end-to-end — if it stops writing `## GSTACK REVIEW REPORT` to the plan file, the test catches it. +- **E2E tests verify Codex is offered in every plan skill.** Four new lightweight tests confirm that `/office-hours`, `/plan-ceo-review`, `/plan-design-review`, and `/plan-eng-review` all check for Codex availability, prompt the user, and handle the fallback when Codex is unavailable. + +### For contributors + +- New E2E tests in `test/skill-e2e-plan.test.ts`: `plan-review-report`, `codex-offered-eng-review`, `codex-offered-ceo-review`, `codex-offered-office-hours`, `codex-offered-design-review` +- Updated touchfile mappings and selection count assertions +- Added `touchfiles` to the documented global touchfile list in CLAUDE.md + +## [0.11.14.0] - 2026-03-24 — Windows Browse Fix + +### Fixed + +- **Browse engine now works on Windows.** Three compounding bugs blocked all Windows `/browse` users: the server process died when the CLI exited (Bun's `unref()` doesn't truly detach on Windows), the health check never ran because `process.kill(pid, 0)` is broken in Bun binaries on Windows, and Chromium's sandbox failed when spawned through the Bun→Node process chain. All three are now fixed. Credits to @fqueiro (PR #191) for identifying the `detached: true` approach. +- **Health check runs first on all platforms.** `ensureServer()` now tries an HTTP health check before falling back to PID-based detection — more reliable on every OS, not just Windows. +- **Startup errors are logged to disk.** When the server fails to start, errors are written to `~/.gstack/browse-startup-error.log` so Windows users (who lose stderr due to process detachment) can debug. +- **Chromium sandbox disabled on Windows.** Chromium's sandbox requires elevated privileges when spawned through the Bun→Node chain — now disabled on Windows only. + +### For contributors + +- New tests for `isServerHealthy()` and startup error logging in `browse/test/config.test.ts` + +## [0.11.13.0] - 2026-03-24 — Worktree Isolation + Infrastructure Elegance + +### Added + +- **E2E tests now run in git worktrees.** Gemini and Codex tests no longer pollute your working tree. Each test suite gets an isolated worktree, and useful changes the AI agent makes are automatically harvested as patches you can cherry-pick. Run `git apply ~/.gstack-dev/harvests//gemini.patch` to grab improvements. +- **Harvest deduplication.** If a test keeps producing the same improvement across runs, it's detected via SHA-256 hash and skipped — no duplicate patches piling up. +- **`describeWithWorktree()` helper.** Any E2E test can now opt into worktree isolation with a one-line wrapper. Future tests that need real repo context (git history, real diff) can use this instead of tmpdirs. + +### Changed + +- **Gen-skill-docs is now a modular resolver pipeline.** The monolithic 1700-line generator is split into 8 focused resolver modules (browse, preamble, design, review, testing, utility, constants, codex-helpers). Adding a new placeholder resolver is now a single file instead of editing a megafunction. +- **Eval results are project-scoped.** Results now live in `~/.gstack/projects/$SLUG/evals/` instead of the global `~/.gstack-dev/evals/`. Multi-project users no longer get eval results mixed together. + +### For contributors + +- WorktreeManager (`lib/worktree.ts`) is a reusable platform module — future skills like `/batch` can import it directly. +- 12 new unit tests for WorktreeManager covering lifecycle, harvest, dedup, and error handling. +- `GLOBAL_TOUCHFILES` updated so worktree infrastructure changes trigger all E2E tests. + +## [0.11.12.0] - 2026-03-24 — Triple-Voice Autoplan + +Every `/autoplan` phase now gets two independent second opinions — one from Codex (OpenAI's frontier model) and one from a fresh Claude subagent. Three AI reviewers looking at your plan from different angles, each phase building on the last. + +### Added + +- **Dual voices in every autoplan phase.** CEO review, Design review, and Eng review each run both a Codex challenge and an independent Claude subagent simultaneously. You get a consensus table showing where the models agree and disagree — disagreements surface as taste decisions at the final gate. +- **Phase-cascading context.** Codex gets prior-phase findings as context (CEO concerns inform Design review, CEO+Design inform Eng). Claude subagent stays truly independent for genuine cross-model validation. +- **Structured consensus tables.** CEO phase scores 6 strategic dimensions, Design uses the litmus scorecard, Eng scores 6 architecture dimensions. CONFIRMED/DISAGREE for each. +- **Cross-phase synthesis.** Phase 4 gate highlights themes that appeared independently in multiple phases — high-confidence signals when different reviewers catch the same issue. +- **Sequential enforcement.** STOP markers between phases + pre-phase checklists prevent autoplan from accidentally parallelizing CEO/Design/Eng (each phase depends on the previous). +- **Phase-transition summaries.** Brief status at each phase boundary so you can track progress without waiting for the full pipeline. +- **Degradation matrix.** When Codex or the Claude subagent fails, autoplan gracefully degrades with clear labels (`[codex-only]`, `[subagent-only]`, `[single-reviewer mode]`). + +## [0.11.11.0] - 2026-03-23 — Community Wave 3 + +10 community PRs merged — bug fixes, platform support, and workflow improvements. + +### Added + +- **Chrome multi-profile cookie import.** You can now import cookies from any Chrome profile, not just Default. Profile picker shows account email for easy identification. Batch import across all visible domains. +- **Linux Chromium cookie import.** Cookie import now works on Linux for Chrome, Chromium, Brave, and Edge. Supports both GNOME Keyring (libsecret) and the "peanuts" fallback for headless environments. +- **Chrome extensions in browse sessions.** Set `BROWSE_EXTENSIONS_DIR` to load Chrome extensions (ad blockers, accessibility tools, custom headers) into your browse testing sessions. +- **Project-scoped gstack install.** `setup --local` installs gstack into `.claude/skills/` in your current project instead of globally. Useful for per-project version pinning. +- **Distribution pipeline checks.** `/office-hours`, `/plan-eng-review`, `/ship`, and `/review` now check whether new CLI tools or libraries have a build/publish pipeline. No more shipping artifacts nobody can download. +- **Dynamic skill discovery.** Adding a new skill directory no longer requires editing a hardcoded list. `skill-check` and `gen-skill-docs` automatically discover skills from the filesystem. +- **Auto-trigger guard.** Skills now include explicit trigger criteria in their descriptions to prevent Claude Code from auto-firing them based on semantic similarity. The existing proactive suggestion system is preserved. + +### Fixed + +- **Browse server startup crash.** The browse server lock acquisition failed when `.gstack/` directory didn't exist, causing every invocation to think another process held the lock. Fixed by creating the state directory before lock acquisition. +- **Zsh glob errors in skill preamble.** The telemetry cleanup loop no longer throws `no matches found` in zsh when no pending files exist. +- **`--force` now actually forces upgrades.** `gstack-upgrade --force` clears the snooze file, so you can upgrade immediately after snoozing. +- **Three-dot diff in /review scope drift detection.** Scope drift analysis now correctly shows changes since branch creation, not accumulated changes on the base branch. +- **CI workflow YAML parsing.** Fixed unquoted multiline `run:` scalars that broke YAML parsing. Added actionlint CI workflow. + +### Community + +Thanks to @osc, @Explorer1092, @Qike-Li, @francoisaubert1, @itstimwhite, @yinanli1917-cloud for contributions in this wave. + +## [0.11.10.0] - 2026-03-23 — CI Evals on Ubicloud + +### Added + +- **E2E evals now run in CI on every PR.** 12 parallel GitHub Actions runners on Ubicloud spin up per PR, each running one test suite. Docker image pre-bakes bun, node, Claude CLI, and deps so setup is near-instant. Results posted as a PR comment with pass/fail + cost breakdown. +- **3x faster eval runs.** All E2E tests run concurrently within files via `testConcurrentIfSelected`. Wall clock drops from ~18min to ~6min — limited by the slowest individual test, not sequential sum. +- **Docker CI image** (`Dockerfile.ci`) with pre-installed toolchain. Rebuilds automatically when Dockerfile or package.json changes, cached by content hash in GHCR. + +### Fixed + +- **Routing tests now work in CI.** Skills are installed at top-level `.claude/skills/` instead of nested under `.claude/skills/gstack/` — project-level skill discovery doesn't recurse into subdirectories. + +### For contributors + +- `EVALS_CONCURRENCY=40` in CI for maximum parallelism (local default stays at 15) +- Ubicloud runners at ~$0.006/run (10x cheaper than GitHub standard runners) +- `workflow_dispatch` trigger for manual re-runs + +## [0.11.9.0] - 2026-03-23 — Codex Skill Loading Fix + +### Fixed + +- **Codex no longer rejects gstack skills with "invalid SKILL.md".** Existing installs had oversized description fields (>1024 chars) that Codex silently rejected. The build now errors if any Codex description exceeds 1024 chars, setup always regenerates `.agents/` to prevent stale files, and a one-time migration auto-cleans oversized descriptions on existing installs. +- **`package.json` version now stays in sync with `VERSION`.** Was 6 minor versions behind. A new CI test catches future drift. + +### Added + +- **Codex E2E tests now assert no skill loading errors.** The exact "Skipped loading skill(s)" error that prompted this fix is now a regression test — `stderr` is captured and checked. +- **Codex troubleshooting entry in README.** Manual fix instructions for users who hit the loading error before the auto-migration runs. + +### For contributors + +- `test/gen-skill-docs.test.ts` validates all `.agents/` descriptions stay within 1024 chars +- `gstack-update-check` includes a one-time migration that deletes oversized Codex SKILL.md files +- P1 TODO added: Codex→Claude reverse buddy check skill + +## [0.11.8.0] - 2026-03-23 — zsh Compatibility Fix + +### Fixed + +- **gstack skills now work in zsh without errors.** Every skill preamble used a `.pending-*` glob pattern that triggered zsh's "no matches found" error on every invocation (the common case where no pending telemetry files exist). Replaced shell glob with `find` to avoid zsh's NOMATCH behavior entirely. Thanks to @hnshah for the initial report and fix in PR #332. Fixes #313. + +### Added + +- **Regression test for zsh glob safety.** New test verifies all generated SKILL.md files use `find` instead of bare shell globs for `.pending-*` pattern matching. + +## [0.11.7.0] - 2026-03-23 — /review → /ship Handoff Fix + +### Fixed + +- **`/review` now satisfies the ship readiness gate.** Previously, running `/review` before `/ship` always showed "NOT CLEARED" because `/review` didn't log its result and `/ship` only looked for `/plan-eng-review`. Now `/review` persists its outcome to the review log, and all dashboards recognize both `/review` (diff-scoped) and `/plan-eng-review` (plan-stage) as valid Eng Review sources. +- **Ship abort prompt now mentions both review options.** When Eng Review is missing, `/ship` suggests "run `/review` or `/plan-eng-review`" instead of only mentioning `/plan-eng-review`. + +### For contributors + +- Based on PR #338 by @malikrohail. DRY improvement per eng review: updated the shared `REVIEW_DASHBOARD` resolver instead of creating a duplicate ship-only resolver. +- 4 new validation tests covering review-log persistence, dashboard propagation, and abort text. + +## [0.11.6.0] - 2026-03-23 — Infrastructure-First Security Audit + +### Added + +- **`/cso` v2 — start where the breaches actually happen.** The security audit now begins with your infrastructure attack surface (leaked secrets in git history, dependency CVEs, CI/CD pipeline misconfigurations, unverified webhooks, Dockerfile security) before touching application code. 15 phases covering secrets archaeology, supply chain, CI/CD, LLM/AI security, skill supply chain, OWASP Top 10, STRIDE, and active verification. +- **Two audit modes.** `--daily` runs a zero-noise scan with an 8/10 confidence gate (only reports findings it's highly confident about). `--comprehensive` does a deep monthly scan with a 2/10 bar (surfaces everything worth investigating). +- **Active verification.** Every finding gets independently verified by a subagent before reporting — no more grep-and-guess. Variant analysis: when one vulnerability is confirmed, the entire codebase is searched for the same pattern. +- **Trend tracking.** Findings are fingerprinted and tracked across audit runs. You can see what's new, what's fixed, and what's been ignored. +- **Diff-scoped auditing.** `--diff` mode scopes the audit to changes on your branch vs the base branch — perfect for pre-merge security checks. +- **3 E2E tests** with planted vulnerabilities (hardcoded API keys, tracked `.env` files, unsigned webhooks, unpinned GitHub Actions, rootless Dockerfiles). All verified passing. + +### Changed + +- **Stack detection before scanning.** v1 ran Ruby/Java/PHP/C# patterns on every project without checking the stack. v2 detects your framework first and prioritizes relevant checks. +- **Proper tool usage.** v1 used raw `grep` in Bash; v2 uses Claude Code's native `Grep` tool for reliable results without truncation. + +## [0.11.5.2] - 2026-03-22 — Outside Voice + +### Added + +- **Plan reviews now offer an independent second opinion.** After all review sections complete in `/plan-ceo-review` or `/plan-eng-review`, you can get a "brutally honest outside voice" from a different AI model (Codex CLI, or a fresh Claude subagent if Codex isn't installed). It reads your plan, finds what the review missed — logical gaps, unstated assumptions, feasibility risks — and presents findings verbatim. Optional, recommended, never blocks shipping. +- **Cross-model tension detection.** When the outside voice disagrees with the review findings, the disagreements are surfaced automatically and offered as TODOs so nothing gets lost. +- **Outside Voice in the Review Readiness Dashboard.** `/ship` now shows whether an outside voice ran on the plan, alongside the existing CEO/Eng/Design/Adversarial review rows. + +### Changed + +- **`/plan-eng-review` Codex integration upgraded.** The old hardcoded Step 0.5 is replaced with a richer resolver that adds Claude subagent fallback, review log persistence, dashboard visibility, and higher reasoning effort (`xhigh`). + +## [0.11.5.1] - 2026-03-23 — Inline Office Hours + +### Changed + +- **No more "open another window" for /office-hours.** When `/plan-ceo-review` or `/plan-eng-review` offer to run `/office-hours` first, it now runs inline in the same conversation. The review picks up right where it left off after the design doc is ready. Same for mid-session detection when you're still figuring out what to build. +- **Handoff note infrastructure removed.** The handoff notes that bridged the old "go to another window" flow are no longer written. Existing notes from prior sessions are still read for backward compatibility. + +## [0.11.5.0] - 2026-03-23 — Bash Compatibility Fix + +### Fixed + +- **`gstack-review-read` and `gstack-review-log` no longer crash under bash.** These scripts used `source <(gstack-slug)` which silently fails to set variables under bash with `set -euo pipefail`, causing `SLUG: unbound variable` errors. Replaced with `eval "$(gstack-slug)"` which works correctly in both bash and zsh. +- **All SKILL.md templates updated.** Every template that instructed agents to run `source <(gstack-slug)` now uses `eval "$(gstack-slug)"` for cross-shell compatibility. Regenerated all SKILL.md files from templates. +- **Regression tests added.** New tests verify `eval "$(gstack-slug)"` works under bash strict mode, and guard against `source <(.*gstack-slug` patterns reappearing in templates or bin scripts. + +## [0.11.4.0] - 2026-03-22 — Codex in Office Hours + +### Added + +- **Your brainstorming now gets a second opinion.** After premise challenge in `/office-hours`, you can opt in to a Codex cold read — a completely independent AI that hasn't seen the conversation reviews your problem, answers, and premises. It steelmans your idea, identifies the most revealing thing you said, challenges one premise, and proposes a 48-hour prototype. Two different AI models seeing different things catches blind spots neither would find alone. +- **Cross-Model Perspective in design docs.** When you use the second opinion, the design doc automatically includes a `## Cross-Model Perspective` section capturing what Codex said — so the independent view is preserved for downstream reviews. +- **New founder signal: defended premise with reasoning.** When Codex challenges one of your premises and you keep it with articulated reasoning (not just dismissal), that's tracked as a positive signal of conviction. + +## [0.11.3.0] - 2026-03-23 — Design Outside Voices + +### Added + +- **Every design review now gets a second opinion.** `/plan-design-review`, `/design-review`, and `/design-consultation` dispatch both Codex (OpenAI) and a fresh Claude subagent in parallel to independently evaluate your design — then synthesize findings with a litmus scorecard showing where they agree and disagree. Cross-model agreement = high confidence; disagreement = investigate. +- **OpenAI's design hard rules baked in.** 7 hard rejection criteria, 7 litmus checks, and a landing-page vs app-UI classifier from OpenAI's "Designing Delightful Frontends" framework — merged with gstack's existing 10-item AI slop blacklist. Your design gets evaluated against the same rules OpenAI recommends for their own models. +- **Codex design voice in every PR.** The lightweight design review that runs in `/ship` and `/review` now includes a Codex design check when frontend files change — automatic, no opt-in needed. +- **Outside voices in /office-hours brainstorming.** After wireframe sketches, you can now get Codex + Claude subagent design perspectives on your approaches before committing to a direction. +- **AI slop blacklist extracted as shared constant.** The 10 anti-patterns (purple gradients, 3-column icon grids, centered everything, etc.) are now defined once and shared across all design skills. Easier to maintain, impossible to drift. + +## [0.11.2.0] - 2026-03-22 — Codex Just Works + +### Fixed + +- **Codex no longer shows "exceeds maximum length of 1024 characters" on startup.** Skill descriptions compressed from ~1,200 words to ~280 words — well under the limit. Every skill now has a test enforcing the cap. +- **No more duplicate skill discovery.** Codex used to find both source SKILL.md files and generated Codex skills, showing every skill twice. Setup now creates a minimal runtime root at `~/.codex/skills/gstack` with only the assets Codex needs — no source files exposed. +- **Old direct installs auto-migrate.** If you previously cloned gstack into `~/.codex/skills/gstack`, setup detects this and moves it to `~/.gstack/repos/gstack` so skills aren't discovered from the source checkout. +- **Sidecar directory no longer linked as a skill.** The `.agents/skills/gstack` runtime asset directory was incorrectly symlinked alongside real skills — now skipped. + +### Added + +- **Repo-local Codex installs.** Clone gstack into `.agents/skills/gstack` inside any repo and run `./setup --host codex` — skills install next to the checkout, no global `~/.codex/` needed. Generated preambles auto-detect whether to use repo-local or global paths at runtime. +- **Kiro CLI support.** `./setup --host kiro` installs skills for the Kiro agent platform, rewriting paths and symlinking runtime assets. Auto-detected by `--host auto` if `kiro-cli` is installed. +- **`.agents/` is now gitignored.** Generated Codex skill files are no longer committed — they're created at setup time from templates. Removes 14,000+ lines of generated output from the repo. + +### Changed + +- **`GSTACK_DIR` renamed to `SOURCE_GSTACK_DIR` / `INSTALL_GSTACK_DIR`** throughout the setup script for clarity about which path points to the source repo vs the install location. +- **CI validates Codex generation succeeds** instead of checking committed file freshness (since `.agents/` is no longer committed). + +## [0.11.1.1] - 2026-03-22 — Plan Files Always Show Review Status + +### Added + +- **Every plan file now shows review status.** When you exit plan mode, the plan file automatically gets a `GSTACK REVIEW REPORT` section — even if you haven't run any formal reviews yet. Previously, this section only appeared after running `/plan-eng-review`, `/plan-ceo-review`, `/plan-design-review`, or `/codex review`. Now you always know where you stand: which reviews have run, which haven't, and what to do next. + +## [0.11.1.0] - 2026-03-22 — Global Retro: Cross-Project AI Coding Retrospective + +### Added + +- **`/retro global` — see everything you shipped across every project in one report.** Scans your Claude Code, Codex CLI, and Gemini CLI sessions, traces each back to its git repo, deduplicates by remote, then runs a full retro across all of them. Global shipping streak, context-switching metrics, per-project breakdowns with personal contributions, and cross-tool usage patterns. Run `/retro global 14d` for a two-week view. +- **Per-project personal contributions in global retro.** Each project in the global retro now shows YOUR commits, LOC, key work, commit type mix, and biggest ship — separate from team totals. Solo projects say "Solo project — all commits are yours." Team projects you didn't touch show session count only. +- **`gstack-global-discover` — the engine behind global retro.** Standalone discovery script that finds all AI coding sessions on your machine, resolves working directories to git repos, normalizes SSH/HTTPS remotes for dedup, and outputs structured JSON. Compiled binary ships with gstack — no `bun` runtime needed. + +### Fixed + +- **Discovery script reads only the first few KB of session files** instead of loading entire multi-MB JSONL transcripts into memory. Prevents OOM on machines with extensive coding history. +- **Claude Code session counts are now accurate.** Previously counted all JSONL files in a project directory; now only counts files modified within the time window. +- **Week windows (`1w`, `2w`) are now midnight-aligned** like day windows, so `/retro global 1w` and `/retro global 7d` produce consistent results. + +## [0.11.0.0] - 2026-03-22 — /cso: Zero-Noise Security Audits + +### Added + +- **`/cso` — your Chief Security Officer.** Full codebase security audit: OWASP Top 10, STRIDE threat modeling, attack surface mapping, data classification, and dependency scanning. Each finding includes severity, confidence score, a concrete exploit scenario, and remediation options. Not a linter — a threat model. +- **Zero-noise false positive filtering.** 17 hard exclusions and 9 precedents adapted from Anthropic's security review methodology. DOS isn't a finding. Test files aren't attack surface. React is XSS-safe by default. Every finding must score 8/10+ confidence to make the report. The result: 3 real findings, not 3 real + 12 theoretical. +- **Independent finding verification.** Each candidate finding is verified by a fresh sub-agent that only sees the finding and the false positive rules — no anchoring bias from the initial scan. Findings that fail independent verification are silently dropped. +- **`browse storage` now redacts secrets automatically.** Tokens, JWTs, API keys, GitHub PATs, and Bearer tokens are detected by both key name and value prefix. You see `[REDACTED — 42 chars]` instead of the secret. +- **Azure metadata endpoint blocked.** SSRF protection for `browse goto` now covers all three major cloud providers (AWS, GCP, Azure). + +### Fixed + +- **`gstack-slug` hardened against shell injection.** Output sanitized to alphanumeric, dot, dash, and underscore only. All remaining `eval $(gstack-slug)` callers migrated to `source <(...)`. +- **DNS rebinding protection.** `browse goto` now resolves hostnames to IPs and checks against the metadata blocklist — prevents attacks where a domain initially resolves to a safe IP, then switches to a cloud metadata endpoint. +- **Concurrent server start race fixed.** An exclusive lockfile prevents two CLI invocations from both killing the old server and starting new ones simultaneously, which could leave orphaned Chromium processes. +- **Smarter storage redaction.** Key matching now uses underscore-aware boundaries (won't false-positive on `keyboardShortcuts` or `monkeyPatch`). Value detection expanded to cover AWS, Stripe, Anthropic, Google, Sendgrid, and Supabase key prefixes. +- **CI workflow YAML lint error fixed.** + +### For contributors + +- **Community PR triage process documented** in CONTRIBUTING.md. +- **Storage redaction test coverage.** Four new tests for key-based and value-based detection. + +## [0.10.2.0] - 2026-03-22 — Autoplan Depth Fix + +### Fixed + +- **`/autoplan` now produces full-depth reviews instead of compressing everything to one-liners.** When autoplan said "auto-decide," it meant "decide FOR the user using principles" — but the agent interpreted it as "skip the analysis entirely." Now autoplan explicitly defines the contract: auto-decide replaces your judgment, not the analysis. Every review section still gets read, diagrammed, and evaluated. You get the same depth as running each review manually. +- **Execution checklists for CEO and Eng phases.** Each phase now enumerates exactly what must be produced — premise challenges, architecture diagrams, test coverage maps, failure registries, artifacts on disk. No more "follow that file at full depth" without saying what "full depth" means. +- **Pre-gate verification catches skipped outputs.** Before presenting the final approval gate, autoplan now checks a concrete checklist of required outputs. Missing items get produced before the gate opens (max 2 retries, then warns). +- **Test review can never be skipped.** The Eng review's test diagram section — the highest-value output — is explicitly marked NEVER SKIP OR COMPRESS with instructions to read actual diffs, map every codepath to coverage, and write the test plan artifact. + +## [0.10.1.0] - 2026-03-22 — Test Coverage Catalog + +### Added + +- **Test coverage audit now works everywhere — plan, ship, and review.** The codepath tracing methodology (ASCII diagrams, quality scoring, gap detection) is shared across `/plan-eng-review`, `/ship`, and `/review` via a single `{{TEST_COVERAGE_AUDIT}}` resolver. Plan mode adds missing tests to your plan before you write code. Ship mode auto-generates tests for gaps. Review mode finds untested paths during pre-landing review. One methodology, three contexts, zero copy-paste. +- **`/review` Step 4.75 — test coverage diagram.** Before landing code, `/review` now traces every changed codepath and produces an ASCII coverage map showing what's tested (★★★/★★/★) and what's not (GAP). Gaps become INFORMATIONAL findings that follow the Fix-First flow — you can generate the missing tests right there. +- **E2E test recommendations built in.** The coverage audit knows when to recommend E2E tests (common user flows, tricky integrations where unit tests can't cover it) vs unit tests, and flags LLM prompt changes that need eval coverage. No more guessing whether something needs an integration test. +- **Regression detection iron rule.** When a code change modifies existing behavior, gstack always writes a regression test — no asking, no skipping. If you changed it, you test it. +- **`/ship` failure triage.** When tests fail during ship, the coverage audit classifies each failure and recommends next steps instead of just dumping the error output. +- **Test framework auto-detection.** Reads your CLAUDE.md for test commands first, then auto-detects from project files (package.json, Gemfile, pyproject.toml, etc.). Works with any framework. + +### Fixed + +- **gstack no longer crashes in repos without an `origin` remote.** The `gstack-repo-mode` helper now gracefully handles missing remotes, bare repos, and empty git output — defaulting to `unknown` mode instead of crashing the preamble. +- **`REPO_MODE` defaults correctly when the helper emits nothing.** Previously an empty response from `gstack-repo-mode` left `REPO_MODE` unset, causing downstream template errors. + +## [0.10.0.0] - 2026-03-22 — Autoplan + +### Added + +- **`/autoplan` — one command, fully reviewed plan.** Hand it a rough plan and it runs the full CEO → design → eng review pipeline automatically. Reads the actual review skill files from disk (same depth, same rigor as running each review manually) and makes intermediate decisions using 6 encoded principles: completeness, boil lakes, pragmatic, DRY, explicit over clever, bias toward action. Taste decisions (close approaches, borderline scope, codex disagreements) surface at a final approval gate. You approve, override, interrogate, or revise. Saves a restore point so you can re-run from scratch. Writes review logs compatible with `/ship`'s dashboard. + +## [0.9.8.0] - 2026-03-21 — Deploy Pipeline + E2E Performance + +### Added + +- **`/land-and-deploy` — merge, deploy, and verify in one command.** Takes over where `/ship` left off. Merges the PR, waits for CI and deploy workflows, then runs canary verification on your production URL. Auto-detects your deploy platform (Fly.io, Render, Vercel, Netlify, Heroku, GitHub Actions). Offers revert at every failure point. One command from "PR approved" to "verified in production." +- **`/canary` — post-deploy monitoring loop.** Watches your live app for console errors, performance regressions, and page failures using the browse daemon. Takes periodic screenshots, compares against pre-deploy baselines, and alerts on anomalies. Run `/canary https://myapp.com --duration 10m` after any deploy. +- **`/benchmark` — performance regression detection.** Establishes baselines for page load times, Core Web Vitals, and resource sizes. Compares before/after on every PR. Tracks performance trends over time. Catches the bundle size regressions that code review misses. +- **`/setup-deploy` — one-time deploy configuration.** Detects your deploy platform, production URL, health check endpoints, and deploy status commands. Writes the config to CLAUDE.md so all future `/land-and-deploy` runs are fully automatic. +- **`/review` now includes Performance & Bundle Impact analysis.** The informational review pass checks for heavy dependencies, missing lazy loading, synchronous script tags, and bundle size regressions. Catches moment.js-instead-of-date-fns before it ships. + +### Changed + +- **E2E tests now run 3-5x faster.** Structure tests default to Sonnet (5x faster, 5x cheaper). Quality tests (planted-bug detection, design quality, strategic review) stay on Opus. Full suite dropped from 50-80 minutes to ~15-25 minutes. +- **`--retry 2` on all E2E tests.** Flaky tests get a second chance without masking real failures. +- **`test:e2e:fast` tier.** Excludes the 8 slowest Opus quality tests for quick feedback (~5-7 minutes). Run `bun run test:e2e:fast` for rapid iteration. +- **E2E timing telemetry.** Every test now records `first_response_ms`, `max_inter_turn_ms`, and `model` used. Wall-clock timing shows whether parallelism is actually working. + +### Fixed + +- **`plan-design-review-plan-mode` no longer races.** Each test gets its own isolated tmpdir — no more concurrent tests polluting each other's working directory. +- **`ship-local-workflow` no longer wastes 6 of 15 turns.** Ship workflow steps are inlined in the test prompt instead of having the agent read the 700+ line SKILL.md at runtime. +- **`design-consultation-core` no longer fails on synonym sections.** "Colors" matches "Color", "Type System" matches "Typography" — fuzzy synonym-based matching with all 7 sections still required. + +## [0.9.7.0] - 2026-03-21 — Plan File Review Report + +### Added + +- **Every plan file now shows which reviews have run.** After any review skill finishes (`/plan-ceo-review`, `/plan-eng-review`, `/plan-design-review`, `/codex review`), a markdown table is appended to the plan file itself — showing each review's trigger command, purpose, run count, status, and findings summary. Anyone reading the plan can see review status at a glance without checking conversation history. +- **Review logs now capture richer data.** CEO reviews log scope proposal counts (proposed/accepted/deferred), eng reviews log total issues found, design reviews log before→after scores, and codex reviews log how many findings were fixed. The plan file report uses these fields directly — no more guessing from partial metadata. + +## [0.9.6.0] - 2026-03-21 — Auto-Scaled Adversarial Review + +### Changed + +- **Review thoroughness now scales automatically with diff size.** Small diffs (<50 lines) skip adversarial review entirely — no wasted time on typo fixes. Medium diffs (50–199 lines) get a cross-model adversarial challenge from Codex (or a Claude adversarial subagent if Codex isn't installed). Large diffs (200+ lines) get all four passes: Claude structured, Codex structured review with pass/fail gate, Claude adversarial subagent, and Codex adversarial challenge. No configuration needed — it just works. +- **Claude now has an adversarial mode.** A fresh Claude subagent with no checklist bias reviews your code like an attacker — finding edge cases, race conditions, security holes, and silent data corruption that the structured review might miss. Findings are classified as FIXABLE (auto-fixed) or INVESTIGATE (your call). +- **Review dashboard shows "Adversarial" instead of "Codex Review."** The dashboard row reflects the new multi-model reality — it tracks whichever adversarial passes actually ran, not just Codex. + +## [0.9.5.0] - 2026-03-21 — Builder Ethos + +### Added + +- **ETHOS.md — gstack's builder philosophy in one document.** Four principles: The Golden Age (AI compression ratios), Boil the Lake (completeness is cheap), Search Before Building (three layers of knowledge), and Build for Yourself. This is the philosophical source of truth that every workflow skill references. +- **Every workflow skill now searches before recommending.** Before suggesting infrastructure patterns, concurrency approaches, or framework-specific solutions, gstack checks if the runtime has a built-in and whether the pattern is current best practice. Three layers of knowledge — tried-and-true (Layer 1), new-and-popular (Layer 2), and first-principles (Layer 3) — with the most valuable insights prized above all. +- **Eureka moments.** When first-principles reasoning reveals that conventional wisdom is wrong, gstack names it, celebrates it, and logs it. Your weekly `/retro` now surfaces these insights so you can see where your projects zigged while others zagged. +- **`/office-hours` adds Landscape Awareness phase.** After understanding your problem through questioning but before challenging premises, gstack searches for what the world thinks — then runs a three-layer synthesis to find where conventional wisdom might be wrong for your specific case. +- **`/plan-eng-review` adds search check.** Step 0 now verifies architectural patterns against current best practices and flags custom solutions where built-ins exist. +- **`/investigate` searches on hypothesis failure.** When your first debugging hypothesis is wrong, gstack searches for the exact error message and known framework issues before guessing again. +- **`/design-consultation` three-layer synthesis.** Competitive research now uses the structured Layer 1/2/3 framework to find where your product should deliberately break from category norms. +- **CEO review saves context when handing off to `/office-hours`.** When `/plan-ceo-review` suggests running `/office-hours` first, it now saves a handoff note with your system audit findings and any discussion so far. When you come back and re-invoke `/plan-ceo-review`, it picks up that context automatically — no more starting from scratch. + +## [0.9.4.1] - 2026-03-20 + +### Changed + +- **`/retro` no longer nags about PR size.** The retro still reports PR size distribution (Small/Medium/Large/XL) as neutral data, but no longer flags XL PRs as problems or recommends splitting them. AI reviews don't fatigue — the unit of work is the feature, not the diff. + +## [0.9.4.0] - 2026-03-20 — Codex Reviews On By Default + +### Changed + +- **Codex code reviews now run automatically in `/ship` and `/review`.** No more "want a second opinion?" prompt every time — Codex reviews both your code (with a pass/fail gate) and runs an adversarial challenge by default. First-time users get a one-time opt-in prompt; after that, it's hands-free. Configure with `gstack-config set codex_reviews enabled|disabled`. +- **All Codex operations use maximum reasoning power.** Review, adversarial, and consult modes all use `xhigh` reasoning effort — when an AI is reviewing your code, you want it thinking as hard as possible. +- **Codex review errors can't corrupt the dashboard.** Auth failures, timeouts, and empty responses are now detected before logging results, so the Review Readiness Dashboard never shows a false "passed" entry. Adversarial stderr is captured separately. +- **Codex review log includes commit hash.** Staleness detection now works correctly for Codex reviews, matching the same commit-tracking behavior as eng/CEO/design reviews. + +### Fixed + +- **Codex-for-Codex recursion prevented.** When gstack runs inside Codex CLI (`.agents/skills/`), the Codex review step is completely stripped — no accidental infinite loops. + +## [0.9.3.0] - 2026-03-20 — Windows Support + +### Fixed + +- **gstack now works on Windows 11.** Setup no longer hangs when verifying Playwright, and the browse server automatically falls back to Node.js to work around a Bun pipe-handling bug on Windows ([bun#4253](https://github.com/oven-sh/bun/issues/4253)). Just make sure Node.js is installed alongside Bun. macOS and Linux are completely unaffected. +- **Path handling works on Windows.** All hardcoded `/tmp` paths and Unix-style path separators now use platform-aware equivalents via a new `platform.ts` module. Path traversal protection works correctly with Windows backslash separators. + +### Added + +- **Bun API polyfill for Node.js.** When the browse server runs under Node.js on Windows, a compatibility layer provides `Bun.serve()`, `Bun.spawn()`, `Bun.spawnSync()`, and `Bun.sleep()` equivalents. Fully tested. +- **Node server build script.** `browse/scripts/build-node-server.sh` transpiles the server for Node.js, stubs `bun:sqlite`, and injects the polyfill — all automated during `bun run build`. + +## [0.9.2.0] - 2026-03-20 — Gemini CLI E2E Tests + +### Added + +- **Gemini CLI is now tested end-to-end.** Two E2E tests verify that gstack skills work when invoked by Google's Gemini CLI (`gemini -p`). The `gemini-discover-skill` test confirms skill discovery from `.agents/skills/`, and `gemini-review-findings` runs a full code review via gstack-review. Both parse Gemini's stream-json NDJSON output and track token usage. +- **Gemini JSONL parser with 10 unit tests.** `parseGeminiJSONL` handles all Gemini event types (init, message, tool_use, tool_result, result) with defensive parsing for malformed input. The parser is a pure function, independently testable without spawning the CLI. +- **`bun run test:gemini`** and **`bun run test:gemini:all`** scripts for running Gemini E2E tests independently. Gemini tests are also included in `test:evals` and `test:e2e` aggregate scripts. + +## [0.9.1.0] - 2026-03-20 — Adversarial Spec Review + Skill Chaining + +### Added + +- **Your design docs now get stress-tested before you see them.** When you run `/office-hours`, an independent AI reviewer checks your design doc for completeness, consistency, clarity, scope creep, and feasibility — up to 3 rounds. You get a quality score (1-10) and a summary of what was caught and fixed. The doc you approve has already survived adversarial review. +- **Visual wireframes during brainstorming.** For UI ideas, `/office-hours` now generates a rough HTML wireframe using your project's design system (from DESIGN.md) and screenshots it. You see what you're designing while you're still thinking, not after you've coded it. +- **Skills help each other now.** `/plan-ceo-review` and `/plan-eng-review` detect when you'd benefit from running `/office-hours` first and offer it — one-tap to switch, one-tap to decline. If you seem lost during a CEO review, it'll gently suggest brainstorming first. +- **Spec review metrics.** Every adversarial review logs iterations, issues found/fixed, and quality score to `~/.gstack/analytics/spec-review.jsonl`. Over time, you can see if your design docs are getting better. + +## [0.9.0.1] - 2026-03-19 + +### Changed + +- **Telemetry opt-in now defaults to community mode.** First-time prompt asks "Help gstack get better!" (community mode with stable device ID for trend tracking). If you decline, you get a second chance with anonymous mode (no unique ID, just a counter). Respects your choice either way. + +### Fixed + +- **Review logs and telemetry now persist during plan mode.** When you ran `/plan-ceo-review`, `/plan-eng-review`, or `/plan-design-review` in plan mode, the review result wasn't saved to disk — so the dashboard showed stale or missing entries even though you just completed a review. Same issue affected telemetry logging at the end of every skill. Both now work reliably in plan mode. + +## [0.9.0] - 2026-03-19 — Works on Codex, Gemini CLI, and Cursor + +**gstack now works on any AI agent that supports the open SKILL.md standard.** Install once, use from Claude Code, OpenAI Codex CLI, Google Gemini CLI, or Cursor. All 21 skills are available in `.agents/skills/` -- just run `./setup --host codex` or `./setup --host auto` and your agent discovers them automatically. + +- **One install, four agents.** Claude Code reads from `.claude/skills/`, everything else reads from `.agents/skills/`. Same skills, same prompts, adapted for each host. Hook-based safety skills (careful, freeze, guard) get inline safety advisory prose instead of hooks -- they work everywhere. +- **Auto-detection.** `./setup --host auto` detects which agents you have installed and sets up both. Already have Claude Code? It still works exactly the same. +- **Codex-adapted output.** Frontmatter is stripped to just name + description (Codex doesn't need allowed-tools or hooks). Paths are rewritten from `~/.claude/` to `~/.codex/`. The `/codex` skill itself is excluded from Codex output -- it's a Claude wrapper around `codex exec`, which would be self-referential. +- **CI checks both hosts.** The freshness check now validates Claude and Codex output independently. Stale Codex docs break the build just like stale Claude docs. + +## [0.8.6] - 2026-03-19 + +### Added + +- **You can now see how you use gstack.** Run `gstack-analytics` to see a personal usage dashboard — which skills you use most, how long they take, your success rate. All data stays local on your machine. +- **Opt-in community telemetry.** On first run, gstack asks if you want to share anonymous usage data (skill names, duration, crash info — never code or file paths). Choose "yes" and you're part of the community pulse. Change anytime with `gstack-config set telemetry off`. +- **Community health dashboard.** Run `gstack-community-dashboard` to see what the gstack community is building — most popular skills, crash clusters, version distribution. All powered by Supabase. +- **Install base tracking via update check.** When telemetry is enabled, gstack fires a parallel ping to Supabase during update checks — giving us an install-base count without adding any latency. Respects your telemetry setting (default off). GitHub remains the primary version source. +- **Crash clustering.** Errors are automatically grouped by type and version in the Supabase backend, so the most impactful bugs surface first. +- **Upgrade funnel tracking.** We can now see how many people see upgrade prompts vs actually upgrade — helps us ship better releases. +- **/retro now shows your gstack usage.** Weekly retrospectives include skill usage stats (which skills you used, how often, success rate) alongside your commit history. +- **Session-specific pending markers.** If a skill crashes mid-run, the next invocation correctly finalizes only that session — no more race conditions between concurrent gstack sessions. + +## [0.8.5] - 2026-03-19 + +### Fixed + +- **`/retro` now counts full calendar days.** Running a retro late at night no longer silently misses commits from earlier in the day. Git treats bare dates like `--since="2026-03-11"` as "11pm on March 11" if you run it at 11pm — now we pass `--since="2026-03-11T00:00:00"` so it always starts from midnight. Compare mode windows get the same fix. +- **Review log no longer breaks on branch names with `/`.** Branch names like `garrytan/design-system` caused review log writes to fail because Claude Code runs multi-line bash blocks as separate shell invocations, losing variables between commands. New `gstack-review-log` and `gstack-review-read` atomic helpers encapsulate the entire operation in a single command. +- **All skill templates are now platform-agnostic.** Removed Rails-specific patterns (`bin/test-lane`, `RAILS_ENV`, `.includes()`, `rescue StandardError`, etc.) from `/ship`, `/review`, `/plan-ceo-review`, and `/plan-eng-review`. The review checklist now shows examples for Rails, Node, Python, and Django side-by-side. +- **`/ship` reads CLAUDE.md to discover test commands** instead of hardcoding `bin/test-lane` and `npm run test`. If no test commands are found, it asks the user and persists the answer to CLAUDE.md. + +### Added + +- **Platform-agnostic design principle** codified in CLAUDE.md — skills must read project config, never hardcode framework commands. +- **`## Testing` section** in CLAUDE.md for `/ship` test command discovery. + +## [0.8.4] - 2026-03-19 + +### Added + +- **`/ship` now automatically syncs your docs.** After creating the PR, `/ship` runs `/document-release` as Step 8.5 — README, ARCHITECTURE, CONTRIBUTING, and CLAUDE.md all stay current without an extra command. No more stale docs after shipping. +- **Six new skills in the docs.** README, docs/skills.md, and BROWSER.md now cover `/codex` (multi-AI second opinion), `/careful` (destructive command warnings), `/freeze` (directory-scoped edit lock), `/guard` (full safety mode), `/unfreeze`, and `/gstack-upgrade`. The sprint skill table keeps its 15 specialists; a new "Power tools" section covers the rest. +- **Browse handoff documented everywhere.** BROWSER.md command table, docs/skills.md deep-dive, and README "What's new" all explain `$B handoff` and `$B resume` for CAPTCHA/MFA/auth walls. +- **Proactive suggestions know about all skills.** Root SKILL.md.tmpl now suggests `/codex`, `/careful`, `/freeze`, `/guard`, `/unfreeze`, and `/gstack-upgrade` at the right workflow stages. + +## [0.8.3] - 2026-03-19 + +### Added + +- **Plan reviews now guide you to the next step.** After running `/plan-ceo-review`, `/plan-eng-review`, or `/plan-design-review`, you get a recommendation for what to run next — eng review is always suggested as the required shipping gate, design review is suggested when UI changes are detected, and CEO review is softly mentioned for big product changes. No more remembering the workflow yourself. +- **Reviews know when they're stale.** Each review now records the commit it was run at. The dashboard compares that against your current HEAD and tells you exactly how many commits have elapsed — "eng review may be stale — 13 commits since review" instead of guessing. +- **`skip_eng_review` respected everywhere.** If you've opted out of eng review globally, the chaining recommendations won't nag you about it. +- **Design review lite now tracks commits too.** The lightweight design check that runs inside `/review` and `/ship` gets the same staleness tracking as full reviews. + +### Fixed + +- **Browse no longer navigates to dangerous URLs.** `goto`, `diff`, and `newtab` now block `file://`, `javascript:`, `data:` schemes and cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`). Localhost and private IPs are still allowed for local QA testing. (Closes #17) +- **Setup script tells you what's missing.** Running `./setup` without `bun` installed now shows a clear error with install instructions instead of a cryptic "command not found." (Closes #147) +- **`/debug` renamed to `/investigate`.** Claude Code has a built-in `/debug` command that shadowed the gstack skill. The systematic root-cause debugging workflow now lives at `/investigate`. (Closes #190) +- **Shell injection surface reduced.** gstack-slug output is now sanitized to `[a-zA-Z0-9._-]` only, making both `eval` and `source` callers safe. (Closes #133) +- **25 new security tests.** URL validation (16 tests) and path traversal validation (14 tests) now have dedicated unit test suites covering scheme blocking, metadata IP blocking, directory escapes, and prefix collision edge cases. + +## [0.8.2] - 2026-03-19 + +### Added + +- **Hand off to a real Chrome when the headless browser gets stuck.** Hit a CAPTCHA, auth wall, or MFA prompt? Run `$B handoff "reason"` and a visible Chrome opens at the exact same page with all your cookies and tabs intact. Solve the problem, tell Claude you're done, and `$B resume` picks up right where you left off with a fresh snapshot. +- **Auto-handoff hint after 3 consecutive failures.** If the browse tool fails 3 times in a row, it suggests using `handoff` — so you don't waste time watching the AI retry a CAPTCHA. +- **15 new tests for the handoff feature.** Unit tests for state save/restore, failure tracking, edge cases, plus integration tests for the full headless-to-headed flow with cookie and tab preservation. + +### Changed + +- `recreateContext()` refactored to use shared `saveState()`/`restoreState()` helpers — same behavior, less code, ready for future state persistence features. +- `browser.close()` now has a 5-second timeout to prevent hangs when closing headed browsers on macOS. + +## [0.8.1] - 2026-03-19 + +### Fixed + +- **`/qa` no longer refuses to use the browser on backend-only changes.** Previously, if your branch only changed prompt templates, config files, or service logic, `/qa` would analyze the diff, conclude "no UI to test," and suggest running evals instead. Now it always opens the browser -- falling back to a Quick mode smoke test (homepage + top 5 navigation targets) when no specific pages are identified from the diff. + +## [0.8.0] - 2026-03-19 — Multi-AI Second Opinion + +**`/codex` — get an independent second opinion from a completely different AI.** + +Three modes. `/codex review` runs OpenAI's Codex CLI against your diff and gives a pass/fail gate — if Codex finds critical issues (`[P1]`), it fails. `/codex challenge` goes adversarial: it tries to find ways your code will fail in production, thinking like an attacker and a chaos engineer. `/codex ` opens a conversation with Codex about your codebase, with session continuity so follow-ups remember context. + +When both `/review` (Claude) and `/codex review` have run, you get a cross-model analysis showing which findings overlap and which are unique to each AI — building intuition for when to trust which system. + +**Integrated everywhere.** After `/review` finishes, it offers a Codex second opinion. During `/ship`, you can run Codex review as an optional gate before pushing. In `/plan-eng-review`, Codex can independently critique your plan before the engineering review begins. All Codex results show up in the Review Readiness Dashboard. + +**Also in this release:** Proactive skill suggestions — gstack now notices what stage of development you're in and suggests the right skill. Don't like it? Say "stop suggesting" and it remembers across sessions. + +## [0.7.4] - 2026-03-18 + +### Changed + +- **`/qa` and `/design-review` now ask what to do with uncommitted changes** instead of refusing to start. When your working tree is dirty, you get an interactive prompt with three options: commit your changes, stash them, or abort. No more cryptic "ERROR: Working tree is dirty" followed by a wall of text. + +## [0.7.3] - 2026-03-18 + +### Added + +- **Safety guardrails you can turn on with one command.** Say "be careful" or "safety mode" and `/careful` will warn you before any destructive command — `rm -rf`, `DROP TABLE`, force-push, `kubectl delete`, and more. You can override every warning. Common build artifact cleanups (`rm -rf node_modules`, `dist`, `.next`) are whitelisted. +- **Lock edits to one folder with `/freeze`.** Debugging something and don't want Claude to "fix" unrelated code? `/freeze` blocks all file edits outside a directory you choose. Hard block, not just a warning. Run `/unfreeze` to remove the restriction without ending your session. +- **`/guard` activates both at once.** One command for maximum safety when touching prod or live systems — destructive command warnings plus directory-scoped edit restrictions. +- **`/debug` now auto-freezes edits to the module being debugged.** After forming a root cause hypothesis, `/debug` locks edits to the narrowest affected directory. No more accidental "fixes" to unrelated code during debugging. +- **You can now see which skills you use and how often.** Every skill invocation is logged locally to `~/.gstack/analytics/skill-usage.jsonl`. Run `bun run analytics` to see your top skills, per-repo breakdown, and how often safety hooks actually catch something. Data stays on your machine. +- **Weekly retros now include skill usage.** `/retro` shows which skills you used during the retro window alongside your usual commit analysis and metrics. + +## [0.7.2] - 2026-03-18 + +### Fixed + +- `/retro` date ranges now align to midnight instead of the current time. Running `/retro` at 9pm no longer silently drops the morning of the start date — you get full calendar days. +- `/retro` timestamps now use your local timezone instead of hardcoded Pacific time. Users outside the US-West coast get correct local hours in histograms, session detection, and streak tracking. + +## [0.7.1] - 2026-03-19 + +### Added + +- **gstack now suggests skills at natural moments.** You don't need to know slash commands — just talk about what you're doing. Brainstorming an idea? gstack suggests `/office-hours`. Something's broken? It suggests `/debug`. Ready to deploy? It suggests `/ship`. Every workflow skill now has proactive triggers that fire when the moment is right. +- **Lifecycle map.** gstack's root skill description now includes a developer workflow guide mapping 12 stages (brainstorm → plan → review → code → debug → test → ship → docs → retro) to the right skill. Claude sees this in every session. +- **Opt-out with natural language.** If proactive suggestions feel too aggressive, just say "stop suggesting things" — gstack remembers across sessions. Say "be proactive again" to re-enable. +- **11 journey-stage E2E tests.** Each test simulates a real moment in the developer lifecycle with realistic project context (plan.md, error logs, git history, code) and verifies the right skill fires from natural language alone. 11/11 pass. +- **Trigger phrase validation.** Static tests verify every workflow skill has "Use when" and "Proactively suggest" phrases — catches regressions for free. + +### Fixed + +- `/debug` and `/office-hours` were completely invisible to natural language — no trigger phrases at all. Now both have full reactive + proactive triggers. + +## [0.7.0] - 2026-03-18 — YC Office Hours + +**`/office-hours` — sit down with a YC partner before you write a line of code.** + +Two modes. If you're building a startup, you get six forcing questions distilled from how YC evaluates products: demand reality, status quo, desperate specificity, narrowest wedge, observation & surprise, and future-fit. If you're hacking on a side project, learning to code, or at a hackathon, you get an enthusiastic brainstorming partner who helps you find the coolest version of your idea. + +Both modes write a design doc that feeds directly into `/plan-ceo-review` and `/plan-eng-review`. After the session, the skill reflects back what it noticed about how you think — specific observations, not generic praise. + +**`/debug` — find the root cause, not the symptom.** + +When something is broken and you don't know why, `/debug` is your systematic debugger. It follows the Iron Law: no fixes without root cause investigation first. Traces data flow, matches against known bug patterns (race conditions, nil propagation, stale cache, config drift), and tests hypotheses one at a time. If 3 fixes fail, it stops and questions the architecture instead of thrashing. + +## [0.6.4.1] - 2026-03-18 + +### Added + +- **Skills now discoverable via natural language.** All 12 skills that were missing explicit trigger phrases now have them — say "deploy this" and Claude finds `/ship`, say "check my diff" and it finds `/review`. Following Anthropic's best practice: "the description field is not a summary — it's when to trigger." + +## [0.6.4.0] - 2026-03-17 + +### Added + +- **`/plan-design-review` is now interactive — rates 0-10, fixes the plan.** Instead of producing a report with letter grades, the designer now works like CEO and Eng review: rates each design dimension 0-10, explains what a 10 looks like, then edits the plan to get there. One AskUserQuestion per design choice. The output is a better plan, not a document about the plan. +- **CEO review now calls in the designer.** When `/plan-ceo-review` detects UI scope in a plan, it activates a Design & UX section (Section 11) covering information architecture, interaction state coverage, AI slop risk, and responsive intention. For deep design work, it recommends `/plan-design-review`. +- **14 of 15 skills now have full test coverage (E2E + LLM-judge + validation).** Added LLM-judge quality evals for 10 skills that were missing them: ship, retro, qa-only, plan-ceo-review, plan-eng-review, plan-design-review, design-review, design-consultation, document-release, gstack-upgrade. Added real E2E test for gstack-upgrade (was a `.todo`). Added design-consultation to command validation. +- **Bisect commit style.** CLAUDE.md now requires every commit to be a single logical change — renames separate from rewrites, test infrastructure separate from test implementations. + +### Changed + +- `/qa-design-review` renamed to `/design-review` — the "qa-" prefix was confusing now that `/plan-design-review` is plan-mode. Updated across all 22 files. + +## [0.6.3.0] - 2026-03-17 + +### Added + +- **Every PR touching frontend code now gets a design review automatically.** `/review` and `/ship` apply a 20-item design checklist against changed CSS, HTML, JSX, and view files. Catches AI slop patterns (purple gradients, 3-column icon grids, generic hero copy), typography issues (body text < 16px, blacklisted fonts), accessibility gaps (`outline: none`), and `!important` abuse. Mechanical CSS fixes are auto-applied; design judgment calls ask you first. +- **`gstack-diff-scope` categorizes what changed in your branch.** Run `source <(gstack-diff-scope main)` and get `SCOPE_FRONTEND=true/false`, `SCOPE_BACKEND`, `SCOPE_PROMPTS`, `SCOPE_TESTS`, `SCOPE_DOCS`, `SCOPE_CONFIG`. Design review uses it to skip silently on backend-only PRs. Ship pre-flight uses it to recommend design review when frontend files are touched. +- **Design review shows up in the Review Readiness Dashboard.** The dashboard now distinguishes between "LITE" (code-level, runs automatically in /review and /ship) and "FULL" (visual audit via /plan-design-review with browse binary). Both show up as Design Review entries. +- **E2E eval for design review detection.** Planted CSS/HTML fixtures with 7 known anti-patterns (Papyrus font, 14px body text, `outline: none`, `!important`, purple gradient, generic hero copy, 3-column feature grid). The eval verifies `/review` catches at least 4 of 7. + +## [0.6.2.0] - 2026-03-17 + +### Added + +- **Plan reviews now think like the best in the world.** `/plan-ceo-review` applies 14 cognitive patterns from Bezos (one-way doors, Day 1 proxy skepticism), Grove (paranoid scanning), Munger (inversion), Horowitz (wartime awareness), Chesky/Graham (founder mode), and Altman (leverage obsession). `/plan-eng-review` applies 15 patterns from Larson (team state diagnosis), McKinley (boring by default), Brooks (essential vs accidental complexity), Beck (make the change easy), Majors (own your code in production), and Google SRE (error budgets). `/plan-design-review` applies 12 patterns from Rams (subtraction default), Norman (time-horizon design), Zhuo (principled taste), Gebbia (design for trust, storyboard the journey), and Ive (care is visible). +- **Latent space activation, not checklists.** The cognitive patterns name-drop frameworks and people so the LLM draws on its deep knowledge of how they actually think. The instruction is "internalize these, don't enumerate them" — making each review a genuine perspective shift, not a longer checklist. + +## [0.6.1.0] - 2026-03-17 + +### Added + +- **E2E and LLM-judge tests now only run what you changed.** Each test declares which source files it depends on. When you run `bun run test:e2e`, it checks your diff and skips tests whose dependencies weren't touched. A branch that only changes `/retro` now runs 2 tests instead of 31. Use `bun run test:e2e:all` to force everything. +- **`bun run eval:select` previews which tests would run.** See exactly which tests your diff triggers before spending API credits. Supports `--json` for scripting and `--base ` to override the base branch. +- **Completeness guardrail catches forgotten test entries.** A free unit test validates that every `testName` in the E2E and LLM-judge test files has a corresponding entry in the TOUCHFILES map. New tests without entries fail `bun test` immediately — no silent always-run degradation. + +### Changed + +- `test:evals` and `test:e2e` now auto-select based on diff (was: all-or-nothing) +- New `test:evals:all` and `test:e2e:all` scripts for explicit full runs + +## 0.6.1 — 2026-03-17 — Boil the Lake + +Every gstack skill now follows the **Completeness Principle**: always recommend the +full implementation when AI makes the marginal cost near-zero. No more "Choose B +because it's 90% of the value" when option A is 70 lines more code. + +Read the philosophy: https://garryslist.org/posts/boil-the-ocean + +- **Completeness scoring**: every AskUserQuestion option now shows a completeness + score (1-10), biasing toward the complete solution +- **Dual time estimates**: effort estimates show both human-team and CC+gstack time + (e.g., "human: ~2 weeks / CC: ~1 hour") with a task-type compression reference table +- **Anti-pattern examples**: concrete "don't do this" gallery in the preamble so the + principle isn't abstract +- **First-time onboarding**: new users see a one-time introduction linking to the + essay, with option to open in browser +- **Review completeness gaps**: `/review` now flags shortcut implementations where the + complete version costs <30 min CC time +- **Lake Score**: CEO and Eng review completion summaries show how many recommendations + chose the complete option vs shortcuts +- **CEO + Eng review dual-time**: temporal interrogation, effort estimates, and delight + opportunities all show both human and CC time scales + +## 0.6.0.1 — 2026-03-17 + +- **`/gstack-upgrade` now catches stale vendored copies automatically.** If your global gstack is up to date but the vendored copy in your project is behind, `/gstack-upgrade` detects the mismatch and syncs it. No more manually asking "did we vendor it?" — it just tells you and offers to update. +- **Upgrade sync is safer.** If `./setup` fails while syncing a vendored copy, gstack restores the previous version from backup instead of leaving a broken install. + +### For contributors + +- Standalone usage section in `gstack-upgrade/SKILL.md.tmpl` now references Steps 2 and 4.5 (DRY) instead of duplicating detection/sync bash blocks. Added one new version-comparison bash block. +- Update check fallback in standalone mode now matches the preamble pattern (global path → local path → `|| true`). + +## 0.6.0 — 2026-03-17 + +- **100% test coverage is the key to great vibe coding.** gstack now bootstraps test frameworks from scratch when your project doesn't have one. Detects your runtime, researches the best framework, asks you to pick, installs it, writes 3-5 real tests for your actual code, sets up CI/CD (GitHub Actions), creates TESTING.md, and adds test culture instructions to CLAUDE.md. Every Claude Code session after that writes tests naturally. +- **Every bug fix now gets a regression test.** When `/qa` fixes a bug and verifies it, Phase 8e.5 automatically generates a regression test that catches the exact scenario that broke. Tests include full attribution tracing back to the QA report. Auto-incrementing filenames prevent collisions across sessions. +- **Ship with confidence — coverage audit shows what's tested and what's not.** `/ship` Step 3.4 builds a code path map from your diff, searches for corresponding tests, and produces an ASCII coverage diagram with quality stars (★★★ = edge cases + errors, ★★ = happy path, ★ = smoke test). Gaps get tests auto-generated. PR body shows "Tests: 42 → 47 (+5 new)". +- **Your retro tracks test health.** `/retro` now shows total test files, tests added this period, regression test commits, and trend deltas. If test ratio drops below 20%, it flags it as a growth area. +- **Design reviews generate regression tests too.** `/qa-design-review` Phase 8e.5 skips CSS-only fixes (those are caught by re-running the design audit) but writes tests for JavaScript behavior changes like broken dropdowns or animation failures. + +### For contributors + +- Added `generateTestBootstrap()` resolver to `gen-skill-docs.ts` (~155 lines). Registered as `{{TEST_BOOTSTRAP}}` in the RESOLVERS map. Inserted into qa, ship (Step 2.5), and qa-design-review templates. +- Phase 8e.5 regression test generation added to `qa/SKILL.md.tmpl` (46 lines) and CSS-aware variant to `qa-design-review/SKILL.md.tmpl` (12 lines). Rule 13 amended to allow creating new test files. +- Step 3.4 test coverage audit added to `ship/SKILL.md.tmpl` (88 lines) with quality scoring rubric and ASCII diagram format. +- Test health tracking added to `retro/SKILL.md.tmpl`: 3 new data gathering commands, metrics row, narrative section, JSON schema field. +- `qa-only/SKILL.md.tmpl` gets recommendation note when no test framework detected. +- `qa-report-template.md` gains Regression Tests section with deferred test specs. +- ARCHITECTURE.md placeholder table updated with `{{TEST_BOOTSTRAP}}` and `{{REVIEW_DASHBOARD}}`. +- WebSearch added to allowed-tools for qa, ship, qa-design-review. +- 26 new validation tests, 2 new E2E evals (bootstrap + coverage audit). +- 2 new P3 TODOs: CI/CD for non-GitHub providers, auto-upgrade weak tests. + +## 0.5.4 — 2026-03-17 + +- **Engineering review is always the full review now.** `/plan-eng-review` no longer asks you to choose between "big change" and "small change" modes. Every plan gets the full interactive walkthrough (architecture, code quality, tests, performance). Scope reduction is only suggested when the complexity check actually triggers — not as a standing menu option. +- **Ship stops asking about reviews once you've answered.** When `/ship` asks about missing reviews and you say "ship anyway" or "not relevant," that decision is saved for the branch. No more getting re-asked every time you re-run `/ship` after a pre-landing fix. + +### For contributors + +- Removed SMALL_CHANGE / BIG_CHANGE / SCOPE_REDUCTION menu from `plan-eng-review/SKILL.md.tmpl`. Scope reduction is now proactive (triggered by complexity check) rather than a menu item. +- Added review gate override persistence to `ship/SKILL.md.tmpl` — writes `ship-review-override` entries to `$BRANCH-reviews.jsonl` so subsequent `/ship` runs skip the gate. +- Updated 2 E2E test prompts to match new flow. + +## 0.5.3 — 2026-03-17 + +- **You're always in control — even when dreaming big.** `/plan-ceo-review` now presents every scope expansion as an individual decision you opt into. EXPANSION mode recommends enthusiastically, but you say yes or no to each idea. No more "the agent went wild and added 5 features I didn't ask for." +- **New mode: SELECTIVE EXPANSION.** Hold your current scope as the baseline, but see what else is possible. The agent surfaces expansion opportunities one by one with neutral recommendations — you cherry-pick the ones worth doing. Perfect for iterating on existing features where you want rigor but also want to be tempted by adjacent improvements. +- **Your CEO review visions are saved, not lost.** Expansion ideas, cherry-pick decisions, and 10x visions are now persisted to `~/.gstack/projects/{repo}/ceo-plans/` as structured design documents. Stale plans get archived automatically. If a vision is exceptional, you can promote it to `docs/designs/` in your repo for the team. + +- **Smarter ship gates.** `/ship` no longer nags you about CEO and Design reviews when they're not relevant. Eng Review is the only required gate (and you can disable even that with `gstack-config set skip_eng_review true`). CEO Review is recommended for big product changes; Design Review for UI work. The dashboard still shows all three — it just won't block you for the optional ones. + +### For contributors + +- Added SELECTIVE EXPANSION mode to `plan-ceo-review/SKILL.md.tmpl` with cherry-pick ceremony, neutral recommendation posture, and HOLD SCOPE baseline. +- Rewrote EXPANSION mode's Step 0D to include opt-in ceremony — distill vision into discrete proposals, present each as AskUserQuestion. +- Added CEO plan persistence (0D-POST step): structured markdown with YAML frontmatter (`status: ACTIVE/ARCHIVED/PROMOTED`), scope decisions table, archival flow. +- Added `docs/designs` promotion step after Review Log. +- Mode Quick Reference table expanded to 4 columns. +- Review Readiness Dashboard: Eng Review required (overridable via `skip_eng_review` config), CEO/Design optional with agent judgment. +- New tests: CEO review mode validation (4 modes, persistence, promotion), SELECTIVE EXPANSION E2E test. + +## 0.5.2 — 2026-03-17 + +- **Your design consultant now takes creative risks.** `/design-consultation` doesn't just propose a safe, coherent system — it explicitly breaks down SAFE CHOICES (category baseline) vs. RISKS (where your product stands out). You pick which rules to break. Every risk comes with a rationale for why it works and what it costs. +- **See the landscape before you choose.** When you opt into research, the agent browses real sites in your space with screenshots and accessibility tree analysis — not just web search results. You see what's out there before making design decisions. +- **Preview pages that look like your product.** The preview page now renders realistic product mockups — dashboards with sidebar nav and data tables, marketing pages with hero sections, settings pages with forms — not just font swatches and color palettes. + +## 0.5.1 — 2026-03-17 +- **Know where you stand before you ship.** Every `/plan-ceo-review`, `/plan-eng-review`, and `/plan-design-review` now logs its result to a review tracker. At the end of each review, you see a **Review Readiness Dashboard** showing which reviews are done, when they ran, and whether they're clean — with a clear CLEARED TO SHIP or NOT READY verdict. +- **`/ship` checks your reviews before creating the PR.** Pre-flight now reads the dashboard and asks if you want to continue when reviews are missing. Informational only — it won't block you, but you'll know what you skipped. +- **One less thing to copy-paste.** The SLUG computation (that opaque sed pipeline for computing `owner-repo` from git remote) is now a shared `bin/gstack-slug` helper. All 14 inline copies across templates replaced with `source <(gstack-slug)`. If the format ever changes, fix it once. +- **Screenshots are now visible during QA and browse sessions.** When gstack takes screenshots, they now show up as clickable image elements in your output — no more invisible `/tmp/browse-screenshot.png` paths you can't see. Works in `/qa`, `/qa-only`, `/plan-design-review`, `/qa-design-review`, `/browse`, and `/gstack`. + +### For contributors + +- Added `{{REVIEW_DASHBOARD}}` resolver to `gen-skill-docs.ts` — shared dashboard reader injected into 4 templates (3 review skills + ship). +- Added `bin/gstack-slug` helper (5-line bash) with unit tests. Outputs `SLUG=` and `BRANCH=` lines, sanitizes `/` to `-`. +- New TODOs: smart review relevance detection (P3), `/merge` skill for review-gated PR merge (P2). + +## 0.5.0 — 2026-03-16 + +- **Your site just got a design review.** `/plan-design-review` opens your site and reviews it like a senior product designer — typography, spacing, hierarchy, color, responsive, interactions, and AI slop detection. Get letter grades (A-F) per category, a dual headline "Design Score" + "AI Slop Score", and a structured first impression that doesn't pull punches. +- **It can fix what it finds, too.** `/qa-design-review` runs the same designer's eye audit, then iteratively fixes design issues in your source code with atomic `style(design):` commits and before/after screenshots. CSS-safe by default, with a stricter self-regulation heuristic tuned for styling changes. +- **Know your actual design system.** Both skills extract your live site's fonts, colors, heading scale, and spacing patterns via JS — then offer to save the inferred system as a `DESIGN.md` baseline. Finally know how many fonts you're actually using. +- **AI Slop detection is a headline metric.** Every report opens with two scores: Design Score and AI Slop Score. The AI slop checklist catches the 10 most recognizable AI-generated patterns — the 3-column feature grid, purple gradients, decorative blobs, emoji bullets, generic hero copy. +- **Design regression tracking.** Reports write a `design-baseline.json`. Next run auto-compares: per-category grade deltas, new findings, resolved findings. Watch your design score improve over time. +- **80-item design audit checklist** across 10 categories: visual hierarchy, typography, color/contrast, spacing/layout, interaction states, responsive, motion, content/microcopy, AI slop, and performance-as-design. Distilled from Vercel's 100+ rules, Anthropic's frontend design skill, and 6 other design frameworks. + +### For contributors + +- Added `{{DESIGN_METHODOLOGY}}` resolver to `gen-skill-docs.ts` — shared design audit methodology injected into both `/plan-design-review` and `/qa-design-review` templates, following the `{{QA_METHODOLOGY}}` pattern. +- Added `~/.gstack-dev/plans/` as a local plans directory for long-range vision docs (not checked in). CLAUDE.md and TODOS.md updated. +- Added `/setup-design-md` to TODOS.md (P2) for interactive DESIGN.md creation from scratch. + +## 0.4.5 — 2026-03-16 + +- **Review findings now actually get fixed, not just listed.** `/review` and `/ship` used to print informational findings (dead code, test gaps, N+1 queries) and then ignore them. Now every finding gets action: obvious mechanical fixes are applied automatically, and genuinely ambiguous issues are batched into a single question instead of 8 separate prompts. You see `[AUTO-FIXED] file:line Problem → what was done` for each auto-fix. +- **You control the line between "just fix it" and "ask me first."** Dead code, stale comments, N+1 queries get auto-fixed. Security issues, race conditions, design decisions get surfaced for your call. The classification lives in one place (`review/checklist.md`) so both `/review` and `/ship` stay in sync. + +### Fixed + +- **`$B js "const x = await fetch(...); return x.status"` now works.** The `js` command used to wrap everything as an expression — so `const`, semicolons, and multi-line code all broke. It now detects statements and uses a block wrapper, just like `eval` already did. +- **Clicking a dropdown option no longer hangs forever.** If an agent sees `@e3 [option] "Admin"` in a snapshot and runs `click @e3`, gstack now auto-selects that option instead of hanging on an impossible Playwright click. The right thing just happens. +- **When click is the wrong tool, gstack tells you.** Clicking an `
+
+
Detecting browsers...
+
+ + + + +
+
Imported to Session
+
+
No cookies imported yet
+
+ +
+ + + + +`; +} diff --git a/.claude/skills/gstack/browse/src/find-browse.ts b/.claude/skills/gstack/browse/src/find-browse.ts new file mode 100644 index 0000000..93c4a26 --- /dev/null +++ b/.claude/skills/gstack/browse/src/find-browse.ts @@ -0,0 +1,61 @@ +/** + * find-browse — locate the gstack browse binary. + * + * Compiled to browse/dist/find-browse (standalone binary, no bun runtime needed). + * Outputs the absolute path to the browse binary on stdout, or exits 1 if not found. + */ + +import { existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +// ─── Binary Discovery ─────────────────────────────────────────── + +function getGitRoot(): string | null { + try { + const proc = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], { + stdout: 'pipe', + stderr: 'pipe', + }); + if (proc.exitCode !== 0) return null; + return proc.stdout.toString().trim(); + } catch { + return null; + } +} + +export function locateBinary(): string | null { + const root = getGitRoot(); + const home = homedir(); + const markers = ['.codex', '.agents', '.claude']; + + // Workspace-local takes priority (for development) + if (root) { + for (const m of markers) { + const local = join(root, m, 'skills', 'gstack', 'browse', 'dist', 'browse'); + if (existsSync(local)) return local; + } + } + + // Global fallback + for (const m of markers) { + const global = join(home, m, 'skills', 'gstack', 'browse', 'dist', 'browse'); + if (existsSync(global)) return global; + } + + return null; +} + +// ─── Main ─────────────────────────────────────────────────────── + +function main() { + const bin = locateBinary(); + if (!bin) { + process.stderr.write('ERROR: browse binary not found. Run: cd && ./setup\n'); + process.exit(1); + } + + console.log(bin); +} + +main(); diff --git a/.claude/skills/gstack/browse/src/meta-commands.ts b/.claude/skills/gstack/browse/src/meta-commands.ts new file mode 100644 index 0000000..16ed7f8 --- /dev/null +++ b/.claude/skills/gstack/browse/src/meta-commands.ts @@ -0,0 +1,269 @@ +/** + * Meta commands — tabs, server control, screenshots, chain, diff, snapshot + */ + +import type { BrowserManager } from './browser-manager'; +import { handleSnapshot } from './snapshot'; +import { getCleanText } from './read-commands'; +import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands'; +import { validateNavigationUrl } from './url-validation'; +import * as Diff from 'diff'; +import * as fs from 'fs'; +import * as path from 'path'; +import { TEMP_DIR, isPathWithin } from './platform'; + +// Security: Path validation to prevent path traversal attacks +const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()]; + +export function validateOutputPath(filePath: string): void { + const resolved = path.resolve(filePath); + const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir)); + if (!isSafe) { + throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`); + } +} + +export async function handleMetaCommand( + command: string, + args: string[], + bm: BrowserManager, + shutdown: () => Promise | void +): Promise { + switch (command) { + // ─── Tabs ────────────────────────────────────────── + case 'tabs': { + const tabs = await bm.getTabListWithTitles(); + return tabs.map(t => + `${t.active ? '→ ' : ' '}[${t.id}] ${t.title || '(untitled)'} — ${t.url}` + ).join('\n'); + } + + case 'tab': { + const id = parseInt(args[0], 10); + if (isNaN(id)) throw new Error('Usage: browse tab '); + bm.switchTab(id); + return `Switched to tab ${id}`; + } + + case 'newtab': { + const url = args[0]; + const id = await bm.newTab(url); + return `Opened tab ${id}${url ? ` → ${url}` : ''}`; + } + + case 'closetab': { + const id = args[0] ? parseInt(args[0], 10) : undefined; + await bm.closeTab(id); + return `Closed tab${id ? ` ${id}` : ''}`; + } + + // ─── Server Control ──────────────────────────────── + case 'status': { + const page = bm.getPage(); + const tabs = bm.getTabCount(); + return [ + `Status: healthy`, + `URL: ${page.url()}`, + `Tabs: ${tabs}`, + `PID: ${process.pid}`, + ].join('\n'); + } + + case 'url': { + return bm.getCurrentUrl(); + } + + case 'stop': { + await shutdown(); + return 'Server stopped'; + } + + case 'restart': { + // Signal that we want a restart — the CLI will detect exit and restart + console.log('[browse] Restart requested. Exiting for CLI to restart.'); + await shutdown(); + return 'Restarting...'; + } + + // ─── Visual ──────────────────────────────────────── + case 'screenshot': { + // Parse priority: flags (--viewport, --clip) → selector (@ref, CSS) → output path + const page = bm.getPage(); + let outputPath = `${TEMP_DIR}/browse-screenshot.png`; + let clipRect: { x: number; y: number; width: number; height: number } | undefined; + let targetSelector: string | undefined; + let viewportOnly = false; + + const remaining: string[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--viewport') { + viewportOnly = true; + } else if (args[i] === '--clip') { + const coords = args[++i]; + if (!coords) throw new Error('Usage: screenshot --clip x,y,w,h [path]'); + const parts = coords.split(',').map(Number); + if (parts.length !== 4 || parts.some(isNaN)) + throw new Error('Usage: screenshot --clip x,y,width,height — all must be numbers'); + clipRect = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }; + } else if (args[i].startsWith('--')) { + throw new Error(`Unknown screenshot flag: ${args[i]}`); + } else { + remaining.push(args[i]); + } + } + + // Separate target (selector/@ref) from output path + for (const arg of remaining) { + if (arg.startsWith('@e') || arg.startsWith('@c') || arg.startsWith('.') || arg.startsWith('#') || arg.includes('[')) { + targetSelector = arg; + } else { + outputPath = arg; + } + } + + validateOutputPath(outputPath); + + if (clipRect && targetSelector) { + throw new Error('Cannot use --clip with a selector/ref — choose one'); + } + if (viewportOnly && clipRect) { + throw new Error('Cannot use --viewport with --clip — choose one'); + } + + if (targetSelector) { + const resolved = await bm.resolveRef(targetSelector); + const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector); + await locator.screenshot({ path: outputPath, timeout: 5000 }); + return `Screenshot saved (element): ${outputPath}`; + } + + if (clipRect) { + await page.screenshot({ path: outputPath, clip: clipRect }); + return `Screenshot saved (clip ${clipRect.x},${clipRect.y},${clipRect.width},${clipRect.height}): ${outputPath}`; + } + + await page.screenshot({ path: outputPath, fullPage: !viewportOnly }); + return `Screenshot saved${viewportOnly ? ' (viewport)' : ''}: ${outputPath}`; + } + + case 'pdf': { + const page = bm.getPage(); + const pdfPath = args[0] || `${TEMP_DIR}/browse-page.pdf`; + validateOutputPath(pdfPath); + await page.pdf({ path: pdfPath, format: 'A4' }); + return `PDF saved: ${pdfPath}`; + } + + case 'responsive': { + const page = bm.getPage(); + const prefix = args[0] || `${TEMP_DIR}/browse-responsive`; + validateOutputPath(prefix); + const viewports = [ + { name: 'mobile', width: 375, height: 812 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'desktop', width: 1280, height: 720 }, + ]; + const originalViewport = page.viewportSize(); + const results: string[] = []; + + for (const vp of viewports) { + await page.setViewportSize({ width: vp.width, height: vp.height }); + const path = `${prefix}-${vp.name}.png`; + await page.screenshot({ path, fullPage: true }); + results.push(`${vp.name} (${vp.width}x${vp.height}): ${path}`); + } + + // Restore original viewport + if (originalViewport) { + await page.setViewportSize(originalViewport); + } + + return results.join('\n'); + } + + // ─── Chain ───────────────────────────────────────── + case 'chain': { + // Read JSON array from args[0] (if provided) or expect it was passed as body + const jsonStr = args[0]; + if (!jsonStr) throw new Error('Usage: echo \'[["goto","url"],["text"]]\' | browse chain'); + + let commands: string[][]; + try { + commands = JSON.parse(jsonStr); + } catch { + throw new Error('Invalid JSON. Expected: [["command", "arg1", "arg2"], ...]'); + } + + if (!Array.isArray(commands)) throw new Error('Expected JSON array of commands'); + + const results: string[] = []; + const { handleReadCommand } = await import('./read-commands'); + const { handleWriteCommand } = await import('./write-commands'); + + for (const cmd of commands) { + const [name, ...cmdArgs] = cmd; + try { + let result: string; + if (WRITE_COMMANDS.has(name)) result = await handleWriteCommand(name, cmdArgs, bm); + else if (READ_COMMANDS.has(name)) result = await handleReadCommand(name, cmdArgs, bm); + else if (META_COMMANDS.has(name)) result = await handleMetaCommand(name, cmdArgs, bm, shutdown); + else throw new Error(`Unknown command: ${name}`); + results.push(`[${name}] ${result}`); + } catch (err: any) { + results.push(`[${name}] ERROR: ${err.message}`); + } + } + + return results.join('\n\n'); + } + + // ─── Diff ────────────────────────────────────────── + case 'diff': { + const [url1, url2] = args; + if (!url1 || !url2) throw new Error('Usage: browse diff '); + + const page = bm.getPage(); + await validateNavigationUrl(url1); + await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 }); + const text1 = await getCleanText(page); + + await validateNavigationUrl(url2); + await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 }); + const text2 = await getCleanText(page); + + const changes = Diff.diffLines(text1, text2); + const output: string[] = [`--- ${url1}`, `+++ ${url2}`, '']; + + for (const part of changes) { + const prefix = part.added ? '+' : part.removed ? '-' : ' '; + const lines = part.value.split('\n').filter(l => l.length > 0); + for (const line of lines) { + output.push(`${prefix} ${line}`); + } + } + + return output.join('\n'); + } + + // ─── Snapshot ───────────────────────────────────── + case 'snapshot': { + return await handleSnapshot(args, bm); + } + + // ─── Handoff ──────────────────────────────────── + case 'handoff': { + const message = args.join(' ') || 'User takeover requested'; + return await bm.handoff(message); + } + + case 'resume': { + bm.resume(); + // Re-snapshot to capture current page state after human interaction + const snapshot = await handleSnapshot(['-i'], bm); + return `RESUMED\n${snapshot}`; + } + + default: + throw new Error(`Unknown meta command: ${command}`); + } +} diff --git a/.claude/skills/gstack/browse/src/platform.ts b/.claude/skills/gstack/browse/src/platform.ts new file mode 100644 index 0000000..c022b1d --- /dev/null +++ b/.claude/skills/gstack/browse/src/platform.ts @@ -0,0 +1,17 @@ +/** + * Cross-platform constants for gstack browse. + * + * On macOS/Linux: TEMP_DIR = '/tmp', path.sep = '/' — identical to hardcoded values. + * On Windows: TEMP_DIR = os.tmpdir(), path.sep = '\\' — correct Windows behavior. + */ + +import * as os from 'os'; +import * as path from 'path'; + +export const IS_WINDOWS = process.platform === 'win32'; +export const TEMP_DIR = IS_WINDOWS ? os.tmpdir() : '/tmp'; + +/** Check if resolvedPath is within dir, using platform-aware separators. */ +export function isPathWithin(resolvedPath: string, dir: string): boolean { + return resolvedPath === dir || resolvedPath.startsWith(dir + path.sep); +} diff --git a/.claude/skills/gstack/browse/src/read-commands.ts b/.claude/skills/gstack/browse/src/read-commands.ts new file mode 100644 index 0000000..5d93156 --- /dev/null +++ b/.claude/skills/gstack/browse/src/read-commands.ts @@ -0,0 +1,335 @@ +/** + * Read commands — extract data from pages without side effects + * + * text, html, links, forms, accessibility, js, eval, css, attrs, + * console, network, cookies, storage, perf + */ + +import type { BrowserManager } from './browser-manager'; +import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers'; +import type { Page } from 'playwright'; +import * as fs from 'fs'; +import * as path from 'path'; +import { TEMP_DIR, isPathWithin } from './platform'; + +/** Detect await keyword, ignoring comments. Accepted risk: await in string literals triggers wrapping (harmless). */ +function hasAwait(code: string): boolean { + const stripped = code.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''); + return /\bawait\b/.test(stripped); +} + +/** Detect whether code needs a block wrapper {…} vs expression wrapper (…) inside an async IIFE. */ +function needsBlockWrapper(code: string): boolean { + const trimmed = code.trim(); + if (trimmed.split('\n').length > 1) return true; + if (/\b(const|let|var|function|class|return|throw|if|for|while|switch|try)\b/.test(trimmed)) return true; + if (trimmed.includes(';')) return true; + return false; +} + +/** Wrap code for page.evaluate(), using async IIFE with block or expression body as needed. */ +function wrapForEvaluate(code: string): string { + if (!hasAwait(code)) return code; + const trimmed = code.trim(); + return needsBlockWrapper(trimmed) + ? `(async()=>{\n${code}\n})()` + : `(async()=>(${trimmed}))()`; +} + +// Security: Path validation to prevent path traversal attacks +const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()]; + +export function validateReadPath(filePath: string): void { + if (path.isAbsolute(filePath)) { + const resolved = path.resolve(filePath); + const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir)); + if (!isSafe) { + throw new Error(`Absolute path must be within: ${SAFE_DIRECTORIES.join(', ')}`); + } + } + const normalized = path.normalize(filePath); + if (normalized.includes('..')) { + throw new Error('Path traversal sequences (..) are not allowed'); + } +} + +/** + * Extract clean text from a page (strips script/style/noscript/svg). + * Exported for DRY reuse in meta-commands (diff). + */ +export async function getCleanText(page: Page): Promise { + return await page.evaluate(() => { + const body = document.body; + if (!body) return ''; + const clone = body.cloneNode(true) as HTMLElement; + clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove()); + return clone.innerText + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .join('\n'); + }); +} + +export async function handleReadCommand( + command: string, + args: string[], + bm: BrowserManager +): Promise { + const page = bm.getPage(); + + switch (command) { + case 'text': { + return await getCleanText(page); + } + + case 'html': { + const selector = args[0]; + if (selector) { + const resolved = await bm.resolveRef(selector); + if ('locator' in resolved) { + return await resolved.locator.innerHTML({ timeout: 5000 }); + } + return await page.innerHTML(resolved.selector); + } + return await page.content(); + } + + case 'links': { + const links = await page.evaluate(() => + [...document.querySelectorAll('a[href]')].map(a => ({ + text: a.textContent?.trim().slice(0, 120) || '', + href: (a as HTMLAnchorElement).href, + })).filter(l => l.text && l.href) + ); + return links.map(l => `${l.text} → ${l.href}`).join('\n'); + } + + case 'forms': { + const forms = await page.evaluate(() => { + return [...document.querySelectorAll('form')].map((form, i) => { + const fields = [...form.querySelectorAll('input, select, textarea')].map(el => { + const input = el as HTMLInputElement; + return { + tag: el.tagName.toLowerCase(), + type: input.type || undefined, + name: input.name || undefined, + id: input.id || undefined, + placeholder: input.placeholder || undefined, + required: input.required || undefined, + value: input.type === 'password' ? '[redacted]' : (input.value || undefined), + options: el.tagName === 'SELECT' + ? [...(el as HTMLSelectElement).options].map(o => ({ value: o.value, text: o.text })) + : undefined, + }; + }); + return { + index: i, + action: form.action || undefined, + method: form.method || 'get', + id: form.id || undefined, + fields, + }; + }); + }); + return JSON.stringify(forms, null, 2); + } + + case 'accessibility': { + const snapshot = await page.locator("body").ariaSnapshot(); + return snapshot; + } + + case 'js': { + const expr = args[0]; + if (!expr) throw new Error('Usage: browse js '); + const wrapped = wrapForEvaluate(expr); + const result = await page.evaluate(wrapped); + return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? ''); + } + + case 'eval': { + const filePath = args[0]; + if (!filePath) throw new Error('Usage: browse eval '); + validateReadPath(filePath); + if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`); + const code = fs.readFileSync(filePath, 'utf-8'); + const wrapped = wrapForEvaluate(code); + const result = await page.evaluate(wrapped); + return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? ''); + } + + case 'css': { + const [selector, property] = args; + if (!selector || !property) throw new Error('Usage: browse css '); + const resolved = await bm.resolveRef(selector); + if ('locator' in resolved) { + const value = await resolved.locator.evaluate( + (el, prop) => getComputedStyle(el).getPropertyValue(prop), + property + ); + return value; + } + const value = await page.evaluate( + ([sel, prop]) => { + const el = document.querySelector(sel); + if (!el) return `Element not found: ${sel}`; + return getComputedStyle(el).getPropertyValue(prop); + }, + [resolved.selector, property] + ); + return value; + } + + case 'attrs': { + const selector = args[0]; + if (!selector) throw new Error('Usage: browse attrs '); + const resolved = await bm.resolveRef(selector); + if ('locator' in resolved) { + const attrs = await resolved.locator.evaluate((el) => { + const result: Record = {}; + for (const attr of el.attributes) { + result[attr.name] = attr.value; + } + return result; + }); + return JSON.stringify(attrs, null, 2); + } + const attrs = await page.evaluate((sel) => { + const el = document.querySelector(sel); + if (!el) return `Element not found: ${sel}`; + const result: Record = {}; + for (const attr of el.attributes) { + result[attr.name] = attr.value; + } + return result; + }, resolved.selector); + return typeof attrs === 'string' ? attrs : JSON.stringify(attrs, null, 2); + } + + case 'console': { + if (args[0] === '--clear') { + consoleBuffer.clear(); + return 'Console buffer cleared.'; + } + const entries = args[0] === '--errors' + ? consoleBuffer.toArray().filter(e => e.level === 'error' || e.level === 'warning') + : consoleBuffer.toArray(); + if (entries.length === 0) return args[0] === '--errors' ? '(no console errors)' : '(no console messages)'; + return entries.map(e => + `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}` + ).join('\n'); + } + + case 'network': { + if (args[0] === '--clear') { + networkBuffer.clear(); + return 'Network buffer cleared.'; + } + if (networkBuffer.length === 0) return '(no network requests)'; + return networkBuffer.toArray().map(e => + `${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)` + ).join('\n'); + } + + case 'dialog': { + if (args[0] === '--clear') { + dialogBuffer.clear(); + return 'Dialog buffer cleared.'; + } + if (dialogBuffer.length === 0) return '(no dialogs captured)'; + return dialogBuffer.toArray().map(e => + `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}` + ).join('\n'); + } + + case 'is': { + const property = args[0]; + const selector = args[1]; + if (!property || !selector) throw new Error('Usage: browse is \nProperties: visible, hidden, enabled, disabled, checked, editable, focused'); + + const resolved = await bm.resolveRef(selector); + let locator; + if ('locator' in resolved) { + locator = resolved.locator; + } else { + locator = page.locator(resolved.selector); + } + + switch (property) { + case 'visible': return String(await locator.isVisible()); + case 'hidden': return String(await locator.isHidden()); + case 'enabled': return String(await locator.isEnabled()); + case 'disabled': return String(await locator.isDisabled()); + case 'checked': return String(await locator.isChecked()); + case 'editable': return String(await locator.isEditable()); + case 'focused': { + const isFocused = await locator.evaluate( + (el) => el === document.activeElement + ); + return String(isFocused); + } + default: + throw new Error(`Unknown property: ${property}. Use: visible, hidden, enabled, disabled, checked, editable, focused`); + } + } + + case 'cookies': { + const cookies = await page.context().cookies(); + return JSON.stringify(cookies, null, 2); + } + + case 'storage': { + if (args[0] === 'set' && args[1]) { + const key = args[1]; + const value = args[2] || ''; + await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]); + return `Set localStorage["${key}"]`; + } + const storage = await page.evaluate(() => ({ + localStorage: { ...localStorage }, + sessionStorage: { ...sessionStorage }, + })); + // Redact values that look like secrets (tokens, keys, passwords, JWTs) + const SENSITIVE_KEY = /(^|[_.-])(token|secret|key|password|credential|auth|jwt|session|csrf)($|[_.-])|api.?key/i; + const SENSITIVE_VALUE = /^(eyJ|sk-|sk_live_|sk_test_|pk_live_|pk_test_|rk_live_|sk-ant-|ghp_|gho_|github_pat_|xox[bpsa]-|AKIA[A-Z0-9]{16}|AIza|SG\.|Bearer\s|sbp_)/; + const redacted = JSON.parse(JSON.stringify(storage)); + for (const storeType of ['localStorage', 'sessionStorage'] as const) { + const store = redacted[storeType]; + if (!store) continue; + for (const [key, value] of Object.entries(store)) { + if (typeof value !== 'string') continue; + if (SENSITIVE_KEY.test(key) || SENSITIVE_VALUE.test(value)) { + store[key] = `[REDACTED — ${value.length} chars]`; + } + } + } + return JSON.stringify(redacted, null, 2); + } + + case 'perf': { + const timings = await page.evaluate(() => { + const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; + if (!nav) return 'No navigation timing data available.'; + return { + dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart), + tcp: Math.round(nav.connectEnd - nav.connectStart), + ssl: Math.round(nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0), + ttfb: Math.round(nav.responseStart - nav.requestStart), + download: Math.round(nav.responseEnd - nav.responseStart), + domParse: Math.round(nav.domInteractive - nav.responseEnd), + domReady: Math.round(nav.domContentLoadedEventEnd - nav.startTime), + load: Math.round(nav.loadEventEnd - nav.startTime), + total: Math.round(nav.loadEventEnd - nav.startTime), + }; + }); + if (typeof timings === 'string') return timings; + return Object.entries(timings) + .map(([k, v]) => `${k.padEnd(12)} ${v}ms`) + .join('\n'); + } + + default: + throw new Error(`Unknown read command: ${command}`); + } +} diff --git a/.claude/skills/gstack/browse/src/server.ts b/.claude/skills/gstack/browse/src/server.ts new file mode 100644 index 0000000..fe2c27c --- /dev/null +++ b/.claude/skills/gstack/browse/src/server.ts @@ -0,0 +1,385 @@ +/** + * gstack browse server — persistent Chromium daemon + * + * Architecture: + * Bun.serve HTTP on localhost → routes commands to Playwright + * Console/network/dialog buffers: CircularBuffer in-memory + async disk flush + * Chromium crash → server EXITS with clear error (CLI auto-restarts) + * Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min) + * + * State: + * State file: /.gstack/browse.json (set via BROWSE_STATE_FILE env) + * Log files: /.gstack/browse-{console,network,dialog}.log + * Port: random 10000-60000 (or BROWSE_PORT env for debug override) + */ + +import { BrowserManager } from './browser-manager'; +import { handleReadCommand } from './read-commands'; +import { handleWriteCommand } from './write-commands'; +import { handleMetaCommand } from './meta-commands'; +import { handleCookiePickerRoute } from './cookie-picker-routes'; +import { COMMAND_DESCRIPTIONS } from './commands'; +import { SNAPSHOT_FLAGS } from './snapshot'; +import { resolveConfig, ensureStateDir, readVersionHash } from './config'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; + +// ─── Config ───────────────────────────────────────────────────── +const config = resolveConfig(); +ensureStateDir(config); + +// ─── Auth ─────────────────────────────────────────────────────── +const AUTH_TOKEN = crypto.randomUUID(); +const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10); +const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min + +function validateAuth(req: Request): boolean { + const header = req.headers.get('authorization'); + return header === `Bearer ${AUTH_TOKEN}`; +} + +// ─── Help text (auto-generated from COMMAND_DESCRIPTIONS) ──────── +function generateHelpText(): string { + // Group commands by category + const groups = new Map(); + for (const [cmd, meta] of Object.entries(COMMAND_DESCRIPTIONS)) { + const display = meta.usage || cmd; + const list = groups.get(meta.category) || []; + list.push(display); + groups.set(meta.category, list); + } + + const categoryOrder = [ + 'Navigation', 'Reading', 'Interaction', 'Inspection', + 'Visual', 'Snapshot', 'Meta', 'Tabs', 'Server', + ]; + + const lines = ['gstack browse — headless browser for AI agents', '', 'Commands:']; + for (const cat of categoryOrder) { + const cmds = groups.get(cat); + if (!cmds) continue; + lines.push(` ${(cat + ':').padEnd(15)}${cmds.join(', ')}`); + } + + // Snapshot flags from source of truth + lines.push(''); + lines.push('Snapshot flags:'); + const flagPairs: string[] = []; + for (const flag of SNAPSHOT_FLAGS) { + const label = flag.valueHint ? `${flag.short} ${flag.valueHint}` : flag.short; + flagPairs.push(`${label} ${flag.long}`); + } + // Print two flags per line for compact display + for (let i = 0; i < flagPairs.length; i += 2) { + const left = flagPairs[i].padEnd(28); + const right = flagPairs[i + 1] || ''; + lines.push(` ${left}${right}`); + } + + return lines.join('\n'); +} + +// ─── Buffer (from buffers.ts) ──────────────────────────────────── +import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers'; +export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry }; + +const CONSOLE_LOG_PATH = config.consoleLog; +const NETWORK_LOG_PATH = config.networkLog; +const DIALOG_LOG_PATH = config.dialogLog; +let lastConsoleFlushed = 0; +let lastNetworkFlushed = 0; +let lastDialogFlushed = 0; +let flushInProgress = false; + +async function flushBuffers() { + if (flushInProgress) return; // Guard against concurrent flush + flushInProgress = true; + + try { + // Console buffer + const newConsoleCount = consoleBuffer.totalAdded - lastConsoleFlushed; + if (newConsoleCount > 0) { + const entries = consoleBuffer.last(Math.min(newConsoleCount, consoleBuffer.length)); + const lines = entries.map(e => + `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}` + ).join('\n') + '\n'; + fs.appendFileSync(CONSOLE_LOG_PATH, lines); + lastConsoleFlushed = consoleBuffer.totalAdded; + } + + // Network buffer + const newNetworkCount = networkBuffer.totalAdded - lastNetworkFlushed; + if (newNetworkCount > 0) { + const entries = networkBuffer.last(Math.min(newNetworkCount, networkBuffer.length)); + const lines = entries.map(e => + `[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)` + ).join('\n') + '\n'; + fs.appendFileSync(NETWORK_LOG_PATH, lines); + lastNetworkFlushed = networkBuffer.totalAdded; + } + + // Dialog buffer + const newDialogCount = dialogBuffer.totalAdded - lastDialogFlushed; + if (newDialogCount > 0) { + const entries = dialogBuffer.last(Math.min(newDialogCount, dialogBuffer.length)); + const lines = entries.map(e => + `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}` + ).join('\n') + '\n'; + fs.appendFileSync(DIALOG_LOG_PATH, lines); + lastDialogFlushed = dialogBuffer.totalAdded; + } + } catch { + // Flush failures are non-fatal — buffers are in memory + } finally { + flushInProgress = false; + } +} + +// Flush every 1 second +const flushInterval = setInterval(flushBuffers, 1000); + +// ─── Idle Timer ──────────────────────────────────────────────── +let lastActivity = Date.now(); + +function resetIdleTimer() { + lastActivity = Date.now(); +} + +const idleCheckInterval = setInterval(() => { + if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) { + console.log(`[browse] Idle for ${IDLE_TIMEOUT_MS / 1000}s, shutting down`); + shutdown(); + } +}, 60_000); + +// ─── Command Sets (from commands.ts — single source of truth) ─── +import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands'; +export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS }; + +// ─── Server ──────────────────────────────────────────────────── +const browserManager = new BrowserManager(); +let isShuttingDown = false; + +// Find port: explicit BROWSE_PORT, or random in 10000-60000 +async function findPort(): Promise { + // Explicit port override (for debugging) + if (BROWSE_PORT) { + try { + const testServer = Bun.serve({ port: BROWSE_PORT, fetch: () => new Response('ok') }); + testServer.stop(); + return BROWSE_PORT; + } catch { + throw new Error(`[browse] Port ${BROWSE_PORT} (from BROWSE_PORT env) is in use`); + } + } + + // Random port with retry + const MIN_PORT = 10000; + const MAX_PORT = 60000; + const MAX_RETRIES = 5; + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + const port = MIN_PORT + Math.floor(Math.random() * (MAX_PORT - MIN_PORT)); + try { + const testServer = Bun.serve({ port, fetch: () => new Response('ok') }); + testServer.stop(); + return port; + } catch { + continue; + } + } + throw new Error(`[browse] No available port after ${MAX_RETRIES} attempts in range ${MIN_PORT}-${MAX_PORT}`); +} + +/** + * Translate Playwright errors into actionable messages for AI agents. + */ +function wrapError(err: any): string { + const msg = err.message || String(err); + // Timeout errors + if (err.name === 'TimeoutError' || msg.includes('Timeout') || msg.includes('timeout')) { + if (msg.includes('locator.click') || msg.includes('locator.fill') || msg.includes('locator.hover')) { + return `Element not found or not interactable within timeout. Check your selector or run 'snapshot' for fresh refs.`; + } + if (msg.includes('page.goto') || msg.includes('Navigation')) { + return `Page navigation timed out. The URL may be unreachable or the page may be loading slowly.`; + } + return `Operation timed out: ${msg.split('\n')[0]}`; + } + // Multiple elements matched + if (msg.includes('resolved to') && msg.includes('elements')) { + return `Selector matched multiple elements. Be more specific or use @refs from 'snapshot'.`; + } + // Pass through other errors + return msg; +} + +async function handleCommand(body: any): Promise { + const { command, args = [] } = body; + + if (!command) { + return new Response(JSON.stringify({ error: 'Missing "command" field' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + try { + let result: string; + + if (READ_COMMANDS.has(command)) { + result = await handleReadCommand(command, args, browserManager); + } else if (WRITE_COMMANDS.has(command)) { + result = await handleWriteCommand(command, args, browserManager); + } else if (META_COMMANDS.has(command)) { + result = await handleMetaCommand(command, args, browserManager, shutdown); + } else if (command === 'help') { + const helpText = generateHelpText(); + return new Response(helpText, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }); + } else { + return new Response(JSON.stringify({ + error: `Unknown command: ${command}`, + hint: `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`, + }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + browserManager.resetFailures(); + return new Response(result, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }); + } catch (err: any) { + browserManager.incrementFailures(); + let errorMsg = wrapError(err); + const hint = browserManager.getFailureHint(); + if (hint) errorMsg += '\n' + hint; + return new Response(JSON.stringify({ error: errorMsg }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} + +async function shutdown() { + if (isShuttingDown) return; + isShuttingDown = true; + + console.log('[browse] Shutting down...'); + clearInterval(flushInterval); + clearInterval(idleCheckInterval); + await flushBuffers(); // Final flush (async now) + + await browserManager.close(); + + // Clean up state file + try { fs.unlinkSync(config.stateFile); } catch {} + + process.exit(0); +} + +// Handle signals +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); +// Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths. +// Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check. +if (process.platform === 'win32') { + process.on('exit', () => { + try { fs.unlinkSync(config.stateFile); } catch {} + }); +} + +// ─── Start ───────────────────────────────────────────────────── +async function start() { + // Clear old log files + try { fs.unlinkSync(CONSOLE_LOG_PATH); } catch {} + try { fs.unlinkSync(NETWORK_LOG_PATH); } catch {} + try { fs.unlinkSync(DIALOG_LOG_PATH); } catch {} + + const port = await findPort(); + + // Launch browser + await browserManager.launch(); + + const startTime = Date.now(); + const server = Bun.serve({ + port, + hostname: '127.0.0.1', + fetch: async (req) => { + resetIdleTimer(); + + const url = new URL(req.url); + + // Cookie picker routes — no auth required (localhost-only) + if (url.pathname.startsWith('/cookie-picker')) { + return handleCookiePickerRoute(url, req, browserManager); + } + + // Health check — no auth required (now async) + if (url.pathname === '/health') { + const healthy = await browserManager.isHealthy(); + return new Response(JSON.stringify({ + status: healthy ? 'healthy' : 'unhealthy', + uptime: Math.floor((Date.now() - startTime) / 1000), + tabs: browserManager.getTabCount(), + currentUrl: browserManager.getCurrentUrl(), + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // All other endpoints require auth + if (!validateAuth(req)) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (url.pathname === '/command' && req.method === 'POST') { + const body = await req.json(); + return handleCommand(body); + } + + return new Response('Not found', { status: 404 }); + }, + }); + + // Write state file (atomic: write .tmp then rename) + const state = { + pid: process.pid, + port, + token: AUTH_TOKEN, + startedAt: new Date().toISOString(), + serverPath: path.resolve(import.meta.dir, 'server.ts'), + binaryVersion: readVersionHash() || undefined, + }; + const tmpFile = config.stateFile + '.tmp'; + fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 }); + fs.renameSync(tmpFile, config.stateFile); + + browserManager.serverPort = port; + console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`); + console.log(`[browse] State file: ${config.stateFile}`); + console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`); +} + +start().catch((err) => { + console.error(`[browse] Failed to start: ${err.message}`); + // Write error to disk for the CLI to read — on Windows, the CLI can't capture + // stderr because the server is launched with detached: true, stdio: 'ignore'. + try { + const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log'); + fs.mkdirSync(config.stateDir, { recursive: true }); + fs.writeFileSync(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`); + } catch { + // stateDir may not exist — nothing more we can do + } + process.exit(1); +}); diff --git a/.claude/skills/gstack/browse/src/snapshot.ts b/.claude/skills/gstack/browse/src/snapshot.ts new file mode 100644 index 0000000..24380ba --- /dev/null +++ b/.claude/skills/gstack/browse/src/snapshot.ts @@ -0,0 +1,398 @@ +/** + * Snapshot command — accessibility tree with ref-based element selection + * + * Architecture (Locator map — no DOM mutation): + * 1. page.locator(scope).ariaSnapshot() → YAML-like accessibility tree + * 2. Parse tree, assign refs @e1, @e2, ... + * 3. Build Playwright Locator for each ref (getByRole + nth) + * 4. Store Map on BrowserManager + * 5. Return compact text output with refs prepended + * + * Extended features: + * --diff / -D: Compare against last snapshot, return unified diff + * --annotate / -a: Screenshot with overlay boxes at each @ref + * --output / -o: Output path for annotated screenshot + * -C / --cursor-interactive: Scan for cursor:pointer/onclick/tabindex elements + * + * Later: "click @e3" → look up Locator → locator.click() + */ + +import type { Page, Locator } from 'playwright'; +import type { BrowserManager, RefEntry } from './browser-manager'; +import * as Diff from 'diff'; +import { TEMP_DIR, isPathWithin } from './platform'; + +// Roles considered "interactive" for the -i flag +const INTERACTIVE_ROLES = new Set([ + 'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', + 'listbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio', + 'option', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', + 'treeitem', +]); + +interface SnapshotOptions { + interactive?: boolean; // -i: only interactive elements + compact?: boolean; // -c: remove empty structural elements + depth?: number; // -d N: limit tree depth + selector?: string; // -s SEL: scope to CSS selector + diff?: boolean; // -D / --diff: diff against last snapshot + annotate?: boolean; // -a / --annotate: annotated screenshot + outputPath?: string; // -o / --output: path for annotated screenshot + cursorInteractive?: boolean; // -C / --cursor-interactive: scan cursor:pointer etc. +} + +/** + * Snapshot flag metadata — single source of truth for CLI parsing and doc generation. + * + * Imported by: + * - gen-skill-docs.ts (generates {{SNAPSHOT_FLAGS}} tables) + * - skill-parser.ts (validates flags in SKILL.md examples) + */ +export const SNAPSHOT_FLAGS: Array<{ + short: string; + long: string; + description: string; + takesValue?: boolean; + valueHint?: string; + optionKey: keyof SnapshotOptions; +}> = [ + { short: '-i', long: '--interactive', description: 'Interactive elements only (buttons, links, inputs) with @e refs', optionKey: 'interactive' }, + { short: '-c', long: '--compact', description: 'Compact (no empty structural nodes)', optionKey: 'compact' }, + { short: '-d', long: '--depth', description: 'Limit tree depth (0 = root only, default: unlimited)', takesValue: true, valueHint: '', optionKey: 'depth' }, + { short: '-s', long: '--selector', description: 'Scope to CSS selector', takesValue: true, valueHint: '', optionKey: 'selector' }, + { short: '-D', long: '--diff', description: 'Unified diff against previous snapshot (first call stores baseline)', optionKey: 'diff' }, + { short: '-a', long: '--annotate', description: 'Annotated screenshot with red overlay boxes and ref labels', optionKey: 'annotate' }, + { short: '-o', long: '--output', description: 'Output path for annotated screenshot (default: /browse-annotated.png)', takesValue: true, valueHint: '', optionKey: 'outputPath' }, + { short: '-C', long: '--cursor-interactive', description: 'Cursor-interactive elements (@c refs — divs with pointer, onclick)', optionKey: 'cursorInteractive' }, +]; + +interface ParsedNode { + indent: number; + role: string; + name: string | null; + props: string; // e.g., "[level=1]" + children: string; // inline text content after ":" + rawLine: string; +} + +/** + * Parse CLI args into SnapshotOptions — driven by SNAPSHOT_FLAGS metadata. + */ +export function parseSnapshotArgs(args: string[]): SnapshotOptions { + const opts: SnapshotOptions = {}; + for (let i = 0; i < args.length; i++) { + const flag = SNAPSHOT_FLAGS.find(f => f.short === args[i] || f.long === args[i]); + if (!flag) throw new Error(`Unknown snapshot flag: ${args[i]}`); + if (flag.takesValue) { + const value = args[++i]; + if (!value) throw new Error(`Usage: snapshot ${flag.short} `); + if (flag.optionKey === 'depth') { + (opts as any)[flag.optionKey] = parseInt(value, 10); + if (isNaN(opts.depth!)) throw new Error('Usage: snapshot -d '); + } else { + (opts as any)[flag.optionKey] = value; + } + } else { + (opts as any)[flag.optionKey] = true; + } + } + return opts; +} + +/** + * Parse one line of ariaSnapshot output. + * + * Format examples: + * - heading "Test" [level=1] + * - link "Link A": + * - /url: /a + * - textbox "Name" + * - paragraph: Some text + * - combobox "Role": + */ +function parseLine(line: string): ParsedNode | null { + // Match: (indent)(- )(role)( "name")?( [props])?(: inline)? + const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+(\[.*?\]))?\s*(?::\s*(.*))?$/); + if (!match) { + // Skip metadata lines like "- /url: /a" + return null; + } + return { + indent: match[1].length, + role: match[2], + name: match[3] ?? null, + props: match[4] || '', + children: match[5]?.trim() || '', + rawLine: line, + }; +} + +/** + * Take an accessibility snapshot and build the ref map. + */ +export async function handleSnapshot( + args: string[], + bm: BrowserManager +): Promise { + const opts = parseSnapshotArgs(args); + const page = bm.getPage(); + + // Get accessibility tree via ariaSnapshot + let rootLocator: Locator; + if (opts.selector) { + rootLocator = page.locator(opts.selector); + const count = await rootLocator.count(); + if (count === 0) throw new Error(`Selector not found: ${opts.selector}`); + } else { + rootLocator = page.locator('body'); + } + + const ariaText = await rootLocator.ariaSnapshot(); + if (!ariaText || ariaText.trim().length === 0) { + bm.setRefMap(new Map()); + return '(no accessible elements found)'; + } + + // Parse the ariaSnapshot output + const lines = ariaText.split('\n'); + const refMap = new Map(); + const output: string[] = []; + let refCounter = 1; + + // Track role+name occurrences for nth() disambiguation + const roleNameCounts = new Map(); + const roleNameSeen = new Map(); + + // First pass: count role+name pairs for disambiguation + for (const line of lines) { + const node = parseLine(line); + if (!node) continue; + const key = `${node.role}:${node.name || ''}`; + roleNameCounts.set(key, (roleNameCounts.get(key) || 0) + 1); + } + + // Second pass: assign refs and build locators + for (const line of lines) { + const node = parseLine(line); + if (!node) continue; + + const depth = Math.floor(node.indent / 2); + const isInteractive = INTERACTIVE_ROLES.has(node.role); + + // Depth filter + if (opts.depth !== undefined && depth > opts.depth) continue; + + // Interactive filter: skip non-interactive but still count for locator indices + if (opts.interactive && !isInteractive) { + // Still track for nth() counts + const key = `${node.role}:${node.name || ''}`; + roleNameSeen.set(key, (roleNameSeen.get(key) || 0) + 1); + continue; + } + + // Compact filter: skip elements with no name and no inline content that aren't interactive + if (opts.compact && !isInteractive && !node.name && !node.children) continue; + + // Assign ref + const ref = `e${refCounter++}`; + const indent = ' '.repeat(depth); + + // Build Playwright locator + const key = `${node.role}:${node.name || ''}`; + const seenIndex = roleNameSeen.get(key) || 0; + roleNameSeen.set(key, seenIndex + 1); + const totalCount = roleNameCounts.get(key) || 1; + + let locator: Locator; + if (opts.selector) { + locator = page.locator(opts.selector).getByRole(node.role as any, { + name: node.name || undefined, + }); + } else { + locator = page.getByRole(node.role as any, { + name: node.name || undefined, + }); + } + + // Disambiguate with nth() if multiple elements share role+name + if (totalCount > 1) { + locator = locator.nth(seenIndex); + } + + refMap.set(ref, { locator, role: node.role, name: node.name || '' }); + + // Format output line + let outputLine = `${indent}@${ref} [${node.role}]`; + if (node.name) outputLine += ` "${node.name}"`; + if (node.props) outputLine += ` ${node.props}`; + if (node.children) outputLine += `: ${node.children}`; + + output.push(outputLine); + } + + // ─── Cursor-interactive scan (-C) ───────────────────────── + if (opts.cursorInteractive) { + try { + const cursorElements = await page.evaluate(() => { + const STANDARD_INTERACTIVE = new Set([ + 'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY', 'DETAILS', + ]); + + const results: Array<{ selector: string; text: string; reason: string }> = []; + const allElements = document.querySelectorAll('*'); + + for (const el of allElements) { + // Skip standard interactive elements (already in ARIA tree) + if (STANDARD_INTERACTIVE.has(el.tagName)) continue; + // Skip hidden elements + if (!(el as HTMLElement).offsetParent && el.tagName !== 'BODY') continue; + + const style = getComputedStyle(el); + const hasCursorPointer = style.cursor === 'pointer'; + const hasOnclick = el.hasAttribute('onclick'); + const hasTabindex = el.hasAttribute('tabindex') && parseInt(el.getAttribute('tabindex')!, 10) >= 0; + const hasRole = el.hasAttribute('role'); + + if (!hasCursorPointer && !hasOnclick && !hasTabindex) continue; + // Skip if it has an ARIA role (likely already captured) + if (hasRole) continue; + + // Build deterministic nth-child CSS path + const parts: string[] = []; + let current: Element | null = el; + while (current && current !== document.documentElement) { + const parent = current.parentElement; + if (!parent) break; + const siblings = [...parent.children]; + const index = siblings.indexOf(current) + 1; + parts.unshift(`${current.tagName.toLowerCase()}:nth-child(${index})`); + current = parent; + } + const selector = parts.join(' > '); + + const text = (el as HTMLElement).innerText?.trim().slice(0, 80) || el.tagName.toLowerCase(); + const reasons: string[] = []; + if (hasCursorPointer) reasons.push('cursor:pointer'); + if (hasOnclick) reasons.push('onclick'); + if (hasTabindex) reasons.push(`tabindex=${el.getAttribute('tabindex')}`); + + results.push({ selector, text, reason: reasons.join(', ') }); + } + return results; + }); + + if (cursorElements.length > 0) { + output.push(''); + output.push('── cursor-interactive (not in ARIA tree) ──'); + let cRefCounter = 1; + for (const elem of cursorElements) { + const ref = `c${cRefCounter++}`; + const locator = page.locator(elem.selector); + refMap.set(ref, { locator, role: 'cursor-interactive', name: elem.text }); + output.push(`@${ref} [${elem.reason}] "${elem.text}"`); + } + } + } catch { + output.push(''); + output.push('(cursor scan failed — CSP restriction)'); + } + } + + // Store ref map on BrowserManager + bm.setRefMap(refMap); + + if (output.length === 0) { + return '(no interactive elements found)'; + } + + const snapshotText = output.join('\n'); + + // ─── Annotated screenshot (-a) ──────────────────────────── + if (opts.annotate) { + const screenshotPath = opts.outputPath || `${TEMP_DIR}/browse-annotated.png`; + // Validate output path (consistent with screenshot/pdf/responsive) + const resolvedPath = require('path').resolve(screenshotPath); + const safeDirs = [TEMP_DIR, process.cwd()]; + if (!safeDirs.some((dir: string) => isPathWithin(resolvedPath, dir))) { + throw new Error(`Path must be within: ${safeDirs.join(', ')}`); + } + try { + // Inject overlay divs at each ref's bounding box + const boxes: Array<{ ref: string; box: { x: number; y: number; width: number; height: number } }> = []; + for (const [ref, entry] of refMap) { + try { + const box = await entry.locator.boundingBox({ timeout: 1000 }); + if (box) { + boxes.push({ ref: `@${ref}`, box }); + } + } catch { + // Element may be offscreen or hidden — skip + } + } + + await page.evaluate((boxes) => { + for (const { ref, box } of boxes) { + const overlay = document.createElement('div'); + overlay.className = '__browse_annotation__'; + overlay.style.cssText = ` + position: absolute; top: ${box.y}px; left: ${box.x}px; + width: ${box.width}px; height: ${box.height}px; + border: 2px solid red; background: rgba(255,0,0,0.1); + pointer-events: none; z-index: 99999; + font-size: 10px; color: red; font-weight: bold; + `; + const label = document.createElement('span'); + label.textContent = ref; + label.style.cssText = 'position: absolute; top: -14px; left: 0; background: red; color: white; padding: 0 3px; font-size: 10px;'; + overlay.appendChild(label); + document.body.appendChild(overlay); + } + }, boxes); + + await page.screenshot({ path: screenshotPath, fullPage: true }); + + // Always remove overlays + await page.evaluate(() => { + document.querySelectorAll('.__browse_annotation__').forEach(el => el.remove()); + }); + + output.push(''); + output.push(`[annotated screenshot: ${screenshotPath}]`); + } catch { + // Remove overlays even on screenshot failure + try { + await page.evaluate(() => { + document.querySelectorAll('.__browse_annotation__').forEach(el => el.remove()); + }); + } catch {} + } + } + + // ─── Diff mode (-D) ─────────────────────────────────────── + if (opts.diff) { + const lastSnapshot = bm.getLastSnapshot(); + if (!lastSnapshot) { + bm.setLastSnapshot(snapshotText); + return snapshotText + '\n\n(no previous snapshot to diff against — this snapshot stored as baseline)'; + } + + const changes = Diff.diffLines(lastSnapshot, snapshotText); + const diffOutput: string[] = ['--- previous snapshot', '+++ current snapshot', '']; + + for (const part of changes) { + const prefix = part.added ? '+' : part.removed ? '-' : ' '; + const diffLines = part.value.split('\n').filter(l => l.length > 0); + for (const line of diffLines) { + diffOutput.push(`${prefix} ${line}`); + } + } + + bm.setLastSnapshot(snapshotText); + return diffOutput.join('\n'); + } + + // Store for future diffs + bm.setLastSnapshot(snapshotText); + + return output.join('\n'); +} diff --git a/.claude/skills/gstack/browse/src/url-validation.ts b/.claude/skills/gstack/browse/src/url-validation.ts new file mode 100644 index 0000000..4f2c922 --- /dev/null +++ b/.claude/skills/gstack/browse/src/url-validation.ts @@ -0,0 +1,95 @@ +/** + * URL validation for navigation commands — blocks dangerous schemes and cloud metadata endpoints. + * Localhost and private IPs are allowed (primary use case: QA testing local dev servers). + */ + +const BLOCKED_METADATA_HOSTS = new Set([ + '169.254.169.254', // AWS/GCP/Azure instance metadata + 'fd00::', // IPv6 unique local (metadata in some cloud setups) + 'metadata.google.internal', // GCP metadata + 'metadata.azure.internal', // Azure IMDS +]); + +/** + * Normalize hostname for blocklist comparison: + * - Strip trailing dot (DNS fully-qualified notation) + * - Strip IPv6 brackets (URL.hostname includes [] for IPv6) + * - Resolve hex (0xA9FEA9FE) and decimal (2852039166) IP representations + */ +function normalizeHostname(hostname: string): string { + // Strip IPv6 brackets + let h = hostname.startsWith('[') && hostname.endsWith(']') + ? hostname.slice(1, -1) + : hostname; + // Strip trailing dot + if (h.endsWith('.')) h = h.slice(0, -1); + return h; +} + +/** + * Check if a hostname resolves to the link-local metadata IP 169.254.169.254. + * Catches hex (0xA9FEA9FE), decimal (2852039166), and octal (0251.0376.0251.0376) forms. + */ +function isMetadataIp(hostname: string): boolean { + // Try to parse as a numeric IP via URL constructor — it normalizes all forms + try { + const probe = new URL(`http://${hostname}`); + const normalized = probe.hostname; + if (BLOCKED_METADATA_HOSTS.has(normalized)) return true; + // Also check after stripping trailing dot + if (normalized.endsWith('.') && BLOCKED_METADATA_HOSTS.has(normalized.slice(0, -1))) return true; + } catch { + // Not a valid hostname — can't be a metadata IP + } + return false; +} + +/** + * Resolve a hostname to its IP addresses and check if any resolve to blocked metadata IPs. + * Mitigates DNS rebinding: even if the hostname looks safe, the resolved IP might not be. + */ +async function resolvesToBlockedIp(hostname: string): Promise { + try { + const dns = await import('node:dns'); + const { resolve4 } = dns.promises; + const addresses = await resolve4(hostname); + return addresses.some(addr => BLOCKED_METADATA_HOSTS.has(addr)); + } catch { + // DNS resolution failed — not a rebinding risk + return false; + } +} + +export async function validateNavigationUrl(url: string): Promise { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`Invalid URL: ${url}`); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error( + `Blocked: scheme "${parsed.protocol}" is not allowed. Only http: and https: URLs are permitted.` + ); + } + + const hostname = normalizeHostname(parsed.hostname.toLowerCase()); + + if (BLOCKED_METADATA_HOSTS.has(hostname) || isMetadataIp(hostname)) { + throw new Error( + `Blocked: ${parsed.hostname} is a cloud metadata endpoint. Access is denied for security.` + ); + } + + // DNS rebinding protection: resolve hostname and check if it points to metadata IPs. + // Skip for loopback/private IPs — they can't be DNS-rebinded and the async DNS + // resolution adds latency that breaks concurrent E2E tests under load. + const isLoopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; + const isPrivateNet = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/.test(hostname); + if (!isLoopback && !isPrivateNet && await resolvesToBlockedIp(hostname)) { + throw new Error( + `Blocked: ${parsed.hostname} resolves to a cloud metadata IP. Possible DNS rebinding attack.` + ); + } +} diff --git a/.claude/skills/gstack/browse/src/write-commands.ts b/.claude/skills/gstack/browse/src/write-commands.ts new file mode 100644 index 0000000..3e80c7f --- /dev/null +++ b/.claude/skills/gstack/browse/src/write-commands.ts @@ -0,0 +1,354 @@ +/** + * Write commands — navigate and interact with pages (side effects) + * + * goto, back, forward, reload, click, fill, select, hover, type, + * press, scroll, wait, viewport, cookie, header, useragent + */ + +import type { BrowserManager } from './browser-manager'; +import { findInstalledBrowsers, importCookies, listSupportedBrowserNames } from './cookie-import-browser'; +import { validateNavigationUrl } from './url-validation'; +import * as fs from 'fs'; +import * as path from 'path'; +import { TEMP_DIR, isPathWithin } from './platform'; + +export async function handleWriteCommand( + command: string, + args: string[], + bm: BrowserManager +): Promise { + const page = bm.getPage(); + + switch (command) { + case 'goto': { + const url = args[0]; + if (!url) throw new Error('Usage: browse goto '); + await validateNavigationUrl(url); + const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); + const status = response?.status() || 'unknown'; + return `Navigated to ${url} (${status})`; + } + + case 'back': { + await page.goBack({ waitUntil: 'domcontentloaded', timeout: 15000 }); + return `Back → ${page.url()}`; + } + + case 'forward': { + await page.goForward({ waitUntil: 'domcontentloaded', timeout: 15000 }); + return `Forward → ${page.url()}`; + } + + case 'reload': { + await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }); + return `Reloaded ${page.url()}`; + } + + case 'click': { + const selector = args[0]; + if (!selector) throw new Error('Usage: browse click '); + + // Auto-route: if ref points to a real