Skill: newtontech/pr-proof-of-work (version 1.0.0)

--- name: tdd-e2e-pr-workflow description: > TDD-driven E2E workflow with real Playwright browser screenshots as PR proof. Use when: (1) fix bugs or implement features with test-first approach, (2) create PRs with visual before/after proof from E2E tests, (3) generate Playwright E2E tests with BEFORE/AFTER screenshots from real browser, (4) post screenshot comparisons as PR comments on GitHub. Workflow: pick one open issue → write E2E test → implement fix → PR with screenshots. Process ONE issue at a time. Complete current PR before starting next. NEVER use page.setContent() — always test through the real application. Triggers: "tdd e2e", "screenshot pr", "before after screenshot", "visual pr proof", "e2e pr workflow", "issue to pr with screenshots", "playwright tdd". --- # TDD E2E PR Workflow One Issue → One PR with visual proof. **Flow**: Select one issue → Write test (BEFORE screenshot) → Fix → Test passes (AFTER screenshot) → PR with screenshot comment. **Golden Rule**: Study ≥2 existing PASSING tests before writing any new test. Wrong fixture/selector usage is #1 failure cause. --- ## Phase 0: Select One Issue Choose an open issue to work on, or pick randomly: ```bash # Option A: List and manually select gh issue list --repo <owner/repo> --state open --json number,title,body # Option B: Pick one randomly gh issue list --repo <owner/repo> --state open --json number,title | \ jq -r '.[] | "\(.number): \(.title)"' | shuf -n 1 ``` 1. Note the issue number and create a short kebab-case slug from title (e.g. "fix-discard-button") 2. Create isolated worktree: ```bash git worktree add .worktrees/fix/<slug> -b fix/<slug> cd .worktrees/fix/<slug> ``` 3. Copy [screenshot-reporter.ts](assets/screenshot-reporter.ts) into the worktree's e2e/ directory (not tracked on fix branches) 4. Set screenshot output: ```bash export E2E_SCREENSHOT_DIR="$(pwd)/e2e-screenshots" ``` --- ## Phase 1: Write Real Browser Test ### Step A: Study existing tests (MANDATORY) Read ≥2 passing E2E tests to understand: - Fixture API (`withProject`, `withSession`, `gotoSession`, `sdk`) - Action helpers (`waitSessionIdle`, `runTerminal`, `openSettings`) - Selector patterns (data-component, data-slot, ARIA roles) - Data seeding (`apply_patch`, terminal commands, SDK methods) ### Step B: Write test with BEFORE screenshot ```typescript import { test, expect } from "../fixtures" import { createScreenshotReporter } from "../screenshot-reporter" test("feature description", async ({ page, withProject }) => { const screenshot = createScreenshotReporter(page, "test-name") await withProject(async (project) => { // ... setup using project's fixture patterns ... await screenshot.captureBefore("initial-state") // ... assertions ... }) }) ``` **Playwright TS transform gotcha** — rejects inline object params: ```typescript // WRONG: fs.mkdirSync(dir, { recursive: true }) // RIGHT: const opts = { recursive: true } fs.mkdirSync(dir, opts) ``` Apply to ALL object literals: `fs.mkdirSync`, `page.screenshot`, `page.waitForFunction`, etc. --- ## Phase 2: Implement Fix Write minimum code to pass. Do NOT modify test assertions. ### Debugging loop (most tests need 2-4 iterations): 1. **Read error carefully** — 90% are selector/fixture mismatches, not logic bugs 2. **Verify DOM** — use `page.waitForSelector` or `page.waitForFunction` to confirm elements exist 3. **Check prerequisites** — hover before clicking, expand before asserting, wait for idle 4. **Use `page.pause()`** to inspect live DOM 5. **Never weaken assertions** — fix the code, not the test ### Common failure patterns (see [references/debugging-guide.md](references/debugging-guide.md)): | Symptom | Likely Cause | Fix | |---|---|---| | Timeout on selector | Wrong data attribute or shadow DOM | Check DOM with `page.pause()`, try ARIA roles | | `waitMark` never resolves | Seed function missing expected content | Match seed format exactly from passing tests | | Button not visible | Missing hover/expand step | Hover parent row, expand section first | | Review panel empty | Wrong changes mode (git vs session) | Switch mode before asserting | | Stale element reference | Race condition with async updates | Add `waitSessionIdle` after mutations | --- ## Phase 3: AFTER Screenshot + Hard Gate ```typescript await expect(fixedElement).toBeVisible() await screenshot.captureAfter("feature-working") ``` **Hard gate** — ALL three must be true before PR: - Test passes (exit code 0) - `BEFORE-*.png` exists - `AFTER-*.png` exists --- ## Phase 4: PR + Screenshot Comment ```bash # Stage, commit, push git add -A git commit -m "fix: <issue-description> (closes #<issue-number>)" git push origin fix/<slug> # Create PR referencing the issue gh pr create \ --title "fix: <short-description>" \ --body "Closes #<issue-number> ## Summary <Brief description of the fix> ## Screenshots | Before | After | |--------|-------| | ![Before](<BEFORE-screenshot-url>) | ![After](<AFTER-screenshot-url>) | ## Verification - [x] E2E test passes - [x] BEFORE screenshot captured - [x] AFTER screenshot captured" \ --repo <owner/repo> # Push screenshots to branch for GitHub raw URLs git add e2e-screenshots/ git commit -m "chore: add e2e screenshots" git push origin fix/<slug> ``` `gh pr comment` has no `--attach` — push images to branch, use raw GitHub URLs. --- ## Backend-Only PRs For PRs without direct UI changes, find the closest UI-exercisable path: | Change Type | E2E Strategy | Real Example | |---|---|---| | Config utility | Open settings dialog | `openSettings(page)` → screenshot config panel | | Python resolver | Run terminal command | `runTerminal(page, {cmd: "python3 --version"})` → show output | | Race condition | Trigger rapid operations | Create file externally → click refresh → verify | | State management | Multi-session switching | Select file in session A → switch to B → back to A | | Cache bypass | Force refresh | Add external file → click refresh button → verify | | URL parsing | Check connection status | `openStatusPopover(page)` → verify connected | | Focus management | Verify focus after action | Open file → check prompt still focused | **NEVER use `page.setContent()`** — always test through the real application. --- ## Quick Reference ```bash # Select an issue (manual or random) gh issue list --repo owner/repo --state open --json number,title # Create worktree git worktree add .worktrees/fix/ISSUE -b fix/ISSUE cd .worktrees/fix/ISSUE # Set screenshot dir export E2E_SCREENSHOT_DIR="$(pwd)/e2e-screenshots" # Run E2E test npm run test:e2e -- <test-file> # Push and create PR git push origin fix/ISSUE gh pr create --title "fix: ..." --body "Closes #N" --repo owner/repo ``` --- ## Constraints - **One issue at a time** — complete one PR before starting the next - BEFORE and AFTER must BOTH exist before PR comment — hard gate, no exceptions - Screenshots save to worktree via `E2E_SCREENSHOT_DIR`, never to main repo - Always study ≥2 passing tests first — this is the #1 rule - NEVER use `page.setContent()` — real app only - Playwright TS transform rejects inline object params — always extract to variable - `screenshot-reporter.ts` must be copied to each worktree's e2e/ (not on fix branches) - Test MUST pass before PR — never create PR with failing tests

