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
366 lines
12 KiB
Bash
Executable File
366 lines
12 KiB
Bash
Executable File
#!/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}"
|