Marathon Engine

SkillFiles & storage

Run a list of work units to completion with an Agent Team: derive a dependency DAG and hot-file map, spawn one ephemeral teammate per unit (or combined group), drive each PR through pr-review-merge, smart-merge in waves, recover from crashes, and run a retrospective. Source-agnostic — the caller supplies a work-source adapter. A library skill invoked BY the /tm and /issues commands, not run directly by a user (it needs a caller-supplied adapter). TRIGGER when a command needs autonomous multi-unit team orchestration to completion — a tag, issue queue, backlog, or set of tickets run to done with Agent Teams. For a single PR use pr-review-merge instead; not for one-off single-task work.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Marathon Engine skill

What this skill tells your AI

The instructions your AI receives, as published by bjcoombs/ai-native-toolkit in skills/marathon/SKILL.md and read by ahel’s review.

Source-agnostic team orchestration. The caller supplies a work-source adapter; this skill owns DAG analysis, hot-file combining, team lifecycle, waves, crash recovery, and the retrospective. It uses the pr-review-merge skill for every PR.

Work-Source Adapter Contract

The calling command MUST fill these four operations before invoking this skill:

OperationWhat it returns / does
enumerateA list of work units, each {id, title, requirements, dependencies[], complexity}
mark in-progressMarks one unit started in the source of truth
close on mergeHow a merged PR closes the unit (e.g. a label, a status set, or PR Closes #N)
branch / worktreeThe branch name and worktree/<...> path convention for a unit

The caller also passes Marathon Configuration values (base branch, required approvals, bot-reviewer rules, CI patterns) read from the project's CLAUDE.md.

Phase 0: Capability Detection

# Agent Teams
echo $CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS

Set $TEAMS_AVAILABLE (true if result is "1").

Project-Specific Configuration

Read the repo's CLAUDE.md for a ## Marathon Configuration section. This provides project-specific overrides for marathon behavior. Extract these values (with defaults if section is missing):

SettingDefaultDescription
$BASE_BRANCHmainBranch to create worktrees from and merge PRs into
$REQUIRED_APPROVALS1Minimum approvals for auto-merge
$MARKDOWN_APPROVALS1Approvals for markdown-only PRs
$RETRO_LOG(none)Path to retrospective log file
Bot reviewer rules(none)Per-bot thread resolution patterns
CI patterns(none)Known flaky checks, pre-existing failures

If no Marathon Configuration section exists, advise the user to set one up — this is non-blocking; emit the notice and proceed with defaults (do not wait for an answer):

No Marathon Configuration found in this project's CLAUDE.md.

For best results, add a ## Marathon Configuration section to your project's CLAUDE.md.
Run `/tm-marathon-config-example` to see the configuration template (it covers both /tm and /issues), then copy and customize it for your project.

Proceeding with defaults: base branch=main, 1 approval, no bot reviewer rules.

Defaults apply for non-marathon use (single task mode, planning mode) without prompting. The template below uses $BASE_BRANCH where previous versions hardcoded develop.

Execution Modes

The steps below are written for team mode — the lead chairs an Agent Team, spawns one ephemeral teammate per unit, and coordinates via SendMessage and shutdown_request. Phase 0's $TEAMS_AVAILABLE selects the mode:

ModeWhenHow the body maps
Team$TEAMS_AVAILABLE trueRun the body as written: spawn one background teammate (Agent with run_in_background) per unit/combined group into the session's single implicit team, message-driven monitoring.
Phased sub-agent$TEAMS_AVAILABLE falseNo persistent team and no SendMessage. The lead runs each wave as a batch of parallel subagents, reads their returned transcripts in place of messages, and drives the same loop. See Subagent Fallback.

Everything else — the DAG analysis, hot-file combining, tracking file, smart-merge, crash recovery, and retrospective — is identical across modes; only the teammate-coordination mechanism differs. Where a step is team-only (the SendMessage events, early-shutdown, and idle-ping handling), the phased fallback simply has no equivalent: subagents return rather than message.

One team per session. This build allows exactly one implicit team per Claude Code session, and the main session is its permanent lead. A team-mode marathon claims that single team - so do not start another team-mode skill (a second marathon, a /huddle) in the same session: its teammates would join this marathon's team and share one task list and mailbox. To run two team-mode workstreams at once (two PRDs in flight, or a huddle defining the next PRD while this marathon implements the current one), use a separate session - a second terminal with its own worktree. Each session gets its own isolated team (session-<id>-named), lead, task list, and mailbox. (Cross-session, the only shared state to watch is Task Master's global tag selection: pass --tag on every call or use the MCP tools so two concurrent marathons don't stomp each other's active tag.)

