This guide is for maintainers of src/epic/, both automated (bots) and human. Read it before changing Epic code, an Epic seam in a shared file, or the epic config block. The user-facing contract lives in docs/modes.md → Epic Mode, and the keys are documented in docs/configuration.md → epic. AGENTS.md and docs/engineering-invariants.md still apply in full. Nothing here is an exception to them.
Rule zero: Epic off means upstream behaviour. A project whose config does not set epic.mode.enabled: true (or the legacy turbo.epic.mode.enabled: true) must behave exactly as it would without Epic. When an epic is not open, the shared-file hooks must cost no more than one existsSync (see Seams). tests/integration/epic-lifecycle-contract-final-off.test.ts pins this end to end.
| Epic term | Means | Where it lives |
|---|---|---|
| epic | one plan, from /swarm epic start to /swarm epic close |
the lifecycle row (lifecycle.ts) |
| phase | an iteration of the plan; phases run strictly in order | plan.phases[]; EpicPhaseRecord |
| task | a story: one coder unit with a declared scope | plan.phases[].tasks[] |
| wave | a parallel delivery: ready tasks whose declared scopes do not conflict, dispatched together | EpicWaveRecord (scopes frozen at issue) |
| close | the retrospective: the report and scorecard, the learning merge, and the branch landing | close.ts, scorecard.ts, learning-store.ts |
Epic is its own mode. It is not a Turbo overlay: it enables neither Turbo nor Lean Turbo, and it never waives per-task QA. It reuses Lean Turbo's conflict predicates by import (src/turbo/lean/conflicts.ts, partition-common.ts, planner.ts) and never modifies src/turbo/lean/.
/swarm epic start ──► epic_next_wave ──► Task × wave (one message) ──► per-task QA ──┐
(sizing, refusals, ▲ (closes the previous wave, issues the next) │
epic branch, row, └───────────────────────────────────────────────────────────┘
sentinel, base ref) │ phase done
▼
phase-ready-for-review ──► epic_phase_review ──► phase_complete ──► next phase …
│ all phases
▼
epic-complete ──► /swarm epic close [--land squash|merge|none]
| Step | Entry point | Implementation |
|---|---|---|
| Shape (before start) | save_plan result epic_shaping; /swarm coupling --suggest |
plan-shaping-seam.ts → shaping.ts / shaping-suggestions.ts / shaping-sizing.ts |
| Start | /swarm epic start [--force] (src/commands/epic.ts) |
start.ts. Checks in order, first one wins: epic-disabled-by-config; host-unsupported (only on genuinely client-less hosts — see Hosts); no-plan / plan-ledger-unreadable; epic-state-unreadable; an epic already open for the same plan returns already-open (idempotent success, not a refusal), and one open for another plan refuses epic-open-for-other-plan; turbo-active; (git) dirty-baseline, detached-head, epic-branch-exists; in-flight-coders; not-epic-sized (--force overrides, recorded); branch-create-failed (git checkout -b failed after the row was created, so the row and sentinel are rolled back). |
| Next wave | tool epic_next_wave (src/tools/epic-next-wave.ts) |
next-wave.ts → wave-close.ts (close the active wave) → wave-select.ts → components.ts (pick the next one) → next-wave-format.ts (result and text) |
| Dispatch | the architect's Task calls, admitted by the delegation gate |
gate-policy.ts (resolveEpicDispatchPolicy, computeEpicWaveVerdict) |
| Landing | the worktree merge-back of each coder | task-landing.ts (merge commit swarm(task <id>): … + Swarm-Plan: trailer from plan-key.ts) |
| Residue | non-coder writers (test_engineer, docs) in the main tree | residue-commit.ts |
| Phase review | tool epic_phase_review (src/tools/epic-phase-review.ts), then phase_complete |
phase-readiness.ts (gate epic_phase_readiness) |
| Close | /swarm epic close; /swarm close finalizes an open epic as abandoned-by-swarm-close |
close.ts, epic-branch.ts (landing), markers.ts (refs), learning-store.ts (prior merge) |
| Inspect | /swarm epic status [--repair-refs], report, learning, prior, clear-merge-failure |
src/commands/epic.ts, report.ts, src/commands/epic-learning.ts, merge-epoch.ts |
epic_next_wave statuses: dispatch | declare-scopes | in-progress | blocked | phase-ready-for-review | epic-complete | refused. Refusal reasons are EpicNextWaveRefusal, and blocked reasons are EpicNextWaveBlockReason, both in next-wave-format.ts. The architect banner (EPIC_MODE_BANNER, src/config/constants.ts) says only "call epic_next_wave and do what its status says". The procedure travels in the tool's response text.
| File | Responsibility | Key exports |
|---|---|---|
config.ts |
The only place that knows where Epic settings live: top-level epic, plus the legacy turbo.epic migration. Has no runtime imports. |
resolveEpicConfig, isEpicModeConfigEnabled, isEpicCochangeConfigEnabled, migrateLegacyEpicConfig, hasLegacyEpicConfig, legacyEpicIssuePath, annotateLegacyEpicKeys, LEGACY_EPIC_CONFIG_WARNING |
config-gate.ts |
Directory-keyed gate (loads config, fails closed) and the disabled message; re-exports the pure gates | isEpicModeConfigEnabledForDirectory, EPIC_MODE_CONFIG_DISABLED_MESSAGE |
lifecycle.ts |
Authoritative lifecycle row (coordination namespace turbo.epic.lifecycle), sentinel projection, the probe, and CAS writes |
isEpicOpenForProject, epicSentinelExists, getOpenEpic, inspectEpic, createEpicRecord, updateEpicRecord, markEpicClosing, deleteEpicState, repairEpicSentinel, EpicRecordV1, EpicWaveRecord, EpicTaskOutcome |
start.ts |
/swarm epic start orchestration and refusals |
startEpic, findTurboActivity, findDirtyBaseline, findInFlightCoderWork, computeEpicSizing |
sizing.ts |
Pure sizing verdict (T, C, S, S_eff thresholds) | evaluateEpicSizing, resolveEpicSizingThresholds, computeEffectiveSpeedup |
shaping-sizing.ts |
THE plan sizing: a dry run of the component planner, under a work budget | sizeEpicPlan, epicSizingContextFor, epicWaveWidth, estimateEpicScopes |
shaping.ts |
Pure plan shaping: ranks suggestions and what-ifs | shapeEpicPlan, formatEpicShapingLines |
shaping-suggestions.ts |
Suggestion builders, each one a concrete save_plan patch |
EpicShapingSuggestion, narrowScopeSuggestion, fileSuggestion, splitSuggestion, mergeSuggestion |
plan-shaping-seam.ts |
The save_plan → epic_shaping seam, plus the per-plan iteration counter |
computeSavePlanEpicShaping, bumpShapingIteration |
next-wave.ts |
epic_next_wave: refusals, closing a wave, choosing the next, issuing it |
runEpicNextWave, completedBeforeEpic |
next-wave-format.ts |
Pure result shapes and architect-facing text | EpicNextWaveResult, EpicNextWaveBlockReason, EpicNextWaveRefusal, buildDispatchInstructions |
wave-select.ts |
Chooses the next wave of the current phase (no writes) | selectNextEpicWave, cochangePairsWithin |
components.ts |
Pure wave planner: conflict graph → components → modes → greedy wave; the sizing dry run | planNextEpicWave, buildEpicConflictGraph, partitionEpicComponents, dryRunEpicPhase |
planning-signals.ts |
Planner inputs read once per planning call (learned hot files and co-writes, co-change, density threshold) | loadEpicPlanningSignals |
wave-close.ts |
Closes a wave: outcomes, divergence, attribution, learning update | computeWaveClose, applyWaveClose, recordEpicWaveLearning |
gate-policy.ts |
The delegation gate's authority while an epic is open; THE wave verdict | resolveEpicDispatchPolicy, computeEpicWaveVerdict |
task-landing.ts |
Coder landing as a commit on the epic branch; isolation-degraded refusal | epicCommitLandingFor, resolveEpicTaskContext, epicIsolationDegradedMessage |
residue-commit.ts |
Residue commits for non-coder main-tree writes; dirty-baseline classification | commitEpicResidueAfterDelegation, commitTaskResidue, classifyDirtyBaseline |
epic-branch.ts |
commit_policy, the swarm/epic/<epicKey> branch, the landing preflight, landing at close |
resolveEpicCommitPolicy, checkEpicBranch, preflightEpicLanding, epicBranchName |
markers.ts |
Git refs refs/swarm/epics/<epicKey>/{base,waves/<seq>,tasks/<id>} (sync, repair, delete) |
writeEpicRef, syncEpicRefs, deleteEpicRefs, planEpicTaskRefRepair |
plan-key.ts |
Plan identity for commit markers (Swarm-Plan: trailer) |
computePlanKey, formatEpicTaskCommitMessage, resolvePlanMarkerScope, parseTaskMarkerLog |
git-once.ts |
Single-attempt git for state-changing commands | gitExecOnce, gitProbeExitCode |
merge-epoch.ts |
Plan-epoch filter over the shared worktree merge-status registry | relevantMergeFailure, epicMergeFailureSkipsCheckpoint, clearMergeFailureCommand |
phase-readiness.ts |
Phase reviewer and phase critic dispatch, evidence, and the phase_complete gate |
verifyEpicPhaseReadiness, computeEpicPhaseBinding, EPIC_PHASE_REVIEW_TOOL |
close.ts |
/swarm epic close and /swarm close finalization; close reports |
closeEpic, finalizeOpenEpicOnSwarmClose, pruneEpicPriorReports |
scorecard.ts |
Pure scorecard over the epic record | computeEpicScorecard, formatEpicScorecardLines |
report.ts |
/swarm epic report: the live or past scorecard |
selectEpicReport, liveEpicScorecard, listEpicPriorReports |
learning.ts |
Pure learning model (scope expansion, decaying hot set) | resolveEpicLearningSettings, EPIC_LEARNING_INCIDENT_WEIGHTS, DEFAULT_EPIC_LEARNING_SETTINGS |
learning-store.ts |
Persistence of the project prior and the epic posterior; the v1 import | loadEpicLearningView, initEpicPosterior, applyClosedWavesToPosterior, readEpicPrior |
legacy-migration.ts |
One-time retirement of Epic v1 session state | retireLegacyEpicSessionState |
declared-scopes.ts |
Declared scopes from live v2 declare_scope bindings only |
resolveEpicDeclaredScopes |
cochange-conflict.ts |
Path + co-change pair predicate (conservative: it can only add a conflict) | epicPairConflict |
cochange-source.ts |
Co-change pairs from git history, cached per HEAD (FIFO, 10 directories) | getCoChangePairs, getCoChangeData, peekCoChangeData |
coupling-report.ts |
/swarm coupling metric p and the per-module contention |
computeCouplingReport, formatCouplingReportMarkdown |
Epic files outside src/epic/: src/commands/epic.ts and src/commands/epic-learning.ts (the slash command), src/commands/coupling.ts, src/tools/epic-next-wave.ts, and src/tools/epic-phase-review.ts. Tool wiring is in src/tools/tool-metadata.ts (agents: []), src/tools/manifest.ts, src/tools/index.ts, src/tools/plugin-registration.ts (epic_phase_review needs the review dispatcher), and the opt-in EPIC_AGENT_TOOL_MAP (src/config/constants.ts, architect only). The planner regression harness lives in scripts/lib/epic-sim.ts and scripts/epic-bench.ts (never bundled).
- Epic off ⇒ upstream behaviour. Every seam in a shared file either reads config that is already loaded (no I/O) or probes sentinel-first (
epicSentinelExists/isEpicOpenForProject), so a project with no open epic costs at most oneexistsSyncand adds noawait. Non-Epic outputs stay byte-identical. Proof:final-offcontract,tests/unit/epic/disabled-passthrough.test.ts, and the per-seam tests below. - Every multi-task wave that is issued is
all_disjointundercomputeEpicWaveVerdict, the exact call the delegation gate repeats at dispatch over the frozen scopes.next-wave.ts:assertWaveDisjointnarrows a non-disjoint selection to one task and raises a critical warning, because that would be a planner bug. Proof:tests/unit/epic/next-wave-verdict.test.ts. - Planner predicate ⊇ gate verdict relation. The component planner's conflict edges (path ∪ co-change on declared scopes, plus learned scope expansion, which only ADDS path edges) are a superset of the verdict's, so the planner never picks a wave the gate would serialize. Co-change keeps using declared scopes: its "exclusively owns one side" rule is not monotone. Proof:
tests/unit/epic/components-property.test.ts(seeded property test), and the C5/C6 contract tests. - State changes are token-guarded CAS. Lifecycle writes run inside the coordination store's
BEGIN IMMEDIATEtransaction. A create is CAS fromexpectedRevision: null, an update is CAS on the revision with an optionalexpectedToken, and the sentinel is removed compare-and-delete on epicKey + token. A late close can never delete a newer epic. Proof:tests/unit/epic/lifecycle-writes.test.ts,lifecycle-probe.test.ts. - Git writes are single-attempt. Epic's own state-changing git commands go through
git-once.ts(no transient retry), and the real repository state is re-read after a failure. Coder landings use the shared worktree merge path with Epic's commit message (src/worktree/merge.ts,landingCommitMessage). - Refs are create-only, then CAS.
writeEpicRefcreates a missing ref withupdate-ref <ref> <sha> "", leaves an equal one unchanged, and otherwise updates by compare-and-swap from the recorded old value. It never force-writes. Deletes are guarded by the expected old value. Refs are never pushed. - Learning starts neutral and stays bounded. An empty or clean history has no hot files and no expansion. A file is hot only when it has excess incidents (
α' ≥ 1andr − m0 > hot_excess). State is capped atMAX_EPIC_LEARNING_FILESandMAX_EPIC_LEARNING_EDGES, decays× decay_per_epicper learning epic and by whole half-lives, and drops entries below 0.05. Learning is planner analysis only, never write authorization. Proof:tests/unit/epic/learning*.test.ts. - The
save_planseam runs after the plan lock, fails open, and is bounded. It runs only when the already-loaded config has Epic on, and it does nothing while an epic is open. It uses a warm co-change cache only (no git scan) and a work budget (skipped-budgetbeyond it). A throw leaves the save successful, withoutepic_shaping. Proof:tests/unit/epic/plan-shaping-seam.test.ts,tests/unit/tools/save-plan-epic-shaping*.test.ts. - The plan ledger stays authoritative (AGENTS.md §5). Epic never writes
plan.json. Fix tasks are added throughsave_plan, and phases close throughphase_complete. - One open epic per project, bound to one plan identity. A renamed or replaced plan orphans the epic: the probe answers "no open epic", and
/swarm epic close --abandonrepairs it.
Each seam is Epic-gated, and none of them changes non-Epic output. "One existsSync" means the sentinel .swarm/epic/epic.json check.
| File : function | What it does for Epic | Why it is a no-op with Epic off | Proving test(s) |
|---|---|---|---|
src/config/loader.ts:buildConfigWithMeta (step 0, before the user + project merge; recovery labels) |
Moves each file's legacy turbo.epic to that file's top-level epic (per-key merge, the file's top-level wins), warns once, labels recovered keys (from turbo.epic) |
hasLegacyEpicConfig is a pure own-key check; without it each raw file is used unchanged (same reference) and annotateLegacyEpicKeys returns its input |
tests/unit/config/epic-config-path.test.ts |
src/services/config-doctor.ts:collectRawRetiredEpicKeyFindings / collectRawLegacyEpicConfigFindings |
retired-config-key and legacy-epic-config-path findings (report-only) |
Returns before any file read when resolveEpicConfig(config) is undefined |
epic-retired-config-keys.test.ts, epic-config-path.test.ts |
src/services/config-doctor.ts:collectRawStrictSectionFindings / collectRawValueConstraintFindings |
Validate each raw file as the loader does (migrateLegacyEpicConfig first) and report issues at the written path (legacyEpicIssuePath) |
Both helpers return their input unchanged for a file without turbo.epic |
epic-config-path-doctor.test.ts |
src/agents/index.ts:createSwarmAgents, getAgentConfigs |
Architect gets the Epic tools and prompt lines (EPIC_AGENT_TOOL_MAP) |
isEpicModeConfigEnabled(config) is false: a pure property read |
tests/unit/agents/epic-tool-gating.test.ts |
src/full-auto/policy.ts:resolveAgentCapabilityTools |
Same grant for the Full-Auto capability derivation | Same pure gate | epic-tool-gating.test.ts |
src/tools/save-plan.ts:executeSavePlan |
epic_shaping in the result |
Gate on the loaded config, so there is no read, I/O or await | save-plan-epic-shaping*.test.ts, final-off |
src/hooks/delegation-gate.ts:createDelegationGateHook (coder before-hook) |
Active wave = dispatch authority; required isolation; EPIC_ISOLATION_DEGRADED; a PR-feedback coder overlapping an issued wave's frozen scope is refused (EPIC_PR_FEEDBACK_SCOPE_OVERLAP) |
epicSentinelExists false ⇒ epicPolicy = null, no PR-feedback overlap check, and every expression keeps its original value |
delegation-gate-epic-wave.test.ts, delegation-gate-epic-isolation.test.ts, delegation-gate-epic-pr-feedback.test.ts, delegation-gate-epic-pr-feedback-wave.test.ts |
src/hooks/delegation-gate.ts:createDelegationGateHook (Task after-hook) |
Residue commit for non-coder writers | Synchronous epicSentinelExists gate, with no extra await |
tests/unit/epic/residue-commit.test.ts, epic-phase-handoff.test.ts |
src/hooks/delegation-gate.ts:buildParallelExecutionGuidance |
Suppresses the SERIAL/Lean advisory while an epic is open | Sentinel-first probe | delegation-gate-epic-guidance.test.ts |
src/hooks/delegation-gate/worktree-isolation.ts:finishStandardWorktreeDispatch |
Lane lands as a swarm(task …) merge commit, or is refused EPIC_LANDING_INDEX_DIRTY |
epicCommitLandingFor returns undefined after one existsSync |
tests/unit/epic/task-landing.test.ts, contract C3 |
src/background/completion-observer.ts:createBackgroundCompletionObserver |
Residue commit for background non-coder writers | Synchronous epicSentinelExists gate |
completion-observer-epic-residue.test.ts |
src/plan/manager.ts:updateTaskStatus |
Skips the #2582 auto-checkpoint for an epic task whose merge-back failed | epicMergeFailureSkipsCheckpoint is sentinel-first |
manager-auto-checkpoint-2582.test.ts, epic-worktree-merge-guard.test.ts |
src/state.ts:completeModifiedFilesForTask |
Keeps per-task write attribution until wave close | isEpicOpenForProject false ⇒ the original reset |
tests/unit/state/task-modified-files.test.ts |
src/state.ts:resolveInitialTurboMode |
Config turbo_mode does not seed Turbo while an epic is open |
Sentinel-first, evaluated only when turbo_mode is true |
turbo-mode-seeding-epic-sentinel.test.ts |
src/session/snapshot-reader.ts:rehydrateState |
A restored session comes back with Turbo off while an epic is open | Probed only for a session that would restore Turbo | snapshot-mode-state-regression.test.ts |
src/session/snapshot-writer.ts |
Keeps writing the legacy epicModeActive: false constant |
Byte-identical snapshot JSON | snapshot tests |
src/hooks/system-enhancer.ts:createSystemEnhancerHook |
EPIC_MODE_BANNER for the architect |
Sentinel-first probe | system-enhancer-epic-banner.test.ts, system-enhancer-epic-open-banner.test.ts |
src/tools/phase-complete.ts:executePhaseComplete |
Adds epic_phase_readiness and makes Lean readiness not applicable |
Check pushed only when an epic is open, so the gate report stays byte-identical | phase-complete-epic-readiness.test.ts, contract C1a |
src/commands/turbo.ts:handleTurboCommand |
Refuses Turbo-enabling paths while an epic is open; turbo epic redirects |
Sentinel-first probe; the replies are upstream text | turbo-epic-redirect.test.ts |
src/commands/close/orchestrator.ts:handleCloseCommand |
Finalizes an open epic before the archive stage | finalizeOpenEpicOnSwarmClose returns null on the config gate (no I/O) |
tests/unit/epic/close-finalize.test.ts, close-finalizer-clean.test.ts |
src/worktree/merge.ts |
Optional landingCommitMessage (explicit --no-ff merge commit) |
Absent ⇒ the plain git merge --no-edit |
task-landing.test.ts |
src/hooks/guardrails/index.ts:toolBefore (gate tools, e.g. pre_check_batch) |
In a multi-task wave, credits the gate run by its files to the one wave task whose frozen scope owns them (resolveEpicGateTaskAttribution), or to none with an advisory |
Sentinel-first; none (no epic / single-task wave) keeps the upstream currentTaskId path |
tests/unit/epic/gate-attribution.test.ts, tests/unit/hooks/guardrails-epic-stage-a-attribution.test.ts |
src/review/contracts.ts:ReviewDispatchRequest / createReviewModelDispatcher |
Optional tools map, used only by epic_phase_review's provider-refusal retry (EPIC_PHASE_REVIEW_BASH_RETRY_TOOLS: the read-only map with bash re-enabled, because OpenCode Zen's free tier answers a bash-disabled request with HTTP 403; still narrower than the standard reviewer/critic agents, whose own config denies only write/edit/patch among the built-ins; the retry fires once on an empty completion, or on an error dispatch whose providerError is an authentication/configuration refusal: that category, HTTP 403, or the statusless ProviderAuthError; a rate limit, quota or outage is left to the model-fallback chain) |
Absent ⇒ the shared DEFAULT_READ_ONLY_TOOLS, exactly as before |
tests/unit/review/dispatcher.test.ts, tests/unit/epic/phase-readiness-provider-retry.test.ts |
src/db/coordination-store.ts:listCoordinationStateKeys |
Key-only listing for corrupt-row recovery | Called only by Epic | lifecycle-writes.test.ts (corrupt-state repair) |
Upstream consumers of Epic modules (not gated, so be careful): src/plan/parallel-verdict.ts and src/tools/plan-conflict-check.ts use cochange-conflict.ts / cochange-source.ts for non-Epic parallel verdicts. A change to those two modules changes non-Epic behaviour.
| Artifact | Writer | Lifetime | Retention row |
|---|---|---|---|
coordination row, namespace turbo.epic.lifecycle (authority) |
lifecycle.ts |
one per project; deleted at close | epic-lifecycle |
.swarm/epic/epic.json (sentinel projection) |
lifecycle.ts |
while open | epic-lifecycle |
.swarm/epic/shaping.json (shaping iteration) |
plan-shaping-seam.ts |
per plan identity | epic-lifecycle |
.swarm/epic/reports/<key>.json |
close.ts |
archived and cleaned by /swarm close |
epic-lifecycle |
.swarm/epic/posterior.json |
learning-store.ts |
open epic; merged into the prior at close | epic-learning |
.swarm/epic-prior/learning.json (project prior) |
learning-store.ts |
survives /swarm close; /swarm epic prior reset clears it |
epic-learning |
.swarm/epic-prior/reports/<epicKey>-<stamp>.json (epic-report-v2) |
close.ts |
newest 50 kept; survives /swarm close |
epic-prior-reports |
.swarm/evidence/{phase}/epic-phase-review.json |
phase-readiness.ts |
one per phase, overwritten | lean-turbo-evidence |
.swarm/epic/coupling-report.json |
src/commands/coupling.ts (/swarm coupling --persist only) |
one file, rewritten per run; cleaned with .swarm/epic/ by /swarm close |
epic-turbo-state |
.swarm/epic-state.json, .swarm/epic/{calibration.json,divergence.jsonl}, namespace turbo.epic.session |
no writer; legacy-migration.ts retires the v1 session rows and renames the projection, and learning-store.ts imports the calibration files once |
swept | epic-turbo-state |
refs/swarm/epics/<epicKey>/{base,waves/<seq>,tasks/<id>} |
markers.ts |
deleted at close unless epic.retain_refs; never pushed |
— |
branch swarm/epic/<epicKey> |
epic-branch.ts |
kept after close (user deletes it) | — |
Which state wins. The coordination row is the authority for "an epic is open". The sentinel is only its projection, written and removed inside the same lifecycle transaction. A sentinel without a row means no open epic, and /swarm epic status repairs it. A row whose plan identity no longer matches the current plan is orphaned: the probe answers "no open epic". The plan ledger stays the authority for task status, and Epic only reads it. The epic record's wave, task and phase state is the authority for Epic decisions; the git refs mirror it (syncEpicRefs) and --repair-refs reconciles them. The project prior outlives epics, and the posterior belongs to the open epic and is merged into the prior at close. For config precedence, see Config.
The rows live in scripts/retention-registry.data.ts and are rendered in docs/observability-retention-registry.md. Keep the line citations there exact, because bun run check:registry-citations verifies them. Never rename the turbo.epic.* coordination namespaces. They are persisted data names from before the move to src/epic/, not config paths.
Epic needs the OpenCode SDK client (swarmState.opencodeClient) for worktree-isolated coders (STANDARD_WORKTREE_ISOLATION_UNAVAILABLE → EPIC_ISOLATION_DEGRADED) and for epic_phase_review (src/review/contracts.ts). The OpenCode 2 adapter installs a session-capable client (the v2 session shim, createV2SessionClient in src/host/v2/setup.ts), so hostProvidesClient passes on v2 and the host-unsupported refusal does not fire there (_internals.hostProvidesClient; null means an uninitialized process such as a test and is allowed). Since #3169 Phase 4 Epic is STARTABLE on v2: a human-typed /swarm command dispatches natively through the shared v1 command handler (src/host/v2/agents-commands.ts), so /swarm epic start reaches the real registry handler — the agent still cannot (epic is human-only; the model has no path into the host's command-execute surface). Coder isolation works there too: since #3169 Phase 3 the shim places worktree (foreign-directory) child sessions with an eager, placement-verified parentless create, so precreateStandardWorktreeSession succeeds on v2 instead of degrading to EPIC_ISOLATION_DEGRADED. Proof for the client-less refusal: tests/unit/epic/start-host-unsupported.test.ts (which also pins the v2-shim "starts" case).
- The canonical block is top-level
epic(EpicConfigSchemainsrc/config/schema.ts):mode {enabled, activation_threshold},cochange {enabled, threshold, min_co_changes},learning {enabled, decay_per_epic, half_life_days, hot_excess},sizing {min_tasks, min_scope_coverage, min_effective_speedup, coder_fraction},commit_policy,retain_refs, andphase_review {timeout_ms}(per-roleepic_phase_reviewdispatch budget, read byresolveEpicPhaseReviewTimeoutMs; same bounds and default asauto_review.timeout_ms). All of these are strict, and they need noturboblock. Defaults and meanings are indocs/configuration.md. - One resolver. Every reader goes through
resolveEpicConfig(config)(config.ts), either directly or viaconfig-gate.ts. Never readconfig.epicorconfig.turbo.epicanywhere else. - The legacy
turbo.epicpath is accepted permanently, and that is tested behaviour, not a TODO.- Precedence. The loader migrates each raw config file before the user + project merge. Within a file, top-level
epicwins per key and the legacy block fills in the rest. Across files the normal precedence applies (project over user), whichever path each file used. - The
turboblock. Aturboblock left holding nothing is dropped, soturbo.strategyis no longer needed. - Warnings and recovery. The loader warns once per advisory-dedup window whenever any file has
turbo.epic(LEGACY_EPIC_CONFIG_WARNING), and labels keys it recovers from a legacy block(from turbo.epic). An invalidepicvalue goes through the standard recovery ladder, like any known key. - Doctor. It validates each raw file as the loader does and reports at the path the user wrote. It reports
legacy-epic-config-pathand never auto-fixes, because its fix model edits one value in one file and has no move-and-merge. - JSON schema. It marks the path
deprecated, but it still requiresturbo.strategyfor editor validation; this is documented. - Parsed configs that skip the loader.
resolveEpicConfigfalls back toturbo.epic, and top-level wins whole.
- Precedence. The loader migrates each raw config file before the user + project merge. Within a file, top-level
- Retired keys (
mode.min_commits_for_signal,calibration) are accepted and stripped under both paths, warned once (findRetiredEpicConfigKeys,RETIRED_EPIC_*inschema.ts), reported by the doctor, and keptdeprecatedin the JSON schema. - Epic also reads
turbo.lean.max_parallel_codersfor a git epic's wave width when a Lean block exists (default 4; 1 in a non-git project).
- Unit:
tests/unit/epic/holds one area per file, with fixtures*-fixture.ts. Epic tests in shared directories:tests/unit/agents/epic-tool-gating.test.ts;tests/unit/commands/{epic*,coupling*,turbo-epic-redirect}.test.ts;tests/unit/config/{epic-config-path,epic-config-path-doctor,epic-retired-config-keys,epic-learning-config,turbo-mode-seeding-epic-sentinel}.test.ts;tests/unit/hooks/{delegation-gate-epic-*,system-enhancer-epic-*}.test.ts;tests/unit/tools/{epic-*,phase-complete-epic-readiness,save-plan-epic-shaping*}.test.ts;tests/unit/background/completion-observer-epic-residue.test.ts; andtests/unit/state/task-modified-files.test.ts. - Contracts (
tests/integration/):epic-lifecycle-contract-c0…c7each pin the behaviour one commit introduced.epic-lifecycle-contract-finalis the full lifecycle on real git.epic-lifecycle-contract-final-offis the config-off twin and must stay byte-identical to the no-Epic baseline. Scenario tests:epic-phase-fix-wave,epic-phase-handoff,epic-wave-planning, andepic-worktree-merge-guard. Helpers:tests/helpers/epic-{final-contract,landing,lifecycle}.ts. - Planner regression harness:
bun scripts/epic-bench.tsprints the table (scripts/epic-bench.tsovertests/fixtures/epic-bench/*.json, simulated byscripts/lib/epic-sim.ts). The gate itself is the normal unit testtests/unit/epic/epic-bench.test.ts. After an intended planner or learning change, runbun scripts/epic-bench.ts --write-golden, review thetests/fixtures/epic-bench/golden.jsondiff line by line, and explain it in the commit. A golden change that nobody can explain is a regression. - Running: run one file per process (AGENTS.md §6). Never use broad
test_runnerscopes.The third loop runs the Epic tests that live in shared directories, together with shared tests that touch Epic. Tests follow the repo rules:for f in tests/unit/epic/*.test.ts; do bun --smol test "$f" || echo "FAIL $f"; done for f in tests/integration/epic-*.test.ts; do bun --smol test "$f" --timeout 120000 || echo "FAIL $f"; done for f in $(grep -rliE 'epic' tests/unit --include='*.test.ts' | grep -v '^tests/unit/epic/'); do bun --smol test "$f" || echo "FAIL $f"; done bun run scripts/check-config-consumption.ts && bun run drift:check
bun:test, a frozen clock (tests/helpers/test-clock),canonicalMkdtemp,_internalsDI, and a 500-line limit per file.
Add a plan-shaping suggestion type
- Add the variant to
EpicShapingSuggestionand a builder inshaping-suggestions.ts. It must emit a concretesave_planpatch: complete resultingfiles_touched/dependsfor each touched task, plus valid unused ids for new tasks. - Wire it into
shapeEpicPlan(shaping.ts). Respect the rank table (declare-scope→ … →merge-tasks), the what-if guard (a patch that increases unscheduled tasks is rejected), and the work budget. - Format it in
formatEpicShapingLines, and check the/swarm coupling --suggestoutput (src/commands/coupling.ts). - Add tests in
shaping.test.ts/shaping-patches.test.tsthat apply the patch throughsave_plan, assave-plan-epic-shaping-apply.test.tsdoes. Update the list indocs/modes.md(Plan shaping) and in the registry text of/swarm coupling(src/commands/registry.ts), then runbun run generate:commands-docs --write.
Add an epic_next_wave blocked reason
- Add it to
EpicNextWaveBlockReason(next-wave-format.ts), and produce it innext-wave.tswith a message that names the remedy. - Keep the call idempotent: calling it again with nothing changed must return the same answer. The architect is told to call
epic_next_waveagain whenever it is unsure. - Add a test in the matching
tests/unit/epic/next-wave-*.test.ts, document it indocs/modes.md(theepic_next_waveflow), and update the tool description insrc/tools/tool-metadata.tsif the status list changes.
Change a learning weight or threshold
- Edit
EPIC_LEARNING_INCIDENT_WEIGHTS/ the constants inlearning.ts, or the defaults inEpicConfigSchema.learningtogether withDEFAULT_EPIC_LEARNING_SETTINGS(they must agree). - Update
learning.test.ts, thenbun scripts/epic-bench.ts --write-goldenand explain the golden diff. The harness guards the hot set and density demotion. - Update the formula in the
learning.tsheader and indocs/modes.md(Learning) anddocs/configuration.md.
Add an Epic config key
- Add it to
EpicConfigSchema(src/config/schema.ts) inside the right strict sub-object, with a default and a doc comment. Read it only throughresolveEpicConfig(config)?.…. - Regenerate:
bun run schema:generate(updatesopencode-swarm.schema.jsonand the generated key table indocs/configuration.md). Document the key in theepicsection ofdocs/configuration.mdand in the table indocs/modes.md. - A nested key needs no ratchet entry, but
bun run scripts/check-config-consumption.tsmust still pass. A new top-level key needs aCONFIG_CONSUMERSentry insrc/config/consumers.ts(#2904 ratchet) and a doctorvalidateConfigKeycase, and it moves the pinned key count intests/unit/scripts/check-config-consumption-cli.test.ts. - Test the top-level path and the legacy
turbo.epicpath (the migration carries any key). To retire a key, add it toRETIRED_EPIC_MODE_KEYS/RETIRED_EPIC_KEYSwith a replacement hint. Keep accepting it.
Debug a stuck epic
/swarm epic status(or bare/swarm epic) shows the epic, waves, phases, components, divergence, orphan detection, sentinel/row repair, and recorded merge failures (blocking, undated or stale, with the remedy).- Call
epic_next_waveand readstatusandreason.blocked: merge-failedmeans a recorded worktree merge failure. Once the task's work is really in the tree, or the record belongs to another plan, run/swarm epic clear-merge-failure <taskId>(preview) and then--confirm. - Task refs that a rebase or amend made unreachable:
/swarm epic status --repair-refs. epic-branch-mismatch: rungit checkout swarm/epic/<epicKey>.- Unreadable or orphaned state, or a plan that is gone:
/swarm epic close --abandonworks even with the config gate off, once no coder is still running (it refusescoders-livewhile a coder settlement is owned by a live dispatch). It never lands, and it switches back to the original branch when the tree is clean. - To see what the planner learned, use
/swarm epic learning. To start from a clean prior, use/swarm epic prior reset, which prints a single-use confirm token.
This section records why things are the way they are. Do not undo a decision listed here without understanding the failure it prevents.
- No non-Epic change. Epic lives in shared files only behind gates that cost at most one
existsSync(the sentinel) or a read of config that is already loaded. Bugs found in shared code are worked around on the Epic side, not fixed in the Epic change set; they are listed under Known caveats. The only approved non-Epic difference is that the architect no longer gets the Epic tools by default:EPIC_AGENT_TOOL_MAPgrants them only whenepic.mode.enabledis true, so non-Epic prompts and permissions stay clean. - Commit at landing, not at completion. Epic v1 committed a task when it completed (the "Rule 2" marker commits). That deadlocked in-wave rework: the first attempt landed unstaged, the rework worktree was cut from a HEAD that lacked it, and the second landing overlapped the uncommitted bytes forever. Now each coder's worktree lands as a real
--no-ffmerge commit, so the work is in HEAD before anything depends on it. Non-coder writers (tests, docs) get residue commits for the same reason.update_task_statusdoes no git writes. - Plan-scoped markers. Task ids repeat across plans (every plan has a
1.1). Commit markers therefore carry aSwarm-Plan: <planKey>trailer, and merge failures are filtered by epoch on the Epic side. The shared merge-status registry also serves/swarm lanesand recovery, so Epic does not re-key it. A failure with no date counts as relevant (fail closed), andclear-merge-failureis the way out. - Epic branch plus squash landing by default. Epic commits never pollute the user's branch while the epic runs. Closing stages a squash for the user to review and commit, and the user's own hooks and signing run on that final commit.
current-branchkeeps the old behaviour for people who want it. - Sentinel separate from the row, with token CAS. The row in the coordination store is the authority, but opening the database on every hot-path probe would cost non-Epic users I/O. The sentinel file makes "Epic is off" cost one
existsSync. Writes happen inside aBEGIN IMMEDIATEtransaction, and removals compare epicKey + token, so concurrent or late closes can never delete a newer epic. The probe has no memo, because a memo goes stale across processes. - The wave is the gate's authority, with one verdict. Live scope bindings expire and follow plan revisions, so the gate decides from the scopes frozen when
epic_next_waveissued the wave.epic_next_waveasserts the samecomputeEpicWaveVerdictcall that the gate repeats. The gate therefore never serializes a wave the planner issued, and never admits a coder outside the active wave. - Per-component planning, not a plan-wide coupling gate. The v1 plan-wide
pthreshold serialized whole plans because of one dense cluster. Components let independent clusters run in parallel, and only a dense component runs one task per wave (activation_threshold, reused as the density threshold). Sizing at start uses Amdahl's law (S_eff), because QA stays serial. - Learning model. Two signals, both analysis only and never write authorization:
- Learned scope expansion only ADDS planner edges, so it can never break the verdict parity above.
- A hot file needs excess evidence (a Beta prior, and
α' ≥ 1), so a cold start is neutral: an empty history serializes nothing. - The strongest-co-writer discount stops one declarer writing a file from counting as heat, because expansion already separates them.
- Age decay is a step function in whole half-lives (full weight for a whole half-life), plus
decay_per_epic, and the state is capped. This keeps the prior bounded and current.
- Shaping work budget.
save_plansits on the architect's critical path, so shaping is pure, uses only the warm co-change cache (no git scan), runs after the plan lock is released, fails open, and is cut off by a work budget (skipped-budget). Every suggestion is a concretesave_planpatch judged by a what-if, never prose. - Config is top-level
epic, and the legacy path migrates permanently. Epic is not a Turbo overlay, and underturboa missingstrategysilently dropped the whole block. Removing the legacy path would break existing configs, so it is migrated forever: per file, then merged with the normal precedence. Retired keys are accepted and stripped rather than rejected. An old config then loads without any recovery and with a precise "retired" warning, instead of an unrecognized-key recovery. - Tool-owned lifecycle. The banner says only "call
epic_next_wave". The procedure travels in the tool's response, so weaker models cannot drift from it, andepic_next_waveis idempotent so that repeat calls are safe.
- These are user-facing limits, and each has a remedy. They are listed in
docs/modes.md(Epic Mode): a manual edit to an undeclared tracked file blocks the next wave (dirty-baseline); landing and residue commits skip hooks and signing until the squash at close; a plaingit rebasedrops landing merges (use--repair-refs); Turbo enabled in a different OpenCode process is not detectable; learned signals are path heuristics. - The scorecard's concurrency factor, sizing's effective speedup, and the harness are estimates, not speed measurements.
- CI: the pull-request platform-path filter in
.github/workflows/ci.yml(job outputtouches-platform-paths) listssrc/turbo/but notsrc/epic/. The workflow is pinned by the required-check contract hash (scripts/required-check-contract.json), so editing it requires recapturing the contract. A pull request that touches onlysrc/epic/therefore runs only the Ubuntu unit matrix, whilemerge_groupalways runs the full three-OS matrix.
Non-Epic upstream issues observed while building Epic (Epic works around them and does not change them):
- Lean
planLeanTurboLanesproduces one lane in practice (src/turbo/lean/planner.ts), and its scope read uses the v1 projection. - Stale comments name deleted Epic v1 modules:
src/utils/logger.ts(epic-promotions),src/turbo/lean/partition-common.ts(planEpicWaves),src/turbo/lean/planner.ts. - Foreground coder write attribution lands on the child session (#2926).
- Some upstream tests fail or leak on a pristine upstream checkout independently of Epic. Examples are
tests/unit/tools/phase-complete-phase-council.adversarial.test.tsandsystem-guidance-delivery-fail-open-2780, which writes into the real data directory. Before blaming Epic for a failure, compare against a clean upstream checkout.