Files
small-scripts/scripts/mm-worktree-task
Paul O'Reilly 840d7509e1 mm scripts: default to MiniMax-M3.1-Flash-Preview with per-script effort
Flash is the default model; --m3 falls back to M3, --effort overrides.
Defaults: max (review-minimax, review-loop, mm-worktree-task), medium
(explore), low (ci-triage). ask-minimax-run applies MINIMAX_EFFORT to
Flash models when --effort is not passed. Bump stale fixture date.
2026-10-13 10:23:53 +13:00

542 lines
19 KiB
Bash
Executable File

#!/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 <item-uuid> [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:
<item-uuid> Full 36-char planner work-item UUID. Required unless
--brief is given together with --slug.
Options:
--slug <slug> Worktree/branch suffix (default: first 8 chars of uuid)
--base <ref> Branch/commit the worktree is created from and that
--collect diffs against (default: main)
--focus "<line>" One-line focus appended to the task frame
--brief <file> 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 "<list>" Extra --allowedTools entries (comma-separated)
--timeout <secs> Sub-session timeout (default: 1800)
--m3 Use MiniMax-M3[1m] instead of the default MiniMax-M3.1-Flash-Preview
--effort <level> Thinking depth low|medium|high|xhigh|max (default: max;
not sent with --m3 unless given)
--gate "<command>" 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 <secs> Max seconds the --gate command may run (default: 900)
--context <name> 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=<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=<name> same as --context
MM_WORKTREE_TASK_CPCONFIG=<file> cpconfig to resolve the context from
(default: ~/.cpconfig)
Env overrides (for testing):
MM_WORKTREE_TASK_FAKE_ITEM=<file> fake `planner items get --json` output
MM_WORKTREE_TASK_FAKE_DOCS_DIR=<dir> fake docs: <dir>/list.json + <dir>/<name>
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 <<EOF
You are running non-interactively (-p) as an implementation subagent inside a git worktree of the ${REPO_NAME} repo.
WORKTREE: ${WORKTREE} BRANCH: ${BRANCH}
Your current working directory IS the worktree. Never cd elsewhere. Never touch \`git config\`. Never \`git push\`. Never \`git stash\`.
Project rules that apply to you:
- Spec-driven: if you change behaviour, update the matching spec/*.md requirement in the same commit (add a new numbered requirement ID following the file's existing scheme; keep the rationale line).
- Python 3.12, type hints, Pydantic v2. Async tests use ONLY \`@pytest.mark.asyncio\` + \`async def\`.
- Do NOT run the full test suite. Run ONLY the test files you touched or created.
- Do not get stuck fixing test infrastructure. If a fixture is missing, mock it.
- Read the spec and existing tests around the code before editing. Match existing names exactly.
- Keep the diff minimal and focused on this task.
When done:
1. \`git add -A && git commit -m "<type>: <summary> (${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:-<not found>}"
echo " cp-cli ctx: ${CP_CONTEXT:-<cp-cli default>}"
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:-<none>}"
[[ -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="<unresolvable HEAD: ${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}"