feat: add mm-worktree-task — launch MiniMax worktree implementation sub-sessions
Wraps ask-minimax-run to run a MiniMax M3 implementation sub-session in an isolated git worktree, with the prompt assembled from a planner work item (cp_cli item + documents + parent/siblings). Supports --dryrun, --collect, and --upload, per the pattern used for the 2026-09-10 seven-worktree batch (see agent-runtimes memory/gotchas-minimax.md). Claude-Session: https://claude.ai/code/session_015nXvGNNNixJPFwZKZHNyha
This commit is contained in:
365
scripts/mm-worktree-task
Executable file
365
scripts/mm-worktree-task
Executable file
@@ -0,0 +1,365 @@
|
||||
#!/usr/bin/env bash
|
||||
# Launch a MiniMax M3 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 M3 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)
|
||||
--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)
|
||||
--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
|
||||
|
||||
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=""
|
||||
DOCS="recent"
|
||||
EXTRA_TOOLS=""
|
||||
TIMEOUT=1800
|
||||
UPLOAD=0
|
||||
COLLECT=0
|
||||
DRYRUN=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--slug) SLUG="$2"; shift 2 ;;
|
||||
--focus) FOCUS="$2"; shift 2 ;;
|
||||
--brief) BRIEF="$2"; shift 2 ;;
|
||||
--docs) DOCS="$2"; shift 2 ;;
|
||||
--tools) EXTRA_TOOLS="$2"; shift 2 ;;
|
||||
--timeout) TIMEOUT="$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
|
||||
|
||||
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")"
|
||||
|
||||
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 main..HEAD"
|
||||
git -C "$WORKTREE" log --oneline main..HEAD || true
|
||||
echo
|
||||
info "git diff --stat main...HEAD"
|
||||
git -C "$WORKTREE" diff --stat main...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}"
|
||||
( cd "$REPO_ROOT" && python3 -m lib.cp_cli planner items documents put "$UUID" "$DOC_NAME" --file "$RESULT_FILE" )
|
||||
( cd "$REPO_ROOT" && python3 -m lib.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="$(cd "$REPO_ROOT" && python3 -m lib.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
|
||||
local list_json
|
||||
if [[ -n "${MM_WORKTREE_TASK_FAKE_DOCS_DIR:-}" ]]; then
|
||||
[[ -f "${MM_WORKTREE_TASK_FAKE_DOCS_DIR}/list.json" ]] || return
|
||||
list_json="$(cat "${MM_WORKTREE_TASK_FAKE_DOCS_DIR}/list.json")"
|
||||
elif [[ -n "$UUID" ]]; then
|
||||
list_json="$(cd "$REPO_ROOT" && python3 -m lib.cp_cli planner items documents list "$UUID" --json 2>/dev/null || echo '{"documents":[]}')"
|
||||
else
|
||||
return
|
||||
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"]="$(cd "$REPO_ROOT" && python3 -m lib.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="$(cd "$REPO_ROOT" && python3 -m lib.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
|
||||
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 \"MiniMax-M3[1m]\" --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 " Slug: ${SLUG}"
|
||||
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
|
||||
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" main
|
||||
|
||||
info "launching ask-minimax-run (detached, timeout ${TIMEOUT}s)"
|
||||
(
|
||||
cd "$WORKTREE"
|
||||
start=$(date +%s)
|
||||
set +e
|
||||
setsid nohup timeout "$TIMEOUT" ask-minimax-run --model "MiniMax-M3[1m]" \
|
||||
--allowedTools "$ALLOWLIST" --permission-mode acceptEdits \
|
||||
--add-dir "$REPORT_DIR" -p "$(cat "$PROMPT_FILE")" \
|
||||
> "$STDOUT_FILE" 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
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}"
|
||||
Reference in New Issue
Block a user