Entry Gate (non-removable)

Before decomposing the run (Step 1), the acceptance contract must be frozen. Invoke the start gate first:

python scripts/contract/start_gate.py <run-id>   # run-id = the Task Master tag (/tm) or issue-queue slug (/issues)

start_gate.py fails closed (non-zero) unless the run has freeze evidence (a contract frozen before decomposition per FLOOR.md clause ii) or an operator_signoff-recorded signed skip. A signed skip is loud, human-authorized, and permanently caps the run at UNVERIFIED — it can never certify PASS. Do not run Step 1 or spawn any teammate until this gate exits zero. Part of the constitutional floor (FLOOR.md); the retro may propose changes to this step but never self-apply them.

Step 1: DAG + Hot-File Analysis

CRITICAL: Global Source-of-Truth Write Rule Never run source-of-truth write commands as parallel background jobs — concurrent writes race. Each such command may internally switch global state, and concurrent invocations can silently land work on wrong targets. Always run source-of-truth write commands sequentially inline — 10 concurrent background add-task calls once landed tasks on the wrong tags.

Enumerate work units via the adapter's enumerate operation.

Analyze dependency tree for maximum concurrency:

  1. Map the dependency tree — which tasks block which?

  2. Identify the critical path (longest sequential chain)

  3. Challenge unnecessary dependencies — different files/modules may not need sequencing

  4. Look for tasks chained sequentially that could run in parallel

  5. Identify hot files — files touched by multiple tasks. Record as $HOT_FILES.

  6. Primary mitigation: combine tasks that share hot files into one teammate. Combined units share one branch and worktree, so there is no inter-unit merge and the conflict class is eliminated entirely. Combine when:

    • Tasks share hot files (strongest signal — prefer combining over dependency management for small, coupled tasks)
    • Tightly coupled output (e.g., "add resources" + "add docs for resources")
    • Content-only tasks touching non-overlapping directories (e.g., adding 3 independent pattern dirs)
    • One is docs/config for the other, or one is meaningless without the other
    • Small tasks (complexity 1-2) that share a theme — PR-per-task overhead exceeds the work itself

    Combining has a ceiling — it must not swallow the parallelism it exists to protect. Combining buys zero conflicts by trading away concurrency, so it only pays while the combined unit stays small and genuinely coupled. A hot file is a combine candidate, not a combine mandate. Do NOT combine when it would:

    • push the combined unit past ~complexity 8 — one teammate then serially implements a large PR, which is slower than parallel teammates each resolving an additive conflict;
    • collapse the wave — if combining would leave fewer than 2 parallel units where the DAG allowed more, you have destroyed the wave, not optimized it; use dependencies instead;
    • fold in a task that depends on the others, or a complexity-8+ task — a dependency is a sequencing signal, not a combine signal. Sequence it across PRs; don't serialize it inside one.

    Some hot files are touched by every PR and want sequential merge, never combining: a version counter (.claude-plugin/plugin.json .version), a changelog, a lockfile. Assign each teammate its target value explicitly at spawn and merge in order (highest version wins) — combining all PRs to dodge a one-line version conflict is the trap, not the fix.

    Version values assigned at spawn are final — never re-message a new version to an in-flight teammate (it races with PR_CREATED/REVIEW_CLEAR and produces crossed-message churn). If readiness order ends up differing from the planned merge order, that is handled at merge time, not by re-messaging — see Smart Merge.

    One caveat overrides "identical bumps merge cleanly": if the repo auto-publishes an immutable per-version artifact on a version change (e.g. a standalone-skills-v<version> build that fires on the plugin.json bump), identical bumps across parallel PRs silently break it — the first merge fires the build from an incomplete tree and permanently consumes that version's tag, and the later identical bumps don't change the version so the build never re-fires. There, do not use identical bumps: have the last-merging PR bump one step higher (or bump once at the very end, after all merges) so the complete tree republishes.

  7. Fallback: dependencies — when combining isn't feasible or would breach the ceiling above (any task complexity 8+, a real dependency between the tasks, fundamentally different concerns despite a shared file, or 5+ tasks on one file):

    • Add explicit dependencies — merge the simpler/faster task first, then the other depends on it.
    • Teammate prompts: include conflict resolution patterns
    • If 5+ tasks touch one file, decide by the kind of contention. This additive-vs-serialize split is a 5+-on-one-file rule and does not override Step 1.6 below that threshold: a small coupled pair (2-4 tasks) sharing one additive hot file under the complexity ceiling still combines — combining is the primary mechanic, it yields 0 conflicts, and it costs only one parallel slot. At 5+ the arithmetic flips: purely additive edits (schema appends, barrel exports, route registration — the "accept both sides" cases) are cheap to merge, so keep the units parallel in one wave and merge them in order rather than collapsing four-plus parallel slots into one teammate; do NOT serialize them. Reserve the dedicated consolidation task (one teammate owns that file; the others depend on it) for same-line or structural contention where parallel edits would genuinely conflict — and even then, prefer it over folding all 5+ into one mega-PR.
  8. Report the optimized plan:

    ## Dependency Analysis: <tag>
    
    Critical path: <task-ids> (<N> points sequential)
    Parallel capacity: <M> tasks in first wave
    
    Combined tasks:
    - Tasks <X>+<Y>: <reason> (single teammate, single PR)
    
    Hot files:
    - <file-path>: tasks <ids> (pattern: <e.g., "accept both sides">)
    
    Optimizations:
    - Removed dependency <X> → <Y>: different modules
    
  9. Apply dependency changes via the work source's dependency-update mechanism.

