feat: review-minimax-run + ci-triage-minimax-run MiniMax offload wrappers

First-pass branch review and CI failure triage in MiniMax M3 sub-sessions.
Both materialise inputs themselves (no Bash in the sub-session), gate
results strictly, retry once, and follow the explore-minimax 0/1/2/3 exit
contract. Specs with RM-n/CT-n IDs; offline tests via fake seams (36
assertions). Verified live: planted-issue branch -> 5 confirmed findings,
clean branch -> NO FINDINGS, failed gac-scratch run -> correct step +
honest low confidence.

Claude-Session: https://claude.ai/code/session_01Ku87DDWwbJkTx6SuV3sQDc
This commit is contained in:
Paul O'Reilly
2026-09-11 13:48:09 +12:00
parent abed9e7c25
commit 9488335713
6 changed files with 843 additions and 0 deletions

212
scripts/ci-triage-minimax-run Executable file
View File

@@ -0,0 +1,212 @@
#!/usr/bin/env bash
# Triage a failing Gitea Actions run in a MiniMax M3 sub-session.
# Fetches logs via gitea-agent-companion (token stays inside gac), redacts
# credential-shaped content, and has MiniMax write a structured triage file.
# Spec: specs/ci-triage-minimax-run.spec.md. Used by /ci-triage-minimax.
#
# Usage:
# ci-triage-minimax-run --repo <owner/repo> --run <N> [--job <name|id>]
# [--out <triage-file>] [--tail N] [--max-turns N] [--timeout SECS]
# [--dryrun|-n]
#
# Exit codes:
# 0 triage file written
# 1 usage/config error (bad flags, gac usage error)
# 2 gac auth/API/not-found error, MiniMax failed after retry, or timeout
# 3 triage file missing or failed the result gate after retry
set -euo pipefail
usage() { grep '^#' "$0" | sed 's/^# \{0,1\}//' | sed -n '2,16p'; }
REPO=""
RUN=""
JOB=""
OUT_FILE=""
TAIL_LINES=2000
MAX_TURNS=25
TIMEOUT_SECS=300
DRYRUN=0
CRED_PATTERN='ghp_[A-Za-z0-9]|glpat-|x-access-token:|Authorization:|BEGIN [A-Z ]*PRIVATE KEY|AKIA[0-9A-Z]{16}'
while [[ $# -gt 0 ]]; do
case "$1" in
--repo) REPO="$2"; shift 2 ;;
--run) RUN="$2"; shift 2 ;;
--job) JOB="$2"; shift 2 ;;
--out) OUT_FILE="$2"; shift 2 ;;
--tail) TAIL_LINES="$2"; shift 2 ;;
--max-turns) MAX_TURNS="$2"; shift 2 ;;
--timeout) TIMEOUT_SECS="$2"; shift 2 ;;
--dryrun|-n) DRYRUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "error: unknown argument '$1'" >&2; usage >&2; exit 1 ;;
esac
done
# --- CT-1 preflight -----------------------------------------------------------
[[ "$REPO" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || { echo "error: --repo must be owner/repo" >&2; exit 1; }
[[ "$RUN" =~ ^[0-9]+$ ]] || { echo "error: --run must be a positive integer" >&2; exit 1; }
command -v jq >/dev/null || { echo "error: jq not found" >&2; exit 1; }
command -v ask-minimax-run >/dev/null || { echo "error: ask-minimax-run not on PATH" >&2; exit 1; }
command -v gitea-agent-companion >/dev/null || { echo "error: gitea-agent-companion not on PATH" >&2; exit 1; }
# gac (as of v0.0.2.10) does not fall back to the secrets path itself; point it
# at the well-known token file when the caller set no token env. Path only —
# the token content is never read or printed here.
if [[ -z "${GITEA_TOKEN:-}" && -z "${GITEA_TOKEN_FILE:-}" && -f "$HOME/dev/claude/secrets/gitea/ai_admin/token" ]]; then
export GITEA_TOKEN_FILE="$HOME/dev/claude/secrets/gitea/ai_admin/token"
fi
if [[ -z "$OUT_FILE" ]]; then
OUT_FILE="${XDG_RUNTIME_DIR:-/tmp}/ci-triage-minimax/${REPO//\//-}/run-$RUN-triage.md"
fi
OUT_DIR=$(dirname "$OUT_FILE")
mkdir -p "$OUT_DIR"
WORK_DIR=$(mktemp -d /tmp/ci-triage-minimax.XXXXXX)
trap 'rm -rf "$WORK_DIR"' EXIT
JOB_ARGS=()
[[ -n "$JOB" ]] && JOB_ARGS=(--job "$JOB")
GAC_CMD=(gitea-agent-companion inspect "$REPO" --run "$RUN" "${JOB_ARGS[@]}" --logs --json --tail "$TAIL_LINES")
# --- dryrun (before any fetch) ------------------------------------------------
if [[ "$DRYRUN" -eq 1 ]]; then
echo "== ci-triage-minimax-run --dryrun"
echo "repo: $REPO"
echo "run: $RUN job: ${JOB:-auto}"
echo "out: $OUT_FILE"
echo "tail: $TAIL_LINES max turns: $MAX_TURNS timeout: ${TIMEOUT_SECS}s"
echo "-- gac fetch command --"
echo "${GAC_CMD[*]} > run-logs.ndjson"
echo "-- failing step --"
echo "<run-logs not fetched in dryrun>"
echo "-- sub-session command --"
echo "cd <workdir> && timeout $TIMEOUT_SECS ask-minimax-run --model 'MiniMax-M3[1m]'" \
"--permission-mode default --allowedTools 'Read,Glob,Grep,Write'" \
"--disallowedTools 'Edit,MultiEdit,NotebookEdit,Bash,WebFetch,WebSearch'" \
"--max-turns $MAX_TURNS --output-format json --add-dir $OUT_DIR -p < prompt.txt"
exit 0
fi
# --- CT-2/CT-3 fetch with exit mapping ----------------------------------------
if [[ -n "${CI_TRIAGE_FAKE_LOGS:-}" ]]; then # CT-10 test seam
cp "$CI_TRIAGE_FAKE_LOGS" "$WORK_DIR/run-logs.ndjson"
echo "(fake logs — no job table)" > "$WORK_DIR/jobs.txt"
else
rc=0
"${GAC_CMD[@]}" > "$WORK_DIR/run-logs.ndjson" 2>"$WORK_DIR/gac-stderr.txt" || rc=$?
if [[ "$rc" -ne 0 ]]; then
cat "$WORK_DIR/gac-stderr.txt" >&2 || true
case "$rc" in
2) echo "error: gac usage error (see above)" >&2; exit 1 ;;
*) echo "error: gac failed rc=$rc (auth/API/run-not-found — possibly transient)" >&2; exit 2 ;;
esac
fi
gitea-agent-companion inspect "$REPO" --run "$RUN" > "$WORK_DIR/jobs.txt" 2>/dev/null || true
fi
# --- CT-4 redaction (in place; the log IS the payload) --------------------------
sed -E -i "s#($CRED_PATTERN)[^\"[:space:]]*#[REDACTED]#g" "$WORK_DIR/run-logs.ndjson"
# --- CT-5 envelope parse --------------------------------------------------------
FAILED_STEP=$(jq -r 'select(.failed_step != null) | .failed_step' "$WORK_DIR/run-logs.ndjson" 2>/dev/null | head -1)
SLICED=$(jq -r '.sliced // empty' "$WORK_DIR/run-logs.ndjson" 2>/dev/null | head -1)
# --- CT-6 prompt ----------------------------------------------------------------
FULL_PROMPT="$WORK_DIR/prompt.txt"
{
cat <<'PREAMBLE'
You are a CI FAILURE TRIAGE agent running non-interactively. Analyse the CI
run logs provided as files in your working directory. Rules:
- Inputs (in cwd): run-logs.ndjson (log lines + metadata envelope), jobs.txt.
- Write ONLY the triage file at the path given below. Modify nothing else.
- Do not run anything. Do not suggest running full test suites or builds.
- Search the log lines for these error signatures specifically:
error:|FAILED|Traceback|exit code|denied|timeout|not found|no such|ECONN|refused
- Quote log lines verbatim. Do NOT invent step names, file paths, or causes
you cannot point to in the log. If the log is truncated or step boundaries
are partial, say so — do not improvise.
Triage file format — exactly these sections:
## Failing step
## Error excerpt (<=10 verbatim log lines)
## Probable cause (1-3 sentences, must not be empty)
## Suggested next action (ONE command or one edit description; advisory only)
## Confidence (high|medium|low, one line why)
PREAMBLE
echo
echo "## Run metadata"
echo "repo: $REPO run: $RUN"
if [[ -n "$FAILED_STEP" ]]; then
echo "The CI system identified the failing step as: $FAILED_STEP"
echo "Confirm against the log; analyse WHY it failed."
else
echo "The CI system could NOT identify a failing step (failed_step is null:"
echo "cancelled/crashed run or no failed job). You MUST write the literal"
echo "word UNKNOWN under '## Failing step' unless the log itself proves one."
fi
[[ "$SLICED" == "true" ]] && echo "Note: the log window was sliced to the failure region."
echo
echo "## Triage file to write"
echo "$OUT_FILE"
} > "$FULL_PROMPT"
# --- CT-7 invocation ------------------------------------------------------------
run_once() {
local json_out="$1"
if [[ -n "${CI_TRIAGE_FAKE_JSON:-}" ]]; then # CT-10 test seam
cp "$CI_TRIAGE_FAKE_JSON" "$json_out"
# optional companion seam: plays the sub-session's Write of the triage file
[[ -n "${CI_TRIAGE_FAKE_TRIAGE:-}" ]] && cp "$CI_TRIAGE_FAKE_TRIAGE" "$OUT_FILE"
return 0
fi
( cd "$WORK_DIR" && timeout "$TIMEOUT_SECS" ask-minimax-run \
--model "MiniMax-M3[1m]" \
--permission-mode default \
--allowedTools "Read,Glob,Grep,Write" \
--disallowedTools "Edit,MultiEdit,NotebookEdit,Bash,WebFetch,WebSearch" \
--max-turns "$MAX_TURNS" \
--output-format json \
--add-dir "$OUT_DIR" \
-p < "$FULL_PROMPT" > "$json_out" 2>"$WORK_DIR/stderr.txt" )
}
gate_ok() { # CT-8
[[ -f "$OUT_FILE" ]] || return 1
grep -q '^## Failing step' "$OUT_FILE" || return 1
# non-blank content line under "## Probable cause"
awk '/^## Probable cause/{f=1; next} /^## /{f=0} f && NF {found=1} END{exit !found}' "$OUT_FILE"
}
JSON_OUT="$WORK_DIR/response.json"
RUN_FAILED=0
for attempt in 1 2; do
rm -f "$OUT_FILE" # CT-9: no stale attempt-1 file may satisfy the gate
RUN_FAILED=0
rc=0
run_once "$JSON_OUT" || rc=$?
if [[ "$rc" -eq 124 ]]; then
echo "error: sub-session timed out after ${TIMEOUT_SECS}s (not retried)" >&2
exit 2
fi
[[ "$rc" -ne 0 ]] && RUN_FAILED=1
jq -e 'select(.is_error == true)' "$JSON_OUT" >/dev/null 2>&1 && RUN_FAILED=1
[[ "$RUN_FAILED" -eq 0 ]] && gate_ok && break
if [[ $attempt -eq 1 ]]; then
echo "warn: attempt 1 failed ($(tail -1 "$WORK_DIR/stderr.txt" 2>/dev/null || echo 'no stderr')); retrying once" >&2
fi
done
if [[ "$RUN_FAILED" -eq 1 ]]; then
echo "error: MiniMax failed after retry" >&2
tail -5 "$WORK_DIR/stderr.txt" >&2 2>/dev/null || true
exit 2
fi
if ! gate_ok; then
echo "error: triage file missing or failed the result gate after retry" >&2
exit 3
fi
echo "ok: triage written to $OUT_FILE"

