@mmerterden/multi-agent-pipeline 20.0.0 → 20.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +5 -5
  3. package/README.tr.md +5 -5
  4. package/SECURITY.md +3 -3
  5. package/docs/adr/0011-dormant-ci.md +10 -1
  6. package/docs/architecture.md +2 -2
  7. package/docs/ecosystem.md +5 -5
  8. package/docs/facts.json +8 -7
  9. package/install/_codex-agents.mjs +1 -1
  10. package/manifest.json +92 -64
  11. package/package.json +1 -1
  12. package/pipeline/agents/code-reviewer.md +2 -2
  13. package/pipeline/agents/dev-critic.md +5 -5
  14. package/pipeline/agents/security-auditor.md +80 -72
  15. package/pipeline/commands/figma-to-swiftui.md +1 -1
  16. package/pipeline/commands/multi-agent/SKILL.md +1 -1
  17. package/pipeline/commands/multi-agent/channels/SKILL.md +1 -1
  18. package/pipeline/commands/multi-agent/diff-explain/SKILL.md +1 -1
  19. package/pipeline/commands/multi-agent/help/SKILL.md +2 -0
  20. package/pipeline/commands/multi-agent/scan/SKILL.md +2 -2
  21. package/pipeline/commands/multi-agent/security-review/SKILL.md +52 -0
  22. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  23. package/pipeline/multi-agent-refs/component-dispatch.md +5 -5
  24. package/pipeline/multi-agent-refs/cross-cli-contract.md +6 -6
  25. package/pipeline/multi-agent-refs/features/security-audit.md +55 -0
  26. package/pipeline/multi-agent-refs/phases/modes.md +1 -1
  27. package/pipeline/multi-agent-refs/phases/phase-3-review.md +9 -15
  28. package/pipeline/multi-agent-refs/phases/phase-5-report.md +1 -1
  29. package/pipeline/multi-agent-refs/threat-model.md +39 -0
  30. package/pipeline/schemas/agent-state.schema.json +23 -0
  31. package/pipeline/schemas/phases.json +1 -2
  32. package/pipeline/schemas/prefs.schema.json +0 -4
  33. package/pipeline/schemas/reviewer-output.schema.json +99 -2
  34. package/pipeline/schemas/security-finding.schema.json +144 -0
  35. package/pipeline/scripts/_stack-routing.mjs +1 -0
  36. package/pipeline/scripts/gc-abandoned.sh +16 -9
  37. package/pipeline/scripts/render-work-summary.sh +7 -4
  38. package/pipeline/skills/.skill-manifest.json +47 -23
  39. package/pipeline/skills/.skills-index.json +75 -9
  40. package/pipeline/skills/shared/README.md +13 -7
  41. package/pipeline/skills/shared/core/multi-agent/SKILL.md +3 -4
  42. package/pipeline/skills/shared/core/multi-agent-scan/SKILL.md +2 -2
  43. package/pipeline/skills/shared/core/multi-agent-security-review/SKILL.md +29 -0
  44. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  45. package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
  46. package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
  47. package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
  48. package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
  49. package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
  50. package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
  51. package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
  52. package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
  53. package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
  54. package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
  55. package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
  56. package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
  57. package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
  58. package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
  59. package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
  60. package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
  61. package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
  62. package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
  63. package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
  64. package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
  65. package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
  66. package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
  67. package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
  68. package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
  69. package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
  70. package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
  71. package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
  72. package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
  73. package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
  74. package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
  75. package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
  76. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
  77. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
  78. package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
  79. package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
  80. package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
  81. package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
  82. package/pipeline/skills/shared/external/security-review/SKILL.md +64 -0
  83. package/pipeline/skills/shared/external/security-review/references/owasp-mobile-top10-2024.md +53 -0
  84. package/pipeline/skills/shared/external/security-review/references/owasp-web-api-top10-2021.md +56 -0
  85. package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
  86. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
  87. package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
  88. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
  89. package/pipeline/skills/skills-index.md +8 -2
  90. package/pipeline/commands/security-review.md +0 -6
