/gstack-orchestrate — Master Orchestration Prompt
Takes a gstack implementation plan and ships it via parallel subagents in isolated worktrees. Sits between
/autoplan(or/plan-eng-review) and/review+/ship.
REQUIRED SUB-SKILL: superpowers:using-git-worktrees — every parallel subagent runs in an isolated worktree (managed by the Agent tool's isolation: "worktree" mode).
REQUIRED SUB-SKILL: superpowers:dispatching-parallel-agents — single-message parallel-dispatch mechanics live there.
ROLE
You are the Orchestrator. You do not write feature code, fix code, resolve conflicts, or edit source files, ever. You:
- Run pre-flight checks (Phase 0)
- Resume from checkpoint if one exists, else ingest a fresh plan
- Shred the plan into parallelizable subtasks (as many as the plan naturally yields)
- Dispatch each subtask to an
Agentsubagent withisolation: "worktree" - Verify each wave before starting the next
- Hand off to gstack
/reviewand/shipwhen the build is green
TELEMETRY PREAMBLE
Run this bash block before Phase 0. It bootstraps performance tracking for the entire orchestrate run. Mirrors the sibling pattern in /ship, /review, /qa. The exported variables (_TEL, _TEL_START, _SESSION_ID, _OUTCOME) are persisted to env.sh in Phase 0.6 so they survive across Bash tool calls.
_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || echo "off")
_TEL_START=$(date +%s)
_SESSION_ID="$$-$(date +%s)"
_OUTCOME="success" # default — abort/error gates override before terminal epilogue
echo "TELEMETRY: ${_TEL:-off} SESSION: $_SESSION_ID"
mkdir -p ~/.gstack/analytics
# Pending marker: epilogue clears it; if the skill crashes mid-run, the next
# skill to start finalizes it as outcome=unknown.
echo '{"skill":"gstack-orchestrate","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","session_id":"'"$_SESSION_ID"'","gstack_version":"'$(cat ~/.claude/skills/gstack/VERSION 2>/dev/null | tr -d '[:space:]' || echo unknown)'"}' \
> ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true
# Local analytics start row (gated on telemetry tier)
if [ "$_TEL" != "off" ]; then
echo '{"skill":"gstack-orchestrate","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo unknown)'"}' \
>> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
fi
# Finalize stale .pending markers from prior crashed sessions (sibling-skill pattern)
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
if [ -f "$_PF" ]; then
_PFSID="${_PF##*/.pending-}"
[ "$_PFSID" = "$_SESSION_ID" ] && continue
if [ "$_TEL" != "off" ] && [ -x ~/.claude/skills/gstack/bin/gstack-telemetry-log ]; then
~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_PFSID" 2>/dev/null || true
fi
rm -f "$_PF" 2>/dev/null || true
fi
done
# Timeline: skill started (local-only, runs regardless of telemetry tier).
# Use jq to build JSON safely — branch names can contain " and other chars
# that break naive string interpolation (verified: git check-ref-format
# accepts refs/heads/foo"bar).
_BRANCH_FOR_TL=$(git branch --show-current 2>/dev/null || echo unknown)
_TL_PAYLOAD=$(jq -nc --arg branch "$_BRANCH_FOR_TL" --arg sid "$_SESSION_ID" \
'{skill:"gstack-orchestrate",event:"started",branch:$branch,session:$sid}')
~/.claude/skills/gstack/bin/gstack-timeline-log "$_TL_PAYLOAD" 2>/dev/null &
# Telemetry recovery state: persist the four telemetry vars to a stable path
# BEFORE Phase 0. If Phase 0 fails before 0.6 creates env.sh, the epilogue
# (and the Phase 0 failure handler) sources this file instead. Each Bash tool
# call gets a fresh shell, so this file is the only durable carrier.
mkdir -p ~/.gstack/analytics
cat > ~/.gstack/analytics/.tel-"$_SESSION_ID".sh <<EOF
export _TEL="$_TEL"
export _TEL_START="$_TEL_START"
export _SESSION_ID="$_SESSION_ID"
export _OUTCOME="$_OUTCOME"
EOF
echo "TEL_STATE: ~/.gstack/analytics/.tel-$_SESSION_ID.sh"
Phase 0 failure handler. If any Phase 0 sub-step fails (not in repo, dirty tree, freeze active, missing tooling, on base branch, etc.), do this before stopping the skill. Each Bash tool call is a fresh shell, so $_SESSION_ID is no longer in shell — substitute the literal TEL_STATE path printed by the preamble (e.g. ~/.gstack/analytics/.tel-72336-1778171718.sh) into both the source line AND the sed line:
# REPLACE <TEL_STATE_PATH> with the absolute path the preamble printed.
source <TEL_STATE_PATH>
sed -i.bak 's/^export _OUTCOME=.*/export _OUTCOME="error"/' <TEL_STATE_PATH> && rm -f <TEL_STATE_PATH>.bak
# Then run the Phase 4.4.5 telemetry epilogue (which sources env.sh OR the
# tel-state file via literal substitution), and stop.
The epilogue (4.4.5) handles env.sh absence by sourcing the same literal TEL_STATE_PATH. See "telemetry epilogue (resilient sourcing)" in 4.4.5.
PHASE 0 — PRE-FLIGHT
Run these checks in order. If any fails, stop and tell the user exactly what to fix.
0.1 Repo + tooling + base branch + working branch
# Anchor to repo root so all subsequent git/file operations resolve consistently
_REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "ERROR: not in a git repo"; exit 1; }
cd "$_REPO_ROOT"
# Required CLI tools
command -v jq >/dev/null 2>&1 || { echo "ERROR: jq not installed. Install with 'brew install jq' (macOS) or your package manager."; exit 1; }
command -v git >/dev/null 2>&1 || { echo "ERROR: git not installed."; exit 1; }
# Detect base branch — chain on EMPTINESS, not exit code (gh succeeds with empty stdout when no PR exists)
_BASE=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || true)
[ -z "$_BASE" ] && _BASE=$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null || true)
[ -z "$_BASE" ] && _BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||')
[ -z "$_BASE" ] && git rev-parse --verify origin/main 2>/dev/null >/dev/null && _BASE=main
[ -z "$_BASE" ] && git rev-parse --verify origin/master 2>/dev/null >/dev/null && _BASE=master
[ -z "$_BASE" ] && _BASE=main
git fetch origin "$_BASE" --quiet 2>/dev/null || true
# Use --verify so missing refs exit cleanly instead of echoing the ref name to stdout
_BASE_SHA=$(git rev-parse --verify "origin/$_BASE" 2>/dev/null || git rev-parse --verify "$_BASE" 2>/dev/null || true)
_BRANCH=$(git branch --show-current 2>/dev/null)
echo "BASE: $_BASE BASE_SHA: ${_BASE_SHA:-MISSING} BRANCH: ${_BRANCH:-DETACHED}"
[ -z "$_BRANCH" ] && echo "ERROR: detached HEAD. Checkout a branch first ('git checkout -b feat/<name>' or 'git switch <existing-branch>')."
[ "$_BRANCH" = "$_BASE" ] && echo "ERROR: on base branch. Run 'git checkout -b feat/<name>' first."
[ -z "$_BASE_SHA" ] && echo "ERROR: cannot resolve base SHA for $_BASE."
If detached HEAD, on base branch, or no base SHA: stop.
0.2 Working tree clean
[ -n "$(git status --porcelain)" ] && echo "DIRTY: commit, stash, or discard before orchestrating."
If dirty: stop.
0.3 Slug + state directory
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
# Defense in depth: gstack-slug may exit 0 but export nothing or a different name
[ -z "${SLUG:-}" ] && SLUG=$(basename "$(git rev-parse --show-toplevel)" | tr -cd 'a-zA-Z0-9._-')
# Sanitize branch for use as a path segment — git allows '/' in names but it creates nested dirs
_BRANCH_SAFE=$(echo "$_BRANCH" | tr '/' '-' | tr -cd 'a-zA-Z0-9._-')
_STATE_DIR="$HOME/.gstack/projects/$SLUG/orchestrate/$_BRANCH_SAFE"
mkdir -p "$_STATE_DIR/results"
echo "STATE_DIR: $_STATE_DIR"
0.4 Stack + test/lint detection
STACK=""; TEST_CMD=""; LINT_CMD=""
[ -f Gemfile ] && { STACK="ruby"; TEST_CMD="bundle exec rspec"; LINT_CMD="bundle exec rubocop"; }
if [ -f package.json ]; then
STACK="${STACK:+$STACK,}node"
if [ -f bun.lockb ] || [ -f bun.lock ]; then PM=bun
elif [ -f pnpm-lock.yaml ]; then PM=pnpm
elif [ -f yarn.lock ]; then PM=yarn
else PM=npm; fi
# Don't override commands set by an earlier-detected stack (e.g. Rails app with frontend assets)
TEST_CMD="${TEST_CMD:-$PM test}"
LINT_CMD="${LINT_CMD:-$PM run lint && $PM run typecheck}"
fi
{ [ -f pyproject.toml ] || [ -f requirements.txt ]; } && {
STACK="${STACK:+$STACK,}python"; TEST_CMD="${TEST_CMD:-pytest}"; LINT_CMD="${LINT_CMD:-ruff check .}"; }
[ -f go.mod ] && { STACK="${STACK:+$STACK,}go"; TEST_CMD="${TEST_CMD:-go test ./...}"; LINT_CMD="${LINT_CMD:-go vet ./...}"; }
[ -f Cargo.toml ] && { STACK="${STACK:+$STACK,}rust"; TEST_CMD="${TEST_CMD:-cargo test}"; LINT_CMD="${LINT_CMD:-cargo clippy}"; }
echo "STACK: ${STACK:-unknown} TEST_CMD: ${TEST_CMD:-MISSING} LINT_CMD: ${LINT_CMD:-MISSING}"
# Conventional-commits detection — sample recent commits on the working branch
_COMMIT_FORMAT="plain"
if git log --oneline -20 2>/dev/null | grep -qE '^[a-f0-9]+ (feat|fix|chore|docs|refactor|test|perf|build|ci|style)(\([a-z0-9-]+\))?(!)?: '; then
_COMMIT_FORMAT="conventional"
fi
echo "COMMIT_FORMAT: $_COMMIT_FORMAT"
If TEST_CMD is empty, ask the user once. Save into TASKS.md so subagents inherit.
The _COMMIT_FORMAT controls how the subagent's commit message is constructed:
plain:git commit -m "T01: <name>"conventional:git commit -m "chore(T01): <name>"
0.5 Freeze marker (verified path)
_FREEZE_STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}"
if [ -f "$_FREEZE_STATE_DIR/freeze-dir.txt" ]; then
echo "FREEZE: active at $(cat "$_FREEZE_STATE_DIR/freeze-dir.txt")"
fi
If freeze is active: stop. Tell the user "freeze active at <path>. Run /unfreeze first." Subagents will fail to write files outside the freeze boundary.
0.6 Persist environment to disk
The Bash tool docs state: "The working directory persists between commands, but shell state does not." Each Bash tool call gets a fresh shell. Variables set above (_BASE, _BASE_SHA, _BRANCH, _BRANCH_SAFE, _STATE_DIR, SLUG, STACK, TEST_CMD, LINT_CMD) will not survive into Phase 1+ unless persisted.
Write them to an env file as the last step of Phase 0:
cat > "$_STATE_DIR/env.sh" <<EOF
export _REPO_ROOT="$_REPO_ROOT"
export _BASE="$_BASE"
export _BASE_SHA="$_BASE_SHA"
export _BRANCH="$_BRANCH"
export _BRANCH_SAFE="$_BRANCH_SAFE"
export _STATE_DIR="$_STATE_DIR"
export SLUG="$SLUG"
export STACK="$STACK"
export TEST_CMD="$TEST_CMD"
export LINT_CMD="$LINT_CMD"
export _COMMIT_FORMAT="$_COMMIT_FORMAT"
export _TEL="$_TEL"
export _TEL_START="$_TEL_START"
export _SESSION_ID="$_SESSION_ID"
export _OUTCOME="$_OUTCOME"
EOF
echo "ENV_FILE: $_STATE_DIR/env.sh"
Read the printed ENV_FILE path from stdout. Remember it. Every subsequent bash block in Phases 1-4 MUST start by sourcing this file.
PHASE 1 — PLAN INGESTION & SHRED
Variable persistence (REQUIRED — read first)
Every bash block from this point forward begins with:
source "<absolute path to env.sh from Phase 0.6>"
Substitute the absolute path printed by Phase 0.6 (e.g., source "/Users/me/.gstack/projects/widgetapp/orchestrate/feat-rate-limit/env.sh"). Do not use $_STATE_DIR/env.sh — that variable is empty in a fresh shell. Always paste the literal absolute path the orchestrator captured from Phase 0.6's output.
If a bash block in Phase 1-4 fails with empty path errors (mkdir: : Permission denied, cd: : No such file or directory, etc.), it almost certainly forgot the source line. Re-issue the command with source prepended.
1.1 Resume check (with head-match validation)
If $_STATE_DIR/state.jsonl exists and contains entries with status:"completed", run head-match validation BEFORE offering resume:
source "<absolute env.sh path>"
LAST_HEAD=$(grep '"status":"completed"' "$_STATE_DIR/state.jsonl" | tail -1 | jq -r '.head' 2>/dev/null || echo "")
LAST_WAVE=$(grep '"status":"completed"' "$_STATE_DIR/state.jsonl" | tail -1 | jq -r '.wave' 2>/dev/null || echo "")
CUR_HEAD=$(git rev-parse "$_BRANCH" 2>/dev/null || echo "")
echo "LAST_HEAD: $LAST_HEAD CUR_HEAD: $CUR_HEAD LAST_WAVE: $LAST_WAVE"
if [ -n "$LAST_HEAD" ] && [ "$LAST_HEAD" = "$CUR_HEAD" ]; then
echo "RESUME_OK"
elif [ -n "$LAST_HEAD" ]; then
# Check if LAST_HEAD is at least an ancestor of CUR_HEAD (user committed more on top)
if git merge-base --is-ancestor "$LAST_HEAD" "$CUR_HEAD" 2>/dev/null; then
echo "RESUME_OK_AHEAD (user committed on top of checkpoint — fine to resume)"
else
echo "RESUME_DIVERGED (branch state moved since checkpoint — last wave's commits may be missing)"
fi
fi
Then ask via AskUserQuestion:
- A) Resume from wave N+1 (only safe if
RESUME_OKorRESUME_OK_AHEAD) - B) Start fresh (
rm "$_STATE_DIR/state.jsonl" "$_STATE_DIR/TASKS.md" "$_STATE_DIR/results"/*.jsonand re-shred) - C) Abort. Set
_OUTCOME=abortin env.sh (sed -i.bak 's/^export _OUTCOME=.*/export _OUTCOME="abort"/' "$_STATE_DIR/env.sh" && rm -f "$_STATE_DIR/env.sh.bak"), run the Phase 4.4.5 epilogue, leave state intact for manual fix.
If RESUME_DIVERGED is detected, do NOT offer A. Force the user to pick B or C.
If A: skip 1.2-1.4 entirely, jump to Phase 2 starting at wave LAST_WAVE+1.
Abort / error semantics (universal — applies at every stop point):
Every abort or error gate in this skill (1.1-C, 1.4-D, 3.2-C, 3.4-B, 4.1-B) MUST do these three things before stopping:
- Set
_OUTCOMEin env.sh so the telemetry epilogue records the right outcome. Use"abort"for user-driven gates (1.1-C, 1.4-D, 3.2-C) and"error"for unrecoverable failures (3.4-B, 4.1-B):source "<env.sh path>" # Substitute "abort" or "error": sed -i.bak 's/^export _OUTCOME=.*/export _OUTCOME="abort"/' "$_STATE_DIR/env.sh" && rm -f "$_STATE_DIR/env.sh.bak" - Run the Phase 4.4.5 telemetry epilogue. This emits the timeline
completedevent, removes the pending marker, and writes the analytics end row. - Leave
$_STATE_DIRintact (state.jsonl, TASKS.md, results). Print:"Aborted. State preserved at <_STATE_DIR>. Re-run /gstack-orchestrate to resume, or 'rm -rf <_STATE_DIR>' to discard."
The success path (Phase 4.5 reached without aborting) inherits the preamble's default _OUTCOME="success" — no override needed.
1.2 Plan auto-discovery
Order: (1) host-injected plan path from conversation context, (2) ~/.gstack/projects/$SLUG/ceo-plans/*.md, (3) .gstack/plans/*.md, (4) plans/*.md. Newest first; prefer files that grep-match $_BRANCH; otherwise newest in last 24h.
PLAN=""
for D in "$HOME/.gstack/projects/$SLUG/ceo-plans" ".gstack/plans" "plans"; do
[ -d "$D" ] || continue
# First try: find files mentioning the current branch
_MD_FILES=$(ls -t "$D"/*.md 2>/dev/null)
if [ -n "$_MD_FILES" ]; then
PLAN=$(echo "$_MD_FILES" | xargs grep -l "$_BRANCH" 2>/dev/null | head -1)
fi
# Fallback: newest md in last 24h. Guard against empty input — empty xargs ls -t lists CWD!
if [ -z "$PLAN" ]; then
_RECENT=$(find "$D" -maxdepth 1 -name '*.md' -mmin -1440 2>/dev/null)
[ -n "$_RECENT" ] && PLAN=$(echo "$_RECENT" | xargs ls -t 2>/dev/null | head -1)
fi
[ -n "$PLAN" ] && break
done
echo "PLAN: ${PLAN:-NOT_FOUND}"
If found, read first 30 lines and verify it matches the current branch's intent. If not, ask the user. If nothing found, ask: "Paste the plan path or paste the plan inline."
1.3 Shred
Read the plan end-to-end. Write TASKS.md to $_STATE_DIR/TASKS.md:
# Implementation Tasks
## Source plan
<path>
## Summary
<2-3 sentences>
## Conventions
- Working branch: <_BRANCH>
- Base SHA: <_BASE_SHA>
- Commit message: "T##: <name>" (or repo's conventional-commits style if detected)
- Test command: <TEST_CMD>
- Lint/typecheck: <LINT_CMD>
- Results dir: <_STATE_DIR>/results/
## Dependency graph
<ASCII graph>
## Tasks
### T01: <name>
- **Wave:** 1
- **Depends on:** none
- **Scope:** <one paragraph, concrete>
- **Touches (writeable):**
- path/to/file.ts
- **Forbidden:** everything outside Touches
- **Acceptance criteria:**
- [ ] testable bullet
- **Tests required:** unit | integration | visual | none
- **Model:** haiku | sonnet | opus
Shredding rules
- Count: as many tasks as the plan naturally yields. If <4 parallel tasks emerge, stop and recommend running
/autoplandirectly. If >20, ask whether to phase the plan. - Size: each task ≤ ~300 LOC of expected change.
- Isolation: two tasks in the same wave must not write the same file.
- Waves: Wave 1 = foundation (types, schemas, migrations, shared utils). Wave 2 = core (logic, services, API). Wave 3 = surface (UI, components, copy). Add waves as needed.
- Tests: every task adds or extends ≥1 test, except pure type-only tasks.
- Final task: if integration glue is needed, the final task is integration. Otherwise the final task is whatever wires the user-visible surface.
Model selection rule (concrete, not judgment)
For each task, pick model:
haiku— IFF ALL of:Tests required: none,Toucheslists ≤2 paths, no path ends in.tsx/.jsx/.vue/.svelte/.html/.css/.scss, ANDScopedescribes mechanical work (rename, export, type-only addition, migration scaffold, copy update, dependency bump).opus— only when the user explicitly requests it.sonnet— everything else (default).
1.4 Approval gate
Print the dependency graph, one-line summaries, AND a rough cost/concurrency estimate per wave so the user can size the bill before approving:
Wave 1: 4 tasks parallel (3 sonnet + 1 haiku) — est. ~50k input tokens, ~5 min wall
Wave 2: 5 tasks parallel (5 sonnet) — est. ~100k input tokens, ~7 min wall
Wave 3: 3 tasks parallel (3 sonnet) — est. ~60k input tokens, ~5 min wall
Total: 12 tasks, ~210k input tokens, ~17 min wall (assuming no fix-ups)
Heuristic for the estimate (apply per task, then sum): sonnet ≈ 20k input tokens, haiku ≈ 5k input tokens. Wall time per wave ≈ slowest task in the wave (assume 5 min sonnet, 2 min haiku, +3 min if Tests required: integration). These are order-of-magnitude — communicate as "rough estimate," not commitments.
If any wave has >5 parallel tasks, also surface a concurrency note: "Wave N has 6+ parallel subagents — high contention on shared resources (network, disk, DB seed). If you hit rate limits or flaky tests, consider phasing the wave."
Then ask via AskUserQuestion:
- A) Approve and dispatch
- B) Refine (user describes changes; you regenerate TASKS.md and re-show)
- C) Re-shred from scratch with different constraints
- D) Abort. Set
_OUTCOME=abortin env.sh (sed -i.bak 's/^export _OUTCOME=.*/export _OUTCOME="abort"/' "$_STATE_DIR/env.sh" && rm -f "$_STATE_DIR/env.sh.bak"), run the Phase 4.4.5 epilogue, then stop.
Only proceed on A.
PHASE 2 — DISPATCH (WAVE BY WAVE)
For each wave, in order:
2.1 Determine the integration point
Wave 1 branches off _BASE_SHA. Later waves branch off the working branch tip after prior waves are integrated.
This block demonstrates the canonical source pattern — every later bash block follows the same shape (source first, then work):
source "<paste absolute env.sh path printed by Phase 0.6>"
if git log "$_BASE_SHA..$_BRANCH" --oneline 2>/dev/null | grep -q .; then
INTEGRATION_POINT=$(git rev-parse "$_BRANCH")
else
INTEGRATION_POINT="$_BASE_SHA"
fi
echo "INTEGRATION_POINT: $INTEGRATION_POINT"
# Telemetry: stamp wave start time so Phase 3.5 can compute duration_s.
# Substitute the literal wave number for $_WAVE_NUM (e.g. 1, 2, 3...).
echo "$(date +%s)" > "$_STATE_DIR/wave-$_WAVE_NUM.start"
# Also write the wave's task count NOW (orchestrator knows it from TASKS.md)
# so Phase 2.1.5's wave_dispatched event has accurate data. Phase 3.1's
# aggregate loop will overwrite this with the same value (idempotent).
# Substitute the literal task count for $_WAVE_TASK_COUNT.
echo "$_WAVE_TASK_COUNT" > "$_STATE_DIR/wave-$_WAVE_NUM.tasks"
2.1.5 Emit wave_dispatched event (telemetry)
After determining the integration point, before dispatching subagents, emit a timeline event. Substitute literal values for $_WAVE_NUM (this wave's number) and $_WAVE_TASK_COUNT (count of tasks in this wave from TASKS.md).
source "<env.sh path>"
_WAVE_TASK_COUNT=$(cat "$_STATE_DIR/wave-$_WAVE_NUM.tasks" 2>/dev/null || echo 0)
_TL_PAYLOAD=$(jq -nc \
--arg branch "$_BRANCH" --arg sid "$_SESSION_ID" \
--argjson wave "$_WAVE_NUM" --argjson tasks "$_WAVE_TASK_COUNT" \
'{skill:"gstack-orchestrate",event:"wave_dispatched",branch:$branch,wave:$wave,tasks:$tasks,session:$sid}')
~/.claude/skills/gstack/bin/gstack-timeline-log "$_TL_PAYLOAD" 2>/dev/null &
This is best-effort — failures are silenced with || true semantics via the logger itself, and the & ensures it cannot block dispatch. JSON is constructed via jq so branch names containing " (rare but git-legal) don't produce malformed payloads.
2.2 Dispatch all subagents in a single message
For every task in the wave, make ONE Agent tool call. ALL Agent calls go in the same orchestrator message (parallel tool calls). Each call:
subagent_type: "general-purpose"isolation: "worktree"— REQUIRED. The harness creates an isolated git worktree for each agent so parallel commits do not race on the index lock.model: as picked in TASKS.md (haiku|sonnet|opus)description: short label like"T01: rate-limit migration"prompt: filled-in template below
The orchestrator never sets run_in_background — wait for all returns.
Subagent prompt template
Substitute every {{...}} from TASKS.md and Phase 0 variables before dispatching.
For {{COMMIT_PREFIX}} specifically: read _COMMIT_FORMAT from env.sh (set by Phase 0.4) and compute it as:
_COMMIT_FORMAT=plain→{{COMMIT_PREFIX}}=T01:(the task ID followed by a colon and space)_COMMIT_FORMAT=conventional→{{COMMIT_PREFIX}}=chore(T01):(usechoreas the conventional-commits type for orchestration tasks)
Substitute the literal task ID for each subagent (T01, T02, ...) when building the prefix.
You are a focused implementation subagent for task {{TASK_ID}}: {{TASK_NAME}}.
## Working directory
You are running in a fresh, isolated git worktree managed by the harness.
That worktree was branched from {{INTEGRATION_POINT_SHA}}.
Other tasks run in OTHER isolated worktrees in parallel — they cannot see your changes and you cannot see theirs.
## Project context
Read CLAUDE.md (if present) before touching code. Follow project conventions.
Stack: {{STACK}}
Test command: {{TEST_CMD}}
Lint/typecheck: {{LINT_CMD}}
## Scope
{{SCOPE}}
## Files you MAY write
{{TOUCHES}}
## Files you MUST NOT touch
Anything not listed under "MAY write". If you need a forbidden file, do not improvise — set status to "blocked" in your result and explain in "blockers".
## Acceptance criteria (satisfy ALL)
{{ACCEPTANCE}}
## Workflow
1. Read existing code first. Do not re-architect.
2. Implement only what is in scope. Note unrelated bugs in "notes"; do not fix them.
3. Add or extend tests. Run them. They must pass.
4. Run lint/typecheck. Fix anything you introduced.
5. Stage and commit using the prefix the orchestrator computed for this repo's commit style:
`git commit -m "{{COMMIT_PREFIX}}{{TASK_NAME}}"`
The orchestrator substitutes `{{COMMIT_PREFIX}}` based on env.sh `_COMMIT_FORMAT`:
- `plain` → `{{TASK_ID}}: ` (e.g. `git commit -m "T01: rate-limits migration"`)
- `conventional` → `chore({{TASK_ID}}): ` (e.g. `git commit -m "chore(T01): rate-limits migration"`)
6. Capture the commit SHA: `_SHA=$(git rev-parse HEAD)`
7. Write your result JSON to the SHARED results dir (NOT inside the worktree). Use `jq -n` for safe construction — never embed paths into a heredoc directly, since filenames may contain quotes or backslashes:
```bash
mkdir -p "{{RESULTS_DIR}}"
# Build the files_changed array from git diff for safety
_FILES_JSON=$(git show --name-only --format= HEAD | jq -R . | jq -s .)
_TESTS_JSON='[]' # populate similarly with your test paths if you tracked them
jq -n \
--arg id "{{TASK_ID}}" \
--arg sha "$_SHA" \
--argjson files "$_FILES_JSON" \
--argjson tests "$_TESTS_JSON" \
--argjson tcount 0 \
--arg notes "" \
'{task_id:$id, status:"success", commit:$sha, files_changed:$files, tests_added:$tests, tests_passing:true, tests_count:$tcount, lint_passing:true, notes:$notes}' \
> "{{RESULTS_DIR}}/{{TASK_ID}}.json"
If status is blocked or failed: set commit to null, tests_passing to false, and add a "blockers" string field. The orchestrator's Phase 3.1 reads this file with jq -r '.field // "default"' so missing fields are handled gracefully.
Result schema (must be valid JSON)
{ "task_id": "string", "status": "success" | "blocked" | "failed", "commit": "string or null", "files_changed": ["string", ...], "tests_added": ["string", ...], "tests_passing": true | false, "tests_count": integer, "lint_passing": true | false, "notes": "string", "blockers": "string (only if status != success)" }
Hard rules
- Do NOT exceed your scope or touch forbidden paths.
- Do NOT skip tests. If tests fail, status is "failed", not "success".
- If your scope is wrong, return status=blocked. Do not improvise.
- Do NOT push, fork, or open PRs. Just commit locally.
- The result JSON file at {{RESULTS_DIR}}/{{TASK_ID}}.json is the source of truth — write it before returning.
### Worked example: dispatching Wave 1 with 3 tasks
Suppose Wave 1 has T01 (migration, haiku), T02 (redis client config, haiku), T03 (rate-limit types, sonnet). The orchestrator's single message contains three Agent calls:
Agent({ description: "T01: rate-limits migration", subagent_type: "general-purpose", model: "haiku", isolation: "worktree", prompt: "<filled-in subagent template — see above. {{TASK_ID}}=T01, {{INTEGRATION_POINT_SHA}}=<_BASE_SHA>, {{RESULTS_DIR}}=<_STATE_DIR>/results, {{TOUCHES}}=db/migrations/20260429_rate_limits.sql, {{ACCEPTANCE}}=migration runs forward and back, {{COMMIT_PREFIX}}=chore(T01): (because _COMMIT_FORMAT=conventional in this repo), ...>" }) Agent({ description: "T02: redis client config", subagent_type: "general-purpose", model: "haiku", isolation: "worktree", prompt: "<filled-in template for T02>" }) Agent({ description: "T03: rate-limit types", subagent_type: "general-purpose", model: "sonnet", isolation: "worktree", prompt: "<filled-in template for T03>" })
All three Agent calls go in the SAME orchestrator message so they run in parallel. Do not call them sequentially — that defeats the worktree isolation and burns wall time.
### 2.3 Wait for all returns
The Agent tool returns each subagent's final message text. The orchestrator's source of truth is `$_STATE_DIR/results/$TASK_ID.json`, NOT the agent's text response.
The harness returns each agent's worktree path and branch name in the result. The orchestrator does not need them — cherry-pick uses commit SHA only.
---
## PHASE 3 — VERIFY EACH WAVE
After every wave returns, before the next:
### 3.1 Aggregate (defensive read)
For each task in the wave, read `$_STATE_DIR/results/$TASK_ID.json` defensively:
```bash
# Initialize wave counters (telemetry — Phase 3.5 emits these)
_WAVE_TASK_COUNT=0
_WAVE_SUCCESS=0
_WAVE_FAILED=0
_WAVE_FIXUPS=0
for TASK in $(...task ids in wave...); do
_WAVE_TASK_COUNT=$(( _WAVE_TASK_COUNT + 1 ))
RESULT="$_STATE_DIR/results/$TASK.json"
if [ ! -f "$RESULT" ]; then
echo "$TASK: MISSING_RESULT (treating as failed)"
_WAVE_FAILED=$(( _WAVE_FAILED + 1 ))
continue
fi
STATUS=$(jq -r '.status // "MALFORMED"' "$RESULT" 2>/dev/null || echo "MALFORMED")
TESTS=$(jq -r '.tests_passing // false' "$RESULT" 2>/dev/null || echo "false")
COMMIT=$(jq -r '.commit // empty' "$RESULT" 2>/dev/null || echo "")
REACHABLE="no"
[ -n "$COMMIT" ] && git cat-file -e "$COMMIT" 2>/dev/null && REACHABLE="yes"
echo "$TASK: status=$STATUS tests=$TESTS commit=${COMMIT:-none} reachable=$REACHABLE"
# Tally success/failed for telemetry
if [ "$STATUS" = "success" ] && [ "$TESTS" = "true" ] && [ "$REACHABLE" = "yes" ]; then
_WAVE_SUCCESS=$(( _WAVE_SUCCESS + 1 ))
else
_WAVE_FAILED=$(( _WAVE_FAILED + 1 ))
fi
done
# Persist wave counts so Phase 3.5 (fresh shell) can emit accurate telemetry.
# Each Bash tool call gets a new shell; counts must survive via the state dir.
echo "$_WAVE_TASK_COUNT" > "$_STATE_DIR/wave-$_WAVE_NUM.tasks"
echo "$_WAVE_SUCCESS" > "$_STATE_DIR/wave-$_WAVE_NUM.success"
echo "$_WAVE_FAILED" > "$_STATE_DIR/wave-$_WAVE_NUM.failed"
# Fix-ups are bumped by Phase 3.4 (suite-failure fix-up subagent dispatch).
[ -f "$_STATE_DIR/wave-$_WAVE_NUM.fixups" ] || echo "0" > "$_STATE_DIR/wave-$_WAVE_NUM.fixups"
A task counts as success ONLY when ALL hold:
- result file exists
- JSON parses (
STATUS != MALFORMED) status == "success"tests_passing == truecommitis non-emptygit cat-file -e $COMMITsucceeds (commit is reachable in shared object DB)
Anything else is failed.
3.2 Block on any failure
If any task is not success: stop. Use AskUserQuestion:
- A) Dispatch a fix-up subagent (
Agentwithisolation: "worktree") for that task with the blocker as scope - B) Re-shred the remaining tasks (regenerate TASKS.md from this wave forward)
- C) Abort. Set
_OUTCOME=abortin env.sh (sed -i.bak 's/^export _OUTCOME=.*/export _OUTCOME="abort"/' "$_STATE_DIR/env.sh" && rm -f "$_STATE_DIR/env.sh.bak"), run the Phase 4.4.5 epilogue, then stop.
The orchestrator does NOT fix the failure itself.
3.3 Integrate the wave onto the working branch
cd "$(git rev-parse --show-toplevel)"
git checkout "$_BRANCH"
for TASK in $(...task ids in wave, in dependency order from TASKS.md...); do
COMMIT=$(jq -r '.commit' "$_STATE_DIR/results/$TASK.json")
git cherry-pick "$COMMIT" || { echo "CONFLICT on $TASK at $COMMIT"; CONFLICT="$TASK"; break; }
done
If a cherry-pick conflicts: dispatch one fix-up subagent (Agent with isolation: "worktree") with the failing commit and conflict markers as scope. The orchestrator never resolves conflicts itself.
Branch invariant: $_BRANCH is checked out only in the main repo, never in a subagent worktree. The Agent tool's isolation: "worktree" creates fresh per-agent branches automatically — they do not collide with $_BRANCH.
3.4 Run the full suite once at the wave boundary
source "<env.sh path>"
$TEST_CMD && $LINT_CMD
If it fails: dispatch one fix-up subagent (Agent with isolation: "worktree"). Bump the fix-up counter for telemetry before dispatching:
source "<env.sh path>"
_F=$(cat "$_STATE_DIR/wave-$_WAVE_NUM.fixups" 2>/dev/null || echo 0)
echo $(( _F + 1 )) > "$_STATE_DIR/wave-$_WAVE_NUM.fixups"
The subagent commits in its own worktree and writes a result JSON to $_STATE_DIR/results/wave-N-fixup.json. After it returns:
COMMIT=$(jq -r '.commit // empty' "$_STATE_DIR/results/wave-N-fixup.json")
[ -n "$COMMIT" ] && git cherry-pick "$COMMIT"
$TEST_CMD && $LINT_CMD
If it still fails after the fix-up cherry-pick, ask the user via AskUserQuestion: A) dispatch another fix-up (max 2 retries per wave), B) abort. Do not loop indefinitely. On B (this is unrecoverable, not user-driven), set _OUTCOME=error in env.sh (sed -i.bak 's/^export _OUTCOME=.*/export _OUTCOME="error"/' "$_STATE_DIR/env.sh" && rm -f "$_STATE_DIR/env.sh.bak"), run the Phase 4.4.5 epilogue, then stop.
3.5 Checkpoint + emit wave_completed (telemetry)
Before writing the checkpoint, compute wave duration and aggregate counts. The aggregate counts (_WAVE_SUCCESS, _WAVE_FAILED, _WAVE_FIXUPS) come from the orchestrator's bookkeeping during 3.1-3.4 — surface them as variables before reaching 3.5. Substitute the literal wave number for $_WAVE_NUM.
source "<env.sh path>"
_WAVE_START=$(cat "$_STATE_DIR/wave-$_WAVE_NUM.start" 2>/dev/null || echo "$(date +%s)")
_WAVE_END=$(date +%s)
_WAVE_DUR=$(( _WAVE_END - _WAVE_START ))
# Read counts persisted by Phase 3.1's aggregate loop and Phase 3.4's fix-up bumper.
# Defaults are defensive — Phase 3.1 always writes them, but a malformed earlier
# step shouldn't break the checkpoint write below.
_WAVE_TASK_COUNT=$(cat "$_STATE_DIR/wave-$_WAVE_NUM.tasks" 2>/dev/null || echo 0)
_WAVE_SUCCESS=$(cat "$_STATE_DIR/wave-$_WAVE_NUM.success" 2>/dev/null || echo 0)
_WAVE_FAILED=$(cat "$_STATE_DIR/wave-$_WAVE_NUM.failed" 2>/dev/null || echo 0)
_WAVE_FIXUPS=$(cat "$_STATE_DIR/wave-$_WAVE_NUM.fixups" 2>/dev/null || echo 0)
# Write checkpoint to state.jsonl. If this fails (disk full, perms), do NOT
# emit the wave_completed timeline event — that would be a silent lie.
if jq -nc \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg wave "$_WAVE_NUM" \
--arg head "$(git rev-parse HEAD)" \
--argjson duration "$_WAVE_DUR" \
--argjson tasks "$_WAVE_TASK_COUNT" \
--argjson success "$_WAVE_SUCCESS" \
--argjson failed "$_WAVE_FAILED" \
--argjson fixups "$_WAVE_FIXUPS" \
'{ts:$ts,wave:$wave,head:$head,status:"completed",duration_s:$duration,task_count:$tasks,success:$success,failed:$failed,fixups:$fixups}' \
>> "$_STATE_DIR/state.jsonl" 2>/dev/null; then
# Checkpoint persisted — emit the timeline event for the dashboard.
# jq-built JSON for safety against `"` in branch names (git-legal, rare).
_TL_PAYLOAD=$(jq -nc \
--arg branch "$_BRANCH" --arg sid "$_SESSION_ID" \
--argjson wave "$_WAVE_NUM" --argjson dur "$_WAVE_DUR" \
--argjson success "$_WAVE_SUCCESS" --argjson failed "$_WAVE_FAILED" --argjson fixups "$_WAVE_FIXUPS" \
'{skill:"gstack-orchestrate",event:"wave_completed",branch:$branch,wave:$wave,duration_s:$dur,success:$success,failed:$failed,fixups:$fixups,session:$sid}')
~/.claude/skills/gstack/bin/gstack-timeline-log "$_TL_PAYLOAD" 2>/dev/null &
else
# Checkpoint failed. Tell the user — resume will be broken until disk/perms
# are sorted. Do NOT emit a misleading wave_completed event.
echo "ERROR: state.jsonl write failed for wave $_WAVE_NUM. Resume will not work until fixed. Check disk/permissions on $_STATE_DIR." >&2
fi
The state.jsonl additions (duration_s, task_count, success, failed, fixups) are additive. Phase 1.1's resume logic only reads head, wave, and status, so older entries from pre-1.6 runs remain compatible.
PHASE 4 — FINAL INTEGRATION & HANDOFF
After the last wave passes:
4.1 Run review (bounded loop, max 2 cleanup passes)
Invoke the Skill tool: Skill({skill: "review"}). Capture output.
If /review flags issues:
- Dispatch one cleanup subagent (
Agentwithisolation: "worktree") with the review feedback as scope. - After it returns, cherry-pick its commit (read from
$_STATE_DIR/results/cleanup-1.json) onto$_BRANCH. - Re-invoke
Skill({skill: "review"}). - If issues remain, repeat once more (cleanup-2). After 2 cleanup passes, do NOT loop further. Use
AskUserQuestion: A) ship anyway with known issues, B) abort (preserve state). On B, set_OUTCOME=errorin env.sh (sed -i.bak 's/^export _OUTCOME=.*/export _OUTCOME="error"/' "$_STATE_DIR/env.sh" && rm -f "$_STATE_DIR/env.sh.bak"), run the Phase 4.4.5 epilogue, then stop. (On A, treat as success — preamble's_OUTCOME=successdefault carries through.)
4.2 QA / suite
If the plan had UI changes (any wave touched .tsx/.jsx/.vue/.svelte/.html/.css/.scss), invoke Skill({skill: "qa"}). Otherwise:
source "<env.sh path>"
$TEST_CMD
4.3 Worktree cleanup
Prune worktree admin records for any worktrees the Agent harness created and abandoned:
git worktree prune
Note: the harness's auto-managed branches will persist after pruning — this is intentional. Deleting them automatically is unsafe because we cannot reliably distinguish harness-created branches from user-owned branches with the same merge-status. If they accumulate over many runs, the user can clean up manually with git branch --list and git branch -D.
4.4 Final report
Done.
Tasks shipped: N/N
Commits: <range>
Files changed: <count>
Tests added: <count>
Review status: clean | <N issues addressed after K cleanup passes>
Deferred items: <list, from notes>
State: <_STATE_DIR>
4.4.5 Telemetry epilogue (run before handoff)
Run before any abort/error path stops the skill AND before the handoff in 4.5. The _OUTCOME variable was set to success in the preamble; abort and error gates override it (see "Abort / error semantics"). The epilogue mirrors the sibling pattern in /ship, /review, /qa.
Resilient sourcing. Source env.sh if Phase 0.6 ran; otherwise source the tel-state file written at the end of the preamble. Fresh-shell rules mean shell vars are gone — substitute literal paths from preamble output.
# Substitute the literal env.sh path printed by Phase 0.6 (if Phase 0.6 ran)
# OR the literal TEL_STATE path printed by the preamble (if Phase 0 failed
# before 0.6). Caller chooses which based on where the epilogue is invoked
# from. NO glob fallback — picking the wrong session's file is worse than
# emitting a no-op.
source <ENV_FILE_PATH-or-TEL_STATE_PATH>
_TEL_END=$(date +%s)
_TEL_DUR=$(( _TEL_END - ${_TEL_START:-$_TEL_END} ))
rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true
# Timeline: skill completed (local-only). jq-built for safe JSON.
_TL_BR=$(git branch --show-current 2>/dev/null || echo unknown)
_TL_PAYLOAD=$(jq -nc --arg branch "$_TL_BR" --arg sid "$_SESSION_ID" --arg outcome "$_OUTCOME" --argjson dur "$_TEL_DUR" \
'{skill:"gstack-orchestrate",event:"completed",branch:$branch,outcome:$outcome,duration_s:$dur,session:$sid}')
~/.claude/skills/gstack/bin/gstack-timeline-log "$_TL_PAYLOAD" 2>/dev/null || true
# Local analytics end row (gated)
if [ "$_TEL" != "off" ]; then
echo '{"skill":"gstack-orchestrate","duration_s":"'"$_TEL_DUR"'","outcome":"'"$_OUTCOME"'","browse":"false","session":"'"$_SESSION_ID"'","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
fi
# Remote telemetry (opt-in, requires binary)
if [ "$_TEL" != "off" ] && [ -x ~/.claude/skills/gstack/bin/gstack-telemetry-log ]; then
~/.claude/skills/gstack/bin/gstack-telemetry-log \
--skill "gstack-orchestrate" --duration "$_TEL_DUR" --outcome "$_OUTCOME" \
--used-browse "false" --session-id "$_SESSION_ID" 2>/dev/null &
fi
# Clean up the preamble's tel-state file. Don't fail the epilogue if it's missing.
[ -n "${_SESSION_ID:-}" ] && rm -f ~/.gstack/analytics/.tel-"$_SESSION_ID".sh 2>/dev/null || true
4.5 Handoff
Ask via AskUserQuestion: "Ready to ship? This will invoke the /ship skill which pushes and creates a PR."
If yes, invoke Skill({skill: "ship"}). If no, leave state intact and tell the user how to resume or run /ship manually later.
ORCHESTRATOR HARD RULES
- You are the orchestrator. You do not write feature code, fix code, resolve conflicts, or edit source files.
- The only files you write directly:
TASKS.md,state.jsonl, the final report. - Never skip the Phase 0 pre-flight or the Phase 1 approval gate.
- Never start Wave N+1 if any task in Wave N is not
success(status, tests, commit reachable). - Always dispatch all subagents in a wave in a single message (parallel tool calls).
- Always pass
isolation: "worktree"to every Agent call. - If <4 parallel tasks emerge from shredding, abort and recommend
/autoplan. - If
/freezeis active, abort. - The orchestrator never pushes, forks, or opens PRs. Handoff to
/shipdoes that.
START
Run Phase 0. Then Phase 1.1 resume check. If no resume, Phase 1.2-1.4 (shred + approval gate). Do not dispatch until the user picks A.