path: assets/screenshot-reporter.ts
content: import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import type { Page } from "@playwright/test"

const __dirname = path.dirname(fileURLToPath(import.meta.url))

/**
 * E2E screenshot reporter for PR before/after comparison.
 *
 * Output: controlled by E2E_SCREENSHOT_DIR env var (defaults to <e2e>/screenshots).
 * Each test gets a subdirectory with:
 *   - BEFORE-*.png  — failure/initial state
 *   - AFTER-*.png   — success/fixed state
 *   - manifest.json — records before/after paths for PR comment script
 */

const SCREENSHOT_DIR = process.env.E2E_SCREENSHOT_DIR || path.join(__dirname, "screenshots")

export interface ScreenshotReporter {
  capture: (label: string, options?: { fullPage?: boolean }) => Promise<string>
  captureBefore: (label: string, options?: { fullPage?: boolean }) => Promise<string>
  captureAfter: (label: string, options?: { fullPage?: boolean }) => Promise<string>
}

interface ScreenshotManifest {
  testName: string
  before?: string
  after?: string
}

export function createScreenshotReporter(page: Page, testName: string): ScreenshotReporter {
  const dir = path.join(SCREENSHOT_DIR, testName)
  const manifest: ScreenshotManifest = { testName }

  const ensureDir = () => {
    if (!fs.existsSync(dir)) {
      const opts = { recursive: true }
      fs.mkdirSync(dir, opts)
    }
  }

  const writeManifest = () => {
    ensureDir()
    const manifestPath = path.join(dir, "manifest.json")
    const data = JSON.stringify(manifest, null, 2)
    fs.writeFileSync(manifestPath, data)
  }

  return {
    async capture(label, options = {}) {
      ensureDir()
      const filepath = path.join(dir, `${label}-${Date.now()}.png`)
      const shotOpts = { path: filepath, fullPage: options.fullPage ?? true }
      await page.screenshot(shotOpts)
      return filepath
    },

    async captureBefore(label, options = {}) {
      ensureDir()
      const filepath = path.join(dir, `BEFORE-${label}-${Date.now()}.png`)
      const shotOpts = { path: filepath, fullPage: options.fullPage ?? true }
      await page.screenshot(shotOpts)
      manifest.before = filepath
      writeManifest()
      return filepath
    },

    async captureAfter(label, options = {}) {
      ensureDir()
      const filepath = path.join(dir, `AFTER-${label}-${Date.now()}.png`)
      const shotOpts = { path: filepath, fullPage: options.fullPage ?? true }
      await page.screenshot(shotOpts)
      manifest.after = filepath
      writeManifest()
      return filepath
    },
  }
}


