Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
464 changes: 464 additions & 0 deletions docs/superpowers/plans/2026-04-30-plan-4-parallel-subagents.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions plugin/agents/builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,15 @@ description: FrinkLoop builder — implements ONE task. Reads task spec, edits f

## Failure handling
If you can't make progress, return with status `BLOCKED` and a one-line reason. The mvp-loop will queue a fix task or escalate.

## Worktree contract (Plan 4)

When the loop dispatches you with a `WORKTREE_PATH` parameter, you operate inside that directory only:

- `cd "$WORKTREE_PATH"` is your first step
- All edits and commits happen in this worktree
- Your branch is `frinkloop/task-<id>` — you don't choose it, the orchestrator created it
- Don't merge anything yourself. The orchestrator does the fast-forward back to the project's main branch.
- Don't touch other worktrees or the main project tree (`$PROJECT_DIR` outside the worktree).

If `WORKTREE_PATH` is unset, you're in single-task mode — operate directly in `$PROJECT_DIR` as before (Plan 2 behavior).
47 changes: 47 additions & 0 deletions plugin/lib/loop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,53 @@ queue_fix_task() {
echo "$next_id"
}

# Returns up to MAX task ids that can run in parallel — pending, deps-satisfied, paths-disjoint.
# Tasks with no `paths_touched` are treated as non-conflicting (they join the batch freely).
# Tasks whose paths_touched overlap with an already-taken path are skipped.
# Greedy: walks pending tasks in order and takes each eligible one.
pick_parallel_batch() {
local max="${1:-10}"
local mid
mid=$(active_milestone)
if [ -z "$mid" ]; then
return 0
fi

jq -r --arg mid "$mid" --argjson max "$max" '
.milestones[] | select(.id == $mid) | .tasks as $all
| ($all | map(select(.status == "pending")) | map(.id)) as $pending_ids
| ($all | map(select(.status == "pending"))) as $pending
| $pending
| reduce .[] as $t (
{"batch": [], "claimed": []};
if (.batch | length) >= $max then
.
else
# Check deps satisfied: all depends_on resolved (not in pending)
(($t.depends_on // []) | map(. as $d | $pending_ids | index($d)) | all(. == null)) as $deps_ok
# Paths for this task
| ($t.paths_touched // []) as $tp
# Save claimed array for use in map context
| .claimed as $claimed
# Check path conflict: if task has paths, check none overlap with claimed
| (
($tp | length) == 0
or ($tp | map(. as $p | $claimed | index($p)) | all(. == null))
) as $paths_ok
| if $deps_ok and $paths_ok then
{
"batch": (.batch + [$t.id]),
"claimed": (.claimed + $tp)
}
else
.
end
end
)
| .batch | join(" ")
' "$FRINKLOOP_DIR/tasks.json"
}

# Mark a milestone done if and only if all its tasks are done.
# Returns 1 (no-op) if any task is still not done.
mark_milestone_done() {
Expand Down
3 changes: 2 additions & 1 deletion plugin/lib/schemas/tasks.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"depends_on": { "type": "array", "items": { "type": "string" } },
"defer_to_phase_2": { "type": "boolean" },
"retries": { "type": "integer", "minimum": 0 },
"notes": { "type": "string" }
"notes": { "type": "string" },
"paths_touched": { "type": "array", "items": { "type": "string" } }
}
}
}
Expand Down
52 changes: 52 additions & 0 deletions plugin/lib/worktrees.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# FrinkLoop worktree manager — per-task isolation for parallel builders.
# Caller's cwd must be a git repo (the project being built). Worktrees live under
# <project>/.frinkloop/worktrees/task-<id>/ branched from current HEAD.

set -euo pipefail

WORKTREE_BASE=".frinkloop/worktrees"

create_task_worktree() {
local task_id="$1"
local branch="frinkloop/task-${task_id}"
local path="$WORKTREE_BASE/task-${task_id}"
if git worktree list --porcelain | grep -q "$path$"; then
echo "$(pwd)/$path"
return 0
fi
git worktree add "$path" -b "$branch" >/dev/null
echo "$(pwd)/$path"
}

remove_task_worktree() {
local task_id="$1"
local branch="frinkloop/task-${task_id}"
local path="$WORKTREE_BASE/task-${task_id}"
if [ -d "$path" ]; then
git worktree remove --force "$path" >/dev/null 2>&1 || true
fi
git branch -D "$branch" >/dev/null 2>&1 || true
}

list_task_worktrees() {
git worktree list --porcelain | awk '
/^worktree / { wt=$2 }
/^branch refs\/heads\/frinkloop\/task-/ {
branch=substr($2, length("refs/heads/") + 1)
print wt " " branch
}
'
}

prune_task_worktrees() {
local paths
paths=$(list_task_worktrees)
while IFS= read -r p; do
[ -z "$p" ] && continue
# Each line is "<worktree_path> frinkloop/task-<id>"
local branch="${p##* }"
local task_id="${branch##*/task-}"
remove_task_worktree "$task_id"
done <<< "$paths"
}
31 changes: 31 additions & 0 deletions plugin/skills/mvp-loop/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,37 @@ If `config.yaml` has `hitl: milestones`, after each milestone is marked done, se

Read `config.yaml`. If `compression ∈ {lite, full, ultra}`, prepend a caveman directive to subagent prompts when dispatching. Loop narration itself stays uncompressed.

## Parallel fan-out (Plan 4)

When `pick_parallel_batch 10` returns ≥2 task ids, the loop dispatches multiple builder subagents simultaneously via the Task tool, capped at 10 per Claude Code limit.

### Steps

1. Source `plugin/lib/worktrees.sh`.
2. For each task id in the batch:
- `create_task_worktree <id>` → returns the absolute worktree path
- Set `status: in-progress` on the task in tasks.json
3. Dispatch all builders in a SINGLE assistant message with multiple Task tool calls. Each builder receives:
- The task JSON
- The absolute worktree path (its only writable directory)
- The branch name (`frinkloop/task-<id>`)
- Compression directive (if config says so)
4. After all builders complete (Claude Code awaits all parallel Task calls before continuing):
- **Aggregator step:** for each completed task, read ONLY the artifact / commit sha. Do NOT read the subagent transcript.
- Merge each `frinkloop/task-<id>` branch back into the project's main branch in id-sorted order, fast-forward where possible. Conflicts → mark the task BLOCKED.
- Run qa for each. Run `verify_task` for each.
- On verify pass: `mark_task_done <id>` and `remove_task_worktree <id>`.
- On verify fail: `queue_fix_task <id> "<error>"`. Don't remove the worktree (next iteration may retry).

### When to fall back to sequential

- Batch size 1 → use the existing sequential path (skip worktree creation; build in main project tree as before)
- Any task without `paths_touched` set → that task runs alone (`pick_parallel_batch` already enforces this rule)

### Cleanup

After every milestone completes, run `prune_task_worktrees` to free disk.

## What this skill is NOT

- Not parallel — that's Plan 4
Expand Down
61 changes: 61 additions & 0 deletions tests/plan-4/test_parallel_batch.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env bats

setup() {
TMPDIR=$(mktemp -d)
export FRINKLOOP_DIR="$TMPDIR/.frinkloop"
mkdir -p "$FRINKLOOP_DIR"
PLUGIN_LIB_DIR="$(cd "$BATS_TEST_DIRNAME/../../plugin/lib" && pwd)"
source "$PLUGIN_LIB_DIR/state.sh"
source "$PLUGIN_LIB_DIR/loop.sh"
state_init main

cat > "$FRINKLOOP_DIR/tasks.json" <<EOF
{
"schema_version": 1,
"milestones": [
{ "id": "m1", "title": "Build", "status": "in-progress",
"tasks": [
{"id":"T01","title":"Add login","status":"pending","kind":"feature","paths_touched":["src/auth/"]},
{"id":"T02","title":"Add settings page","status":"pending","kind":"feature","paths_touched":["src/settings/"]},
{"id":"T03","title":"Add billing page","status":"pending","kind":"feature","paths_touched":["src/billing/"]},
{"id":"T04","title":"Refactor auth helper","status":"pending","kind":"feature","paths_touched":["src/auth/"]},
{"id":"T05","title":"Update README","status":"pending","kind":"doc"}
]
}
]
}
EOF
}

teardown() {
rm -rf "$TMPDIR"
}

@test "pick_parallel_batch returns up to N tasks with disjoint paths" {
run pick_parallel_batch 10
[ "$status" -eq 0 ]
# Should return T01, T02, T03, T05 (T04 conflicts with T01 on src/auth/) — 4 ids
echo "$output" | grep -q "T01"
echo "$output" | grep -q "T02"
echo "$output" | grep -q "T03"
echo "$output" | grep -q "T05"
! echo "$output" | grep -q "T04"
}

@test "pick_parallel_batch respects the max parameter" {
run pick_parallel_batch 2
[ "$status" -eq 0 ]
count=$(echo "$output" | wc -w)
[ "$count" -eq 2 ]
}

@test "pick_parallel_batch returns empty when nothing is pending" {
jq '.milestones[0].tasks |= map(.status = "done")' "$FRINKLOOP_DIR/tasks.json" > /tmp/t && mv /tmp/t "$FRINKLOOP_DIR/tasks.json"
run pick_parallel_batch 10
[ -z "$output" ]
}

@test "pick_parallel_batch always picks the first one (matches pick_next_task)" {
run pick_parallel_batch 1
[ "$output" = "T01" ]
}
18 changes: 18 additions & 0 deletions tests/plan-4/test_skill_updates.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env bats

@test "mvp-loop SKILL.md mentions parallel fan-out and pick_parallel_batch" {
grep -q "pick_parallel_batch" plugin/skills/mvp-loop/SKILL.md
grep -q -i "parallel" plugin/skills/mvp-loop/SKILL.md
grep -q "10" plugin/skills/mvp-loop/SKILL.md
}

@test "mvp-loop SKILL.md mentions worktree-per-task" {
grep -q -i "worktree" plugin/skills/mvp-loop/SKILL.md
grep -q "create_task_worktree" plugin/skills/mvp-loop/SKILL.md
}

@test "builder agent has worktree contract section" {
grep -q -i "worktree" plugin/agents/builder.md
grep -q "PROJECT_DIR" plugin/agents/builder.md
grep -q "frinkloop/task-" plugin/agents/builder.md
}
59 changes: 59 additions & 0 deletions tests/plan-4/test_worktrees.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env bats

setup() {
TMPDIR=$(mktemp -d)
PLUGIN_LIB_DIR="$(cd "$BATS_TEST_DIRNAME/../../plugin/lib" && pwd)"

export PROJECT_DIR="$TMPDIR/proj"
mkdir -p "$PROJECT_DIR"
cd "$PROJECT_DIR"
git init -q
git config user.email t@example.com
git config user.name t
echo "init" > README.md
git add README.md
git -c commit.gpgsign=false commit -q -m "init"
source "$PLUGIN_LIB_DIR/worktrees.sh"
}

teardown() {
cd /
rm -rf "$TMPDIR"
}

@test "create_task_worktree creates a worktree at the right path" {
path=$(create_task_worktree T01)
[ -d "$path" ]
[ -f "$path/README.md" ]
}

@test "create_task_worktree creates a unique branch frinkloop/task-<id>" {
create_task_worktree T01 >/dev/null
run git branch --list 'frinkloop/task-T01'
[ -n "$output" ]
}

@test "list_task_worktrees returns paths matching the task pattern" {
create_task_worktree T01 >/dev/null
create_task_worktree T02 >/dev/null
run list_task_worktrees
[ "$status" -eq 0 ]
echo "$output" | grep -q "frinkloop/task-T01"
echo "$output" | grep -q "frinkloop/task-T02"
}

@test "remove_task_worktree cleans up cleanly" {
path=$(create_task_worktree T01)
remove_task_worktree T01
[ ! -d "$path" ]
run git branch --list 'frinkloop/task-T01'
[ -z "$output" ]
}

@test "prune_task_worktrees removes all task worktrees" {
create_task_worktree T01 >/dev/null
create_task_worktree T02 >/dev/null
prune_task_worktrees
run list_task_worktrees
[ -z "$output" ]
}