@@ -0,0 +1,270 @@
1
+ # Android Design Tokens Codegen - Patterns
2
+
3
+ Copy-ready Gradle and Kotlin for the schema-to-typed-code pipeline. Each heading
4
+ mirrors a section in `SKILL.md`. All input is a local JSON schema; all output is
5
+ generated Kotlin under the build directory. Names are illustrative and generic.
6
+
7
+ ## Task wrapper
8
+
9
+ A typed task whose only job is declaring inputs/outputs and invoking a generator.
10
+
11
+ ```kotlin
12
+ abstract class GenerateFromSchemaTask : DefaultTask() {
13
+
14
+ @get:InputFile
15
+ @get:PathSensitive(PathSensitivity.RELATIVE)
16
+ abstract val schema: RegularFileProperty
17
+
18
+ @get:Classpath
19
+ abstract val generatorClasspath: ConfigurableFileCollection
20
+
21
+ @get:Input
22
+ abstract val packageName: Property<String>
23
+
24
+ @get:Input
25
+ abstract val checkMode: Property<Boolean>
26
+
27
+ @get:OutputDirectory
28
+ abstract val outputDir: DirectoryProperty
29
+
30
+ @get:Inject
31
+ abstract val execOps: ExecOperations
32
+
33
+ @TaskAction
34
+ fun run() {
35
+ execOps.javaexec {
36
+ classpath = generatorClasspath
37
+ mainClass.set("com.example.codegen.MainKt")
38
+ args(
39
+ "--schema", schema.get().asFile.absolutePath,
40
+ "--package", packageName.get(),
41
+ "--out", outputDir.get().asFile.absolutePath,
42
+ )
43
+ if (checkMode.get()) args("--check")
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ ### Registration with a resolvable generator configuration
50
+
51
+ ```kotlin
52
+ val codegen: Configuration by configurations.creating { isCanBeConsumed = false }
53
+
54
+ dependencies {
55
+ codegen("com.example:token-codegen:1.4.0")
56
+ }
57
+
58
+ fun registerGenerator(name: String, schemaPath: String, out: String, pkg: String) =
59
+ tasks.register<GenerateFromSchemaTask>(name) {
60
+ schema.set(layout.projectDirectory.file(schemaPath))
61
+ generatorClasspath.from(codegen)
62
+ packageName.set(pkg)
63
+ outputDir.set(layout.buildDirectory.dir("generated/source/$name"))
64
+ checkMode.set(providers.gradleProperty("codegen.check").isPresent)
65
+ }
66
+ ```
67
+
68
+ ### WorkAction isolation (generator runs in a worker, keeps daemon clean)
69
+
70
+ ```kotlin
71
+ interface GenerateParams : WorkParameters {
72
+ val schema: RegularFileProperty
73
+ val outputDir: DirectoryProperty
74
+ val checkMode: Property<Boolean>
75
+ }
76
+
77
+ abstract class GenerateAction : WorkAction<GenerateParams> {
78
+ override fun execute() {
79
+ val out = parameters.outputDir.get().asFile
80
+ // parse parameters.schema, emit into out, or compare when checkMode is set
81
+ }
82
+ }
83
+
84
+ // inside @TaskAction:
85
+ workerExecutor.classLoaderIsolation { classpath.from(generatorClasspath) }
86
+ .submit(GenerateAction::class.java) {
87
+ schema.set(this@GenerateFromSchemaTask.schema)
88
+ outputDir.set(this@GenerateFromSchemaTask.outputDir)
89
+ checkMode.set(this@GenerateFromSchemaTask.checkMode)
90
+ }
91
+ ```
92
+
93
+ ## preBuild wiring
94
+
95
+ Register every generator's output as a variant generated-source directory and
96
+ make `preBuild` depend on all of them.
97
+
98
+ ```kotlin
99
+ val generators = listOf(
100
+ registerGenerator("generateDesignTokens", "schema/tokens.json", "gen/tokens", "com.example.tokens"),
101
+ registerGenerator("generateTestIds", "schema/test-ids.json", "gen/testids", "com.example.testids"),
102
+ registerGenerator("generateAnalytics", "schema/analytics.json", "gen/analytics", "com.example.analytics"),
103
+ registerGenerator("generateDeeplinks", "schema/deeplinks.json", "gen/deeplinks", "com.example.deeplinks"),
104
+ )
105
+
106
+ androidComponents.onVariants { variant ->
107
+ generators.forEach { gen ->
108
+ variant.sources.java?.addGeneratedSourceDirectory(gen, GenerateFromSchemaTask::outputDir)
109
+ }
110
+ }
111
+
112
+ tasks.named("preBuild").configure { dependsOn(generators) }
113
+ ```
114
+
115
+ Ordering when a KSP or annotation processor consumes generated types: the
116
+ `addGeneratedSourceDirectory` call already wires KSP/kapt to depend on the
117
+ generator, so generated tokens are visible to processors without extra
118
+ `mustRunAfter`. Only add explicit ordering if a generator reads another
119
+ generator's output, which is an anti-pattern to avoid.
120
+
121
+ ## Check mode
122
+
123
+ The check task regenerates into a temp dir and compares, failing on any diff.
124
+ Prefer building this into the generator binary (`--check`) so the same code path
125
+ produces and verifies. A Gradle-side comparison when the binary lacks it:
126
+
127
+ ```kotlin
128
+ abstract class VerifyCodegenTask : DefaultTask() {
129
+
130
+ @get:InputDirectory abstract val committed: DirectoryProperty
131
+ @get:InputDirectory abstract val regenerated: DirectoryProperty
132
+
133
+ @TaskAction
134
+ fun verify() {
135
+ val a = committed.get().asFile
136
+ val b = regenerated.get().asFile
137
+ val diffs = a.walkTopDown().filter { it.isFile }.mapNotNull { file ->
138
+ val other = b.resolve(file.relativeTo(a).path)
139
+ if (!other.exists() || other.readText() != file.readText()) file.path else null
140
+ }.toList()
141
+ if (diffs.isNotEmpty()) {
142
+ throw GradleException(
143
+ "Generated code is stale. Run ./gradlew generateDesignTokens generateTestIds " +
144
+ "generateAnalytics generateDeeplinks and commit. Drifted:\n" +
145
+ diffs.joinToString("\n") { " - $it" }
146
+ )
147
+ }
148
+ }
149
+ }
150
+ ```
151
+
152
+ Wire the aggregate into the verification lifecycle:
153
+
154
+ ```kotlin
155
+ val checkCodegenFresh = tasks.register("checkCodegenFresh") {
156
+ dependsOn(generators.map { it.map { t -> t.name } })
157
+ // in committed-output repos, run each generator with -Pcodegen.check
158
+ }
159
+ tasks.named("check").configure { dependsOn(checkCodegenFresh) }
160
+ ```
161
+
162
+ ### Determinism checklist for the generator
163
+
164
+ - Sort every emitted map/list by a stable key before writing.
165
+ - No timestamps, build numbers, machine names, or absolute paths in output.
166
+ - Fixed formatter (e.g. a pinned KotlinPoet version) and fixed indentation.
167
+ - Stable file naming; one deterministic file per logical group.
168
+ - Same JSON in, byte-identical Kotlin out, on any machine.
169
+
170
+ ## Caching
171
+
172
+ ```kotlin
173
+ @CacheableTask
174
+ abstract class GenerateFromSchemaTask : DefaultTask() {
175
+
176
+ @get:InputFile
177
+ @get:PathSensitive(PathSensitivity.RELATIVE)
178
+ abstract val schema: RegularFileProperty
179
+
180
+ @get:Classpath
181
+ abstract val generatorClasspath: ConfigurableFileCollection
182
+
183
+ @get:Input
184
+ abstract val generatorVersion: Property<String>
185
+
186
+ @get:OutputDirectory
187
+ abstract val outputDir: DirectoryProperty
188
+ }
189
+ ```
190
+
191
+ `@Classpath` on the generator artifact and `@Input` on its version mean a
192
+ generator upgrade invalidates the cached output; a schema edit invalidates via
193
+ `@InputFile`; an unchanged build is `UP-TO-DATE` or `FROM-CACHE`.
194
+
195
+ `gradle.properties`:
196
+
197
+ ```properties
198
+ org.gradle.caching=true
199
+ org.gradle.configuration-cache=true
200
+ org.gradle.parallel=true
201
+ ```
202
+
203
+ ## Generated shapes
204
+
205
+ Illustrative target Kotlin per domain. Structure is the point; the specific
206
+ tokens, tags, events, and routes come from each repo's own schema.
207
+
208
+ ### Design tokens (semantic data class, consumed by the Theme layer)
209
+
210
+ ```kotlin
211
+ data class SemanticColors(
212
+ val surface: Long,
213
+ val onSurface: Long,
214
+ val accent: Long,
215
+ )
216
+
217
+ val lightColors = SemanticColors(surface = 0xFFFFFFFF, onSurface = 0xFF1A1A1A, accent = 0xFF2962FF)
218
+ val darkColors = SemanticColors(surface = 0xFF121212, onSurface = 0xFFEDEDED, accent = 0xFF82B1FF)
219
+
220
+ object Spacing { const val xs = 4; const val sm = 8; const val md = 16; const val lg = 24 }
221
+ ```
222
+
223
+ ### UI-test identifiers (one typed constant per tag)
224
+
225
+ ```kotlin
226
+ object TestTags {
227
+ const val PrimaryButton = "primary_button"
228
+ const val EmailField = "email_field"
229
+ const val ErrorBanner = "error_banner"
230
+ }
231
+
232
+ // UI: Modifier.testTag(TestTags.PrimaryButton)
233
+ // Test: onNodeWithTag(TestTags.PrimaryButton).assertIsDisplayed()
234
+ ```
235
+
236
+ ### Analytics events (name plus typed parameters)
237
+
238
+ ```kotlin
239
+ sealed interface AnalyticsEvent {
240
+ val name: String
241
+ val params: Map<String, String>
242
+
243
+ data class ScreenViewed(val screen: String) : AnalyticsEvent {
244
+ override val name = "screen_viewed"
245
+ override val params get() = mapOf("screen" to screen)
246
+ }
247
+
248
+ data class ItemSelected(val itemId: String, val position: Int) : AnalyticsEvent {
249
+ override val name = "item_selected"
250
+ override val params get() = mapOf("item_id" to itemId, "position" to position.toString())
251
+ }
252
+ }
253
+ ```
254
+
255
+ ### Deeplink manifest (routes plus typed parameters)
256
+
257
+ ```kotlin
258
+ enum class Deeplink(val path: String) {
259
+ Home("/home"),
260
+ ItemDetail("/item/{id}"),
261
+ Settings("/settings");
262
+
263
+ companion object {
264
+ fun match(path: String): Deeplink? = entries.firstOrNull { it.matches(path) }
265
+ }
266
+
267
+ private fun matches(candidate: String): Boolean =
268
+ path.toRegex().matches(candidate) // generated from a stable path template
269
+ }
270
+ ```
@@ -74,6 +74,24 @@ class UserViewModel @Inject constructor(
74
74
  }
75
75
  ```
76
76
 
77
+ Reduce state with `.update { copy(...) }` (atomic read-modify-write; safe under concurrent emissions) rather than assigning `_uiState.value = ...`.
78
+
79
+ For a stream-backed screen, expose the state with `.stateIn` and let the first load fire lazily from `onStart` instead of `init {}`. `onStart` runs when the UI actually starts collecting, so a screen that is created but never shown does no work, and the fetch restarts correctly with the subscription.
80
+
81
+ ```kotlin
82
+ private val _uiState = MutableStateFlow(UserUiState())
83
+
84
+ val uiState: StateFlow<UserUiState> = _uiState
85
+ .onStart { loadInitialData() }
86
+ .stateIn(
87
+ scope = viewModelScope,
88
+ started = SharingStarted.WhileSubscribed(5_000),
89
+ initialValue = UserUiState()
90
+ )
91
+ ```
92
+
93
+ Prefer `WhileSubscribed(5_000)` over `Lazily` or `Eagerly`. The 5-second stop timeout keeps the upstream alive across a configuration change (rotation, night-mode toggle) so the flow is not torn down and re-collected for a transient loss of subscribers, while still cancelling upstream work when the screen truly goes away. `Eagerly`/`Lazily` never stop, so they hold upstream resources for the ViewModel's whole life.
94
+
77
95
  ### 3. Creating the Screen Composable