253
scripts/review-minimax-run Executable file
View File

@@ -0,0 +1,253 @@
#!/usr/bin/env bash
# First-pass code review of a git branch in a MiniMax M3 sub-session.
# The wrapper materialises the diff/log itself (the sub-session gets no Bash),
# runs MiniMax read-only except for one findings file, and gates the result.
# Spec: specs/review-minimax-run.spec.md. Used by /review-minimax; safe directly.
#
# Usage:
# review-minimax-run --worktree <path> [--base <ref>] [--out <findings-file>]
# [--profile agent-runtimes] [--max-diff-lines N] [--max-turns N]
# [--timeout SECS] [--dryrun|-n]
#
# Exit codes:
# 0 findings file written (may contain "NO FINDINGS")
# 1 usage/config error, secrets root, credential-shaped or empty/oversize diff
# 2 MiniMax failed after retry, timeout, or sub-session dirtied the worktree
# 3 findings file missing or failed the result gate after retry
set -euo pipefail
usage() { grep '^#' "$0" | sed 's/^# \{0,1\}//' | sed -n '2,16p'; }
WORKTREE=""
BASE="main"
OUT_FILE=""
PROFILE=""
MAX_DIFF_LINES=6000
MAX_TURNS=40
TIMEOUT_SECS=540
DRYRUN=0
# RM-3: credential-shaped content in the diff aborts the review outright.
CRED_PATTERN='ghp_[A-Za-z0-9]|glpat-|x-access-token:|Authorization:|BEGIN [A-Z ]*PRIVATE KEY|AKIA[0-9A-Z]{16}'
while [[ $# -gt 0 ]]; do
case "$1" in
--worktree) WORKTREE="$2"; shift 2 ;;
--base) BASE="$2"; shift 2 ;;
--out) OUT_FILE="$2"; shift 2 ;;
--profile) PROFILE="$2"; shift 2 ;;
--max-diff-lines) MAX_DIFF_LINES="$2"; shift 2 ;;
--max-turns) MAX_TURNS="$2"; shift 2 ;;
--timeout) TIMEOUT_SECS="$2"; shift 2 ;;
--dryrun|-n) DRYRUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "error: unknown argument '$1'" >&2; usage >&2; exit 1 ;;
esac
done
# --- RM-1 preflight -----------------------------------------------------------
[[ -n "$WORKTREE" ]] || { echo "error: --worktree required" >&2; exit 1; }
command -v jq >/dev/null || { echo "error: jq not found" >&2; exit 1; }
command -v ask-minimax-run >/dev/null || { echo "error: ask-minimax-run not on PATH" >&2; exit 1; }
WORKTREE=$(realpath -e "$WORKTREE" 2>/dev/null) || { echo "error: worktree does not exist" >&2; exit 1; }
case "$WORKTREE" in
*/secrets|*/secrets/*|*/local_secrets|*/local_secrets/*)
echo "error: refusing secrets path '$WORKTREE'" >&2; exit 1 ;;
esac
git -C "$WORKTREE" rev-parse --show-toplevel >/dev/null 2>&1 || { echo "error: '$WORKTREE' is not a git checkout" >&2; exit 1; }
BRANCH=$(git -C "$WORKTREE" rev-parse --abbrev-ref HEAD)
[[ "$BRANCH" != "HEAD" ]] || { echo "error: worktree is on a detached HEAD" >&2; exit 1; }
git -C "$WORKTREE" rev-parse --verify --quiet "$BASE" >/dev/null || { echo "error: base ref '$BASE' does not resolve" >&2; exit 1; }
REPO_NAME=$(basename "$(git -C "$WORKTREE" rev-parse --show-toplevel)")
BRANCH_SLUG=${BRANCH//\//-}
if [[ -z "$OUT_FILE" ]]; then
OUT_FILE="${XDG_RUNTIME_DIR:-/tmp}/review-minimax/$REPO_NAME/$BRANCH_SLUG-findings.md"
fi
OUT_DIR=$(dirname "$OUT_FILE")
mkdir -p "$OUT_DIR"
WORK_DIR=$(mktemp -d /tmp/review-minimax.XXXXXX)
trap 'rm -rf "$WORK_DIR"' EXIT
# --- RM-2 materialise inputs (wrapper runs git, never the sub-session) --------
git -C "$WORKTREE" diff "$BASE...HEAD" -- \
':(exclude)secrets/**' ':(exclude)local_secrets/**' \
':(exclude)*.pem' ':(exclude)*.key' ':(exclude)*secret*' \
> "$WORK_DIR/diff.patch"
git -C "$WORKTREE" log --oneline "$BASE..HEAD" > "$WORK_DIR/commits.txt"
git -C "$WORKTREE" diff --stat "$BASE...HEAD" > "$WORK_DIR/stat.txt"
# --- RM-3 / RM-4 gates --------------------------------------------------------
if grep -qE "$CRED_PATTERN" "$WORK_DIR/diff.patch"; then
echo "error: diff contains credential-shaped content; review in-session" >&2
exit 1
fi
DIFF_LINES=$(wc -l < "$WORK_DIR/diff.patch")
[[ "$DIFF_LINES" -gt 0 ]] || { echo "error: empty diff $BASE...HEAD — nothing to review" >&2; exit 1; }
if [[ "$DIFF_LINES" -gt "$MAX_DIFF_LINES" ]]; then
echo "error: diff is $DIFF_LINES lines (> $MAX_DIFF_LINES); raise --max-diff-lines or review in-session" >&2
exit 1
fi
# --- RM-5 prompt --------------------------------------------------------------
FULL_PROMPT="$WORK_DIR/prompt.txt"
{
cat <<'PREAMBLE'
You are a FIRST-PASS CODE REVIEWER running non-interactively. Review the branch
diff provided as files in your working directory. Rules:
- Inputs (in cwd): diff.patch (the review scope), commits.txt, stat.txt.
- The worktree listed below is readable for SURROUNDING CONTEXT ONLY.
- Write ONLY the findings file at the path given below. Never modify, create,
or delete any other file anywhere.
- Do not run anything. Do not fix anything. Review only.
- If an input file is missing or truncated, write a findings file saying
exactly that and stop — do not improvise or fetch more.
- Every finding MUST cite file:line from the diff or worktree, quote at most
10 lines, and carry a severity and a confidence. Do NOT invent names, paths,
or behaviour — a claim you cannot cite is omitted, not guessed.
Review checklist — look specifically for these patterns:
- Injection: string-built SQL; subprocess with shell=True; unquoted shell
variables; eval/exec on external input; paths used without realpath checks.
- Credentials: hardcoded password/api_key/token assignments; secrets passed as
command-line arguments; verify=False on HTTP calls.
- Weak crypto: random.* for security values; md5/sha1 for passwords.
- Over-permissive: chmod 777/666; 0.0.0.0 binds without justification;
debug=True; privileged/cluster-admin in manifests; :latest or unpinned images.
- Supply chain: curl|bash or wget|sh without checksum; unpinned dependencies.
- Operational: webhook/POST handlers without idempotency or duplicate checks;
health endpoints returning 200 unconditionally; read-then-write without
transaction or lock; CI steps consuming prior artifacts without needs:.
- Error handling: exceptions swallowed silently; internal detail leaked to
clients; bare except around control flow.
- Tests in the diff: assertions that cannot fail; fixtures that bypass the
auth/dependency they claim to exercise; asserts on wrong response shape.
Findings file format (exactly this shape):
- One block per finding:
### [BLOCKER|SHOULD-FIX|NIT] <short title>
- Location: <file>:<line>
- Category: <checklist category>
- Confidence: confirmed|suspected
- Excerpt (<=10 lines) and one-sentence rationale.
- Order blocks most-severe first.
- If the diff is clean against the checklist, write exactly: NO FINDINGS
PREAMBLE
if [[ "$PROFILE" == "agent-runtimes" ]]; then
cat <<'PROFILEBLOCK'
Additional profile checklist (agent-runtimes test files):
- API test paths missing the /v1/ prefix (404s at runtime).
- Assertions on body["type"] instead of body["detail"]["type"] (RFC 9457 is
wrapped inside FastAPI's detail).
- Manifest bodies with {params, nodes, definition_version} at top level
instead of {project_id, space_id, definition_version, definition:{...}}.
- Subscript access on dataclass fixtures (obj["field"] instead of obj.field).
- Local auth-header fixture dicts that do not override the authenticate
dependency (cross-tenant checks silently pass as anonymous).
PROFILEBLOCK
fi
echo
echo "## Worktree (context reads only)"
echo "$WORKTREE (branch: $BRANCH, base: $BASE)"
echo
echo "## Findings file to write"
echo "$OUT_FILE"
} > "$FULL_PROMPT"
# --- dryrun -------------------------------------------------------------------
if [[ "$DRYRUN" -eq 1 ]]; then
echo "== review-minimax-run --dryrun"
echo "worktree: $WORKTREE"
echo "branch: $BRANCH"
echo "base: $BASE"
echo "out: $OUT_FILE"
echo "profile: ${PROFILE:-none}"
echo "diff lines: $DIFF_LINES (budget $MAX_DIFF_LINES)"
echo "max turns: $MAX_TURNS timeout: ${TIMEOUT_SECS}s"
echo "-- stat --"
cat "$WORK_DIR/stat.txt"
echo "-- prompt --"
cat "$FULL_PROMPT"
echo "-- command --"
echo "cd $WORK_DIR && timeout $TIMEOUT_SECS ask-minimax-run --model 'MiniMax-M3[1m]'" \
"--permission-mode default --allowedTools 'Read,Glob,Grep,Write'" \
"--disallowedTools 'Edit,MultiEdit,NotebookEdit,Bash,WebFetch,WebSearch'" \
"--max-turns $MAX_TURNS --output-format json --add-dir $WORKTREE --add-dir $OUT_DIR -p < prompt.txt"
exit 0
fi
# --- RM-6 invocation ----------------------------------------------------------
run_once() {
local json_out="$1"
if [[ -n "${REVIEW_MINIMAX_FAKE_JSON:-}" ]]; then # RM-11 test seam
cp "$REVIEW_MINIMAX_FAKE_JSON" "$json_out"
# optional companion seam: plays the sub-session's Write of the findings file
[[ -n "${REVIEW_MINIMAX_FAKE_FINDINGS:-}" ]] && cp "$REVIEW_MINIMAX_FAKE_FINDINGS" "$OUT_FILE"
return 0
fi
( cd "$WORK_DIR" && timeout "$TIMEOUT_SECS" ask-minimax-run \
--model "MiniMax-M3[1m]" \
--permission-mode default \
--allowedTools "Read,Glob,Grep,Write" \
--disallowedTools "Edit,MultiEdit,NotebookEdit,Bash,WebFetch,WebSearch" \
--max-turns "$MAX_TURNS" \
--output-format json \
--add-dir "$WORKTREE" \
--add-dir "$OUT_DIR" \
-p < "$FULL_PROMPT" > "$json_out" 2>"$WORK_DIR/stderr.txt" )
}
tree_clean() { [[ -z "$(git -C "$WORKTREE" status --porcelain)" ]]; }
gate_ok() { # RM-8: strict — severity block + resolvable citation, or NO FINDINGS
[[ -f "$OUT_FILE" ]] || return 1
grep -q 'NO FINDINGS' "$OUT_FILE" && return 0
grep -qE '^### \[(BLOCKER|SHOULD-FIX|NIT)\]' "$OUT_FILE" || return 1
local cited
while read -r cited; do
[[ -e "$WORKTREE/$cited" || -e "$cited" ]] && return 0
done < <(grep -oE 'Location: [^:[:space:]]+' "$OUT_FILE" | sed 's/^Location: //')
return 1
}
JSON_OUT="$WORK_DIR/response.json"
RUN_FAILED=0
for attempt in 1 2; do
rm -f "$OUT_FILE" # RM-9: a stale attempt-1 file must not satisfy the gate
RUN_FAILED=0
rc=0
run_once "$JSON_OUT" || rc=$?
if [[ "$rc" -eq 124 ]]; then
echo "error: sub-session timed out after ${TIMEOUT_SECS}s (not retried)" >&2
exit 2
fi
[[ "$rc" -ne 0 ]] && RUN_FAILED=1
jq -e 'select(.is_error == true)' "$JSON_OUT" >/dev/null 2>&1 && RUN_FAILED=1
if ! tree_clean; then # RM-7
echo "error: sub-session dirtied the worktree — inspect: git -C $WORKTREE status" >&2
exit 2
fi
[[ "$RUN_FAILED" -eq 0 ]] && gate_ok && break
if [[ $attempt -eq 1 ]]; then
echo "warn: attempt 1 failed ($(tail -1 "$WORK_DIR/stderr.txt" 2>/dev/null || echo 'no stderr')); retrying once" >&2
fi
done
if [[ "$RUN_FAILED" -eq 1 ]]; then
echo "error: MiniMax failed after retry" >&2
tail -5 "$WORK_DIR/stderr.txt" >&2 2>/dev/null || true
exit 2
fi
if ! gate_ok; then
echo "error: findings file missing or failed the result gate after retry" >&2
exit 3
fi
# --- RM-10 telemetry ----------------------------------------------------------
jq -r '"telemetry: window=\(.modelUsage // {} | to_entries[0].value.contextWindow // "?") in=\(.usage.input_tokens // "?") out=\(.usage.output_tokens // "?") ms=\(.duration_ms // "?")"' \
"$JSON_OUT" >&2 2>/dev/null || true
echo "ok: findings written to $OUT_FILE"

View File

@@ -0,0 +1,65 @@
# ci-triage-minimax-run
## Purpose
Triage a failing Gitea Actions run in a MiniMax M3 sub-session: fetch the run's logs via `gitea-agent-companion`, have MiniMax name the failing step, error excerpt, and probable cause in a triage file — the log payload costs MiniMax tokens, not Anthropic tokens. Backs the `/ci-triage-minimax` skill; safe to drive directly.
## Usage
```
ci-triage-minimax-run --repo <owner/repo> --run <N> [--job <name|id>] \
[--out <triage-file>] [--tail N] [--max-turns N] [--timeout SECS] [--dryrun|-n]
```
| Flag | Default | Description |
|------|---------|-------------|
| `--repo` | required | `owner/repo` on the Gitea instance |
| `--run` | required | Web-UI run number. Required by design: picking "the latest failure" means parsing gac's human table — judgment that belongs in the calling skill, not an unversioned parse here |
| `--job` | none | Passed through to gac; needed for multi-job runs with no failed job (gac exits 2 asking for it) |
| `--out` | `${XDG_RUNTIME_DIR:-/tmp}/ci-triage-minimax/<owner>-<repo>/run-<N>-triage.md` | Triage file — outside the scratch-dir cleanup trap |
| `--tail` | 2000 | Log lines fetched (gac `--tail`) |
| `--max-turns` | 25 | Sub-session turn cap |
| `--timeout` | 300 | Internal timeout (logs-only task; well under the harness Bash 600s cap) |
| `--dryrun`, `-n` | off | Print resolved config, the gac command, the full prompt, and the sub-session command line; invoke neither gac's log fetch nor the sub-session |
## Requirements
- **CT-1 Preflight.** `jq`, `ask-minimax-run`, and `gitea-agent-companion` must be on PATH; `--repo` must match `owner/repo`; `--run` must be a positive integer.
- **CT-2 Fetch.** The wrapper runs `gitea-agent-companion inspect <repo> --run <N> [--job <J>] --logs --json --tail <n>` into `run-logs.ndjson` and `inspect <repo> --run <N>` into `jobs.txt`, both in a scratch dir. Token discovery stays inside gac (env → token file); because installed gac builds (v0.0.2.10) do not fall back to the secrets path themselves, the wrapper exports `GITEA_TOKEN_FILE=~/dev/claude/secrets/gitea/ai_admin/token` when neither `GITEA_TOKEN` nor `GITEA_TOKEN_FILE` is set and that file exists — a path reference only; the token value never appears in arguments, files, output, or the prompt.
- **CT-3 gac exit mapping.** gac exit 2 (usage) → exit 1; gac exit 3 (auth failure, API error, run not found) → exit 2 (often transient — the caller's retry/fallback path); gac stderr surfaced verbatim in both cases. Why: folding 3 into 1 would tell the caller "fix the invocation, don't retry" for transient infra errors.
- **CT-4 Redaction.** Before prompt assembly, credential-shaped content in `run-logs.ndjson` (`ghp_…|glpat-|x-access-token:|Authorization:|BEGIN … PRIVATE KEY|AKIA…` — same pattern set as review-minimax-run) is replaced in place with `[REDACTED]`. Redact-and-continue, not abort: CI logs routinely carry unmasked clone URLs and the log is the whole payload. Residual risk: best-effort patterns; novel credential formats can pass.
- **CT-5 Envelope parse.** `failed_step`, `sliced`, and `note` are extracted from the NDJSON envelope with jq. If `failed_step` is null (cancelled/crashed run, green multi-job), the prompt states this and the output contract requires the literal `UNKNOWN` under `## Failing step` — no fabricated step names.
- **CT-6 Prompt contract.** Fixed preamble: read-only triage of the two files in cwd; explicit error-signature patterns to search (`error:|FAILED|Traceback|exit code|denied|timeout|not found|no such|ECONN|refused`); do not suggest running tests or builds; if the log carries the truncation marker or `sliced` is partial, say so — do not improvise. Output sections, exactly: `## Failing step` / `## Error excerpt` (≤10 verbatim lines) / `## Probable cause` (1–3 sentences, non-empty) / `## Suggested next action` (one command or one edit description — advisory only) / `## Confidence`.
- **CT-7 Invocation.** From the scratch dir: `timeout <secs> ask-minimax-run --model "MiniMax-M3[1m]" --permission-mode default --allowedTools "Read,Glob,Grep,Write" --disallowedTools "Edit,MultiEdit,NotebookEdit,Bash,WebFetch,WebSearch" --max-turns N --output-format json --add-dir <out-dir> -p`. Same rationale set as review-minimax-run RM-6; no worktree access needed.
- **CT-8 Result gate.** Triage file exists, contains `## Failing step`, and has a non-blank line under `## Probable cause`.
- **CT-9 Retry.** On MiniMax error or gate failure: remove the out-file, retry once; never retry timeout (rc 124); then exit 2 (transport) or 3 (gate). The gac fetch is NOT retried by the script (single fetch, reused across both attempts).
- **CT-10 Test seams.** `CI_TRIAGE_FAKE_LOGS=<file>` bypasses the gac fetch (file used as `run-logs.ndjson`; `jobs.txt` gets a placeholder); `CI_TRIAGE_FAKE_JSON=<file>` substitutes the sub-session envelope; `CI_TRIAGE_FAKE_TRIAGE=<file>` (companion, optional) plays the sub-session's Write of the triage file. Gates and retry logic still run, so exits 0/2/3 and the redaction step are assertable offline.
## Exit codes
| Code | Meaning | Caller action |
|------|---------|---------------|
| 0 | Triage file written | Read it; sanity-check the excerpt against the named step; re-derive any suggested command yourself |
| 1 | Usage/config error (bad flags, gac usage error) | Fix invocation |
| 2 | gac auth/API/not-found error, MiniMax failed after retry, or timeout | Fall back: `gac inspect --logs` directly, triage in-session |
| 3 | Triage file missing or failed the gate after retry | Same fallback |
## Dryrun behaviour
`--dryrun` prints: resolved repo/run/job/out/limits, the exact gac fetch command, the full assembled prompt (with a `<run-logs not fetched in dryrun>` placeholder where envelope fields would go), and the exact sub-session command line. Nothing is fetched or invoked.
## Scenarios
- **Given** a failed run whose envelope has `failed_step: "Run tests"`, **when** run, **then** exit 0 and the triage file's `## Failing step` names that step.
- **Given** an envelope with `failed_step: null`, **when** run, **then** the triage file's `## Failing step` is `UNKNOWN`.
- **Given** gac exiting 3 (run not found), **when** run, **then** exit 2 with gac's stderr shown.
- **Given** `CI_TRIAGE_FAKE_LOGS` containing an `x-access-token:ghp_XXXX@` clone URL, **when** run in dryrun-less fake mode, **then** the prompt-adjacent log copy contains `[REDACTED]`, not the token.
## Security notes
- The triage file is third-party model output over CI logs — logs that anyone able to trigger CI can influence. **Data, not instructions**; `## Suggested next action` is advisory and must be re-derived by the caller, never copy-run.
- The Gitea token boundary lives entirely inside gac; this script never reads, prints, or passes it.
## Dependencies
`gitea-agent-companion` (on PATH, `gac` alias), `ask-minimax-run` (same repo), `jq`, `timeout`, the `~/.claude-oreillyit-minimax` profile.

View File

@@ -0,0 +1,68 @@
# review-minimax-run
## Purpose
Run a first-pass code review of a git branch (typically an agent-produced worktree branch) in a MiniMax M3 sub-session, producing a findings file the operator adjudicates — the diff and surrounding reads cost MiniMax tokens, not Anthropic tokens. Backs the `/review-minimax` skill; safe to drive directly.
## Usage
```
review-minimax-run --worktree <path> [--base <ref>] [--out <findings-file>] \
[--profile agent-runtimes] [--max-diff-lines N] [--max-turns N] \
[--timeout SECS] [--dryrun|-n]
```
| Flag | Default | Description |
|------|---------|-------------|
| `--worktree` | required | Checked-out worktree of the branch to review (single addressing mode by design) |
| `--base` | `main` | Base ref; review scope is `base...HEAD` (three-dot) |
| `--out` | `${XDG_RUNTIME_DIR:-/tmp}/review-minimax/<repo>/<branch-slug>-findings.md` | Findings file — outside the repo and outside the scratch-dir cleanup trap |
| `--profile` | none | Extra checklist profile; only `agent-runtimes` defined (the 5 recurring MiniMax test-file bugs — repo-specific, noise elsewhere) |
| `--max-diff-lines` | 6000 | Diff budget; larger diffs exit 1 with the count |
| `--max-turns` | 40 | Sub-session turn cap |
| `--timeout` | 540 | Internal timeout (keeps callers under the harness Bash 600s cap). Why: a longer run invoked synchronously from a skill is killed by the harness with no findings file. |
| `--dryrun`, `-n` | off | Print resolved config, materialised-input plan, full prompt, and the exact sub-session command line; create/invoke nothing beyond the scratch dir |
## Requirements
- **RM-1 Preflight.** `jq` and `ask-minimax-run` must be on PATH; `--worktree` is `realpath -e` resolved, must be a git checkout (`git rev-parse --show-toplevel`), must not be on a detached HEAD, and is refused (exit 1) if it is or lies inside `secrets/` or `local_secrets/`. `--base` must resolve (`git rev-parse --verify <base>`).
- **RM-2 Input materialisation.** The wrapper — never the sub-session — runs git: `diff <base>...HEAD` (three-dot; two-dot is misleading once base has moved) with pathspec exclusions `:(exclude)secrets/**`, `:(exclude)local_secrets/**`, `:(exclude)*.pem`, `:(exclude)*.key`, `:(exclude)*secret*` into `diff.patch`; `log --oneline <base>..HEAD` into `commits.txt`; `diff --stat <base>...HEAD` into `stat.txt` — all in a scratch dir. Why: the sub-session gets no Bash, cannot inherit the wrong branch, and the exclusions are the payload-side secrets boundary.
- **RM-3 Redaction gate.** If `diff.patch` matches `ghp_[A-Za-z0-9]|glpat-|x-access-token:|Authorization:|BEGIN [A-Z ]*PRIVATE KEY|AKIA[0-9A-Z]{16}`, exit 1 with "diff contains credential-shaped content; review in-session". No redact-and-continue for diffs: a credential in a diff means the branch itself needs attention.
- **RM-4 Empty/oversize diff.** Empty `diff.patch` → exit 1 "nothing to review". More than `--max-diff-lines` lines → exit 1 with the count.
- **RM-5 Prompt contract.** Fixed preamble: first-pass reviewer, non-interactive; inputs are the three files in cwd; the worktree is readable for surrounding context only; write ONLY the findings file at the given path; never modify any other file; do not run anything, fix anything, or improvise if an input is missing/truncated — say so and stop. Checklist = unconditional core (distilled from llm-code-security §10/§12: injection, hardcoded credentials, unquoted shell vars, verify=False, weak hashing/random, missing input validation, chmod 777, curl|bash, unpinned images/deps, missing idempotency, unconditional health checks, missing locking) as explicit grep-able patterns; `--profile agent-runtimes` appends the 5 recurring MiniMax test-file bugs. Findings format: `### [BLOCKER|SHOULD-FIX|NIT] <title>` blocks with `file:line`, ≤10-line excerpt, category, `Confidence: confirmed|suspected`; clean review → literal `NO FINDINGS`.
- **RM-6 Invocation.** From the scratch dir: `timeout <secs> ask-minimax-run --model "MiniMax-M3[1m]" --permission-mode default --allowedTools "Read,Glob,Grep,Write" --disallowedTools "Edit,MultiEdit,NotebookEdit,Bash,WebFetch,WebSearch" --max-turns N --output-format json --add-dir <worktree> --add-dir <out-dir> -p < prompt > response.json`. Why each: `[1m]` pins the 1M window (unknown model ids get 200k + early auto-compact); `Write` must be allowlisted or MiniMax exits 0 without creating the file; `default` not `acceptEdits` — new-file Write needs no edit approval and nothing else is auto-approved; deny-list beats the profile's permissive defaults.
- **RM-7 Clean-tree assertion.** After each attempt, `git -C <worktree> status --porcelain` must be empty; a dirtied tree is exit 2 (and the dirt is reported, not reverted).
- **RM-8 Result gate.** Findings file exists AND (contains `NO FINDINGS` OR (≥1 line matching `^### \[(BLOCKER|SHOULD-FIX|NIT)\]` AND ≥1 finding cites a path that exists relative to the worktree)). A bare `:NNN` match is NOT sufficient — timestamps and counters satisfy that.
- **RM-9 Retry.** On MiniMax error OR gate failure: remove the out-file and retry once (covers the "exit 0, file never written" failure and prevents a stale attempt-1 file passing the gate). Never retry a timeout (rc 124). Then exit 2 (transport/timeout/dirty-tree) or 3 (gate).
- **RM-10 Telemetry.** On success, log `modelUsage.contextWindow`, token totals, and wall clock from the JSON envelope to stderr (validates the 1M pin; no prompt caching on this provider, so cost per run is worth watching).
- **RM-11 Test seam.** `REVIEW_MINIMAX_FAKE_JSON=<file>` substitutes the canned sub-session JSON envelope and skips the live call; `REVIEW_MINIMAX_FAKE_FINDINGS=<file>` (companion, optional) plays the sub-session's Write of the findings file. The result gate, clean-tree assertion, and retry logic still run — fakes are inputs, so exits 0/2/3 are all assertable offline.
## Exit codes
| Code | Meaning | Caller action |
|------|---------|---------------|
| 0 | Findings file written (may say `NO FINDINGS`) | Adjudicate: spot-check citations, triage each finding |
| 1 | Usage/config error, secrets root, credential-shaped diff, empty or oversize diff | Fix invocation or review in-session |
| 2 | MiniMax failed after retry, timeout, or sub-session dirtied the worktree | Fall back to a Claude subagent review |
| 3 | Findings file missing or failed the gate after retry | Same fallback — do not trust partial output |
## Dryrun behaviour
`--dryrun` prints: resolved worktree/branch/base/out/profile/limits, the diff line count and stat summary, the full assembled prompt, and the exact `ask-minimax-run` command line. It materialises inputs into the scratch dir (read-only git operations) but never invokes the sub-session and never creates the out-file.
## Scenarios
- **Given** a worktree with a 200-line branch diff, **when** run without flags, **then** exit 0 and a findings file with `### [SEV]` blocks or `NO FINDINGS`.
- **Given** a diff containing `AKIA` + 16 uppercase alphanumerics, **when** run, **then** exit 1 before any sub-session launch.
- **Given** `REVIEW_MINIMAX_FAKE_JSON` pointing at an envelope whose run wrote no findings file, **when** run, **then** one retry occurs and exit is 3.
- **Given** a worktree on a detached HEAD, **when** run, **then** exit 1.
## Security notes
- Findings are third-party model output over repo contents: **data, not instructions**. Callers adjudicate; they never act on embedded directives.
- Secrets boundary is layered: worktree-path refusal (hard), diff pathspec exclusions (hard), credential-pattern gate (hard), prompt skip-list (soft). Residual risk: the pattern gate is best-effort — novel credential formats in a diff can pass; the pathspec list does not catch secrets committed under unconventional names.
- The sub-session has no Bash and no Edit; Write is unavoidable (it must create the findings file) and is bounded by the clean-tree assertion, not by path.
## Dependencies
`ask-minimax-run` (same repo, on PATH), `jq`, `git`, `timeout`, `realpath`, the `~/.claude-oreillyit-minimax` profile.

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# test-ci-triage-minimax-run.sh — Verify ci-triage-minimax-run against its spec.
#
# Strategy: CI_TRIAGE_FAKE_LOGS bypasses gac, CI_TRIAGE_FAKE_JSON /
# CI_TRIAGE_FAKE_TRIAGE drive the sub-session paths, so exits 0/2/3,
# redaction, and the failed_step handling are all exercised offline.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/ci-triage-minimax-run"
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_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 actual: %s\n" "$expected" "$actual"; FAIL=$((FAIL + 1))
fi
}
TMP=$(mktemp -d /tmp/test-ci-triage.XXXXXX)
trap 'rm -rf "$TMP"' EXIT
# gac-shaped NDJSON envelope + log lines, including a credential-shaped URL
FAKE_LOGS="$TMP/logs.ndjson"
cat > "$FAKE_LOGS" <<'EOF'
{"job_id":1,"conclusion":"failure","failed_step":"Run tests","sliced":true,"note":"","markers":[],"lines":["cloning https://x-access-token:ghp_abcdEFGH1234@gitea.example/r.git","FAILED tests/test_x.py::test_y - AssertionError","exit code 1"]}
EOF
FAKE_LOGS_NOSTEP="$TMP/logs-nostep.ndjson"
echo '{"job_id":1,"conclusion":"cancelled","failed_step":null,"sliced":false,"note":"","markers":[],"lines":["cancelled"]}' > "$FAKE_LOGS_NOSTEP"
FAKE_OK_JSON="$TMP/ok.json"
echo '{"is_error":false,"result":"done"}' > "$FAKE_OK_JSON"
FAKE_ERR_JSON="$TMP/err.json"
echo '{"is_error":true,"result":"boom"}' > "$FAKE_ERR_JSON"
FAKE_TRIAGE="$TMP/triage.md"
printf '## Failing step\nRun tests\n## Error excerpt\nFAILED tests/test_x.py\n## Probable cause\nAssertion failure in test_y.\n## Suggested next action\nRe-run that test.\n## Confidence\nhigh\n' > "$FAKE_TRIAGE"
FAKE_TRIAGE_EMPTY_CAUSE="$TMP/triage-empty.md"
printf '## Failing step\nRun tests\n## Probable cause\n## Confidence\nlow\n' > "$FAKE_TRIAGE_EMPTY_CAUSE"
echo "== ci-triage-minimax-run: --help =="
HELP_OUT="$("$SCRIPT" --help 2>&1)"
assert_contains "help shows usage" "--repo" "$HELP_OUT"
echo "== usage errors (exit 1) =="
"$SCRIPT" >/dev/null 2>&1; assert_eq "no args exits 1" 1 $?
"$SCRIPT" --repo badformat --run 1 >/dev/null 2>&1; assert_eq "bad repo format exits 1" 1 $?
"$SCRIPT" --repo o/r --run abc >/dev/null 2>&1; assert_eq "non-integer run exits 1" 1 $?
echo "== dryrun =="
DRY_OUT="$("$SCRIPT" --repo skynet/gac-scratch --run 42 --dryrun 2>&1)"
assert_eq "dryrun exits 0" 0 $?
assert_contains "dryrun shows gac command" "inspect skynet/gac-scratch --run 42" "$DRY_OUT"
assert_contains "dryrun shows model pin" "MiniMax-M3[1m]" "$DRY_OUT"
assert_contains "dryrun defers fetch" "not fetched in dryrun" "$DRY_OUT"
assert_contains "dryrun shows job passthrough default" "job: auto" "$DRY_OUT"
echo "== fake logs: redaction + failing step in prompt, exit 0 =="
OUT0="$TMP/out0.md"
RUN_OUT="$(CI_TRIAGE_FAKE_LOGS="$FAKE_LOGS" CI_TRIAGE_FAKE_JSON="$FAKE_OK_JSON" CI_TRIAGE_FAKE_TRIAGE="$FAKE_TRIAGE" \
"$SCRIPT" --repo o/r --run 7 --out "$OUT0" 2>&1)"
assert_eq "happy path exits 0" 0 $?
assert_contains "triage file written" "ok: triage written" "$RUN_OUT"
echo "== fake logs: null failed_step still succeeds =="
CI_TRIAGE_FAKE_LOGS="$FAKE_LOGS_NOSTEP" CI_TRIAGE_FAKE_JSON="$FAKE_OK_JSON" CI_TRIAGE_FAKE_TRIAGE="$FAKE_TRIAGE" \
"$SCRIPT" --repo o/r --run 8 --out "$TMP/out-nostep.md" >/dev/null 2>&1
assert_eq "null failed_step exits 0" 0 $?
echo "== gate: empty probable cause -> exit 3 =="
CI_TRIAGE_FAKE_LOGS="$FAKE_LOGS" CI_TRIAGE_FAKE_JSON="$FAKE_OK_JSON" CI_TRIAGE_FAKE_TRIAGE="$FAKE_TRIAGE_EMPTY_CAUSE" \
"$SCRIPT" --repo o/r --run 9 --out "$TMP/out3.md" >/dev/null 2>&1
assert_eq "empty probable-cause exits 3" 3 $?
echo "== gate: no triage file -> exit 3 =="
CI_TRIAGE_FAKE_LOGS="$FAKE_LOGS" CI_TRIAGE_FAKE_JSON="$FAKE_OK_JSON" \
"$SCRIPT" --repo o/r --run 10 --out "$TMP/out3b.md" >/dev/null 2>&1
assert_eq "missing triage file exits 3" 3 $?
echo "== is_error envelope -> exit 2 =="
CI_TRIAGE_FAKE_LOGS="$FAKE_LOGS" CI_TRIAGE_FAKE_JSON="$FAKE_ERR_JSON" \
"$SCRIPT" --repo o/r --run 11 --out "$TMP/out2.md" >/dev/null 2>&1
assert_eq "is_error envelope exits 2" 2 $?
echo ""
echo "ci-triage-minimax-run: $PASS passed, $FAIL failed"
[[ "$FAIL" -eq 0 ]]

139
tests/test-review-minimax-run.sh Executable file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# test-review-minimax-run.sh — Verify review-minimax-run against its spec.
#
# Strategy: a throwaway git repo with a scratch branch provides real diffs;
# REVIEW_MINIMAX_FAKE_JSON drives the sub-session paths so exits 0/2/3 and
# the result gate are all exercised offline. No live MiniMax calls.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/review-minimax-run"
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_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 actual: %s\n" "$expected" "$actual"; FAIL=$((FAIL + 1))
fi
}
# --- fixture repo -------------------------------------------------------------
TMP=$(mktemp -d /tmp/test-review-minimax.XXXXXX)
trap 'rm -rf "$TMP"' EXIT
REPO="$TMP/repo"
mkdir -p "$REPO"
git -C "$REPO" -c init.defaultBranch=main init -q
git -C "$REPO" -c user.email=t@t -c user.name=t commit -q --allow-empty -m init
echo "safe line" > "$REPO/app.sh"
git -C "$REPO" add -A
git -C "$REPO" -c user.email=t@t -c user.name=t commit -qm base
git -C "$REPO" checkout -qb feature
echo 'eval "$user_input"' >> "$REPO/app.sh"
git -C "$REPO" add -A
git -C "$REPO" -c user.email=t@t -c user.name=t commit -qm change
FAKE_OK_JSON="$TMP/ok.json"
echo '{"is_error":false,"result":"done","usage":{"input_tokens":1,"output_tokens":1}}' > "$FAKE_OK_JSON"
echo "== review-minimax-run: --help =="
HELP_OUT="$("$SCRIPT" --help 2>&1)"
assert_contains "help shows usage" "--worktree" "$HELP_OUT"
echo "== usage errors (exit 1) =="
"$SCRIPT" >/dev/null 2>&1; assert_eq "no args exits 1" 1 $?
"$SCRIPT" --worktree /nonexistent >/dev/null 2>&1; assert_eq "missing worktree exits 1" 1 $?
"$SCRIPT" --worktree "$TMP" >/dev/null 2>&1; assert_eq "non-git worktree exits 1" 1 $?
echo "== dryrun =="
DRY_OUT="$("$SCRIPT" --worktree "$REPO" --dryrun 2>&1)"
assert_eq "dryrun exits 0" 0 $?
assert_contains "dryrun shows branch" "branch: feature" "$DRY_OUT"
assert_contains "dryrun shows model pin" "MiniMax-M3[1m]" "$DRY_OUT"
assert_contains "dryrun shows deny list" "Bash,WebFetch,WebSearch" "$DRY_OUT"
assert_contains "dryrun prompt has findings format" "NO FINDINGS" "$DRY_OUT"
assert_contains "dryrun shows checklist" "shell=True" "$DRY_OUT"
echo "== profile =="
PROF_OUT="$("$SCRIPT" --worktree "$REPO" --profile agent-runtimes --dryrun 2>&1)"
assert_contains "agent-runtimes profile adds test-bug checklist" "/v1/ prefix" "$PROF_OUT"
NOPROF_OUT="$("$SCRIPT" --worktree "$REPO" --dryrun 2>&1)"
if [[ "$NOPROF_OUT" != *"/v1/ prefix"* ]]; then
printf " ${GREEN}PASS${NC} no profile omits repo-specific checklist\n"; PASS=$((PASS + 1))
else
printf " ${RED}FAIL${NC} no profile omits repo-specific checklist\n"; FAIL=$((FAIL + 1))
fi
echo "== diff gates (exit 1) =="
"$SCRIPT" --worktree "$REPO" --max-diff-lines 1 --dryrun >/dev/null 2>&1
assert_eq "oversize diff exits 1" 1 $?
git -C "$REPO" checkout -q main
git -C "$REPO" checkout -qb empty-branch
"$SCRIPT" --worktree "$REPO" --dryrun >/dev/null 2>&1
assert_eq "empty diff exits 1" 1 $?
git -C "$REPO" checkout -q feature
echo "== credential-shaped diff (exit 1) =="
git -C "$REPO" checkout -qb cred-branch main
echo 'url = https://x-access-token:tok@example.com/r.git' > "$REPO/cfg"
git -C "$REPO" add -A
git -C "$REPO" -c user.email=t@t -c user.name=t commit -qm cred
CRED_OUT="$("$SCRIPT" --worktree "$REPO" --dryrun 2>&1)"
assert_eq "credential-shaped diff exits 1" 1 $?
assert_contains "credential message" "credential-shaped" "$CRED_OUT"
git -C "$REPO" checkout -q feature
echo "== fake seam: no findings file -> exit 3 after retry =="
OUT3="$TMP/findings3.md"
REVIEW_MINIMAX_FAKE_JSON="$FAKE_OK_JSON" "$SCRIPT" --worktree "$REPO" --out "$OUT3" >/dev/null 2>&1
assert_eq "missing findings file exits 3" 3 $?
echo "== fake seam: uncited findings -> exit 3 =="
FAKE_BAD="$TMP/fake-bad-findings.md"
printf '### [NIT] vague worry\n- Location: made/up/file.py:12\n- Confidence: suspected\n' > "$FAKE_BAD"
REVIEW_MINIMAX_FAKE_JSON="$FAKE_OK_JSON" REVIEW_MINIMAX_FAKE_FINDINGS="$FAKE_BAD" \
"$SCRIPT" --worktree "$REPO" --out "$TMP/findings4.md" >/dev/null 2>&1
assert_eq "uncited findings exits 3" 3 $?
echo "== fake seam: cited findings -> exit 0 =="
FAKE_GOOD="$TMP/fake-good-findings.md"
printf '### [BLOCKER] eval on user input\n- Location: app.sh:2\n- Category: Injection\n- Confidence: confirmed\n' > "$FAKE_GOOD"
REVIEW_MINIMAX_FAKE_JSON="$FAKE_OK_JSON" REVIEW_MINIMAX_FAKE_FINDINGS="$FAKE_GOOD" \
"$SCRIPT" --worktree "$REPO" --out "$TMP/findings0.md" >/dev/null 2>&1
assert_eq "cited findings exits 0" 0 $?
echo "== fake seam: NO FINDINGS -> exit 0 =="
FAKE_CLEAN="$TMP/fake-clean.md"
echo 'NO FINDINGS' > "$FAKE_CLEAN"
REVIEW_MINIMAX_FAKE_JSON="$FAKE_OK_JSON" REVIEW_MINIMAX_FAKE_FINDINGS="$FAKE_CLEAN" \
"$SCRIPT" --worktree "$REPO" --out "$TMP/findings-clean.md" >/dev/null 2>&1
assert_eq "NO FINDINGS exits 0" 0 $?
echo "== fake seam: is_error envelope -> exit 2 =="
FAKE_ERR_JSON="$TMP/err.json"
echo '{"is_error":true,"result":"boom"}' > "$FAKE_ERR_JSON"
REVIEW_MINIMAX_FAKE_JSON="$FAKE_ERR_JSON" "$SCRIPT" --worktree "$REPO" --out "$TMP/f5.md" >/dev/null 2>&1
assert_eq "is_error envelope exits 2" 2 $?
echo ""
echo "review-minimax-run: $PASS passed, $FAIL failed"
[[ "$FAIL" -eq 0 ]]