Step 2: Team + Tracking

This build uses a single implicit team: the team forms as you spawn named background teammates (next) - each Agent(name: "task-<id>", run_in_background: true) joins the session's implicit team and is addressable via SendMessage(to: "task-<id>"). Proceed straight to tracking.

PR tracking — persisted in worktree dir (survives crashes and team cleanup):

TRACK_FILE=~/dev/github.com/<org>/<repo>/worktree/<tag>/pr-tracking.json
mkdir -p "$(dirname "$TRACK_FILE")"

if [ -f "$TRACK_FILE" ]; then
  echo "EXISTING_TRACKING: reconciling against source of truth and GitHub"
else
  echo '{"meta":{"tag":"<tag>","wave":1,"repo":"<owner>/<repo>","flaky_checks":[]},"tasks":{}}' | jq . > "$TRACK_FILE"
fi

Reconciliation (run at start if tracking file exists):

  1. Read unit status via the adapter's enumerate operation.
  2. Cross-reference each tracking entry:
    • Source done but tracking working → merged externally. Remove from tracking.
    • Source in-progress but PR merged on GitHub → mark unit done, remove from tracking.
    • Source pending but tracking has PR → stale entry. Remove, check cleanup needed.
    • Source in-progress and PR open → valid. Keep, update last_ci.
  3. Source in-progress but NOT in tracking → check GitHub for open PR. If found, add to tracking. If not, reset unit to pending.
  4. Write reconciled file.

Tracking structure:

{
  "meta": {"tag": "<tag>", "wave": 1, "repo": "<owner>/<repo>", "flaky_checks": ["E2E"]},
  "tasks": {
    "task-<id>": {
      "pr": 123,
      "status": "working|review_clear|merged",
      "model": "sonnet|opus",
      "wave": 1,
      "last_ci": "passing|failing|unstable|pending"
    }
  }
}

CRUD operations:

# Add/update task
jq --arg task "task-<id>" --argjson pr <number> --arg model "sonnet" --argjson wave 1 \
  '.tasks[$task] = {"pr": $pr, "status": "working", "model": $model, "wave": $wave, "last_ci": "pending"}' \
  "$TRACK_FILE" > "$TRACK_FILE.tmp" && mv "$TRACK_FILE.tmp" "$TRACK_FILE"

# Update CI status
jq --arg task "task-<id>" --arg ci "passing" \
  '.tasks[$task].last_ci = $ci' "$TRACK_FILE" > "$TRACK_FILE.tmp" && mv "$TRACK_FILE.tmp" "$TRACK_FILE"

# Read all
jq -r '.tasks | to_entries[] | "\(.key) → PR #\(.value.pr) (\(.value.status), CI: \(.value.last_ci), wave \(.value.wave))"' "$TRACK_FILE"

# Remove after merge+cleanup
jq --arg task "task-<id>" '.tasks |= del(.[$task])' "$TRACK_FILE" > "$TRACK_FILE.tmp" \
  && mv "$TRACK_FILE.tmp" "$TRACK_FILE"

Identify known-flaky checks at marathon start:

gh api repos/<owner>/<repo>/branches/$BASE_BRANCH/protection \
  --jq '.required_status_checks.contexts // []'

Store non-required check names in meta.flaky_checks.

Step 3: Spawn Teammates

Pre-spawn: Read the retro log's open template changes: Before writing any spawn prompt, read the retro log (Marathon Configuration $RETRO_LOG) Template Changes table — skip this step entirely if $RETRO_LOG is unset (defaults supply none), the same escape the completion-time read uses. Apply every row still marked Pending to this run's spawn prompts and lead behaviour now - that is what the table is for. Reading these only at retro time is too late — the same friction then recurs the whole run, which is exactly how past fixes sat unapplied across entire marathons before shipping.

Pre-spawn: Check for already-completed work: Before spawning Wave 1, check recent merged PRs for task keywords to avoid spawning work that's already done:

gh pr list --state merged --limit 20 --json title,mergedAt,headRefName \
  | jq '.[] | select(.headRefName | test("<tag>")) | {title, mergedAt, headRefName}'

Cross-reference with pending tasks. If a task's work was already merged (e.g., from a prior crashed marathon), mark it done and skip spawning.

Model selection:

  • Opus (default for reliability): Multi-file PRs, review-heavy tasks, tasks touching shared files (barrel exports, routing, config), complexity 5+
  • Sonnet (cost-efficient for simple work): Single-file changes, isolated modules, complexity 1-4 with no shared-file risk, docs/config-only tasks

Sonnet is cost-effective but has a recurring false REVIEW_CLEAR problem — reports review-clear without verifying all criteria. Opus has not shown this. When in doubt, use opus — the cost delta is cheaper than intervention time.

Haiku cannot reliably handle review loops — never use for teammates.

Combined-group identity: combining is the primary mechanic, so a teammate often covers several units. Give a combined group one identity derived from its member ids: name task-<id>-<id> (e.g. task-1-2) — the Agent name regex allows only letters, digits, _, and -, so a + in the name is rejected at spawn; branch <tag>--<id>+<id>--<slug> and worktree worktree/<tag>/<id>+<id>--<slug> may keep + (git accepts it in refs and paths). Its complexity is the sum of its members'. The Scope guard and the activity-check find path below operate on this combined branch/worktree — substitute the combined id wherever the singular <task-id> appears. Mark each member unit in-progress and close each on merge.

