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:
@@ -21,6 +21,7 @@ This project uses **spec-driven development** (OpenSpec) as a testbed for agent-
|
||||
| `git-identity` | Switch git user.name/email from a saved identity menu (`~/.git-identities`) | Done |
|
||||
| `git-status-report` | Recursive git repo status with diff stats | Done |
|
||||
| `md-to-docx` | Markdown to DOCX conversion | Done |
|
||||
| `mm-worktree-task` | Launch a MiniMax M3 implementation sub-session in an isolated git worktree, prompt assembled from a planner work item | Done |
|
||||
| `mp3-to-mp4` | Convert MP3 to MP4 with a static title card | Done |
|
||||
| `split-wezterm` | Split WezTerm pane into a rows × cols grid | Done |
|
||||
| `sync-repos` | Mirror git repos across remotes | Done |
|
||||
|
||||
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}"
|
||||
119
specs/mm-worktree-task.spec.md
Normal file
119
specs/mm-worktree-task.spec.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# mm-worktree-task
|
||||
|
||||
## Purpose
|
||||
|
||||
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, so multiple implementation briefs can run detached and in parallel without touching the main working tree.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
mm-worktree-task <item-uuid> [OPTIONS]
|
||||
```
|
||||
|
||||
### Arguments
|
||||
|
||||
| Arg | Description |
|
||||
|-----|-------------|
|
||||
| `<item-uuid>` | Full 36-char planner work-item UUID. Required unless `--brief` is given together with `--slug`. |
|
||||
|
||||
### Options
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--slug <slug>` | first 8 chars of the uuid | Worktree/branch name suffix. Worktree = `<repo>/.claude/worktrees/mm-<slug>`, branch = `mm/<slug>`. |
|
||||
| `--focus "<line>"` | — | One-line focus appended to the task frame. |
|
||||
| `--brief <file>` | — | Escape hatch: use this file as the task body instead of (or in addition to) the fetched item. |
|
||||
| `--docs all\|recent\|none` | `recent` | Which item documents to bundle. `recent` = documents updated in the last 14 days; falls back to `all` if the CLI does not expose update dates. The full list of document names is always included in the prompt regardless of which are bundled. |
|
||||
| `--tools "<list>"` | — | Extra `--allowedTools` entries merged into the default allowlist (comma-separated). |
|
||||
| `--timeout <secs>` | `1800` | Max seconds the sub-session may run (`timeout <secs>` wrapper). |
|
||||
| `--upload` | off | After the sub-session exits, upload its report as a document on the item and patch `flow_state` to `impl-complete`. Implemented only in combination with `--collect` (see Behaviour §6) since launch is detached. |
|
||||
| `--collect` | off | Don't launch. Print `git log --oneline main..HEAD`, `git diff --stat main...HEAD`, and the report file for an already-run slug's worktree. |
|
||||
| `--dryrun`, `-n` | off | Assemble everything, print the bundle manifest, the full prompt, and the exact command that would run. Create nothing: no worktree, no branch, no launch, no bundle/prompt files left outside the report dir's dryrun preview. |
|
||||
| `--help`, `-h` | — | Show usage and exit 0. |
|
||||
|
||||
### Environment overrides (for testing)
|
||||
|
||||
| Var | Effect |
|
||||
|-----|--------|
|
||||
| `MM_WORKTREE_TASK_FAKE_ITEM=<file>` | Read item JSON from this file instead of calling `cp_cli planner items get`. |
|
||||
| `MM_WORKTREE_TASK_FAKE_DOCS_DIR=<dir>` | Read document list/bodies from this directory instead of calling `cp_cli planner items documents *`. Expected layout: `<dir>/list.json` (`{"documents": [{"name": ..., "updated_at": ...}, ...]}`) and `<dir>/<name>` per document body. |
|
||||
|
||||
## Behaviour
|
||||
|
||||
1. Determine repo root: `git rev-parse --show-toplevel` from cwd. Fail (exit 1) if not inside a git repository.
|
||||
2. Resolve `<slug>`: `--slug` if given, else first 8 chars of `<item-uuid>` (or required alongside `--brief` when no uuid is given).
|
||||
3. Resolve report dir: `${XDG_RUNTIME_DIR:-/tmp}/mm-worktree-task/<repo-name>/`. Create it (unless `--dryrun`, which only prints the path).
|
||||
4. **Fetch the item** (run from repo root, needs the repo's `lib`), unless `MM_WORKTREE_TASK_FAKE_ITEM` is set:
|
||||
- `python3 -m lib.cp_cli planner items get <uuid> --json` → fields used: `id`, `title`, `label`, `state`, `flow_state`, `body`, `tags`, `parent_id`, `related_items`.
|
||||
- `python3 -m lib.cp_cli planner items documents list <uuid> --json` → document names (+ `updated_at` if present).
|
||||
- For each document selected by `--docs`: `python3 -m lib.cp_cli planner items documents get <uuid> <name>` (fake dir equivalent: read `<dir>/<name>`).
|
||||
- If `parent_id` is set: fetch the parent item (title + body) via `planner items get <parent_id> --json`.
|
||||
- If `related_items` includes `blocked_by` targets: fetch each target's title + state.
|
||||
- Fetch sibling tasks under the same parent: `planner items list <project> --parent <parent_id> --json` if `parent_id` is set (best-effort; skip silently if the command errors, e.g. flag unsupported for the active project).
|
||||
- Fetching uses the operator's own `cp_cli` auth — the sub-session itself never calls `cp_cli`.
|
||||
5. Write `<report-dir>/<slug>-bundle.md` with `## ` sections: `## Item`, `## Parent` (if any), `## Blocked By` (if any), `## Siblings` (if any), `## Documents` (full name list, marking which are bundled below), then one `## Document: <name>` section per bundled document with its body. In `--dryrun`, the bundle is still assembled and its manifest (section list + doc names) is printed, but the file is not written to disk — instead its path is printed as the path that would be written.
|
||||
6. Create the worktree (skipped entirely under `--dryrun` and `--collect`): `git worktree add <repo>/.claude/worktrees/mm-<slug> -b mm/<slug> main`. Fail clearly (exit 1) if the branch or worktree directory already exists.
|
||||
7. Assemble the prompt file `<report-dir>/<slug>-prompt.txt`:
|
||||
- Fixed preamble: worktree path and branch; never `cd` elsewhere, never `git push`, never `git stash`, never touch `git config`; spec-driven (a behaviour change requires the matching `spec/*.md` requirement in the same commit); Python 3.12 / Pydantic v2 / `@pytest.mark.asyncio` only; do NOT run the full test suite, only the touched test files; don't fix test infrastructure, mock it; read specs and existing tests before editing, match existing names; keep the diff minimal; commit with `git add -A && git commit -m "<type>: <summary> (<uuid>)"` after verifying `git rev-parse --abbrev-ref HEAD` prints `mm/<slug>`; write the report to `<report-dir>/<slug>-result.md` with sections Summary / Files changed / Tests run / Decisions / Open questions; one-line stdout ack only.
|
||||
- Task frame: `Implement work item <uuid>: <title>. Read the bundle at <bundle-path> fully before editing. The item body is the requirement; documents are supporting context. Documents available on the item (not all bundled): <names>.`
|
||||
- `--focus` line, if given, appended after the task frame.
|
||||
- If `--brief <file>` is given, its content is appended (or used standalone as the task body when no uuid was given).
|
||||
8. Launch detached (skipped under `--dryrun` and `--collect`):
|
||||
```
|
||||
cd <worktree> && setsid nohup timeout <secs> ask-minimax-run --model "MiniMax-M3[1m]" \
|
||||
--allowedTools "<default-allowlist>[,<--tools>]" --permission-mode acceptEdits \
|
||||
--add-dir <report-dir> -p "$(cat <prompt-file>)" \
|
||||
> <report-dir>/<slug>-stdout.txt 2>&1
|
||||
```
|
||||
wrapped in a backgrounded subshell that, on exit, appends `<slug> rc=<rc> elapsed=<s>s` to `<report-dir>/launch.log`. The parent process returns immediately.
|
||||
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*)`.
|
||||
9. Print: report dir, prompt file path, stdout file path, launch.log path, and a one-line `poll with: tail -f <stdout file>` hint.
|
||||
10. `--collect`: instead of steps 6-9, for the given `<slug>`'s existing worktree, print `git -C <worktree> log --oneline main..HEAD`, `git -C <worktree> diff --stat main...HEAD`, and the contents of `<report-dir>/<slug>-result.md` if present. If `--upload` is also given and the report file exists:
|
||||
- `python3 -m lib.cp_cli planner items documents put <uuid> mm-<slug>-<YYYY-MM-DD>.md --file <report>`
|
||||
- `python3 -m lib.cp_cli planner items patch <uuid> --flow-state impl-complete --description "mm-worktree-task: <slug> committed on mm/<slug>"`
|
||||
`--upload` without `--collect` is an error (exit 1): "`--upload` requires `--collect` (launch is detached; upload happens after the sub-session has produced a report)".
|
||||
|
||||
## Dryrun Behaviour
|
||||
|
||||
With `--dryrun`/`-n`:
|
||||
- No worktree, branch, bundle file, prompt file, or report-dir directory is created; no `ask-minimax-run` process is started.
|
||||
- Prints, in order: resolved slug/uuid/worktree path/branch, the bundle manifest (section names + document names, bundled vs. available-only), the full assembled prompt text, and the exact `ask-minimax-run` command line that would run (including the resolved allowlist and timeout).
|
||||
- Exit 0.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
| Case | Handling |
|
||||
|------|----------|
|
||||
| Not inside a git repo | Error, exit 1 |
|
||||
| Neither `<item-uuid>` nor (`--brief` + `--slug`) given | Error: `item-uuid is required unless --brief and --slug are both given`, exit 1 |
|
||||
| Worktree dir or branch `mm/<slug>` already exists (non-`--dryrun`, non-`--collect`) | Error, exit 1, no partial state left behind |
|
||||
| `--collect` for a slug with no worktree | Error: `no worktree found for slug '<slug>'`, exit 1 |
|
||||
| `--upload` without `--collect` | Error, exit 1 |
|
||||
| `--docs recent` but CLI gives no `updated_at` | Fall back to `--docs all` behaviour, note this in bundle `## Documents` section |
|
||||
| cp_cli sibling-list command unsupported | Skip `## Siblings` section silently (best-effort) |
|
||||
|
||||
## Security
|
||||
|
||||
- The MiniMax sub-session never calls `cp_cli` and never receives an operator token; only files (bundle, prompt) are passed into the worktree/report dir via `--add-dir`.
|
||||
- The script never reads or prints the MiniMax API key; `ask-minimax-run` handles `provider.env` itself.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Dry run against a real item
|
||||
mm-worktree-task 1f17f61e-317e-4e10-bcd6-bee6a3264379 --slug ci-gate --dryrun
|
||||
|
||||
# Launch for real, with an extra focus line and extra tool
|
||||
mm-worktree-task 1f17f61e-317e-4e10-bcd6-bee6a3264379 --slug ci-gate \
|
||||
--focus "Only touch .gitea/workflows and pyproject.toml" \
|
||||
--tools "Bash(scripts/*)"
|
||||
|
||||
# Poll progress
|
||||
tail -f "${XDG_RUNTIME_DIR:-/tmp}/mm-worktree-task/agent-runtimes/ci-gate-stdout.txt"
|
||||
|
||||
# Collect the result once done
|
||||
mm-worktree-task 1f17f61e-317e-4e10-bcd6-bee6a3264379 --slug ci-gate --collect
|
||||
|
||||
# Collect and upload the report + mark impl-complete
|
||||
mm-worktree-task 1f17f61e-317e-4e10-bcd6-bee6a3264379 --slug ci-gate --collect --upload
|
||||
```
|
||||
1
tests/fixtures/mm-worktree-task-docs/list.json
vendored
Normal file
1
tests/fixtures/mm-worktree-task-docs/list.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"documents": [{"name": "notes.md", "updated_at": "2026-09-01T00:00:00Z"}, {"name": "old-notes.md", "updated_at": "2020-01-01T00:00:00Z"}]}
|
||||
1
tests/fixtures/mm-worktree-task-docs/notes.md
vendored
Normal file
1
tests/fixtures/mm-worktree-task-docs/notes.md
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Some supporting notes.
|
||||
1
tests/fixtures/mm-worktree-task-docs/old-notes.md
vendored
Normal file
1
tests/fixtures/mm-worktree-task-docs/old-notes.md
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Stale notes, should not be bundled under recent.
|
||||
11
tests/fixtures/mm-worktree-task-item.json
vendored
Normal file
11
tests/fixtures/mm-worktree-task-item.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "1f17f61e-317e-4e10-bcd6-bee6a3264379",
|
||||
"title": "Wire up CI merge gate",
|
||||
"label": "task",
|
||||
"state": "open",
|
||||
"flow_state": "ready",
|
||||
"body": "Make the CI merge gate real: run tests on push to main, not just PRs.",
|
||||
"tags": ["ci"],
|
||||
"parent_id": null,
|
||||
"related_items": []
|
||||
}
|
||||
141
tests/test-mm-worktree-task.sh
Executable file
141
tests/test-mm-worktree-task.sh
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-mm-worktree-task.sh — Verify mm-worktree-task dryrun behaviour against the spec.
|
||||
#
|
||||
# Strategy: all assertions use --dryrun (-n) with fake item/docs fixtures, so
|
||||
# no cp_cli calls, worktrees, branches, or launches happen.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
SCRIPT="$REPO_ROOT/scripts/mm-worktree-task"
|
||||
FIXTURE_ITEM="$REPO_ROOT/tests/fixtures/mm-worktree-task-item.json"
|
||||
FIXTURE_DOCS_DIR="$REPO_ROOT/tests/fixtures/mm-worktree-task-docs"
|
||||
TEST_UUID="1f17f61e-317e-4e10-bcd6-bee6a3264379"
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_contains() {
|
||||
local label=$1 needle=$2 haystack=$3
|
||||
if [[ "$haystack" == *"$needle"* ]]; then
|
||||
printf " ${GREEN}PASS${NC} %s\n" "$label"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
printf " ${RED}FAIL${NC} %s\n" "$label"
|
||||
printf " expected to contain: %s\n" "$needle"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local label=$1 needle=$2 haystack=$3
|
||||
if [[ "$haystack" != *"$needle"* ]]; then
|
||||
printf " ${GREEN}PASS${NC} %s\n" "$label"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
printf " ${RED}FAIL${NC} %s\n" "$label"
|
||||
printf " expected NOT to contain: %s\n" "$needle"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_eq() {
|
||||
local label=$1 expected=$2 actual=$3
|
||||
if [[ "$expected" == "$actual" ]]; then
|
||||
printf " ${GREEN}PASS${NC} %s\n" "$label"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
printf " ${RED}FAIL${NC} %s\n" "$label"
|
||||
printf " expected: %s\n actual: %s\n" "$expected" "$actual"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "== mm-worktree-task: --help =="
|
||||
HELP_OUT="$("$SCRIPT" --help 2>&1)"
|
||||
HELP_RC=$?
|
||||
assert_eq "help exits 0" "0" "$HELP_RC"
|
||||
assert_contains "help mentions usage" "Usage: mm-worktree-task" "$HELP_OUT"
|
||||
assert_contains "help mentions --dryrun" "--dryrun" "$HELP_OUT"
|
||||
|
||||
echo
|
||||
echo "== mm-worktree-task: missing uuid errors =="
|
||||
MISSING_OUT="$("$SCRIPT" --dryrun 2>&1)"
|
||||
MISSING_RC=$?
|
||||
assert_eq "missing uuid exits 1" "1" "$MISSING_RC"
|
||||
assert_contains "missing uuid error message" "item-uuid is required" "$MISSING_OUT"
|
||||
|
||||
echo
|
||||
echo "== mm-worktree-task: --dryrun with fixture item =="
|
||||
export MM_WORKTREE_TASK_FAKE_ITEM="$FIXTURE_ITEM"
|
||||
export MM_WORKTREE_TASK_FAKE_DOCS_DIR="$FIXTURE_DOCS_DIR"
|
||||
|
||||
DRY_OUT="$("$SCRIPT" "$TEST_UUID" --slug testslug --dryrun 2>&1)"
|
||||
DRY_RC=$?
|
||||
assert_eq "dryrun exits 0" "0" "$DRY_RC"
|
||||
assert_contains "dryrun shows the item title" "Wire up CI merge gate" "$DRY_OUT"
|
||||
assert_contains "dryrun shows worktree path" ".claude/worktrees/mm-testslug" "$DRY_OUT"
|
||||
assert_contains "dryrun shows branch name" "mm/testslug" "$DRY_OUT"
|
||||
assert_contains "dryrun shows bundle path" "testslug-bundle.md" "$DRY_OUT"
|
||||
assert_contains "dryrun shows the ask-minimax-run command" "ask-minimax-run" "$DRY_OUT"
|
||||
assert_contains "dryrun command uses MiniMax-M3[1m] model" 'MiniMax-M3[1m]' "$DRY_OUT"
|
||||
assert_contains "dryrun bundles recent doc notes.md" "notes.md (bundled below)" "$DRY_OUT"
|
||||
assert_contains "dryrun lists old doc as available only" "old-notes.md (available only)" "$DRY_OUT"
|
||||
assert_not_contains "dryrun does not bundle stale doc body" "Stale notes, should not be bundled" "$DRY_OUT"
|
||||
assert_contains "dryrun mentions timeout default" "1800" "$DRY_OUT"
|
||||
|
||||
# No side effects
|
||||
WORKTREE_PATH="$REPO_ROOT/.claude/worktrees/mm-testslug"
|
||||
if [[ -d "$WORKTREE_PATH" ]]; then
|
||||
printf " ${RED}FAIL${NC} dryrun did not create a worktree\n"
|
||||
FAIL=$((FAIL + 1))
|
||||
else
|
||||
printf " ${GREEN}PASS${NC} dryrun did not create a worktree\n"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== mm-worktree-task: --docs all bundles everything =="
|
||||
DRY_ALL_OUT="$("$SCRIPT" "$TEST_UUID" --slug testslug2 --docs all --dryrun 2>&1)"
|
||||
assert_contains "docs=all bundles old-notes.md" "old-notes.md (bundled below)" "$DRY_ALL_OUT"
|
||||
|
||||
echo
|
||||
echo "== mm-worktree-task: --docs none bundles nothing =="
|
||||
DRY_NONE_OUT="$("$SCRIPT" "$TEST_UUID" --slug testslug3 --docs none --dryrun 2>&1)"
|
||||
assert_contains "docs=none has no documents section entries" "(none)" "$DRY_NONE_OUT"
|
||||
|
||||
echo
|
||||
echo "== mm-worktree-task: --focus is appended to the prompt =="
|
||||
DRY_FOCUS_OUT="$("$SCRIPT" "$TEST_UUID" --slug testslug4 --focus "Only touch CI files" --dryrun 2>&1)"
|
||||
assert_contains "focus line present" "Only touch CI files" "$DRY_FOCUS_OUT"
|
||||
|
||||
echo
|
||||
echo "== mm-worktree-task: --collect with no worktree errors =="
|
||||
unset MM_WORKTREE_TASK_FAKE_ITEM
|
||||
unset MM_WORKTREE_TASK_FAKE_DOCS_DIR
|
||||
COLLECT_OUT="$("$SCRIPT" "$TEST_UUID" --slug does-not-exist-slug --collect 2>&1)"
|
||||
COLLECT_RC=$?
|
||||
assert_eq "collect with missing worktree exits 1" "1" "$COLLECT_RC"
|
||||
assert_contains "collect error message" "no worktree found" "$COLLECT_OUT"
|
||||
|
||||
echo
|
||||
echo "== mm-worktree-task: --upload without --collect errors =="
|
||||
UPLOAD_OUT="$("$SCRIPT" "$TEST_UUID" --slug testslug --upload --dryrun 2>&1)"
|
||||
UPLOAD_RC=$?
|
||||
assert_eq "upload without collect exits 1" "1" "$UPLOAD_RC"
|
||||
assert_contains "upload error message" "--upload requires --collect" "$UPLOAD_OUT"
|
||||
|
||||
echo
|
||||
echo "=================================="
|
||||
echo " PASS: $PASS FAIL: $FAIL"
|
||||
echo "=================================="
|
||||
|
||||
if [[ $FAIL -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
Reference in New Issue
Block a user