     1→---
     2→name: review
     3→version: 1.0.0
     4→description: |
     5→  Pre-landing PR review. Analyzes diff against the base branch for SQL safety, LLM trust
     6→  boundary violations, conditional side effects, and other structural issues.
     7→allowed-tools:
     8→  - Bash
     9→  - Read
    10→  - Edit
    11→  - Write
    12→  - Grep
    13→  - Glob
    14→  - AskUserQuestion
    15→---
    16→
    17→{{PREAMBLE}}
    18→
    19→{{BASE_BRANCH_DETECT}}
    20→
    21→# Pre-Landing PR Review
    22→
    23→You are running the `/review` workflow. Analyze the current branch's diff against the base branch for structural issues that tests don't catch.
    24→
    25→---
    26→
    27→## Step 1: Check branch
    28→
    29→1. Run `git branch --show-current` to get the current branch.
    30→2. If on the base branch, output: **"Nothing to review — you're on the base branch or have no changes against it."** and stop.
    31→3. Run `git fetch origin <base> --quiet && git diff origin/<base> --stat` to check if there's a diff. If no diff, output the same message and stop.
    32→
    33→---
    34→
    35→## Step 2: Read the checklist
    36→
    37→Read `.claude/skills/review/checklist.md`.
    38→
    39→**If the file cannot be read, STOP and report the error.** Do not proceed without the checklist.
    40→
    41→---
    42→
    43→## Step 2.5: Check for Greptile review comments
    44→
    45→Read `.claude/skills/review/greptile-triage.md` and follow the fetch, filter, classify, and **escalation detection** steps.
    46→
    47→**If no PR exists, `gh` fails, API returns an error, or there are zero Greptile comments:** Skip this step silently. Greptile integration is additive — the review works without it.
    48→
    49→**If Greptile comments are found:** Store the classifications (VALID & ACTIONABLE, VALID BUT ALREADY FIXED, FALSE POSITIVE, SUPPRESSED) — you will need them in Step 5.
    50→
    51→---
    52→
    53→## Step 3: Get the diff
    54→
    55→Fetch the latest base branch to avoid false positives from stale local state:
    56→
    57→```bash
    58→git fetch origin <base> --quiet
    59→```
    60→
    61→Run `git diff origin/<base>` to get the full diff. This includes both committed and uncommitted changes against the latest base branch.
    62→
    63→---
    64→
    65→## Step 4: Two-pass review
    66→
    67→Apply the checklist against the diff in two passes:
    68→
    69→1. **Pass 1 (CRITICAL):** SQL & Data Safety, Race Conditions & Concurrency, LLM Output Trust Boundary, Enum & Value Completeness
    70→2. **Pass 2 (INFORMATIONAL):** Conditional Side Effects, Magic Numbers & String Coupling, Dead Code & Consistency, LLM Prompt Issues, Test Gaps, View/Frontend
    71→
    72→**Enum & Value Completeness requires reading code OUTSIDE the diff.** When the diff introduces a new enum value, status, tier, or type constant, use Grep to find all files that reference sibling values, then Read those files to check if the new value is handled. This is the one category where within-diff review is insufficient.
    73→
    74→Follow the output format specified in the checklist. Respect the suppressions — do NOT flag items listed in the "DO NOT flag" section.
    75→
    76→---
    77→
    78→## Step 4.5: Design Review (conditional)
    79→
    80→{{DESIGN_REVIEW_LITE}}
    81→
    82→Include any design findings alongside the findings from Step 4. They follow the same Fix-First flow in Step 5 — AUTO-FIX for mechanical CSS fixes, ASK for everything else.
    83→
    84→---
    85→
    86→## Step 5: Fix-First Review
    87→
    88→**Every finding gets action — not just critical ones.**
    89→
    90→Output a summary header: `Pre-Landing Review: N issues (X critical, Y informational)`
    91→
    92→### Step 5a: Classify each finding
    93→
    94→For each finding, classify as AUTO-FIX or ASK per the Fix-First Heuristic in
    95→checklist.md. Critical findings lean toward ASK; informational findings lean
    96→toward AUTO-FIX.
    97→
    98→### Step 5b: Auto-fix all AUTO-FIX items
    99→
   100→Apply each fix directly. For each one, output a one-line summary:
   101→`[AUTO-FIXED] [file:line] Problem → what you did`
   102→
   103→### Step 5c: Batch-ask about ASK items
   104→
   105→If there are ASK items remaining, present them in ONE AskUserQuestion:
   106→
   107→- List each item with a number, the severity label, the problem, and a recommended fix
   108→- For each item, provide options: A) Fix as recommended, B) Skip
   109→- Include an overall RECOMMENDATION
   110→
   111→Example format:
   112→```
   113→I auto-fixed 5 issues. 2 need your input:
   114→
   115→1. [CRITICAL] app/models/post.rb:42 — Race condition in status transition
   116→   Fix: Add `WHERE status = 'draft'` to the UPDATE
   117→   → A) Fix  B) Skip
   118→
   119→2. [INFORMATIONAL] app/services/generator.rb:88 — LLM output not type-checked before DB write
   120→   Fix: Add JSON schema validation
   121→   → A) Fix  B) Skip
   122→
   123→RECOMMENDATION: Fix both — #1 is a real race condition, #2 prevents silent data corruption.
   124→```
   125→
   126→If 3 or fewer ASK items, you may use individual AskUserQuestion calls instead of batching.
   127→
   128→### Step 5d: Apply user-approved fixes
   129→
   130→Apply fixes for items where the user chose "Fix." Output what was fixed.
   131→
   132→If no ASK items exist (everything was AUTO-FIX), skip the question entirely.
   133→
   134→### Greptile comment resolution
   135→
   136→After outputting your own findings, if Greptile comments were classified in Step 2.5:
   137→
   138→**Include a Greptile summary in your output header:** `+ N Greptile comments (X valid, Y fixed, Z FP)`
   139→
   140→Before replying to any comment, run the **Escalation Detection** algorithm from greptile-triage.md to determine whether to use Tier 1 (friendly) or Tier 2 (firm) reply templates.
   141→
   142→1. **VALID & ACTIONABLE comments:** These are included in your findings — they follow the Fix-First flow (auto-fixed if mechanical, batched into ASK if not) (A: Fix it now, B: Acknowledge, C: False positive). If the user chooses A (fix), reply using the **Fix reply template** from greptile-triage.md (include inline diff + explanation). If the user chooses C (false positive), reply using the **False Positive reply template** (include evidence + suggested re-rank), save to both per-project and global greptile-history.
   143→
   144→2. **FALSE POSITIVE comments:** Present each one via AskUserQuestion:
   145→   - Show the Greptile comment: file:line (or [top-level]) + body summary + permalink URL
   146→   - Explain concisely why it's a false positive
   147→   - Options:
   148→     - A) Reply to Greptile explaining why this is incorrect (recommended if clearly wrong)
   149→     - B) Fix it anyway (if low-effort and harmless)
   150→     - C) Ignore — don't reply, don't fix
   151→
   152→   If the user chooses A, reply using the **False Positive reply template** from greptile-triage.md (include evidence + suggested re-rank), save to both per-project and global greptile-history.
   153→
   154→3. **VALID BUT ALREADY FIXED comments:** Reply using the **Already Fixed reply template** from greptile-triage.md — no AskUserQuestion needed:
   155→   - Include what was done and the fixing commit SHA
   156→   - Save to both per-project and global greptile-history
   157→
   158→4. **SUPPRESSED comments:** Skip silently — these are known false positives from previous triage.
   159→
   160→---
   161→
   162→## Step 5.5: TODOS cross-reference
   163→
   164→Read `TODOS.md` in the repository root (if it exists). Cross-reference the PR against open TODOs:
   165→
   166→- **Does this PR close any open TODOs?** If yes, note which items in your output: "This PR addresses TODO: <title>"
   167→- **Does this PR create work that should become a TODO?** If yes, flag it as an informational finding.
   168→- **Are there related TODOs that provide context for this review?** If yes, reference them when discussing related findings.
   169→
   170→If TODOS.md doesn't exist, skip this step silently.
   171→
   172→---
   173→
   174→## Step 5.6: Documentation staleness check
   175→
   176→Cross-reference the diff against documentation files. For each `.md` file in the repo root (README.md, ARCHITECTURE.md, CONTRIBUTING.md, CLAUDE.md, etc.):
   177→
   178→1. Check if code changes in the diff affect features, components, or workflows described in that doc file.
   179→2. If the doc file was NOT updated in this branch but the code it describes WAS changed, flag it as an INFORMATIONAL finding:
   180→   "Documentation may be stale: [file] describes [feature/component] but code changed in this branch. Consider running `/document-release`."
   181→
   182→This is informational only — never critical. The fix action is `/document-release`.
   183→
   184→If no documentation files exist, skip this step silently.
   185→
   186→---
   187→
   188→## Important Rules
   189→
   190→- **Read the FULL diff before commenting.** Do not flag issues already addressed in the diff.
   191→- **Fix-first, not read-only.** AUTO-FIX items are applied directly. ASK items are only applied after user approval. Never commit, push, or create PRs — that's /ship's job.
   192→- **Be terse.** One line problem, one line fix. No preamble.
   193→- **Only flag real problems.** Skip anything that's fine.
   194→- **Use Greptile reply templates from greptile-triage.md.** Every reply includes evidence. Never post vague replies.
   195→


     1→---
     2→name: gstack
     3→version: 1.1.0
     4→description: |
     5→  Fast headless browser for QA testing and site dogfooding. Navigate any URL, interact with
     6→  elements, verify page state, diff before/after actions, take annotated screenshots, check
     7→  responsive layouts, test forms and uploads, handle dialogs, and assert element states.
     8→  ~100ms per command. Use when you need to test a feature, verify a deployment, dogfood a
     9→  user flow, or file a bug with evidence.
    10→allowed-tools:
    11→  - Bash
    12→  - Read
    13→  - AskUserQuestion
    14→
    15→---
    16→
    17→{{PREAMBLE}}
    18→
    19→# gstack browse: QA Testing & Dogfooding
    20→
    21→Persistent headless Chromium. First call auto-starts (~3s), then ~100-200ms per command.
    22→Auto-shuts down after 30 min idle. State persists between calls (cookies, tabs, sessions).
    23→
    24→{{BROWSE_SETUP}}
    25→
    26→## IMPORTANT
    27→
    28→- Use the compiled binary via Bash: `$B <command>`
    29→- NEVER use `mcp__claude-in-chrome__*` tools. They are slow and unreliable.
    30→- Browser persists between calls — cookies, login sessions, and tabs carry over.
    31→- Dialogs (alert/confirm/prompt) are auto-accepted by default — no browser lockup.
    32→- **Show screenshots:** After `$B screenshot`, `$B snapshot -a -o`, or `$B responsive`, always use the Read tool on the output PNG(s) so the user can see them. Without this, screenshots are invisible.
    33→
    34→## QA Workflows
    35→
    36→### Test a user flow (login, signup, checkout, etc.)
    37→
    38→```bash
    39→# 1. Go to the page
    40→$B goto https://app.example.com/login
    41→
    42→# 2. See what's interactive
    43→$B snapshot -i
    44→
    45→# 3. Fill the form using refs
    46→$B fill @e3 "test@example.com"
    47→$B fill @e4 "password123"
    48→$B click @e5
    49→
    50→# 4. Verify it worked
    51→$B snapshot -D              # diff shows what changed after clicking
    52→$B is visible ".dashboard"  # assert the dashboard appeared
    53→$B screenshot /tmp/after-login.png
    54→```
    55→
    56→### Verify a deployment / check prod
    57→
    58→```bash
    59→$B goto https://yourapp.com
    60→$B text                          # read the page — does it load?
    61→$B console                       # any JS errors?
    62→$B network                       # any failed requests?
    63→$B js "document.title"           # correct title?
    64→$B is visible ".hero-section"    # key elements present?
    65→$B screenshot /tmp/prod-check.png
    66→```
    67→
    68→### Dogfood a feature end-to-end
    69→
    70→```bash
    71→# Navigate to the feature
    72→$B goto https://app.example.com/new-feature
    73→
    74→# Take annotated screenshot — shows every interactive element with labels
    75→$B snapshot -i -a -o /tmp/feature-annotated.png
    76→
    77→# Find ALL clickable things (including divs with cursor:pointer)
    78→$B snapshot -C
    79→
    80→# Walk through the flow
    81→$B snapshot -i          # baseline
    82→$B click @e3            # interact
    83→$B snapshot -D          # what changed? (unified diff)
    84→
    85→# Check element states
    86→$B is visible ".success-toast"
    87→$B is enabled "#next-step-btn"
    88→$B is checked "#agree-checkbox"
    89→
    90→# Check console for errors after interactions
    91→$B console
    92→```
    93→
    94→### Test responsive layouts
    95→
    96→```bash
    97→# Quick: 3 screenshots at mobile/tablet/desktop
    98→$B goto https://yourapp.com
    99→$B responsive /tmp/layout
   100→
   101→# Manual: specific viewport
   102→$B viewport 375x812     # iPhone
   103→$B screenshot /tmp/mobile.png
   104→$B viewport 1440x900    # Desktop
   105→$B screenshot /tmp/desktop.png
   106→
   107→# Element screenshot (crop to specific element)
   108→$B screenshot "#hero-banner" /tmp/hero.png
   109→$B snapshot -i
   110→$B screenshot @e3 /tmp/button.png
   111→
   112→# Region crop
   113→$B screenshot --clip 0,0,800,600 /tmp/above-fold.png
   114→
   115→# Viewport only (no scroll)
   116→$B screenshot --viewport /tmp/viewport.png
   117→```
   118→
   119→### Test file upload
   120→
   121→```bash
   122→$B goto https://app.example.com/upload
   123→$B snapshot -i
   124→$B upload @e3 /path/to/test-file.pdf
   125→$B is visible ".upload-success"
   126→$B screenshot /tmp/upload-result.png
   127→```
   128→
   129→### Test forms with validation
   130→
   131→```bash
   132→$B goto https://app.example.com/form
   133→$B snapshot -i
   134→
   135→# Submit empty — check validation errors appear
   136→$B click @e10                        # submit button
   137→$B snapshot -D                       # diff shows error messages appeared
   138→$B is visible ".error-message"
   139→
   140→# Fill and resubmit
   141→$B fill @e3 "valid input"
   142→$B click @e10
   143→$B snapshot -D                       # diff shows errors gone, success state
   144→```
   145→
   146→### Test dialogs (delete confirmations, prompts)
   147→
   148→```bash
   149→# Set up dialog handling BEFORE triggering
   150→$B dialog-accept              # will auto-accept next alert/confirm
   151→$B click "#delete-button"     # triggers confirmation dialog
   152→$B dialog                     # see what dialog appeared
   153→$B snapshot -D                # verify the item was deleted
   154→
   155→# For prompts that need input
   156→$B dialog-accept "my answer"  # accept with text
   157→$B click "#rename-button"     # triggers prompt
   158→```
   159→
   160→### Test authenticated pages (import real browser cookies)
   161→
   162→```bash
   163→# Import cookies from your real browser (opens interactive picker)
   164→$B cookie-import-browser
   165→
   166→# Or import a specific domain directly
   167→$B cookie-import-browser comet --domain .github.com
   168→
   169→# Now test authenticated pages
   170→$B goto https://github.com/settings/profile
   171→$B snapshot -i
   172→$B screenshot /tmp/github-profile.png
   173→```
   174→
   175→### Compare two pages / environments
   176→
   177→```bash
   178→$B diff https://staging.app.com https://prod.app.com
   179→```
   180→
   181→### Multi-step chain (efficient for long flows)
   182→
   183→```bash
   184→echo '[
   185→  ["goto","https://app.example.com"],
   186→  ["snapshot","-i"],
   187→  ["fill","@e3","test@test.com"],
   188→  ["fill","@e4","password"],
   189→  ["click","@e5"],
   190→  ["snapshot","-D"],
   191→  ["screenshot","/tmp/result.png"]
   192→]' | $B chain
   193→```
   194→
   195→## Quick Assertion Patterns
   196→
   197→```bash
   198→# Element exists and is visible
   199→$B is visible ".modal"
   200→
   201→# Button is enabled/disabled
   202→$B is enabled "#submit-btn"
   203→$B is disabled "#submit-btn"
   204→
   205→# Checkbox state
   206→$B is checked "#agree"
   207→
   208→# Input is editable
   209→$B is editable "#name-field"
   210→
   211→# Element has focus
   212→$B is focused "#search-input"
   213→
   214→# Page contains text
   215→$B js "document.body.textContent.includes('Success')"
   216→
   217→# Element count
   218→$B js "document.querySelectorAll('.list-item').length"
   219→
   220→# Specific attribute value
   221→$B attrs "#logo"    # returns all attributes as JSON
   222→
   223→# CSS property
   224→$B css ".button" "background-color"
   225→```
   226→
   227→## Snapshot System
   228→
   229→{{SNAPSHOT_FLAGS}}
   230→
   231→## Command Reference
   232→
   233→{{COMMAND_REFERENCE}}
   234→
   235→## Tips
   236→
   237→1. **Navigate once, query many times.** `goto` loads the page; then `text`, `js`, `screenshot` all hit the loaded page instantly.
   238→2. **Use `snapshot -i` first.** See all interactive elements, then click/fill by ref. No CSS selector guessing.
   239→3. **Use `snapshot -D` to verify.** Baseline → action → diff. See exactly what changed.
   240→4. **Use `is` for assertions.** `is visible .modal` is faster and more reliable than parsing page text.
   241→5. **Use `snapshot -a` for evidence.** Annotated screenshots are great for bug reports.
   242→6. **Use `snapshot -C` for tricky UIs.** Finds clickable divs that the accessibility tree misses.
   243→7. **Check `console` after actions.** Catch JS errors that don't surface visually.
   244→8. **Use `chain` for long flows.** Single command, no per-step CLI overhead.
   245→


     1→---
     2→name: browse
     3→version: 1.1.0
     4→description: |
     5→  Fast headless browser for QA testing and site dogfooding. Navigate any URL, interact with
     6→  elements, verify page state, diff before/after actions, take annotated screenshots, check
     7→  responsive layouts, test forms and uploads, handle dialogs, and assert element states.
     8→  ~100ms per command. Use when you need to test a feature, verify a deployment, dogfood a
     9→  user flow, or file a bug with evidence.
    10→allowed-tools:
    11→  - Bash
    12→  - Read
    13→  - AskUserQuestion
    14→
    15→---
    16→
    17→{{PREAMBLE}}
    18→
    19→# browse: QA Testing & Dogfooding
    20→
    21→Persistent headless Chromium. First call auto-starts (~3s), then ~100ms per command.
    22→State persists between calls (cookies, tabs, login sessions).
    23→
    24→{{BROWSE_SETUP}}
    25→
    26→## Core QA Patterns
    27→
    28→### 1. Verify a page loads correctly
    29→```bash
    30→$B goto https://yourapp.com
    31→$B text                          # content loads?
    32→$B console                       # JS errors?
    33→$B network                       # failed requests?
    34→$B is visible ".main-content"    # key elements present?
    35→```
    36→
    37→### 2. Test a user flow
    38→```bash
    39→$B goto https://app.com/login
    40→$B snapshot -i                   # see all interactive elements
    41→$B fill @e3 "user@test.com"
    42→$B fill @e4 "password"
    43→$B click @e5                     # submit
    44→$B snapshot -D                   # diff: what changed after submit?
    45→$B is visible ".dashboard"       # success state present?
    46→```
    47→
    48→### 3. Verify an action worked
    49→```bash
    50→$B snapshot                      # baseline
    51→$B click @e3                     # do something
    52→$B snapshot -D                   # unified diff shows exactly what changed
    53→```
    54→
    55→### 4. Visual evidence for bug reports
    56→```bash
    57→$B snapshot -i -a -o /tmp/annotated.png   # labeled screenshot
    58→$B screenshot /tmp/bug.png                # plain screenshot
    59→$B console                                # error log
    60→```
    61→
    62→### 5. Find all clickable elements (including non-ARIA)
    63→```bash
    64→$B snapshot -C                   # finds divs with cursor:pointer, onclick, tabindex
    65→$B click @c1                     # interact with them
    66→```
    67→
    68→### 6. Assert element states
    69→```bash
    70→$B is visible ".modal"
    71→$B is enabled "#submit-btn"
    72→$B is disabled "#submit-btn"
    73→$B is checked "#agree-checkbox"
    74→$B is editable "#name-field"
    75→$B is focused "#search-input"
    76→$B js "document.body.textContent.includes('Success')"
    77→```
    78→
    79→### 7. Test responsive layouts
    80→```bash
    81→$B responsive /tmp/layout        # mobile + tablet + desktop screenshots
    82→$B viewport 375x812              # or set specific viewport
    83→$B screenshot /tmp/mobile.png
    84→```
    85→
    86→### 8. Test file uploads
    87→```bash
    88→$B upload "#file-input" /path/to/file.pdf
    89→$B is visible ".upload-success"
    90→```
    91→
    92→### 9. Test dialogs
    93→```bash
    94→$B dialog-accept "yes"           # set up handler
    95→$B click "#delete-button"        # trigger dialog
    96→$B dialog                        # see what appeared
    97→$B snapshot -D                   # verify deletion happened
    98→```
    99→
   100→### 10. Compare environments
   101→```bash
   102→$B diff https://staging.app.com https://prod.app.com
   103→```
   104→
   105→### 11. Show screenshots to the user
   106→After `$B screenshot`, `$B snapshot -a -o`, or `$B responsive`, always use the Read tool on the output PNG(s) so the user can see them. Without this, screenshots are invisible.
   107→
   108→## Snapshot Flags
   109→
   110→{{SNAPSHOT_FLAGS}}
   111→
   112→## Full Command List
   113→
   114→{{COMMAND_REFERENCE}}
   115→


     1→/**
     2→ * Command registry — single source of truth for all browse commands.
     3→ *
     4→ * Dependency graph:
     5→ *   commands.ts ──▶ server.ts (runtime dispatch)
     6→ *                ──▶ gen-skill-docs.ts (doc generation)
     7→ *                ──▶ skill-parser.ts (validation)
     8→ *                ──▶ skill-check.ts (health reporting)
     9→ *
    10→ * Zero side effects. Safe to import from build scripts and tests.
    11→ */
    12→
    13→export const READ_COMMANDS = new Set([
    14→  'text', 'html', 'links', 'forms', 'accessibility',
    15→  'js', 'eval', 'css', 'attrs',
    16→  'console', 'network', 'cookies', 'storage', 'perf',
    17→  'dialog', 'is',
    18→]);
    19→
    20→export const WRITE_COMMANDS = new Set([
    21→  'goto', 'back', 'forward', 'reload',
    22→  'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait',
    23→  'viewport', 'cookie', 'cookie-import', 'cookie-import-browser', 'header', 'useragent',
    24→  'upload', 'dialog-accept', 'dialog-dismiss',
    25→]);
    26→
    27→export const META_COMMANDS = new Set([
    28→  'tabs', 'tab', 'newtab', 'closetab',
    29→  'status', 'stop', 'restart',
    30→  'screenshot', 'pdf', 'responsive',
    31→  'chain', 'diff',
    32→  'url', 'snapshot',
    33→]);
    34→
    35→export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
    36→
    37→export const COMMAND_DESCRIPTIONS: Record<string, { category: string; description: string; usage?: string }> = {
    38→  // Navigation
    39→  'goto':    { category: 'Navigation', description: 'Navigate to URL', usage: 'goto <url>' },
    40→  'back':    { category: 'Navigation', description: 'History back' },
    41→  'forward': { category: 'Navigation', description: 'History forward' },
    42→  'reload':  { category: 'Navigation', description: 'Reload page' },
    43→  'url':     { category: 'Navigation', description: 'Print current URL' },
    44→  // Reading
    45→  'text':    { category: 'Reading', description: 'Cleaned page text' },
    46→  'html':    { category: 'Reading', description: 'innerHTML of selector (throws if not found), or full page HTML if no selector given', usage: 'html [selector]' },
    47→  'links':   { category: 'Reading', description: 'All links as "text → href"' },
    48→  'forms':   { category: 'Reading', description: 'Form fields as JSON' },
    49→  'accessibility': { category: 'Reading', description: 'Full ARIA tree' },
    50→  // Inspection
    51→  'js':      { category: 'Inspection', description: 'Run JavaScript expression and return result as string', usage: 'js <expr>' },
    52→  'eval':    { category: 'Inspection', description: 'Run JavaScript from file and return result as string (path must be under /tmp or cwd)', usage: 'eval <file>' },
    53→  'css':     { category: 'Inspection', description: 'Computed CSS value', usage: 'css <sel> <prop>' },
    54→  'attrs':   { category: 'Inspection', description: 'Element attributes as JSON', usage: 'attrs <sel|@ref>' },
    55→  'is':      { category: 'Inspection', description: 'State check (visible/hidden/enabled/disabled/checked/editable/focused)', usage: 'is <prop> <sel>' },
    56→  'console': { category: 'Inspection', description: 'Console messages (--errors filters to error/warning)', usage: 'console [--clear|--errors]' },
    57→  'network': { category: 'Inspection', description: 'Network requests', usage: 'network [--clear]' },
    58→  'dialog':  { category: 'Inspection', description: 'Dialog messages', usage: 'dialog [--clear]' },
    59→  'cookies': { category: 'Inspection', description: 'All cookies as JSON' },
    60→  'storage': { category: 'Inspection', description: 'Read all localStorage + sessionStorage as JSON, or set <key> <value> to write localStorage', usage: 'storage [set k v]' },
    61→  'perf':    { category: 'Inspection', description: 'Page load timings' },
    62→  // Interaction
    63→  'click':   { category: 'Interaction', description: 'Click element', usage: 'click <sel>' },
    64→  'fill':    { category: 'Interaction', description: 'Fill input', usage: 'fill <sel> <val>' },
    65→  'select':  { category: 'Interaction', description: 'Select dropdown option by value, label, or visible text', usage: 'select <sel> <val>' },
    66→  'hover':   { category: 'Interaction', description: 'Hover element', usage: 'hover <sel>' },
    67→  'type':    { category: 'Interaction', description: 'Type into focused element', usage: 'type <text>' },
    68→  'press':   { category: 'Interaction', description: 'Press key — Enter, Tab, Escape, ArrowUp/Down/Left/Right, Backspace, Delete, Home, End, PageUp, PageDown, or modifiers like Shift+Enter', usage: 'press <key>' },
    69→  'scroll':  { category: 'Interaction', description: 'Scroll element into view, or scroll to page bottom if no selector', usage: 'scroll [sel]' },
    70→  'wait':    { category: 'Interaction', description: 'Wait for element, network idle, or page load (timeout: 15s)', usage: 'wait <sel|--networkidle|--load>' },
    71→  'upload':  { category: 'Interaction', description: 'Upload file(s)', usage: 'upload <sel> <file> [file2...]' },
    72→  'viewport':{ category: 'Interaction', description: 'Set viewport size', usage: 'viewport <WxH>' },
    73→  'cookie':  { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie <name>=<value>' },
    74→  'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import <json>' },
    75→  'cookie-import-browser': { category: 'Interaction', description: 'Import cookies from Comet, Chrome, Arc, Brave, or Edge (opens picker, or use --domain for direct import)', usage: 'cookie-import-browser [browser] [--domain d]' },
    76→  'header':  { category: 'Interaction', description: 'Set custom request header (colon-separated, sensitive values auto-redacted)', usage: 'header <name>:<value>' },
    77→  'useragent': { category: 'Interaction', description: 'Set user agent', usage: 'useragent <string>' },
    78→  'dialog-accept': { category: 'Interaction', description: 'Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response', usage: 'dialog-accept [text]' },
    79→  'dialog-dismiss': { category: 'Interaction', description: 'Auto-dismiss next dialog' },
    80→  // Visual
    81→  'screenshot': { category: 'Visual', description: 'Save screenshot (supports element crop via CSS/@ref, --clip region, --viewport)', usage: 'screenshot [--viewport] [--clip x,y,w,h] [selector|@ref] [path]' },
    82→  'pdf':     { category: 'Visual', description: 'Save as PDF', usage: 'pdf [path]' },
    83→  'responsive': { category: 'Visual', description: 'Screenshots at mobile (375x812), tablet (768x1024), desktop (1280x720). Saves as {prefix}-mobile.png etc.', usage: 'responsive [prefix]' },
    84→  'diff':    { category: 'Visual', description: 'Text diff between pages', usage: 'diff <url1> <url2>' },
    85→  // Tabs
    86→  'tabs':    { category: 'Tabs', description: 'List open tabs' },
    87→  'tab':     { category: 'Tabs', description: 'Switch to tab', usage: 'tab <id>' },
    88→  'newtab':  { category: 'Tabs', description: 'Open new tab', usage: 'newtab [url]' },
    89→  'closetab':{ category: 'Tabs', description: 'Close tab', usage: 'closetab [id]' },
    90→  // Server
    91→  'status':  { category: 'Server', description: 'Health check' },
    92→  'stop':    { category: 'Server', description: 'Shutdown server' },
    93→  'restart': { category: 'Server', description: 'Restart server' },
    94→  // Meta
    95→  'snapshot':{ category: 'Snapshot', description: 'Accessibility tree with @e refs for element selection. Flags: -i interactive only, -c compact, -d N depth limit, -s sel scope, -D diff vs previous, -a annotated screenshot, -o path output, -C cursor-interactive @c refs', usage: 'snapshot [flags]' },
    96→  'chain':   { category: 'Meta', description: 'Run commands from JSON stdin. Format: [["cmd","arg1",...],...]' },
    97→};
    98→
    99→// Load-time validation: descriptions must cover exactly the command sets
   100→const allCmds = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
   101→const descKeys = new Set(Object.keys(COMMAND_DESCRIPTIONS));
   102→for (const cmd of allCmds) {
   103→  if (!descKeys.has(cmd)) throw new Error(`COMMAND_DESCRIPTIONS missing entry for: ${cmd}`);
   104→}
   105→for (const key of descKeys) {
   106→  if (!allCmds.has(key)) throw new Error(`COMMAND_DESCRIPTIONS has unknown command: ${key}`);
   107→}
   108→


     1→import { describe, test, expect } from 'bun:test';
     2→import { validateSkill, extractRemoteSlugPatterns, extractWeightsFromTable } from './helpers/skill-parser';
     3→import { ALL_COMMANDS, COMMAND_DESCRIPTIONS, READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from '../browse/src/commands';
     4→import { SNAPSHOT_FLAGS } from '../browse/src/snapshot';
     5→import * as fs from 'fs';
     6→import * as path from 'path';
     7→
     8→const ROOT = path.resolve(import.meta.dir, '..');
     9→
    10→describe('SKILL.md command validation', () => {
    11→  test('all $B commands in SKILL.md are valid browse commands', () => {
    12→    const result = validateSkill(path.join(ROOT, 'SKILL.md'));
    13→    expect(result.invalid).toHaveLength(0);
    14→    expect(result.valid.length).toBeGreaterThan(0);
    15→  });
    16→
    17→  test('all snapshot flags in SKILL.md are valid', () => {
    18→    const result = validateSkill(path.join(ROOT, 'SKILL.md'));
    19→    expect(result.snapshotFlagErrors).toHaveLength(0);
    20→  });
    21→
    22→  test('all $B commands in browse/SKILL.md are valid browse commands', () => {
    23→    const result = validateSkill(path.join(ROOT, 'browse', 'SKILL.md'));
    24→    expect(result.invalid).toHaveLength(0);
    25→    expect(result.valid.length).toBeGreaterThan(0);
    26→  });
    27→
    28→  test('all snapshot flags in browse/SKILL.md are valid', () => {
    29→    const result = validateSkill(path.join(ROOT, 'browse', 'SKILL.md'));
    30→    expect(result.snapshotFlagErrors).toHaveLength(0);
    31→  });
    32→
    33→  test('all $B commands in qa/SKILL.md are valid browse commands', () => {
    34→    const qaSkill = path.join(ROOT, 'qa', 'SKILL.md');
    35→    if (!fs.existsSync(qaSkill)) return; // skip if missing
    36→    const result = validateSkill(qaSkill);
    37→    expect(result.invalid).toHaveLength(0);
    38→  });
    39→
    40→  test('all snapshot flags in qa/SKILL.md are valid', () => {
    41→    const qaSkill = path.join(ROOT, 'qa', 'SKILL.md');
    42→    if (!fs.existsSync(qaSkill)) return;
    43→    const result = validateSkill(qaSkill);
    44→    expect(result.snapshotFlagErrors).toHaveLength(0);
    45→  });
    46→
    47→  test('all $B commands in qa-only/SKILL.md are valid browse commands', () => {
    48→    const qaOnlySkill = path.join(ROOT, 'qa-only', 'SKILL.md');
    49→    if (!fs.existsSync(qaOnlySkill)) return;
    50→    const result = validateSkill(qaOnlySkill);
    51→    expect(result.invalid).toHaveLength(0);
    52→  });
    53→
    54→  test('all snapshot flags in qa-only/SKILL.md are valid', () => {
    55→    const qaOnlySkill = path.join(ROOT, 'qa-only', 'SKILL.md');
    56→    if (!fs.existsSync(qaOnlySkill)) return;
    57→    const result = validateSkill(qaOnlySkill);
    58→    expect(result.snapshotFlagErrors).toHaveLength(0);
    59→  });
    60→
    61→  test('all $B commands in plan-design-review/SKILL.md are valid browse commands', () => {
    62→    const skill = path.join(ROOT, 'plan-design-review', 'SKILL.md');
    63→    if (!fs.existsSync(skill)) return;
    64→    const result = validateSkill(skill);
    65→    expect(result.invalid).toHaveLength(0);
    66→  });
    67→
    68→  test('all snapshot flags in plan-design-review/SKILL.md are valid', () => {
    69→    const skill = path.join(ROOT, 'plan-design-review', 'SKILL.md');
    70→    if (!fs.existsSync(skill)) return;
    71→    const result = validateSkill(skill);
    72→    expect(result.snapshotFlagErrors).toHaveLength(0);
    73→  });
    74→
    75→  test('all $B commands in design-review/SKILL.md are valid browse commands', () => {
    76→    const skill = path.join(ROOT, 'design-review', 'SKILL.md');
    77→    if (!fs.existsSync(skill)) return;
    78→    const result = validateSkill(skill);
    79→    expect(result.invalid).toHaveLength(0);
    80→  });
    81→
    82→  test('all snapshot flags in design-review/SKILL.md are valid', () => {
    83→    const skill = path.join(ROOT, 'design-review', 'SKILL.md');
    84→    if (!fs.existsSync(skill)) return;
    85→    const result = validateSkill(skill);
    86→    expect(result.snapshotFlagErrors).toHaveLength(0);
    87→  });
    88→
    89→  test('all $B commands in design-consultation/SKILL.md are valid browse commands', () => {
    90→    const skill = path.join(ROOT, 'design-consultation', 'SKILL.md');
    91→    if (!fs.existsSync(skill)) return;
    92→    const result = validateSkill(skill);
    93→    expect(result.invalid).toHaveLength(0);
    94→  });
    95→
    96→  test('all snapshot flags in design-consultation/SKILL.md are valid', () => {
    97→    const skill = path.join(ROOT, 'design-consultation', 'SKILL.md');
    98→    if (!fs.existsSync(skill)) return;
    99→    const result = validateSkill(skill);
   100→    expect(result.snapshotFlagErrors).toHaveLength(0);
   101→  });
   102→});
   103→
   104→describe('Command registry consistency', () => {
   105→  test('COMMAND_DESCRIPTIONS covers all commands in sets', () => {
   106→    const allCmds = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
   107→    const descKeys = new Set(Object.keys(COMMAND_DESCRIPTIONS));
   108→    for (const cmd of allCmds) {
   109→      expect(descKeys.has(cmd)).toBe(true);
   110→    }
   111→  });
   112→
   113→  test('COMMAND_DESCRIPTIONS has no extra commands not in sets', () => {
   114→    const allCmds = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
   115→    for (const key of Object.keys(COMMAND_DESCRIPTIONS)) {
   116→      expect(allCmds.has(key)).toBe(true);
   117→    }
   118→  });
   119→
   120→  test('ALL_COMMANDS matches union of all sets', () => {
   121→    const union = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
   122→    expect(ALL_COMMANDS.size).toBe(union.size);
   123→    for (const cmd of union) {
   124→      expect(ALL_COMMANDS.has(cmd)).toBe(true);
   125→    }
   126→  });
   127→
   128→  test('SNAPSHOT_FLAGS option keys are valid SnapshotOptions fields', () => {
   129→    const validKeys = new Set([
   130→      'interactive', 'compact', 'depth', 'selector',
   131→      'diff', 'annotate', 'outputPath', 'cursorInteractive',
   132→    ]);
   133→    for (const flag of SNAPSHOT_FLAGS) {
   134→      expect(validKeys.has(flag.optionKey)).toBe(true);
   135→    }
   136→  });
   137→});
   138→
   139→describe('Usage string consistency', () => {
   140→  // Normalize a usage string to its structural skeleton for comparison.
   141→  // Replaces <param-names> with <>, [optional] with [], strips parenthetical hints.
   142→  // This catches format mismatches (e.g., <name>:<value> vs <name> <value>)
   143→  // without tripping on abbreviation differences (e.g., <sel> vs <selector>).
   144→  function skeleton(usage: string): string {
   145→    return usage
   146→      .replace(/\(.*?\)/g, '')        // strip parenthetical hints like (e.g., Enter, Tab)
   147→      .replace(/<[^>]*>/g, '<>')      // normalize <param-name> → <>
   148→      .replace(/\[[^\]]*\]/g, '[]')   // normalize [optional] → []
   149→      .replace(/\s+/g, ' ')           // collapse whitespace
   150→      .trim();
   151→  }
   152→
   153→  // Cross-check Usage: patterns in implementation against COMMAND_DESCRIPTIONS
   154→  test('implementation Usage: structural format matches COMMAND_DESCRIPTIONS', () => {
   155→    const implFiles = [
   156→      path.join(ROOT, 'browse', 'src', 'write-commands.ts'),
   157→      path.join(ROOT, 'browse', 'src', 'read-commands.ts'),
   158→      path.join(ROOT, 'browse', 'src', 'meta-commands.ts'),
   159→    ];
   160→
   161→    // Extract "Usage: browse <pattern>" from throw new Error(...) calls
   162→    const usagePattern = /throw new Error\(['"`]Usage:\s*browse\s+(.+?)['"`]\)/g;
   163→    const implUsages = new Map<string, string>();
   164→
   165→    for (const file of implFiles) {
   166→      const content = fs.readFileSync(file, 'utf-8');
   167→      let match;
   168→      while ((match = usagePattern.exec(content)) !== null) {
   169→        const usage = match[1].split('\\n')[0].trim();
   170→        const cmd = usage.split(/\s/)[0];
   171→        implUsages.set(cmd, usage);
   172→      }
   173→    }
   174→
   175→    // Compare structural skeletons
   176→    const mismatches: string[] = [];
   177→    for (const [cmd, implUsage] of implUsages) {
   178→      const desc = COMMAND_DESCRIPTIONS[cmd];
   179→      if (!desc) continue;
   180→      if (!desc.usage) continue;
   181→      const descSkel = skeleton(desc.usage);
   182→      const implSkel = skeleton(implUsage);
   183→      if (descSkel !== implSkel) {
   184→        mismatches.push(`${cmd}: docs "${desc.usage}" (${descSkel}) vs impl "${implUsage}" (${implSkel})`);
   185→      }
   186→    }
   187→
   188→    expect(mismatches).toEqual([]);
   189→  });
   190→});
   191→
   192→describe('Generated SKILL.md freshness', () => {
   193→  test('no unresolved {{placeholders}} in generated SKILL.md', () => {
   194→    const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
   195→    const unresolved = content.match(/\{\{\w+\}\}/g);
   196→    expect(unresolved).toBeNull();
   197→  });
   198→
   199→  test('no unresolved {{placeholders}} in generated browse/SKILL.md', () => {
   200→    const content = fs.readFileSync(path.join(ROOT, 'browse', 'SKILL.md'), 'utf-8');
   201→    const unresolved = content.match(/\{\{\w+\}\}/g);
   202→    expect(unresolved).toBeNull();
   203→  });
   204→
   205→  test('generated SKILL.md has AUTO-GENERATED header', () => {
   206→    const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
   207→    expect(content).toContain('AUTO-GENERATED');
   208→  });
   209→});
   210→
   211→// --- Update check preamble validation ---
   212→
   213→describe('Update check preamble', () => {
   214→  const skillsWithUpdateCheck = [
   215→    'SKILL.md', 'browse/SKILL.md', 'qa/SKILL.md',
   216→    'qa-only/SKILL.md',
   217→    'setup-browser-cookies/SKILL.md',
   218→    'ship/SKILL.md', 'review/SKILL.md',
   219→    'plan-ceo-review/SKILL.md', 'plan-eng-review/SKILL.md',
   220→    'retro/SKILL.md',
   221→    'plan-design-review/SKILL.md',
   222→    'design-review/SKILL.md',
   223→    'design-consultation/SKILL.md',
   224→    'document-release/SKILL.md',
   225→  ];
   226→
   227→  for (const skill of skillsWithUpdateCheck) {
   228→    test(`${skill} update check line ends with || true`, () => {
   229→      const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
   230→      // The second line of the bash block must end with || true
   231→      // to avoid exit code 1 when _UPD is empty (up to date)
   232→      const match = content.match(/\[ -n "\$_UPD" \].*$/m);
   233→      expect(match).not.toBeNull();
   234→      expect(match![0]).toContain('|| true');
   235→    });
   236→  }
   237→
   238→  test('all skills with update check are generated from .tmpl', () => {
   239→    for (const skill of skillsWithUpdateCheck) {
   240→      const tmplPath = path.join(ROOT, skill + '.tmpl');
   241→      expect(fs.existsSync(tmplPath)).toBe(true);
   242→    }
   243→  });
   244→
   245→  test('update check bash block exits 0 when up to date', () => {
   246→    // Simulate the exact preamble command from SKILL.md
   247→    const result = Bun.spawnSync(['bash', '-c',
   248→      '_UPD=$(echo "" || true); [ -n "$_UPD" ] && echo "$_UPD" || true'
   249→    ], { stdout: 'pipe', stderr: 'pipe' });
   250→    expect(result.exitCode).toBe(0);
   251→  });
   252→
   253→  test('update check bash block exits 0 when upgrade available', () => {
   254→    const result = Bun.spawnSync(['bash', '-c',
   255→      '_UPD=$(echo "UPGRADE_AVAILABLE 0.3.3 0.4.0" || true); [ -n "$_UPD" ] && echo "$_UPD" || true'
   256→    ], { stdout: 'pipe', stderr: 'pipe' });
   257→    expect(result.exitCode).toBe(0);
   258→    expect(result.stdout.toString().trim()).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
   259→  });
   260→});
   261→
   262→// --- Part 7: Cross-skill path consistency (A1) ---
   263→
   264→describe('Cross-skill path consistency', () => {
   265→  test('REMOTE_SLUG derivation pattern is identical across files that use it', () => {
   266→    const patterns = extractRemoteSlugPatterns(ROOT, ['qa', 'review']);
   267→    const allPatterns: string[] = [];
   268→
   269→    for (const [, filePatterns] of patterns) {
   270→      allPatterns.push(...filePatterns);
   271→    }
   272→
   273→    // Should find at least 2 occurrences (qa/SKILL.md + review/greptile-triage.md)
   274→    expect(allPatterns.length).toBeGreaterThanOrEqual(2);
   275→
   276→    // All occurrences must be character-for-character identical
   277→    const unique = new Set(allPatterns);
   278→    if (unique.size > 1) {
   279→      const variants = Array.from(unique);
   280→      throw new Error(
   281→        `REMOTE_SLUG pattern differs across files:\n` +
   282→        variants.map((v, i) => `  ${i + 1}: ${v}`).join('\n')
   283→      );
   284→    }
   285→  });
   286→
   287→  test('all greptile-history write references specify both per-project and global paths', () => {
   288→    const filesToCheck = [
   289→      'review/SKILL.md',
   290→      'ship/SKILL.md',
   291→      'review/greptile-triage.md',
   292→    ];
   293→
   294→    for (const file of filesToCheck) {
   295→      const filePath = path.join(ROOT, file);
   296→      if (!fs.existsSync(filePath)) continue;
   297→      const content = fs.readFileSync(filePath, 'utf-8');
   298→
   299→      const hasBoth = (content.includes('per-project') && content.includes('global')) ||
   300→        (content.includes('$REMOTE_SLUG/greptile-history') && content.includes('~/.gstack/greptile-history'));
   301→
   302→      expect(hasBoth).toBe(true);
   303→    }
   304→  });
   305→
   306→  test('greptile-triage.md contains both project and global history paths', () => {
   307→    const content = fs.readFileSync(path.join(ROOT, 'review', 'greptile-triage.md'), 'utf-8');
   308→    expect(content).toContain('$REMOTE_SLUG/greptile-history.md');
   309→    expect(content).toContain('~/.gstack/greptile-history.md');
   310→  });
   311→
   312→  test('retro/SKILL.md reads global greptile-history (not per-project)', () => {
   313→    const content = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8');
   314→    expect(content).toContain('~/.gstack/greptile-history.md');
   315→    // Should NOT reference per-project path for reads
   316→    expect(content).not.toContain('$REMOTE_SLUG/greptile-history.md');
   317→  });
   318→});
   319→
   320→// --- Part 7: QA skill structure validation (A2) ---
   321→
   322→describe('QA skill structure validation', () => {
   323→  const qaContent = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
   324→
   325→  test('qa/SKILL.md has all 11 phases', () => {
   326→    const phases = [
   327→      'Phase 1', 'Initialize',
   328→      'Phase 2', 'Authenticate',
   329→      'Phase 3', 'Orient',
   330→      'Phase 4', 'Explore',
   331→      'Phase 5', 'Document',
   332→      'Phase 6', 'Wrap Up',
   333→      'Phase 7', 'Triage',
   334→      'Phase 8', 'Fix Loop',
   335→      'Phase 9', 'Final QA',
   336→      'Phase 10', 'Report',
   337→      'Phase 11', 'TODOS',
   338→    ];
   339→    for (const phase of phases) {
   340→      expect(qaContent).toContain(phase);
   341→    }
   342→  });
   343→
   344→  test('has all four QA modes defined', () => {
   345→    const modes = [
   346→      'Diff-aware',
   347→      'Full',
   348→      'Quick',
   349→      'Regression',
   350→    ];
   351→    for (const mode of modes) {
   352→      expect(qaContent).toContain(mode);
   353→    }
   354→
   355→    // Mode triggers/flags
   356→    expect(qaContent).toContain('--quick');
   357→    expect(qaContent).toContain('--regression');
   358→  });
   359→
   360→  test('has all three tiers defined', () => {
   361→    const tiers = ['Quick', 'Standard', 'Exhaustive'];
   362→    for (const tier of tiers) {
   363→      expect(qaContent).toContain(tier);
   364→    }
   365→  });
   366→
   367→  test('health score weights sum to 100%', () => {
   368→    const weights = extractWeightsFromTable(qaContent);
   369→    expect(weights.size).toBeGreaterThan(0);
   370→
   371→    let sum = 0;
   372→    for (const pct of weights.values()) {
   373→      sum += pct;
   374→    }
   375→    expect(sum).toBe(100);
   376→  });
   377→
   378→  test('health score has all 8 categories', () => {
   379→    const weights = extractWeightsFromTable(qaContent);
   380→    const expectedCategories = [
   381→      'Console', 'Links', 'Visual', 'Functional',
   382→      'UX', 'Performance', 'Content', 'Accessibility',
   383→    ];
   384→    for (const cat of expectedCategories) {
   385→      expect(weights.has(cat)).toBe(true);
   386→    }
   387→    expect(weights.size).toBe(8);
   388→  });
   389→
   390→  test('has four mode definitions (Diff-aware/Full/Quick/Regression)', () => {
   391→    expect(qaContent).toContain('### Diff-aware');
   392→    expect(qaContent).toContain('### Full');
   393→    expect(qaContent).toContain('### Quick');
   394→    expect(qaContent).toContain('### Regression');
   395→  });
   396→
   397→  test('output structure references report directory layout', () => {
   398→    expect(qaContent).toContain('qa-report-');
   399→    expect(qaContent).toContain('baseline.json');
   400→    expect(qaContent).toContain('screenshots/');
   401→    expect(qaContent).toContain('.gstack/qa-reports/');
   402→  });
   403→});
   404→
   405→// --- Part 7: Greptile history format consistency (A3) ---
   406→
   407→describe('Greptile history format consistency', () => {
   408→  test('greptile-triage.md defines the canonical history format', () => {
   409→    const content = fs.readFileSync(path.join(ROOT, 'review', 'greptile-triage.md'), 'utf-8');
   410→    expect(content).toContain('<YYYY-MM-DD>');
   411→    expect(content).toContain('<owner/repo>');
   412→    expect(content).toContain('<type');
   413→    expect(content).toContain('<file-pattern>');
   414→    expect(content).toContain('<category>');
   415→  });
   416→
   417→  test('review/SKILL.md and ship/SKILL.md both reference greptile-triage.md for write details', () => {
   418→    const reviewContent = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
   419→    const shipContent = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
   420→
   421→    expect(reviewContent.toLowerCase()).toContain('greptile-triage.md');
   422→    expect(shipContent.toLowerCase()).toContain('greptile-triage.md');
   423→  });
   424→
   425→  test('greptile-triage.md defines all 9 valid categories', () => {
   426→    const content = fs.readFileSync(path.join(ROOT, 'review', 'greptile-triage.md'), 'utf-8');
   427→    const categories = [
   428→      'race-condition', 'null-check', 'error-handling', 'style',
   429→      'type-safety', 'security', 'performance', 'correctness', 'other',
   430→    ];
   431→    for (const cat of categories) {
   432→      expect(content).toContain(cat);
   433→    }
   434→  });
   435→});
   436→
   437→// --- Hardcoded branch name detection in templates ---
   438→
   439→describe('No hardcoded branch names in SKILL templates', () => {
   440→  const tmplFiles = [
   441→    'ship/SKILL.md.tmpl',
   442→    'review/SKILL.md.tmpl',
   443→    'qa/SKILL.md.tmpl',
   444→    'plan-ceo-review/SKILL.md.tmpl',
   445→    'retro/SKILL.md.tmpl',
   446→    'document-release/SKILL.md.tmpl',
   447→    'plan-eng-review/SKILL.md.tmpl',
   448→    'plan-design-review/SKILL.md.tmpl',
   449→  ];
   450→
   451→  // Patterns that indicate hardcoded 'main' in git commands
   452→  const gitMainPatterns = [
   453→    /\bgit\s+diff\s+(?:origin\/)?main\b/,
   454→    /\bgit\s+log\s+(?:origin\/)?main\b/,
   455→    /\bgit\s+fetch\s+origin\s+main\b/,
   456→    /\bgit\s+merge\s+origin\/main\b/,
   457→    /\borigin\/main\b/,
   458→  ];
   459→
   460→  // Lines that are allowed to mention 'main' (fallback logic, prose)
   461→  const allowlist = [
   462→    /fall\s*back\s+to\s+`main`/i,
   463→    /fall\s*back\s+to\s+`?main`?/i,
   464→    /typically\s+`?main`?/i,
   465→    /If\s+on\s+`main`/i,  // old pattern — should not exist
   466→  ];
   467→
   468→  for (const tmplFile of tmplFiles) {
   469→    test(`${tmplFile} has no hardcoded 'main' in git commands`, () => {
   470→      const filePath = path.join(ROOT, tmplFile);
   471→      if (!fs.existsSync(filePath)) return;
   472→      const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
   473→      const violations: string[] = [];
   474→
   475→      for (let i = 0; i < lines.length; i++) {
   476→        const line = lines[i];
   477→        const isAllowlisted = allowlist.some(p => p.test(line));
   478→        if (isAllowlisted) continue;
   479→
   480→        for (const pattern of gitMainPatterns) {
   481→          if (pattern.test(line)) {
   482→            violations.push(`Line ${i + 1}: ${line.trim()}`);
   483→            break;
   484→          }
   485→        }
   486→      }
   487→
   488→      if (violations.length > 0) {
   489→        throw new Error(
   490→          `${tmplFile} has hardcoded 'main' in git commands:\n` +
   491→          violations.map(v => `  ${v}`).join('\n')
   492→        );
   493→      }
   494→    });
   495→  }
   496→});
   497→
   498→// --- Part 7b: TODOS-format.md reference consistency ---
   499→
   500→describe('TODOS-format.md reference consistency', () => {
   501→  test('review/TODOS-format.md exists and defines canonical format', () => {
   502→    const content = fs.readFileSync(path.join(ROOT, 'review', 'TODOS-format.md'), 'utf-8');
   503→    expect(content).toContain('**What:**');
   504→    expect(content).toContain('**Why:**');
   505→    expect(content).toContain('**Priority:**');
   506→    expect(content).toContain('**Effort:**');
   507→    expect(content).toContain('## Completed');
   508→  });
   509→
   510→  test('skills that write TODOs reference TODOS-format.md', () => {
   511→    const shipContent = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
   512→    const ceoPlanContent = fs.readFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), 'utf-8');
   513→    const engPlanContent = fs.readFileSync(path.join(ROOT, 'plan-eng-review', 'SKILL.md'), 'utf-8');
   514→
   515→    expect(shipContent).toContain('TODOS-format.md');
   516→    expect(ceoPlanContent).toContain('TODOS-format.md');
   517→    expect(engPlanContent).toContain('TODOS-format.md');
   518→  });
   519→});
   520→
   521→// --- v0.4.1 feature coverage: RECOMMENDATION format, session awareness, enum completeness ---
   522→
   523→describe('v0.4.1 preamble features', () => {
   524→  const skillsWithPreamble = [
   525→    'SKILL.md', 'browse/SKILL.md', 'qa/SKILL.md',
   526→    'qa-only/SKILL.md',
   527→    'setup-browser-cookies/SKILL.md',
   528→    'ship/SKILL.md', 'review/SKILL.md',
   529→    'plan-ceo-review/SKILL.md', 'plan-eng-review/SKILL.md',
   530→    'retro/SKILL.md',
   531→    'plan-design-review/SKILL.md',
   532→    'design-review/SKILL.md',
   533→    'design-consultation/SKILL.md',
   534→    'document-release/SKILL.md',
   535→  ];
   536→
   537→  for (const skill of skillsWithPreamble) {
   538→    test(`${skill} contains RECOMMENDATION format`, () => {
   539→      const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
   540→      expect(content).toContain('RECOMMENDATION: Choose');
   541→      expect(content).toContain('AskUserQuestion');
   542→    });
   543→
   544→    test(`${skill} contains session awareness`, () => {
   545→      const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
   546→      expect(content).toContain('_SESSIONS');
   547→      expect(content).toContain('RECOMMENDATION');
   548→    });
   549→  }
   550→});
   551→
   552→// --- Contributor mode preamble structure validation ---
   553→
   554→describe('Contributor mode preamble structure', () => {
   555→  const skillsWithPreamble = [
   556→    'SKILL.md', 'browse/SKILL.md', 'qa/SKILL.md',
   557→    'qa-only/SKILL.md',
   558→    'setup-browser-cookies/SKILL.md',
   559→    'ship/SKILL.md', 'review/SKILL.md',
   560→    'plan-ceo-review/SKILL.md', 'plan-eng-review/SKILL.md',
   561→    'retro/SKILL.md',
   562→    'plan-design-review/SKILL.md',
   563→    'design-review/SKILL.md',
   564→    'design-consultation/SKILL.md',
   565→    'document-release/SKILL.md',
   566→  ];
   567→
   568→  for (const skill of skillsWithPreamble) {
   569→    test(`${skill} has 0-10 rating in contributor mode`, () => {
   570→      const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
   571→      expect(content).toContain('0 to 10');
   572→      expect(content).toContain('My rating');
   573→    });
   574→
   575→    test(`${skill} has calibration example`, () => {
   576→      const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
   577→      expect(content).toContain('Calibration');
   578→      expect(content).toContain('the bar');
   579→    });
   580→
   581→    test(`${skill} has "what would make this a 10" field`, () => {
   582→      const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
   583→      expect(content).toContain('What would make this a 10');
   584→    });
   585→
   586→    test(`${skill} uses periodic reflection (not per-command)`, () => {
   587→      const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
   588→      expect(content).toContain('workflow step');
   589→      expect(content).not.toContain('After you use gstack-provided CLIs');
   590→    });
   591→  }
   592→});
   593→
   594→describe('Enum & Value Completeness in review checklist', () => {
   595→  const checklist = fs.readFileSync(path.join(ROOT, 'review', 'checklist.md'), 'utf-8');
   596→
   597→  test('checklist has Enum & Value Completeness section', () => {
   598→    expect(checklist).toContain('Enum & Value Completeness');
   599→  });
   600→
   601→  test('Enum & Value Completeness is classified as CRITICAL', () => {
   602→    // It should appear under Pass 1 — CRITICAL, not Pass 2
   603→    const pass1Start = checklist.indexOf('### Pass 1');
   604→    const pass2Start = checklist.indexOf('### Pass 2');
   605→    const enumStart = checklist.indexOf('Enum & Value Completeness');
   606→    expect(enumStart).toBeGreaterThan(pass1Start);
   607→    expect(enumStart).toBeLessThan(pass2Start);
   608→  });
   609→
   610→  test('Enum & Value Completeness mentions tracing through consumers', () => {
   611→    expect(checklist).toContain('Trace it through every consumer');
   612→    expect(checklist).toContain('case');
   613→    expect(checklist).toContain('allowlist');
   614→  });
   615→
   616→  test('Enum & Value Completeness is in the severity classification as CRITICAL', () => {
   617→    const gateSection = checklist.slice(checklist.indexOf('## Severity Classification'));
   618→    // The ASCII art has CRITICAL on the left and INFORMATIONAL on the right
   619→    // Enum & Value Completeness should appear on a line with the CRITICAL tree (├─ or └─)
   620→    const enumLine = gateSection.split('\n').find(l => l.includes('Enum & Value Completeness'));
   621→    expect(enumLine).toBeDefined();
   622→    // It's on the left (CRITICAL) side — starts with ├─ or └─
   623→    expect(enumLine!.trimStart().startsWith('├─') || enumLine!.trimStart().startsWith('└─')).toBe(true);
   624→  });
   625→
   626→  test('Fix-First Heuristic exists in checklist and is referenced by review + ship', () => {
   627→    expect(checklist).toContain('## Fix-First Heuristic');
   628→    expect(checklist).toContain('AUTO-FIX');
   629→    expect(checklist).toContain('ASK');
   630→
   631→    const reviewSkill = fs.readFileSync(path.join(ROOT, 'review/SKILL.md'), 'utf-8');
   632→    const shipSkill = fs.readFileSync(path.join(ROOT, 'ship/SKILL.md'), 'utf-8');
   633→    expect(reviewSkill).toContain('AUTO-FIX');
   634→    expect(reviewSkill).toContain('[AUTO-FIXED]');
   635→    expect(shipSkill).toContain('AUTO-FIX');
   636→    expect(shipSkill).toContain('[AUTO-FIXED]');
   637→  });
   638→});
   639→
   640→// --- Completeness Principle spot-check ---
   641→
   642→describe('Completeness Principle in generated SKILL.md files', () => {
   643→  const skillsWithPreamble = [
   644→    'SKILL.md', 'browse/SKILL.md', 'qa/SKILL.md',
   645→    'qa-only/SKILL.md',
   646→    'setup-browser-cookies/SKILL.md',
   647→    'ship/SKILL.md', 'review/SKILL.md',
   648→    'plan-ceo-review/SKILL.md', 'plan-eng-review/SKILL.md',
   649→    'retro/SKILL.md',
   650→    'plan-design-review/SKILL.md',
   651→    'design-review/SKILL.md',
   652→    'design-consultation/SKILL.md',
   653→    'document-release/SKILL.md',
   654→  ];
   655→
   656→  for (const skill of skillsWithPreamble) {
   657→    test(`${skill} contains Completeness Principle section`, () => {
   658→      const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
   659→      expect(content).toContain('Completeness Principle');
   660→      expect(content).toContain('Boil the Lake');
   661→    });
   662→  }
   663→
   664→  test('Completeness Principle includes compression table', () => {
   665→    const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
   666→    expect(content).toContain('CC+gstack');
   667→    expect(content).toContain('Compression');
   668→  });
   669→
   670→  test('Completeness Principle includes anti-patterns', () => {
   671→    const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
   672→    expect(content).toContain('BAD:');
   673→    expect(content).toContain('Anti-patterns');
   674→  });
   675→});
   676→
   677→// --- Part 7: Planted-bug fixture validation (A4) ---
   678→
   679→describe('Planted-bug fixture validation', () => {
   680→  test('qa-eval ground truth has exactly 5 planted bugs', () => {
   681→    const groundTruth = JSON.parse(
   682→      fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'qa-eval-ground-truth.json'), 'utf-8')
   683→    );
   684→    expect(groundTruth.bugs).toHaveLength(5);
   685→    expect(groundTruth.total_bugs).toBe(5);
   686→  });
   687→
   688→  test('qa-eval-spa ground truth has exactly 5 planted bugs', () => {
   689→    const groundTruth = JSON.parse(
   690→      fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'qa-eval-spa-ground-truth.json'), 'utf-8')
   691→    );
   692→    expect(groundTruth.bugs).toHaveLength(5);
   693→    expect(groundTruth.total_bugs).toBe(5);
   694→  });
   695→
   696→  test('qa-eval-checkout ground truth has exactly 5 planted bugs', () => {
   697→    const groundTruth = JSON.parse(
   698→      fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'qa-eval-checkout-ground-truth.json'), 'utf-8')
   699→    );
   700→    expect(groundTruth.bugs).toHaveLength(5);
   701→    expect(groundTruth.total_bugs).toBe(5);
   702→  });
   703→
   704→  test('qa-eval.html contains the planted bugs', () => {
   705→    const html = fs.readFileSync(path.join(ROOT, 'browse', 'test', 'fixtures', 'qa-eval.html'), 'utf-8');
   706→    // BUG 1: broken link
   707→    expect(html).toContain('/nonexistent-404-page');
   708→    // BUG 2: disabled submit
   709→    expect(html).toContain('disabled');
   710→    // BUG 3: overflow
   711→    expect(html).toContain('overflow: hidden');
   712→    // BUG 4: missing alt
   713→    expect(html).toMatch(/<img[^>]*src="\/logo\.png"[^>]*>/);
   714→    expect(html).not.toMatch(/<img[^>]*src="\/logo\.png"[^>]*alt=/);
   715→    // BUG 5: console error
   716→    expect(html).toContain("Cannot read properties of undefined");
   717→  });
   718→
   719→  test('review-eval-vuln.rb contains expected vulnerability patterns', () => {
   720→    const content = fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'review-eval-vuln.rb'), 'utf-8');
   721→    expect(content).toContain('params[:id]');
   722→    expect(content).toContain('update_column');
   723→  });
   724→});
   725→
   726→// --- CEO review mode validation ---
   727→
   728→describe('CEO review mode validation', () => {
   729→  const content = fs.readFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), 'utf-8');
   730→
   731→  test('has all four CEO review modes defined', () => {
   732→    const modes = ['SCOPE EXPANSION', 'SELECTIVE EXPANSION', 'HOLD SCOPE', 'SCOPE REDUCTION'];
   733→    for (const mode of modes) {
   734→      expect(content).toContain(mode);
   735→    }
   736→  });
   737→
   738→  test('has CEO plan persistence step', () => {
   739→    expect(content).toContain('ceo-plans');
   740→    expect(content).toContain('status: ACTIVE');
   741→  });
   742→
   743→  test('has docs/designs promotion section', () => {
   744→    expect(content).toContain('docs/designs');
   745→    expect(content).toContain('PROMOTED');
   746→  });
   747→
   748→  test('mode quick reference has four columns', () => {
   749→    expect(content).toContain('EXPANSION');
   750→    expect(content).toContain('SELECTIVE');
   751→    expect(content).toContain('HOLD SCOPE');
   752→    expect(content).toContain('REDUCTION');
   753→  });
   754→});
   755→
   756→// --- gstack-slug helper ---
   757→
   758→describe('gstack-slug', () => {
   759→  const SLUG_BIN = path.join(ROOT, 'bin', 'gstack-slug');
   760→
   761→  test('binary exists and is executable', () => {
   762→    expect(fs.existsSync(SLUG_BIN)).toBe(true);
   763→    const stat = fs.statSync(SLUG_BIN);
   764→    expect(stat.mode & 0o111).toBeGreaterThan(0);
   765→  });
   766→
   767→  test('outputs SLUG and BRANCH lines in a git repo', () => {
   768→    const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' });
   769→    expect(result.exitCode).toBe(0);
   770→    const output = result.stdout.toString();
   771→    expect(output).toContain('SLUG=');
   772→    expect(output).toContain('BRANCH=');
   773→  });
   774→
   775→  test('SLUG does not contain forward slashes', () => {
   776→    const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' });
   777→    const slug = result.stdout.toString().match(/SLUG=(.*)/)?.[1] ?? '';
   778→    expect(slug).not.toContain('/');
   779→    expect(slug.length).toBeGreaterThan(0);
   780→  });
   781→
   782→  test('BRANCH does not contain forward slashes', () => {
   783→    const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' });
   784→    const branch = result.stdout.toString().match(/BRANCH=(.*)/)?.[1] ?? '';
   785→    expect(branch).not.toContain('/');
   786→    expect(branch.length).toBeGreaterThan(0);
   787→  });
   788→
   789→  test('output is eval-compatible (KEY=VALUE format)', () => {
   790→    const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' });
   791→    const lines = result.stdout.toString().trim().split('\n');
   792→    expect(lines.length).toBe(2);
   793→    expect(lines[0]).toMatch(/^SLUG=.+/);
   794→    expect(lines[1]).toMatch(/^BRANCH=.+/);
   795→  });
   796→});
   797→
   798→// --- Test Bootstrap validation ---
   799→
   800→describe('Test Bootstrap ({{TEST_BOOTSTRAP}}) integration', () => {
   801→  test('TEST_BOOTSTRAP resolver produces valid content', () => {
   802→    const qaContent = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
   803→    expect(qaContent).toContain('Test Framework Bootstrap');
   804→    expect(qaContent).toContain('RUNTIME:ruby');
   805→    expect(qaContent).toContain('RUNTIME:node');
   806→    expect(qaContent).toContain('RUNTIME:python');
   807→    expect(qaContent).toContain('no-test-bootstrap');
   808→    expect(qaContent).toContain('BOOTSTRAP_DECLINED');
   809→  });
   810→
   811→  test('TEST_BOOTSTRAP appears in qa/SKILL.md', () => {
   812→    const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
   813→    expect(content).toContain('Test Framework Bootstrap');
   814→    expect(content).toContain('TESTING.md');
   815→    expect(content).toContain('CLAUDE.md');
   816→  });
   817→
   818→  test('TEST_BOOTSTRAP appears in ship/SKILL.md', () => {
   819→    const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
   820→    expect(content).toContain('Test Framework Bootstrap');
   821→    expect(content).toContain('Step 2.5');
   822→  });
   823→
   824→  test('TEST_BOOTSTRAP appears in design-review/SKILL.md', () => {
   825→    const content = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
   826→    expect(content).toContain('Test Framework Bootstrap');
   827→  });
   828→
   829→  test('TEST_BOOTSTRAP does NOT appear in qa-only/SKILL.md', () => {
   830→    const content = fs.readFileSync(path.join(ROOT, 'qa-only', 'SKILL.md'), 'utf-8');
   831→    expect(content).not.toContain('Test Framework Bootstrap');
   832→    // But should have the recommendation note
   833→    expect(content).toContain('No test framework detected');
   834→    expect(content).toContain('Run `/qa` to bootstrap');
   835→  });
   836→
   837→  test('bootstrap includes framework knowledge table', () => {
   838→    const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
   839→    expect(content).toContain('vitest');
   840→    expect(content).toContain('minitest');
   841→    expect(content).toContain('pytest');
   842→    expect(content).toContain('cargo test');
   843→    expect(content).toContain('phpunit');
   844→    expect(content).toContain('ExUnit');
   845→  });
   846→
   847→  test('bootstrap includes CI/CD pipeline generation', () => {
   848→    const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
   849→    expect(content).toContain('.github/workflows/test.yml');
   850→    expect(content).toContain('GitHub Actions');
   851→  });
   852→
   853→  test('bootstrap includes first real tests step', () => {
   854→    const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
   855→    expect(content).toContain('First real tests');
   856→    expect(content).toContain('git log --since=30.days');
   857→    expect(content).toContain('Prioritize by risk');
   858→  });
   859→
   860→  test('bootstrap includes vibe coding philosophy', () => {
   861→    const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
   862→    expect(content).toContain('vibe coding');
   863→    expect(content).toContain('100% test coverage');
   864→  });
   865→
   866→  test('WebSearch is in allowed-tools for qa, ship, design-review', () => {
   867→    const qa = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
   868→    const ship = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
   869→    const qaDesign = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
   870→    expect(qa).toContain('WebSearch');
   871→    expect(ship).toContain('WebSearch');
   872→    expect(qaDesign).toContain('WebSearch');
   873→  });
   874→});
   875→
   876→// --- Phase 8e.5 regression test validation ---
   877→
   878→describe('Phase 8e.5 regression test generation', () => {
   879→  test('qa/SKILL.md contains Phase 8e.5', () => {
   880→    const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
   881→    expect(content).toContain('8e.5. Regression Test');
   882→    expect(content).toContain('test(qa): regression test');
   883→    expect(content).toContain('WTF-likelihood exclusion');
   884→  });
   885→
   886→  test('qa/SKILL.md Rule 13 is amended for regression tests', () => {
   887→    const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
   888→    expect(content).toContain('Only modify tests when generating regression tests in Phase 8e.5');
   889→    expect(content).not.toContain('Never modify tests or CI configuration');
   890→  });
   891→
   892→  test('design-review has CSS-aware Phase 8e.5 variant', () => {
   893→    const content = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
   894→    expect(content).toContain('8e.5. Regression Test (design-review variant)');
   895→    expect(content).toContain('CSS-only');
   896→    expect(content).toContain('test(design): regression test');
   897→  });
   898→
   899→  test('regression test includes full attribution comment format', () => {
   900→    const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
   901→    expect(content).toContain('// Regression: ISSUE-NNN');
   902→    expect(content).toContain('// Found by /qa on');
   903→    expect(content).toContain('// Report: .gstack/qa-reports/');
   904→  });
   905→
   906→  test('regression test uses auto-incrementing names', () => {
   907→    const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
   908→    expect(content).toContain('auto-incrementing');
   909→    expect(content).toContain('max number + 1');
   910→  });
   911→});
   912→
   913→// --- Step 3.4 coverage audit validation ---
   914→
   915→describe('Step 3.4 test coverage audit', () => {
   916→  test('ship/SKILL.md contains Step 3.4', () => {
   917→    const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
   918→    expect(content).toContain('Step 3.4: Test Coverage Audit');
   919→    expect(content).toContain('CODE PATH COVERAGE');
   920→  });
   921→
   922→  test('Step 3.4 includes quality scoring rubric', () => {
   923→    const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
   924→    expect(content).toContain('★★★');
   925→    expect(content).toContain('★★');
   926→    expect(content).toContain('edge cases AND error paths');
   927→    expect(content).toContain('happy path only');
   928→  });
   929→
   930→  test('Step 3.4 includes before/after test count', () => {
   931→    const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
   932→    expect(content).toContain('Count test files before');
   933→    expect(content).toContain('Count test files after');
   934→  });
   935→
   936→  test('ship PR body includes Test Coverage section', () => {
   937→    const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
   938→    expect(content).toContain('## Test Coverage');
   939→  });
   940→
   941→  test('ship rules include test generation rule', () => {
   942→    const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
   943→    expect(content).toContain('Step 3.4 generates coverage tests');
   944→    expect(content).toContain('Never commit failing tests');
   945→  });
   946→
   947→  test('Step 3.4 includes vibe coding philosophy', () => {
   948→    const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
   949→    expect(content).toContain('vibe coding becomes yolo coding');
   950→  });
   951→
   952→  test('Step 3.4 traces actual codepaths, not just syntax', () => {
   953→    const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
   954→    expect(content).toContain('Trace every codepath');
   955→    expect(content).toContain('Trace data flow');
   956→    expect(content).toContain('Diagram the execution');
   957→  });
   958→
   959→  test('Step 3.4 maps user flows and interaction edge cases', () => {
   960→    const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
   961→    expect(content).toContain('Map user flows');
   962→    expect(content).toContain('Interaction edge cases');
   963→    expect(content).toContain('Double-click');
   964→    expect(content).toContain('Navigate away');
   965→    expect(content).toContain('Error states the user can see');
   966→    expect(content).toContain('Empty/zero/boundary states');
   967→  });
   968→
   969→  test('Step 3.4 diagram includes USER FLOW COVERAGE section', () => {
   970→    const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
   971→    expect(content).toContain('USER FLOW COVERAGE');
   972→    expect(content).toContain('Code paths:');
   973→    expect(content).toContain('User flows:');
   974→  });
   975→});
   976→
   977→// --- Retro test health validation ---
   978→
   979→describe('Retro test health tracking', () => {
   980→  test('retro/SKILL.md has test health data gathering commands', () => {
   981→    const content = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8');
   982→    expect(content).toContain('# 10. Test file count');
   983→    expect(content).toContain('# 11. Regression test commits');
   984→    expect(content).toContain('# 12. Test files changed');
   985→  });
   986→
   987→  test('retro/SKILL.md has Test Health metrics row', () => {
   988→    const content = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8');
   989→    expect(content).toContain('Test Health');
   990→    expect(content).toContain('regression tests');
   991→  });
   992→
   993→  test('retro/SKILL.md has Test Health narrative section', () => {
   994→    const content = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8');
   995→    expect(content).toContain('### Test Health');
   996→    expect(content).toContain('Total test files');
   997→    expect(content).toContain('vibe coding safe');
   998→  });
   999→
  1000→  test('retro JSON schema includes test_health field', () => {
  1001→    const content = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8');
  1002→    expect(content).toContain('test_health');
  1003→    expect(content).toContain('total_test_files');
  1004→    expect(content).toContain('regression_test_commits');
  1005→  });
  1006→});
  1007→
  1008→// --- QA report template regression tests section ---
  1009→
  1010→describe('QA report template', () => {
  1011→  test('qa-report-template.md has Regression Tests section', () => {
  1012→    const content = fs.readFileSync(path.join(ROOT, 'qa', 'templates', 'qa-report-template.md'), 'utf-8');
  1013→    expect(content).toContain('## Regression Tests');
  1014→    expect(content).toContain('committed / deferred / skipped');
  1015→    expect(content).toContain('### Deferred Tests');
  1016→    expect(content).toContain('**Precondition:**');
  1017→  });
  1018→});
  1019→


     1→/**
     2→ * LLM-as-a-Judge evals for generated SKILL.md quality.
     3→ *
     4→ * Uses the Anthropic API directly (not Agent SDK) to evaluate whether
     5→ * generated command docs are clear, complete, and actionable for an AI agent.
     6→ *
     7→ * Requires: ANTHROPIC_API_KEY env var (or EVALS=1 with key already set)
     8→ * Run: EVALS=1 bun run test:eval
     9→ *
    10→ * Cost: ~$0.05-0.15 per run (sonnet)
    11→ */
    12→
    13→import { describe, test, expect, afterAll } from 'bun:test';
    14→import Anthropic from '@anthropic-ai/sdk';
    15→import * as fs from 'fs';
    16→import * as path from 'path';
    17→import { callJudge, judge } from './helpers/llm-judge';
    18→import type { JudgeScore } from './helpers/llm-judge';
    19→import { EvalCollector } from './helpers/eval-store';
    20→import { selectTests, detectBaseBranch, getChangedFiles, LLM_JUDGE_TOUCHFILES, GLOBAL_TOUCHFILES } from './helpers/touchfiles';
    21→
    22→const ROOT = path.resolve(import.meta.dir, '..');
    23→// Run when EVALS=1 is set (requires ANTHROPIC_API_KEY in env)
    24→const evalsEnabled = !!process.env.EVALS;
    25→const describeEval = evalsEnabled ? describe : describe.skip;
    26→
    27→// Eval result collector
    28→const evalCollector = evalsEnabled ? new EvalCollector('llm-judge') : null;
    29→
    30→// --- Diff-based test selection ---
    31→let selectedTests: string[] | null = null;
    32→
    33→if (evalsEnabled && !process.env.EVALS_ALL) {
    34→  const baseBranch = process.env.EVALS_BASE
    35→    || detectBaseBranch(ROOT)
    36→    || 'main';
    37→  const changedFiles = getChangedFiles(baseBranch, ROOT);
    38→
    39→  if (changedFiles.length > 0) {
    40→    const selection = selectTests(changedFiles, LLM_JUDGE_TOUCHFILES, GLOBAL_TOUCHFILES);
    41→    selectedTests = selection.selected;
    42→    process.stderr.write(`\nLLM-judge selection (${selection.reason}): ${selection.selected.length}/${Object.keys(LLM_JUDGE_TOUCHFILES).length} tests\n`);
    43→    if (selection.skipped.length > 0) {
    44→      process.stderr.write(`  Skipped: ${selection.skipped.join(', ')}\n`);
    45→    }
    46→    process.stderr.write('\n');
    47→  }
    48→}
    49→
    50→/** Wrap a describe block to skip if none of its tests are selected. */
    51→function describeIfSelected(name: string, testNames: string[], fn: () => void) {
    52→  const anySelected = selectedTests === null || testNames.some(t => selectedTests!.includes(t));
    53→  (anySelected ? describeEval : describe.skip)(name, fn);
    54→}
    55→
    56→/** Skip an individual test if not selected (for multi-test describe blocks). */
    57→function testIfSelected(testName: string, fn: () => Promise<void>, timeout: number) {
    58→  const shouldRun = selectedTests === null || selectedTests.includes(testName);
    59→  (shouldRun ? test : test.skip)(testName, fn, timeout);
    60→}
    61→
    62→describeIfSelected('LLM-as-judge quality evals', [
    63→  'command reference table', 'snapshot flags reference',
    64→  'browse/SKILL.md reference', 'setup block', 'regression vs baseline',
    65→], () => {
    66→  testIfSelected('command reference table', async () => {
    67→    const t0 = Date.now();
    68→    const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
    69→    const start = content.indexOf('## Command Reference');
    70→    const end = content.indexOf('## Tips');
    71→    const section = content.slice(start, end);
    72→
    73→    const scores = await judge('command reference table', section);
    74→    console.log('Command reference scores:', JSON.stringify(scores, null, 2));
    75→
    76→    evalCollector?.addTest({
    77→      name: 'command reference table',
    78→      suite: 'LLM-as-judge quality evals',
    79→      tier: 'llm-judge',
    80→      passed: scores.clarity >= 4 && scores.completeness >= 4 && scores.actionability >= 4,
    81→      duration_ms: Date.now() - t0,
    82→      cost_usd: 0.02,
    83→      judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
    84→      judge_reasoning: scores.reasoning,
    85→    });
    86→
    87→    expect(scores.clarity).toBeGreaterThanOrEqual(4);
    88→    expect(scores.completeness).toBeGreaterThanOrEqual(4);
    89→    expect(scores.actionability).toBeGreaterThanOrEqual(4);
    90→  }, 30_000);
    91→
    92→  testIfSelected('snapshot flags reference', async () => {
    93→    const t0 = Date.now();
    94→    const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
    95→    const start = content.indexOf('## Snapshot System');
    96→    const end = content.indexOf('## Command Reference');
    97→    const section = content.slice(start, end);
    98→
    99→    const scores = await judge('snapshot flags reference', section);
   100→    console.log('Snapshot flags scores:', JSON.stringify(scores, null, 2));
   101→
   102→    evalCollector?.addTest({
   103→      name: 'snapshot flags reference',
   104→      suite: 'LLM-as-judge quality evals',
   105→      tier: 'llm-judge',
   106→      passed: scores.clarity >= 4 && scores.completeness >= 4 && scores.actionability >= 4,
   107→      duration_ms: Date.now() - t0,
   108→      cost_usd: 0.02,
   109→      judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
   110→      judge_reasoning: scores.reasoning,
   111→    });
   112→
   113→    expect(scores.clarity).toBeGreaterThanOrEqual(4);
   114→    expect(scores.completeness).toBeGreaterThanOrEqual(4);
   115→    expect(scores.actionability).toBeGreaterThanOrEqual(4);
   116→  }, 30_000);
   117→
   118→  testIfSelected('browse/SKILL.md reference', async () => {
   119→    const t0 = Date.now();
   120→    const content = fs.readFileSync(path.join(ROOT, 'browse', 'SKILL.md'), 'utf-8');
   121→    const start = content.indexOf('## Snapshot Flags');
   122→    const section = content.slice(start);
   123→
   124→    const scores = await judge('browse skill reference (flags + commands)', section);
   125→    console.log('Browse SKILL.md scores:', JSON.stringify(scores, null, 2));
   126→
   127→    evalCollector?.addTest({
   128→      name: 'browse/SKILL.md reference',
   129→      suite: 'LLM-as-judge quality evals',
   130→      tier: 'llm-judge',
   131→      passed: scores.clarity >= 4 && scores.completeness >= 4 && scores.actionability >= 4,
   132→      duration_ms: Date.now() - t0,
   133→      cost_usd: 0.02,
   134→      judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
   135→      judge_reasoning: scores.reasoning,
   136→    });
   137→
   138→    expect(scores.clarity).toBeGreaterThanOrEqual(4);
   139→    expect(scores.completeness).toBeGreaterThanOrEqual(4);
   140→    expect(scores.actionability).toBeGreaterThanOrEqual(4);
   141→  }, 30_000);
   142→
   143→  testIfSelected('setup block', async () => {
   144→    const t0 = Date.now();
   145→    const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
   146→    const setupStart = content.indexOf('## SETUP');
   147→    const setupEnd = content.indexOf('## IMPORTANT');
   148→    const section = content.slice(setupStart, setupEnd);
   149→
   150→    const scores = await judge('setup/binary discovery instructions', section);
   151→    console.log('Setup block scores:', JSON.stringify(scores, null, 2));
   152→
   153→    evalCollector?.addTest({
   154→      name: 'setup block',
   155→      suite: 'LLM-as-judge quality evals',
   156→      tier: 'llm-judge',
   157→      passed: scores.actionability >= 3 && scores.clarity >= 3,
   158→      duration_ms: Date.now() - t0,
   159→      cost_usd: 0.02,
   160→      judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
   161→      judge_reasoning: scores.reasoning,
   162→    });
   163→
   164→    // Setup block is intentionally minimal (binary discovery only).
   165→    // SKILL_DIR is inferred from context, so judge sometimes scores 3.
   166→    expect(scores.actionability).toBeGreaterThanOrEqual(3);
   167→    expect(scores.clarity).toBeGreaterThanOrEqual(3);
   168→  }, 30_000);
   169→
   170→  testIfSelected('regression vs baseline', async () => {
   171→    const t0 = Date.now();
   172→    const generated = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
   173→    const genStart = generated.indexOf('## Command Reference');
   174→    const genEnd = generated.indexOf('## Tips');
   175→    const genSection = generated.slice(genStart, genEnd);
   176→
   177→    const baseline = `## Command Reference
   178→
   179→### Navigation
   180→| Command | Description |
   181→|---------|-------------|
   182→| \`goto <url>\` | Navigate to URL |
   183→| \`back\` / \`forward\` | History navigation |
   184→| \`reload\` | Reload page |
   185→| \`url\` | Print current URL |
   186→
   187→### Interaction
   188→| Command | Description |
   189→|---------|-------------|
   190→| \`click <sel>\` | Click element |
   191→| \`fill <sel> <val>\` | Fill input |
   192→| \`select <sel> <val>\` | Select dropdown |
   193→| \`hover <sel>\` | Hover element |
   194→| \`type <text>\` | Type into focused element |
   195→| \`press <key>\` | Press key (Enter, Tab, Escape) |
   196→| \`scroll [sel]\` | Scroll element into view |
   197→| \`wait <sel>\` | Wait for element (max 10s) |
   198→| \`wait --networkidle\` | Wait for network to be idle |
   199→| \`wait --load\` | Wait for page load event |
   200→


     1→import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
     2→import { runSkillTest } from './helpers/session-runner';
     3→import type { SkillTestResult } from './helpers/session-runner';
     4→import { outcomeJudge, callJudge } from './helpers/llm-judge';
     5→import { EvalCollector, judgePassed } from './helpers/eval-store';
     6→import type { EvalTestEntry } from './helpers/eval-store';
     7→import { startTestServer } from '../browse/test/test-server';
     8→import { selectTests, detectBaseBranch, getChangedFiles, E2E_TOUCHFILES, GLOBAL_TOUCHFILES } from './helpers/touchfiles';
     9→import { spawnSync } from 'child_process';
    10→import * as fs from 'fs';
    11→import * as path from 'path';
    12→import * as os from 'os';
    13→
    14→const ROOT = path.resolve(import.meta.dir, '..');
    15→
    16→// Skip unless EVALS=1. Session runner strips CLAUDE* env vars to avoid nested session issues.
    17→//
    18→// BLAME PROTOCOL: When an eval fails, do NOT claim "pre-existing" or "not related
    19→// to our changes" without proof. Run the same eval on main to verify. These tests
    20→// have invisible couplings — preamble text, SKILL.md content, and timing all affect
    21→// agent behavior. See CLAUDE.md "E2E eval failure blame protocol" for details.
    22→const evalsEnabled = !!process.env.EVALS;
    23→const describeE2E = evalsEnabled ? describe : describe.skip;
    24→
    25→// --- Diff-based test selection ---
    26→// When EVALS_ALL is not set, only run tests whose touchfiles were modified.
    27→// Set EVALS_ALL=1 to force all tests. Set EVALS_BASE to override base branch.
    28→let selectedTests: string[] | null = null; // null = run all
    29→
    30→if (evalsEnabled && !process.env.EVALS_ALL) {
    31→  const baseBranch = process.env.EVALS_BASE
    32→    || detectBaseBranch(ROOT)
    33→    || 'main';
    34→  const changedFiles = getChangedFiles(baseBranch, ROOT);
    35→
    36→  if (changedFiles.length > 0) {
    37→    const selection = selectTests(changedFiles, E2E_TOUCHFILES, GLOBAL_TOUCHFILES);
    38→    selectedTests = selection.selected;
    39→    process.stderr.write(`\nE2E selection (${selection.reason}): ${selection.selected.length}/${Object.keys(E2E_TOUCHFILES).length} tests\n`);
    40→    if (selection.skipped.length > 0) {
    41→      process.stderr.write(`  Skipped: ${selection.skipped.join(', ')}\n`);
    42→    }
    43→    process.stderr.write('\n');
    44→  }
    45→  // If changedFiles is empty (e.g., on main branch), selectedTests stays null → run all
    46→}
    47→
    48→/** Wrap a describe block to skip entirely if none of its tests are selected. */
    49→function describeIfSelected(name: string, testNames: string[], fn: () => void) {
    50→  const anySelected = selectedTests === null || testNames.some(t => selectedTests!.includes(t));
    51→  (anySelected ? describeE2E : describe.skip)(name, fn);
    52→}
    53→
    54→/** Skip an individual test if not selected (for multi-test describe blocks). */
    55→function testIfSelected(testName: string, fn: () => Promise<void>, timeout: number) {
    56→  const shouldRun = selectedTests === null || selectedTests.includes(testName);
    57→  (shouldRun ? test : test.skip)(testName, fn, timeout);
    58→}
    59→
    60→// Eval result collector — accumulates test results, writes to ~/.gstack-dev/evals/ on finalize
    61→const evalCollector = evalsEnabled ? new EvalCollector('e2e') : null;
    62→
    63→// Unique run ID for this E2E session — used for heartbeat + per-run log directory
    64→const runId = new Date().toISOString().replace(/[:.]/g, '').replace('T', '-').slice(0, 15);
    65→
    66→/** DRY helper to record an E2E test result into the eval collector. */
    67→function recordE2E(name: string, suite: string, result: SkillTestResult, extra?: Partial<EvalTestEntry>) {
    68→  // Derive last tool call from transcript for machine-readable diagnostics
    69→  const lastTool = result.toolCalls.length > 0
    70→    ? `${result.toolCalls[result.toolCalls.length - 1].tool}(${JSON.stringify(result.toolCalls[result.toolCalls.length - 1].input).slice(0, 60)})`
    71→    : undefined;
    72→
    73→  evalCollector?.addTest({
    74→    name, suite, tier: 'e2e',
    75→    passed: result.exitReason === 'success' && result.browseErrors.length === 0,
    76→    duration_ms: result.duration,
    77→    cost_usd: result.costEstimate.estimatedCost,
    78→    transcript: result.transcript,
    79→    output: result.output?.slice(0, 2000),
    80→    turns_used: result.costEstimate.turnsUsed,
    81→    browse_errors: result.browseErrors,
    82→    exit_reason: result.exitReason,
    83→    timeout_at_turn: result.exitReason === 'timeout' ? result.costEstimate.turnsUsed : undefined,
    84→    last_tool_call: lastTool,
    85→    ...extra,
    86→  });
    87→}
    88→
    89→let testServer: ReturnType<typeof startTestServer>;
    90→let tmpDir: string;
    91→const browseBin = path.resolve(ROOT, 'browse', 'dist', 'browse');
    92→
    93→/**
    94→ * Copy a directory tree recursively (files only, follows structure).
    95→ */
    96→function copyDirSync(src: string, dest: string) {
    97→  fs.mkdirSync(dest, { recursive: true });
    98→  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
    99→    const srcPath = path.join(src, entry.name);
   100→    const destPath = path.join(dest, entry.name);
   101→    if (entry.isDirectory()) {
   102→      copyDirSync(srcPath, destPath);
   103→    } else {
   104→      fs.copyFileSync(srcPath, destPath);
   105→    }
   106→  }
   107→}
   108→
   109→/**
   110→ * Set up browse shims (binary symlink, find-browse, remote-slug) in a tmpDir.
   111→ */
   112→function setupBrowseShims(dir: string) {
   113→  // Symlink browse binary
   114→  const binDir = path.join(dir, 'browse', 'dist');
   115→  fs.mkdirSync(binDir, { recursive: true });
   116→  if (fs.existsSync(browseBin)) {
   117→    fs.symlinkSync(browseBin, path.join(binDir, 'browse'));
   118→  }
   119→
   120→  // find-browse shim
   121→  const findBrowseDir = path.join(dir, 'browse', 'bin');
   122→  fs.mkdirSync(findBrowseDir, { recursive: true });
   123→  fs.writeFileSync(
   124→    path.join(findBrowseDir, 'find-browse'),
   125→    `#!/bin/bash\necho "${browseBin}"\n`,
   126→    { mode: 0o755 },
   127→  );
   128→
   129→  // remote-slug shim (returns test-project)
   130→  fs.writeFileSync(
   131→    path.join(findBrowseDir, 'remote-slug'),
   132→    `#!/bin/bash\necho "test-project"\n`,
   133→    { mode: 0o755 },
   134→  );
   135→}
   136→
   137→/**
   138→ * Print cost summary after an E2E test.
   139→ */
   140→function logCost(label: string, result: { costEstimate: { turnsUsed: number; estimatedTokens: number; estimatedCost: number }; duration: number }) {
   141→  const { turnsUsed, estimatedTokens, estimatedCost } = result.costEstimate;
   142→  const durationSec = Math.round(result.duration / 1000);
   143→  console.log(`${label}: $${estimatedCost.toFixed(2)} (${turnsUsed} turns, ${(estimatedTokens / 1000).toFixed(1)}k tokens, ${durationSec}s)`);
   144→}
   145→
   146→/**
   147→ * Dump diagnostic info on planted-bug outcome failure (decision 1C).
   148→ */
   149→function dumpOutcomeDiagnostic(dir: string, label: string, report: string, judgeResult: any) {
   150→  try {
   151→    const transcriptDir = path.join(dir, '.gstack', 'test-transcripts');
   152→    fs.mkdirSync(transcriptDir, { recursive: true });
   153→    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
   154→    fs.writeFileSync(
   155→      path.join(transcriptDir, `${label}-outcome-${timestamp}.json`),
   156→      JSON.stringify({ label, report, judgeResult }, null, 2),
   157→    );
   158→  } catch { /* non-fatal */ }
   159→}
   160→
   161→// Fail fast if Anthropic API is unreachable — don't burn through 13 tests getting ConnectionRefused
   162→if (evalsEnabled) {
   163→  const check = spawnSync('sh', ['-c', 'echo "ping" | claude -p --max-turns 1 --output-format stream-json --verbose --dangerously-skip-permissions'], {
   164→    stdio: 'pipe', timeout: 30_000,
   165→  });
   166→  const output = check.stdout?.toString() || '';
   167→  if (output.includes('ConnectionRefused') || output.includes('Unable to connect')) {
   168→    throw new Error('Anthropic API unreachable — aborting E2E suite. Fix connectivity and retry.');
   169→  }
   170→}
   171→
   172→describeIfSelected('Skill E2E tests', [
   173→  'browse-basic', 'browse-snapshot', 'skillmd-setup-discovery',
   174→  'skillmd-no-local-binary', 'skillmd-outside-git', 'contributor-mode', 'session-awareness',
   175→], () => {
   176→  beforeAll(() => {
   177→    testServer = startTestServer();
   178→    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-'));
   179→    setupBrowseShims(tmpDir);
   180→  });
   181→
   182→  afterAll(() => {
   183→    testServer?.server?.stop();
   184→    try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
   185→  });
   186→
   187→  testIfSelected('browse-basic', async () => {
   188→    const result = await runSkillTest({
   189→      prompt: `You have a browse binary at ${browseBin}. Assign it to B variable and run these commands in sequence:
   190→1. $B goto ${testServer.url}
   191→2. $B snapshot -i
   192→3. $B text
   193→4. $B screenshot /tmp/skill-e2e-test.png
   194→Report the results of each command.`,
   195→      workingDirectory: tmpDir,
   196→      maxTurns: 10,
   197→      timeout: 60_000,
   198→      testName: 'browse-basic',
   199→      runId,
   200→    });