Skip to content

Instantly share code, notes, and snippets.

@esciara
Last active May 10, 2026 22:37
Show Gist options
  • Select an option

  • Save esciara/4eb1a507971b73a3492fe266f659c0ac to your computer and use it in GitHub Desktop.

Select an option

Save esciara/4eb1a507971b73a3492fe266f659c0ac to your computer and use it in GitHub Desktop.
issue-hunter automation — Gas Town automated contribution bot for gastownhall/gastown

Issue Hunter

Automated contribution bot for gastownhall/gastown. Runs every 6 hours, finds open issues without PRs, and opens fixes via esciara/gastown.

Architecture

launchd (every 6h)
  └─▶ [dispatcher polecat] mol-issue-hunter-dispatch
        - Queries gastownhall/gastown for open issues with no PR
        - Skips on-hold issues (needs-info, needs-repro, needs-triage, in-progress)
        - Skips issues with an existing branch on the fork
        - Tier 1: esciara-authored issues (any age), priority desc + oldest first
        - Tier 2: rolling 2-week windows, same ordering, fills remaining slots
        - For each selected issue: bd create + gt sling --formula mol-issue-hunter-work-p1
              │
              └─▶ Stage 1 polecat — mol-issue-hunter-work-p1 (investigate, read-only)
                    - Reads issue + all comments
                    - If blocked → GitHub comment + Telegram DM → exits DEFERRED
                    - If clear → dispatches Stage 2 via bash (bd create + gt sling)
                              │
                              └─▶ Stage 2 polecat — mol-issue-hunter-work-p2 (implement)
                                    - Fresh context (new session, guaranteed)
                                    - Syncs fork, creates branch, implements fix
                                    - Commits implementation
                                    - Dispatches Stage 3 via bash, passing branch name
                                              │
                                              └─▶ Stage 3 polecat — mol-issue-hunter-work-p3 (review + ship)
                                                    - Fresh context (new session, guaranteed)
                                                    - Receives branch name as var (no source re-reading)
                                                    - Build + test + /review --branch
                                                    - Pushes PR to gastownhall/gastown
                                                    - Telegram notification

Stage boundaries are enforced by bash commands (gt sling + gt done), not advisory instructions. A new polecat session is structurally guaranteed at each stage boundary.

Files

File Purpose
.beads/formulas/mol-issue-hunter-dispatch.formula.toml Dispatcher formula
.beads/formulas/mol-issue-hunter-work-p1.formula.toml Stage 1: investigate
.beads/formulas/mol-issue-hunter-work-p2.formula.toml Stage 2: implement
.beads/formulas/mol-issue-hunter-work-p3.formula.toml Stage 3: review + ship
.beads/formulas/mol-issue-hunter-work.formula.toml Single-session convenience (small issues)
automations/issue-hunter/config.json Config reference
~/Library/LaunchAgents/com.gastown.issue-hunter.plist macOS scheduler (6h interval)
automations/issue-hunter/issue-hunter.log Runtime log (gitignored)

One-time Setup

1. Load the launchd scheduler

Run from a normal terminal (not inside Claude Code):

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.gastown.issue-hunter.plist

# Verify it registered
launchctl list | grep issue-hunter
# Expected: -  0  com.gastown.issue-hunter

To stop it permanently:

launchctl bootout gui/$(id -u)/com.gastown.issue-hunter

2. Set up Telegram notifications

  1. Open Telegram, message @BotFather, run /newbot, copy the token

  2. Message your new bot once (any text)

  3. Get your chat ID:

    curl https://api.telegram.org/bot<TOKEN>/getUpdates
    # Copy the "id" field from result[0].message.chat
  4. Provide credentials via one of these two methods:

    Option A — environment variables (simple): Add to ~/.zshrc:

    export TELEGRAM_BOT_TOKEN=<token>
    export TELEGRAM_CHAT_ID=<id>

    Then reload: source ~/.zshrc

    Option B — macOS Keychain (works from SSH, no UI prompt):

    security add-generic-password -a "$USER" -s "telegram-bot-token" -w "<token>" -A
    security add-generic-password -a "$USER" -s "telegram-chat-id"   -w "<id>"    -A

    The -A flag allows any application (including launchd and SSH sessions) to read the value without an authentication prompt.

    The automation checks for env vars first; if unset, it falls back to these Keychain entries automatically.

If neither is configured, the automation runs normally but skips Telegram notifications (logs a warning instead).

3. Ensure the gastown rig is running before the first fire

gt witness start gastown
gt refinery start gastown

Manual trigger (test without waiting 6h)

cd ~/my-gastown
BEAD=$(bd create "issue-hunter: manual dispatch" --type task | grep "Created issue:" | awk '{print $3}')
gt sling "$BEAD" gastown --formula mol-issue-hunter-dispatch

Watch progress:

gt rig list          # Shows running polecats
gt mol status        # Shows active molecules
tail -f automations/issue-hunter/issue-hunter.log

PR format

Workers open PRs from esciara:<branch> targeting gastownhall/gastown:main.

Branch naming: issue-hunter/<number>-<short-slug> Example: issue-hunter/3874-duplicate-commands-provisioned

PR body follows the upstream template:

## Summary
## Changes
## Testing

Closes #<number>

When human input is needed

If a worker cannot proceed (missing info, ambiguous design decision), it:

  1. Posts a comment on the GitHub issue explaining exactly what is needed
  2. Sends a Telegram DM with the issue link
  3. Exits with status DEFERRED

The issue will be skipped on subsequent runs until you reply to the comment. Once you do, the next run will pick it up again.

Configuration

