diff --git a/README.md b/README.md index 246863d..81af070 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/scripts/mm-worktree-task b/scripts/mm-worktree-task new file mode 100755 index 0000000..77785ca --- /dev/null +++ b/scripts/mm-worktree-task @@ -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 [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: + Full 36-char planner work-item UUID. Required unless + --brief is given together with --slug. + +Options: + --slug Worktree/branch suffix (default: first 8 chars of uuid) + --focus "" One-line focus appended to the task frame + --brief 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 "" Extra --allowedTools entries (comma-separated) + --timeout 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= fake `planner items get --json` output + MM_WORKTREE_TASK_FAKE_DOCS_DIR= fake docs: /list.json + / +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 <: (${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}" diff --git a/specs/mm-worktree-task.spec.md b/specs/mm-worktree-task.spec.md new file mode 100644 index 0000000..ab8a27f --- /dev/null +++ b/specs/mm-worktree-task.spec.md @@ -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 [OPTIONS] +``` + +### Arguments + +| Arg | Description | +|-----|-------------| +| `` | Full 36-char planner work-item UUID. Required unless `--brief` is given together with `--slug`. | + +### Options + +| Flag | Default | Description | +|------|---------|-------------| +| `--slug ` | first 8 chars of the uuid | Worktree/branch name suffix. Worktree = `/.claude/worktrees/mm-`, branch = `mm/`. | +| `--focus ""` | — | One-line focus appended to the task frame. | +| `--brief ` | — | 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 ""` | — | Extra `--allowedTools` entries merged into the default allowlist (comma-separated). | +| `--timeout ` | `1800` | Max seconds the sub-session may run (`timeout ` 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=` | Read item JSON from this file instead of calling `cp_cli planner items get`. | +| `MM_WORKTREE_TASK_FAKE_DOCS_DIR=` | Read document list/bodies from this directory instead of calling `cp_cli planner items documents *`. Expected layout: `/list.json` (`{"documents": [{"name": ..., "updated_at": ...}, ...]}`) and `/` 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` if given, else first 8 chars of `` (or required alongside `--brief` when no uuid is given). +3. Resolve report dir: `${XDG_RUNTIME_DIR:-/tmp}/mm-worktree-task//`. 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 --json` → fields used: `id`, `title`, `label`, `state`, `flow_state`, `body`, `tags`, `parent_id`, `related_items`. + - `python3 -m lib.cp_cli planner items documents list --json` → document names (+ `updated_at` if present). + - For each document selected by `--docs`: `python3 -m lib.cp_cli planner items documents get ` (fake dir equivalent: read `/`). + - If `parent_id` is set: fetch the parent item (title + body) via `planner items get --json`. + - If `related_items` includes `blocked_by` targets: fetch each target's title + state. + - Fetch sibling tasks under the same parent: `planner items list --parent --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 `/-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: ` 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 /.claude/worktrees/mm- -b mm/ main`. Fail clearly (exit 1) if the branch or worktree directory already exists. +7. Assemble the prompt file `/-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 ": ()"` after verifying `git rev-parse --abbrev-ref HEAD` prints `mm/`; write the report to `/-result.md` with sections Summary / Files changed / Tests run / Decisions / Open questions; one-line stdout ack only. + - Task frame: `Implement work item : . 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 +``` diff --git a/tests/fixtures/mm-worktree-task-docs/list.json b/tests/fixtures/mm-worktree-task-docs/list.json new file mode 100644 index 0000000..33b57ae --- /dev/null +++ b/tests/fixtures/mm-worktree-task-docs/list.json @@ -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"}]} diff --git a/tests/fixtures/mm-worktree-task-docs/notes.md b/tests/fixtures/mm-worktree-task-docs/notes.md new file mode 100644 index 0000000..4204607 --- /dev/null +++ b/tests/fixtures/mm-worktree-task-docs/notes.md @@ -0,0 +1 @@ +Some supporting notes. diff --git a/tests/fixtures/mm-worktree-task-docs/old-notes.md b/tests/fixtures/mm-worktree-task-docs/old-notes.md new file mode 100644 index 0000000..6df7b37 --- /dev/null +++ b/tests/fixtures/mm-worktree-task-docs/old-notes.md @@ -0,0 +1 @@ +Stale notes, should not be bundled under recent. diff --git a/tests/fixtures/mm-worktree-task-item.json b/tests/fixtures/mm-worktree-task-item.json new file mode 100644 index 0000000..4fb4ed3 --- /dev/null +++ b/tests/fixtures/mm-worktree-task-item.json @@ -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": [] +} diff --git a/tests/test-mm-worktree-task.sh b/tests/test-mm-worktree-task.sh new file mode 100755 index 0000000..926f72c --- /dev/null +++ b/tests/test-mm-worktree-task.sh @@ -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