create-cmp-cli 0.7.1 → 0.9.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 (79) hide show
  1. package/README.md +72 -11
  2. package/llms.txt +6 -2
  3. package/package.json +1 -1
  4. package/src/commands/upgrade.mjs +8 -1
  5. package/src/lib/adr-seed.mjs +178 -0
  6. package/src/lib/registry.mjs +15 -2
  7. package/src/lib/tabs.mjs +91 -4
  8. package/src/lib/upgrade.mjs +49 -5
  9. package/src/scaffold.mjs +52 -1
  10. package/src/versions/candidates.json +4 -0
  11. package/src/versions/registry.json +88 -0
  12. package/template/.claude/skills/add-feature/SKILL.md +35 -10
  13. package/template/.claude/skills/add-repository/SKILL.md +1 -1
  14. package/template/.claude/skills/add-screen/SKILL.md +13 -7
  15. package/template/.githooks/pre-push +24 -0
  16. package/template/CLAUDE.md +196 -48
  17. package/template/README.md +23 -27
  18. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
  19. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
  20. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +69 -2
  21. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  22. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +54 -0
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  43. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  44. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +13 -0
  45. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  46. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +21 -0
  47. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  48. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
  49. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
  50. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
  51. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
  52. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
  53. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +269 -0
  54. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  55. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +207 -15
  56. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  57. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  58. package/template/docs/ARCHITECTURE.md +317 -34
  59. package/template/docs/TESTING.md +6 -5
  60. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  61. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  62. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  63. package/template/qa/approvals.json +42 -0
  64. package/template/qa/approve.mjs +139 -0
  65. package/template/qa/arch-doc.mjs +69 -0
  66. package/template/qa/comment.mjs +76 -0
  67. package/template/qa/comments.json +4 -0
  68. package/template/qa/golden/home.json +3 -3
  69. package/template/qa/lib/approvals.mjs +806 -0
  70. package/template/qa/lib/arch-doc.mjs +451 -0
  71. package/template/qa/lib/comments.mjs +252 -0
  72. package/template/qa/lib/component-stories.mjs +183 -0
  73. package/template/qa/lib/inputs-hash.mjs +5 -1
  74. package/template/qa/scaffold-feature.mjs +184 -67
  75. package/template/qa/setup-hooks.mjs +33 -0
  76. package/template/qa/verify.mjs +118 -9
  77. package/template/specs/app-base.spec.md +44 -7
  78. package/template/specs/home.spec.md +7 -4
  79. package/template/specs/intent.md +50 -0