Edit automations/issue-hunter/config.json to change:

  • upstream_repo / fork_repo
  • max_issues (default: 5, capped by rig's scheduler.max_polecats)
  • selection.prefer_author and selection.priority_labels
  • selection.skip_labels — issues with any of these labels are skipped (default: needs-info, needs-repro, needs-triage, in-progress)
  • selection.recency_window_weeks — window size for tier 2 rolling selection (default: 2)

To change the rig's polecat limit:

gt config set scheduler.max_polecats 5 --rig gastown

Logs

tail -f ~/my-gastown/automations/issue-hunter/issue-hunter.log
{
"_comment": "Issue Hunter automation — runs every 6h, contributes to upstream gastown",
"enabled": false,
"upstream_repo": "gastownhall/gastown",
"fork_repo": "esciara/gastown",
"rig": "gastown",
"max_issues": 5,
"selection": {
"_comment": "Tier 1: esciara-authored issues (any age), by priority then oldest first. Tier 2: rolling 2-week windows, same ordering.",
"prefer_author": "esciara",
"recency_window_weeks": 2,
"priority_labels": ["priority/p0", "priority/p1", "priority/p2", "priority/p3"],
"skip_labels": ["status/needs-info", "status/needs-repro", "status/needs-triage", "status/in-progress"]
},
"telegram": {
"_comment": "Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in your shell environment.",
"_setup": [
"1. Message @BotFather on Telegram, run /newbot, copy the token",
"2. Message your bot once, then run: curl https://api.telegram.org/bot<TOKEN>/getUpdates",
"3. Copy the chat id from the response",
"4. Add to ~/.zshrc: export TELEGRAM_BOT_TOKEN=... && export TELEGRAM_CHAT_ID=..."
]
}
}
description = """
Scan gastownhall/gastown for open issues without PRs, select up to 5 by priority,
and sling a worker polecat per issue.
## Selection rules (strict two-tier)
Tier 1 — esciara-authored issues, any age, sorted by priority (p0 > p1 > p2 > p3 > none)
Tier 2 — only if tier 1 fills fewer than max_issues slots: rolling 2-week windows
(0–14d first, then 14–28d, etc.), sorted by priority then oldest first.
Skip any issue that already has an open or recently-merged PR referencing it.
Skip any issue with an on-hold label: status/needs-info, status/needs-repro,
status/needs-triage, status/in-progress.
Skip any issue that is already claimed (branch exists on esciara/gastown fork).
## PR coverage detection (two methods, both applied)
1. GitHub closingIssuesReferences: the issues a PR will auto-close when merged (most reliable)
2. Keyword regex in PR title/body: closes/fixes/resolves #N (catches informal references)
## Variables
| Variable | Description |
|---------------|--------------------------------------|
| upstream_repo | Upstream repo (default: gastownhall/gastown) |
| fork_repo | Fork repo (default: esciara/gastown) |
| max_issues | Max issues to sling (default: 5) |
## Notes
- This polecat does NO code work. It queries, decides, and slings — then exits.
- Worker polecats run in parallel, up to the rig's polecat limit.
- Already-claimed issues are detected via existing remote branches matching
the pattern `issue-hunter/<number>-*` on the fork."""
formula = "mol-issue-hunter-dispatch"
version = 1
[[steps]]
id = "fetch-open-issues"
title = "Fetch open issues from upstream"
description = """
Query gastownhall/gastown for all open issues that are NOT pull requests.
```bash
# Fetch open issues with labels, author, and creation date (up to 200)
gh issue list \
--repo {{upstream_repo}} \
--state open \
--limit 200 \
--json number,title,labels,author,body,createdAt \
> /tmp/ih-issues-raw.json
# Sanity check — abort early if nothing returned
COUNT=$(jq length /tmp/ih-issues-raw.json)
echo "Open issues found: ${COUNT}"
if [ "${COUNT}" = "0" ]; then
echo "No open issues found. Nothing to do."
rm -f /tmp/ih-issues-raw.json
gt done --status COMPLETED --skip-verify
exit 0
fi
```
**Exit criteria:** `/tmp/ih-issues-raw.json` exists and contains at least one issue."""
[[steps]]
id = "filter-no-pr"
title = "Filter out issues that already have an open or recently-merged PR"
needs = ["fetch-open-issues"]
description = """
For each issue, check whether an open or recently-merged PR already covers it.
Two detection methods are applied — an issue is filtered out if either fires:
1. GitHub closingIssuesReferences: the issues a PR is linked to auto-close (most reliable)
2. Keyword regex in PR title/body: closes/fixes/resolves #N (catches informal references)
```bash
# Open PRs: fetch with closingIssuesReferences + title/body for keyword fallback
gh pr list \
--repo {{upstream_repo}} \
--state open \
--limit 200 \
--json number,title,body,closingIssuesReferences \
> /tmp/ih-prs-open.json
# Recently merged PRs (last 30 days): same fields
gh pr list \
--repo {{upstream_repo}} \
--state merged \
--limit 100 \
--json number,title,body,closingIssuesReferences \
> /tmp/ih-prs-merged.json
python3 - <<'PYEOF'
import json, re
def extract_keyword_refs(text):
return set(int(m) for m in re.findall(r'(?:closes?|fixes?|resolves?)\\s+#(\\d+)', text or '', re.I))
covered = set()
for f in ['/tmp/ih-prs-open.json', '/tmp/ih-prs-merged.json']:
for pr in json.load(open(f)):
# Method 1: GitHub's own closingIssuesReferences
for ref in pr.get('closingIssuesReferences') or []:
covered.add(ref['number'])
# Method 2: keyword regex fallback
covered |= extract_keyword_refs(pr.get('body') or '')
covered |= extract_keyword_refs(pr.get('title') or '')
issues = json.load(open('/tmp/ih-issues-raw.json'))
uncovered = [i for i in issues if i['number'] not in covered]
json.dump(uncovered, open('/tmp/ih-issues-uncovered.json', 'w'), indent=2)
print(f"Covered by a PR: {len(covered)} issue(s)")
print(f"Uncovered (candidates): {len(uncovered)} of {len(issues)}")
PYEOF
```
**Exit criteria:** `/tmp/ih-issues-uncovered.json` contains only issues with no covering PR."""
[[steps]]
id = "filter-on-hold"
title = "Filter out on-hold issues"
needs = ["filter-no-pr"]
description = """
Skip issues with any label that signals they cannot be worked on autonomously:
status/needs-info, status/needs-repro, status/needs-triage, status/in-progress.
```bash
python3 - <<'PYEOF'
import json
ON_HOLD = {"status/needs-info", "status/needs-repro", "status/needs-triage", "status/in-progress"}
issues = json.load(open('/tmp/ih-issues-uncovered.json'))
workable = [
i for i in issues
if not any(l['name'] in ON_HOLD for l in i.get('labels', []))
]
json.dump(workable, open('/tmp/ih-issues-workable.json', 'w'), indent=2)
skipped = len(issues) - len(workable)
print(f"On-hold (skipped): {skipped} issue(s)")
print(f"Workable (continuing): {len(workable)}")
PYEOF
```
**Exit criteria:** `/tmp/ih-issues-workable.json` contains only actionable issues."""
[[steps]]
id = "filter-no-branch"
title = "Filter out issues already claimed (branch exists on fork)"
needs = ["filter-on-hold"]
description = """
Skip issues for which a branch `issue-hunter/<number>-*` already exists on the fork.
This prevents double-slinging on re-runs before the previous polecat finishes.
```bash
git -C "$(gt rig path {{rig}})" fetch origin --prune 2>/dev/null || true
git -C "$(gt rig path {{rig}})" branch -r \
| grep 'origin/issue-hunter/' \
| sed 's|.*origin/issue-hunter/||' \
| grep -oE '^[0-9]+' \
> /tmp/ih-claimed-numbers.txt
python3 - <<'PYEOF'
import json
claimed = set()
try:
claimed = set(int(l.strip()) for l in open('/tmp/ih-claimed-numbers.txt') if l.strip().isdigit())
except FileNotFoundError:
pass
issues = json.load(open('/tmp/ih-issues-workable.json'))
available = [i for i in issues if i['number'] not in claimed]
json.dump(available, open('/tmp/ih-issues-available.json', 'w'), indent=2)
print(f"Available (not claimed): {len(available)}")
PYEOF
```
**Exit criteria:** `/tmp/ih-issues-available.json` contains only unclaimed issues."""
[[steps]]
id = "select-and-rank"
title = "Select and rank up to max_issues by priority (strict two-tier)"
needs = ["filter-no-branch"]
description = """
Apply strict two-tier selection:
Tier 1 — esciara-authored issues, any age, sorted by priority (p0 > p1 > p2 > p3 > none),
then oldest first within the same priority.
These always take priority over everyone else's issues.
Tier 2 — fills remaining slots (if tier 1 < max_issues) with non-esciara issues, using
rolling 2-week windows (0–14 days old first, then 14–28 days, then 28–42 days,
etc.). Within each window: sorted by priority then oldest first (FIFO fairness).
esciara issues already in tier 1 are excluded from tier 2.
```bash
python3 - <<'PYEOF'
import json
from datetime import datetime, timezone, timedelta
PREFER_AUTHOR = "esciara"
PRIORITY_ORDER = {"priority/p0": 0, "priority/p1": 1, "priority/p2": 2, "priority/p3": 3}
MAX = int("{{max_issues}}")
WINDOW_SIZE = timedelta(weeks=2)
NOW = datetime.now(timezone.utc)
def get_priority(issue):
labels = [l['name'] for l in issue.get('labels', [])]
return min((PRIORITY_ORDER[l] for l in labels if l in PRIORITY_ORDER), default=99)
def get_created_at(issue):
return datetime.fromisoformat(issue['createdAt'].replace('Z', '+00:00'))
issues = json.load(open('/tmp/ih-issues-available.json'))
# Tier 1: esciara-authored, any age, by priority then oldest first
tier1 = [i for i in issues if i['author']['login'] == PREFER_AUTHOR]
tier1.sort(key=lambda i: (get_priority(i), get_created_at(i)))
selected = tier1[:MAX]
# Tier 2: rolling 2-week windows, oldest first within each window
if len(selected) < MAX:
selected_nums = {i['number'] for i in selected}
candidates = [
i for i in issues
if i['number'] not in selected_nums
and i['author']['login'] != PREFER_AUTHOR
]
window_start = 0
while len(selected) < MAX and candidates:
window_begin = NOW - WINDOW_SIZE * (window_start + 1)
window_end = NOW - WINDOW_SIZE * window_start
window = [i for i in candidates if window_begin <= get_created_at(i) < window_end]
window.sort(key=lambda i: (get_priority(i), get_created_at(i)))
take = window[:MAX - len(selected)]
selected += take
candidates = [i for i in candidates if i not in take]
window_start += 1
if window_start > 52: # safety: don't go back more than ~1 year
break
json.dump(selected, open('/tmp/ih-issues-selected.json', 'w'), indent=2)
t1 = len(tier1[:MAX])
print(f"Selected {len(selected)} issue(s) ({t1} tier-1 esciara, {len(selected)-t1} tier-2 windowed):")
for i in selected:
labels = [l['name'] for l in i.get('labels', [])]
author = i['author']['login']
age = (NOW - get_created_at(i)).days
print(f" #{i['number']} [{', '.join(labels) or 'no labels'}] @{author} ({age}d) {i['title'][:50]}")
PYEOF
```
If the selected list is empty, log a message and exit cleanly — nothing to do.
**Exit criteria:** `/tmp/ih-issues-selected.json` contains 0–{{max_issues}} issues."""
[[steps]]
id = "sling-workers"
title = "Sling one worker polecat per selected issue"
needs = ["select-and-rank"]
description = """
For each selected issue, create a work bead then sling it with the mol-issue-hunter-work formula.
Note: `gt sling <formula> <rig>` is broken (see gastownhall/gastown#3917). Workaround:
create a rig-prefixed bead first, then `gt sling <bead> <rig> --formula mol-issue-hunter-work`.
```bash
python3 - <<'PYEOF'
import json, subprocess, sys, os
issues = json.load(open('/tmp/ih-issues-selected.json'))
if not issues:
print("No issues to sling. Exiting cleanly.")
sys.exit(0)
RIG_PATH = subprocess.run(
["gt", "rig", "path", "{{rig}}"], capture_output=True, text=True
).stdout.strip()
for issue in issues:
num = issue['number']
title = issue['title'][:50].replace('"', '\\"')
print(f"Slinging worker for issue #{num}: {title}")
# Step 1: create a work bead in the rig (needs rig prefix)
bead_result = subprocess.run(
["bd", "create",
f"issue-hunter: Fix upstream #{num}: {title}",
"--type", "task"],
capture_output=True, text=True, cwd=RIG_PATH
)
if bead_result.returncode != 0:
print(f" ERROR creating bead for #{num}: {bead_result.stderr.strip()}")
continue
# Extract bead ID from output (e.g. "✓ Created issue: gt-abc — ...")
bead_id = None
for line in bead_result.stdout.splitlines():
if "Created issue:" in line:
bead_id = line.split("Created issue:")[1].strip().split()[0]
break
if not bead_id:
print(f" ERROR: could not parse bead ID from: {bead_result.stdout.strip()}")
continue
print(f" Bead: {bead_id}")
# Step 2: sling the bead with Phase 1 formula (deterministic 3-phase dispatch)
sling_result = subprocess.run([
"gt", "sling", bead_id, "{{rig}}",
"--formula", "mol-issue-hunter-work-p1",
"--var", f"github_issue={num}",
"--var", "upstream_repo={{upstream_repo}}",
"--var", "fork_repo={{fork_repo}}",
"--args", f"Contribute a fix for upstream issue #{num}: {title}"
], capture_output=True, text=True)
if sling_result.returncode != 0:
print(f" ERROR slinging #{num}: {sling_result.stderr.strip()}")
else:
print(f" OK: {sling_result.stdout.strip()}")
PYEOF
```
**Exit criteria:** One bead + `gt sling` call per selected issue. Worker polecats are spawning."""
[[steps]]
id = "exit"
title = "Dispatcher exits — workers take it from here"
needs = ["sling-workers"]
description = """
The dispatcher's job is done. Clean up temp files and exit.
```bash
rm -f /tmp/ih-issues-*.json /tmp/ih-prs-*.json /tmp/ih-claimed-numbers.txt
gt done --status COMPLETED --merge=local
```
Workers are running in parallel in the gastown rig. Check `gt rig list` to monitor them."""
[vars]
[vars.upstream_repo]
description = "Upstream GitHub repo (read-only source of issues)"
default = "gastownhall/gastown"
[vars.fork_repo]
description = "Your fork to push branches to"
default = "esciara/gastown"
[vars.rig]
description = "Gas Town rig name"
default = "gastown"
[vars.max_issues]
description = "Maximum number of issues to sling in one run"
default = "5"
description = """
Phase 1 of 3 — Investigate only.
Reads the issue, assesses feasibility, then deterministically dispatches Phase 2
via a bash command (not an advisory instruction). Fresh context for Phase 2 is
guaranteed because the dispatch creates a new polecat session.
If blocked: posts a GitHub comment and exits DEFERRED.
If clear: saves assessment to bead notes, dispatches mol-issue-hunter-work-p2, exits.
## Variables
| Variable | Description |
|---------------|------------------------------------------|
| github_issue | GitHub issue number to work on |
| upstream_repo | Upstream repo (default: gastownhall/gastown) |
| fork_repo | Fork repo (default: esciara/gastown) |"""
formula = "mol-issue-hunter-work-p1"
version = 1
[[steps]]
id = "load-issue"
title = "Load issue and all comments"
acceptance = "Issue body and all comments read; clear understanding of the problem"
description = """
Read the full issue body and all comments. Build a complete picture before
deciding anything.
```bash
gh issue view {{github_issue}} \
--repo {{upstream_repo}} \
--json number,title,body,labels,author,comments,state \
> /tmp/ih-work-issue-{{github_issue}}.json
gh issue view {{github_issue}} --repo {{upstream_repo}} --comments
```
Read the output carefully:
- What is the exact problem?
- What is the expected behaviour?
- Are there reproduction steps?
- Are there open questions in the comments?
- Is there a suggested fix or design direction?"""
[[steps]]
id = "assess-feasibility"
title = "Assess whether enough information exists to implement"
needs = ["load-issue"]
acceptance = "Decision documented in bead notes: CLEAR or BLOCKED with reason"
description = """
Decide: can this issue be implemented now, or does it need human input?
**Blockers (require human input):**
- Missing reproduction steps for a bug
- Design decision not made (multiple valid approaches, no consensus)
- Issue references unavailable external context
- Conflicting requirements in issue vs. comments
- Discussion/RFC with no resolution
**NOT a blocker (proceed):**
- Missing tests (you write them)
- Vague wording you can reasonably interpret
- No suggested implementation (you design it)
Document your decision:
```bash
bd update {{issue}} --notes "Assessment: [CLEAR|BLOCKED] — <reason>"
```
Then go to `notify-blocked` if BLOCKED, or `dispatch-phase-2` if CLEAR."""
[[steps]]
id = "notify-blocked"
title = "Post clarification comment and exit DEFERRED"
needs = ["assess-feasibility"]
acceptance = "GitHub comment posted, Telegram notification sent, polecat exited"
description = """
Only run this step if the issue is BLOCKED.
**1. Post GitHub comment:**
```bash
QUESTION="<the specific question or missing info>"
gh issue comment {{github_issue}} \
--repo {{upstream_repo}} \
--body "$(cat <<'BODY'
Hi! I was looking at this issue to contribute a fix.
Before I proceed, I need a bit more information:
**Question:** ${QUESTION}
Once this is clarified I can pick this up in the next automated run.
BODY
)"
```
**2. Send Telegram notification:**
```bash
: "${TELEGRAM_BOT_TOKEN:=$(security find-generic-password -a "$USER" -s telegram-bot-token -w 2>/dev/null)}"
: "${TELEGRAM_CHAT_ID:=$(security find-generic-password -a "$USER" -s telegram-chat-id -w 2>/dev/null)}"
if [ -z "${TELEGRAM_BOT_TOKEN:-}" ] || [ -z "${TELEGRAM_CHAT_ID:-}" ]; then
echo "WARNING: Telegram credentials not set — skipping"
else
MSG="🔍 Issue Hunter needs input%0A%0AIssue: #{{github_issue}}%0Ahttps://github.com/{{upstream_repo}}/issues/{{github_issue}}"
curl -s -X POST \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}&text=${MSG}&parse_mode=HTML" \
> /dev/null
fi
```
**3. Exit:**
```bash
rm -f /tmp/ih-work-issue-{{github_issue}}.json
gt done --status DEFERRED --merge=local
```"""
[[steps]]
id = "dispatch-phase-2"
title = "Dispatch Phase 2 and exit"
needs = ["assess-feasibility"]
acceptance = "Phase 2 polecat slung, this polecat exited cleanly"
description = """
Only run this step if the issue assessment was CLEAR.
Dispatch Phase 2 as a new polecat session (guarantees fresh context for source reading).
```bash
TITLE=$(jq -r '.title' /tmp/ih-work-issue-{{github_issue}}.json | cut -c1-60)
# Create Phase 2 bead in the rig (needs rig-prefixed bead)
P2_BEAD_OUTPUT=$(bd create \
"issue-hunter p2: #{{github_issue}}: ${TITLE}" \
--type task 2>&1)
echo "$P2_BEAD_OUTPUT"
P2_BEAD=$(echo "$P2_BEAD_OUTPUT" \
| grep "Created issue:" \
| sed 's/.*Created issue: //' \
| awk '{print $1}')
if [ -z "$P2_BEAD" ]; then
echo "ERROR: could not parse Phase 2 bead ID"
exit 1
fi
echo "Phase 2 bead: $P2_BEAD"
# Sling Phase 2 to a fresh polecat session
gt sling "$P2_BEAD" {{rig}} \
--formula mol-issue-hunter-work-p2 \
--var github_issue={{github_issue}} \
--var upstream_repo={{upstream_repo}} \
--var fork_repo={{fork_repo}} \
--var p1_bead={{issue}} \
--args "Phase 2: implement fix for upstream #{{github_issue}}: ${TITLE}"
echo "Phase 2 slung to $P2_BEAD — fresh session will start shortly"
# Clean up and exit this session
rm -f /tmp/ih-work-issue-{{github_issue}}.json
gt done --status COMPLETED --merge=local
```"""
[vars]
[vars.github_issue]
description = "GitHub issue number to work on"
required = true
[vars.upstream_repo]
description = "Upstream GitHub repo"
default = "gastownhall/gastown"
[vars.fork_repo]
description = "Your fork to push branches to"
default = "esciara/gastown"
[vars.rig]
description = "Gas Town rig to sling Phase 2 into"
default = "gastown"
description = """
Phase 2 of 3 — Setup + Implement.
Starts in a fresh session (dispatched by Phase 1). Reads the issue again
(Phase 1 may have cleaned up /tmp), sets up the branch, implements the fix,
then deterministically dispatches Phase 3 via bash before exiting.
Phase 3 gets fresh context for /review --branch, isolated from the
source-file reading done here.
## Variables
| Variable | Description |
|---------------|------------------------------------------|
| github_issue | GitHub issue number to work on |
| upstream_repo | Upstream repo (default: gastownhall/gastown) |
| fork_repo | Fork repo (default: esciara/gastown) |
| p1_bead | Phase 1 bead ID (for reading assessment notes) |
| rig | Gas Town rig name (default: gastown) |"""
formula = "mol-issue-hunter-work-p2"
version = 1
[[steps]]
id = "load-context"
title = "Load issue context and Phase 1 assessment"
acceptance = "Issue data in /tmp, assessment notes read from Phase 1 bead"
description = """
Re-fetch issue data (Phase 1 cleaned up /tmp) and read the Phase 1 assessment.
```bash
# Re-fetch issue
gh issue view {{github_issue}} \
--repo {{upstream_repo}} \
--json number,title,body,labels,author,comments,state \
> /tmp/ih-work-issue-{{github_issue}}.json
gh issue view {{github_issue}} --repo {{upstream_repo}} --comments
# Read Phase 1 assessment notes (if p1_bead is set)
if [ -n "{{p1_bead}}" ]; then
echo "=== Phase 1 assessment ==="
bd show {{p1_bead}} --format json 2>/dev/null | jq -r '.notes // "(no notes)"'
fi
```"""
[[steps]]
id = "setup-fork-branch"
title = "Sync fork with upstream and create feature branch"
needs = ["load-context"]
acceptance = "On a clean branch based on upstream/main, project dependencies installed"
description = """
**1. Add upstream remote if not already present:**
```bash
git remote -v | grep upstream || \
git remote add upstream https://github.com/{{upstream_repo}}.git
```
**2. Fetch upstream and sync main:**
```bash
git fetch upstream
git fetch origin
git checkout main
git merge upstream/main --ff-only
git push origin main
```
If `--ff-only` fails (diverged history), investigate before forcing.
**3. Create feature branch:**
```bash
SLUG=$(jq -r '.title' /tmp/ih-work-issue-{{github_issue}}.json \
| tr '[:upper:]' '[:lower:]' \
| sed 's/[^a-z0-9]/-/g' \
| sed 's/--*/-/g' \
| cut -c1-40 \
| sed 's/-$//')
BRANCH="issue-hunter/{{github_issue}}-${SLUG}"
git checkout -b "${BRANCH}" upstream/main
echo "Branch: ${BRANCH}"
```
**4. Project setup:**
```bash
go mod download 2>/dev/null || true
```"""
[[steps]]
id = "implement"
title = "Implement the fix"
needs = ["setup-fork-branch"]
acceptance = "Implementation complete, all changes committed, git status clean"
description = """
Implement the fix for issue #{{github_issue}}.
**Before writing code:**
- Read the relevant source files thoroughly
- Understand existing patterns, naming conventions, test style
- Check contribution guidelines:
```bash
cat CONTRIBUTING.md 2>/dev/null || cat docs/CONTRIBUTING.md 2>/dev/null || true
```
- Check existing tests:
```bash
go test ./... 2>&1 | tail -5
```
**Principles:**
- Follow existing code style exactly
- Write tests for new behaviour
- Keep changes scoped to the issue
- Commit atomically:
```bash
git add <files>
git commit -m "<type>: <description>"
```
**If stuck for >15 min:**
```bash
gh issue comment {{github_issue}} --repo {{upstream_repo}} \
--body "Hit an unexpected complexity — <describe>. Will revisit."
: "${TELEGRAM_BOT_TOKEN:=$(security find-generic-password -a "$USER" -s telegram-bot-token -w 2>/dev/null)}"
: "${TELEGRAM_CHAT_ID:=$(security find-generic-password -a "$USER" -s telegram-chat-id -w 2>/dev/null)}"
MSG="⚠️ Issue Hunter stuck on #{{github_issue}}%0Ahttps://github.com/{{upstream_repo}}/issues/{{github_issue}}"
[ -n "${TELEGRAM_BOT_TOKEN:-}" ] && curl -s -X POST \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}&text=${MSG}" > /dev/null
gt done --status DEFERRED --merge=local
```"""
[[steps]]
id = "dispatch-phase-3"
title = "Dispatch Phase 3 and exit"
needs = ["implement"]
acceptance = "Phase 3 polecat slung with branch name, this polecat exited cleanly"
description = """
Implementation is committed. Dispatch Phase 3 as a new polecat session —
it gets fresh context for /review --branch, without this session's source-reading baggage.
```bash
BRANCH=$(git branch --show-current)
TITLE=$(jq -r '.title' /tmp/ih-work-issue-{{github_issue}}.json | cut -c1-60)
echo "Dispatching Phase 3 for branch: ${BRANCH}"
# Create Phase 3 bead in the rig
P3_BEAD_OUTPUT=$(bd create \
"issue-hunter p3: #{{github_issue}}: ${TITLE}" \
--type task 2>&1)
echo "$P3_BEAD_OUTPUT"
P3_BEAD=$(echo "$P3_BEAD_OUTPUT" \
| grep "Created issue:" \
| sed 's/.*Created issue: //' \
| awk '{print $1}')
if [ -z "$P3_BEAD" ]; then
echo "ERROR: could not parse Phase 3 bead ID"
exit 1
fi
echo "Phase 3 bead: $P3_BEAD"
# Sling Phase 3 with the branch name passed as a var
gt sling "$P3_BEAD" {{rig}} \
--formula mol-issue-hunter-work-p3 \
--var github_issue={{github_issue}} \
--var upstream_repo={{upstream_repo}} \
--var fork_repo={{fork_repo}} \
--var branch="${BRANCH}" \
--args "Phase 3: review and ship fix for upstream #{{github_issue}}: ${TITLE}"
echo "Phase 3 slung to $P3_BEAD — fresh session will start shortly"
# Clean up and exit
rm -f /tmp/ih-work-issue-{{github_issue}}.json
gt done --status COMPLETED --merge=local
```"""
[vars]
[vars.github_issue]
description = "GitHub issue number to work on"
required = true
[vars.upstream_repo]
description = "Upstream GitHub repo"
default = "gastownhall/gastown"
[vars.fork_repo]
description = "Your fork to push branches to"
default = "esciara/gastown"
[vars.p1_bead]
description = "Phase 1 bead ID (for reading assessment notes)"
default = ""
[vars.rig]
description = "Gas Town rig to sling Phase 3 into"
default = "gastown"
description = """
Phase 3 of 3 — Quality + Ship.
Starts in a fresh session (dispatched by Phase 2). Receives the branch name
as a var so it doesn't need to read source files — it only needs the diff.
This isolation is the whole point: /review --branch runs with a clean context.
## Variables
| Variable | Description |
|---------------|------------------------------------------|
| github_issue | GitHub issue number to work on |
| upstream_repo | Upstream repo (default: gastownhall/gastown) |
| fork_repo | Fork repo (default: esciara/gastown) |
| branch | Feature branch to review and ship |"""
formula = "mol-issue-hunter-work-p3"
version = 1
[[steps]]
id = "checkout-branch"
title = "Checkout the implementation branch"
acceptance = "On the correct feature branch, upstream remote present"
description = """
```bash
echo "Branch to review: {{branch}}"
git remote -v | grep upstream || \
git remote add upstream https://github.com/{{upstream_repo}}.git
git fetch upstream
git fetch origin
git checkout {{branch}} 2>/dev/null || \
git checkout -b {{branch}} origin/{{branch}}
git log --oneline upstream/main..HEAD
```"""
[[steps]]
id = "build-and-review"
title = "Build, test, lint, self-review"
needs = ["checkout-branch"]
acceptance = "Build passes, tests pass, self-review grade ≥ B"
description = """
**1. Build:**
```bash
go build ./...
```
**2. Test changed packages:**
```bash
PKGS=$(git diff upstream/main...HEAD --name-only \
| grep '\\.go$' \
| xargs -I{} dirname {} \
| sort -u \
| sed 's|^|./|' \
| tr '\n' ' ')
go test ${PKGS} -timeout 2m
```
**3. Lint:**
```bash
golangci-lint run ./... 2>/dev/null || go vet ./...
```
**4. Self-review:**
```bash
/review --branch
```
Fix any CRITICAL or MAJOR findings. Grade must be B or better.
**5. Verify clean diff:**
```bash
git diff --stat upstream/main...HEAD
```"""
[[steps]]
id = "push-and-pr"
title = "Push branch to fork and open PR to upstream"
needs = ["build-and-review"]
acceptance = "PR is open on gastownhall/gastown, URL captured"
description = """
**1. Final rebase onto upstream/main:**
```bash
git fetch upstream
git rebase upstream/main
```
**2. Push to fork:**
```bash
git push origin "{{branch}}" --force-with-lease
```
**3. Fetch issue for PR description:**
```bash
gh issue view {{github_issue}} --repo {{upstream_repo}} \
--json number,title,body,labels,author,comments,state \
> /tmp/ih-work-issue-{{github_issue}}.json
gh issue view {{github_issue}} --repo {{upstream_repo}}
```
**4. Open PR:**
```bash
TITLE=$(jq -r '.title' /tmp/ih-work-issue-{{github_issue}}.json)
gh pr create \
--repo {{upstream_repo}} \
--head "esciara:{{branch}}" \
--base main \
--title "fix: ${TITLE}" \
--body "$(cat <<'PRBODY'
## Summary
<1-3 bullet points describing what the change does>
## Changes
<list of key files changed and why>
## Testing
- [ ] `go build ./...` passes
- [ ] Relevant tests pass
- [ ] `go vet ./...` passes
Closes #{{github_issue}}
PRBODY
)"
```
Substitute placeholders with actual content.
**5. Capture PR URL:**
```bash
gh pr view --repo {{upstream_repo}} "{{branch}}" --json url -q .url
```"""
[[steps]]
id = "notify-success"
title = "Send Telegram success notification"
needs = ["push-and-pr"]
acceptance = "Notification sent (or skipped with warning), PR URL logged"
description = """
```bash
PR_URL=$(gh pr view --repo {{upstream_repo}} "{{branch}}" --json url -q .url 2>/dev/null || echo "unknown")
TITLE=$(jq -r '.title' /tmp/ih-work-issue-{{github_issue}}.json 2>/dev/null || echo "unknown")
: "${TELEGRAM_BOT_TOKEN:=$(security find-generic-password -a "$USER" -s telegram-bot-token -w 2>/dev/null)}"
: "${TELEGRAM_CHAT_ID:=$(security find-generic-password -a "$USER" -s telegram-chat-id -w 2>/dev/null)}"
if [ -z "${TELEGRAM_BOT_TOKEN:-}" ] || [ -z "${TELEGRAM_CHAT_ID:-}" ]; then
echo "Skipping Telegram notification (credentials not found)"
echo "PR URL: ${PR_URL}"
else
MSG="✅ PR opened for issue #{{github_issue}}%0A%0A${TITLE}%0A%0A${PR_URL}"
curl -s -X POST \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}&text=${MSG}" \
> /dev/null
echo "Telegram notification sent: ${PR_URL}"
fi
```"""
[[steps]]
id = "exit"
title = "Clean up and exit"
needs = ["notify-success"]
acceptance = "Polecat exits cleanly, PR is live"
description = """
```bash
rm -f /tmp/ih-work-issue-{{github_issue}}.json
gt done --status COMPLETED --merge=local
```"""
[vars]
[vars.github_issue]
description = "GitHub issue number to work on"
required = true
[vars.branch]
description = "Feature branch name (passed from Phase 2)"
required = true
[vars.upstream_repo]
description = "Upstream GitHub repo"
default = "gastownhall/gastown"
[vars.fork_repo]
description = "Your fork to push branches to"
default = "esciara/gastown"
description = """
Contribute a fix for a single gastownhall/gastown GitHub issue.
This formula runs in three phases, each in a fresh session:
**Phase 1 — Investigate** (read-only): load-issue → assess-feasibility → handoff
**Phase 2 — Setup + Implement** (heavy file-reading): setup-fork-branch → implement → handoff
**Phase 3 — Quality + Ship**: build-and-review → push-and-pr → notify-success → exit
A `resume-check` step at the start routes to the right phase based on bead notes.
The session that reads source files is NEVER the same session that runs /review --branch.
## Branch naming
issue-hunter/<number>-<short-slug>
e.g. issue-hunter/3874-duplicate-commands-provisioned
## PR target
Base: gastownhall/gastown default branch
Head: esciara/gastown:<branch>
## Notification
Reads TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID from env vars, falling back
to macOS Keychain entries "telegram-bot-token" and "telegram-chat-id".
If neither is available, notification is skipped (warning logged).
## Variables
| Variable | Description |
|---------------|------------------------------------------|
| github_issue | GitHub issue number to work on |
| upstream_repo | Upstream repo (default: gastownhall/gastown) |
| fork_repo | Fork repo (default: esciara/gastown) |"""
formula = "mol-issue-hunter-work"
version = 2
[[steps]]
id = "resume-check"
title = "Check resume state — route to the correct phase"
description = """
Always run this step first. Read bead notes to determine which phase to execute.
```bash
bd show {{issue}} --format json 2>/dev/null | jq -r '.notes // ""'
```
**Routing rules (read notes carefully):**
- If notes contain `PHASE_2_COMPLETE` → **skip directly to `build-and-review` (Phase 3)**
- If notes contain `PHASE_1_COMPLETE` → **skip directly to `setup-fork-branch` (Phase 2)**
- Otherwise → **start from `load-issue` (Phase 1, fresh run)**
Print your routing decision clearly so it's visible in the session log.
**Exit criteria:** You know which phase to execute and have announced it."""
[[steps]]
id = "load-issue"
title = "Load issue and all comments [Phase 1]"
needs = ["resume-check"]
description = """
**SKIP this step if resuming from Phase 2 or Phase 3** (notes contain PHASE_1_COMPLETE or PHASE_2_COMPLETE).
Read the full issue body and all comments. Build a complete picture before
deciding anything.
```bash
# Full issue detail
gh issue view {{github_issue}} \
--repo {{upstream_repo}} \
--json number,title,body,labels,author,comments,state \
> /tmp/ih-work-issue-{{github_issue}}.json
# Human-readable view for reading
gh issue view {{github_issue}} --repo {{upstream_repo}} --comments
```
Read the output carefully:
- What is the exact problem?
- What is the expected behaviour?
- Are there reproduction steps?
- Are there open questions in the comments that haven't been answered?
- Is there a suggested fix or design direction?
**Exit criteria:** You have a clear understanding of the issue's requirements."""
[[steps]]
id = "assess-feasibility"
title = "Assess whether enough information exists to implement [Phase 1]"
needs = ["load-issue"]
description = """
**SKIP this step if resuming from Phase 2 or Phase 3** (notes contain PHASE_1_COMPLETE or PHASE_2_COMPLETE).
Decide: can this issue be implemented now, or does it need human input?
**Blockers that require human input:**
- Missing reproduction steps for a bug
- Design decision not yet made (multiple valid approaches, no consensus in comments)
- Issue references external context not available (private system, customer data)
- Conflicting requirements in issue vs. comments
- Issue is marked as a discussion/RFC with no resolution
**NOT a blocker (proceed):**
- Missing tests (you write them)
- Vague wording that you can reasonably interpret from context
- No suggested implementation (you design it)
- Issue is old but still valid
**Decision:**
- If blocked → proceed to `notify-blocked` step
- If clear → proceed to `phase-1-handoff` step
Document your decision:
```bash
# Note what you decided and why (survives session death)
bd update {{issue}} --notes "Assessment: [CLEAR|BLOCKED] — <reason>"
```
**Exit criteria:** Clear decision documented in bead notes."""
[[steps]]
id = "notify-blocked"
title = "Post clarification comment and notify via Telegram — then exit [Phase 1]"
needs = ["assess-feasibility"]
description = """
Only execute this step if the issue is BLOCKED on human input.
If the issue is clear, go to `phase-1-handoff` instead.
**1. Post a GitHub comment asking the specific question:**
```bash
QUESTION="<the specific question or missing info>"
ISSUE_URL="https://github.com/{{upstream_repo}}/issues/{{github_issue}}"
gh issue comment {{github_issue}} \
--repo {{upstream_repo}} \
--body "$(cat <<'BODY'
Hi! I was looking at this issue to contribute a fix.
Before I proceed, I need a bit more information:
**Question:** ${QUESTION}
Once this is clarified I can pick this up in the next automated run.
BODY
)"
```
**2. Send Telegram notification:**
```bash
: "${TELEGRAM_BOT_TOKEN:=$(security find-generic-password -a "$USER" -s telegram-bot-token -w 2>/dev/null)}"
: "${TELEGRAM_CHAT_ID:=$(security find-generic-password -a "$USER" -s telegram-chat-id -w 2>/dev/null)}"
if [ -z "${TELEGRAM_BOT_TOKEN:-}" ] || [ -z "${TELEGRAM_CHAT_ID:-}" ]; then
echo "WARNING: TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID not set — skipping notification"
else
MSG="🔍 Issue Hunter needs your input%0A%0AIssue: #{{github_issue}} — $(jq -r .title /tmp/ih-work-issue-{{github_issue}}.json)%0ARepo: {{upstream_repo}}%0A%0AQuestion posted as a comment. Please clarify so the next run can proceed.%0A%0Ahttps://github.com/{{upstream_repo}}/issues/{{github_issue}}"
curl -s -X POST \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}&text=${MSG}&parse_mode=HTML" \
> /dev/null
echo "Telegram notification sent."
fi
```
**3. Exit cleanly:**
```bash
rm -f /tmp/ih-work-issue-{{github_issue}}.json
gt done --status DEFERRED --merge=local
```
**Exit criteria:** Comment posted on GitHub, Telegram notification sent (if configured), polecat exits."""
[[steps]]
id = "phase-1-handoff"
title = "Save Phase 1 state and hand off to fresh session [Phase 1]"
needs = ["assess-feasibility"]
description = """
Only run this if the issue assessment was CLEAR (not blocked).
**SKIP if resuming from Phase 2 or Phase 3.**
Phase 1 is complete. Save the phase marker to bead notes and hand off to a fresh
session so Phase 2 starts with a clean context budget for reading source files.
**1. Save phase marker:**
```bash
# Read current notes (to preserve the assessment)
CURRENT_NOTES=$(bd show {{issue}} --format json 2>/dev/null | jq -r '.notes // ""')
bd update {{issue}} --notes "PHASE_1_COMPLETE
${CURRENT_NOTES}"
```
**2. Hand off to fresh session:**
```
/handoff
```
The new session will pick up this bead from the hook, run `resume-check`, see
`PHASE_1_COMPLETE` in notes, and proceed directly to `setup-fork-branch`.
**Exit criteria:** Phase marker saved to bead notes, /handoff called."""
[[steps]]
id = "setup-fork-branch"
title = "Sync fork with upstream and create feature branch [Phase 2]"
needs = ["resume-check"]
description = """
**SKIP if resuming from Phase 3** (notes contain PHASE_2_COMPLETE).
Only execute this step if the issue assessment was CLEAR (confirmed by PHASE_1_COMPLETE in notes).
**1. Add upstream remote if not already present:**
```bash
git remote -v | grep upstream || \
git remote add upstream https://github.com/{{upstream_repo}}.git
```
**2. Fetch upstream and sync main:**
```bash
git fetch upstream
git fetch origin
git checkout main
git merge upstream/main --ff-only
git push origin main
```
If `--ff-only` fails (diverged history), investigate before forcing.
**3. Create feature branch:**
```bash
# Build a short slug from the issue title
ISSUE_JSON=$(gh issue view {{github_issue}} \
--repo {{upstream_repo}} \
--json number,title,body,labels,author,comments,state)
echo "${ISSUE_JSON}" > /tmp/ih-work-issue-{{github_issue}}.json
SLUG=$(echo "${ISSUE_JSON}" | jq -r '.title' \
| tr '[:upper:]' '[:lower:]' \
| sed 's/[^a-z0-9]/-/g' \
| sed 's/--*/-/g' \
| cut -c1-40 \
| sed 's/-$//')
BRANCH="issue-hunter/{{github_issue}}-${SLUG}"
git checkout -b "${BRANCH}" upstream/main
echo "Branch: ${BRANCH}"
```
**4. Run project setup:**
```bash
go mod download 2>/dev/null || true
```
**Exit criteria:** On a clean branch based on upstream/main, ready to implement."""
[[steps]]
id = "implement"
title = "Implement the fix [Phase 2]"
needs = ["setup-fork-branch"]
description = """
**SKIP if resuming from Phase 3** (notes contain PHASE_2_COMPLETE).
Implement the fix for issue #{{github_issue}}.
**Before writing code:**
- Read the relevant source files thoroughly
- Understand the existing patterns, naming conventions, test style
- Check the CONTRIBUTING.md or docs/ for contribution guidelines:
```bash
cat CONTRIBUTING.md 2>/dev/null || cat docs/CONTRIBUTING.md 2>/dev/null || true
```
- Check if there are existing tests to guide you:
```bash
go test ./... 2>&1 | tail -5
```
**Working principles:**
- Follow the existing code style exactly (Go conventions, file layout)
- Write tests for new behaviour — look at existing test files for the pattern
- Keep changes scoped to the issue — no scope creep
- Make atomic, logical commits:
```bash
git add <files>
git commit -m "<type>: <description>"
```
Types: feat, fix, refactor, test, docs, chore
**Persist findings (session survival):**
```bash
bd update {{issue}} --notes "Progress: <what you've done, what's left>"
```
**If stuck for >15 min:**
The issue may be harder than assessed. Post a comment and notify:
```bash
gh issue comment {{github_issue}} --repo {{upstream_repo}} \
--body "Hit an unexpected complexity — <describe>. Will revisit."
: "${TELEGRAM_BOT_TOKEN:=$(security find-generic-password -a "$USER" -s telegram-bot-token -w 2>/dev/null)}"
: "${TELEGRAM_CHAT_ID:=$(security find-generic-password -a "$USER" -s telegram-chat-id -w 2>/dev/null)}"
MSG="⚠️ Issue Hunter stuck on #{{github_issue}}%0A%0A<describe blocker>%0Ahttps://github.com/{{upstream_repo}}/issues/{{github_issue}}"
[ -n "${TELEGRAM_BOT_TOKEN:-}" ] && curl -s -X POST \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}&text=${MSG}" > /dev/null
gt done --status DEFERRED --merge=local
```
**Exit criteria:** Implementation complete, all changes committed, `git status` clean."""
[[steps]]
id = "phase-2-handoff"
title = "Save Phase 2 state and hand off to fresh session [Phase 2]"
needs = ["implement"]
description = """
**SKIP if resuming from Phase 3** (notes contain PHASE_2_COMPLETE).
Implementation is done. Save the phase marker and hand off to a fresh session.
The fresh session will run /review --branch without the source-file reading
context that would push us over the limit.
**1. Capture branch name for Phase 3:**
```bash
BRANCH=$(git branch --show-current)
echo "Branch to review: ${BRANCH}"
```
**2. Save phase marker:**
```bash
CURRENT_NOTES=$(bd show {{issue}} --format json 2>/dev/null | jq -r '.notes // ""')
bd update {{issue}} --notes "PHASE_2_COMPLETE
branch=${BRANCH}
${CURRENT_NOTES}"
```
**3. Hand off to fresh session:**
```
/handoff
```
The new session will pick up this bead from the hook, run `resume-check`, see
`PHASE_2_COMPLETE` in notes, and proceed directly to `build-and-review`.
**Exit criteria:** Phase marker (with branch name) saved to bead notes, /handoff called."""
[[steps]]
id = "build-and-review"
title = "Build, test, lint, self-review [Phase 3]"
needs = ["resume-check"]
description = """
Phase 3 begins here. Read the branch name from bead notes:
```bash
BRANCH=$(bd show {{issue}} --format json 2>/dev/null | jq -r '.notes // ""' \
| grep '^branch=' | cut -d= -f2-)
echo "Reviewing branch: ${BRANCH}"
git checkout "${BRANCH}" 2>/dev/null || true
```
**1. Build:**
```bash
go build ./...
```
**2. Run tests relevant to changed packages:**
```bash
# Find changed packages
PKGS=$(git diff upstream/main...HEAD --name-only \
| grep '\\.go$' \
| xargs -I{} dirname {} \
| sort -u \
| sed 's|^|./|' \
| tr '\n' ' ')
go test ${PKGS} -timeout 2m
```
**3. Lint:**
```bash
golangci-lint run ./... 2>/dev/null || go vet ./...
```
**4. Self-review:**
```bash
/review --branch
```
Fix any CRITICAL or MAJOR findings. Grade must be B or better.
**5. Verify diff is clean (no unintended files):**
```bash
git diff --stat upstream/main...HEAD
```
**Exit criteria:** Build passes, tests pass, self-review grade ≥ B."""
[[steps]]
id = "push-and-pr"
title = "Push branch to fork and open PR to upstream [Phase 3]"
needs = ["build-and-review"]
description = """
Push to your fork and open a PR targeting gastownhall/gastown.
**1. Final rebase onto upstream/main:**
```bash
git fetch upstream
git rebase upstream/main
```
**2. Push to fork:**
```bash
BRANCH=$(git branch --show-current)
git push origin "${BRANCH}" --force-with-lease
```
**3. Re-fetch issue data for PR description (Phase 3 starts fresh):**
```bash
gh issue view {{github_issue}} --repo {{upstream_repo}} \
--json number,title,body,labels,author,comments,state \
> /tmp/ih-work-issue-{{github_issue}}.json
gh issue view {{github_issue}} --repo {{upstream_repo}}
```
**4. Build PR description:**
The PR body must follow the upstream template (## Summary, ## Changes, ## Testing):
```bash
BRANCH=$(git branch --show-current)
TITLE=$(jq -r '.title' /tmp/ih-work-issue-{{github_issue}}.json)
gh pr create \
--repo {{upstream_repo}} \
--head "esciara:${BRANCH}" \
--base main \
--title "fix: ${TITLE}" \
--body "$(cat <<'PRBODY'
## Summary
<1-3 bullet points describing what the change does>
## Changes
<list of key files changed and why>
## Testing
- [ ] `go build ./...` passes
- [ ] Relevant tests pass
- [ ] `go vet ./...` passes
Closes #{{github_issue}}
PRBODY
)"
```
Substitute the template placeholders with actual content based on your implementation.
**5. Capture the PR URL:**
```bash
gh pr view --repo {{upstream_repo}} "${BRANCH}" --json url -q .url
```
**Exit criteria:** PR is open on gastownhall/gastown, URL captured."""
[[steps]]
id = "notify-success"
title = "Send Telegram success notification [Phase 3]"
needs = ["push-and-pr"]
description = """
Notify yourself that the PR is open.
```bash
BRANCH=$(git branch --show-current)
PR_URL=$(gh pr view --repo {{upstream_repo}} "${BRANCH}" --json url -q .url 2>/dev/null || echo "unknown")
TITLE=$(jq -r '.title' /tmp/ih-work-issue-{{github_issue}}.json 2>/dev/null || echo "unknown")
: "${TELEGRAM_BOT_TOKEN:=$(security find-generic-password -a "$USER" -s telegram-bot-token -w 2>/dev/null)}"
: "${TELEGRAM_CHAT_ID:=$(security find-generic-password -a "$USER" -s telegram-chat-id -w 2>/dev/null)}"
if [ -z "${TELEGRAM_BOT_TOKEN:-}" ] || [ -z "${TELEGRAM_CHAT_ID:-}" ]; then
echo "Skipping Telegram notification (credentials not found in env or Keychain)"
echo "PR URL: ${PR_URL}"
else
MSG="✅ PR opened for issue #{{github_issue}}%0A%0A${TITLE}%0A%0A${PR_URL}"
curl -s -X POST \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}&text=${MSG}" \
> /dev/null
echo "Telegram notification sent: ${PR_URL}"
fi
```
**Exit criteria:** Notification sent (or skipped with warning). PR URL logged."""
[[steps]]
id = "exit"
title = "Clean up and exit [Phase 3]"
needs = ["notify-success"]
description = """
Clean up and exit.
```bash
rm -f /tmp/ih-work-issue-{{github_issue}}.json
gt done --status COMPLETED --merge=local
```
**Exit criteria:** Polecat exits cleanly. PR is live on upstream."""
[vars]
[vars.github_issue]
description = "GitHub issue number to work on"
required = true
[vars.upstream_repo]
description = "Upstream GitHub repo"
default = "gastownhall/gastown"
[vars.fork_repo]
description = "Your fork to push branches to"
default = "esciara/gastown"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment