Marathon Engine
SkillFiles & storageRun 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.
No other account needed.
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:
| Operation | What it returns / does |
|---|---|
| enumerate | A list of work units, each {id, title, requirements, dependencies[], complexity} |
| mark in-progress | Marks one unit started in the source of truth |
| close on merge | How a merged PR closes the unit (e.g. a label, a status set, or PR Closes #N) |
| branch / worktree | The 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):
| Setting | Default | Description |
|---|---|---|
$BASE_BRANCH | main | Branch to create worktrees from and merge PRs into |
$REQUIRED_APPROVALS | 1 | Minimum approvals for auto-merge |
$MARKDOWN_APPROVALS | 1 | Approvals 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:
| Mode | When | How the body maps |
|---|---|---|
| Team | $TEAMS_AVAILABLE true | Run 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 false | No 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:
-
Map the dependency tree — which tasks block which?
-
Identify the critical path (longest sequential chain)
-
Challenge unnecessary dependencies — different files/modules may not need sequencing
-
Look for tasks chained sequentially that could run in parallel
-
Identify hot files — files touched by multiple tasks. Record as
$HOT_FILES. -
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 theplugin.jsonbump), 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. -
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.
-
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 -
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):
- Read unit status via the adapter's enumerate operation.
- Cross-reference each tracking entry:
- Source
donebut trackingworking→ merged externally. Remove from tracking. - Source
in-progressbut PR merged on GitHub → mark unit done, remove from tracking. - Source
pendingbut tracking has PR → stale entry. Remove, check cleanup needed. - Source
in-progressand PR open → valid. Keep, updatelast_ci.
- Source
- Source
in-progressbut NOT in tracking → check GitHub for open PR. If found, add to tracking. If not, reset unit topending. - 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
- Implement → push incrementally → create PR → message lead PR_CREATED
- Fix any failing required checks and any already-posted bot threads; push. Do NOT watch CI - the lead owns that.
- 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. - 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_requestpromptly 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:
| Task | Teammate | Model | Status |
|---|---|---|---|
| - | task- | sonnet | Spawned |
#### 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