path: scripts/pr-comment-screenshots.sh
content: #!/usr/bin/env bash
# Post before/after screenshots as a PR comment.
#
# Usage: pr-comment-screenshots.sh <owner/repo> <pr-number> <screenshot-dir>
#
# Reads manifest.json from each test subdirectory, uploads images to a
# temporary branch, and posts a markdown comment with embedded images.
#
# No Python dependency — uses only grep/cut for JSON parsing.
#
set -euo pipefail

REPO="${1:?Usage: pr-comment-screenshots.sh <owner/repo> <pr-number> <screenshot-dir>}"
PR_NUM="${2:?Missing PR number}"
SCREENSHOT_DIR="${3:?Missing screenshot dir}"

if [ ! -d "$SCREENSHOT_DIR" ]; then
  echo "ERROR: screenshot dir not found: $SCREENSHOT_DIR" >&2
  exit 1
fi

cd "$(git rev-parse --show-toplevel)"

# ── 1. Push screenshots to a temp branch for raw URLs ──
BRANCH="e2e-screenshots-${PR_NUM}"
CURRENT=$(git branch --show-current)

# Save screenshots to temp location
TMP=$(mktemp -d)
cp -r "$SCREENSHOT_DIR"/* "$TMP/" 2>/dev/null || true

git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH" 2>/dev/null
mkdir -p e2e-screenshots
cp -r "$TMP"/* e2e-screenshots/ 2>/dev/null || true
git add e2e-screenshots/
git commit -m "chore: add E2E screenshots for PR #${PR_NUM}" 2>/dev/null || true

REMOTE=$(git remote -v | grep -m1 "(push)" | awk '{print $1}')
git push "$REMOTE" "$BRANCH" --force 2>&1 || true
git checkout "$CURRENT" 2>/dev/null

# ── 2. Build comment from manifests ──
BODY="## E2E Test Screenshots\n\n"
BODY+="| Test | Before (Bug) | After (Fixed) |\n|---|:---:|:---:|\n"

HAS_PAIRS=false
for manifest in "$SCREENSHOT_DIR"/*/manifest.json; do
  [ -f "$manifest" ] || continue

  # Parse simple JSON with grep (no jq/python dependency needed)
  TEST_NAME=$(grep -o '"testName":"[^"]*"' "$manifest" | cut -d'"' -f4 || echo "test")
  BEFORE_PATH=$(grep -o '"before":"[^"]*"' "$manifest" | cut -d'"' -f4 || echo "")
  AFTER_PATH=$(grep -o '"after":"[^"]*"' "$manifest" | cut -d'"' -f4 || echo "")

  BEFORE_FILENAME=$(basename "$BEFORE_PATH" 2>/dev/null || echo "")
  AFTER_FILENAME=$(basename "$AFTER_PATH" 2>/dev/null || echo "")
  TEST_DIR=$(basename "$(dirname "$manifest")" 2>/dev/null || echo "")

  if [ -n "$BEFORE_FILENAME" ] && [ -n "$AFTER_FILENAME" ]; then
    HAS_PAIRS=true
    BODY+="| **${TEST_NAME}** | "
    BODY+="![Before](https://github.com/${REPO}/raw/${BRANCH}/e2e-screenshots/${TEST_DIR}/${BEFORE_FILENAME}) | "
    BODY+="![After](https://github.com/${REPO}/raw/${BRANCH}/e2e-screenshots/${TEST_DIR}/${AFTER_FILENAME}) |\n"
  fi
done

if [ "$HAS_PAIRS" = false ]; then
  BODY+="_No before/after pairs found in ${SCREENSHOT_DIR}_\n"
fi

BODY+="\n---\n_Generated by tdd-e2e-pr-workflow skill_"

echo -e "$BODY" > /tmp/pr-comment-body.md

# ── 3. Post comment ──
gh pr comment "$PR_NUM" --repo "$REPO" --body-file /tmp/pr-comment-body.md
echo "Comment posted to PR #$PR_NUM"

# Cleanup
rm -rf "$TMP" /tmp/pr-comment-body.md


path: references/testing-patterns.md
content: # Testing Patterns — Lessons from 13 Real E2E Tests

Patterns extracted from running 13 Playwright E2E tests across different PR types (UI, backend, config, race conditions).

## Table of Contents

- [Data Seeding Patterns](#data-seeding-patterns)
- [Navigation Patterns](#navigation-patterns)
- [Selector Strategies](#selector-strategies)
- [Timing & Wait Patterns](#timing--wait-patterns)
- [Backend PR Test Strategies](#backend-pr-test-strategies)
- [Review Panel Patterns](#review-panel-patterns)
- [File Tree Patterns](#file-tree-patterns)
- [Common Pitfalls](#common-pitfalls)

---

## Data Seeding Patterns

### Via `apply_patch` (AI prompt)

Used by: upload-improve, ai-visible-undo, doc-shortname, md-render-opt

```typescript
async function patch(sdk, sessionID, patchText) {
  await sdk.session.promptAsync({
    sessionID,
    agent: "build",
    system: [
      "You are seeding deterministic e2e UI state.",
      "Your only valid response is one apply_patch tool call.",
      `Use this JSON input: ${JSON.stringify({ patchText })}`,
      "Do not call any other tools. Do not output plain text.",
    ].join("\n"),
    parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
  })
  // CRITICAL: must wait for session to finish processing
  await waitSessionIdle(sdk, sessionID, 120_000)
}
```

Seed format:
```
*** Begin Patch
*** Add File: src/index.ts
+line 01 mark-value
+line 02 mark-value
*** End Patch
```

**Must include a unique mark line** for `waitMark()` to find in the DOM:
```
+title mark-value
+mark mark-value        ← this is what waitMark looks for
+line 01 mark-value
```

### Via terminal commands

Used by: windows-python, refresh-button, file-management

```typescript
import { runTerminal, waitTerminalReady } from "../actions"

await runTerminal(page, {
  cmd: "python3 --version",
  [REDACTED_SECRET]",    // wait for this in output
  timeout: 10_000,
})
```

### Via external file creation

Used by: file-management, refresh-button

```typescript
await page.evaluate(async (dir) => {
  const fs = require("fs")
  const path = require("path")
  const filePath = path.join(dir, "new-file.txt")
  const content = "created externally"
  fs.writeFileSync(filePath, content)
}, projectDirectory)
```

---

## Navigation Patterns

### Open file via `/open` command

Used by: pdf-reader, pdf-focus-translate, md-render-opt

```typescript
// Open the slash command palette
const prompt = page.locator('[data-component="prompt-input"]')
await prompt.click()
await page.keyboard.type("/open")

// Wait for search dialog
await page.waitForSelector('[data-slot="command-palette"]')

// Type filename to search
await page.keyboard.type("test.pdf")

// Click the matching file item
const fileItem = page.locator('[data-slot="command-palette"] [role="option"]').first()
await fileItem.click()

// Wait for file tab to appear
await page.waitForSelector('[data-slot="session-tab"][data-active="true"]')
```

### Session switching

Used by: session-mixing

```typescript
// Create two sessions
await withSession(sdk, "session-a", async (sessionA) => {
  await withSession(sdk, "session-b", async (sessionB) => {
    // Navigate to session A
    await gotoSession(sessionA.id)
    // ... interact with A ...
    // Navigate to session B
    await gotoSession(sessionB.id)
    // ... interact with B ...
    // Navigate back to A
    await gotoSession(sessionA.id)
  })
})
```

### Open settings dialog

Used by: opencode-rename

```typescript
import { openSettings } from "../actions"

const dialog = await openSettings(page)
await expect(dialog.getByText("General")).toBeVisible()
```

### Open status popover

Used by: url-launch

```typescript
import { openStatusPopover } from "../actions"

const { popoverBody } = await openStatusPopover(page)
// Verify server connection status
```

---

## Selector Strategies

### Priority order (most robust first):

1. **ARIA roles** — `getByRole("button", { name: "Refresh" })`
2. **data-component** — `[data-component="prompt-input"]`
3. **data-slot** — `[data-slot="session-tab"]`
4. **data-file** — `[data-file="test.ts"]`
5. **getByText** — `page.getByText("Upload file here")`
6. **CSS classes** — LAST RESORT only (fragile)

### Shadow DOM handling

Some components use shadow DOM (e.g., `diffs-container`):
```typescript
// WRONG: page.locator("diffs-container .content") — can't pierce shadow
// RIGHT:
await page.waitForFunction(
  ({ file, mark }) => {
    const view = document.querySelector(".scroll-view__viewport")
    const head = Array.from(view.querySelectorAll("h3")).find(
      (n) => n.textContent?.includes(file)
    )
    const host = head?.parentElement?.querySelector("diffs-container")
    return host?.shadowRoot?.textContent?.includes(`mark ${mark}`)
  },
  { file, mark },
  { timeout: 60_000 }
)
```

### Markdown table selectors

Markdown is rendered in regular DOM, NOT shadow DOM:
```typescript
// WRONG: page.locator("table td") — may not match rendered structure
// RIGHT:
await expect(page.getByRole("columnheader", { name: "Feature" })).toBeVisible()
await expect(page.getByRole("cell", { name: "Syntax" })).toBeVisible()
```

### Context menu items

```typescript
// Right-click to open
await row.click({ button: "right" })

// Find context menu items
const menu = page.locator('[data-component="context-menu-content"]')
await expect(menu).toBeVisible()

const uploadItem = menu.locator('[data-slot="context-menu-item-label"]', {
  hasText: /上传文件到此处/,
})
await expect(uploadItem).toBeVisible()
```

---

## Timing & Wait Patterns

### Critical wait sequences

```typescript
// After patch via AI prompt
await waitSessionIdle(sdk, sessionID, 120_000)

// After file creation via terminal
await runTerminal(page, { cmd: "...", [REDACTED_SECRET]" })

// After external file creation + refresh
await page.waitForTimeout(500)  // let filesystem sync
await refreshButton.click()
await page.waitForSelector('[data-file="new-file.txt"]')
```

### Poll pattern for async operations

```typescript
await expect.poll(
  async () => {
    const diff = await sdk.session.diff({ sessionID })
    return (diff.data ?? []).length
  },
  { timeout: 60_000 },
).toBe(expectedCount)
```

---

## Backend PR Test Strategies

Real examples of testing backend changes through UI:

| Backend Change | UI Path | Test |
|---|---|---|
| Python resolver | Terminal → `python3 --version` | Verify output contains "Python" |
| Config dir name | Settings dialog → General panel | Show config UI |
| Race condition | File tree → create + refresh | New file appears after refresh |
| Cache bypass | File tree → force refresh | Fresh data replaces stale |
| State isolation | Two sessions → switch between | Selection preserved per session |
| URL parsing | Status popover → Servers tab | Connected state visible |
| Focus management | Open file → check prompt focus | Prompt still focusable |

---

## Review Panel Patterns

### Switching changes mode (CRITICAL)

Review panel has multiple modes: "git", "session", "turn". Some UI elements only render in specific modes.

```typescript
async function switchToSessionMode(page) {
  // Find the changes mode selector (NOT the diff-style selector)
  const triggers = page.locator('[data-component="select-trigger"]')
  const modeTrigger = triggers.filter({ hasText: /changes/i }).first()
  await modeTrigger.click()

  // Select "Session changes" from dropdown
  const items = page.locator('[data-slot="select-select-item"]')
  const sessionItem = items.filter({ hasText: /session/i }).first()
  await sessionItem.click()
}
```

**Lesson**: The discard button (`onDiscardFile`) only renders when `store.changes === "session" || store.changes === "turn"`. Default is "git". Must switch first.

### Review panel expand/show

```typescript
async function show(page) {
  const btn = page.getByRole("button", { name: "Toggle review" }).first()
  await expect(btn).toBeVisible()
  if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
  await expect(btn).toHaveAttribute("aria-expanded", "true")
}

async function expand(page) {
  const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
  await expect(btn).toBeVisible()
  await btn.click()
}
```

---

## File Tree Patterns

### Expand folders and click files

```typescript
const tree = page.locator("#file-tree-panel")

const expand = async (name) => {
  const folder = tree.getByRole("button", { name, exact: true }).first()
  await expect(folder).toBeVisible()
  if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click()
}

await expand("src")
await expand("components")

const file = tree.getByRole("button", { name: "index.ts", exact: true }).nth(0)
await file.click()
```

### Right-click context menu

```typescript
const row = page.locator(`[data-file="${file}"]`).first()
await expect(row).toBeVisible()
await row.click({ button: "right" })

const menu = page.locator('[data-component="context-menu-content"]')
await expect(menu).toBeVisible()
```

### Refresh button

```typescript
// Refresh button may not have accessible name — use CSS as fallback
const refreshBtn = page.locator("button.ml-auto").first()
await refreshBtn.click()
```

---

## Common Pitfalls

### Pitfall #1: Missing `waitSessionIdle` after operations

```typescript
// WRONG: immediately interact after prompt
await sdk.session.promptAsync({ ... })
await page.click("#review-tab")  // too early!

// RIGHT: wait for session to finish
await sdk.session.promptAsync({ ... })
await waitSessionIdle(sdk, sessionID, 120_000)  // wait up to 2 min
await page.click("#review-tab")
```

**Impact**: Test fails with timeout on DOM assertions. This was the #1 bug in the ai-visible-undo test (failed 5+ times).

### Pitfall #2: Wrong seed format

```typescript
// WRONG: seed missing "mark" line → waitMark() never finds it
function body(mark) {
  return Array.from({ length: 32 }, (_, i) => `line ${i} ${mark}`)
}

// RIGHT: include both title and mark for waitMark
function body(mark) {
  return [`title ${mark}`, `mark ${mark}`,
    ...Array.from({ length: 32 }, (_, i) => `line ${i} ${mark}`)]
}
```

### Pitfall #3: Playwright TS object literal transform

```typescript
// WRONG — Playwright TS transform rejects inline objects
fs.mkdirSync(dir, { recursive: true })
await page.screenshot({ path: file, fullPage: true })

// RIGHT — extract to variable
const mkdirOpts = { recursive: true }
fs.mkdirSync(dir, mkdirOpts)
const shotOpts = { path: file, fullPage: true }
await page.screenshot(shotOpts)
```

### Pitfall #4: Shadow DOM assumptions

```typescript
// WRONG: Playwright locators can't pierce shadow DOM
const content = page.locator("diffs-container .diff-content")

// RIGHT: use waitForFunction with JS querySelector on shadowRoot
await page.waitForFunction((sel) => {
  const host = document.querySelector(sel)
  return host?.shadowRoot?.textContent?.includes("expected")
}, "diffs-container")
```

### Pitfall #5: Review panel mode not set

The review panel has modes (git/session/turn). Some buttons only appear in specific modes. Always check which mode is active before asserting element visibility.

### Pitfall #6: Concurrent git push conflicts

When running batch agents, each agent tries to push to `e2e-screenshots` branch. Solution:
1. Each agent saves screenshots locally
2. Single batch push at end: `git pull` → copy → `git push`
3. Or use unique branch names: `e2e-screenshots-pr-${PR_NUM}`


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal:  | SkillSpector: suspicious (HIGH)