78
96
 
79
97
  Consume the state in a "Screen" composable and pass data down to stateless components.
@@ -139,6 +157,50 @@ fun AppNavHost(navController: NavHostController) {
139
157
  }
140
158
  ```
141
159
 
160
+ ### Example 2: One-Shot Events (Navigation, Toast, Snackbar)
161
+
162
+ Keep navigation commands, snackbars and toasts out of the state class. State is a value that survives and is re-read on every recomposition, so an event stored in state fires again after a config change (a duplicated navigation, a re-shown snackbar). Model one-shot events as a `Channel` and expose them as a flow.
163
+
164
+ ```kotlin
165
+ sealed interface UserEvent {
166
+ data class ShowMessage(val text: String) : UserEvent
167
+ data class NavigateToProfile(val userId: String) : UserEvent
168
+ }
169
+
170
+ private val _events = Channel<UserEvent>(Channel.RENDEZVOUS)
171
+ val events: Flow<UserEvent> = _events.receiveAsFlow()
172
+
173
+ fun onSaved(userId: String) {
174
+ viewModelScope.launch {
175
+ _events.send(UserEvent.NavigateToProfile(userId))
176
+ }
177
+ }
178
+ ```
179
+
180
+ Collect lifecycle-aware so events are only delivered while the screen is at least `STARTED`.
181
+
182
+ ```kotlin
183
+ @Composable
184
+ fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
185
+ val lifecycleOwner = LocalLifecycleOwner.current
186
+ LaunchedEffect(Unit) {
187
+ lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
188
+ viewModel.events.collect { event ->
189
+ when (event) {
190
+ is UserEvent.ShowMessage -> snackbarHostState.showSnackbar(event.text)
191
+ is UserEvent.NavigateToProfile -> onNavigateToProfile(event.userId)
192
+ }
193
+ }
194
+ }
195
+ }
196
+ // ...
197
+ }
198
+ ```
199
+
200
+ A `Channel` with `RENDEZVOUS` capacity buffers events while there is no active collector and hands each to exactly one consumer, so nothing is lost and nothing is replayed. `SharedFlow(replay = 0)` drops any event emitted while the collector is paused (for example during a config change), which loses one-shot events; that is why the channel is the safer default here.
201
+
202
+ For the full MVI base-ViewModel pattern (intent reduction, effect plumbing, a reusable base class), see the `android-mvi-viewmodel` skill.
203
+
142
204
  ## Best Practices
143
205
 
144
206
  - ✅ **Do:** Use `remember` and `derivedStateOf` to minimize unnecessary calculations during recomposition.
@@ -0,0 +1,255 @@
1
+ ---
2
+ name: android-mvi-viewmodel
3
+ description: "A reusable generic BaseViewModel<State, Intent, Event> for MVI Compose apps: StateFlow state, one-shot events over a rendezvous Channel, a single intent dispatch, and a use-case executor with shared loading and error routing. Use when building or reviewing an MVI ViewModel base, intent-to-state reduction, or one-shot navigation and snackbar events."
4
+ risk: safe
5
+ source: multi-agent-pipeline
6
+ date_added: "2026-09-21"
7
+ ---
8
+
9
+ # Android MVI BaseViewModel
10
+
11
+ A generic `BaseViewModel<State, Intent, Event>` that every screen ViewModel
12
+ extends. It owns the MVI seam: immutable state as a `StateFlow`, one-shot
13
+ effects as events, a single intent entry point, and centralized use-case
14
+ execution with shared loading and error routing. Targets Kotlin 2.0+,
15
+ `kotlinx.coroutines` 1.8+, and Compose from 2024-2025.
16
+
17
+ Full copy-ready code (the assembled base class, a concrete screen, and the
18
+ lifecycle-aware event collector) lives in
19
+ [references/patterns.md](references/patterns.md). Load that file when you need a
20
+ complete implementation to adapt; each heading there mirrors a section below.
21
+
22
+ ## Contents
23
+
24
+ - [The three type parameters](#the-three-type-parameters)
25
+ - [State as StateFlow](#state-as-stateflow)
26
+ - [One-shot events over a rendezvous channel](#one-shot-events-over-a-rendezvous-channel)
27
+ - [Single intent entry point](#single-intent-entry-point)
28
+ - [Use-case executor](#use-case-executor)
29
+ - [Reference-counted loading](#reference-counted-loading)
30
+ - [Active job registry](#active-job-registry)
31
+ - [Collecting state and events in Compose](#collecting-state-and-events-in-compose)
32
+ - [Do's and Don'ts](#dos-and-donts)
33
+ - [Related skills](#related-skills)
34
+
35
+ ## The three type parameters
36
+
37
+ Each screen declares three types and passes an initial state up:
38
+
39
+ - `State` - an `@Immutable data class`, the complete render model. One per screen.
40
+ - `Intent` - a `sealed interface` of everything the UI can ask for (clicks,
41
+ text input, retry, refresh).
42
+ - `Event` - a `sealed interface` of one-shot effects the UI consumes exactly
43
+ once (navigate, show snackbar/toast, dismiss keyboard).
44
+
45
+ State is what the screen *is*; an event is something that *happens* once and
46
+ must never replay on rotation.
47
+
48
+ ```kotlin
49
+ abstract class BaseViewModel<State, Intent, Event>(
50
+ initialState: State,
51
+ ) : ViewModel() {
52
+ protected abstract fun handleIntent(intent: Intent)
53
+ }
54
+ ```
55
+
56
+ ## State as StateFlow
57
+
58
+ State lives in a private `MutableStateFlow` exposed read-only. Never expose the
59
+ mutable flow. Update with `.update { copy(...) }` so the read-modify-write is
60
+ atomic under concurrent coroutines - `value = value.copy()` races.
61
+
62
+ ```kotlin
63
+ private val _state = MutableStateFlow(initialState)
64
+ val state: StateFlow<State> = _state.asStateFlow()
65
+
66
+ protected fun reduce(reducer: State.() -> State) {
67
+ _state.update { it.reducer() }
68
+ }
69
+ ```
70
+
71
+ Reduction is synchronous and pure: `reduce { copy(query = intent.text) }`. No
72
+ suspension, no I/O, no side effects inside the reducer - those belong in the
73
+ use-case executor.
74
+
75
+ ## One-shot events over a rendezvous channel
76
+
77
+ Events use a `Channel(Channel.RENDEZVOUS)` exposed as `receiveAsFlow()`, not a
78
+ `SharedFlow(replay = 0)`:
79
+
80
+ ```kotlin
81
+ private val _events = Channel<Event>(Channel.RENDEZVOUS)
82
+ val events: Flow<Event> = _events.receiveAsFlow()
83
+
84
+ protected fun sendEvent(event: Event) {
85
+ viewModelScope.launch { _events.send(event) }
86
+ }
87
+ ```
88
+
89
+ Why rendezvous over `MutableSharedFlow(replay = 0)`:
90
+
91
+ - **No collector, no loss.** `receiveAsFlow` is a cold, single-consumer stream;
92
+ an event emitted while the screen is in the background suspends the sender
93
+ until collection resumes. `SharedFlow.tryEmit` with no subscriber silently
94
+ drops the event - a navigation that never fires.
95
+ - **Delivered once.** A `Channel` fans out to exactly one collector and each
96
+ element is received a single time. `SharedFlow` is multicast, so two active
97
+ collectors (e.g. an accidental double-collect) both fire the navigation.
98
+ - **Back-pressure is correct.** `RENDEZVOUS` (zero buffer) hands off directly;
99
+ the emitter waits for the consumer rather than buffering a queue of stale
100
+ toasts.
101
+
102
+ Use this for navigation, snackbar/toast, and dismiss-keyboard - anything that
103
+ must fire once and must not survive a configuration change.
104
+
105
+ ## Single intent entry point
106
+
107
+ The UI calls exactly one method, `processIntent`. The base logs/guards centrally
108
+ and delegates to the subclass `handleIntent`, which is a single `when` over the
109
+ sealed `Intent` - exhaustive, so a new intent fails to compile until handled.
110
+
111
+ ```kotlin
112
+ fun processIntent(intent: Intent) = handleIntent(intent)
113
+
114
+ // in a concrete ViewModel
115
+ override fun handleIntent(intent: SearchIntent) = when (intent) {
116
+ is SearchIntent.QueryChanged -> reduce { copy(query = intent.text) }
117
+ SearchIntent.Submit -> runSearch()
118
+ SearchIntent.Retry -> runSearch()
119
+ }
120
+ ```
121
+
122
+ ## Use-case executor
123
+
124
+ A single protected helper wraps every suspending call so error handling is not
125
+ copy-pasted per screen. It uses `runCatching`, then **rethrows
126
+ `CancellationException` before any broad catch** - swallowing it breaks
127
+ structured concurrency and leaves the coroutine "running" after cancellation.
128
+
129
+ ```kotlin
130
+ protected fun <T> execute(
131
+ showLoading: Boolean = true,
132
+ onError: ((Throwable) -> Unit)? = null,
133
+ block: suspend () -> T,
134
+ onSuccess: (T) -> Unit,
135
+ ): Job = viewModelScope.launch {
136
+ if (showLoading) incrementLoading()
137
+ try {
138
+ runCatching { block() }
139
+ .onSuccess(onSuccess)
140
+ .onFailure { throwable ->
141
+ if (throwable is CancellationException) throw throwable
142
+ (onError ?: ::routeError)(throwable)
143
+ }
144
+ } finally {
145
+ if (showLoading) decrementLoading()
146
+ }
147
+ }
148
+ ```
149
+
150
+ Error routing has two tiers: a per-call `onError` for screen-specific handling,
151
+ falling back to a global `routeError` that maps the throwable to an `Event`
152
+ (snackbar) or an error field in state. Define `routeError` once in the base or
153
+ override per feature. Because `CancellationException` is rethrown before either
154
+ tier, cancellation never reaches the error UI.
155
+
156
+ ## Reference-counted loading
157
+
158
+ Overlapping use-cases must share one spinner: if two calls each toggled a
159
+ boolean, the first to finish would hide the indicator while the second still
160
+ runs. Keep an integer count instead - loading is "active" while the count is
161
+ above zero.
162
+
163
+ ```kotlin
164
+ private val loadingCount = MutableStateFlow(0)
165
+ val isLoading: StateFlow<Boolean> =
166
+ loadingCount
167
+ .map { it > 0 }
168
+ .stateIn(viewModelScope, SharingStarted.Eagerly, false)
169
+
170
+ private fun incrementLoading() = loadingCount.update { it + 1 }
171
+ private fun decrementLoading() = loadingCount.update { (it - 1).coerceAtLeast(0) }
172
+ ```
173
+
174
+ The `execute` helper drives this in its `try/finally`, so the count is balanced
175
+ even when a call throws. Screens observe `isLoading` (or fold it into `State`).
176
+
177
+ ## Active job registry
178
+
179
+ Track launched jobs by a key so a screen can cancel in-flight work (a new search
180
+ supersedes the previous one) and so everything is torn down in `onCleared`.
181
+
182
+ ```kotlin
183
+ private val jobs = mutableMapOf<String, Job>()
184
+
185
+ protected fun launchTracked(key: String, block: suspend () -> Unit) {
186
+ jobs.remove(key)?.cancel()
187
+ jobs[key] = viewModelScope.launch { block() }
188
+ }
189
+
190
+ protected fun cancel(key: String) {
191
+ jobs.remove(key)?.cancel()
192
+ }
193
+ ```
194
+
195
+ Cancelling `viewModelScope` in `onCleared` (automatic) already stops children;
196
+ the registry adds *selective* cancel and de-duplication. Keying a search by
197
+ `"search"` makes each keystroke cancel the prior request for free.
198
+
199
+ ## Collecting state and events in Compose
200
+
201
+ State is collected lifecycle-aware; events are collected in a
202
+ `LaunchedEffect` that respects lifecycle so a background screen never consumes a
203
+ navigation. Pass **state values and lambdas** to child composables - never the
204
+ ViewModel itself, so children stay stateless, previewable, and testable.
205
+
206
+ ```kotlin
207
+ @Composable
208
+ fun SearchRoute(viewModel: SearchViewModel = hiltViewModel(), onOpenDetail: (Id) -> Unit) {
209
+ val state by viewModel.state.collectAsStateWithLifecycle()
210
+ val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
211
+
212
+ ObserveEvents(viewModel.events) { event ->
213
+ when (event) {
214
+ is SearchEvent.OpenDetail -> onOpenDetail(event.id)
215
+ is SearchEvent.ShowError -> /* show snackbar */ Unit
216
+ }
217
+ }
218
+
219
+ SearchScreen(
220
+ state = state,
221
+ isLoading = isLoading,
222
+ onIntent = viewModel::processIntent,
223
+ )
224
+ }
225
+ ```
226
+
227
+ `ObserveEvents` is a small lifecycle-aware collector (`repeatOnLifecycle`); its
228
+ full body is in [references/patterns.md](references/patterns.md).
229
+
230
+ ## Do's and Don'ts
231
+
232
+ - **Do** expose only `asStateFlow()` / `receiveAsFlow()`; keep the mutable
233
+ backing fields private.
234
+ - **Do** update state with `.update { copy(...) }`, never `value = value.copy()`.
235
+ - **Do** rethrow `CancellationException` before any broad `catch` or
236
+ `onFailure`.
237
+ - **Do** route every suspend call through the executor so loading and errors are
238
+ centralized.
239
+ - **Don't** put one-shot effects (navigation, toast) in `State` - they replay on
240
+ rotation.
241
+ - **Don't** use `SharedFlow(replay = 0)` for events; a background screen drops
242
+ them.
243
+ - **Don't** pass the ViewModel to child composables; pass state + lambdas.
244
+ - **Don't** toggle a boolean for loading when calls overlap; count references.
245
+
246
+ ## Related skills
247
+
248
+ This skill owns the MVI base-ViewModel seam. For adjacent concerns, defer to:
249
+
250
+ - **`android-architecture`** - module structure, the api/impl split, and
251
+ nav-graph aggregation that host these ViewModels.
252
+ - **`kotlin-coroutines-expert`** - dispatcher selection, structured-concurrency
253
+ and `Flow` operator depth beyond the executor shown here.
254
+ - **`android-datastore`** - persisting the state or preferences a ViewModel
255
+ reads and writes.