diff --git a/plugins/README.md b/plugins/README.md index cf4a21ecc5..8795748bd0 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -13,6 +13,7 @@ Learn more in the [official plugins documentation](https://docs.claude.com/en/do | Name | Description | Contents | |------|-------------|----------| | [agent-sdk-dev](./agent-sdk-dev/) | Development kit for working with the Claude Agent SDK | **Command:** `/new-sdk-app` - Interactive setup for new Agent SDK projects
**Agents:** `agent-sdk-verifier-py`, `agent-sdk-verifier-ts` - Validate SDK applications against best practices | +| [cc-taskrunner](./cc-taskrunner/) | Autonomous task queue with safety hooks, branch isolation, and automatic PR creation | **Commands:** `/taskrunner`, `/taskrunner-add`, `/taskrunner-list` - Queue and execute tasks in headless Claude Code sessions
**Agent:** `task-executor` - Debug and monitor autonomous task execution | | [claude-opus-4-5-migration](./claude-opus-4-5-migration/) | Migrate code and prompts from Sonnet 4.x and Opus 4.1 to Opus 4.5 | **Skill:** `claude-opus-4-5-migration` - Automated migration of model strings, beta headers, and prompt adjustments | | [code-review](./code-review/) | Automated PR code review using multiple specialized agents with confidence-based scoring to filter false positives | **Command:** `/code-review` - Automated PR review workflow
**Agents:** 5 parallel Sonnet agents for CLAUDE.md compliance, bug detection, historical context, PR history, and code comments | | [commit-commands](./commit-commands/) | Git workflow automation for committing, pushing, and creating pull requests | **Commands:** `/commit`, `/commit-push-pr`, `/clean_gone` - Streamlined git operations | diff --git a/plugins/cc-taskrunner/.claude-plugin/plugin.json b/plugins/cc-taskrunner/.claude-plugin/plugin.json new file mode 100755 index 0000000000..3ea9f7fd55 --- /dev/null +++ b/plugins/cc-taskrunner/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "cc-taskrunner", + "version": "1.0.0", + "description": "Autonomous task queue for Claude Code with safety hooks, branch isolation, and automatic PR creation", + "author": { + "name": "Kurt Overmier", + "email": "admin@stackbilt.dev" + } +} diff --git a/plugins/cc-taskrunner/README.md b/plugins/cc-taskrunner/README.md new file mode 100755 index 0000000000..694d415445 --- /dev/null +++ b/plugins/cc-taskrunner/README.md @@ -0,0 +1,78 @@ +# cc-taskrunner + +Autonomous task queue for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) with safety hooks, branch isolation, and automatic PR creation. + +Queue tasks. Go to sleep. Wake up to PRs. + +## Installation + +```bash +claude plugin:add /path/to/cc-taskrunner +``` + +Or clone from the [cc-taskrunner repository](https://github.com/Stackbilt-dev/cc-taskrunner) and add the `plugin/` directory. + +## Commands + +| Command | Description | +|---------|-------------| +| `/taskrunner` | Run pending tasks from the queue | +| `/taskrunner-add` | Add a task to the queue | +| `/taskrunner-list` | Show all tasks and their status | + +### Quick Start + +``` +> /taskrunner-add Write unit tests for the auth middleware in src/middleware.ts + +Added task a1b2c3d4: Write unit tests for the auth middleware + +> /taskrunner --max 1 +``` + +## Safety Architecture + +Three layers of protection prevent autonomous sessions from causing damage: + +1. **Safety hooks** — Block `AskUserQuestion`, destructive commands (`rm -rf`, `git push --force`, `DROP TABLE`), production deploys, and secret access +2. **CLI constraints** — `--max-turns` caps agentic loops, `--output-format json` enables structured parsing +3. **Mission brief** — Every task gets explicit constraints: no questions, no deploys, no destructive ops, commit work, output completion signal + +Safety hooks are **only active during task execution**, not during interactive Claude Code sessions. + +## Branch Isolation + +Each task runs on its own branch (`auto/{task-id}`). Main is never directly modified. + +- Uncommitted work is stashed before task execution and restored after +- Tasks that produce commits get automatic PRs via `gh` CLI +- Empty branches (no commits) are cleaned up automatically + +## Agents + +The `task-executor` agent helps monitor and debug task execution: + +``` +> "Why did my last task fail?" +``` + +It checks queue status, exit codes, Claude Code process state, and provides debugging guidance. + +## Requirements + +- bash 4+, python3, jq +- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH) +- Optional: `gh` CLI for automatic PR creation + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `CC_QUEUE_FILE` | `./queue.json` | Path to the task queue | +| `CC_POLL_INTERVAL` | `60` | Seconds between polls in loop mode | +| `CC_MAX_TASKS` | `0` | Max tasks per run (0 = unlimited) | +| `CC_MAX_TURNS` | `25` | Default Claude Code turns per task | + +## License + +Apache License 2.0 — Copyright 2026 Stackbilt LLC diff --git a/plugins/cc-taskrunner/agents/task-executor.md b/plugins/cc-taskrunner/agents/task-executor.md new file mode 100755 index 0000000000..4e6f1de60c --- /dev/null +++ b/plugins/cc-taskrunner/agents/task-executor.md @@ -0,0 +1,62 @@ +--- +name: task-executor +description: Use this agent to monitor or debug autonomous task execution. Examples - "Why did my task fail?", "Show me the output from the last task run", "Check if the taskrunner is still running" +model: inherit +color: cyan +tools: ["Bash", "Read", "Grep", "Glob"] +--- + +You are a task execution specialist that helps users monitor, debug, and understand cc-taskrunner execution. + +## Capabilities + +1. **Check running tasks**: Look for active Claude Code processes +2. **Read task output**: Parse JSON output files from completed tasks +3. **Debug failures**: Analyze why tasks failed or got blocked +4. **Queue analysis**: Identify duplicate tasks, conflicting file targets, or sizing issues + +## Debugging a Failed Task + +When a task fails, check these in order: + +1. **Queue status** — Read the queue file to find the failed task and its `result` field: + ```bash + cat ${CC_QUEUE_FILE:-queue.json} | python3 -c "import json,sys; [print(json.dumps(t, indent=2)) for t in json.load(sys.stdin) if t.get('status')=='failed']" + ``` + +2. **Exit code meaning**: + - `0` = success with TASK_COMPLETE signal + - `1` = Claude Code error or crash + - `2` = TASK_BLOCKED reported + - `3` = no completion signal (task may have run out of turns) + +3. **Common failure patterns**: + - **Out of turns**: Task needed more than `max_turns`. Suggest increasing or splitting the task. + - **Blocked by safety hook**: Check if the task tried a destructive operation. Look for "BLOCKED:" in output. + - **TASK_BLOCKED**: Task hit an obstacle it couldn't resolve. Read the reason. + - **Branch conflict**: Task branch already exists with divergent history. Check `git branch -a`. + +4. **PR status** — If a task completed but PR creation failed: + ```bash + gh pr list --head "auto/" --state all + ``` + +## Queue Health Check + +Analyze the queue for issues: +- Tasks targeting the same files (merge conflict risk) +- Tasks with overly broad prompts (scope creep risk) +- Tasks with `max_turns` > 25 (should be split) +- Stale `running` tasks (process may have crashed) + +## Process Check + +Look for running taskrunner processes: +```bash +ps aux | grep taskrunner.sh | grep -v grep +``` + +Look for active Claude Code sessions: +```bash +ps aux | grep "claude -p" | grep -v grep +``` diff --git a/plugins/cc-taskrunner/commands/taskrunner-add.md b/plugins/cc-taskrunner/commands/taskrunner-add.md new file mode 100755 index 0000000000..74e27a2c95 --- /dev/null +++ b/plugins/cc-taskrunner/commands/taskrunner-add.md @@ -0,0 +1,75 @@ +--- +description: Add a task to the autonomous queue +argument-hint: "" +allowed-tools: ["Bash", "Read", "Write", "AskUserQuestion"] +--- + +# cc-taskrunner — Add Task + +Add a task to the cc-taskrunner queue for autonomous execution. + +## Behavior + +**If $ARGUMENTS is provided:** +Use it as the task title and prompt. + +**If $ARGUMENTS is empty:** +Ask the user what task to add using AskUserQuestion with these fields: +- Title: short description of the task +- Prompt: detailed instructions (file paths, constraints, completion criteria) +- Authority: `auto_safe` (branch + PR) or `operator` (run on current branch) +- Max turns: 5-25 (default 25) + +## Steps + +1. Parse or gather task details. + +2. Add the task: + ```bash + bash ${CLAUDE_PLUGIN_ROOT}/taskrunner.sh add "$TASK_TITLE" + ``` + + For more control (custom repo, prompt, authority, turns), write directly to the queue file: + ```bash + python3 -c " + import json, uuid, datetime + task = { + 'id': str(uuid.uuid4()), + 'title': '''$TITLE''', + 'repo': '$REPO', + 'prompt': '''$PROMPT''', + 'authority': '$AUTHORITY', + 'max_turns': $MAX_TURNS, + 'status': 'pending', + 'created_at': datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') + } + queue_file = '${CC_QUEUE_FILE:-queue.json}' + try: + with open(queue_file) as f: + queue = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + queue = [] + queue.append(task) + with open(queue_file, 'w') as f: + json.dump(queue, f, indent=2) + print(f'Added task {task[\"id\"][:8]}: {task[\"title\"]}') + " + ``` + +3. Show confirmation with task ID and suggest running `/taskrunner` to execute. + +## Prompt Writing Tips + +Good prompts are specific: +- Name file paths explicitly: "Read `src/services/quota.ts`" +- State completion criteria: "Tests should pass" +- Include context: each task is a fresh session +- Say what NOT to do: "Do NOT modify the database schema" +- End with: "Commit your work with a descriptive message" + +## Authority Levels + +| Authority | Branch? | PR? | Use for | +|-----------|---------|-----|---------| +| `operator` | No | No | Tasks that run on your current branch | +| `auto_safe` | Yes | Yes | Tests, docs, research, refactors | diff --git a/plugins/cc-taskrunner/commands/taskrunner-list.md b/plugins/cc-taskrunner/commands/taskrunner-list.md new file mode 100755 index 0000000000..d969f38e64 --- /dev/null +++ b/plugins/cc-taskrunner/commands/taskrunner-list.md @@ -0,0 +1,26 @@ +--- +description: Show the current task queue +argument-hint: "" +allowed-tools: ["Bash", "Read"] +--- + +# cc-taskrunner — List Queue + +Show all tasks in the queue with their status. + +## Steps + +1. Run the list command: + ```bash + bash ${CLAUDE_PLUGIN_ROOT}/taskrunner.sh list + ``` + +2. If the queue file doesn't exist or is empty, tell the user and suggest `/taskrunner-add`. + +3. Present the results. Status symbols: + - `○` pending — waiting to run + - `▶` running — currently executing + - `✓` completed — finished successfully + - `✗` failed — task errored or was blocked + +4. If there are pending tasks, suggest `/taskrunner` to execute them. diff --git a/plugins/cc-taskrunner/commands/taskrunner.md b/plugins/cc-taskrunner/commands/taskrunner.md new file mode 100755 index 0000000000..fc13da46ca --- /dev/null +++ b/plugins/cc-taskrunner/commands/taskrunner.md @@ -0,0 +1,70 @@ +--- +description: Run pending tasks from the queue, show status, or execute a specific number of tasks +argument-hint: "[--max N] [--dry-run] [--loop]" +allowed-tools: ["Bash", "Read", "Glob", "Grep", "Agent"] +--- + +# cc-taskrunner — Run Task Queue + +Execute pending tasks from the queue using headless Claude Code sessions with safety hooks, branch isolation, and automatic PR creation. + +## Behavior + +**If $ARGUMENTS is empty or contains only flags:** +Run the taskrunner with the provided flags (or defaults). + +**If $ARGUMENTS contains "--dry-run":** +Preview what would run without executing. + +**If $ARGUMENTS contains "--max N":** +Run at most N tasks. + +**If $ARGUMENTS contains "--loop":** +Run in polling mode (check queue every 60s). + +## Steps + +1. Verify prerequisites exist: + ```bash + command -v claude && command -v jq && command -v python3 + ``` + If any are missing, tell the user what to install. + +2. Check that the taskrunner script exists: + ```bash + ls ${CLAUDE_PLUGIN_ROOT}/taskrunner.sh + ``` + +3. Check current queue status first: + ```bash + bash ${CLAUDE_PLUGIN_ROOT}/taskrunner.sh list + ``` + +4. If queue is empty and no `--loop` flag, inform user and suggest `/taskrunner-add` to queue tasks. + +5. If queue has pending tasks, execute: + ```bash + bash ${CLAUDE_PLUGIN_ROOT}/taskrunner.sh $ARGUMENTS + ``` + +6. After execution completes, show a summary: + - Number of tasks completed vs failed + - Links to any PRs created + - Any tasks that reported TASK_BLOCKED + +## Important Notes + +- Tasks run in headless Claude Code sessions — safety hooks block interactive questions, destructive operations, and production deploys +- Each task gets its own git branch (`auto/{task-id}`) unless it has `operator` authority +- Uncommitted work in the repo is automatically stashed and restored +- Tasks that produce commits get automatic PRs via `gh` CLI +- The `--dry-run` flag previews tasks without executing them + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `CC_QUEUE_FILE` | `./queue.json` | Path to the task queue | +| `CC_POLL_INTERVAL` | `60` | Seconds between polls in loop mode | +| `CC_MAX_TASKS` | `0` | Max tasks per run (0 = unlimited) | +| `CC_MAX_TURNS` | `25` | Default Claude Code turns per task | diff --git a/plugins/cc-taskrunner/safety/block-interactive.sh b/plugins/cc-taskrunner/safety/block-interactive.sh new file mode 100755 index 0000000000..d071523433 --- /dev/null +++ b/plugins/cc-taskrunner/safety/block-interactive.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# block-interactive.sh — Blocks AskUserQuestion in unattended sessions +# +# PreToolUse hook: exits 2 to block, 0 to allow. +# When Claude tries to ask a question, this forces it to decide instead. +# +# Copyright 2026 Stackbilt LLC — Apache 2.0 + +INPUT=$(cat) +TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) + +if [[ "$TOOL" == "AskUserQuestion" ]]; then + echo "BLOCKED: Autonomous mode — do not ask questions. Make a reasonable decision and document your reasoning." >&2 + exit 2 +fi + +exit 0 diff --git a/plugins/cc-taskrunner/safety/safety-gate.sh b/plugins/cc-taskrunner/safety/safety-gate.sh new file mode 100755 index 0000000000..eec34175ce --- /dev/null +++ b/plugins/cc-taskrunner/safety/safety-gate.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# safety-gate.sh — Blocks destructive commands in unattended sessions +# +# PreToolUse hook for Bash tool: checks command for dangerous patterns. +# Blocks: rm -rf, git push --force, DROP TABLE, deploys, secret access. +# +# Copyright 2026 Stackbilt LLC — Apache 2.0 + +INPUT=$(cat) +TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) + +if [[ "$TOOL" != "Bash" ]]; then + exit 0 +fi + +CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null) + +# Destructive filesystem operations +if echo "$CMD" | grep -qiE '(rm\s+-rf|rm\s+-r\s+/|>\s*/dev/)'; then + echo "BLOCKED: Destructive filesystem operation not allowed in autonomous mode" >&2 + exit 2 +fi + +# Destructive git operations +if echo "$CMD" | grep -qiE '(git\s+reset\s+--hard|git\s+push\s+--force|git\s+push\s+-f|git\s+clean\s+-f)'; then + echo "BLOCKED: Destructive git operation not allowed in autonomous mode" >&2 + exit 2 +fi + +# Database destruction +if echo "$CMD" | grep -qiE '(DROP\s+TABLE|TRUNCATE\s+TABLE|DELETE\s+FROM\s+\w+\s*$)'; then + echo "BLOCKED: Destructive database operation not allowed in autonomous mode" >&2 + exit 2 +fi + +# Production deploys (require human approval) +if echo "$CMD" | grep -qiE '(wrangler\s+deploy|wrangler\s+publish|npm\s+run\s+deploy|kubectl\s+apply|terraform\s+apply)'; then + echo "BLOCKED: Production deploys require human approval. Commit your work and stop." >&2 + exit 2 +fi + +# Secret management +if echo "$CMD" | grep -qiE '(wrangler\s+secret|echo\s+.*API_KEY|echo\s+.*TOKEN|echo\s+.*SECRET)'; then + echo "BLOCKED: Secret management not allowed in autonomous mode" >&2 + exit 2 +fi + +exit 0 diff --git a/plugins/cc-taskrunner/safety/syntax-check.sh b/plugins/cc-taskrunner/safety/syntax-check.sh new file mode 100755 index 0000000000..3df35bdbc4 --- /dev/null +++ b/plugins/cc-taskrunner/safety/syntax-check.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# syntax-check.sh — PostToolUse hook for Edit/Write +# +# After editing TypeScript/JavaScript files, runs a quick syntax check +# so errors are caught immediately rather than 50 tool calls later. +# +# Copyright 2026 Stackbilt LLC — Apache 2.0 + +INPUT=$(cat) +TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) + +if [[ "$TOOL" != "Edit" && "$TOOL" != "Write" ]]; then + exit 0 +fi + +FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null) + +# Only check TypeScript/JavaScript files +case "$FILE" in + *.ts|*.tsx|*.js|*.jsx) + # Find nearest tsconfig + DIR=$(dirname "$FILE") + TSCONFIG="" + while [[ "$DIR" != "/" && "$DIR" != "." ]]; do + if [[ -f "${DIR}/tsconfig.json" ]]; then + TSCONFIG="${DIR}/tsconfig.json" + break + fi + DIR=$(dirname "$DIR") + done + + if [[ -n "$TSCONFIG" ]]; then + ERRORS=$(cd "$(dirname "$TSCONFIG")" && npx tsc --noEmit --pretty false 2>&1 | grep -c "error TS" || true) + if [[ "$ERRORS" -gt 0 ]]; then + echo "WARNING: ${ERRORS} TypeScript error(s) detected after editing ${FILE}. Run typecheck to see details." >&2 + fi + fi + ;; +esac + +# Never block — advisory only +exit 0 diff --git a/plugins/cc-taskrunner/taskrunner.sh b/plugins/cc-taskrunner/taskrunner.sh new file mode 100755 index 0000000000..b4f147d15f --- /dev/null +++ b/plugins/cc-taskrunner/taskrunner.sh @@ -0,0 +1,506 @@ +#!/usr/bin/env bash +# cc-taskrunner — Autonomous task queue for Claude Code +# +# Plugin-compatible version. Executes tasks from a local queue file using +# headless Claude Code sessions with safety hooks, branch-per-task isolation, +# and automatic PR creation. +# +# Copyright 2026 Stackbilt LLC +# Licensed under Apache License 2.0 +# +# Usage: +# ./taskrunner.sh # Run until queue empty +# ./taskrunner.sh --max 5 # Run at most 5 tasks +# ./taskrunner.sh --loop # Loop forever (poll every 60s) +# ./taskrunner.sh --dry-run # Show what would run without executing +# ./taskrunner.sh add "Fix the bug" # Add a task to the queue + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +QUEUE_FILE="${CC_QUEUE_FILE:-$(pwd)/queue.json}" +SAFETY_DIR="${SCRIPT_DIR}/safety" +HOOKS_SETTINGS="" +POLL_INTERVAL="${CC_POLL_INTERVAL:-60}" +MAX_TASKS="${CC_MAX_TASKS:-0}" # 0 = unlimited +MAX_TURNS="${CC_MAX_TURNS:-25}" +DRY_RUN=false +LOOP_MODE=false +TASKS_RUN=0 + +# ─── Parse args ────────────────────────────────────────────── + +ACTION="" +while [[ $# -gt 0 ]]; do + case "$1" in + --max) MAX_TASKS="$2"; shift 2 ;; + --loop) LOOP_MODE=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + --turns) MAX_TURNS="$2"; shift 2 ;; + add) ACTION="add"; shift; break ;; + list) ACTION="list"; shift ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +# ─── Helpers ───────────────────────────────────────────────── + +log() { echo "[$(date '+%H:%M:%S')] $*"; } +err() { echo "[$(date '+%H:%M:%S')] ERROR: $*" >&2; } + +# ─── Queue management ─────────────────────────────────────── + +init_queue() { + if [[ ! -f "$QUEUE_FILE" ]]; then + echo '[]' > "$QUEUE_FILE" + fi +} + +add_task() { + local title="$1" + local repo="${2:-.}" + local prompt="${3:-$title}" + local authority="${4:-auto_safe}" + local max_turns="${5:-$MAX_TURNS}" + + init_queue + + local task_id + task_id=$(python3 -c 'import uuid; print(str(uuid.uuid4()))') + + python3 -c " +import json, sys + +task = { + 'id': '$task_id', + 'title': sys.argv[1], + 'repo': sys.argv[2], + 'prompt': sys.argv[3], + 'authority': '$authority', + 'max_turns': int('$max_turns'), + 'status': 'pending', + 'created_at': '$(date -u +%Y-%m-%dT%H:%M:%SZ)' +} + +with open('$QUEUE_FILE', 'r') as f: + queue = json.load(f) +queue.append(task) +with open('$QUEUE_FILE', 'w') as f: + json.dump(queue, f, indent=2) + +print(f'Added task {task[\"id\"][:8]}: {task[\"title\"]}') +" "$title" "$repo" "$prompt" +} + +list_tasks() { + init_queue + python3 -c " +import json +with open('$QUEUE_FILE') as f: + queue = json.load(f) +if not queue: + print('Queue is empty.') +else: + for t in queue: + status = t.get('status', 'pending') + symbol = {'pending': '○', 'running': '▶', 'completed': '✓', 'failed': '✗'}.get(status, '?') + print(f'{symbol} {t[\"id\"][:8]} {status:10} {t[\"title\"][:60]}') +" +} + +fetch_next_task() { + init_queue + python3 -c " +import json +with open('$QUEUE_FILE') as f: + queue = json.load(f) +for t in queue: + if t.get('status') == 'pending': + print(json.dumps(t)) + break +else: + print('') +" +} + +update_task_status() { + local task_id="$1" status="$2" result="${3:-}" + python3 -c " +import json, sys +with open('$QUEUE_FILE', 'r') as f: + queue = json.load(f) +for t in queue: + if t['id'] == sys.argv[1]: + t['status'] = sys.argv[2] + if sys.argv[3]: + t['result'] = sys.argv[3][:4000] + break +with open('$QUEUE_FILE', 'w') as f: + json.dump(queue, f, indent=2) +" "$task_id" "$status" "$result" +} + +# ─── Generate hooks settings ──────────────────────────────── + +ensure_hooks_settings() { + HOOKS_SETTINGS=$(mktemp /tmp/cc-hooks-XXXXXX.json) + + cat > "$HOOKS_SETTINGS" </dev/null) + authority=$(echo "$task_json" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("authority", "auto_safe"))' 2>/dev/null) + + log "┌─ Task: ${title}" + log "│ ID: ${task_id:0:8}" + log "│ Repo: ${repo}" + log "│ Turns: ${max_turns}" + + if $DRY_RUN; then + log "└─ [DRY RUN] Would execute. Skipping." + return 0 + fi + + # Resolve repo path + local repo_path + if [[ "$repo" == "." ]]; then + repo_path="$(pwd)" + elif [[ -d "$repo" ]]; then + repo_path="$(cd "$repo" && pwd)" + else + err "Repo not found: ${repo}" + update_task_status "$task_id" "failed" "Repo not found: ${repo}" + return 1 + fi + + # Mark as running + update_task_status "$task_id" "running" + + # ─── Branch lifecycle ───────────────────────────────────── + local branch="" + local use_branch=false + local stashed=false + cd "$repo_path" + + # Non-operator tasks get their own branch + if [[ "$authority" != "operator" ]]; then + use_branch=true + branch="auto/${task_id:0:8}" + + # Stash uncommitted changes to protect live work + if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then + git stash push -m "cc-taskrunner:${task_id:0:8}" --include-untracked 2>/dev/null && stashed=true + log "│ Stashed uncommitted changes" + fi + + # Start from main + git checkout main 2>/dev/null || git checkout master 2>/dev/null + git pull --ff-only 2>/dev/null || true + + # Create or reset task branch + if git rev-parse --verify "$branch" >/dev/null 2>&1; then + git checkout "$branch" + git reset --hard main 2>/dev/null + else + git checkout -b "$branch" + fi + log "│ Branch: ${branch}" + fi + + # Build mission prompt + local mission_prompt + mission_prompt="$(cat < +MISSION +)" + + # Snapshot tree state before task runs (to avoid auto-committing pre-existing files) + local pre_snapshot + pre_snapshot=$(mktemp /tmp/cc-pre-XXXXXX.txt) + cd "$repo_path" + git diff --name-only 2>/dev/null > "$pre_snapshot" + git ls-files --others --exclude-standard 2>/dev/null >> "$pre_snapshot" + + # Execute + local output_file exit_code=0 + output_file=$(mktemp /tmp/cc-task-XXXXXX.json) + trap "rm -f ${output_file} ${pre_snapshot} ${HOOKS_SETTINGS}" RETURN + + log "│ Starting Claude Code session..." + + cd "$repo_path" + unset CLAUDECODE CLAUDE_CODE_ENTRYPOINT 2>/dev/null || true + eval "$(build_claude_cmd "$mission_prompt" "$max_turns")" \ + > "$output_file" 2>&1 || exit_code=$? + + # Extract result + local result_text + result_text=$(python3 -c ' +import json, sys +try: + data = json.load(open(sys.argv[1])) + print(data.get("result", "")) +except: + with open(sys.argv[1]) as f: + print(f.read()[:4000]) +' "$output_file" 2>/dev/null || cat "$output_file" | head -c 4000) + + # ─── Handle commits, push, PR ────────────────────────────── + local pr_url="" + cd "$repo_path" + + if $use_branch; then + local commit_count + commit_count=$(git rev-list main..HEAD --count 2>/dev/null || echo "0") + + # Only auto-commit files that the TASK created/modified (not pre-existing dirty files) + local task_dirty_files=() + while IFS= read -r f; do + [[ -z "$f" ]] && continue + if ! grep -qxF "$f" "$pre_snapshot" 2>/dev/null; then + task_dirty_files+=("$f") + fi + done < <(git diff --name-only 2>/dev/null; git ls-files --others --exclude-standard 2>/dev/null) + + if [[ ${#task_dirty_files[@]} -gt 0 ]]; then + log "│ Auto-committing ${#task_dirty_files[@]} task-created files (skipping pre-existing changes)" + for f in "${task_dirty_files[@]}"; do + git add "$f" 2>/dev/null + done + git commit -m "auto: uncommitted changes from task ${task_id:0:8} + +Task: ${title}" 2>/dev/null || true + commit_count=$((commit_count + 1)) + fi + + # Push and create PR if there are commits + if [[ "$commit_count" -gt 0 ]]; then + log "│ Pushing ${commit_count} commit(s) to ${branch}..." + git push -u origin "$branch" 2>/dev/null || true + + # Create PR if gh CLI is available + if command -v gh >/dev/null 2>&1; then + local remote_url repo_slug + remote_url=$(git remote get-url origin 2>/dev/null) + repo_slug=$(echo "$remote_url" | sed -E 's|.*github\.com[:/](.+)(\.git)?$|\1|' | sed 's/\.git$//') + + pr_url=$(gh pr create \ + --repo "$repo_slug" \ + --base main \ + --head "$branch" \ + --title "[auto] ${title}" \ + --body "$(cat </dev/null || echo "") + + if [[ -n "$pr_url" ]]; then + log "│ PR created: ${pr_url}" + result_text="${result_text} + +[cc-taskrunner] PR: ${pr_url}" + else + log "│ WARNING: PR creation failed" + fi + fi + else + log "│ No commits on branch — cleaning up" + git checkout main 2>/dev/null || git checkout master 2>/dev/null + git branch -D "$branch" 2>/dev/null || true + branch="" + if [[ "$stashed" == "true" ]]; then + git stash pop 2>/dev/null && log "│ Restored stashed changes" || true + stashed=false + fi + fi + + # Return to main + git checkout main 2>/dev/null || git checkout master 2>/dev/null + + # Restore stashed changes + if [[ "$stashed" == "true" ]]; then + git stash pop 2>/dev/null && log "│ Restored stashed changes" || log "│ WARNING: stash pop failed" + fi + fi + + # Check completion signal + if echo "$result_text" | grep -qF "TASK_COMPLETE"; then + log "│ Completion signal found" + elif echo "$result_text" | grep -qF "TASK_BLOCKED"; then + log "│ Task reported BLOCKED" + exit_code=2 + else + log "│ WARNING: No completion signal in output" + if [[ $exit_code -eq 0 ]]; then + exit_code=3 + fi + fi + + # Update queue + local status="completed" + [[ $exit_code -ne 0 ]] && status="failed" + update_task_status "$task_id" "$status" "$result_text" + + if [[ $exit_code -eq 0 ]]; then + log "└─ COMPLETED${pr_url:+ (PR: ${pr_url})}" + else + log "└─ FAILED (exit code ${exit_code})" + fi + + TASKS_RUN=$((TASKS_RUN + 1)) + return $exit_code +} + +# ─── Handle subcommands ───────────────────────────────────── + +if [[ "$ACTION" == "add" ]]; then + add_task "$*" + exit 0 +fi + +if [[ "$ACTION" == "list" ]]; then + list_tasks + exit 0 +fi + +# ─── Main loop ─────────────────────────────────────────────── + +main() { + ensure_hooks_settings + + log "cc-taskrunner starting" + log " Queue: ${QUEUE_FILE}" + log " Safety: ${SAFETY_DIR}" + log " Max: $([ "$MAX_TASKS" -eq 0 ] && echo 'unlimited' || echo "$MAX_TASKS")" + log " Turns: ${MAX_TURNS}" + log " Mode: $(${DRY_RUN} && echo 'DRY RUN' || echo 'LIVE')" + + if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + log " GitHub: authenticated" + else + log " GitHub: not authenticated (PRs will be skipped)" + fi + log "" + + while true; do + if [[ "$MAX_TASKS" -gt 0 && "$TASKS_RUN" -ge "$MAX_TASKS" ]]; then + log "Task limit reached (${TASKS_RUN}/${MAX_TASKS}). Stopping." + break + fi + + local task_json + task_json=$(fetch_next_task) + + if [[ -z "$task_json" ]]; then + if $LOOP_MODE; then + log "Queue empty. Polling again in ${POLL_INTERVAL}s..." + sleep "$POLL_INTERVAL" + continue + else + log "Queue empty. ${TASKS_RUN} task(s) completed. Done." + break + fi + fi + + execute_task "$task_json" || true + sleep 2 + done +} + +main