@clawhub-kbo4sho-144d2e0fd0
Pre-publish audience reaction check. Run any content (tweet, launch copy, pricing page, announcement, blog post) through diverse AI personas before publishin...
---
name: preflight
description: Pre-publish audience reaction check. Run any content (tweet, launch copy, pricing page, announcement, blog post) through diverse AI personas before publishing. Returns engagement prediction, share potential, and specific rewrites. Use when about to post on social media, launch a product, announce pricing changes, publish a blog post, or any time you want to predict audience reaction before going live. Triggers on "preflight this", "how will this land", "test this before posting", "will anyone care about this", "check this copy", "pre-test this announcement".
---
# Preflight
Pre-publish content through simulated audience personas. Get a verdict before you ship.
## Workflow
Given content the user wants to publish, run it through audience personas and return a verdict.
### 1. Load Personas
Check for `preflight-personas.md` in the project root. If it exists, use those personas. Otherwise use the defaults in `references/personas.md`.
For quick checks, use 4 personas: The Scroller, The Skeptic, The Ready Buyer, The Amplifier.
For thorough checks, use all 8.
### 2. Evaluate
For each persona, adopt that persona fully and evaluate the content by answering:
1. **FIRST REACTION** (1-2 sentences): Gut reaction in the first 3 seconds
2. **WOULD YOU ENGAGE?** (yes/no + why): Would you like, comment, click, or reply?
3. **WOULD YOU SHARE?** (yes/no + why): Would you send this to someone or repost it?
4. **ONE REWRITE** (1-2 sentences): One change to make this work better for this persona
Be blunt, specific, and honest. No hedging. Stay in character.
### 3. Score
Count engagement and share signals across all personas:
- **Engage rate**: % of personas who would engage
- **Share rate**: % of personas who would share
### 4. Verdict
- 🟢 **SHIP IT** — 50%+ would share. Publish as-is.
- 🟡 **REVISE** — engaging but not shareable. Read the rewrites, apply the best one, optionally re-run.
- 🟠 **RETHINK** — mixed signals. The message itself may be wrong, not just the wording.
- 🔴 **KILL IT** — not landing. Don't publish. Rethink the approach.
### 5. Output
Present results as:
```
PREFLIGHT: [verdict]
Engage: X/Y personas | Share: X/Y personas
[For each persona, one line summary of reaction + their rewrite suggestion]
```
If patterns emerge across personas (e.g., "3 of 4 want to see an image"), call that out as the top actionable insight.
Keep output brief. The user wants a decision, not an essay.
## Customization
See `references/personas.md` for the default persona library and instructions for creating project-specific personas.
## Integration
This skill works as a step in any publishing workflow. When used autonomously (heartbeats, cron, content pipelines), run the quick check (4 personas) by default. Use the full 8 when the user explicitly asks for a thorough preflight.
FILE:references/personas.md
# Preflight Personas
Default audience segments for pre-publish testing. These can be overridden per-project by placing a `preflight-personas.md` in the project root.
## Default Personas
### The Scroller (casual social media user)
- Age 22, college student, 3+ hours/day on TikTok/Instagram
- Zero patience for anything that doesn't hook in 2 seconds
- Judges everything by shareability: "would I send this to the group chat?"
- Doesn't read past the first line unless it's funny or shocking
### The Skeptic (experienced tech user)
- Age 34, developer or indie hacker
- Has seen 1000 product launches. Immune to hype
- Evaluates: is this real? is this different? what's the catch?
- Will fact-check claims and call out bullshit publicly
### The Ready Buyer (clear purchase intent)
- Age 38, has disposable income, willing to pay for value
- Doesn't care about hype — cares about "does this solve my problem"
- Wants clarity: what is it, what does it cost, how do I start
- Will bounce if confused, not because of price but because of friction
### The Amplifier (content creator / influencer)
- Age 27, 50K+ followers, always looking for content angles
- Evaluates: "can I make a video/post about this?"
- Cares about novelty, visual appeal, and controversy potential
- Will share if it's interesting, even if they don't personally use it
### The Worrier (privacy/trust-conscious)
- Age 45, reads terms of service, uses ad blockers
- First question: "what's the catch?" Second: "what happens to my data?"
- Absence of trust signals = immediate suspicion
- Will warn others publicly if something feels sketchy
### The Parent (gatekeeper for family)
- Age 40, evaluating on behalf of kids or family
- Concerns: safety, appropriateness, cost, value
- "Would I be comfortable with my kid using this?"
- If it passes the parent filter, becomes an evangelist in parent groups
### The Competitor (someone in your space)
- Age 30, building something similar or adjacent
- Evaluates: positioning, pricing, differentiation
- Looking for weaknesses to exploit and strengths to copy
- Their reaction tells you how defensible your positioning is
### The Confused (doesn't get it)
- Age 55, not tech-savvy, might be your user's parent or boss
- If they don't understand it in one sentence, it fails
- Tests clarity of your value proposition
- "I don't get it" is the most valuable feedback
## Customization
To customize personas for a specific project, create `preflight-personas.md` in the project root with the same format. The skill will use project-specific personas when available, falling back to these defaults.
Each persona should have:
- A short name and archetype
- Age and context
- What they evaluate / care about
- How they'd react (share, ignore, criticize, buy)
FILE:scripts/preflight.py
#!/usr/bin/env python3
"""
Preflight — Pre-publish audience reaction simulator.
Runs content through diverse personas via local Ollama and returns a verdict.
Usage:
python preflight.py "Your tweet or launch copy here"
python preflight.py --file post.md
python preflight.py --file post.md --personas custom-personas.md
python preflight.py "copy here" --model qwen2.5:14b
python preflight.py "copy here" --format json
"""
import argparse
import json
import os
import sys
import time
from dataclasses import dataclass, field
from typing import Optional
try:
from openai import OpenAI
except ImportError:
print("Error: openai package required. Install with: pip install openai")
sys.exit(1)
# Default personas (used if no personas file provided)
DEFAULT_PERSONAS = [
{
"name": "The Scroller",
"archetype": "casual social media user",
"prompt": "You are a 22-year-old college student who spends 3+ hours daily on TikTok and Instagram. You have zero patience — if something doesn't hook you in 2 seconds, you scroll past. You judge everything by whether you'd send it to your group chat. You don't read past the first line unless it's funny or shocking."
},
{
"name": "The Skeptic",
"archetype": "experienced tech user",
"prompt": "You are a 34-year-old developer who has seen 1000 product launches. You're immune to hype. You evaluate: is this real? is this different? what's the catch? You will fact-check claims and call out bullshit. You've been burned by vaporware before."
},
{
"name": "The Ready Buyer",
"archetype": "clear purchase intent",
"prompt": "You are a 38-year-old professional with disposable income. You're willing to pay for value but don't care about hype. You want clarity: what is it, what does it cost, how do I start. You'll bounce if confused — not because of price but because of friction."
},
{
"name": "The Amplifier",
"archetype": "content creator",
"prompt": "You are a 27-year-old content creator with 50K+ followers. You're always looking for content angles. You evaluate: can I make a video or post about this? You care about novelty, visual appeal, and controversy potential. You'll share if it's interesting, even if you don't personally use it."
},
{
"name": "The Worrier",
"archetype": "privacy-conscious user",
"prompt": "You are a 45-year-old who reads terms of service and uses ad blockers. Your first question is always 'what's the catch?' and your second is 'what happens to my data?' Missing trust signals make you immediately suspicious. You will warn others publicly if something feels sketchy."
},
{
"name": "The Parent",
"archetype": "family gatekeeper",
"prompt": "You are a 40-year-old parent evaluating this on behalf of your family. Your concerns: safety, appropriateness, cost, value. You ask 'would I be comfortable with my kid seeing this?' If it passes your filter, you become an evangelist in parent groups."
},
{
"name": "The Competitor",
"archetype": "someone in your space",
"prompt": "You are a 30-year-old building something similar or adjacent. You evaluate positioning, pricing, and differentiation. You're looking for weaknesses to exploit and strengths to copy. Your reaction reveals how defensible the positioning is."
},
{
"name": "The Confused",
"archetype": "doesn't get it",
"prompt": "You are a 55-year-old who isn't tech-savvy. You might be the user's parent or boss. If you don't understand something in one sentence, it fails. You test whether the value proposition is clear to normal people. Your most valuable feedback is 'I don't get it.'"
},
]
EVALUATION_PROMPT = """You are evaluating a piece of content that someone is about to publish. React honestly based on your persona.
THE CONTENT:
---
{content}
---
Answer these 4 questions in character. Be specific, blunt, and honest. No hedging.
1. FIRST REACTION (1-2 sentences): What's your gut reaction in the first 3 seconds?
2. WOULD YOU ENGAGE? (yes/no + why): Would you like, comment, click, or reply?
3. WOULD YOU SHARE? (yes/no + why): Would you send this to someone or repost it?
4. ONE REWRITE (1-2 sentences): If you could change one thing to make this work better for you, what would it be?
Keep your total response under 150 words. No preamble."""
@dataclass
class PersonaResult:
name: str
archetype: str
reaction: str
would_engage: bool
would_share: bool
raw_response: str
@dataclass
class PreflightResult:
content: str
personas: list
results: list = field(default_factory=list)
verdict: str = ""
summary: str = ""
engage_rate: float = 0.0
share_rate: float = 0.0
elapsed_seconds: float = 0.0
def load_personas_from_file(path: str) -> list:
"""Parse personas from a markdown file."""
personas = []
current = None
with open(path, 'r') as f:
lines = f.readlines()
for line in lines:
line = line.rstrip()
if line.startswith('### '):
if current:
personas.append(current)
name = line[4:].strip()
# Split on ( to get archetype
if '(' in name:
parts = name.split('(')
name = parts[0].strip()
archetype = parts[1].rstrip(')').strip()
else:
archetype = ""
current = {"name": name, "archetype": archetype, "prompt": ""}
elif current and line.startswith('- '):
current["prompt"] += line[2:] + " "
if current:
personas.append(current)
# Build proper prompts from bullet points
for p in personas:
if p["prompt"]:
p["prompt"] = f"You are {p['name']} ({p['archetype']}). {p['prompt'].strip()}"
return personas if personas else DEFAULT_PERSONAS
def run_preflight(
content: str,
personas: list = None,
model: str = "qwen2.5:32b",
base_url: str = "http://localhost:11434/v1",
output_format: str = "text"
) -> PreflightResult:
"""Run content through all personas and return results."""
if personas is None:
personas = DEFAULT_PERSONAS
client = OpenAI(api_key="ollama", base_url=base_url)
result = PreflightResult(content=content, personas=[p["name"] for p in personas])
start = time.time()
for persona in personas:
try:
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": persona["prompt"]},
{"role": "user", "content": EVALUATION_PROMPT.format(content=content)}
],
temperature=0.8,
max_tokens=300,
)
raw = response.choices[0].message.content.strip()
# Parse engagement/share signals from response
lower = raw.lower()
would_engage = any(phrase in lower for phrase in [
"yes", "i would", "i'd click", "i'd comment", "i'd like",
"definitely", "absolutely", "for sure"
]) and "1." in raw # rough heuristic
would_share = "yes" in lower.split("share")[0][-30:] if "share" in lower else False
pr = PersonaResult(
name=persona["name"],
archetype=persona["archetype"],
reaction=raw,
would_engage="yes" in lower[:200],
would_share=would_share,
raw_response=raw
)
result.results.append(pr)
# Progress indicator
print(f" ✓ {persona['name']}", file=sys.stderr)
except Exception as e:
print(f" ✗ {persona['name']}: {e}", file=sys.stderr)
result.elapsed_seconds = time.time() - start
# Calculate rates
if result.results:
result.engage_rate = sum(1 for r in result.results if r.would_engage) / len(result.results)
result.share_rate = sum(1 for r in result.results if r.would_share) / len(result.results)
# Generate verdict
if result.share_rate >= 0.5:
result.verdict = "🟢 SHIP IT"
elif result.engage_rate >= 0.5:
result.verdict = "🟡 REVISE — engaging but not shareable"
elif result.engage_rate >= 0.25:
result.verdict = "🟠 RETHINK — mixed signals"
else:
result.verdict = "🔴 KILL IT — not landing"
# Build summary
result.summary = f"""PREFLIGHT RESULTS
{'=' * 50}
Verdict: {result.verdict}
Engage rate: {result.engage_rate:.0%} ({sum(1 for r in result.results if r.would_engage)}/{len(result.results)} personas)
Share rate: {result.share_rate:.0%} ({sum(1 for r in result.results if r.would_share)}/{len(result.results)} personas)
Time: {result.elapsed_seconds:.1f}s ({len(result.results)} personas)
{'=' * 50}
"""
for r in result.results:
engage_icon = "👍" if r.would_engage else "👎"
share_icon = "🔄" if r.would_share else "—"
result.summary += f"\n{engage_icon} {share_icon} {r.name} ({r.archetype})\n"
result.summary += f"{r.raw_response}\n"
result.summary += f"{'-' * 40}\n"
return result
def main():
parser = argparse.ArgumentParser(description="Preflight — pre-publish audience reaction check")
parser.add_argument("content", nargs="?", help="Content to test (or use --file)")
parser.add_argument("--file", "-f", help="Read content from file")
parser.add_argument("--personas", "-p", help="Custom personas markdown file")
parser.add_argument("--model", "-m", default="qwen2.5:32b", help="Ollama model (default: qwen2.5:32b)")
parser.add_argument("--base-url", default="http://localhost:11434/v1", help="Ollama API base URL")
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format")
parser.add_argument("--fast", action="store_true", help="Use fewer personas (Scroller, Skeptic, Buyer, Amplifier)")
args = parser.parse_args()
# Get content
if args.file:
with open(args.file) as f:
content = f.read().strip()
elif args.content:
content = args.content
elif not sys.stdin.isatty():
content = sys.stdin.read().strip()
else:
parser.error("Provide content as argument, --file, or via stdin")
# Get personas
personas = None
if args.personas:
personas = load_personas_from_file(args.personas)
elif args.fast:
personas = DEFAULT_PERSONAS[:4] # Scroller, Skeptic, Buyer, Amplifier
print(f"\n🛫 Preflight check — {len(personas or DEFAULT_PERSONAS)} personas\n", file=sys.stderr)
result = run_preflight(
content=content,
personas=personas,
model=args.model,
base_url=args.base_url,
output_format=args.format
)
if args.format == "json":
output = {
"verdict": result.verdict,
"engage_rate": result.engage_rate,
"share_rate": result.share_rate,
"elapsed_seconds": result.elapsed_seconds,
"personas": [
{
"name": r.name,
"archetype": r.archetype,
"would_engage": r.would_engage,
"would_share": r.would_share,
"response": r.raw_response
}
for r in result.results
]
}
print(json.dumps(output, indent=2))
else:
print(result.summary)
if __name__ == "__main__":
main()
Perform pixel-level visual regression testing on web apps by capturing, comparing screenshots, and gating deployments based on configurable similarity thresh...
---
name: visual-qa
description: Visual regression testing pipeline for web applications. Capture baseline screenshots, compare against new builds using pixel-level diffing, and gate deployments based on visual similarity thresholds. Use when: visual QA, visual regression testing, screenshot comparison, UI verification, visual diff, pre-deploy check, or validating UI changes before merge.
---
# Visual QA
A visual regression testing pipeline for web applications. Capture baseline screenshots of your app, compare new screenshots against baselines using pixel-level diffing, and pass/fail based on configurable similarity thresholds.
## When to Use
- **Pre-deploy visual verification** — gate merges/deploys until UI changes are approved
- **Regression testing** — catch unintended visual changes in CI
- **UI review workflow** — generate diff images for design/code review
- **Cross-browser/viewport testing** — verify responsive layouts
- **Component library QA** — ensure design system changes don't break consumers
## Quick Start
### 1. Capture Baselines
```bash
# Capture single URL
python scripts/capture.py http://localhost:3000 --output .visual-qa/baselines
# Capture multiple viewports
python scripts/capture.py http://localhost:3000 --viewports desktop mobile tablet --output .visual-qa/baselines
# Capture multiple pages from config
python scripts/capture.py --config .visual-qa.json --output .visual-qa/baselines
```
### 2. Compare Against Baselines
```bash
# Compare new screenshots
python scripts/diff.py --baseline .visual-qa/baselines --current .visual-qa/current --threshold 99
```
### 3. All-in-One Gate
```bash
# Capture + diff in one command
python scripts/gate.py --baseline .visual-qa/baselines --url http://localhost:3000 --threshold 99
# With local dev server
python scripts/gate.py --baseline .visual-qa/baselines --server "npm run dev" --port 3000 --threshold 99
# Using config file
python scripts/gate.py --config .visual-qa.json
```
## Config File Pattern
Create `.visual-qa.json` in your project root:
```json
{
"urls": ["/", "/about", "/pricing"],
"baseUrl": "http://localhost:3000",
"viewports": ["desktop", "mobile"],
"threshold": 99,
"server": "npm run dev",
"port": 3000,
"baselineDir": ".visual-qa/baselines",
"ignore": [".visual-qa/diffs", ".visual-qa/current"]
}
```
## Scripts
All scripts support `--help` for detailed usage.
### capture.py
Capture screenshots using Playwright (headless Chromium).
**Features:**
- Multiple viewport sizes: desktop (1280x800), tablet (768x1024), mobile (375x812)
- Waits for networkidle before capture
- Optional local server start/stop
- Configurable output directory
- Descriptive filenames: `{url-slug}_{viewport}.png`
**Usage:**
```bash
python scripts/capture.py <url> --output <dir> [options]
python scripts/capture.py --config <config.json> --output <dir>
```
### diff.py
Compare screenshots using pixel-level diffing (Pillow).
**Features:**
- Pixel-by-pixel comparison
- Diff images with red/magenta overlay highlighting changes
- Similarity percentage per image pair
- Pass/fail based on threshold (default 99%)
- Summary report with pass/fail status
- Saves diff images to output directory
**Usage:**
```bash
python scripts/diff.py --baseline <dir> --current <dir> --output <diff-dir> --threshold <percent>
```
### gate.py
All-in-one gate: capture + diff in a single command.
**Features:**
- Combines capture and diff steps
- Starts/stops local server automatically if needed
- Returns exit code 0 (pass) or 1 (fail)
- Human-readable summary output
- Can use config file or CLI args
**Usage:**
```bash
python scripts/gate.py --baseline <dir> --url <url> --threshold <percent>
python scripts/gate.py --baseline <dir> --server <command> --port <port> --threshold <percent>
python scripts/gate.py --config <config.json>
```
## Workflow Examples
### Initial Baseline Capture
```bash
# Start your app
npm run dev
# Capture baselines (desktop + mobile)
python scripts/capture.py http://localhost:3000 --viewports desktop mobile --output .visual-qa/baselines
```
### CI/CD Integration
```bash
# In your CI pipeline after build
python scripts/gate.py --baseline .visual-qa/baselines --server "npm start" --port 3000 --threshold 99
# Exit code 0 = pass, 1 = fail
if [ $? -eq 0 ]; then
echo "Visual QA passed ✓"
else
echo "Visual QA failed ✗"
exit 1
fi
```
### Review Workflow
```bash
# 1. Developer makes UI changes
# 2. Capture new screenshots
python scripts/capture.py http://localhost:3000 --output .visual-qa/current
# 3. Generate diff images
python scripts/diff.py --baseline .visual-qa/baselines --current .visual-qa/current --output .visual-qa/diffs
# 4. Review diff images in .visual-qa/diffs/
# 5. If changes are intentional, update baselines:
rm -rf .visual-qa/baselines
mv .visual-qa/current .visual-qa/baselines
```
### Multi-Page Testing
Create `.visual-qa.json`:
```json
{
"urls": ["/", "/products", "/about", "/contact"],
"baseUrl": "http://localhost:3000",
"viewports": ["desktop", "mobile"],
"threshold": 99,
"baselineDir": ".visual-qa/baselines"
}
```
```bash
# Capture all pages
python scripts/capture.py --config .visual-qa.json --output .visual-qa/baselines
# Gate all pages
python scripts/gate.py --config .visual-qa.json
```
## Dependencies
Scripts require Playwright and Pillow:
```bash
pip install playwright pillow
python -m playwright install chromium
```
Scripts will check for dependencies and print install instructions if missing.
## Thresholds
The `--threshold` parameter controls similarity percentage (0-100):
- **99%** (default) — strict, catches most visual changes
- **95%** — moderate, allows minor rendering differences (anti-aliasing, fonts)
- **90%** — loose, allows more variation (use for dynamic content)
Experiment to find the right threshold for your app. Start strict (99%) and loosen if you get false positives.
## Ignoring Dynamic Content
For pages with dynamic content (dates, user-specific data):
1. **Use data attributes** to hide dynamic elements during testing:
```css
[data-test-hide] { visibility: hidden !important; }
```
2. **Capture specific viewport regions** (future enhancement)
3. **Loosen threshold** for pages with acceptable dynamic content
## Troubleshooting
**"Command not found: python"**
- Use `python3` instead of `python`
**"Playwright not installed"**
- Run: `pip install playwright && python -m playwright install chromium`
**"Similarity below threshold but images look the same"**
- Font rendering, anti-aliasing, or sub-pixel differences. Lower threshold to 98-95%.
**"Server not starting"**
- Check that `--port` matches your server's port
- Ensure server command is correct (`npm run dev`, `npm start`, etc.)
- Increase wait time in gate.py (default 5s)
**"Images not found"**
- Check that baseline directory exists and contains PNGs
- Ensure current screenshots were captured to the correct directory
- Verify filenames match pattern: `{url-slug}_{viewport}.png`
## Tips
- **Commit baselines** to Git so your team shares the same reference
- **Add `.visual-qa/diffs` and `.visual-qa/current` to `.gitignore`**
- **Run in CI** as a required check before merge
- **Update baselines** when intentional UI changes are made
- **Use multiple viewports** to catch responsive layout issues
- **Test empty/error/loading states** by capturing those URLs explicitly
## Integration with Other Skills
- **ux-qa-gate** — Use visual-qa as part of the UX QA checklist
- **webapp-testing** — Combine with Playwright functional tests
- **coding-agent** — Sub-agents building UI must pass visual-qa before completion
---
For detailed script options, run:
```bash
python scripts/capture.py --help
python scripts/diff.py --help
python scripts/gate.py --help
```
FILE:scripts/capture.py
#!/usr/bin/env python3
"""
Capture screenshots using Playwright (headless Chromium).
"""
import argparse
import json
import os
import sys
import time
import subprocess
import signal
from pathlib import Path
from urllib.parse import urlparse
def check_dependencies():
"""Check if required dependencies are installed."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("❌ Playwright not installed")
print("\nInstall with:")
print(" pip install playwright")
print(" python3 -m playwright install chromium")
sys.exit(1)
def slugify(text):
"""Convert text to URL-safe slug."""
return text.strip('/').replace('/', '_').replace(':', '').replace('?', '').replace('&', '_').replace('=', '_') or 'index'
def capture_screenshot(page, url, viewport_name, output_dir):
"""Capture a screenshot of a URL at a specific viewport size."""
from playwright.sync_api import sync_playwright
viewports = {
'desktop': {'width': 1280, 'height': 800},
'tablet': {'width': 768, 'height': 1024},
'mobile': {'width': 375, 'height': 812}
}
if viewport_name not in viewports:
print(f"⚠️ Unknown viewport: {viewport_name}")
return None
viewport = viewports[viewport_name]
page.set_viewport_size(viewport)
try:
# Navigate and wait for network idle
page.goto(url, wait_until='networkidle', timeout=30000)
# Additional wait for any animations/transitions
time.sleep(0.5)
# Generate filename
parsed = urlparse(url)
path_slug = slugify(parsed.path)
filename = f"{path_slug}_{viewport_name}.png"
filepath = os.path.join(output_dir, filename)
# Capture screenshot
page.screenshot(path=filepath, full_page=True)
print(f"✓ {filename}")
return filepath
except Exception as e:
print(f"✗ {url} ({viewport_name}): {e}")
return None
def start_server(command, port, cwd=None):
"""Start a local development server."""
print(f"🚀 Starting server: {command}")
env = os.environ.copy()
env['PORT'] = str(port)
process = subprocess.Popen(
command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=cwd,
env=env,
preexec_fn=os.setsid if os.name != 'nt' else None
)
# Wait for server to start
print(f"⏳ Waiting for server on port {port}...")
max_wait = 30
waited = 0
while waited < max_wait:
try:
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1)
result = sock.connect_ex(('localhost', port))
sock.close()
if result == 0:
print(f"✓ Server ready on port {port}")
return process
except:
pass
time.sleep(1)
waited += 1
# Check if process died
if process.poll() is not None:
stdout, stderr = process.communicate()
print(f"❌ Server failed to start")
print(f"stdout: {stdout.decode()}")
print(f"stderr: {stderr.decode()}")
return None
print(f"❌ Server did not start within {max_wait}s")
stop_server(process)
return None
def stop_server(process):
"""Stop a server process."""
if process is None:
return
print("🛑 Stopping server...")
try:
if os.name != 'nt':
# Kill process group on Unix
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
else:
# Kill process on Windows
process.terminate()
process.wait(timeout=5)
print("✓ Server stopped")
except:
# Force kill if graceful shutdown failed
try:
if os.name != 'nt':
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
else:
process.kill()
except:
pass
def main():
parser = argparse.ArgumentParser(
description='Capture screenshots using Playwright',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Capture single URL
%(prog)s http://localhost:3000 --output screenshots/
# Capture with multiple viewports
%(prog)s http://localhost:3000 --viewports desktop mobile tablet --output screenshots/
# Start local server first
%(prog)s http://localhost:3000 --server "npm run dev" --port 3000 --output screenshots/
# Use config file
%(prog)s --config .visual-qa.json --output screenshots/
Config file format (.visual-qa.json):
{
"urls": ["/", "/about", "/pricing"],
"baseUrl": "http://localhost:3000",
"viewports": ["desktop", "mobile"],
"server": "npm run dev",
"port": 3000
}
"""
)
parser.add_argument('url', nargs='?', help='URL to capture (or use --config)')
parser.add_argument('--config', help='Path to config JSON file')
parser.add_argument('--output', '-o', default='screenshots', help='Output directory (default: screenshots)')
parser.add_argument('--viewports', '-v', nargs='+', default=['desktop'],
choices=['desktop', 'tablet', 'mobile'],
help='Viewport sizes to capture (default: desktop)')
parser.add_argument('--server', help='Command to start local dev server')
parser.add_argument('--port', type=int, default=3000, help='Server port (default: 3000)')
parser.add_argument('--wait', type=int, default=5, help='Seconds to wait for server startup (default: 5)')
args = parser.parse_args()
# Check dependencies
check_dependencies()
from playwright.sync_api import sync_playwright
# Load config if provided
config = {}
if args.config:
try:
with open(args.config) as f:
config = json.load(f)
except Exception as e:
print(f"❌ Failed to load config: {e}")
sys.exit(1)
# Determine URLs to capture
urls = []
base_url = config.get('baseUrl', f'http://localhost:{args.port}')
if config.get('urls'):
# URLs from config (relative paths)
for path in config['urls']:
if path.startswith('http'):
urls.append(path)
else:
urls.append(f"{base_url}{path if path.startswith('/') else '/' + path}")
elif args.url:
urls = [args.url]
else:
print("❌ No URL provided. Use positional argument or --config")
parser.print_help()
sys.exit(1)
# Determine viewports
viewports = config.get('viewports', args.viewports)
# Determine output directory
output_dir = config.get('baselineDir', args.output)
# Create output directory
os.makedirs(output_dir, exist_ok=True)
# Start server if needed
server_process = None
server_command = config.get('server', args.server)
server_port = config.get('port', args.port)
if server_command:
server_process = start_server(server_command, server_port)
if server_process is None:
sys.exit(1)
try:
# Launch browser
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context()
page = context.new_page()
print(f"\n📸 Capturing screenshots...")
print(f" URLs: {len(urls)}")
print(f" Viewports: {', '.join(viewports)}")
print(f" Output: {output_dir}\n")
captured = 0
failed = 0
for url in urls:
for viewport in viewports:
result = capture_screenshot(page, url, viewport, output_dir)
if result:
captured += 1
else:
failed += 1
browser.close()
print(f"\n✓ Captured {captured} screenshots")
if failed > 0:
print(f"✗ Failed {failed} screenshots")
finally:
# Stop server if we started it
if server_process:
stop_server(server_process)
sys.exit(0 if failed == 0 else 1)
if __name__ == '__main__':
main()
FILE:scripts/diff.py
#!/usr/bin/env python3
"""
Compare screenshots using pixel-level diffing (Pillow).
"""
import argparse
import os
import sys
from pathlib import Path
def check_dependencies():
"""Check if required dependencies are installed."""
try:
from PIL import Image, ImageChops, ImageDraw
except ImportError:
print("❌ Pillow not installed")
print("\nInstall with:")
print(" pip install pillow")
sys.exit(1)
def compare_images(baseline_path, current_path, diff_path, threshold):
"""
Compare two images and generate a diff image.
Returns similarity percentage (0-100).
"""
from PIL import Image, ImageChops, ImageDraw
try:
baseline = Image.open(baseline_path).convert('RGB')
current = Image.open(current_path).convert('RGB')
except Exception as e:
print(f"❌ Failed to open images: {e}")
return None
# Ensure images are the same size
if baseline.size != current.size:
print(f"⚠️ Size mismatch: {baseline.size} vs {current.size}")
# Resize current to match baseline
current = current.resize(baseline.size, Image.Resampling.LANCZOS)
# Calculate pixel differences
diff = ImageChops.difference(baseline, current)
# Convert to grayscale for analysis
diff_gray = diff.convert('L')
# Calculate similarity
# Count pixels that are identical (diff value = 0)
histogram = diff_gray.histogram()
identical_pixels = histogram[0] # pixels with value 0 (no difference)
total_pixels = baseline.size[0] * baseline.size[1]
similarity = (identical_pixels / total_pixels) * 100
# Generate diff image with red overlay on changes
diff_overlay = baseline.copy()
# Create a red mask for changed pixels
diff_data = diff_gray.getdata()
mask = Image.new('RGB', baseline.size)
mask_pixels = []
for pixel_diff in diff_data:
if pixel_diff > 10: # Threshold for "changed" pixel (ignore tiny differences)
mask_pixels.append((255, 0, 255)) # Magenta for changes
else:
mask_pixels.append((0, 0, 0)) # Black for unchanged
mask.putdata(mask_pixels)
# Blend the mask with the baseline
diff_overlay = Image.blend(diff_overlay, mask, 0.5)
# Save diff image
diff_overlay.save(diff_path)
return similarity
def main():
parser = argparse.ArgumentParser(
description='Compare screenshots against baselines using pixel-level diffing',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Compare current screenshots against baselines
%(prog)s --baseline screenshots/baseline --current screenshots/current
# With custom threshold and output directory
%(prog)s --baseline .visual-qa/baselines --current .visual-qa/current --output .visual-qa/diffs --threshold 98
# Show only failures
%(prog)s --baseline baseline/ --current current/ --threshold 99 --quiet
Threshold:
- 99 (default): Strict, catches most visual changes
- 95: Moderate, allows minor rendering differences
- 90: Loose, allows more variation (dynamic content)
"""
)
parser.add_argument('--baseline', '-b', required=True, help='Baseline screenshots directory')
parser.add_argument('--current', '-c', required=True, help='Current screenshots directory')
parser.add_argument('--output', '-o', default='diffs', help='Output directory for diff images (default: diffs)')
parser.add_argument('--threshold', '-t', type=float, default=99.0,
help='Similarity threshold percentage (default: 99.0)')
parser.add_argument('--quiet', '-q', action='store_true', help='Only show failures')
args = parser.parse_args()
# Check dependencies
check_dependencies()
from PIL import Image
# Validate directories
if not os.path.isdir(args.baseline):
print(f"❌ Baseline directory not found: {args.baseline}")
sys.exit(1)
if not os.path.isdir(args.current):
print(f"❌ Current directory not found: {args.current}")
sys.exit(1)
# Create output directory
os.makedirs(args.output, exist_ok=True)
# Find all PNG files in baseline
baseline_files = set()
for filename in os.listdir(args.baseline):
if filename.endswith('.png'):
baseline_files.add(filename)
if not baseline_files:
print(f"❌ No PNG files found in baseline directory: {args.baseline}")
sys.exit(1)
# Find matching files in current
current_files = set()
for filename in os.listdir(args.current):
if filename.endswith('.png'):
current_files.add(filename)
if not current_files:
print(f"❌ No PNG files found in current directory: {args.current}")
sys.exit(1)
# Find common files
common_files = baseline_files & current_files
if not common_files:
print(f"❌ No matching files found between baseline and current")
print(f" Baseline files: {sorted(baseline_files)}")
print(f" Current files: {sorted(current_files)}")
sys.exit(1)
# Warn about missing files
baseline_only = baseline_files - current_files
current_only = current_files - baseline_files
if baseline_only:
print(f"⚠️ Files in baseline but not current: {sorted(baseline_only)}")
if current_only:
print(f"⚠️ Files in current but not baseline: {sorted(current_only)}")
if not args.quiet:
print(f"\n🔍 Comparing {len(common_files)} screenshots...")
print(f" Threshold: {args.threshold}%")
print(f" Diff output: {args.output}\n")
# Compare all files
results = []
passed = 0
failed = 0
for filename in sorted(common_files):
baseline_path = os.path.join(args.baseline, filename)
current_path = os.path.join(args.current, filename)
diff_path = os.path.join(args.output, f"diff_{filename}")
similarity = compare_images(baseline_path, current_path, diff_path, args.threshold)
if similarity is None:
failed += 1
continue
passed_threshold = similarity >= args.threshold
status = "✓" if passed_threshold else "✗"
if passed_threshold:
passed += 1
else:
failed += 1
results.append({
'filename': filename,
'similarity': similarity,
'passed': passed_threshold,
'status': status
})
if not args.quiet or not passed_threshold:
print(f"{status} {filename}: {similarity:.2f}%")
# Summary
print(f"\n{'='*60}")
print(f"SUMMARY")
print(f"{'='*60}")
print(f"Total: {len(common_files)}")
print(f"Passed: {passed} ({(passed/len(common_files)*100):.1f}%)")
print(f"Failed: {failed} ({(failed/len(common_files)*100):.1f}%)")
print(f"Threshold: {args.threshold}%")
if failed > 0:
print(f"\n❌ Visual regression test FAILED")
print(f" Review diff images in: {args.output}")
sys.exit(1)
else:
print(f"\n✓ Visual regression test PASSED")
sys.exit(0)
if __name__ == '__main__':
main()
FILE:scripts/gate.py
#!/usr/bin/env python3
"""
All-in-one visual QA gate: capture + diff in a single command.
"""
import argparse
import json
import os
import sys
import tempfile
import subprocess
from pathlib import Path
def run_capture(args, config, current_dir):
"""Run capture.py to generate current screenshots."""
capture_script = os.path.join(os.path.dirname(__file__), 'capture.py')
cmd = [sys.executable, capture_script]
# Build URL(s) from config or args
if config.get('urls'):
# Write a temp config that uses current_dir instead of baselineDir
temp_config = config.copy()
temp_config.pop('baselineDir', None) # Remove baselineDir from config
temp_config_path = os.path.join(current_dir, '.temp-config.json')
with open(temp_config_path, 'w') as f:
json.dump(temp_config, f)
cmd.extend(['--config', temp_config_path])
elif args.url:
cmd.append(args.url)
elif config.get('baseUrl'):
cmd.append(config['baseUrl'])
else:
print("❌ No URL or config provided")
return False
cmd.extend(['--output', current_dir])
# Viewports from args or config
viewports = args.viewports or config.get('viewports')
if viewports:
cmd.extend(['--viewports'] + viewports)
# Server from args or config
server = args.server or config.get('server')
if server:
cmd.extend(['--server', server])
# Port from args or config
port = args.port or config.get('port')
if port:
cmd.extend(['--port', str(port)])
print(f"🔧 Running capture...")
result = subprocess.run(cmd)
return result.returncode == 0
def run_diff(baseline_dir, current_dir, diff_dir, threshold):
"""Run diff.py to compare screenshots."""
diff_script = os.path.join(os.path.dirname(__file__), 'diff.py')
cmd = [
sys.executable, diff_script,
'--baseline', baseline_dir,
'--current', current_dir,
'--output', diff_dir,
'--threshold', str(threshold)
]
print(f"\n🔍 Running diff...")
result = subprocess.run(cmd)
return result.returncode == 0
def main():
parser = argparse.ArgumentParser(
description='All-in-one visual QA gate: capture + diff',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Basic gate with URL
%(prog)s --baseline .visual-qa/baselines --url http://localhost:3000
# With local dev server
%(prog)s --baseline .visual-qa/baselines --server "npm run dev" --port 3000
# Using config file
%(prog)s --config .visual-qa.json
# Custom threshold
%(prog)s --baseline .visual-qa/baselines --url http://localhost:3000 --threshold 95
Exit codes:
0 = Visual QA passed (all screenshots within threshold)
1 = Visual QA failed (one or more screenshots differ)
Config file format (.visual-qa.json):
{
"urls": ["/", "/about", "/pricing"],
"baseUrl": "http://localhost:3000",
"viewports": ["desktop", "mobile"],
"threshold": 99,
"server": "npm run dev",
"port": 3000,
"baselineDir": ".visual-qa/baselines"
}
"""
)
parser.add_argument('--config', help='Path to config JSON file')
parser.add_argument('--baseline', '-b', help='Baseline screenshots directory (required if no config)')
parser.add_argument('--url', help='URL to capture (or use --config)')
parser.add_argument('--viewports', '-v', nargs='+',
choices=['desktop', 'tablet', 'mobile'],
help='Viewport sizes to capture')
parser.add_argument('--server', help='Command to start local dev server')
parser.add_argument('--port', type=int, help='Server port')
parser.add_argument('--threshold', '-t', type=float, default=99.0,
help='Similarity threshold percentage (default: 99.0)')
parser.add_argument('--keep-current', action='store_true',
help='Keep current screenshots after comparison (default: delete)')
parser.add_argument('--keep-diffs', action='store_true',
help='Keep diff images after comparison (default: delete on pass)')
args = parser.parse_args()
# Load config if provided
config = {}
if args.config:
try:
with open(args.config) as f:
config = json.load(f)
except Exception as e:
print(f"❌ Failed to load config: {e}")
sys.exit(1)
# Determine baseline directory
baseline_dir = args.baseline or config.get('baselineDir')
if not baseline_dir:
print("❌ No baseline directory provided. Use --baseline or config file")
parser.print_help()
sys.exit(1)
if not os.path.isdir(baseline_dir):
print(f"❌ Baseline directory not found: {baseline_dir}")
print(f"\nCreate baselines first:")
print(f" python3 {os.path.join(os.path.dirname(__file__), 'capture.py')} --output {baseline_dir} ...")
sys.exit(1)
# Determine threshold
threshold = config.get('threshold', args.threshold)
# Create temporary directories for current screenshots and diffs
with tempfile.TemporaryDirectory() as temp_dir:
current_dir = os.path.join(temp_dir, 'current')
diff_dir = os.path.join(temp_dir, 'diffs')
os.makedirs(current_dir)
os.makedirs(diff_dir)
print("="*60)
print("VISUAL QA GATE")
print("="*60)
print(f"Baseline: {baseline_dir}")
print(f"Threshold: {threshold}%")
print("="*60)
print()
# Step 1: Capture current screenshots
if not run_capture(args, config, current_dir):
print("\n❌ Capture failed")
sys.exit(1)
# Step 2: Compare against baselines
passed = run_diff(baseline_dir, current_dir, diff_dir, threshold)
# Optionally copy current screenshots and diffs to persistent location
if args.keep_current:
keep_current_dir = '.visual-qa/current'
os.makedirs(keep_current_dir, exist_ok=True)
subprocess.run(['cp', '-r', f'{current_dir}/.', keep_current_dir])
print(f"\n📁 Current screenshots saved to: {keep_current_dir}")
if args.keep_diffs or not passed:
keep_diff_dir = '.visual-qa/diffs'
os.makedirs(keep_diff_dir, exist_ok=True)
subprocess.run(['cp', '-r', f'{diff_dir}/.', keep_diff_dir])
print(f"📁 Diff images saved to: {keep_diff_dir}")
# Exit with appropriate code
if passed:
print("\n" + "="*60)
print("✓ VISUAL QA GATE PASSED")
print("="*60)
sys.exit(0)
else:
print("\n" + "="*60)
print("✗ VISUAL QA GATE FAILED")
print("="*60)
print(f"\nReview diff images to see what changed.")
if not args.keep_diffs:
print(f"Diff images were saved to: .visual-qa/diffs")
print(f"\nIf changes are intentional, update baselines:")
print(f" rm -rf {baseline_dir}")
print(f" python3 {os.path.join(os.path.dirname(__file__), 'capture.py')} --output {baseline_dir} ...")
sys.exit(1)
if __name__ == '__main__':
main()
Discover and resolve open source GitHub issues across community repos during idle time. Finds good-first-issue/help-wanted/documentation issues, forks repos,...
---
name: oss-contributor
description: "Discover and resolve open source GitHub issues across community repos during idle time. Finds good-first-issue/help-wanted/documentation issues, forks repos, implements fixes, and opens PRs on your behalf. Use for idle agent contribution, building GitHub profile activity, or community open source work. Usage: /oss-contributor [--repos owner/repo,...] [--labels bug,docs] [--limit 5] [--dry-run] [--auto] [--model sonnet] [--notify-channel -1002381931352]"
user-invocable: true
metadata:
{ "openclaw": { "requires": { "bins": ["curl", "git"] }, "primaryEnv": "GH_TOKEN" } }
---
# oss-contributor — Idle Agent Open Source Contributor
You are an open source contribution orchestrator. Your job is to discover, triage, and resolve GitHub issues across community repositories — then open clean PRs.
IMPORTANT: Do NOT use the `gh` CLI. Use curl + GitHub REST API exclusively. GH_TOKEN is already in the environment.
```
curl -s -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" ...
```
---
## Phase 1 — Parse Arguments & Load Config
Parse arguments after `/oss-contributor`.
| Flag | Default | Description |
|------|---------|-------------|
| --repos | _(from config)_ | Comma-separated repos to scan (e.g. `openclaw/openclaw,vercel/next.js`) |
| --labels | `good-first-issue,help-wanted,documentation` | Issue labels to filter by |
| --limit | 5 | Max issues to fetch per repo |
| --languages | _(from config)_ | Filter repos by primary language |
| --max-complexity | medium | Skip issues above this: low, medium, high |
| --dry-run | false | Discover + triage only, no PRs |
| --auto | false | Headless mode for heartbeat/cron (no confirmation prompts) |
| --discover | false | Find trending repos matching your topics (in addition to configured repos) |
| --model | _(agent default)_ | Model for fix sub-agents |
| --notify-channel | _(none)_ | Telegram channel for PR notifications |
| --yes | false | Skip confirmation, process all eligible issues |
Load config from workspace:
```bash
CONFIG_FILE="$HOME/clawd/oss-contributor.json"
if [ ! -f "$CONFIG_FILE" ]; then
CONFIG_FILE="./oss-contributor.json"
fi
```
Config schema (all fields optional — CLI flags override):
```json
{
"github_username": "your-username",
"repos": ["openclaw/openclaw", "vercel/next.js"],
"discover_topics": ["design-systems", "accessibility", "react"],
"labels": ["good-first-issue", "help-wanted", "documentation"],
"languages": ["typescript", "javascript", "python"],
"max_complexity": "medium",
"daily_limit": 3,
"auto_labels": ["documentation", "typo", "test"],
"approval_labels": ["bug", "enhancement"],
"blocklist": ["some-org/private-repo"],
"contributing_rules": {
"commit_style": "conventional",
"always_run_tests": true
}
}
```
Resolve GitHub username:
```bash
curl -s -H "Authorization: Bearer $GH_TOKEN" https://api.github.com/user | jq -r '.login'
```
Store as `GH_USER`.
---
## Phase 2 — Discover Issues
### 2a. Scan Configured Repos
For each repo in the repos list (from config or --repos flag):
1. Check blocklist — skip if repo matches
2. Fetch issues:
```bash
curl -s -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/{REPO}/issues?labels={LABELS}&state=open&per_page={LIMIT}&sort=created&direction=desc"
```
3. Filter out pull requests (exclude items where `pull_request` key exists)
4. Filter out assigned issues (skip if `assignees` array is non-empty)
5. Filter out issues with recent comments from bots or "I'm working on this" signals
### 2b. Discover Trending Repos (if --discover)
Search for repos matching configured topics:
```bash
curl -s -H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/search/repositories?q=topic:{TOPIC}+language:{LANG}+good-first-issues:>0&sort=stars&per_page=5"
```
For each discovered repo, fetch issues using the same process as 2a.
### 2c. Check Daily Limit
Read the activity log:
```bash
ACTIVITY_FILE="$HOME/clawd/memory/oss-activity.json"
```
Count PRs opened today. If >= `daily_limit`, stop:
> "Daily limit reached ({N}/{daily_limit} PRs today). Try again tomorrow."
### 2d. Deduplicate
Track previously attempted issues to avoid retrying failures:
```bash
HISTORY_FILE="$HOME/clawd/memory/oss-history.json"
```
Schema:
```json
{
"attempted": {
"owner/repo#123": { "date": "2026-02-27", "result": "merged|failed|pending" }
}
}
```
Skip any issue already in history with result != "merged" and date < 7 days ago.
---
## Phase 3 — Triage & Rank
For each candidate issue, estimate complexity:
**Low complexity** (auto-approve):
- Labels: `documentation`, `typo`, `good-first-issue`, `test`
- Issue body < 500 chars
- Single file referenced
- Keywords: "typo", "broken link", "missing docs", "add test"
**Medium complexity** (default max):
- Labels: `bug`, `help-wanted`
- Issue body 500-2000 chars
- 2-5 files likely affected
- Clear reproduction steps or expected behavior described
**High complexity** (skip unless configured):
- Labels: `enhancement`, `feature`, `refactor`
- Issue body > 2000 chars or references architecture
- Multi-file, multi-system changes
- No clear fix path
Filter to issues at or below `--max-complexity`.
Rank remaining issues by:
1. Repo star count (higher = more visible contribution)
2. Issue age (older = more likely abandoned, good pickup)
3. Label match strength
4. Complexity (lower first)
---
## Phase 4 — Present & Confirm
Display ranked issues:
| # | Repo | Issue | Title | Complexity | Stars |
|---|------|-------|-------|------------|-------|
| 1 | vercel/next.js | #45123 | Fix broken link in docs | Low | 125K |
| 2 | openclaw/openclaw | #892 | Add test for parser edge case | Low | 8K |
| 3 | tailwindlabs/heroicons | #234 | Missing aria labels | Medium | 21K |
If `--dry-run`: display table and stop.
If `--auto` or `--yes`: proceed with all issues automatically.
Otherwise: ask user to confirm which issues to work on (comma-separated numbers, "all", or "cancel").
---
## Phase 5 — Fork & Fix
For each confirmed issue, spawn a sub-agent. Max 3 concurrent (be respectful of API limits).
### Pre-flight per repo
1. **Check if fork exists:**
```bash
curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/{GH_USER}/{REPO_NAME}"
```
2. **Fork if needed:**
```bash
curl -s -X POST -H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/{OWNER}/{REPO_NAME}/forks"
```
Wait up to 30 seconds for fork to be ready (poll with GET).
3. **Read CONTRIBUTING.md** (if exists):
```bash
curl -s -H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/{OWNER}/{REPO_NAME}/contents/CONTRIBUTING.md" | jq -r '.content' | base64 -d
```
4. **Read PR template** (if exists):
```bash
# Check common locations for PR templates
for path in ".github/PULL_REQUEST_TEMPLATE.md" ".github/pull_request_template.md" "PULL_REQUEST_TEMPLATE.md" ".github/PULL_REQUEST_TEMPLATE/default.md"; do
TMPL=$(curl -s -H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/{OWNER}/{REPO_NAME}/contents/$path" | jq -r '.content // empty' | base64 -d 2>/dev/null)
if [ -n "$TMPL" ]; then break; fi
done
```
Pass contributing guidelines AND PR template to sub-agent. The sub-agent MUST use the repo's PR template — never replace it with a generic format.
### Sub-agent Task Prompt
```
You are a focused open source contributor. Fix ONE GitHub issue and open a clean PR.
IMPORTANT: Use curl + GitHub REST API only. No gh CLI.
<config>
Source repo: {SOURCE_REPO}
Your fork: {GH_USER}/{REPO_NAME}
Base branch: {DEFAULT_BRANCH}
Your GitHub username: {GH_USER}
</config>
<issue>
Repository: {SOURCE_REPO}
Issue: #{number}
Title: {title}
URL: {url}
Labels: {labels}
Body: {body}
</issue>
<contributing>
{CONTRIBUTING_MD_CONTENT or "No CONTRIBUTING.md found. Follow standard conventions."}
</contributing>
<pr_template>
{PR_TEMPLATE_CONTENT or "No PR template found. Use a clean Summary / Changes / Testing format."}
</pr_template>
CRITICAL: If a PR template exists, you MUST use it. Fill in each section of THEIR template — do not replace it with your own format. Append the AI disclosure block at the end, after the template content.
<instructions>
0. SETUP — Ensure GH_TOKEN is available:
export GH_TOKEN=$(cat ~/.openclaw/openclaw.json 2>/dev/null | jq -r '.skills.entries["gh-issues"].apiKey // empty')
Verify: echo "Token: 0:10..."
1. CLONE — Clone your fork into a temp directory:
WORKDIR=$(mktemp -d)
cd $WORKDIR
git clone https://x-access-token:[email protected]/{GH_USER}/{REPO_NAME}.git
cd {REPO_NAME}
git remote add upstream https://github.com/{SOURCE_REPO}.git
git fetch upstream
git checkout -b fix/issue-{number} upstream/{DEFAULT_BRANCH}
2. CONFIDENCE CHECK — Before implementing:
- Read the issue body carefully
- Search the codebase for relevant code (grep/find)
- Is the scope reasonable?
- Rate confidence 1-10. If < 7, STOP and report why.
3. UNDERSTAND — Identify what needs to change and where.
4. IMPLEMENT — Make the minimal, focused fix:
- Match existing code style exactly
- Change only what's necessary
- Follow CONTRIBUTING.md rules if provided
5. TEST — If a test suite exists, run it:
- Look for: package.json scripts, Makefile, pytest, cargo test, etc.
- Run tests. If they fail due to your change, fix it.
- If tests fail for unrelated reasons, note it in the PR.
6. COMMIT — Use conventional commit style:
git add {files}
git commit -m "fix: {short_description}
Fixes {SOURCE_REPO}#{number}"
7. PUSH:
git config --global credential.helper ""
GIT_ASKPASS=true git push -u origin fix/issue-{number}
8. OPEN PR via API:
IMPORTANT: If a PR template was provided in <pr_template>, use it as the body structure. Fill in each section of THEIR template with your content. Do NOT replace their template with a generic format.
After filling in the repo's template (or using the fallback format below if no template exists), ALWAYS append this disclosure block at the very end:
---
🤖 **Disclosure:** This PR was authored by an AI agent ([OpenClaw](https://openclaw.ai)) operating on behalf of @{GH_USER}. The human owner reviewed and approved submission. Happy to address any feedback.
Fallback body (ONLY if no PR template exists):
"## Summary\n\n{description}\n\n## Changes\n\n{bullet_list}\n\n## Testing\n\n{test_results}\n\nFixes #{number}"
curl -s -X POST \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/{SOURCE_REPO}/pulls \
-d '{
"title": "fix: {title}",
"head": "{GH_USER}:fix/issue-{number}",
"base": "{DEFAULT_BRANCH}",
"body": "{FILLED_TEMPLATE_WITH_DISCLOSURE}"
}'
9. CLEANUP:
rm -rf $WORKDIR
10. REPORT — Send back: PR URL, files changed, fix summary, any caveats.
</instructions>
<constraints>
- No force-push
- No unrelated changes
- No new dependencies without justification
- If unsure, report analysis instead of guessing
- Be respectful — this is someone else's project
- Max 45 minutes
</constraints>
```
### Spawn config:
- `runTimeoutSeconds: 2700` (45 minutes)
- `cleanup: "keep"`
- `model: "{MODEL}"` if --model provided, otherwise default to sonnet (cost-efficient)
---
## Phase 6 — Results & Logging
After all sub-agents complete, collect results.
### Summary Table
| Repo | Issue | Status | PR | Notes |
|------|-------|--------|----|-------|
| vercel/next.js | #45123 | ✅ PR opened | github.com/.../pull/501 | 1 file, docs fix |
| openclaw/openclaw | #892 | ✅ PR opened | github.com/.../pull/45 | Added 3 tests |
| tailwindlabs/heroicons | #234 | ❌ Failed | — | Could not locate component |
### Update Activity Log
Write to `$HOME/clawd/memory/oss-activity.json`:
```json
{
"2026-02-27": {
"prs_opened": 2,
"prs_failed": 1,
"repos_contributed": ["vercel/next.js", "openclaw/openclaw"],
"issues": [
{ "repo": "vercel/next.js", "issue": 45123, "pr": 501, "status": "opened" },
{ "repo": "openclaw/openclaw", "issue": 892, "pr": 45, "status": "opened" },
{ "repo": "tailwindlabs/heroicons", "issue": 234, "pr": null, "status": "failed" }
]
}
}
```
### Update History
Add all attempted issues to `oss-history.json` with results.
### Notify (if --notify-channel)
```
Use the message tool:
- action: "send"
- channel: "telegram"
- target: "{notify_channel}"
- message: summary table + PR links
```
### Final Output
> "Open source session complete: {N} PRs opened across {M} repos. {F} failed, {S} skipped."
If any PRs were opened, also display:
> "🔗 Your PRs: {list of PR URLs}"
---
## Heartbeat / Cron Integration
To run this skill on a schedule, add to your HEARTBEAT.md or set up a cron:
```markdown
# HEARTBEAT.md
## Open Source Contribution
- Run /oss-contributor --auto during idle periods (2-3x per week)
- Focus: repos relevant to your work or job search targets
```
Or as a cron:
```
/oss-contributor --auto --repos openclaw/openclaw --labels good-first-issue,documentation --limit 3 --notify-channel telegram:8566529935
```
---
## Etiquette Rules (Non-negotiable)
1. **Always fork** — never assume push access
2. **Read CONTRIBUTING.md** — follow their rules, not yours
3. **One issue at a time per repo** — don't spam maintainers
4. **Skip assigned issues** — someone's already on it
5. **Full AI disclosure (mandatory)** — Every PR MUST include the 🤖 disclosure block identifying this as AI-authored with the human owner's @username. This is non-negotiable — maintainers deserve to know.
6. **Respect "no AI PRs" signals** — if repo README or issues mention this, skip
7. **Quality over quantity** — one great PR beats five mediocre ones
8. **Clean up** — delete temp directories, don't leave orphan forks with no PRs
9. **Daily limit** — respect the configured cap (default 3)
10. **Be patient** — don't ping maintainers for review, let them come to it