Teammate prompt template:

Agent(
  subagent_type: "general-purpose",
  run_in_background: true,
  name: "task-<task-id>",   # combined group: task-<id>-<id> — no '+' in agent names (see Combined-group identity above)
  model: "<chosen-model>",
  prompt: """
# Implement <tag>.<task-id>: <task-title>

## Setup
Set the unit in-progress via the adapter; create the worktree using the adapter's branch/worktree convention.

## Requirements
<work unit requirements and subtasks — fetched via the adapter's enumerate operation for this unit id>

## Architectural Direction
<Include architectural guidance, design decisions, or constraints from the lead HERE in the
first message. Teammates may lose context between messages.>

## Project Guidelines
<Include relevant sections from the repo's CLAUDE.md - testing patterns, coding standards.>

## Shell Rules
Always pipe `gh` output to `jq` (never use `gh --jq` with complex filters). Use positive jq filters (`== "FAILURE"` not `!= "SUCCESS"`) - zsh mangles `!=`.

## Known Conflict Patterns
<If $HOT_FILES identified, include here. Otherwise omit.>
Additive files (imports, barrel exports, routes): accept both sides. Same-line conflicts or complex JSX blocks: escalate immediately with file, line range, and both versions.
**Do NOT resolve the `.claude-plugin/plugin.json` version-line conflict yourself** — the version hot-file is lead-owned end to end. If your PR goes DIRTY *only* on the version line because the base advanced, leave it; the lead resolves it on sight. Touching it races the lead and can strand a half-resolved conflict.

## Workflow
1. **Implement using TDD**. Push commits incrementally for backup. If your change touched a module that has a documented co-change partner — a sibling doc or a seam-map README named in your Project Guidelines — update it in the same PR. A code change without its paired doc is a lying map and a predictable review thread; updating it now is cheaper than a follow-up cycle after REVIEW_CLEAR.
2. **Before creating PR**, check for existing: `gh pr list --head "<branch-name>" --state all --json number,state,mergedAt`
   - Merged → message lead, wait idle. Open → use it. None → create one.
3. **Get required checks green, then stand down.** Use the pr-review-merge skill's criteria and thread rules to fix any failing *required* checks and resolve any bot threads already posted, pushing fixes. Then report and go idle. **Do NOT run a `gh pr checks --watch` loop or any background CI watcher** - in marathon mode the lead owns CI watching, the slow `claude-review`/AI-review wait, and the merge. A teammate that watches a slow advisory check sits idle for minutes and floods the lead with idle notifications; that is the lead's job here, not yours. While your PR is not yet at required-green the lead may message you to fix a failing check or thread - respond and push. Once you send REVIEW_CLEAR you are done: the lead does not re-wake you, it spawns a fresh teammate if more work surfaces (one task, one teammate).

## Communication
Only message the lead for **meaningful events**. Send the matching JSON payload from [Teammate Event Payloads](#teammate-event-payloads) as the message `content` (the `event` field self-identifies it; the `summary` stays human-readable):
- PR created: `SendMessage(type: "message", recipient: "lead", content: JSON.stringify({event: "PR_CREATED", task_id: "<tag>.<task-id>", pr_number: <number>, branch: "<branch>"}), summary: "PR created <task-id>")`
- Review clear: `SendMessage(type: "message", recipient: "lead", content: JSON.stringify({event: "REVIEW_CLEAR", task_id: "<tag>.<task-id>", pr_number: <number>, required_checks_green: true, threads_resolved: true}), summary: "Review clear <task-id> — standing down (lead owns claude-review wait + merge)")`
- Blocked: `SendMessage(type: "message", recipient: "lead", content: JSON.stringify({event: "BLOCKED", task_id: "<tag>.<task-id>", pr_number: <number>, blocking_reason: "<reason>", blocking_category: "merge_conflict|ci_failure|dependency|external"}), summary: "Blocked <task-id>")`
- Too complex: `SendMessage(type: "message", recipient: "lead", content: JSON.stringify({event: "TOO_COMPLEX", task_id: "<tag>.<task-id>", complexity_reason: "<reason>", suggested_decomposition: ["<subtask>", "<subtask>"]}), summary: "Too complex <task-id>")`
- Clarification needed: `SendMessage(type: "message", recipient: "lead", content: JSON.stringify({event: "CLARIFICATION_NEEDED", task_id: "<tag>.<task-id>", question: "<question>", context: "<context>"}), summary: "Clarification <task-id>")`

`REVIEW_CLEAR` reports shape, not a verdict the lead trusts blindly — set `required_checks_green`/`threads_resolved` only when genuinely true, but expect the lead to re-verify both via the GitHub API before merging.

## Teammate Event Payloads
Each event is a JSON object whose `event` field names the type. Required fields per type (omit unknown values rather than inventing them):
```json
// PR_CREATED — a PR now exists for this task
{ "event": "PR_CREATED", "task_id": "<tag>.<task-id>", "pr_number": 123, "branch": "<branch-name>" }

// REVIEW_CLEAR — required checks green and posted threads resolved; standing down
{ "event": "REVIEW_CLEAR", "task_id": "<tag>.<task-id>", "pr_number": 123, "required_checks_green": true, "threads_resolved": true }

// BLOCKED — cannot progress without intervention
{ "event": "BLOCKED", "task_id": "<tag>.<task-id>", "pr_number": 123, "blocking_reason": "<what is blocking>", "blocking_category": "merge_conflict|ci_failure|dependency|external" }

// TOO_COMPLEX — task is too large to land as one PR
{ "event": "TOO_COMPLEX", "task_id": "<tag>.<task-id>", "complexity_reason": "<why>", "suggested_decomposition": ["<subtask>", "<subtask>"] }

// CLARIFICATION_NEEDED — requirements ambiguous, need a decision
{ "event": "CLARIFICATION_NEEDED", "task_id": "<tag>.<task-id>", "question": "<the question>", "context": "<relevant context>" }

pr_number is omitted on TOO_COMPLEX/CLARIFICATION_NEEDED (no PR yet) and on BLOCKED if the block predates the PR.

Scope

  • Only create PRs on YOUR branch (<tag>--<task-id>--<slug>, or the combined-group branch <tag>--<id>+<id>--<slug> if you cover several units). Never create PRs on other branches or for work outside your assigned task(s).
  • If you discover related work that needs doing, mention it in your PR description — don't create additional PRs.

Lifecycle

  1. Implement → push incrementally → create PR → message lead PR_CREATED
  2. Fix any failing required checks and any already-posted bot threads; push. Do NOT watch CI - the lead owns that.
  3. Message lead REVIEW_CLEAR (required checks green, threads resolved) and stand down. Do not sit through the slow claude-review/AI-review window - that wait is the lead's to hold.
  4. The lead owns the claude-review wait + merge, cleans up, and shuts you down at green. After REVIEW_CLEAR you are not re-woken - if more work surfaces the lead spawns a fresh teammate (one task, one teammate). Approve the lead's shutdown_request promptly when it arrives, and after REVIEW_CLEAR do NOT idle-ping or re-send merge-readiness nudges — the lead owns the merge; re-nudging an already-cleared PR just churns the lead while it holds the claude-review wait. """ )

Spawn all independent teammates in a single message (parallel Task calls).

## Step 4: Lead Monitoring

Report team status after spawning:

Marathon Started:

TaskTeammateModelStatus
- task-sonnetSpawned

#### Teammate Messages (reactive)

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
30
Forks
5
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
marathon
Source
github.com/bjcoombs/ai-native-toolkit