Delegate a Swift 6 strict-concurrency migration to agents β with the compiler as judge and a journal as the single source of truth.
The official migration guide is a great 40-page document. strictmigrate turns those 40 pages into an executable task queue: the compiler counts the verdicts, the journal records where the migration stands, and the agent (or a human) holds the pen in between.
v0.5 β across the KMP boundary. Swift-side diagnostics, Kotlin-side fixes: routing, symbol slicing, and edit permissions now span the language boundary of a Kotlin Multiplatform project.
$ strictmigrate status
strictmigrate β Swift 6 strict concurrency migration
journal: strictmigrate.yaml.journal
Target Level Sendable Isolation Region Other Total Progress
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ImagePipelineCore complete 1 3 0 0 4 ββββββββββ 50%
DemoApp complete 0 0 2 0 2 ββββββββββ 0%
Total: 6 remaining concurrency diagnostics (from 8 initial) β 25% complete across 2 targetsThat table is the migration meeting. No wiki page, no memory of "what we already fixed" β data.
Migrating a large Swift 5 codebase to Swift 6 strict concurrency means hundreds to thousands of Sendable / actor-isolation diagnostics the moment you raise StrictConcurrency from minimal toward complete:
- Scale β too many diagnostics to triage in an afternoon, or a quarter.
- Official support stops at docs and syntax β
swift-migratorflips flags and syntax; deciding how a type becomesSendable, or where an actor boundary should sit, is semantic work. Verifying that work is mechanical: build, count, test. - Delegating it wholesale to an agent fails structurally β context limits cause partial fixes, sessions regress previous work, and nobody records how far you got. There is no revert boundary.
strictmigrate's bet: split the diagnostics into atomic tasks, let an agent execute one task at a time, judge each result deterministically (build + tests + diagnostic counts), and journal every verdict so any session β human, agent, or CI β reads the same state. v0.1 ships the measurement and journaling half; the agent loop lands in v0.3.
Requires a Swift 6+ toolchain (Xcode 16+ on macOS).
$ git clone https://github.com/ictechgy/strictmigrate
$ cd strictmigrate && swift build -c release
$ cp .build/release/strictmigrate /usr/local/bin/ # or anywhere on PATH$ cd YourSwiftPackage
$ strictmigrate init
Created strictmigrate.yaml.journal
$ strictmigrate measure
Building with -strict-concurrency=complete β¦
build: swift build --no-color-diagnostics --scratch-path .strictmigrate/measure-scratch -Xswiftc -strict-concurrency=complete (exit 1)
diagnostics: 5 tracked (sendable 1, isolation 3, region 1, other 0); 0 unrelated, not counted
Target Sendable Isolation Region Other Total Ξ vs previous
ImagePipelineCore 1 3 1 0 5 new
DemoApp 0 0 0 0 0 new
journal updated: strictmigrate.yaml.journal
$ strictmigrate status # pretty table (above)
$ strictmigrate status --format markdown # paste into a PR or issue
$ strictmigrate status --format json # feed CI or dashboardsCommit strictmigrate.yaml.journal. Raw build logs land in .strictmigrate/, which ignores itself.
$ strictmigrate slice
Queued 5 tasks (dropped 0 stale queued) β journal updated: strictmigrate.yaml.journal
Next up:
t-0001 ImagePipelineCore Sources/ImagePipelineCore/Decoder.swift [shared] (sendable-violationΓ1)
t-0002 ImagePipelineCore Sources/ImagePipelineCore/Renderer.swift [renderSync] (actor-isolationΓ1)
t-0003 ImagePipelineCore Sources/ImagePipelineCore/Renderer.swift [spawnWork] (actor-isolationΓ2)
t-0004 DemoApp Sources/DemoApp/main.swift [(file scope)] (region-violationΓ1)
...
$ strictmigrate next
Task t-0001 β target ImagePipelineCore
File: Sources/ImagePipelineCore/Decoder.swift
Symbol(s): shared
Diagnostics to fix β only these:
- Sources/ImagePipelineCore/Decoder.swift:16:23 [sendable] warning: static property 'shared' is not concurrency-safe β¦
Scope rules (hard boundaries):
1. Modify ONLY `Sources/ImagePipelineCore/Decoder.swift`, ONLY the symbol(s) above.
2. Do not touch other files or symbols, even if you see problems there β they belong to other tasks.
β¦
Verify (the compiler is the judge):
swift build --no-color-diagnostics -Xswiftc -strict-concurrency=complete
strictmigrate measure # updates the journal; closes t-0001 when cleanPaste that prompt into your editor, a chat agent, or a teammate β fix it, then strictmigrate measure again. The measure run reconciles the queue automatically: tasks whose diagnostics are gone close as passed; partial fixes stay open. Nothing is remembered in anyone's head; it is all in the journal.
Let an agent work the queue itself:
$ strictmigrate run --task t-0003 --adapter claude
Measuring current state before dispatch β¦
ββ t-0003 [ImagePipelineCore] Sources/ImagePipelineCore/Renderer.swift Β· spawnWork
attempt 1/2: dispatching claude-code β¦
agent finished (exit 0); measuring β¦
passed β committed aca26a2, journal updated.
1/1 passed β journal: strictmigrate.yaml.journalWhat happened in between, all deterministic:
- Clean-tree precondition β git must be clean (harness state like
.strictmigrate/and agent session dirs are whitelisted). The revert boundary only works from a clean tree. - Dispatch β the task prompt goes to the adapter (
claudeusesclaude --print --permission-mode acceptEdits; the agent never runs builds β judging is not its job). - Scope check β the agent may edit exactly the task's file. Any other edit (even bookkeeping outside whitelisted state dirs) reverts everything, the attempt fails, and the reason lands in the journal.
- Verdict β a fresh strict build runs; the task's symbols must be clean and no symbol anywhere else may have gained diagnostics. Aggregate improvement is not enough.
- Commit or revert β pass: exactly the task's file is committed (
one task = one commit = one revert boundary) and the hash lands in the journal. Fail: edits are discarded, the tree returns to exactly its pre-state, and after--max-attemptsthe task isrevertedwith per-attempt notes.
Any CLI agent works today via the generic adapter:
$ strictmigrate run --adapter command \
--adapter-command 'my-agent --prompt-file $STRICTMIGRATE_PROMPT_FILE'Dedicated adapters: --adapter codex (Codex CLI, workspace-write sandbox) and --adapter acp --acp-command 'claude-code-acp' for any Agent Client Protocol agent β the JSON-RPC handshake, session, and prompt turn run over stdio, with streamed agent messages kept as the transcript.
Kotlin Multiplatform teams hit Swift 6 strict concurrency from the iOS side: the app's build flags every Kotlin-exported non-Sendable type at each crossing, while the fix usually lives in the shared Kotlin module. strictmigrate now works across that boundary β measurement stays on the Swift side (the compiler that judges), while attribution, slicing, and edit permissions extend into the Kotlin source:
.ktfiles resolve to their Kotlin declarations (fun/class/object/interface/val, raw strings, nested comments) for symbol-level tasks.- Kotlin files attribute to
module/sourceSettargets via Gradle directory conventions β no Gradle run needed β and discovered source sets appear in the journal even at zero diagnostics. - Boundary routing: when a task's Swift diagnostics name a non-Sendable type that is declared in Kotlin, the task prompt points at the Kotlin declaration ("prefer fixing it there") and the executor's scope check allows that file β deterministically, from the same function on both ends. One task still means one commit, now possibly spanning both languages.
A runnable version of this scenario lives in Examples/KmpBoundary: a Swift app, a shared Kotlin module, and a boundary violation whose fix touches both sides. The pure-Kotlin strict-mode adapter remains deferred until the Kotlin compiler grows one (KT-72087).
Pass --tests (or --tsan) to make the suite part of the judge:
$ strictmigrate run --task t-0007 --adapter claude --tsan
agent finished (exit 0); measuring β¦
running tests under ThreadSanitizer β¦
passed β committed 1f2a3b4, journal updated.Tests run only once the whole package builds β results alongside a broken build elsewhere would be noise. Test failures or TSan race reports fail the attempt (edit reverted), and the journal records verdict: { build: pass, tests: 47/47, tsan: clean }. Regressions found outside the task's target are labeled with the target they broke (DemoApp:Sources/DemoApp/main.swift [count] +1), so cross-target fallout is visible at a glance.
Prompts, transcripts, and build logs for every attempt are kept under .strictmigrate/ for post-mortems.
A live example lives in Examples/DemoConcurrency β a tiny package with intentional violations of all three categories.
| Command | What it does |
|---|---|
strictmigrate init |
Create an empty journal in the package root. |
strictmigrate measure |
Build with strict concurrency, count diagnostics per target, update the journal, reconcile open tasks. |
strictmigrate status |
Render the journal as a report (pretty, markdown, json). |
strictmigrate slice |
Cluster the last measurement into atomic tasks (one symbol group = one task). |
strictmigrate next |
Print the next task with a ready-to-paste prompt (--peek to leave it queued). |
strictmigrate run |
Execute tasks through an agent: dispatch, judge, commit or revert. |
strictmigrate tasks |
List tasks and their statuses. |
strictmigrate skip |
Mark a task skipped so the queue moves past it. |
--level minimal|targeted|completeβ strict-concurrency level to measure at (defaultcomplete; the honest distance to Swift 6).--with-testsβ include test targets (swift build --build-tests).--incrementalβ reuse the package's.build. Fast, but warnings from files that did not recompile are not re-emitted and will be undercounted. By default measure builds in a throw-away scratch directory so every diagnostic is emitted and counts are reproducible.-Xswiftc <flag>β pass extra swiftc flags (repeatable).--xcresult <path>β parse an existing result bundle instead of runningswift build(see below).--verboseβ list every tracked diagnostic after the summary.
A failing build is the normal case mid-migration: measure reports the exit code and counts what the compiler said. Diagnostics that are not concurrency-related (syntax errors, style warnings) are counted as unrelated and never enter the journal.
v0.1 measures SwiftPM packages directly. For Xcode projects, build once with a result bundle and hand it over:
$ xcodebuild -scheme App -destination 'generic/platform=iOS' \
-resultBundlePath build.xcresult \
OTHER_SWIFT_FLAGS='$(inherited) -strict-concurrency=complete' build
$ strictmigrate measure --xcresult build.xcresultIf a Package.swift is present (xcodebuild on a package), diagnostics are attributed to package targets; otherwise they land under (unattributed) until target mapping ships for project files.
One YAML file per repository, commit target, edited by measure and safe to edit by hand:
version: 1
targets:
ImagePipelineCore:
level: complete # minimal | targeted | complete
diagnostics_baseline: # latest measured counts
sendable: 1
isolation: 3
region: 0
other: 0
diagnostics_initial: # counts at first contact β progress denominator
sendable: 2
isolation: 3
region: 0
other: 0
last_measured_at: '2026-09-06T00:00:00Z'
tasks: [] # v0.2+: atomic tasks (one task = one commit = one revert boundary)status computes progress as 1 β baseline/initial per target. Regressions are reported honestly (you can see β50%), never clamped.
- Collection β
swift buildoutput is parsed fromfile:line:col: error|warning:lines (ANSI colors and hyperlink markup stripped, duplicates from the emit-module/per-file phases collapsed), or fromxcresulttoolJSON for result bundles. - Attribution β files map to targets by longest source-root prefix from
swift package describe. Clean targets appear with zeros; unmapped files land under(unattributed). - Classification β two deterministic layers: newer toolchains append stable diagnostic ids (
[#RegionIsolation::SendingRisksDataRace],[#ActorIsolatedCall],[#MutableGlobalVariable]) which are preferred; older output falls back to keyword rules on the message text. Categories (sendable,isolation,region,other) are a prioritization aid β the tracked total is exact regardless of bucketing.
Known ground roughness, handled explicitly: incremental builds don't re-emit cached warnings (fresh scratch by default), xcresult locations are 0-based (normalized), and the root issues mirror per-action summaries (deduplicated).
- Clustering β one task per (target, file, enclosing symbol). A lightweight brace-depth scanner resolves each diagnostic to its enclosing declaration, so half-fixed symbols never split across tasks β the classic cause of re-appearing diagnostics. Diagnostics in top-level code cluster as
(file scope). - Ordering β leaf targets first:
swift package describedependency edges are topologically sorted so dependencies are fixed before dependents, and each fix gets confirmed by downstream recompiles. Within a target, smaller tasks come first. - Reconciliation β
measureresolves current diagnostics to (file, symbol) identities and closes open tasks whose symbols are clean, recording the build verdict honestly (a task can pass while the overall build still fails elsewhere). Partial fixes stay open; the queue itself is always re-derivable viaslice.
- The compiler is the judge. Verdicts (build, tests, counts) are deterministic. The harness reports them, never manipulates them.
- The journal is the single source of truth. "How far along is the migration?" is answered by data, not memory or PR prose. CI, meetings, and agents all read the same file.
- Agents hold the pen only. In v0.3, an agent (claude-code, codex, ACP) writes the fix for one task; parsing, slicing, verdicts, journaling, and reverting stay deterministic code.
- Atomic tasks. One task = one symbol group = one commit β the revert boundary is always unambiguous.
- Fully local. Diagnostics, journal, and commits stay in the repository. Nothing is sent anywhere.
- v0.6 (undecided) β pure-Kotlin strict-mode adapter, deferred until the Kotlin compiler grows compiler-enforced data-race checking (KT-72087); verdict hooks for CI gates.
$ swift test # unit tests + recorded-fixture tests
$ STRICTMIGRATE_SKIP_INTEGRATION=1 swift test # skip the real-toolchain integration testsThe integration tests compile Examples/DemoConcurrency with your local toolchain and assert exact diagnostic counts β they are the guardrail for the parsing pipeline.