From d28074ca8c82b58a4e40a4029f6a6875f6e8c45b Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sat, 2 May 2026 13:47:38 -0700 Subject: [PATCH] feat(scripts): pnpm work + pnpm debug for agent-driven workflows (#1105) --- package.json | 2 + scripts/debug/README.md | 46 +++++++++++++ scripts/debug/cli.sh | 49 ++++++++++++++ scripts/debug/e2e.sh | 55 +++++++++++++++ scripts/debug/lib.sh | 83 +++++++++++++++++++++++ scripts/debug/logs.sh | 63 +++++++++++++++++ scripts/debug/rust.sh | 51 ++++++++++++++ scripts/debug/unit.sh | 68 +++++++++++++++++++ scripts/review/README.md | 6 +- scripts/review/cli.sh | 12 ++-- scripts/review/fix.sh | 16 ++--- scripts/review/review.sh | 18 ++--- scripts/work/README.md | 38 +++++++++++ scripts/work/cli.sh | 63 +++++++++++++++++ scripts/work/start.sh | 142 +++++++++++++++++++++++++++++++++++++++ 15 files changed, 686 insertions(+), 26 deletions(-) create mode 100644 scripts/debug/README.md create mode 100755 scripts/debug/cli.sh create mode 100755 scripts/debug/e2e.sh create mode 100755 scripts/debug/lib.sh create mode 100755 scripts/debug/logs.sh create mode 100755 scripts/debug/rust.sh create mode 100755 scripts/debug/unit.sh create mode 100644 scripts/work/README.md create mode 100755 scripts/work/cli.sh create mode 100755 scripts/work/start.sh diff --git a/package.json b/package.json index a27c41f8b..042d78b91 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "test:rust": "pnpm --filter openhuman-app test:rust", "mock:api": "node scripts/mock-api-server.mjs", "review": "bash scripts/review/cli.sh", + "work": "bash scripts/work/cli.sh", + "debug": "bash scripts/debug/cli.sh", "rust:check": "pnpm --filter openhuman-app rust:check", "typecheck": "pnpm --filter openhuman-app compile" }, diff --git a/scripts/debug/README.md b/scripts/debug/README.md new file mode 100644 index 000000000..2133a5831 --- /dev/null +++ b/scripts/debug/README.md @@ -0,0 +1,46 @@ +# scripts/debug + +Agent-friendly wrappers around the project's test runners. Each command runs +the underlying tool with full output **teed to a log file** under +`target/debug-logs/`, while keeping stdout small (summary + failure blocks). + +Use `--verbose` on any runner to also stream the raw output. + +## Usage + +```sh +# Vitest +pnpm debug unit # full suite +pnpm debug unit src/components/Foo.test.tsx # one file (positional pattern) +pnpm debug unit -t "renders empty state" # filter by test name +pnpm debug unit Foo -t "renders empty" --verbose + +# WDIO E2E (one spec at a time) +pnpm debug e2e test/e2e/specs/smoke.spec.ts +pnpm debug e2e test/e2e/specs/cron-jobs-flow.spec.ts cron-jobs --verbose + +# cargo tests (uses scripts/test-rust-with-mock.sh) +pnpm debug rust +pnpm debug rust json_rpc_e2e + +# Inspect saved logs +pnpm debug logs # list 50 most recent +pnpm debug logs last # print most recent (last 400 lines) +pnpm debug logs unit # most recent matching prefix "unit" +pnpm debug logs last --tail 100 +``` + +Logs land in `target/debug-logs/--.log`. The directory +is created on demand and is safe to delete — nothing else writes there. + +## Why + +- **Filtering** — positional pattern + `-t ""` for Vitest, single spec + for WDIO; agents don't have to grep the whole tree on every change. +- **Bounded output** — the default summary fits in agent context. Full output + is one `pnpm debug logs last` away. +- **Stable surface** — the runners' flags can churn; this wrapper keeps the + contract small (positional + a couple of flags) so prompts don't break. + +The wrappers don't replace the project test runners — they invoke the +underlying tools/scripts with log capture. diff --git a/scripts/debug/cli.sh b/scripts/debug/cli.sh new file mode 100755 index 000000000..7a248ed8b --- /dev/null +++ b/scripts/debug/cli.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Dispatcher for `pnpm debug `. +# Agent-friendly wrappers around the project's test/run scripts. +# Commands: unit | e2e | rust | logs + +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +usage() { + cat <<'EOF' +Usage: pnpm debug [args] + +Commands: + unit [pattern] [-t ""] [--watch] [--verbose] + Run Vitest. Full log goes to target/debug-logs/unit-.log; + stdout shows only summary + failure blocks unless --verbose. + e2e [log-suffix] [--verbose] + Run a single WDIO spec via app/scripts/e2e-run-spec.sh. + Full log goes to target/debug-logs/e2e--.log. + rust [test-filter] [--verbose] + Run cargo tests with the mock backend (test-rust-with-mock.sh). + Full log goes to target/debug-logs/rust-.log. + logs [list||last] [--head N | --tail N] + Inspect saved debug-log files. `last` shows the most recent. + +Flags common to runners: + --verbose Stream full output to stdout in addition to the log file. + +Exit code = the underlying tool's exit code. +EOF +} + +cmd="${1:-}" +if [ -z "$cmd" ] || [ "$cmd" = "-h" ] || [ "$cmd" = "--help" ]; then + usage + exit 0 +fi +shift + +case "$cmd" in + unit|e2e|rust|logs) + exec "$here/${cmd}.sh" "$@" + ;; + *) + echo "[debug] unknown command: $cmd" >&2 + usage >&2 + exit 1 + ;; +esac diff --git a/scripts/debug/e2e.sh b/scripts/debug/e2e.sh new file mode 100755 index 000000000..f824c533c --- /dev/null +++ b/scripts/debug/e2e.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# e2e.sh [log-suffix] [--verbose] +# Wraps app/scripts/e2e-run-spec.sh with log capture + summary. + +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$here/lib.sh" + +verbose=0 +spec="" +suffix="" +while [ $# -gt 0 ]; do + case "$1" in + --verbose) verbose=1; shift ;; + -*) + echo "[debug:e2e] unknown flag: $1" >&2; exit 1 ;; + *) + if [ -z "$spec" ]; then spec="$1" + elif [ -z "$suffix" ]; then suffix="$1" + else echo "[debug:e2e] unexpected arg: $1" >&2; exit 1 + fi + shift ;; + esac +done + +if [ -z "$spec" ]; then + echo "Usage: pnpm debug e2e [log-suffix] [--verbose]" >&2 + exit 1 +fi + +repo_root="$(debug_repo_root)" +log_dir="$(debug_log_dir)" +[ -n "$suffix" ] || suffix="$(basename "$spec" .spec.ts)" +safe_suffix="$(basename -- "$suffix")" +safe_suffix="${safe_suffix//[^[:alnum:]._-]/-}" +[ -n "$safe_suffix" ] || safe_suffix="spec" +log="$log_dir/e2e-${safe_suffix}-$(debug_timestamp).log" + +echo "[debug:e2e] spec: $spec" +echo "[debug:e2e] log: $log" +rc=0 +debug_run "$log" "$verbose" -- bash "$repo_root/app/scripts/e2e-run-spec.sh" "$spec" "$suffix" || rc=$? + +if [ "$verbose" != "1" ]; then + debug_summarize_wdio "$log" +fi + +if [ "$rc" != "0" ]; then + echo + echo "[debug:e2e] FAILED (exit $rc) — full log: $log" +else + echo "[debug:e2e] OK — log: $log" +fi +exit "$rc" diff --git a/scripts/debug/lib.sh b/scripts/debug/lib.sh new file mode 100755 index 000000000..d982ea120 --- /dev/null +++ b/scripts/debug/lib.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Shared helpers for scripts/debug/*.sh. Source; do not execute. + +set -euo pipefail + +debug_repo_root() { + (cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +} + +debug_log_dir() { + local root + root="$(debug_repo_root)" + mkdir -p "$root/target/debug-logs" + echo "$root/target/debug-logs" +} + +debug_timestamp() { + date +%Y%m%d-%H%M%S +} + +# Run a command, tee its combined output to a log file, return its exit code. +# Usage: debug_run -- [args…] +debug_run() { + local log="$1"; shift + local verbose="$1"; shift + if [ "${1:-}" = "--" ]; then shift; fi + + local rc=0 + if [ "$verbose" = "1" ]; then + set +e + "$@" 2>&1 | tee "$log" + rc=${PIPESTATUS[0]} + set -e + else + set +e + "$@" >"$log" 2>&1 + rc=$? + set -e + fi + return "$rc" +} + +# Print a short summary + the failure block(s) from a Vitest log. +debug_summarize_vitest() { + local log="$1" + echo + echo "--- summary ---" + grep -E '^[[:space:]]*(Test Files|Tests|Duration|Start at)' "$log" | tail -n 20 || true + if grep -qE '^[[:space:]]*FAIL ' "$log"; then + echo + echo "--- failures ---" + grep -E '^[[:space:]]*FAIL ' "$log" || true + echo + echo "--- failure detail (first 200 lines after first FAIL) ---" + awk '/^[[:space:]]*FAIL /{found=1} found{print; n++; if (n>=200) exit}' "$log" + fi +} + +# Print summary lines from a WDIO/Mocha run log. +debug_summarize_wdio() { + local log="$1" + echo + echo "--- summary ---" + grep -E '(passing|failing|pending|tests?, )' "$log" | tail -n 10 || true + if grep -qE '^[[:space:]]*[0-9]+\)' "$log"; then + echo + echo "--- failure detail ---" + awk '/^[[:space:]]*[0-9]+\)/{found=1} found{print}' "$log" | head -n 200 + fi +} + +# Print summary + failure tails from a cargo-test log. +debug_summarize_cargo() { + local log="$1" + echo + echo "--- summary ---" + grep -E '^test result:' "$log" | tail -n 20 || true + if grep -qE '^failures:' "$log"; then + echo + echo "--- failures ---" + awk '/^failures:/{found=1} found{print}' "$log" | head -n 200 + fi +} diff --git a/scripts/debug/logs.sh b/scripts/debug/logs.sh new file mode 100755 index 000000000..105c2cce4 --- /dev/null +++ b/scripts/debug/logs.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# logs.sh [list|last|] [--head N | --tail N] + +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$here/lib.sh" + +target="${1:-list}" +shift || true +mode="" +lines="200" +while [ $# -gt 0 ]; do + case "$1" in + --head) mode="head"; lines="${2:?--head requires N}"; shift 2 ;; + --head=*) mode="head"; lines="${1#*=}"; shift ;; + --tail) mode="tail"; lines="${2:?--tail requires N}"; shift 2 ;; + --tail=*) mode="tail"; lines="${1#*=}"; shift ;; + *) echo "[debug:logs] unknown arg: $1" >&2; exit 1 ;; + esac +done + +log_dir="$(debug_log_dir)" + +if [ "$target" = "list" ]; then + ls -1t "$log_dir" 2>/dev/null | head -n 50 + exit 0 +fi + +resolve_log() { + local t="$1" + if [ "$t" = "last" ]; then + ls -1t "$log_dir" 2>/dev/null | head -n 1 | awk -v d="$log_dir" '{print d "/" $0}' + return + fi + if [ -f "$t" ]; then echo "$t"; return; fi + if [ -f "$log_dir/$t" ]; then echo "$log_dir/$t"; return; fi + # Prefix match + local match + match=$(ls -1t "$log_dir" 2>/dev/null | grep -F "$t" | head -n 1 || true) + if [ -n "$match" ]; then echo "$log_dir/$match"; return; fi + echo "" +} + +file="$(resolve_log "$target")" +if [ -z "$file" ] || [ ! -f "$file" ]; then + echo "[debug:logs] no log matching: $target" >&2 + exit 1 +fi + +echo "[debug:logs] $file" +case "$mode" in + head) head -n "$lines" "$file" ;; + tail) tail -n "$lines" "$file" ;; + "") + if [ "$(wc -l <"$file")" -gt 400 ]; then + echo "--- log is long; showing last 400 lines (use --head/--tail to override) ---" + tail -n 400 "$file" + else + cat "$file" + fi + ;; +esac diff --git a/scripts/debug/rust.sh b/scripts/debug/rust.sh new file mode 100755 index 000000000..6c1cba63b --- /dev/null +++ b/scripts/debug/rust.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# rust.sh [test-filter] [--verbose] [-- …] +# Wraps scripts/test-rust-with-mock.sh. + +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$here/lib.sh" + +verbose=0 +filter="" +passthrough=() +while [ $# -gt 0 ]; do + case "$1" in + --verbose) verbose=1; shift ;; + --) shift; passthrough+=("$@"); break ;; + -*) passthrough+=("$1"); shift ;; + *) + if [ -z "$filter" ]; then filter="$1"; else passthrough+=("$1"); fi + shift ;; + esac +done + +repo_root="$(debug_repo_root)" +log_dir="$(debug_log_dir)" +log="$log_dir/rust-$(debug_timestamp).log" + +cmd=(bash "$repo_root/scripts/test-rust-with-mock.sh") +if [ ${#passthrough[@]} -gt 0 ]; then + cmd+=("${passthrough[@]}") +fi +if [ -n "$filter" ]; then + cmd+=("$filter") +fi + +echo "[debug:rust] log: $log" +echo "[debug:rust] cmd: ${cmd[*]}" +rc=0 +debug_run "$log" "$verbose" -- "${cmd[@]}" || rc=$? + +if [ "$verbose" != "1" ]; then + debug_summarize_cargo "$log" +fi + +if [ "$rc" != "0" ]; then + echo + echo "[debug:rust] FAILED (exit $rc) — full log: $log" +else + echo "[debug:rust] OK — log: $log" +fi +exit "$rc" diff --git a/scripts/debug/unit.sh b/scripts/debug/unit.sh new file mode 100755 index 000000000..abc00a3e0 --- /dev/null +++ b/scripts/debug/unit.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# unit.sh [pattern] [-t ""] [--watch] [--verbose] [-- …] +# Wraps `pnpm --filter openhuman-app test:unit`. + +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$here/lib.sh" + +verbose=0 +watch=0 +pattern="" +test_name="" +passthrough=() +while [ $# -gt 0 ]; do + case "$1" in + --verbose) verbose=1; shift ;; + --watch) watch=1; shift ;; + -t) test_name="${2:?-t requires a value}"; shift 2 ;; + -t=*) test_name="${1#*=}"; shift ;; + --) shift; passthrough+=("$@"); break ;; + -*) + passthrough+=("$1"); shift ;; + *) + if [ -z "$pattern" ]; then pattern="$1"; else passthrough+=("$1"); fi + shift ;; + esac +done + +log_dir="$(debug_log_dir)" +log="$log_dir/unit-$(debug_timestamp).log" + +repo_root="$(debug_repo_root)" +cd "$repo_root/app" + +cmd=(pnpm exec vitest) +if [ "$watch" = "1" ]; then + : # vitest default is watch +else + cmd+=(run) +fi +cmd+=(--config test/vitest.config.ts) +if [ -n "$test_name" ]; then + cmd+=(-t "$test_name") +fi +if [ -n "$pattern" ]; then + cmd+=("$pattern") +fi +if [ ${#passthrough[@]} -gt 0 ]; then + cmd+=("${passthrough[@]}") +fi + +echo "[debug:unit] log: $log" +echo "[debug:unit] cmd: ${cmd[*]}" +rc=0 +debug_run "$log" "$verbose" -- "${cmd[@]}" || rc=$? + +if [ "$verbose" != "1" ]; then + debug_summarize_vitest "$log" +fi + +if [ "$rc" != "0" ]; then + echo + echo "[debug:unit] FAILED (exit $rc) — full log: $log" +else + echo "[debug:unit] OK — log: $log" +fi +exit "$rc" diff --git a/scripts/review/README.md b/scripts/review/README.md index af4bcdd88..6ab1481f5 100644 --- a/scripts/review/README.md +++ b/scripts/review/README.md @@ -12,9 +12,9 @@ integration needed. ## LLM flags -- `review` / `fix`: `--executor-llm ` (default `claude`). Picks the CLI that +- `review` / `fix`: `--agent ` (default `claude`). Picks the CLI that drives the agent prompt. An optional trailing positional `` is - appended verbatim to the executor's prompt (e.g. + appended verbatim to the agent's prompt (e.g. `pnpm review fix 123 "focus on the retry logic"`). - `merge`: `--summary-llm ` (default `gemini`). The LLM that condenses the PR body + commit messages into a concise squash commit body. Use `--summary-llm none` @@ -52,6 +52,6 @@ scripts/review/merge.sh 123 `Co-authored-by:` entries (default filters copilot / codex / cursor / claude / anthropic / openai / chatgpt / `[bot]` / `noreply@github` / `users.noreply.github.com`; matched case-insensitively on name or email). -- Requires `git`, `gh`, `jq`. `review` / `fix` also require the executor LLM CLI +- Requires `git`, `gh`, `jq`. `review` / `fix` also require the agent CLI (default `claude`); `merge` also requires the summary LLM CLI (default `gemini`) unless `--summary-llm none`. diff --git a/scripts/review/cli.sh b/scripts/review/cli.sh index cbe93fb66..38f2d2adb 100755 --- a/scripts/review/cli.sh +++ b/scripts/review/cli.sh @@ -11,14 +11,14 @@ Usage: pnpm review [args] Commands: sync Check out PR as pr/, merge main, wire remotes - review [--executor-llm ] [extra-prompt] + review [--agent ] [extra-prompt] Sync + pr-reviewer agent (review, comment, approve) - Default executor: claude - Trailing extra-prompt is appended to the executor prompt. - fix [--executor-llm ] [extra-prompt] + Default agent: claude + Trailing extra-prompt is appended to the agent prompt. + fix [--agent ] [extra-prompt] Sync + pr-reviewer (apply fixes) + pr-manager-lite (push) - Default executor: claude - Trailing extra-prompt is appended to the executor prompt. + Default agent: claude + Trailing extra-prompt is appended to the agent prompt. merge [--squash|--merge|--rebase] [--dry-run] [--force] [--admin|--auto] [--summary-llm ] Merge via gh (default --squash, deletes branch). Requires reviewDecision=APPROVED and green required checks diff --git a/scripts/review/fix.sh b/scripts/review/fix.sh index 672d6296d..5ef7f68b9 100755 --- a/scripts/review/fix.sh +++ b/scripts/review/fix.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# fix.sh [--executor-llm ] [extra-prompt] +# fix.sh [--agent ] [extra-prompt] # Sync the PR, run pr-reviewer to identify issues and apply fixes, then hand # off to pr-manager-lite to run the quality suite, commit, and push. # -# --executor-llm picks the CLI that drives the agent. Default: claude. +# --agent picks the CLI that drives the work. Default: claude. # A trailing positional (any free-form text) is appended to the -# executor's prompt verbatim. +# agent's prompt verbatim. set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -16,13 +16,13 @@ require git gh jq require_pr_number "${1:-}" pr="$1" -executor_llm="claude" +agent="claude" extra_prompt="" shift while [ $# -gt 0 ]; do case "$1" in - --executor-llm) executor_llm="${2:?--executor-llm requires a value}"; shift 2 ;; - --executor-llm=*) executor_llm="${1#*=}"; shift ;; + --agent) agent="${2:?--agent requires a value}"; shift 2 ;; + --agent=*) agent="${1#*=}"; shift ;; *) if [ -n "$extra_prompt" ]; then echo "[review] unexpected extra arg: $1 (extra-prompt already set)" >&2 @@ -33,7 +33,7 @@ while [ $# -gt 0 ]; do esac done -require "$executor_llm" +require "$agent" sync_pr "$pr" prompt="I've already checked out branch pr/$REVIEW_PR with main \ @@ -49,4 +49,4 @@ Additional instructions from the user: ${extra_prompt}" fi -"$executor_llm" "$prompt" +"$agent" "$prompt" diff --git a/scripts/review/review.sh b/scripts/review/review.sh index 9fb2f8acd..5c0657d41 100755 --- a/scripts/review/review.sh +++ b/scripts/review/review.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash -# review.sh [--executor-llm ] [extra-prompt] +# review.sh [--agent ] [extra-prompt] # Sync the PR locally, then hand off to the pr-reviewer agent to produce a # CodeRabbit-style review, post it, and approve the PR if it looks good. # -# --executor-llm picks the CLI that drives the agent. Default: claude. +# --agent picks the CLI that drives the work. Default: claude. # (Note: the pr-reviewer / pr-manager-lite agents are Claude Code constructs; -# switching executors only makes sense if the alternate CLI understands them.) +# switching agents only makes sense if the alternate CLI understands them.) # A trailing positional (any free-form text) is appended to the -# executor's prompt verbatim. +# agent's prompt verbatim. set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -18,13 +18,13 @@ require git gh jq require_pr_number "${1:-}" pr="$1" -executor_llm="claude" +agent="claude" extra_prompt="" shift while [ $# -gt 0 ]; do case "$1" in - --executor-llm) executor_llm="${2:?--executor-llm requires a value}"; shift 2 ;; - --executor-llm=*) executor_llm="${1#*=}"; shift ;; + --agent) agent="${2:?--agent requires a value}"; shift 2 ;; + --agent=*) agent="${1#*=}"; shift ;; *) if [ -n "$extra_prompt" ]; then echo "[review] unexpected extra arg: $1 (extra-prompt already set)" >&2 @@ -35,7 +35,7 @@ while [ $# -gt 0 ]; do esac done -require "$executor_llm" +require "$agent" sync_pr "$pr" prompt="I've already checked out branch pr/$REVIEW_PR with main \ @@ -53,4 +53,4 @@ Additional instructions from the user: ${extra_prompt}" fi -"$executor_llm" "$prompt" +"$agent" "$prompt" diff --git a/scripts/work/README.md b/scripts/work/README.md new file mode 100644 index 000000000..1e81ff284 --- /dev/null +++ b/scripts/work/README.md @@ -0,0 +1,38 @@ +# scripts/work + +Automate picking up a GitHub issue: sync `main`, cut a working branch, and +hand the issue off to an LLM CLI to start implementing. + +Mirrors the structure of [`scripts/review`](../review) and reuses its +`lib.sh` helpers. + +## Usage + +```sh +pnpm work 1234 # default agent: claude +pnpm work 1234 "focus on the retry path" # extra prompt appended verbatim +pnpm work 1234 --agent codex # any CLI that takes -p "" +pnpm work 1234 --no-checkout # skip git sync; use current branch +``` + +The first numeric arg is treated as the issue number, so `pnpm work 1234 …` +and `pnpm work start 1234 …` are equivalent. + +## What it does + +1. Resolves the target repo from `WORK_REPO`, then falls back to the + `upstream` remote (or `origin`). +2. Fetches the issue (title, body, labels, URL) with `gh`. +3. Checks out `main`, fast-forwards from `upstream`/`origin`, then creates a + branch `/-` (slug derived from the issue title, + max 40 chars). If the branch already exists it's checked out and `main` + is merged in. +4. Hands off to the agent CLI with a prompt containing the issue body, + repo conventions pointers (CLAUDE.md / AGENTS.md), and any trailing + `extra-prompt`. + +## Config + +- `WORK_REPO=owner/name` — override the target repo. +- `WORK_BRANCH_PREFIX=issue` — branch is `/-`. +- Requires `git`, `gh`, `jq`, plus the agent CLI (default `claude`). diff --git a/scripts/work/cli.sh b/scripts/work/cli.sh new file mode 100755 index 000000000..c498c90af --- /dev/null +++ b/scripts/work/cli.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Dispatcher for `pnpm work `. +# Commands: start (default) + +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +usage() { + cat <<'EOF' +Usage: pnpm work [extra-prompt] [--agent ] [--no-checkout] + pnpm work start [extra-prompt] [--agent ] [--no-checkout] + +Pick up a GitHub issue, create a working branch off main, and hand it to an +LLM CLI to start implementing. + +Args: + The GitHub issue to work on. + [extra-prompt] Optional free-form text appended verbatim to + the agent prompt. + +Flags: + --agent Agent CLI to drive (default: claude). The + prompt is passed as a single positional + argument: \` ""\` — works with + the `claude` CLI and `codex exec` style tools. + --no-checkout Don't sync main / create the branch — just + print the prompt and run the agent against + the current branch. + +Env: + WORK_REPO=owner/name Override target repo (default: upstream remote, + falls back to origin). Same resolution as + scripts/review. + WORK_BRANCH_PREFIX=issue Branch name is /- (default: + issue). +EOF +} + +cmd="${1:-}" +if [ -z "$cmd" ] || [ "$cmd" = "-h" ] || [ "$cmd" = "--help" ]; then + usage + exit 0 +fi + +# `pnpm work 1234 …` — first arg is numeric → implicit `start`. +case "$cmd" in + ''|*[!0-9]*) + case "$cmd" in + start) + shift + exec "$here/start.sh" "$@" + ;; + *) + echo "[work] unknown command: $cmd" >&2 + usage >&2 + exit 1 + ;; + esac + ;; + *) + exec "$here/start.sh" "$@" + ;; +esac diff --git a/scripts/work/start.sh b/scripts/work/start.sh new file mode 100755 index 000000000..105b9c71c --- /dev/null +++ b/scripts/work/start.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# start.sh [extra-prompt] [--agent ] [--no-checkout] +# +# Pick up a GitHub issue: +# 1. Sync `main` from upstream. +# 2. Create a working branch `/-` (slug from issue title). +# 3. Pull the issue (title/body/labels) via gh. +# 4. Hand off to the agent CLI with a prompt that includes the issue plus +# repo conventions (CLAUDE.md / AGENTS.md pointers). +# +# --agent picks the CLI that drives the work. Default: claude. +# A trailing positional is appended to the agent prompt. +# --no-checkout skips git sync/branch creation (use the current branch as-is). + +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$here/../.." && pwd)" +# shellcheck source=../review/lib.sh +source "$repo_root/scripts/review/lib.sh" + +require git gh jq + +if [ -z "${1:-}" ]; then + echo "Usage: pnpm work [extra-prompt] [--agent ] [--no-checkout]" >&2 + exit 1 +fi +case "$1" in + ''|*[!0-9]*) + echo "[work] issue-number must be numeric, got: $1" >&2 + exit 1 + ;; +esac + +issue="$1" +shift +agent="claude" +extra_prompt="" +do_checkout=1 +while [ $# -gt 0 ]; do + case "$1" in + --agent) agent="${2:?--agent requires a value}"; shift 2 ;; + --agent=*) agent="${1#*=}"; shift ;; + --no-checkout) do_checkout=0; shift ;; + *) + if [ -n "$extra_prompt" ]; then + echo "[work] unexpected extra arg: $1 (extra-prompt already set)" >&2 + exit 1 + fi + extra_prompt="$1"; shift + ;; + esac +done + +require "$agent" + +# resolve_repo() lives in scripts/review/lib.sh; honour WORK_REPO override too. +repo="${WORK_REPO:-${REVIEW_REPO:-}}" +if [ -z "$repo" ]; then + repo=$(REVIEW_REPO= resolve_repo) +fi +branch_prefix="${WORK_BRANCH_PREFIX:-issue}" + +echo "[work] fetching issue #$issue from $repo..." +issue_json=$(gh issue view "$issue" -R "$repo" \ + --json number,title,body,labels,state,url,assignees) + +state=$(jq -r '.state' <<<"$issue_json") +if [ "$state" != "OPEN" ]; then + echo "[work] ! issue #$issue is $state — continuing anyway" >&2 +fi + +title=$(jq -r '.title' <<<"$issue_json") +body=$(jq -r '.body // ""' <<<"$issue_json") +url=$(jq -r '.url' <<<"$issue_json") +labels=$(jq -r '[.labels[].name] | join(", ")' <<<"$issue_json") + +# Slug: lowercase, alnum + hyphens, max 40 chars, trimmed. +slug=$(printf '%s' "$title" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' \ + | cut -c1-40 \ + | sed -E 's/-+$//') +if [ -z "$slug" ]; then + slug="work" +fi +branch="${branch_prefix}/${issue}-${slug}" + +if [ "$do_checkout" = "1" ]; then + echo "[work] syncing main..." + git checkout main + if git remote get-url upstream >/dev/null 2>&1; then + git fetch upstream + git merge --ff-only upstream/main || git merge upstream/main + fi + if git remote get-url origin >/dev/null 2>&1; then + git pull --ff-only origin main + fi + git submodule update --init --recursive + + if git show-ref --verify --quiet "refs/heads/$branch"; then + echo "[work] branch $branch already exists — checking it out and merging main" + git checkout "$branch" + if ! git merge main; then + echo "[work] merge from main failed on branch $branch; resolve conflicts and re-run." >&2 + exit 1 + fi + else + echo "[work] creating branch $branch off main" + git checkout -b "$branch" + fi +else + echo "[work] --no-checkout: staying on $(git branch --show-current)" +fi + +current_branch=$(git branch --show-current) + +prompt="You are picking up GitHub issue #${issue} on ${repo}. + +Working branch: ${current_branch} +Issue URL: ${url} +Issue title: ${title} +Labels: ${labels:-(none)} + +--- Issue body --- +${body} +--- end issue body --- + +Follow the workflow in CLAUDE.md and AGENTS.md. Plan the change against the +existing domains, implement it, add tests, and keep the diff minimal. When the +implementation is ready, commit on this branch with a message that references +#${issue}, push, and open a PR targeting main using the repo's PR template. Do +not merge." + +if [ -n "$extra_prompt" ]; then + prompt="${prompt} + +Additional instructions from the user: +${extra_prompt}" +fi + +echo "[work] handing off to ${agent} on branch ${current_branch}" +"$agent" "$prompt"