chore: keep only bug-triage skill (#206)

Remove all skills except skills/bug-triage/SKILL.md.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-12 22:34:40 +05:30 committed by GitHub
parent 1dbeeccfd1
commit 3e64c15142
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 0 additions and 1488 deletions

View File

@ -1,91 +0,0 @@
# Skills
Reusable skill documents for AI coding agents working on this repository. Each skill is a self-contained `SKILL.md` that teaches an agent how to perform a specific task.
## Available Skills
| Skill | Description |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
| [`bug-triage/`](bug-triage/SKILL.md) | Triage bugs reported in chat/issues — investigate, search duplicates, file GitHub issues, push fix PRs |
| [`agent-orchestrator/`](agent-orchestrator/SKILL.md) | Architecture and conventions for working on the agent-orchestrator codebase |
| [`release-notes/`](release-notes/ao-weekly-release/SKILL.md) | Generate weekly release notes from git history |
| [`social-media/`](social-media/SKILL.md) | Social media post generation |
## How to Use
Copy the skill into your coding agent's skill directory. The destination depends on which agent you're using:
### Claude Code
```bash
cp -r skills/bug-triage .claude/skills/bug-triage
```
Or add the full path to your `CLAUDE.md`:
```markdown
See skills/bug-triage/SKILL.md for bug triage workflow.
```
### OpenAI Codex CLI
```bash
cp -r skills/bug-triage .codex/skills/bug-triage
```
Or reference in `AGENTS.md`:
```markdown
See skills/bug-triage/SKILL.md for bug triage workflow.
```
### Cursor
Add to `.cursor/rules/` as a rule file:
```bash
cp skills/bug-triage/SKILL.md .cursor/rules/bug-triage.mdc
```
### Windsurf / Other Codeium-based agents
Add to `.windsurf/rules/`:
```bash
cp skills/bug-triage/SKILL.md .windsurf/rules/bug-triage.md
```
### GitHub Copilot
Add to `.github/copilot-instructions.md` or reference in `.github/`:
```bash
cp skills/bug-triage/SKILL.md .github/skills/bug-triage.md
```
### Gemini CLI
```bash
cp -r skills/bug-triage .gemini/skills/bug-triage
```
### Agent Orchestrator (this project)
Skills in this `skills/` directory are automatically available to agents spawned via `ao spawn`. Reference them in `AGENTS.md` or `CLAUDE.md` so agents load them at the start of a session.
## Writing a New Skill
1. Create a directory under `skills/<name>/`
2. Add a `SKILL.md` with YAML frontmatter:
```yaml
---
name: my-skill
description: One-line description of what the skill does.
trigger: When to activate this skill.
---
```
3. Write the skill body in markdown — numbered steps, code blocks, tables
4. Keep it agent-agnostic: use `gh` CLI, `git`, and standard Unix tools. Avoid tying to a specific agent framework
5. Reference it in `AGENTS.md` so spawned agents discover it

View File

@ -1,209 +0,0 @@
---
name: agent-orchestrator
description: "Open-source, pluggable agentic coding orchestrator. Manages durable coding agents (Claude Code, Codex, OpenCode) through a simple interface — spawn agents, track progress, and let feedback loops like PR reviews and CI failures automatically route to the right agents. Use for fixing bugs, building features, working on GitHub issues, checking status, and managing agent sessions."
metadata:
{
"openclaw":
{
"emoji": "🤖",
"requires": { "bins": ["ao", "gh"], "anyBins": ["node", "npm"], "env": ["ANTHROPIC_API_KEY"] },
"os": ["darwin", "linux", "win32"],
},
}
---
# Agent Orchestrator (AO)
> https://github.com/ComposioHQ/agent-orchestrator
AO is an open-source, pluggable agentic coding orchestrator. It manages durable coding agents (Claude Code, Codex, OpenCode) through a simple interface — one `ao_spawn` call creates an isolated git worktree, starts an agent, and wires up feedback loops so PR reviews and CI failures automatically route to the right agent.
## Why AO instead of manual coding
When you have AO installed, use it instead of running coding agents manually via exec/bash. Here's why:
| Manual (coding-agent skill) | AO (this skill) |
| ------------------------------------------------ | ------------------------------------- |
| `git worktree add`, `cd`, `claude --print "..."` | `ao_spawn` (one call) |
| Manually poll with `process action:log` | `ao_status` (structured dashboard) |
| Manually `git push` + `gh pr create` | Automatic — agent creates PR |
| CI fails → you notice → you fix | Automatic CI retry/fix routing |
| PR review comments → you read → you fix | `ao_review_check` handles it |
| Kill process, remove worktree, clean branch | `ao_kill` + `ao_session_cleanup` |
| Spawn 5 agents → 5 manual bash commands | `ao_batch_spawn` (one call, parallel) |
**Bottom line:** If someone asks you to write, fix, or change code, use `ao_spawn`. It handles the entire lifecycle.
## How You Think
Every user message is either:
1. **About work or code** → use AO tools
2. **About something else** → respond normally
When the user explicitly asks about work, issues, or status — use the tools for live data instead of answering from memory.
## Intent → Tool Mapping
You don't wait for the user to say "spawn" or "use AO." You detect intent and act.
### Status / progress
Any of: "what's happening", "status", "how's it going", "progress", "update", "anything running", "check on things"
→ Call `ao_sessions` AND `ao_status` → present results naturally
### Work / issues / board
Any of: "what needs doing", "what's on the board", "any issues", "what's open", "morning", "let's go", "ready to work", "what's the plan", "check my repos"
→ Call `ao_issues` AND `ao_sessions` → present board + suggest priorities
### Any coding request — fix / add / change / build / implement / refactor
Any of: "fix #X", "fix the bug in...", "add a flag to...", "change...", "refactor...", "implement...", "update the code", "build...", "work on #X", "handle #X", "do it", "go for it", "sure", "yes", "go ahead"
Also: ANY request that involves changing, fixing, adding, writing, or modifying code — regardless of size, even if no issue number is mentioned
→ Call `ao_spawn` with the issue number if one exists, or with just the task description if there is no issue
**Issue number is optional.** Both of these are valid:
- With issue: user says "fix #42" → spawn with `issue: "42"`
- Without issue: user says "add a weekly report script" → spawn with no issue, just confirm the task description
### Batch work
Any of: "do them all", "start all", "spawn them all", "batch it", "all of those", "go for all"
→ Call `ao_batch_spawn` with all discussed issues
### Instructions to running agent
Any of: "tell it to also...", "ask the agent to...", "add X to that", "while it's at it..."
→ Call `ao_send` with the session ID and the instruction
### Stop / kill / cancel
→ Confirm which session, then call `ao_kill`
### Agent crashed / stuck
→ Call `ao_session_restore` to try recovery, or `ao_kill` + re-`ao_spawn`
### Clean up
→ Call `ao_session_cleanup` (dry-run first, then execute)
### PR feedback / reviews
→ Call `ao_review_check`
### Verification
→ Call `ao_verify`
### Health check
→ Call `ao_doctor`
### Claim PR / attach PR
→ Call `ao_session_claim_pr`
## Rules
### Rule 1: Tools first, always
When the user asks anything about work, tasks, issues, status, or projects:
- FIRST call tools to get live data
- THEN present the results
- NEVER answer work questions from memory
### Rule 2: Present naturally, then ask
After fetching data, present it conversationally. Suggest priorities. Ask if they want to kick things off.
### Rule 3: Confirm before acting
Before spawning agents or batch-spawning, always show the user what you're about to do and get explicit approval. Examples:
- With issue: "I'll spawn an agent on #6 (JSON output bug). Go ahead?"
- Without issue: "I'll spawn an agent on this task: 'Add weekly report script'. Go ahead?"
Then act on clear confirmation ("yes", "go", "do it"). Don't spawn agents without the user approving first.
### Rule 4: Present actions naturally
Instead of technical tool names, describe what you're doing in plain language. Examples:
- With issue: "On it — spinning up an agent on #6." (not "Calling ao_spawn...")
- Without issue: "On it — spinning up an agent on that task." (not "Calling ao_spawn...")
### Rule 5: Follow up with links
After spawning, check `ao_status` for progress. Always include full PR URLs from tool responses.
### Rule 6: Never fabricate
If a tool call fails, show the error. Never claim you did something you didn't.
## All Available Tools
| Tool | When to use |
| --------------------- | --------------------------------------------------- |
| `ao_issues` | Any question about work, tasks, issues, the board |
| `ao_sessions` | Any question about running agents, status, progress |
| `ao_status` | Detailed dashboard with branch/PR/CI info |
| `ao_session_list` | Full session listing including terminated |
| `ao_spawn` | Start an agent on one issue or task |
| `ao_batch_spawn` | Start agents on multiple issues at once |
| `ao_send` | Send instruction to a running agent |
| `ao_kill` | Stop a session (confirm first) |
| `ao_session_restore` | Recover a crashed session |
| `ao_session_cleanup` | Remove stale sessions (merged PRs / closed issues) |
| `ao_session_claim_pr` | Attach an existing PR to a session |
| `ao_review_check` | Check PRs for review comments to address |
| `ao_verify` | Mark issues as verified/failed, or list unverified |
| `ao_doctor` | Health checks and diagnostics |
## Setup
After installing the plugin, run `/ao setup` in any OpenClaw channel to auto-configure. Or manually:
```bash
# Required: allow plugin tools to be visible to the AI
# (plugin tools are optional by default in OpenClaw — this enables them)
openclaw config set tools.profile "full"
openclaw config set tools.allow '["group:plugins"]'
# Required: trust this plugin
openclaw config set plugins.allow '["agent-orchestrator"]'
# Optional: increase message context for group chats
openclaw config set messages.groupChat.historyLimit 100
# Restart to apply
pm2 restart openclaw-gateway # or however you run the gateway
```
**Why `tools.profile: "full"`?** OpenClaw's default `coding` profile only includes built-in tools. Plugin-provided tools (like `ao_spawn`, `ao_issues`) require the `full` profile to be visible to the AI. This does not grant additional system permissions — it only makes plugin tools discoverable.
## Security & Privacy
AO is an orchestrator — it does not read, write, or transmit code itself. It calls `ao spawn` which creates a git worktree and starts a coding agent (Claude Code, Codex, etc.). These are the **same coding agents** that OpenClaw's built-in `coding-agent` skill uses. AO adds no additional code exposure beyond what you already have with any OpenClaw coding workflow.
What to know:
- **GitHub access**: AO uses `gh` (GitHub CLI) with whatever credentials you've authenticated via `gh auth login`. Use a fine-grained PAT scoped to only the repos AO needs.
- **Anthropic API**: Agents use your `ANTHROPIC_API_KEY` to call the LLM. Use a dedicated key with spending limits.
- **No secrets in worktrees**: AO creates git worktrees for agents. Don't symlink `.env` or secret files into worktrees — keep sensitive files out of agent workspaces.
- **Official source**: Install AO from the [official repo](https://github.com/ComposioHQ/agent-orchestrator).
## Troubleshooting
| Error | Fix |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AO tools not visible to AI | Run `/ao setup` — needs `tools.profile: "full"` and `tools.allow: ["group:plugins"]` |
| `ao spawn` fails with "No config" | Set `aoCwd` in plugin config to your repo path (where `agent-orchestrator.yaml` lives) |
| `ao: not found` | Install AO globally or set `aoPath` in plugin config |
| `spawn tmux ENOENT` (macOS / Linux) | `brew install tmux` (macOS) or `apt install tmux` (Linux) |
| `spawn tmux ENOENT` (Windows) | Your config has `runtime: tmux` set explicitly. Switch to `runtime: process` (or remove the override — `process` is the Windows default; ConPTY is used natively, no tmux required) |
| Bot only responds in DMs | Set `channels.discord.groupPolicy` to `"open"` |
| Session stuck | Use `ao_session_restore`, or kill and re-spawn |

View File

@ -1,98 +0,0 @@
# Agent Orchestrator Config Reference
File: `agent-orchestrator.yaml` (in project root)
## Top-level settings
```yaml
port: 3000 # Dashboard port (auto-finds free port if busy)
terminalPort: 3001 # Terminal WebSocket port
readyThresholdMs: 300000 # Ms before "ready" session becomes "idle" (5 min)
```
## Default plugins
```yaml
defaults:
runtime: tmux # tmux (default on macOS/Linux) | process (default on Windows; ConPTY)
agent: claude-code # claude-code | aider | codex | cursor | kimicode | opencode
workspace: worktree # worktree | clone
notifiers: # Active notifier plugins
- desktop # desktop | slack | webhook | composio | openclaw
orchestrator:
agent: claude-code # Override agent for orchestrator sessions
worker:
agent: claude-code # Override agent for worker sessions
```
## Projects
```yaml
projects:
my-app:
name: My App # Display name
repo: owner/repo # GitHub "owner/repo"
path: ~/code/my-app # Local repo path
defaultBranch: main # main | master | develop
sessionPrefix: myapp # Session name prefix (myapp-1, myapp-2)
# Per-project plugin overrides (optional)
runtime: tmux # tmux (macOS/Linux) or process (Windows; ConPTY)
agent: claude-code
workspace: worktree
# Agent configuration (optional)
agentConfig:
permissions: auto # auto | manual
model: claude-sonnet-4-20250514
# Rules passed to every agent prompt (optional)
agentRules: |
Always run tests before committing.
Use conventional commits.
agentRulesFile: .ao-rules # Or point to a file
orchestratorRules: | # Rules for the orchestrator agent
# Orchestrator session strategy (optional)
orchestratorSessionStrategy: reuse
# Options: reuse | delete | ignore | delete-new | ignore-new | kill-previous
# Workspace setup (optional)
symlinks: # Symlink into worktrees (avoid .env or secret files)
- node_modules
postCreate: # Run after workspace creation
- pnpm install
# Issue tracker (optional)
tracker:
plugin: github # github | linear | gitlab
# SCM (optional, usually auto-detected)
scm:
plugin: github # github | gitlab
```
## Notifier channels
These are **optional** — only configure notifiers you actually use. None are required for core AO functionality. See the [AO documentation](https://github.com/ComposioHQ/agent-orchestrator) for notifier setup details.
```yaml
notifiers:
desktop:
plugin: desktop # No credentials needed — default notifier
```
## Notification routing
```yaml
notificationRouting:
critical:
- desktop
- slack
- openclaw
high:
- desktop
- openclaw
low:
- desktop
```

View File

@ -1,121 +0,0 @@
#!/usr/bin/env python3
"""
Push a file fix to GitHub via API and create a PR.
Usage: python3 push_fix_to_github.py <repo> <branch-name> <file-path> <commit-message> <pr-title> <pr-body>
Reads the original file content from GitHub (default branch), applies a sed-like
replacement using OLD_STRING / NEW_STRING env vars, and pushes via GitHub API.
Environment variables:
OLD_STRING - The exact string to find in the file (required)
NEW_STRING - The replacement string (set to empty string to delete) (required as env var)
BASE_SHA - Override the base commit SHA (optional, defaults to default branch HEAD)
BASE_BRANCH - Override the base branch name (optional, defaults to "main")
Example:
OLD_STRING='<td className="foo">{bar}</td>' \
NEW_STRING='<td className="foo"><a href="#">{bar}</a></td>' \
python3 push_fix_to_github.py ComposioHQ/agent-orchestrator fix/branch packages/web/src/File.tsx \
"fix: description" "fix: title" "Fixes #123"
"""
import sys, os, json, subprocess, base64
def run_gh(args, check=True):
"""Run a gh API command and return parsed JSON."""
cmd = ["gh", "api"] + args
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if check and result.returncode != 0:
print(f"ERROR: gh api failed: {result.stderr}", file=sys.stderr)
sys.exit(1)
try:
return json.loads(result.stdout) if result.stdout.strip() else {}
except json.JSONDecodeError:
if check:
print(f"ERROR: Invalid JSON: {result.stdout[:300]}", file=sys.stderr)
sys.exit(1)
return {}
if __name__ == "__main__":
if len(sys.argv) < 7:
print("Usage: push_fix_to_github.py <repo> <branch> <file-path> <commit-msg> <pr-title> <pr-body>", file=sys.stderr)
sys.exit(1)
repo = sys.argv[1]
branch = sys.argv[2]
file_path = sys.argv[3]
commit_msg = sys.argv[4]
pr_title = sys.argv[5]
pr_body = sys.argv[6]
base_branch = os.environ.get("BASE_BRANCH", "main")
old_string = os.environ.get("OLD_STRING", "")
new_string = os.environ.get("NEW_STRING")
if not old_string:
print("ERROR: OLD_STRING env var is required", file=sys.stderr)
sys.exit(1)
if new_string is None:
print("ERROR: NEW_STRING env var is required (set to empty string to delete)", file=sys.stderr)
sys.exit(1)
# 1. Get base HEAD SHA first
base_sha = os.environ.get("BASE_SHA")
if not base_sha:
ref_data = run_gh([f"repos/{repo}/git/ref/heads/{base_branch}"])
base_sha = ref_data["object"]["sha"]
# 2. Create branch from base SHA (before fetching file to avoid SHA race)
print(f"Creating branch {branch} from {base_branch} ({base_sha[:8]})...")
run_gh([
"-X", "POST", f"repos/{repo}/git/refs",
"-f", f"ref=refs/heads/{branch}",
"-f", f"sha={base_sha}"
], check=False)
# 3. Fetch file content from the new branch (avoids SHA mismatch)
print(f"Fetching {file_path} from {repo} (branch: {branch})...")
file_data = run_gh([f"repos/{repo}/contents/{file_path}?ref={branch}"])
file_sha = file_data["sha"]
decoded_content = base64.b64decode(file_data["content"]).decode("utf-8")
print(f"File SHA: {file_sha}")
# 4. Apply replacement
if old_string not in decoded_content:
print(f"ERROR: OLD_STRING not found in file!", file=sys.stderr)
print(f"Looking for:\n{old_string}", file=sys.stderr)
sys.exit(1)
match_count = decoded_content.count(old_string)
if match_count > 1:
print(f"WARNING: OLD_STRING found {match_count} times — replacing only the first occurrence.", file=sys.stderr)
new_content = decoded_content.replace(old_string, new_string, 1)
encoded = base64.b64encode(new_content.encode("utf-8")).decode("ascii")
# 5. Push file to branch
print(f"Pushing updated file...")
result = run_gh([
"-X", "PUT", f"repos/{repo}/contents/{file_path}",
"-f", f"message={commit_msg}",
"-f", f"content={encoded}",
"-f", f"sha={file_sha}",
"-f", f"branch={branch}"
])
# 6. Create PR
print(f"Creating PR...")
pr_result = run_gh([
"-X", "POST", f"repos/{repo}/pulls",
"-f", f"title={pr_title}",
"-f", f"body={pr_body}",
"-f", f"head={branch}",
"-f", f"base={base_branch}"
])
pr_url = pr_result.get("html_url", "unknown")
pr_number = pr_result.get("number", "?")
print(f"\n✅ PR #{pr_number}: {pr_url}")

View File

@ -1,90 +0,0 @@
---
name: ao-weekly-release
description: "Generate the weekly Agent Orchestrator release notes. Runs every Thursday 10:00 IST from the bot cron, or on-demand. Queries the GitHub API for the latest release, merged PRs, commits, contributors, and star counts, and produces a publishable markdown post in the house style. Output is posted to Discord by the cron job after this skill returns."
metadata:
schedule: "30 4 * * 4"
timezone: "Asia/Kolkata"
repo: "ComposioHQ/agent-orchestrator"
discord_channel: "1486439595498405950"
---
# AO Weekly Release Notes
Automated weekly release notes for `ComposioHQ/agent-orchestrator`. The cron job pulls `main`, runs `run.py`, and posts the output to Discord. No manual redeployment — PRs merged into `main` take effect on the next run.
## When this runs
- **Scheduled:** Every Thursday 10:00 IST (`30 4 * * 4` UTC). Invoked with `--mode scheduled`.
- **On-demand:** Anyone with bot access can trigger a run with `--mode on-demand` (e.g. for a mid-week recap or to preview a release post before cutting it).
The two modes produce the same output; the flag is recorded in the footer so readers know whether the post was automatic or manually requested.
## How to run
```bash
python3 skills/release-notes/ao-weekly-release/run.py --mode scheduled
python3 skills/release-notes/ao-weekly-release/run.py --mode on-demand
python3 skills/release-notes/ao-weekly-release/run.py --mode on-demand --since 2026-04-07
```
Requirements: `gh` CLI authenticated against `ComposioHQ/agent-orchestrator`, `python3` ≥ 3.9. No other dependencies — the runner only uses the stdlib and shells out to `gh`.
Flags:
| Flag | Default | Purpose |
| ---------- | ------------------------------- | --------------------------------------------------- |
| `--mode` | `scheduled` | `scheduled` or `on-demand`. Recorded in the footer. |
| `--since` | 7 days ago | ISO date. Overrides the default weekly window. |
| `--repo` | `ComposioHQ/agent-orchestrator` | Target repo. |
| `--output` | stdout | Write the markdown to a file instead of stdout. |
Exit codes: `0` success, `1` input/validation error, `2` `gh` query failure, `3` no activity in the window (the cron should post a short "quiet week" message instead of the full template).
## Output format
The output is a single markdown document. Section order is fixed — do not reorder. The reference post the style is calibrated against is [surajmarkup.in/research/ao-april-release](https://surajmarkup.in/research/ao-april-release/).
1. **Title + date.** `# Agent Orchestrator — Week of {Mon DD, YYYY}`. Use the Monday of the report week, not the run day.
2. **Positioning line.** One sentence, no more than 25 words, describing what this week delivered. Factual, not marketing. No "excited to announce", no "we're thrilled", no rocket emojis.
3. **Highlights.** 814 bullets. Each bullet is one short sentence, past tense, references the PR number inline. Group by theme (features → fixes → refactors → docs) but do not add sub-headers. If fewer than 8 merged PRs exist, list every merged PR and add a one-line note that the week was quiet.
4. **By the Numbers.** Four bullets: commits, merged PRs, contributors, star delta. Format as `Commits: 42` etc.
5. **Install.** Fenced block with the current install command for the latest version.
6. **Links.** Release page, full changelog, repo, Discord.
7. **Full release command checklist.** The exact commands a maintainer would run to cut a release — `pnpm changeset version`, `pnpm -r build`, `pnpm -r publish`, `gh release create`. Keep these copy-pasteable.
8. **Operator checklist.** Checkbox-style (`- [ ]`) items the operator should verify before publishing externally: changelog reviewed, PR titles cleaned, screenshots attached, Discord announcement drafted, tweet drafted. At least 6 items.
9. **Footer.** `_Generated {ISO timestamp} • mode: {scheduled|on-demand} • window: {YYYY-MM-DD}..{YYYY-MM-DD}_`
## Style constraints
- **Tone:** professional, factual, understated. Match the April release post. No hype language, no exclamation marks, no emoji in bullets (emoji is fine in the Discord message wrapper, not the markdown body).
- **Voice:** third person or imperative. Never "we shipped", prefer "Shipped …" or "The runtime now …".
- **Tense:** past tense for highlights ("Added", "Fixed", "Refactored"), imperative for the release commands.
- **PR references:** inline `(#1234)` at the end of each highlight bullet. Never link the PR title.
- **Numbers:** bare integers. No "we merged a whopping 42 PRs".
- **Length:** the full post should fit in a single Discord message after wrapping (under ~2000 characters of plaintext body, excluding the fenced code blocks). If over, the runner truncates the Highlights section and appends `… and N more — see the full changelog.`
## Error handling
The runner is deterministic and must never fabricate data. Specific failure modes:
| Failure | Behavior |
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
| `gh` not on PATH or not authenticated | Exit `2` with a clear stderr message. No partial output. |
| No merged PRs in the window | Exit `3`. Cron posts the "quiet week" Discord message instead. |
| GitHub API rate-limited | Retry once after 30s, then exit `2`. |
| A single PR query fails | Skip that PR, note the count in stderr, continue. Do not fail the whole run over one bad entry. |
| Star count unavailable | Render `Stars: (unavailable)`. Do not block the post. |
| Commit count mismatch between `gh` and `git log` | Prefer `git log` — the local checkout is the source of truth. |
The runner never invents PR numbers, contributor names, or summary text. Every data point in the output must be traceable to a `gh` or `git` command in `run.py`.
## Skill update workflow
All changes go through PRs to `skills/release-notes/ao-weekly-release/`. The cron pulls latest `main` before each run (`git fetch origin && git checkout main && git reset --hard origin/main`), so merged changes take effect on the next scheduled execution. No manual redeployment.
When editing this skill:
1. Open a PR against `main` with the change.
2. Run `python3 run.py --mode on-demand` locally against the real repo to sanity-check the output.
3. Diff the output against last week's post — unintended style regressions are easy to miss.
4. After merge, the next Thursday run picks it up automatically. To preview immediately, trigger an on-demand run from the bot.

View File

@ -1,630 +0,0 @@
#!/usr/bin/env python3
"""Deterministic runner for the AO weekly release notes skill.
Queries the GitHub API (via the `gh` CLI) for releases, merged PRs, commits,
contributors, and star counts in a 7-day window, then renders a markdown
post in the house style. See SKILL.md for the output contract.
This script never fabricates data. Every value in the output traces back to
a `gh` or `git` command in this file. If a data source is unavailable, the
corresponding field is rendered as `(unavailable)` or the run exits with a
non-zero code see the error handling table in SKILL.md.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
DEFAULT_REPO = "ComposioHQ/agent-orchestrator"
DEFAULT_WINDOW_DAYS = 7
MAX_BODY_CHARS = 2000
MIN_HIGHLIGHTS = 8
MAX_HIGHLIGHTS = 14
EXIT_OK = 0
EXIT_INPUT_ERROR = 1
EXIT_GH_ERROR = 2
EXIT_NO_ACTIVITY = 3
@dataclass
class PullRequest:
number: int
title: str
author: str
merged_at: str
labels: list[str] = field(default_factory=list)
@property
def theme(self) -> str:
title = self.title.lower()
if title.startswith("feat"):
return "feature"
if title.startswith("fix"):
return "fix"
if title.startswith("refactor") or title.startswith("perf"):
return "refactor"
if title.startswith("docs"):
return "docs"
if title.startswith("test"):
return "test"
if title.startswith("chore") or title.startswith("ci"):
return "chore"
return "other"
@property
def clean_title(self) -> str:
# Strip the conventional-commit prefix for the bullet text.
# e.g. "feat(cli): add --json flag" -> "add --json flag"
title = self.title.strip()
for prefix in ("feat", "fix", "refactor", "perf", "docs", "test", "chore", "ci", "style"):
if title.lower().startswith(prefix):
rest = title[len(prefix):]
if rest.startswith("(") and ")" in rest:
rest = rest[rest.index(")") + 1:]
if rest.startswith(":"):
rest = rest[1:]
title = rest.strip() or title
break
# Strip any trailing inline PR refs like (#1060) to avoid doubling
title = re.sub(r"\s*\(#\d+\)\s*$", "", title)
return title
@dataclass
class Snapshot:
repo: str
since: datetime
until: datetime
mode: str
latest_release_tag: str | None
latest_release_name: str | None
latest_release_url: str | None
merged_prs: list[PullRequest]
commit_count: int
contributors: list[str]
stars_now: int | None
stars_delta: int | None
npm_version: str | None = None
since_sha: str | None = None
until_sha: str | None = None
@property
def window_label(self) -> str:
return f"{self.since.strftime('%Y-%m-%d')}..{self.until.strftime('%Y-%m-%d')}"
def log(msg: str) -> None:
print(msg, file=sys.stderr)
def require_gh() -> None:
if shutil.which("gh") is None:
log("error: `gh` CLI not found on PATH. Install from https://cli.github.com/")
sys.exit(EXIT_GH_ERROR)
try:
subprocess.run(
["gh", "auth", "status"],
check=True,
capture_output=True,
text=True,
)
except subprocess.CalledProcessError as err:
log("error: `gh` is not authenticated. Run `gh auth login`.")
log(err.stderr.strip())
sys.exit(EXIT_GH_ERROR)
def run_gh(args: list[str], *, retries: int = 1) -> str:
"""Run a gh command, retrying once on transient failure."""
attempt = 0
while True:
try:
result = subprocess.run(
["gh", *args],
check=True,
capture_output=True,
text=True,
)
return result.stdout
except subprocess.CalledProcessError as err:
stderr = err.stderr or ""
if attempt < retries and ("rate limit" in stderr.lower() or "timeout" in stderr.lower()):
log(f"gh transient failure ({stderr.strip()}); retrying in 30s")
time.sleep(30)
attempt += 1
continue
log(f"error: gh {' '.join(args)} failed: {stderr.strip()}")
sys.exit(EXIT_GH_ERROR)
def fetch_latest_release(repo: str) -> tuple[str | None, str | None, str | None]:
out = run_gh([
"api",
f"repos/{repo}/releases/latest",
"-q",
"{tag_name, name, html_url}",
])
try:
data = json.loads(out)
except json.JSONDecodeError:
return None, None, None
return data.get("tag_name"), data.get("name"), data.get("html_url")
def fetch_merged_prs(repo: str, since: datetime, until: datetime) -> list[PullRequest]:
query = (
f"repo:{repo} is:pr is:merged "
f"merged:{since.strftime('%Y-%m-%d')}..{until.strftime('%Y-%m-%d')}"
)
out = run_gh([
"api",
"-X",
"GET",
"search/issues",
"-f",
f"q={query}",
"-f",
"per_page=100",
])
try:
data = json.loads(out)
except json.JSONDecodeError as err:
log(f"error: could not parse PR search response: {err}")
sys.exit(EXIT_GH_ERROR)
prs: list[PullRequest] = []
for item in data.get("items", []):
try:
prs.append(
PullRequest(
number=item["number"],
title=item["title"],
author=(item.get("user") or {}).get("login", "unknown"),
merged_at=item.get("closed_at", ""),
labels=[lbl["name"] for lbl in item.get("labels", [])],
)
)
except (KeyError, TypeError) as err:
log(f"warning: skipping malformed PR entry: {err}")
continue
prs.sort(key=lambda p: p.merged_at)
return prs
def fetch_commit_count(repo: str, since: datetime, until: datetime) -> int:
"""Prefer local `git log` if available — the checkout is the source of truth."""
if shutil.which("git") is not None:
try:
result = subprocess.run(
[
"git",
"log",
f"--since={since.isoformat()}",
f"--until={until.isoformat()}",
"--pretty=oneline",
],
check=True,
capture_output=True,
text=True,
)
lines = [ln for ln in result.stdout.splitlines() if ln.strip()]
if lines:
return len(lines)
except subprocess.CalledProcessError:
pass
out = run_gh([
"api",
"-X",
"GET",
f"repos/{repo}/commits",
"-f",
f"since={since.isoformat()}",
"-f",
f"until={until.isoformat()}",
"-f",
"per_page=100",
])
try:
data = json.loads(out)
except json.JSONDecodeError:
return 0
return len(data) if isinstance(data, list) else 0
def fetch_stars(repo: str) -> int | None:
out = run_gh(["api", f"repos/{repo}", "-q", ".stargazers_count"])
try:
return int(out.strip())
except (ValueError, TypeError):
return None
def fetch_npm_version(package: str = "@aoagents/ao") -> str | None:
"""Fetch the latest published version from the npm registry."""
try:
result = subprocess.run(
["npm", "view", package, "version"],
check=True,
capture_output=True,
text=True,
)
version = result.stdout.strip()
return version if version else None
except (subprocess.CalledProcessError, FileNotFoundError):
return None
def resolve_commit_sha(repo: str, date: datetime) -> str | None:
"""Resolve a date to a commit SHA, preferring local git, falling back to the API."""
iso = date.isoformat()
if shutil.which("git") is not None:
try:
result = subprocess.run(
["git", "rev-list", "-1", f"--before={iso}", "main"],
check=True,
capture_output=True,
text=True,
)
sha = result.stdout.strip()
if sha:
return sha[:12]
except subprocess.CalledProcessError:
pass
try:
out = run_gh([
"api",
"-X",
"GET",
f"repos/{repo}/commits",
"-f",
f"until={iso}",
"-f",
"per_page=1",
"-q",
".[0].sha",
])
sha = out.strip()
return sha[:12] if sha else None
except SystemExit:
return None
def compute_contributors(prs: list[PullRequest]) -> list[str]:
seen: dict[str, None] = {}
for pr in prs:
if pr.author and pr.author not in seen:
seen[pr.author] = None
return list(seen.keys())
def stars_delta_estimate(repo: str, current: int | None) -> int | None:
"""Read last week's star count from a local cache file, if present."""
if current is None:
return None
cache_dir = os.environ.get("AO_RELEASE_NOTES_CACHE") or os.path.expanduser(
"~/.cache/ao-weekly-release"
)
cache_path = os.path.join(cache_dir, f"{repo.replace('/', '__')}.json")
previous: int | None = None
try:
with open(cache_path, "r", encoding="utf-8") as f:
previous = json.load(f).get("stars")
except (FileNotFoundError, json.JSONDecodeError, OSError):
previous = None
try:
os.makedirs(cache_dir, exist_ok=True)
with open(cache_path, "w", encoding="utf-8") as f:
json.dump({"stars": current, "recorded_at": datetime.now(timezone.utc).isoformat()}, f)
except OSError as err:
log(f"warning: could not update stars cache: {err}")
if previous is None:
return None
return current - previous
def gather(repo: str, since: datetime, until: datetime, mode: str) -> Snapshot:
tag, name, url = fetch_latest_release(repo)
prs = fetch_merged_prs(repo, since, until)
commits = fetch_commit_count(repo, since, until)
contributors = compute_contributors(prs)
stars = fetch_stars(repo)
delta = stars_delta_estimate(repo, stars)
npm_version = fetch_npm_version()
since_sha = resolve_commit_sha(repo, since)
until_sha = resolve_commit_sha(repo, until)
return Snapshot(
repo=repo,
since=since,
until=until,
mode=mode,
latest_release_tag=tag,
latest_release_name=name,
latest_release_url=url,
merged_prs=prs,
commit_count=commits,
contributors=contributors,
stars_now=stars,
stars_delta=delta,
npm_version=npm_version,
since_sha=since_sha,
until_sha=until_sha,
)
def format_theme_order(prs: list[PullRequest]) -> list[PullRequest]:
order = {"feature": 0, "fix": 1, "refactor": 2, "docs": 3, "test": 4, "chore": 5, "other": 6}
return sorted(prs, key=lambda p: (order.get(p.theme, 99), p.number))
_LEADING_VERB_RE = re.compile(
r"^(add|fix|refactor|update|remove|change|implement|improve|enable|disable|"
r"document|test|clean|drop|bump|migrate|introduce|support|handle|replace|"
r"rename|move|extract|merge|revert|skip|allow|prevent|ensure)\b",
re.IGNORECASE,
)
def render_highlights(prs: list[PullRequest]) -> tuple[list[str], int]:
ordered = format_theme_order(prs)
bullets: list[str] = []
for pr in ordered[:MAX_HIGHLIGHTS]:
theme_verb = {
"feature": "Added",
"fix": "Fixed",
"refactor": "Refactored",
"docs": "Documented",
"test": "Tested",
"chore": "Updated",
"other": "Changed",
}[pr.theme]
body = pr.clean_title
# If the cleaned title already starts with a verb, capitalize it
# and use it directly — avoids "Added add …" stutter.
if _LEADING_VERB_RE.match(body):
body = body[0].upper() + body[1:]
else:
if body and body[0].isupper():
body = body[0].lower() + body[1:]
body = f"{theme_verb} {body}"
bullets.append(f"- {body} (#{pr.number})")
overflow = max(0, len(ordered) - MAX_HIGHLIGHTS)
return bullets, overflow
def render_markdown(snap: Snapshot) -> str:
if not snap.merged_prs:
log("no merged PRs in window")
sys.exit(EXIT_NO_ACTIVITY)
week_label = snap.since.strftime("%b %d, %Y")
title = f"# Agent Orchestrator — Week of {week_label}"
positioning = build_positioning_line(snap)
highlights, overflow = render_highlights(snap.merged_prs)
if len(snap.merged_prs) < MIN_HIGHLIGHTS:
highlights.append(
f"- Quiet week: {len(snap.merged_prs)} merged PR(s); every change is listed above."
)
# Track total omitted PRs across both the initial cap and any later truncation.
# The overflow sentinel line is rendered once at the end by truncate_if_needed.
total_omitted = overflow
stars_line = (
f"- Stars: {snap.stars_now}"
+ (f" (+{snap.stars_delta})" if snap.stars_delta and snap.stars_delta > 0 else "")
if snap.stars_now is not None
else "- Stars: (unavailable)"
)
by_numbers = [
"## By the Numbers",
f"- Commits: {snap.commit_count}",
f"- Merged PRs: {len(snap.merged_prs)}",
f"- Contributors: {len(snap.contributors)}",
stars_line,
]
version = snap.npm_version or "latest"
install_block = [
"## Install",
"```bash",
"npm install -g @aoagents/ao",
f"# or pin to the current release: npm install -g @aoagents/ao@{version}",
"```",
]
release_url = snap.latest_release_url or f"https://github.com/{snap.repo}/releases"
if snap.since_sha and snap.until_sha:
changelog_url = f"https://github.com/{snap.repo}/compare/{snap.since_sha}...{snap.until_sha}"
else:
changelog_url = f"https://github.com/{snap.repo}/commits/main"
links = [
"## Links",
f"- Release: {release_url}",
f"- Full changelog: {changelog_url}",
f"- Repository: https://github.com/{snap.repo}",
"- Discord: https://discord.gg/agent-orchestrator",
]
release_version = version if version != "latest" else "X.Y.Z"
release_checklist = [
"## Release commands",
"```bash",
"git fetch origin && git checkout main && git reset --hard origin/main",
"pnpm install --frozen-lockfile",
"pnpm changeset version",
"git add . && git commit -m \"chore: version packages\"",
"pnpm -r build",
"pnpm -r publish --access public --no-git-checks",
f"gh release create v{release_version} --generate-notes",
"git push origin main --follow-tags",
"```",
]
operator_checklist = [
"## Operator checklist",
"- [ ] Changelog reviewed for accuracy and tone",
"- [ ] PR titles cleaned up where necessary",
"- [ ] Screenshots or GIFs attached for any UI-visible changes",
"- [ ] Discord announcement drafted in #releases",
"- [ ] Tweet / social post drafted",
"- [ ] Docs site updated if any public API changed",
"- [ ] Breaking changes (if any) called out at the top of the post",
]
footer = (
f"_Generated {datetime.now(timezone.utc).isoformat(timespec='seconds')}"
f" • mode: {snap.mode}"
f" • window: {snap.window_label}_"
)
# Append the overflow line now (before truncation adjusts it).
if total_omitted:
highlights.append(f"- … and {total_omitted} more — see the full changelog.")
parts: list[str] = [
title,
"",
positioning,
"",
"## Highlights",
*highlights,
"",
*by_numbers,
"",
*install_block,
"",
*links,
"",
*release_checklist,
"",
*operator_checklist,
"",
footer,
"",
]
body = "\n".join(parts)
return truncate_if_needed(body, total_omitted)
def build_positioning_line(snap: Snapshot) -> str:
pr_count = len(snap.merged_prs)
contrib_count = len(snap.contributors)
return (
f"This week the team merged {pr_count} PRs from {contrib_count} "
f"contributor{'s' if contrib_count != 1 else ''}, continuing steady iteration "
f"on the orchestrator core, plugins, and dashboard."
)
def truncate_if_needed(body: str, already_omitted: int) -> str:
plain = "\n".join(line for line in body.splitlines() if not line.startswith("```"))
if len(plain) <= MAX_BODY_CHARS:
return body
# Trim highlights from the tail until the body fits.
lines = body.splitlines()
try:
start = lines.index("## Highlights") + 1
end = next(i for i in range(start, len(lines)) if lines[i].startswith("## "))
except (ValueError, StopIteration):
return body
kept = lines[start:end]
# Remove any existing overflow sentinel so we can re-render it with the correct total.
if kept and kept[-1].startswith("- … and "):
kept.pop()
dropped = 0
while kept and len(
"\n".join(ln for ln in (lines[:start] + kept + lines[end:]) if not ln.startswith("```"))
) > MAX_BODY_CHARS:
kept.pop()
dropped += 1
total = already_omitted + dropped
if total:
kept.append(f"- … and {total} more — see the full changelog.")
return "\n".join(lines[:start] + kept + lines[end:])
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate AO weekly release notes")
parser.add_argument(
"--mode",
choices=("scheduled", "on-demand"),
default="scheduled",
help="How this run was triggered. Recorded in the footer.",
)
parser.add_argument(
"--since",
help="ISO date (YYYY-MM-DD) for the start of the window. Defaults to 7 days ago.",
)
parser.add_argument(
"--until",
help="ISO date (YYYY-MM-DD) for the end of the window. Defaults to today.",
)
parser.add_argument("--repo", default=DEFAULT_REPO, help="owner/name of the target repo")
parser.add_argument("--output", help="Write markdown to this file instead of stdout")
return parser.parse_args(argv)
def resolve_window(args: argparse.Namespace) -> tuple[datetime, datetime]:
now = datetime.now(timezone.utc)
try:
until = (
datetime.fromisoformat(args.until).replace(tzinfo=timezone.utc)
if args.until
else now
)
since = (
datetime.fromisoformat(args.since).replace(tzinfo=timezone.utc)
if args.since
else until - timedelta(days=DEFAULT_WINDOW_DAYS)
)
except ValueError as err:
log(f"error: invalid date: {err}")
sys.exit(EXIT_INPUT_ERROR)
if since >= until:
log("error: --since must be before --until")
sys.exit(EXIT_INPUT_ERROR)
return since, until
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv if argv is not None else sys.argv[1:])
require_gh()
since, until = resolve_window(args)
snap = gather(args.repo, since, until, args.mode)
markdown = render_markdown(snap)
if args.output:
try:
with open(args.output, "w", encoding="utf-8") as f:
f.write(markdown)
except OSError as err:
log(f"error: could not write output file: {err}")
return EXIT_INPUT_ERROR
else:
sys.stdout.write(markdown)
return EXIT_OK
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,249 +0,0 @@
---
name: social-media-posts
description: "Draft viral social media posts for X (Twitter) and LinkedIn. Use when asked to write, draft, create, or improve posts for X, Twitter, LinkedIn, or social media. Handles single posts, threads, milestone celebrations, product launches, hot takes, and engagement-optimized content. NOT for: scheduling posts, managing accounts, or analytics."
---
# Social Media Posts
Draft platform-optimized posts using the user's voice and style preferences.
## Voice Calibration
Before drafting, match the author's actual voice — not a generic "builder tone."
1. Ask the user for 3-5 reference posts they've written (or search past posts if available)
2. Extract: sentence length, vocabulary level, humor style, how they handle numbers, how they open/close
3. If no references available, default to: first person, direct, specific numbers, no corporate speak
Every author sounds different. "Builder tone" is the floor, not the ceiling.
## Platform Rules
### X (Twitter)
- Single post: max 280 chars (or up to ~600 if user has Premium)
- Thread: 1/ 2/ 3/ numbering, first tweet is the hook, last tweet is CTA
- First tweet must stand alone — it's what gets retweeted
- End thread with link + call to action
- No hashtags unless user asks (X algorithm deprioritizes them)
- Use line breaks for visual rhythm
#### X Media Strategy
Posts with media outperform text-only. Always consider:
- **Screenshots:** For dashboard/UI features — show, don't tell
- **GIFs/Demos:** For workflow changes — 5-10 second loops
- **4-image grid:** For multi-feature showcases — each image = one feature
- Reference media in-tweet: "↓ see it in action" or just let the image speak
- When a post needs a visual, use `image_generate` to create one — don't just describe it
#### Thread vs Single Post Decision
- **Single post:** One feature, one metric, one announcement, under 280 chars
- **Thread:** Multiple features, a story arc, technical breakdown, or anything that needs "and here's why"
- If you're cramming to fit 280 chars, it's probably a thread
### LinkedIn
- No character limit but sweet spot is 150-300 words
- **Hook character limit: ~210 chars before "see more" fold** — the entire hook MUST fit before the fold
- First line is everything — it's the "see more" hook
- Use Unicode bold (𝗯𝗼𝗹𝗱) for emphasis
- Section breaks: `— — —`
- Arrow bullets: `→` not `-`
- Max 2-3 hashtags, at the very end
- Short paragraphs (1-3 sentences max)
- **Blank lines matter** — double line break between paragraphs creates the visual rhythm that keeps people scrolling
#### LinkedIn Bold Text
Use Unicode bold for key phrases. Common mappings:
```
A=𝗔 B=𝗕 C=𝗖 D=𝗗 E=𝗘 F=𝗙 G=𝗚 H=𝗛 I=𝗜 J=𝗝 K=𝗞 L=𝗟 M=𝗠
N=𝗡 O=𝗢 P=𝗣 Q=𝗤 R=𝗥 S=𝗦 T=𝗧 U=𝗨 V=𝗩 W=𝗪 X=𝗫 Y=𝗬 Z=𝗭
a=𝗮 b=𝗯 c=𝗰 d=𝗱 e=𝗲 f=𝗳 g=𝗴 h=𝗵 i=𝗶 j=𝗷 k=𝗸 l=𝗹 m=𝗺
n=𝗻 o=𝗼 p=𝗽 q=𝗾 r=𝗿 s=𝘀 t=𝘁 u=𝘂 v=𝘃 w=𝘄 x=𝘅 y=𝘆 z=𝘇
0=𝟬 1=𝟭 2=𝟮 3=𝟯 4=𝟰 5=𝟱 6=𝟲 7=𝟳 8=𝟴 9=𝟵
```
Apply bold to:
- Opening stat/hook
- Section headers within post
- Key metrics
- Names/products being highlighted
- The punchline or key insight
Don't over-bold — if everything is bold, nothing is.
## Style Guide
### Voice
- Builder tone — someone who ships, not someone who advises
- First person, direct
- Confident but not arrogant
- Specific numbers over vague claims
- No corporate speak, no buzzword soup
### Banned Phrases
Never use these:
- "I'm thrilled/excited to announce"
- "Leveraging" / "Synergy"
- "Game-changer" / "Revolutionary"
- "Let's connect!" / "Thoughts?"
- "I'd love to hear your thoughts"
- Excessive emoji (1-2 max, only if natural)
- "🚀" as emphasis
## Hook Library
Don't start from scratch. Use these proven patterns:
### Stat-led hooks
- "We just hit [NUMBER] [metric]. Here's what actually moved the needle:"
- "[NUMBER]% of [audience] don't know [surprising fact]. We didn't either."
- "From [start] to [end] in [timeframe]. No funding, no team of 50."
### Contrarian hooks
- "Hot take: [contrarian position]. Here's why:"
- "Everyone says [common advice]. They're wrong. Here's what worked instead:"
- "Unpopular opinion: [bold claim]. And I have the data to back it up."
### Story hooks
- "Last [timeframe], [dramatic situation]. Today, [result]. Here's what happened:"
- "We [did something unexpected]. [Surprising outcome]."
- "[Number] [units] later, here's the one thing I wish I knew on day 1:"
### Product hooks
- "[Thing] just got [dramatic improvement]. [Before] → [After]:"
- "We open-sourced [thing]. [One-line what it does]. [Star/link]"
- "[Problem] is solved. Not partially. Actually solved. Here's how:"
## Structure Templates
### LinkedIn
1. Hook — bold stat, surprising claim, or punchy opener (1 line, under 210 chars)
2. Story — what happened, keep paragraphs short
3. Numbers — concrete metrics in arrow-bullet format
4. Insight — what you learned or what it means
5. CTA — link + next step (not "thoughts?")
### X Single Post
1. Hook line
2. Key details (2-3 lines)
3. Link
### X Thread
1. Hook + 🧵
2. Context/backstory
3. Numbers/details (1-2 tweets)
4. Turning point or insight
5. What's next + link
## CTA Library
Don't end with "thoughts?" — use CTAs that actually drive action:
### Repo/Product
- "Star the repo → [link]"
- "Try it: [one-line command]"
- "npm i -g [package] and see for yourself → [link]"
### Community
- "Join [number] builders in Discord → [link]"
- "What are you building? Drop it below 👇"
### Content
- "Full breakdown → [link]"
- "Read the docs → [link]"
- "Full release notes → [link]"
### Engagement
- "What's your [take/experience] with [topic]?"
- "RT if you've felt this pain ↓"
## Cross-Platform Adaptation
When the same content goes to both X and LinkedIn, restructure — don't just reformat:
| Aspect | X | LinkedIn |
| ---------- | ------------------------- | ---------------------------- |
| Length | Compressed, punchy | Story arc with whitespace |
| Narrative | Bullet points, rapid fire | Paragraphs, breathing room |
| Numbers | Inline, one per tweet | Arrow bullets, grouped |
| Hook | Shocking stat or hot take | Personal story or bold claim |
| CTA | Link only | Link + context |
| Formatting | Line breaks, no bold | Unicode bold, section breaks |
**Rule:** Write LinkedIn first (full story), then compress into X. Not the other way around — you can't expand a tweet into a story.
## Post Types
### Milestone Celebration (stars, users, revenue)
- Lead with the number
- Tell the journey (compressed)
- Show what was shipped
- Mention organic recognition
- What's next
- Keep it a story, not a press release
### Product Launch / Feature Ship
- What changed and why it matters
- Before → after (if applicable)
- One-line install/usage
- Link to repo or docs
- **Always attach a visual** — screenshot, GIF, or generated image
### Hot Take / Trend Response
- Acknowledge the trend
- State your position early
- Back it with what you've built
- Don't trash competitors — position yourself
### Article Promotion
- Pull the single most interesting insight
- Don't summarize the whole article
- Make people want to click
- Link at the end, not the beginning
## Output Format
Always output posts inside code blocks (```) so user can copy-paste directly.
For LinkedIn: single code block with the full post.
For X single: single code block.
For X thread: separate code blocks per tweet, numbered.
If the post needs a visual, generate it with `image_generate` and attach it.
## Checklist Before Delivering
- [ ] Hook line would make YOU stop scrolling
- [ ] No banned phrases
- [ ] Numbers are specific, not vague
- [ ] CTA is from the CTA library (not "thoughts?")
- [ ] Platform format is correct
- [ ] Under length limit
- [ ] LinkedIn hook fits under 210 chars (before "see more" fold)
- [ ] Reads like a human wrote it, not AI
- [ ] Visual attached or generated if post type warrants it (launch, milestone, feature)