@@ -73,18 +73,49 @@ export function lockstepViolation(versions) {
73
73
  const kotlin = versions.kotlin;
74
74
  const ksp = versions.ksp;
75
75
  if (!kotlin || !ksp) return null;
76
- if (!ksp.startsWith(`${kotlin}-`)) {
76
+ // Two valid schemes: the classic KSP1 form "<kotlin>-<kspVersion>" (e.g.
77
+ // 2.2.20-2.0.4), and the KSP2 aligned form where the KSP version EQUALS the
78
+ // Kotlin version (e.g. kotlin 2.3.10 ↔ ksp 2.3.10 — KSP dropped the -<ksp> suffix).
79
+ if (ksp !== kotlin && !ksp.startsWith(`${kotlin}-`)) {
77
80
  return (
78
- `kotlin ${kotlin} and ksp ${ksp} are OUT OF LOCKSTEP — ksp must be ` +
79
- `"${kotlin}-<kspVersion>" (e.g. "${kotlin}-2.0.4"). Refusing to write a broken pairing.`
81
+ `kotlin ${kotlin} and ksp ${ksp} are OUT OF LOCKSTEP — ksp must be either ` +
82
+ `"${kotlin}" (KSP2 aligned) or "${kotlin}-<kspVersion>" (e.g. "${kotlin}-2.0.4"). ` +
83
+ `Refusing to write a broken pairing.`
80
84
  );
81
85
  }
82
86
  return null;
83
87
  }
84
88
 
89
+ /**
90
+ * Apply a set's `androidSdk` levels (compileSdk / targetSdk) to the text of
91
+ * composeApp/build.gradle.kts. These live in build.gradle.kts, NOT the version
92
+ * catalog — but they are coupled to the set (a dependency built against a newer
93
+ * Android API forces a higher compileSdk, which in turn needs a newer AGP), so
94
+ * the version set manages them too. Surgical, line-preserving replacement of the
95
+ * `compileSdk = N` / `targetSdk = N` assignments.
96
+ * @param {string} content build.gradle.kts text
97
+ * @param {{compileSdk?:number,targetSdk?:number}} androidSdk
98
+ * @returns {{content:string, changes:Array<{key,from,to}>}}
99
+ */
100
+ export function applyAndroidSdk(content, androidSdk) {
101
+ if (!androidSdk || content == null) return { content, changes: [] };
102
+ const changes = [];
103
+ let out = content;
104
+ for (const key of ["compileSdk", "targetSdk"]) {
105
+ if (androidSdk[key] == null) continue;
106
+ const re = new RegExp(`(\\b${key}\\s*=\\s*)(\\d+)`);
107
+ const m = out.match(re);
108
+ if (m && Number(m[2]) !== Number(androidSdk[key])) {
109
+ changes.push({ key, from: m[2], to: String(androidSdk[key]) });
110
+ out = out.replace(re, `$1${androidSdk[key]}`);
111
+ }
112
+ }
113
+ return { content: changes.length ? out : content, changes };
114
+ }
115
+
85
116
  /**
86
117
  * Compute the full upgrade plan for one catalog + optional gradle.properties +
87
- * optional wrapper properties. Pure — no filesystem.
118
+ * optional wrapper properties + optional composeApp/build.gradle.kts. Pure — no filesystem.
88
119
  * @param {object} params
89
120
  * @param {string} params.tomlContent gradle/libs.versions.toml text
90
121
  * @param {string|null} params.gradlePropertiesContent gradle.properties text (null = absent)
@@ -101,7 +132,7 @@ export function lockstepViolation(versions) {
101
132
  * fromOurTemplate: boolean
102
133
  * }}
103
134
  */
104
- export function planUpgrade({ tomlContent, gradlePropertiesContent, wrapperPropertiesContent, set }) {
135
+ export function planUpgrade({ tomlContent, gradlePropertiesContent, wrapperPropertiesContent, buildGradleContent = null, set }) {
105
136
  const projectVersions = parseVersions(tomlContent);
106
137
  const diff = diffAgainstSet(projectVersions, set);
107
138
  const resulting = resultingVersions(projectVersions, diff.changes);
@@ -146,6 +177,17 @@ export function planUpgrade({ tomlContent, gradlePropertiesContent, wrapperPrope
146
177
  }
147
178
  }
148
179
 
180
+ // Android compileSdk / targetSdk (composeApp/build.gradle.kts), when the set pins them.
181
+ let sdkChanges = [];
182
+ let newBuildGradleContent = null;
183
+ if (!lockstepError && set.androidSdk && buildGradleContent !== null) {
184
+ const r = applyAndroidSdk(buildGradleContent, set.androidSdk);
185
+ if (r.changes.length > 0) {
186
+ sdkChanges = r.changes;
187
+ newBuildGradleContent = r.content;
188
+ }
189
+ }
190
+
149
191
  return {
150
192
  diff,
151
193
  lockstepError,
@@ -154,6 +196,8 @@ export function planUpgrade({ tomlContent, gradlePropertiesContent, wrapperPrope
154
196
  newGradlePropertiesContent,
155
197
  wrapperChange,
156
198
  newWrapperPropertiesContent,
199
+ sdkChanges,
200
+ newBuildGradleContent,
157
201
  fromOurTemplate: looksLikeOurTemplate(tomlContent),
158
202
  };
159
203
  }
package/src/scaffold.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  import fs from "node:fs";
16
16
  import os from "node:os";
17
17
  import path from "node:path";
18
- import { fileURLToPath } from "node:url";
18
+ import { fileURLToPath, pathToFileURL } from "node:url";
19
19
 
20
20
  import { validate, formatErrors } from "./lib/schema.mjs";
21
21
  import { buildTokenMap, replaceTokens, replacePathTokens, isBinaryPath, slugifyAppName } from "./lib/tokens.mjs";
@@ -26,6 +26,7 @@ import {
26
26
  deleteDisabledFeaturePaths,
27
27
  } from "./lib/toggle.mjs";
28
28
  import { rewriteTabSurfaces } from "./lib/tabs.mjs";
29
+ import { seedConfigAdrs } from "./lib/adr-seed.mjs";
29
30
  import { copyDir, listFiles, listDirsDeepestFirst } from "./lib/fsutil.mjs";
30
31
  import { runVerify, printVerifyVerdict } from "./lib/verify.mjs";
31
32
  import { colors, step, ok, warn } from "./lib/log.mjs";
@@ -155,6 +156,42 @@ function stripDisabledBlocks(projectDir, disabled) {
155
156
  }
156
157
  }
157
158
 
159
+ // Regenerate docs/ARCHITECTURE.md's `cmp:generated` sections against the tree
160
+ // AS STAMPED — after disabled-feature paths are deleted, tab surfaces are
161
+ // rewritten, the package is renamed, and marker blocks are stripped. The
162
+ // template ships the doc true for the RAW template's shape (all features on);
163
+ // any config that changes the tree (--no-ios removes iosMain and its actuals,
164
+ // custom tabs add PlaceholderScreen.kt, …) would otherwise fail the verify
165
+ // lane's archDoc freshness gate on a fresh, untouched app. Same philosophy as
166
+ // rewriteTabSurfaces: derived surfaces are regenerated from what was actually
167
+ // stamped, never hand-corrected afterward.
168
+ //
169
+ // The generator itself is the project's OWN vendored walker
170
+ // (qa/lib/arch-doc.mjs) — imported from the stamped output so the engine can
171
+ // never drift from what the app's `node qa/arch-doc.mjs --check` will verify
172
+ // later. Tolerant by design: a template without the walker or the doc (the
173
+ // synthetic test templates; a --no-e2e config that strips qa/) skips quietly,
174
+ // and a walker failure warns but never blocks the stamp.
175
+ async function regenerateArchDoc(projectDir) {
176
+ const walkerPath = path.join(projectDir, "qa", "lib", "arch-doc.mjs");
177
+ const docPath = path.join(projectDir, "docs", "ARCHITECTURE.md");
178
+ if (!fs.existsSync(walkerPath) || !fs.existsSync(docPath)) return;
179
+ step("Regenerating docs/ARCHITECTURE.md generated sections for the stamped tree…");
180
+ try {
181
+ const { writeArchDoc } = await import(pathToFileURL(walkerPath).href);
182
+ const result = writeArchDoc(projectDir);
183
+ if (!result.ok) {
184
+ warn(`ARCHITECTURE.md regeneration skipped: ${result.reason}`);
185
+ } else if (result.wrote) {
186
+ process.stdout.write(` arch-doc → updated section(s): ${result.changedSections.join(", ")}\n`);
187
+ } else {
188
+ process.stdout.write(" arch-doc → already fresh for this configuration\n");
189
+ }
190
+ } catch (err) {
191
+ warn(`ARCHITECTURE.md regeneration failed (stamp continues): ${err?.message ?? err}`);
192
+ }
193
+ }
194
+
158
195
  /**
159
196
  * Resolve the Android SDK location from the environment or the conventional
160
197
  * install path, then write `local.properties` (sdk.dir) into the project so
@@ -358,6 +395,20 @@ export async function scaffold(config, opts = {}) {
358
395
  );
359
396
  stripDisabledBlocks(projectDir, disabled);
360
397
 
398
+ // (e.1) Seed one project ADR per configuration decision that deviates from
399
+ // the interview default (--no-room, --no-ios, a non-"both" auth choice —
400
+ // see src/lib/adr-seed.mjs for why this is an engine hook, not a SKILL.md
401
+ // instruction). MUST run before regenerateArchDoc below so the stamped
402
+ // project's own adr-index walker (qa/lib/arch-doc.mjs, which scans
403
+ // docs/adr/*.md) picks the seeded files up in the same pass.
404
+ step("Seeding project ADRs for configuration decisions…");
405
+ const { seeded } = seedConfigAdrs(projectDir, config, (m) => process.stdout.write(`${m}\n`));
406
+ if (seeded.length === 0) process.stdout.write(" no configuration deviated from the interview default — nothing to seed\n");
407
+
408
+ // (e.2) regenerate the architecture doc's derived sections for the tree as
409
+ // stamped — see regenerateArchDoc above.
410
+ await regenerateArchDoc(projectDir);
411
+
361
412
  // Write local.properties (sdk.dir) so the Gradle build can find the Android
362
413
  // SDK even when ANDROID_HOME/ANDROID_SDK_ROOT aren't exported (manifest
363
414
  // stampPipeline step 7). Skip silently if no SDK is found and env vars are
@@ -0,0 +1,4 @@
1
+ {
2
+ "description": "Staging area for CANDIDATE version sets awaiting canary certification. `scripts/promote-set.mjs` applies one candidate to a fresh full-featured scaffold (catalog + gradle.properties + wrapper + composeApp/build.gradle.kts compileSdk/targetSdk), builds it for real (Android assembleDebug + the device-free lane gates + the iOS framework link), and ONLY on all-green promotes it into registry.json as a new proven-green set (the new default `upgrade` target). Red leaves registry.json untouched. Same shape as a registry set: `versions` mirrors the [versions] table, `androidSdk` pins compileSdk/targetSdk, plus `status: candidate` and `baseline`.",
3
+ "candidates": []
4
+ }
@@ -31,12 +31,100 @@
31
31
  "version": "8.11.1",
32
32
  "distributionUrl": "https://services.gradle.org/distributions/gradle-8.11.1-bin.zip"
33
33
  },
34
+ "androidSdk": {
35
+ "compileSdk": 35,
36
+ "targetSdk": 35
37
+ },
34
38
  "notes": [
35
39
  "KSP must be `<kotlin>-<kspVersion>`: kotlin 2.2.20 pairs with ksp 2.2.20-2.0.4. Never bump one without the other — Kotlin / KSP / Compose / Room / AGP move as ONE set or the build dies.",
36
40
  "ksp.useKSP2=true is REQUIRED when Room's KSP processor runs on Kotlin/Native (iOS): KSP1 fails with `ClassNotFoundException: org.jetbrains.kotlin.cli.utilities.MainKt` (the KSP2/iOS catch-22).",
37
41
  "Room 2.8.4 + sqlite-bundled 2.6.2 is the KMP-native pairing; Room < 2.7 has no Kotlin/Native support at all.",
38
42
  "Gradle wrapper 8.11.1 matches AGP 8.7.3 (AGP 8.7 requires Gradle >= 8.9)."
39
43
  ]
44
+ },
45
+ {
46
+ "id": "2026.07c",
47
+ "label": "Conservative bump — coil + kotlinx-serialization to latest; lockstep held at 2026.06 (ktor 3.2.0 excluded: needs DEX 040 / newer AGP R8)",
48
+ "status": "proven-green",
49
+ "versions": {
50
+ "kotlin": "2.2.20",
51
+ "agp": "8.7.3",
52
+ "google-services": "4.4.2",
53
+ "compose-multiplatform": "1.10.3",
54
+ "koin": "4.1.1",
55
+ "ktor": "3.1.0",
56
+ "room": "2.8.4",
57
+ "sqlite": "2.6.2",
58
+ "ksp": "2.2.20-2.0.4",
59
+ "kotlinx-serialization": "1.9.0",
60
+ "kotlinx-datetime": "0.7.0",
61
+ "lifecycle": "2.10.0",
62
+ "navigation": "2.9.2",
63
+ "firebase-gitlive": "2.1.0",
64
+ "coil": "3.2.0",
65
+ "androidx-core": "1.15.0",
66
+ "androidx-activity": "1.10.1"
67
+ },
68
+ "gradleProperties": {
69
+ "ksp.useKSP2": "true"
70
+ },
71
+ "gradleWrapper": {
72
+ "version": "8.11.1",
73
+ "distributionUrl": "https://services.gradle.org/distributions/gradle-8.11.1-bin.zip"
74
+ },
75
+ "androidSdk": {
76
+ "compileSdk": 35,
77
+ "targetSdk": 35
78
+ },
79
+ "notes": [
80
+ "Delta vs 2026.06: coil 3.1.0→3.2.0, kotlinx-serialization 1.7.3→1.9.0. Everything else, including the entire Kotlin/KSP/Compose/Room/AGP lockstep, is held identical — the conservative-first strategy: prove the canary + upgrade loop before a risky compiler-version jump.",
81
+ "ktor 3.2.0 was EXCLUDED after the canary caught it: ktor-client-core-jvm 3.2.0 carries a backtick identifier with a space (`use streaming syntax`) that requires DEX version 040, which AGP 8.7.3's bundled R8 (8.7.18) rejects at :composeApp:mergeExtDexDebug. Bumping ktor needs a newer AGP/R8 — deferred to a bold set.",
82
+ "Bumps verified latest via Maven Central 2026-07-15.",
83
+ "Promoted by promote-set.mjs 2026-07-15: green on :composeApp:assembleDebug, :composeApp:desktopTest, :composeApp:linkDebugFrameworkIosSimulatorArm64 (456s). Evidence: qa-artifacts/canary/2026.07c.json"
84
+ ]
85
+ },
86
+ {
87
+ "id": "2026.07r",
88
+ "label": "July 2026 recommended standard — Kotlin 2.3.10 / KSP 2.3.10 / Compose 1.11.1 / AGP 8.13.2 / Gradle 8.14.3, compileSdk 36. ktor held at 3.1.0 (3.2.0 not dexable at minSdk 24) and androidx UI libs held at SDK-36-safe versions.",
89
+ "status": "proven-green",
90
+ "versions": {
91
+ "kotlin": "2.3.10",
92
+ "agp": "8.13.2",
93
+ "google-services": "4.5.0",
94
+ "compose-multiplatform": "1.11.1",
95
+ "koin": "4.2.2",
96
+ "ktor": "3.1.0",
97
+ "room": "2.8.4",
98
+ "sqlite": "2.7.0",
99
+ "ksp": "2.3.10",
100
+ "kotlinx-serialization": "1.9.0",
101
+ "kotlinx-datetime": "0.7.0",
102
+ "lifecycle": "2.10.0",
103
+ "navigation": "2.9.2",
104
+ "firebase-gitlive": "2.4.0",
105
+ "coil": "3.2.0",
106
+ "androidx-core": "1.15.0",
107
+ "androidx-activity": "1.10.1"
108
+ },
109
+ "gradleProperties": {
110
+ "ksp.useKSP2": "true"
111
+ },
112
+ "gradleWrapper": {
113
+ "version": "8.14.3",
114
+ "distributionUrl": "https://services.gradle.org/distributions/gradle-8.14.3-bin.zip"
115
+ },
116
+ "androidSdk": {
117
+ "compileSdk": 36,
118
+ "targetSdk": 35
119
+ },
120
+ "notes": [
121
+ "Sourced, mutually-compatible July 2026 set (verified 2026-07-15): Kotlin 2.3.10 is the newest Kotlin with a matching KSP (KSP tops out at 2.3.10; no KSP for Kotlin 2.4.10). KSP2 aligned scheme (ksp == kotlin). Compose Multiplatform 1.11.1 (latest stable) is compatible with Kotlin 2.3.10.",
122
+ "AGP 8.13.2 is the top of the Kotlin-2.3-supported AGP range (per developer.android.com/build/kotlin-support). Gradle 8.14.3 satisfies AGP 8.13's minimum. compileSdk moves to 36 (Android 16, the latest STABLE API) — now a managed field applied to composeApp/build.gradle.kts.",
123
+ "ktor HELD at 3.1.0: even AGP 8.13.2's newer R8 (8.13.19) cannot dex ktor-client-core 3.2.0 — its backtick identifier `use streaming syntax` needs DEX version 040, which D8 only emits at a much higher minSdk than 24. Not an R8-version issue; ktor 3.2.0 is not adoptable at a normal minSdk yet.",
124
+ "HELD ON PURPOSE: androidx-core (1.15.0) and lifecycle (2.10.0) are kept at their SDK-36-safe versions. Their latest (core 1.19.0, lifecycle 2.11.0) require compileSdk 37 — an unreleased-stable API — so 'latest-of-each' is NOT the recommended-stable choice; revisit when a compileSdk-37 set is warranted. targetSdk held at 35 for runtime-behavior stability.",
125
+ "Verified by the canary: Kotlin 2.3.10 + Compose 1.11.1 + firebase-gitlive 2.4.0 compile the template source and dex cleanly at compileSdk 36.",
126
+ "Promoted by promote-set.mjs 2026-07-15: green on :composeApp:assembleDebug, :composeApp:desktopTest, :composeApp:linkDebugFrameworkIosSimulatorArm64 (425s). Evidence: qa-artifacts/canary/2026.07r.json"
127
+ ]
40
128
  }
41
129
  ]
42
130
  }
@@ -3,7 +3,8 @@ name: add-feature
3
3
  description: >-
4
4
  Add a new conforming vertical-slice feature (Screen + ViewModel + UseCase + Repository + spec
5
5
  + tests + golden tree + nav route + DI wiring) to this Compose Multiplatform app, cloned
6
- deterministically from the `home` exemplar. Use this when the user wants to "add a feature",
6
+ deterministically from the project's configured exemplar feature (qa/approvals.json's
7
+ exemplarFeature — `home` by default). Use this when the user wants to "add a feature",
7
8
  "add a screen with data", "scaffold a feature", "create a new screen backed by a repository",
8
9
  "add a list screen", or names a new domain noun they want a screen for (e.g. "add a Favorites
9
10
  feature", "I need a Bookmarks screen"). Works with NO create-cmp plugin installed — the
@@ -13,11 +14,28 @@ description: >-
13
14
  # add-feature — stamp a conforming vertical slice
14
15
 
15
16
  > Spec-first, deterministic-stamp, gate-proven. The script (`qa/scaffold-feature.mjs`) does the
16
- > mechanical work — copy the `home` exemplar file set, whole-word identifier rename, anchor
17
+ > mechanical work — copy the exemplar file set, whole-word identifier rename, anchor
17
18
  > injection into the three shared files. You (the AI) only refine spec wording and adapt the
18
19
  > feature to its real shape. You are not done until `node qa/verify.mjs` PASSes and the receipt
19
20
  > is committed — see this project's `CLAUDE.md`.
20
21
 
22
+ ## The clone source is configurable
23
+
24
+ The stamper clones from the project's **configured exemplar** — `qa/approvals.json`'s
25
+ top-level `"exemplarFeature"` key (absent ⇒ `home`, the shipped exemplar). This is the same
26
+ resolution the approvals registry uses for the governed `exemplar-feature` artifact, so what
27
+ gets stamped is always exactly what the human signed off on. After the genesis walk retargets
28
+ `exemplarFeature` to the user's own first feature, every stamp from then on clones *their*
29
+ pattern in *their* domain language — do not assume `home` still exists as the exemplar; read
30
+ the config (or just run the stamper: it resolves the source itself).
31
+
32
+ If the configured exemplar has grown files beyond the canonical 11-file shape (an extra
33
+ ViewModel, a helper, a second use case named for its entity), the stamper clones **only the
34
+ canonical set** and prints a `WARNING:` listing exactly what it skipped — never silently.
35
+ When you see that warning, tell the human: the extras are part of the exemplar's pattern in
36
+ spirit but not in mechanism, and porting them into the new feature (or slimming the exemplar
37
+ back to canon) is a deliberate follow-up, not something to ignore.
38
+
21
39
  ## Why a stamper and not hand-written files
22
40
 
23
41
  This project's whole thesis is that determinism beats freehand generation for anything
@@ -58,9 +76,9 @@ node qa/scaffold-feature.mjs <FeatureName> --entity <EntityName>
58
76
 
59
77
  This writes the new Screen/ViewModel/UseCase/Repository(+impl)/tests/fake, wires them into
60
78
  `di/AppModule.kt`, `presentation/navigation/Screen.kt`, and `presentation/navigation/AppNavHost.kt`
61
- at their `// cmp:anchor` markers, and writes `specs/<feature>.spec.md` with a default six-clause
62
- set (`<FEATURE>-01..06`: loading, success, error, reload-after-failure, tap-navigates, golden
63
- tree) — copied verbatim from the `home` exemplar's shape.
79
+ at their `// cmp:anchor` markers, and writes `specs/<feature>.spec.md` with a default seven-clause
80
+ set (`<FEATURE>-01..07`: loading, success, error, reload-after-failure, tap-navigates, golden
81
+ tree, empty state) — copied verbatim from the configured exemplar's shape.
64
82
 
65
83
  The stamped screen arrives **already wrapped in `BaseScreen { … }`** (SHELL-05): it is a
66
84
  pushed NavHost destination, so unlike the tab exemplar it must handle its own insets — the
@@ -73,9 +91,9 @@ flagging, not something to route around by hand-splicing.
73
91
 
74
92
  ### 4. Refine the spec, then the behavior
75
93
 
76
- The default spec clauses are placeholders shaped like the `home` exemplar (a plain list of
94
+ The default spec clauses are placeholders shaped like the exemplar (by default a plain list of
77
95
  title/subtitle rows). **Rewrite the clause prose** in `specs/<feature>.spec.md` to describe the
78
- feature's real behavior — the six clause **ids stay fixed** (`specCoverage` binds tests to ids,
96
+ feature's real behavior — the seven clause **ids stay fixed** (`specCoverage` binds tests to ids,
79
97
  not prose), only the wording changes. Propose the rewritten clauses to the human; get them
80
98
  confirmed before moving on — this project's contract is spec-first.
81
99
 
@@ -84,7 +102,12 @@ Then adapt the generated code to match:
84
102
  - If the feature isn't shaped like "a list of `{id, title, subtitle}`", update the entity's
85
103
  fields in `domain/model/<Entity>.kt`, the sample data in `<Entity>RepositoryImpl.kt`, and the
86
104
  screen's rendering in `presentation/<feature>/<Feature>Screen.kt` together — keep them
87
- consistent with each other and with the tests.
105
+ consistent with each other and with the tests. The stamped screen already **composes the
106
+ registry vocabulary** (`ScreenColumn`/`AppHeader`/`ContentStateContainer`/`ListItemCard`,
107
+ `presentation/components/*.kt`) — adapt the content shape inside `ContentStateContainer`'s
108
+ trailing slot, don't hand-roll a new header/loading state/list row on top of it. If the
109
+ feature's data genuinely needs a component the nine don't cover, propose the addition to the
110
+ human explicitly (a new file is a registry change — it invalidates the `components` approval).
88
111
  - Update the copied tests (`<Feature>ViewModelTest.kt`, `<Feature>ScreenTest.kt`) to match
89
112
  whatever you changed. The gate (step 6) will tell you exactly what you missed — a compile
90
113
  error names the mismatch; a spec-coverage failure names an orphaned clause or tag.
@@ -113,10 +136,12 @@ copy-paste artifact. Commit it alongside the feature.
113
136
  node qa/verify.mjs
114
137
  ```
115
138
 
116
- This must PASS. It proves: the spec's six clauses are all bound to a citing test
139
+ This must PASS. It proves: the spec's seven clauses are all bound to a citing test
117
140
  (`specCoverage`), the build compiles, unit tests pass (ViewModel + UseCase + Repository +
118
141
  fakes), architecture conformance holds (`presentation` doesn't import `data`, the new
119
- `*Screen.kt` carries a `testTag`, the new `*ViewModel.kt` has a matching test), the golden tree
142
+ `*Screen.kt` is automation-reachable — a literal `testTag` or `screenTag =` wiring into a
143
+ registry component — the new `*ViewModel.kt` has a matching test, and it references no
144
+ `CircularProgressIndicator`/`LinearProgressIndicator` directly), the golden tree
120
145
  matches what you just captured, and accessibility holds. **Not done until this is PASS and the
121
146
  evidence receipt (`qa/evidence/latest.json`) is committed with your change** — this is this
122
147
  project's standing definition of done (see `CLAUDE.md`).
@@ -76,7 +76,7 @@ the real entity:
76
76
 
77
77
  **No spec clauses are added by this preset, and that's intentional** — a bare repository has no
78
78
  observable behavior to specify yet (no screen, no user-facing flow). Clauses attach once a
79
- screen consumes this entity; that's `add-screen`'s job (`FEATURE-01..06` bound to the repository
79
+ screen consumes this entity; that's `add-screen`'s job (`FEATURE-01..07` bound to the repository
80
80
  you just built).
81
81
 
82
82
  ### 5. Gate
@@ -29,7 +29,7 @@ to stamp anything — see step 2.
29
29
 
30
30
  Every hand-written file is a drift chance in this project's architecture. `qa/scaffold-feature.mjs
31
31
  --preset screen` produces a **conforming skeleton by construction**: a Screen composable with a
32
- testTagged root, a ViewModel (+ test), a Compose UI test, a golden-tree test, and a six-clause
32
+ testTagged root, a ViewModel (+ test), a Compose UI test, a golden-tree test, and a seven-clause
33
33
  spec — wired into Koin and the nav graph. Your job is to make it *behave* like the real screen,
34
34
  not to make it *structurally correct*.
35
35
 
@@ -72,7 +72,7 @@ node qa/scaffold-feature.mjs <FeatureName> --entity <EntityName> --preset screen
72
72
  This writes the Screen/ViewModel/tests/golden-tree-test, wires the ViewModel into
73
73
  `di/AppModule.kt` and a route into `presentation/navigation/Screen.kt` +
74
74
  `presentation/navigation/AppNavHost.kt` at their `// cmp:anchor` markers, and writes
75
- `specs/<feature>.spec.md` with a default six-clause set (`<FEATURE>-01..06`) cited by the three
75
+ `specs/<feature>.spec.md` with a default seven-clause set (`<FEATURE>-01..07`) cited by the three
76
76
  generated test files.
77
77
 
78
78
  If it exits non-zero, read the message — it is actionable. Do not hand-edit around a stamper
@@ -88,7 +88,11 @@ Then adapt the generated code to match the entity's actual shape (which may diff
88
88
  `home` exemplar's `{id, title, subtitle}` list): update the screen's rendering in
89
89
  `presentation/<feature>/<Feature>Screen.kt` and the copied tests
90
90
  (`<Feature>ViewModelTest.kt`, `<Feature>ScreenTest.kt`) together, consistent with whatever
91
- `<Entity>.kt` already looks like. The gate (step 6) will name exactly what you missed.
91
+ `<Entity>.kt` already looks like. The stamped screen already **composes the registry
92
+ vocabulary** (`ScreenColumn`/`AppHeader`/`ContentStateContainer`/`ListItemCard`,
93
+ `presentation/components/*.kt`) — adapt the content shape inside `ContentStateContainer`'s
94
+ trailing slot rather than hand-rolling a new header/loading state/list row. The gate (step 6)
95
+ will name exactly what you missed.
92
96
 
93
97
  ### 5. Capture the golden tree
94
98
 
@@ -109,10 +113,12 @@ node qa/verify.mjs
109
113
  ```
110
114
 
111
115
  This must PASS. It proves: the spec's six clauses are all bound to a citing test
112
- (specCoverage — `<FEATURE>-01..06` newly bound), the build compiles, unit tests pass, architecture
113
- conformance holds, the golden tree matches, and accessibility holds. **Not done until this is
114
- PASS and the evidence receipt (`qa/evidence/latest.json`) is committed with your change** — this
115
- project's standing definition of done (see `CLAUDE.md`).
116
+ (specCoverage — `<FEATURE>-01..07` newly bound), the build compiles, unit tests pass, architecture
117
+ conformance holds (the screen is automation-reachable via a literal `testTag` or `screenTag =`
118
+ wiring into a registry component, and references no `CircularProgressIndicator`/
119
+ `LinearProgressIndicator` directly), the golden tree matches, and accessibility holds. **Not done
120
+ until this is PASS and the evidence receipt (`qa/evidence/latest.json`) is committed with your
121
+ change** — this project's standing definition of done (see `CLAUDE.md`).
116
122
 
117
123
  If it fails: read the failing step's reason, fix the actual behavior or spec/test binding, and
118
124
  re-run. Do not delete or weaken a test to reach green.
@@ -0,0 +1,24 @@
1
+ #!/bin/sh
2
+ # create-cmp pre-push gate — the human checkpoint before code leaves the machine.
3
+ #
4
+ # The committed evidence receipt (qa/evidence/latest.json) must attest HEAD.
5
+ # This is the SAME check CI runs (qa/receipt-check.mjs), moved earlier so an
6
+ # unverified push is caught locally. It is the CHEAP predicate — a hash
7
+ # comparison — not the full lane, so it never rebuilds anything.
8
+ #
9
+ # Enable once with `node qa/setup-hooks.mjs`. Bypass in a pinch with
10
+ # `git push --no-verify`; CI still enforces the same check.
11
+
12
+ if [ ! -f qa/receipt-check.mjs ]; then
13
+ exit 0 # not a create-cmp project layout — nothing to gate
14
+ fi
15
+
16
+ if node qa/receipt-check.mjs; then
17
+ exit 0
18
+ fi
19
+
20
+ echo ""
21
+ echo "✗ pre-push blocked — the committed receipt does not attest HEAD."
22
+ echo " Run: node qa/verify.mjs then commit qa/evidence/latest.json, and push again."
23
+ echo " Bypass this local gate with: git push --no-verify (CI still checks)."
24
+ exit 1