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:
Paul O'Reilly
2026-09-10 22:57:55 +12:00
parent aa8883dbc5
commit 9a6d7135c5
8 changed files with 640 additions and 0 deletions

View File

@@ -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
View 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}"

View 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
```

View 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"}]}

View File

@@ -0,0 +1 @@
Some supporting notes.

View File

@@ -0,0 +1 @@
Stale notes, should not be bundled under recent.

View 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
View 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