---
name: apt:fan-out
description: "Cross-task fan-out — spawn N apt-executor workers from one conductor terminal, each in its own worktree"
apt-skill-version: {{APT_VERSION}}
stage: execute
intent: x-fan-out
when_to_use: "You have N pre-planned, independent tasks (no file overlap, no cross-dependencies) and want to run them concurrently from a single host CLI session."
user_invocable: true
internal: false
spawns_agent: true
agent_name: "apt-executor"
task_context: require-existing
default_execution_mode: auto
execution_modes:
  - auto
  - step
allowed-tools: "Bash, Read, Write, Task"
argument-hint: "apt:fan-out --tasks <id,id,id> [--phases <m/p,m/p>] [--unblocked]"
gates: []
---
<objective>
Run N pre-planned Aperant tasks concurrently from one conductor terminal. This skill is a thin orchestrator over the generalized wave engine (`unit_kind: "task"` path, see ADR-0003) and the existing primitives: `apt-tools worktree create --task`, `apt-tools lock claim`, `apt-tools event append`, and the `parallelization.max_tasks` quota. No daemon, no job queue, no in-skill scheduling. The host CLI's main thread is the conductor (ADR-0001); each spawned apt-executor subagent runs in its own worktree (ADR-0002).

This is the "I have 16 unblocked tasks in this phase, run them all" surface. It replaces the old workaround of opening 16 sibling terminals manually.
</objective>

<your_environment>
- **Working directory:** Project root (where you were invoked).
- **apt-tools path:** `node packages/framework/bin/apt-tools.mjs` (or `node .aperant/deps/node_modules/@aperant/framework/bin/apt-tools.mjs` when running from an install mirror).
- **Concurrency cap:** `parallelization.max_tasks` from `.aperant/config.json` (default 4, no paternalism cap per D-07). Read via `cli/config/parallelization.mjs:getMaxTasks(config)`.
- **Worker identity:** Each spawned worker exports `APT_AGENT_ID=fanout-${batch_id}-${task_id}` before any `apt-tools ...` invocation (per FRAMEWORK-BUG-033 defect 3). This pins their `team-status/{agentId}.json` rows across short-lived CLI calls.
- **Constitution:** Read `AGENTS.md` if it exists.
</your_environment>

<state_files>
## State Files

**Reads:**
- `.aperant/state.json` — `active_tasks[*]` filtered when `--phases` or `--unblocked` resolves the task list.
- `.aperant/roadmap/{scope}/milestones.json` — milestone/phase membership for `--phases`.
- `.aperant/config.json:parallelization.max_tasks` — concurrency cap.

**Writes (indirectly via spawned workers):**
- `.aperant/team-status/fanout-${batch_id}-${task_id}.json` — one stable file per worker (per FRAMEWORK-BUG-033 defect 3 fix).
- `.aperant/state.json:active_tasks[*].assigned_to` — workers stamp their `APT_AGENT_ID` on claim (per FRAMEWORK-BUG-033 defect 2 fix).
- `.aperant/events/{today}.jsonl` — `task-claimed`, `wave-started`, `wave-completed` events.

**No filesystem state of its own.** The skill body is pure orchestration glue; all heavy lifting reuses existing primitives.
</state_files>

<process>

## 1. Resolve the batch — canonical task ID list

The three input forms (`--tasks`, `--phases`, `--unblocked`) all resolve to a canonical `task_ids: [...]` list via the apt-tools helper:

```bash
node packages/framework/bin/apt-tools.mjs fan-out resolve . --tasks T1,T2,T3,T4
# OR
node packages/framework/bin/apt-tools.mjs fan-out resolve . --phases m1/p1,m1/p2
# OR
node packages/framework/bin/apt-tools.mjs fan-out resolve . --unblocked
```

Returns:
```json
{
  "status": "ok",
  "command": "fan-out-resolve",
  "task_ids": ["T1", "T2", "T3", "T4"],
  "source": "tasks",
  "batch_id": "fanout-20260519-abc123"
}
```

If `--tasks` IDs fail the `/^[a-z0-9_-]+$/i` validation regex, the helper returns `status: error` and the skill body MUST abort.

## 2. Ensure each task has a worktree

For each `task_id` in the batch:

