#!/usr/bin/env bash # Launch a MiniMax implementation sub-session in an isolated git worktree, # with the prompt assembled from a planner work item. See specs/mm-worktree-task.spec.md. set -euo pipefail RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; BLUE='\033[0;34m'; NC='\033[0m' info() { echo -e "${BLUE}==>${NC} $*"; } ok() { echo -e "${GREEN}✓${NC} $*"; } warn() { echo -e "${YELLOW}!${NC} $*" >&2; } err() { echo -e "${RED}✗${NC} $*" >&2; } DEFAULT_ALLOWLIST="Read,Write,Edit,Glob,Grep,Bash(pytest *),Bash(python3 *),Bash(ruff *),Bash(grep *),Bash(ls *),Bash(git add *),Bash(git commit *),Bash(git status*),Bash(git diff*),Bash(git rev-parse*),Bash(git log*)" usage() { cat <<'EOF' Usage: mm-worktree-task [OPTIONS] Launch a MiniMax implementation sub-session (via ask-minimax-run) in an isolated git worktree of the current repo, with the prompt assembled from a planner work item. Arguments: Full 36-char planner work-item UUID. Required unless --brief is given together with --slug. Options: --slug Worktree/branch suffix (default: first 8 chars of uuid) --base Branch/commit the worktree is created from and that --collect diffs against (default: main) --focus "" One-line focus appended to the task frame --brief Use this file as the task body instead of/in addition to the fetched item --docs all|recent|none Which item documents to bundle (default: recent) --tools "" Extra --allowedTools entries (comma-separated) --timeout Sub-session timeout (default: 1800) --m3 Use MiniMax-M3[1m] instead of the default MiniMax-M3.1-Flash-Preview --effort Thinking depth low|medium|high|xhigh|max (default: max; not sent with --m3 unless given) --gate "" Re-run this command in the worktree yourself, after the sub-session exits, against its LAST commit. Appended as a "Supervisor gate" section in the result file; a missing command, non-zero exit, or timeout are all reported as FAILED, never skipped. --gate-timeout Max seconds the --gate command may run (default: 900) --context cp-cli planner context (default: the ~/.cpconfig context whose project_id equals the repo name, else cp-cli's own current_context) --upload After --collect, upload the report + mark impl-complete --collect Don't launch; print status for an already-run slug --dryrun, -n Assemble everything, print it, create nothing --help, -h Show this help Environment: MM_WORKTREE_TASK_CP_CLI_DIR= checkout holding lib/cp_cli (default: the current repo if it has one, else ~/dev/claude/projects/agent-runtimes) MM_WORKTREE_TASK_CP_CONTEXT= same as --context MM_WORKTREE_TASK_CPCONFIG= cpconfig to resolve the context from (default: ~/.cpconfig) Env overrides (for testing): MM_WORKTREE_TASK_FAKE_ITEM= fake `planner items get --json` output MM_WORKTREE_TASK_FAKE_DOCS_DIR= fake docs: /list.json + / EOF } UUID="" SLUG="" FOCUS="" BRIEF="" BASE="main" DOCS="recent" EXTRA_TOOLS="" TIMEOUT=1800 USE_M3=0 EFFORT_ARG="" GATE="" GATE_TIMEOUT=900 UPLOAD=0 COLLECT=0 DRYRUN=0 CP_CONTEXT="${MM_WORKTREE_TASK_CP_CONTEXT:-}" while [[ $# -gt 0 ]]; do case "$1" in --slug) SLUG="$2"; shift 2 ;; --focus) FOCUS="$2"; shift 2 ;; --brief) BRIEF="$2"; shift 2 ;; --base) BASE="$2"; shift 2 ;; --docs) DOCS="$2"; shift 2 ;; --tools) EXTRA_TOOLS="$2"; shift 2 ;; --timeout) TIMEOUT="$2"; shift 2 ;; --gate) GATE="$2"; shift 2 ;; --gate-timeout) GATE_TIMEOUT="$2"; shift 2 ;; --context) CP_CONTEXT="$2"; shift 2 ;; --m3) USE_M3=1; shift ;; --effort) EFFORT_ARG="$2"; shift 2 ;; --upload) UPLOAD=1; shift ;; --collect) COLLECT=1; shift ;; --dryrun|-n) DRYRUN=1; shift ;; --help|-h) usage; exit 0 ;; -*) err "unknown flag: $1"; usage; exit 1 ;; *) if [[ -z "$UUID" ]]; then UUID="$1"; else err "unexpected argument: $1"; exit 1; fi shift ;; esac done if [[ $USE_M3 -eq 1 ]]; then MODEL="MiniMax-M3[1m]"; EFFORT="$EFFORT_ARG"; else MODEL="MiniMax-M3.1-Flash-Preview"; EFFORT="${EFFORT_ARG:-max}"; fi [[ -z "$EFFORT" || "$EFFORT" =~ ^(low|medium|high|xhigh|max)$ ]] || { err "--effort must be low|medium|high|xhigh|max"; exit 1; } case "$DOCS" in all|recent|none) ;; *) err "--docs must be all|recent|none"; exit 1 ;; esac if [[ -z "$UUID" ]]; then if [[ -z "$BRIEF" || -z "$SLUG" ]]; then err "item-uuid is required unless --brief and --slug are both given" exit 1 fi fi if [[ -n "$UUID" && -z "$SLUG" ]]; then SLUG="${UUID:0:8}" fi if [[ $UPLOAD -eq 1 && $COLLECT -eq 0 ]]; then err "--upload requires --collect (launch is detached; upload happens after the sub-session has produced a report)" exit 1 fi # --- repo root --- if ! REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"; then err "not inside a git repository" exit 1 fi REPO_NAME="$(basename "$REPO_ROOT")" # --- planner CLI: where it runs from, and which context it talks to --- # The script is used from any planner-backed project; cp_cli itself lives in # agent-runtimes. Resolve the checkout once and the context once, then every # planner call goes through cp_cli(). resolve_cp_cli_dir() { local d for d in "${MM_WORKTREE_TASK_CP_CLI_DIR:-}" "$REPO_ROOT" "$HOME/dev/claude/projects/agent-runtimes"; do [[ -n "$d" && ( -f "$d/lib/cp_cli.py" || -d "$d/lib/cp_cli" ) ]] && { echo "$d"; return 0; } done return 1 } CP_CLI_DIR="$(resolve_cp_cli_dir || true)" if [[ -z "$CP_CONTEXT" ]]; then CPCONFIG="${MM_WORKTREE_TASK_CPCONFIG:-$HOME/.cpconfig}" if [[ -f "$CPCONFIG" ]]; then CP_CONTEXT="$(python3 - "$CPCONFIG" "$REPO_NAME" <<'PY' 2>/dev/null || true import sys try: import yaml cfg = yaml.safe_load(open(sys.argv[1])) or {} except Exception: sys.exit(0) for ctx in cfg.get("contexts") or []: if ctx.get("project_id") == sys.argv[2]: print(ctx.get("name", "")) break PY )" fi fi cp_cli() { if [[ -z "$CP_CLI_DIR" ]]; then err "cannot find lib/cp_cli.py (set MM_WORKTREE_TASK_CP_CLI_DIR or check out agent-runtimes)" return 1 fi if [[ -n "$CP_CONTEXT" ]]; then ( cd "$CP_CLI_DIR" && python3 -m lib.cp_cli --context "$CP_CONTEXT" "$@" ) else ( cd "$CP_CLI_DIR" && python3 -m lib.cp_cli "$@" ) fi } REPORT_DIR="${XDG_RUNTIME_DIR:-/tmp}/mm-worktree-task/${REPO_NAME}" WORKTREE="${REPO_ROOT}/.claude/worktrees/mm-${SLUG}" BRANCH="mm/${SLUG}" BUNDLE_FILE="${REPORT_DIR}/${SLUG}-bundle.md" PROMPT_FILE="${REPORT_DIR}/${SLUG}-prompt.txt" STDOUT_FILE="${REPORT_DIR}/${SLUG}-stdout.txt" RESULT_FILE="${REPORT_DIR}/${SLUG}-result.md" LAUNCH_LOG="${REPORT_DIR}/launch.log" ALLOWLIST="$DEFAULT_ALLOWLIST" [[ -n "$EXTRA_TOOLS" ]] && ALLOWLIST="${ALLOWLIST},${EXTRA_TOOLS}" # --- --collect mode --- if [[ $COLLECT -eq 1 ]]; then if [[ ! -d "$WORKTREE" ]]; then err "no worktree found for slug '${SLUG}' (${WORKTREE})" exit 1 fi info "git log --oneline ${BASE}..HEAD" git -C "$WORKTREE" log --oneline "${BASE}..HEAD" || true echo info "git diff --stat ${BASE}...HEAD" git -C "$WORKTREE" diff --stat "${BASE}...HEAD" || true echo if [[ -f "$RESULT_FILE" ]]; then info "report: $RESULT_FILE" cat "$RESULT_FILE" else warn "no report found at $RESULT_FILE" fi if [[ $UPLOAD -eq 1 ]]; then if [[ -z "$UUID" ]]; then err "--upload requires an item-uuid" exit 1 fi if [[ ! -f "$RESULT_FILE" ]]; then err "cannot upload: no report at $RESULT_FILE" exit 1 fi DATE="$(date +%Y-%m-%d)" DOC_NAME="mm-${SLUG}-${DATE}.md" info "uploading ${DOC_NAME} to item ${UUID}" cp_cli planner items documents put "$UUID" "$DOC_NAME" --file "$RESULT_FILE" cp_cli planner items patch "$UUID" --flow-state impl-complete \ --description "mm-worktree-task: ${SLUG} committed on mm/${SLUG}" ok "uploaded and marked impl-complete" fi exit 0 fi # --- gather item + docs --- ITEM_JSON="" TITLE="" DOC_NAMES=() DOC_BUNDLED=() declare -A DOC_BODY fetch_item() { if [[ -n "$UUID" ]]; then if [[ -n "${MM_WORKTREE_TASK_FAKE_ITEM:-}" ]]; then ITEM_JSON="$(cat "$MM_WORKTREE_TASK_FAKE_ITEM")" else ITEM_JSON="$(cp_cli planner items get "$UUID" --json)" fi TITLE="$(echo "$ITEM_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("title",""))')" fi } fetch_docs() { [[ "$DOCS" == "none" ]] && return 0 local list_json if [[ -n "${MM_WORKTREE_TASK_FAKE_DOCS_DIR:-}" ]]; then [[ -f "${MM_WORKTREE_TASK_FAKE_DOCS_DIR}/list.json" ]] || return 0 list_json="$(cat "${MM_WORKTREE_TASK_FAKE_DOCS_DIR}/list.json")" elif [[ -n "$UUID" ]]; then list_json="$(cp_cli planner items documents list "$UUID" --json 2>/dev/null || echo '{"documents":[]}')" else return 0 fi while IFS=$'\t' read -r name updated; do [[ -z "$name" ]] && continue DOC_NAMES+=("$name") local bundle=0 if [[ "$DOCS" == "all" ]]; then bundle=1 elif [[ "$DOCS" == "recent" ]]; then if [[ -n "$updated" && "$updated" != "None" ]]; then local cutoff now updated_epoch now="$(date +%s)" updated_epoch="$(date -d "$updated" +%s 2>/dev/null || echo "$now")" cutoff=$(( now - 14*86400 )) [[ "$updated_epoch" -ge "$cutoff" ]] && bundle=1 else # no update dates available -> fall back to "all" bundle=1 fi fi if [[ $bundle -eq 1 ]]; then DOC_BUNDLED+=("$name") if [[ -n "${MM_WORKTREE_TASK_FAKE_DOCS_DIR:-}" ]]; then DOC_BODY["$name"]="$(cat "${MM_WORKTREE_TASK_FAKE_DOCS_DIR}/${name}" 2>/dev/null || echo "")" else DOC_BODY["$name"]="$(cp_cli planner items documents get "$UUID" "$name" 2>/dev/null || echo "")" fi fi done < <(echo "$list_json" | python3 -c ' import json, sys data = json.load(sys.stdin) for d in data.get("documents", []): name = d.get("name", "") updated = d.get("updated_at", "") print(name + "\t" + str(updated)) ') } fetch_item fetch_docs PARENT_ID="" PARENT_JSON="" if [[ -n "$ITEM_JSON" ]]; then PARENT_ID="$(echo "$ITEM_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("parent_id") or "")')" fi if [[ -n "$PARENT_ID" && -z "${MM_WORKTREE_TASK_FAKE_ITEM:-}" ]]; then PARENT_JSON="$(cp_cli planner items get "$PARENT_ID" --json 2>/dev/null || echo "")" fi # --- build bundle content --- build_bundle() { echo "# Bundle for ${SLUG}" echo if [[ -n "$ITEM_JSON" ]]; then echo "## Item" echo '```json' echo "$ITEM_JSON" echo '```' echo fi if [[ -n "$PARENT_JSON" ]]; then echo "## Parent" echo '```json' echo "$PARENT_JSON" echo '```' echo fi echo "## Documents" if [[ ${#DOC_NAMES[@]} -eq 0 ]]; then echo "(none)" else for n in "${DOC_NAMES[@]}"; do local marker="available only" for b in "${DOC_BUNDLED[@]:-}"; do [[ "$b" == "$n" ]] && marker="bundled below"; done echo "- ${n} (${marker})" done fi echo for n in "${DOC_BUNDLED[@]:-}"; do [[ -z "$n" ]] && continue echo "## Document: ${n}" echo '```' echo "${DOC_BODY[$n]:-}" echo '```' echo done } BUNDLE_CONTENT="$(build_bundle)" # --- build prompt --- DOC_NAME_LIST="$(IFS=', '; echo "${DOC_NAMES[*]:-none}")" build_prompt() { cat <: (${UUID:-brief})"\` on branch ${BRANCH}. First verify: \`git rev-parse --abbrev-ref HEAD\` must print ${BRANCH}, otherwise STOP and report. 2. Write your report to: ${RESULT_FILE} with sections: Summary, Files changed, Tests run, Decisions, Open questions. 3. One-line stdout acknowledgement only. ## Task — ${UUID:-brief only}: ${TITLE} Read the bundle at ${BUNDLE_FILE} fully before editing. The item body is the requirement; documents are supporting context. Documents available on the item (not all bundled): ${DOC_NAME_LIST}. EOF [[ -n "$FOCUS" ]] && echo -e "\nFocus: ${FOCUS}" if [[ -n "$BRIEF" ]]; then echo echo "## Brief" cat "$BRIEF" fi } PROMPT_CONTENT="$(build_prompt)" CMD="cd ${WORKTREE} && setsid nohup timeout ${TIMEOUT} ask-minimax-run --model \"${MODEL}\"${EFFORT:+ --effort ${EFFORT}} --allowedTools \"${ALLOWLIST}\" --permission-mode acceptEdits --add-dir ${REPORT_DIR} -p \"\$(cat ${PROMPT_FILE})\" > ${STDOUT_FILE} 2>&1" if [[ $DRYRUN -eq 1 ]]; then echo "[dryrun] mm-worktree-task — no changes will be made" echo " Repo: ${REPO_ROOT}" echo " cp-cli from: ${CP_CLI_DIR:-}" echo " cp-cli ctx: ${CP_CONTEXT:-}" echo " Slug: ${SLUG}" echo " Base: ${BASE}" echo " UUID: ${UUID:-(none, --brief only)}" echo " Worktree: ${WORKTREE}" echo " Branch: ${BRANCH}" echo " Report dir: ${REPORT_DIR}" echo " Bundle path: ${BUNDLE_FILE}" echo " Prompt path: ${PROMPT_FILE}" echo " Timeout: ${TIMEOUT}s" echo " Allowlist: ${ALLOWLIST}" echo " Gate: ${GATE:-}" [[ -n "$GATE" ]] && echo " Gate timeout: ${GATE_TIMEOUT}s" echo echo "--- Bundle manifest ---" echo "$BUNDLE_CONTENT" echo echo "--- Prompt ---" echo "$PROMPT_CONTENT" echo echo "--- Command ---" echo "$CMD" exit 0 fi # --- real run --- mkdir -p "$REPORT_DIR" echo "$BUNDLE_CONTENT" > "$BUNDLE_FILE" echo "$PROMPT_CONTENT" > "$PROMPT_FILE" if [[ -d "$WORKTREE" ]] || git -C "$REPO_ROOT" show-ref --verify --quiet "refs/heads/${BRANCH}"; then err "worktree or branch already exists for slug '${SLUG}' (${WORKTREE} / ${BRANCH})" exit 1 fi info "creating worktree ${WORKTREE} on branch ${BRANCH}" git -C "$REPO_ROOT" worktree add "$WORKTREE" -b "$BRANCH" "$BASE" # --- supervisor gate: re-run --gate ourselves after the sub-session exits, # against its LAST commit. Never trust the agent's own "tests pass" claim — # see memory/gotchas-minimax.md: more than half of supervised units reported # green where a re-run found red. A missing command, non-zero exit, or # timeout are all FAILED, never a silent skip. run_gate() { local gate_sha status_out dirty_flag=0 gate_out_file gate_rc timed_out=0 local gate_tail verdict had_report=0 agent_claims_pass=0 local gate_pid watchdog_pid timeout_flag gate_sha="$(git -C "$WORKTREE" rev-parse HEAD 2>&1)" || gate_sha="" status_out="$(git -C "$WORKTREE" status --porcelain 2>&1)" [[ -n "$status_out" ]] && dirty_flag=1 # Plain `timeout bash -c "$GATE"` only signals the immediate bash wrapper — # any grandchildren the gate command forks (a test runner's own workers, # say) are reparented and keep running unbounded. `setsid` makes the gate # its own process group (pgid == its pid), so the watchdog can signal the # whole group (`-$gate_pid`) and actually reap everything the gate started. gate_out_file="$(mktemp)" timeout_flag="$(mktemp -u)" set +e setsid bash -c "$GATE" > "$gate_out_file" 2>&1 & gate_pid=$! ( sleep "$GATE_TIMEOUT" if kill -0 "$gate_pid" 2>/dev/null; then : > "$timeout_flag" kill -TERM -- "-${gate_pid}" 2>/dev/null sleep 5 kill -KILL -- "-${gate_pid}" 2>/dev/null fi ) & watchdog_pid=$! wait "$gate_pid" gate_rc=$? kill "$watchdog_pid" 2>/dev/null wait "$watchdog_pid" 2>/dev/null set -e [[ -f "$timeout_flag" ]] && timed_out=1 rm -f "$timeout_flag" gate_tail="$(tail -n 60 "$gate_out_file" 2>/dev/null)" rm -f "$gate_out_file" if [[ $dirty_flag -eq 1 || $gate_rc -ne 0 ]]; then verdict="FAILED" else verdict="PASSED" fi [[ -f "$RESULT_FILE" ]] && had_report=1 if [[ $had_report -eq 1 ]] \ && grep -qiE '(all tests? pass|0 failed|tests? pass(ed)?|passing)' "$RESULT_FILE" 2>/dev/null \ && ! grep -qiE '[1-9][0-9]* (failed|failing)' "$RESULT_FILE" 2>/dev/null; then agent_claims_pass=1 fi { echo echo "## Supervisor gate (re-run by mm-worktree-task)" echo [[ $had_report -eq 0 ]] && echo "(No agent report was found at this path before the gate ran.)" echo "- HEAD: ${gate_sha}" echo "- Command: \`${GATE}\`" if [[ $dirty_flag -eq 1 ]]; then echo "- Worktree status: DIRTY — uncommitted changes present; result does not correspond to a fully committed state" else echo "- Worktree status: clean" fi if [[ $timed_out -eq 1 ]]; then echo "- Exit code: ${gate_rc} (TIMED OUT after ${GATE_TIMEOUT}s)" else echo "- Exit code: ${gate_rc}" fi echo "- Verdict: **${verdict}**" if [[ "$verdict" == "FAILED" && $agent_claims_pass -eq 1 ]]; then echo echo "**MISMATCH:** the agent's own report above claims a passing test run; this independent re-run of the gate FAILED. Treat this section as authoritative, not the agent's claim." fi echo echo "Output (tail):" echo '```' echo "$gate_tail" echo '```' } >> "$RESULT_FILE" } info "launching ask-minimax-run (detached, timeout ${TIMEOUT}s)" ( cd "$WORKTREE" start=$(date +%s) set +e setsid nohup timeout "$TIMEOUT" ask-minimax-run --model "$MODEL" ${EFFORT:+--effort "$EFFORT"} \ --allowedTools "$ALLOWLIST" --permission-mode acceptEdits \ --add-dir "$REPORT_DIR" -p "$(cat "$PROMPT_FILE")" \ > "$STDOUT_FILE" 2>&1 rc=$? set -e if [[ -n "$GATE" ]]; then run_gate fi echo "${SLUG} rc=${rc} elapsed=$(( $(date +%s) - start ))s" >> "$LAUNCH_LOG" ) & disown ok "launched" echo " Report dir: ${REPORT_DIR}" echo " Prompt file: ${PROMPT_FILE}" echo " Stdout file: ${STDOUT_FILE}" echo " Launch log: ${LAUNCH_LOG}" echo " poll with: tail -f ${STDOUT_FILE}"