```bash
node packages/framework/bin/apt-tools.mjs worktree create --task ${task_id}
```

(Skip if `state.active_tasks[task_id].worktree_path` is already set — the create command is idempotent.)

## 3. Spawn N apt-executor subagents

Read the `parallelization.max_tasks` cap from `.aperant/config.json` (or default 4). Slice `task_ids` into batches of size `min(cap, task_ids.length)`. For each task in the current batch, dispatch via the host CLI's `Task` tool with `run_in_background: true`:

- `subagent: apt-executor`
- `prompt` includes:
  - `batch_id` (from step 1)
  - `task_id`
  - The worktree path for this task
  - A reminder: "Export `APT_AGENT_ID=fanout-${batch_id}-${task_id}` in every Bash invocation that runs `apt-tools` — see `packages/framework/agents/apt-executor.md` §0a."

The executor agent's `<process>` §0a already documents the env-export rule; the spawn prompt just has to seed `batch_id` and `task_id`.

## 4. Poll team-status until all workers reach a terminal state

```bash
node packages/framework/bin/apt-tools.mjs team list .
```

Each worker's row uses its `APT_AGENT_ID=fanout-${batch_id}-${task_id}`. A worker is "done" when its corresponding task's `lifecycle_phase` is one of `closed`, `shipped-pending-merge`, or `blocked`.

**On each poll, re-render the multi-task phase rail verbatim** — one boxed panel per worker, branch glyphs `┌─ │ └─` separating panels, status glyphs `✓` (done) / `●` (active) / `○` (pending), inline metrics on completed rows. Pull metrics from each worker's `build-progress.json` / `qa_signoff.json` / `review.json` (see paths in `.aperant/tasks/{task_id}/`):

```
[APT] Fan-out status: {N} workers
┌─ {task 1 short name} ─────────────────────────────
│  Phase 1 Plan       ✓
│  Phase 2 Execute    ✓  7 commits, 81 tests
│  Phase 3 Verify     ✓  APPROVED 9/10/9/9
│  Phase 4 Review     ✓  APPROVE 9/9/9/10/10, 5 findings fixed
│  Phase 5 Ship       ●  opening PR
│
└─ {task 2 short name} ─────────────────────────────
   Phase 1 Plan       ✓
   Phase 2 Execute    ✓  14 commits, 129 tests
   Phase 3 Verify     ●  running
   Phase 4 Review     ○
   Phase 5 Ship       ○

Churned for {Xm Ys} · {K} local agents still running
```

The first panel uses `┌─ ... │` (top-with-stem); the LAST panel uses `└─ ... ` (bottom corner). Footer line: "Churned" = elapsed since the most recent lifecycle event across all in-flight workers; "K local agents still running" = count of workers whose `lifecycle_phase` is non-terminal AND `assigned_to` is non-null.

## 5. Report

When all workers reach a terminal state, render the rail one final time. For each shipped worker (lifecycle `shipped-pending-merge` + `pr_url` set), collapse that panel to the SHIPPED variant:

```
[APT] Fan-out complete
┌─ {task 1 short name} ────────────────────── ✅ SHIPPED
│  Plan ✓ Execute ✓ Verify ✓ Review ✓ Ship ✓
│  PR #{N}: {url}
│
└─ {task 2 short name} ─────────────────────── ⚠ BLOCKED
   {one-line reason}
```

Mix SHIPPED / BLOCKED / in-flight variants in the same rail. Below the panel, emit one consolidated summary: which tasks closed (verdict + PR URL), which are awaiting merge, which blocked. Recommend `/apt:close-task --all` for the post-merge sweep. The `[APT] Fan-out` header lines above each rail are the backward-compat markers for external log-grep tooling — keep them.

</process>

## Design references

- **ADR-0001** — Host CLI main thread is the conductor; no daemon, no in-skill queue.
- **ADR-0002** — One worktree per task; workers run independent feature branches.
- **ADR-0003** — Wave engine accepts `unit_kind: "task"` so cross-task fan-out reuses the same primitives as intra-task subtask waves.

## Config keys

- `parallelization.max_tasks` (default 4) — concurrency cap for the spawn loop.
- `parallelization.max_agents` (default 4) — global agent quota; the spawn loop respects whichever cap is lower.
