create-cmp-cli 0.4.0 → 0.6.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.
package/README.md CHANGED
@@ -25,7 +25,8 @@ npx create-cmp-cli@latest my-app --name Acme --package com.acme.app --yes --veri
25
25
  Deterministic (stamps a frozen, CI-verified template), fully non-interactive with flags, and
26
26
  exits non-zero on failure. Every generated project ships its own verify lane — `node qa/verify.mjs`,
27
27
  8 gates, evidence receipts — with nothing installed. Agent-readable: [llms.txt](./llms.txt) ·
28
- [options.schema.json](./options.schema.json).
28
+ [options.schema.json](./options.schema.json). Also answers to `npm create compose-multiplatform`
29
+ and `npm create kmp` — official aliases ([packages/aliases](packages/aliases)) that delegate here.
29
30
 
30
31
  ## What is this, in plain words
31
32
 
@@ -100,7 +101,7 @@ Everything except `create` works on **any** KMP project, not just ones this tool
100
101
  Hit a KMP build error? [Common CMP/KMP build errors and fixes](docs/errors/README.md) — kotlin↔KSP
101
102
  mismatch, the KSP2/iOS catch-22, `SDK location not found`, `No space left on device`, version drift.
102
103
 
103
- ## The Claude Code plugin (8 skills)
104
+ ## The Claude Code plugin (9 skills)
104
105
 
105
106
  ```text
106
107
  /plugin marketplace add kvdm-co-pilot/create-cmp
@@ -114,13 +115,14 @@ Same engine as the CLI, conversational front door. Each skill is a guided flow,
114
115
  | `cmp-new` | "Make me an app." Interviews you in chat, scaffolds via the engine, generates your bottom-nav tab screens from the exemplar pattern, proves the build green. |
115
116
  | `cmp-doctor` | "Why won't my KMP project build?" Runs the doctor, explains the findings, applies consented fixes. |
116
117
  | `cmp-upgrade` | "Bump my dependencies safely." Diff → apply → verify, with the lockstep guardrails. |
118
+ | `cmp-preview` | "Show me my screens." Live gallery of every screen at a local URL — real DI/theme/data, no device, no emulator, no manual Gradle. Edit → save → the page re-renders itself; changed screens get flagged; a11y violations show per screen. |
117
119
  | `cmp-inspect` | "What did the UI actually render?" Reads a **running** app as structured JSON — hierarchy, geometry, resolved design tokens, navigation state. Never screenshots. Can assert tokens, find drift against your design system, audit accessibility, diff before/after. |
118
120
  | `cmp-dev-client` | "Let me iterate fast." Runs your shared UI in a phone-sized desktop window with hot reload — save a file, see it change. No emulator needed. Firebase stays off on desktop (offline fakes). |
119
121
  | `cmp-firebase-connect` | "Wire up my real Firebase." Drives the Firebase CLI: create/reuse a project, register the app, drop the real `google-services.json` over the placeholder, prove it with a green build. Every cloud action asks first. |
120
122
  | `cmp-test` | "Write tests for my app." *Observes* the running app's semantics tree — what's actually on screen, what's tappable, where navigation goes — and derives the regression suite from that. Tests come from rendered reality, not guesses. |
121
123
  | `cmp-qa-prep` | "Get my test environment up." Emulator + app install + E2E smoke run, with the gotchas handled. |
122
124
 
123
- Plus the **`cmp-inspector` MCP server** (14 tools) — the machine-readable window into a running
125
+ Plus the **`cmp-inspector` MCP server** (18 tools) — the machine-readable window into a running
124
126
  Compose UI that `cmp-inspect`, `cmp-test`, and the verified dev loop are built on. One tree
125
127
  contract, three sources: render a screen headlessly, connect to the live app, or read a device
126
128
  via UIAutomator.
@@ -181,13 +183,21 @@ stamper (`qa/scaffold-feature.mjs`):
181
183
  Any plain Claude Code session — no create-cmp plugin installed — finds these and extends the app
182
184
  correctly by construction.
183
185
 
184
- ### 6. The live inspector — AI-readable UI
185
- Every debug build serves `127.0.0.1:9500` (loopback-only, structurally absent from release): the
186
- UI tree as JSON, the design-token catalog, a screenshot route, a tap route, and a live device
187
- view for humans (`/inspect/remote` — watch the real device in a browser, click to tap). Agents
188
- read structure; humans see pixels.
186
+ ### 6. The inspector — AI-readable UI, previews without a device
187
+ Two loops, one contract. **Live (tier 1):** every debug build serves `127.0.0.1:9500`
188
+ (loopback-only, structurally absent from release): the UI tree as JSON, the design-token catalog,
189
+ a screenshot route, a tap route, and a live device view for humans (`/inspect/remote` — watch the
190
+ real device in a browser, click to tap). **Headless previews (tier 0):** every app ships
191
+ `inspector/PreviewRegistry.kt` (the `@Preview` analog — shell, every tab, detail) and a
192
+ `:composeApp:renderScreens` task that renders each screen with real DI/theme/data to
193
+ `screen.png` + its contract `tree.json` — no device, no emulator; `node qa/preview-gallery.mjs`
194
+ turns the output into one self-contained `index.html` (pixels + wireframe + a11y per screen).
195
+ Agents read structure; humans see pixels.
189
196
 
190
197
  ### 7. The daily-driver extras
198
+ - **Live preview loop** — every real screen rendered headlessly on save (resident hot-reload
199
+ daemon, ~1s warm renders); a self-updating gallery for the human, changed-screen attribution
200
+ and compile-error surfacing for the agent. The agent sees what it builds.
191
201
  - **Desktop dev-client** — shared UI in a phone-sized JVM window, Compose Hot Reload attached.
192
202
  - **CI workflow** — Android job on every push; iOS job ready to un-comment.
193
203
  - **`CLAUDE.md`** — the AI delivery contract itself, stating everything above as rules any AI
@@ -200,8 +210,15 @@ read structure; humans see pixels.
200
210
  **New app → green.** `cmp-new` (or `npx create-cmp-cli`) → interview → stamp → green build proven
201
211
  → tab screens generated. Then `cmp-firebase-connect` to wire your real backend.
202
212
 
203
- **The daily UI loop.** `./gradlew :composeApp:hotRunDesktop --auto` edit Compose savesee
204
- it. No emulator, no Firebase, sub-second feedback.
213
+ **The daily UI loop.** Say "preview my app" (the cmp-preview skill / `preview` MCP tool) a
214
+ live local gallery of EVERY real screen that re-renders on save — no device, no emulator, no
215
+ manual Gradle. The agent runs the same loop to check its own work while it builds: edit →
216
+ `preview_status { waitForRender: true }` → which screens changed (or the compile error, or the
217
+ failed hot swap) → `preview_diff { screen }` for a proven verdict — feedback in seconds instead
218
+ of a 25–40s build or an emulator round-trip. One interactive window instead of stills:
219
+ `./gradlew :composeApp:hotRunDesktop --auto`. Command-line fallback (no plugin needed — the
220
+ scaffolded app carries the whole loop):
221
+ `./gradlew :composeApp:renderScreens && node qa/preview-gallery.mjs`.
205
222
 
206
223
  **The verified dev loop (the flagship).** For any UI change: snapshot the live tree → make the
207
224
  edit → reload → `prove_change` compares before/after structure, token drift, and a11y, and returns
package/llms.txt CHANGED
@@ -12,7 +12,24 @@ npx create-cmp-cli@latest my-app --name Acme --package com.acme.app --yes --veri
12
12
 
13
13
  Other flags: `--bundle-id`, `--region`, `--theme-prefix`, `--ios/--no-ios`, `--firebase/--no-firebase`, `--auth <email|phone|both|none>`, `--room/--no-room`, `--e2e/--no-e2e`, `--inspector/--no-inspector`, `--dev-client/--no-dev-client`, `--tabs Home:home,Profile:person`, `--target-dir`, `--force`. Subcommands `doctor`, `upgrade`, `clean`, and `verify` work on any KMP project.
14
14
 
15
- create-cmp is also invokable as a Claude Code plugin (`/plugin marketplace add kvdm-co-pilot/create-cmp`, then `/plugin install create-cmp`) with eight skills and the `cmp-inspector` MCP server. Generated projects self-verify without the plugin installed: `node qa/verify.mjs` runs 8 gates (spec coverage, build, unit tests, conformance, golden trees, token drift, a11y, on-device E2E) and writes a content-hash-bound evidence receipt; a Stop hook and CI both refuse "done" without a fresh PASS receipt.
15
+ Official alias packages (same tool, same flags, same maintainer): `npm create compose-multiplatform@latest my-app` and `npm create kmp@latest my-app` both delegate to create-cmp-cli.
16
+
17
+ create-cmp is also invokable as a Claude Code plugin (`/plugin marketplace add kvdm-co-pilot/create-cmp`, then `/plugin install create-cmp`) with nine skills and the `cmp-inspector` MCP server (18 tools). Generated projects self-verify without the plugin installed: `node qa/verify.mjs` runs 8 gates (spec coverage, build, unit tests, conformance, golden trees, token drift, a11y, on-device E2E) and writes a content-hash-bound evidence receipt; a Stop hook and CI both refuse "done" without a fresh PASS receipt.
18
+
19
+ ## The UI feedback loop (for agents building UI)
20
+
21
+ Every generated app carries a device-free preview loop — no emulator, no manual Gradle. An
22
+ agent editing ANY screen should use it to see and verify its own work: with the plugin,
23
+ `preview { projectDir }` starts a resident service (live self-updating gallery URL for the
24
+ human), then after each edit `preview_status { waitForRender: true }` blocks until the
25
+ outcome — `changedLastRender` names the screens the edit touched (empty = it reached no
26
+ screen), `lastErrorSource: "compile"` means it didn't build — and `preview_diff { screen }`
27
+ returns a proven verdict (`proven-clean` / `changed-with-regressions` / `no-change`).
28
+ Warm renders take ~1s per screen (resident hot-reload daemon). Without the plugin:
29
+ `./gradlew :composeApp:renderScreens && node qa/preview-gallery.mjs`. Screens are registered
30
+ in `inspector/PreviewRegistry.kt`; the generated `CLAUDE.md`/`AGENTS.md` document the loop
31
+ in-repo ("UI feedback loop" section). Pixels flow to the human, structure (tree JSON) flows
32
+ to the agent — never read PNG bytes into model context.
16
33
 
17
34
  ## Docs
18
35
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-cmp-cli",
3
- "version": "0.4.0",
4
- "description": "The AI delivery harness for Kotlin/Compose Multiplatform — a deterministic, non-interactive project generator that scaffolds a green-building app (Android + iOS) in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Installs the `create-cmp` command.",
3
+ "version": "0.6.0",
4
+ "description": "The AI delivery harness for Kotlin/Compose Multiplatform — a deterministic, non-interactive project generator that scaffolds a green-building app (Android + iOS) in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Every app carries a device-free UI preview loop (real screens rendered headlessly on save; changed-screen attribution and compile-error surfacing for coding agents, a live gallery for humans) plus agent-first docs (CLAUDE.md + AGENTS.md). Installs the `create-cmp` command.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-cmp": "bin/create-cmp.mjs",
@@ -41,7 +41,12 @@
41
41
  "android",
42
42
  "ios",
43
43
  "verify",
44
- "e2e"
44
+ "e2e",
45
+ "preview",
46
+ "storybook",
47
+ "agent feedback loop",
48
+ "agents.md",
49
+ "claude"
45
50
  ],
46
51
  "license": "MIT",
47
52
  "author": "Karel van der Merwe",
@@ -71,7 +71,11 @@ export const checks = [
71
71
  },
72
72
  {
73
73
  id: "jdk",
74
- label: "JDK 17 (Temurin)",
74
+ // Label states the actual requirement (17+), and detect() reports the
75
+ // resolved major — previously the row read "JDK 17 (Temurin)" while
76
+ // happily accepting JDK 21, a label/evidence contradiction that erodes
77
+ // trust in every other row (field-report finding 2.6).
78
+ label: "JDK (17+ required)",
75
79
  platforms: ["darwin", "linux"],
76
80
  detect() {
77
81
  const r = probe("javac", ["-version"]);
@@ -80,7 +84,10 @@ export const checks = [
80
84
  const m = out.match(/(\d+)(\.\d+)?/);
81
85
  const major = m ? parseInt(m[1], 10) : 0;
82
86
  if (!out) return { present: false, detail: "not found" };
83
- return { present: major >= 17, detail: out.split("\n")[0] };
87
+ return {
88
+ present: major >= 17,
89
+ detail: `resolved major ${major} — ${out.split("\n")[0]}`,
90
+ };
84
91
  },
85
92
  installCommand: () =>
86
93
  isMac ? "brew install --cask temurin@17" : "sdk install java 17.0.13-tem # (sdkman)",
package/src/lib/tabs.mjs CHANGED
@@ -317,6 +317,109 @@ fun PlaceholderScreen(title: String, titleTag: String) {
317
317
  `;
318
318
  }
319
319
 
320
+ /** Preview content lambda for one tab in the registry's appTabs(...) call — no-op navigation. */
321
+ function previewTabArg(tab) {
322
+ if (tab.slug === "home") {
323
+ return ` ${tab.param} = { HomeScreen(onItemClick = {}) },`;
324
+ }
325
+ if (tab.slug === "profile") {
326
+ return ` ${tab.param} = { ProfileScreen() },`;
327
+ }
328
+ return ` ${tab.param} = { PlaceholderScreen(title = "${kotlinString(tab.label)}", titleTag = "${tab.slug}_title") },`;
329
+ }
330
+
331
+ /** Per-tab ScreenPreview entry (single tab hosted in TabHost, as AppShell hosts it). */
332
+ function previewEntry(tab) {
333
+ if (tab.slug === "home") {
334
+ return ` ScreenPreview("home", "${kotlinString(tab.label)} tab") { TabHost { HomeScreen(onItemClick = {}) } },`;
335
+ }
336
+ if (tab.slug === "profile") {
337
+ return ` ScreenPreview("profile", "${kotlinString(tab.label)} tab") { TabHost { ProfileScreen() } },`;
338
+ }
339
+ return ` ScreenPreview("${tab.slug}", "${kotlinString(tab.label)} tab") { TabHost { PlaceholderScreen(title = "${kotlinString(tab.label)}", titleTag = "${tab.slug}_title") } },`;
340
+ }
341
+
342
+ /**
343
+ * Render inspector/PreviewRegistry.kt (template-token form) — the `@Preview` analog the
344
+ * renderScreens harness enumerates: one shell entry wiring appTabs(...) like AppNavHost,
345
+ * one entry per tab, plus the shipped Detail destination. For the default tabs this
346
+ * reproduces the static template file byte-for-byte.
347
+ * @param {ReturnType<typeof tabInfos>} infos
348
+ */
349
+ export function renderPreviewRegistryKt(infos) {
350
+ const imports = [
351
+ "import androidx.compose.foundation.layout.Box",
352
+ "import androidx.compose.foundation.layout.fillMaxSize",
353
+ "import androidx.compose.runtime.Composable",
354
+ "import androidx.compose.ui.Modifier",
355
+ "import __PACKAGE__.presentation.components.BaseScreen",
356
+ ];
357
+ if (infos.some((t) => t.slug !== "home" && t.slug !== "profile")) {
358
+ imports.push("import __PACKAGE__.presentation.components.PlaceholderScreen");
359
+ }
360
+ imports.push("import __PACKAGE__.presentation.home.DetailScreen");
361
+ if (infos.some((t) => t.slug === "home")) {
362
+ imports.push("import __PACKAGE__.presentation.home.HomeScreen");
363
+ }
364
+ imports.push("import __PACKAGE__.presentation.navigation.AppShell");
365
+ imports.push("import __PACKAGE__.presentation.navigation.appTabs");
366
+ if (infos.some((t) => t.slug === "profile")) {
367
+ imports.push("import __PACKAGE__.presentation.profile.ProfileScreen");
368
+ }
369
+
370
+ return `package __PACKAGE__.inspector
371
+
372
+ ${imports.join("\n")}
373
+
374
+ /**
375
+ * One previewable screen: a stable [id] (the \`-Pscreen=\` selector and output directory
376
+ * name), a human [title] for the gallery, and the composable [content] exactly as the
377
+ * app hosts it.
378
+ *
379
+ * The \`@Preview\` analog for the create-cmp inspector: the registry makes "render screen
380
+ * X" a closed, enumerable operation. The scaffolder regenerates the tab entries from the
381
+ * configured \`tabs\`; when you add a screen by hand, add it here — the renderScreens
382
+ * harness, the gallery, and golden baselines pick it up by id.
383
+ *
384
+ * State variants (the Storybook "story" analog): a screen in a specific UI state is just
385
+ * another entry with a derived id — e.g. \`ScreenPreview("home@empty", "Home — empty")\`
386
+ * hosting the screen with that state forced (a state-first overload of the screen, or
387
+ * preview-only fakes behind its usual parameters). Every entry renders the same way
388
+ * (gallery card, \`-Pscreen=\` selector, golden baseline), so loading/empty/error states
389
+ * sit side by side with the default seeded state.
390
+ */
391
+ data class ScreenPreview(
392
+ val id: String,
393
+ val title: String,
394
+ val content: @Composable () -> Unit,
395
+ )
396
+
397
+ /** Every registered screen, in gallery order. Ids must be unique and filesystem-safe. */
398
+ fun previewRegistry(): List<ScreenPreview> = listOf(
399
+ ScreenPreview("shell", "App shell — bottom nav (first tab selected)") {
400
+ AppShell(
401
+ tabs = appTabs(
402
+ ${infos.map(previewTabArg).join("\n")}
403
+ ),
404
+ )
405
+ },
406
+ ${infos.map(previewEntry).join("\n")}
407
+ ScreenPreview("detail", "Detail (nav destination)") { DetailScreen(itemId = "1", onBack = {}) },
408
+ )
409
+
410
+ /**
411
+ * Hosts a single tab's content the way [AppShell] does — inside [BaseScreen] — minus the
412
+ * bottom bar, so a tab previews with the same insets/background it gets in the shell.
413
+ */
414
+ @Composable
415
+ private fun TabHost(content: @Composable () -> Unit) {
416
+ BaseScreen {
417
+ Box(Modifier.fillMaxSize()) { content() }
418
+ }
419
+ }
420
+ `;
421
+ }
422
+
320
423
  // --- pipeline entry --------------------------------------------------------------
321
424
 
322
425
  const APPTAB_REL =
@@ -325,6 +428,8 @@ const NAVHOST_REL =
325
428
  "composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt";
326
429
  const PLACEHOLDER_REL =
327
430
  "composeApp/src/commonMain/kotlin/com/example/app/presentation/components/PlaceholderScreen.kt";
431
+ const PREVIEW_REGISTRY_REL =
432
+ "composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt";
328
433
  const SMOKE_REL = "qa/e2e/smoke.yaml";
329
434
 
330
435
  /**
@@ -362,6 +467,14 @@ export function rewriteTabSurfaces(projectDir, tabs, log = () => {}) {
362
467
  }
363
468
  }
364
469
 
470
+ // Present only when the inspector feature is on (the harness's desktopMain dir);
471
+ // absent → skipped, same contract as the other feature-stripped surfaces.
472
+ const previewRegistryPath = path.join(projectDir, PREVIEW_REGISTRY_REL);
473
+ if (fs.existsSync(previewRegistryPath)) {
474
+ fs.writeFileSync(previewRegistryPath, renderPreviewRegistryKt(infos));
475
+ log(` tabs → ${PREVIEW_REGISTRY_REL}`);
476
+ }
477
+
365
478
  const smokePath = path.join(projectDir, SMOKE_REL);
366
479
  if (fs.existsSync(smokePath)) {
367
480
  fs.writeFileSync(smokePath, renderSmokeYaml(infos));
@@ -66,8 +66,15 @@ export async function runVerify({ projectDir, manifest, config, dryRun = false }
66
66
  results.push({ platform: item.platform, command: item.command, code: 0, ran: false });
67
67
  continue;
68
68
  }
69
+ const startedAt = Date.now();
69
70
  const { code } = await runCommand(item.command, projectDir);
70
- results.push({ platform: item.platform, command: item.command, code, ran: true });
71
+ results.push({
72
+ platform: item.platform,
73
+ command: item.command,
74
+ code,
75
+ ran: true,
76
+ durationMs: Date.now() - startedAt,
77
+ });
71
78
  if (code !== 0) green = false;
72
79
  }
73
80
 
@@ -94,4 +101,20 @@ export function printVerifyVerdict(verdict) {
94
101
  ? `\n${colors.green("GREEN — build proven.")}\n`
95
102
  : `\n${colors.red("FAIL — build did not go green.")}\n`
96
103
  );
104
+
105
+ // Machine-readable verdict, one greppable line (field-report finding 2.3):
106
+ // a verify run can exceed 170k log lines, where "-Werror=" clang flags and
107
+ // Xcode phase names false-positive naive error greps. Agents anchor on this
108
+ // marker instead of parsing raw Gradle/xcodebuild output.
109
+ process.stdout.write(
110
+ `::create-cmp-verdict::${JSON.stringify({
111
+ green: verdict.green,
112
+ results: verdict.results.map((r) => ({
113
+ platform: r.platform,
114
+ green: r.code === 0,
115
+ ran: r.ran,
116
+ durationMs: r.durationMs ?? null,
117
+ })),
118
+ })}\n`
119
+ );
97
120
  }
package/src/scaffold.mjs CHANGED
@@ -205,6 +205,46 @@ function applyAppNameSlug(projectDir, appName) {
205
205
  }
206
206
  }
207
207
 
208
+ /**
209
+ * Persist the fully-resolved engine config as `create-cmp.json` in the project
210
+ * root — the durable spec-of-record (field-report finding 5.5-1 / D6). The
211
+ * conformance/consistency tooling compares code against whatever this file
212
+ * currently says, so a hand-edit is a visible spec change rather than drift.
213
+ * @param {string} projectDir
214
+ * @param {object} config validated engine config
215
+ */
216
+ function writeSpecOfRecord(projectDir, config) {
217
+ let engineVersion = "unknown";
218
+ try {
219
+ engineVersion = JSON.parse(
220
+ fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")
221
+ ).version;
222
+ } catch {
223
+ /* best-effort — never fail the stamp over version metadata */
224
+ }
225
+ const record = {
226
+ schemaVersion: 1,
227
+ name: config.appName,
228
+ package: config.package,
229
+ bundleId: config.iosBundleId,
230
+ themePrefix: config.themePrefix,
231
+ region: config.region,
232
+ platforms: config.platforms,
233
+ firebase: config.firebase,
234
+ room: config.room,
235
+ e2e: config.e2e,
236
+ inspector: config.inspector,
237
+ devClient: config.devClient,
238
+ tabs: config.tabs,
239
+ engineVersion,
240
+ stampedAt: new Date().toISOString(),
241
+ };
242
+ fs.writeFileSync(
243
+ path.join(projectDir, "create-cmp.json"),
244
+ JSON.stringify(record, null, 2) + "\n"
245
+ );
246
+ }
247
+
208
248
  /**
209
249
  * Run the full scaffold pipeline.
210
250
  * @param {object} config engine config object (CONTRACT)
@@ -231,10 +271,20 @@ export async function scaffold(config, opts = {}) {
231
271
 
232
272
  const projectDir = path.resolve(config.targetDir);
233
273
  if (fs.existsSync(projectDir)) {
274
+ // Harmless entries must not force `--force`: our own doctor/session
275
+ // droppings (.claude), VCS metadata, and OS/editor noise. The documented
276
+ // doctor→create flow used to poison its own target dir this way. Anything
277
+ // else is real user content and still refuses — naming the offenders so
278
+ // the caller can decide without an `ls` round-trip.
279
+ const HARMLESS = new Set([".git", ".claude", ".DS_Store", ".idea", ".vscode"]);
234
280
  const entries = fs.readdirSync(projectDir).filter((e) => e !== "." && e !== "..");
235
- if (entries.length > 0 && !opts.force) {
281
+ const blocking = entries.filter((e) => !HARMLESS.has(e) && !e.endsWith(".swp"));
282
+ if (blocking.length > 0 && !opts.force) {
283
+ const ignored = entries.filter((e) => !blocking.includes(e));
236
284
  throw new Error(
237
- `target directory ${projectDir} is not empty (pass force to overwrite)`
285
+ `target directory ${projectDir} is not empty (pass force to overwrite)\n` +
286
+ ` Blocking entries: ${blocking.join(", ")}` +
287
+ (ignored.length > 0 ? `\n (ignored as harmless: ${ignored.join(", ")})` : "")
238
288
  );
239
289
  }
240
290
  }
@@ -322,6 +372,14 @@ export async function scaffold(config, opts = {}) {
322
372
  if (fs.existsSync(abs)) fs.rmSync(abs);
323
373
  }
324
374
 
375
+ // Persist the resolved config as the project's spec-of-record. Until now the
376
+ // config was validated, consumed, and discarded — the only pre-code spec in
377
+ // the system evaporated at stamp time, so nothing could later answer "was
378
+ // this app built to its spec?" (tabs ↔ AppTab ↔ smoke consistency, upgrade
379
+ // intent, re-stamp/resume all need it). Committed with the app; hand-edits
380
+ // are visible spec changes, not drift.
381
+ writeSpecOfRecord(projectDir, config);
382
+
325
383
  ok("Scaffold complete.");
326
384
 
327
385
  // (f) verify gate
@@ -0,0 +1,8 @@
1
+ # Agent instructions
2
+
3
+ This repository is agent-first. The full working contract — the definition of done
4
+ (`node qa/verify.mjs` must PASS), the architecture gates, the testing pyramid, and the
5
+ device-free **UI feedback loop** (render every real screen headlessly and see exactly
6
+ what your edit changed) — lives in [CLAUDE.md](./CLAUDE.md).
7
+
8
+ Read CLAUDE.md before making changes. It applies to every coding agent, not only Claude.
@@ -52,6 +52,38 @@ invariants the conformance gates enforce.
52
52
  Commit it with your change — git history is the audit ledger. Binary artifacts under
53
53
  `qa-artifacts/` are hashed into the receipt; never commit them.
54
54
 
55
+ ## UI feedback loop — see what you build, without a device
56
+
57
+ <!-- >>> cmp:feature inspector -->
58
+ While building or changing ANY screen, use the preview loop instead of an emulator: it
59
+ renders this app's REAL screens (real DI, real theme, seeded data) headlessly in seconds
60
+ and tells you exactly what your edit changed.
61
+
62
+ **With the create-cmp plugin (cmp-inspector MCP tools):**
63
+
64
+ 1. `preview { projectDir }` — once per session. Returns a live gallery URL (give it to
65
+ the human; it re-renders itself on every save) plus per-screen structural summaries
66
+ for you. Sources are watched; you never run Gradle by hand.
67
+ 2. After each edit: `preview_status { waitForRender: true }` — blocks until the
68
+ render/compile outcome. `changedLastRender` names the screens your edit touched
69
+ (empty = the edit reached no screen); `lastErrorSource: "compile"` means the edit
70
+ didn't even build (the compiler's `e:` lines are in `lastError`).
71
+ 3. `preview_diff { screen }` — one call proves the change: verdict `proven-clean` /
72
+ `changed-with-regressions` / `no-change`. No snapshot bookkeeping needed.
73
+
74
+ **Without the plugin:** `./gradlew :composeApp:renderScreens` renders every screen to
75
+ `composeApp/build/previews/<id>/{screen.png, tree.json}` (`-Pscreen=<id>` for one);
76
+ `node qa/preview-gallery.mjs` builds a self-contained gallery page from the output.
77
+
78
+ Screens come from `inspector/PreviewRegistry.kt` (desktopMain). **When you add a screen,
79
+ register it there** — a forced-state variant is just another entry (`"home@empty"`).
80
+ Assert on the `tree.json` structure; never read PNG bytes (pixels are for humans).
81
+ <!-- <<< cmp:feature inspector -->
82
+ <!-- >>> cmp:feature dev-client -->
83
+ For one interactive window instead of stills of every screen:
84
+ `./gradlew :composeApp:hotRunDesktop --auto` (Compose Hot Reload dev-client).
85
+ <!-- <<< cmp:feature dev-client -->
86
+
55
87
  ## Docs
56
88
 
57
89
  [`README.md`](./README.md) (front page) · [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md)
@@ -17,6 +17,11 @@ follow the contract in [`CLAUDE.md`](./CLAUDE.md).
17
17
  ./gradlew :composeApp:hotRunDesktop --auto
18
18
  <!-- <<< cmp:feature dev-client -->
19
19
 
20
+ <!-- >>> cmp:feature inspector -->
21
+ # Preview EVERY screen headlessly — no device, no emulator (agents: CLAUDE.md "UI feedback loop")
22
+ ./gradlew :composeApp:renderScreens && node qa/preview-gallery.mjs
23
+ <!-- <<< cmp:feature inspector -->
24
+
20
25
  # Unit tests (fast inner loop)
21
26
  ./gradlew :composeApp:desktopTest
22
27
 
@@ -136,6 +136,13 @@ kotlin {
136
136
  implementation(compose.desktop.currentOs)
137
137
  implementation(libs.kotlinx.coroutines.swing)
138
138
  implementation(libs.ktor.client.cio)
139
+ // >>> cmp:feature inspector
140
+ // Headless semantics API (runDesktopComposeUiTest / onRoot) for the preview
141
+ // harness (inspector/PreviewHarness.kt) — renders real screens with no window.
142
+ // Desktop is a dev-only target, so shipping the test artifact here is deliberate.
143
+ @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
144
+ implementation(compose.uiTest)
145
+ // <<< cmp:feature inspector
139
146
  }
140
147
  }
141
148
 
@@ -250,7 +257,19 @@ compose.resources {
250
257
 
251
258
  // >>> cmp:feature room
252
259
  room {
253
- schemaDirectory("$projectDir/schemas")
260
+ // Per-target schema directories, NOT one shared dir. With a single directory the
261
+ // copyRoomSchemas aggregation task requires every target's exported schema to be
262
+ // byte-identical — and the first entity edit after scaffold trips a cross-target
263
+ // checksum conflict against the stale intermediate of whichever target built last
264
+ // ("Inconsistency detected exporting Room schema files"). Per-target locations are
265
+ // exactly what that error's remediation asks for.
266
+ schemaDirectory("android", "$projectDir/schemas/android")
267
+ schemaDirectory("desktop", "$projectDir/schemas/desktop")
268
+ // >>> cmp:feature ios
269
+ schemaDirectory("iosSimulatorArm64", "$projectDir/schemas/iosSimulatorArm64")
270
+ schemaDirectory("iosX64", "$projectDir/schemas/iosX64")
271
+ schemaDirectory("iosArm64", "$projectDir/schemas/iosArm64")
272
+ // <<< cmp:feature ios
254
273
  }
255
274
  // <<< cmp:feature room
256
275
 
@@ -263,6 +282,52 @@ compose.desktop {
263
282
  }
264
283
  // <<< cmp:feature dev-client
265
284
 
285
+ // >>> cmp:feature inspector
286
+ // Headless screen previews — the project-wired tier-0 loop of the create-cmp inspector.
287
+ // Renders every screen in inspector/PreviewRegistry.kt (real DI, real theme, real data)
288
+ // to build/previews/<id>/{screen.png, tree.json} with NO device, emulator, or window,
289
+ // then qa/preview-gallery.mjs turns the output into one self-contained index.html:
290
+ //
291
+ // ./gradlew :composeApp:renderScreens # all screens
292
+ // ./gradlew :composeApp:renderScreens -Pscreen=home # one screen
293
+ // node qa/preview-gallery.mjs # build the gallery
294
+ //
295
+ // Parameters travel as -P properties -> system properties, NEVER via --args (Gradle's CLI
296
+ // parsing splits space-separated --args values into task names).
297
+ tasks.register<JavaExec>("renderScreens") {
298
+ group = "verification"
299
+ description = "Render registered screens headlessly to PNG + inspector-contract tree JSON."
300
+ val desktopCompilation = kotlin.targets.getByName("desktop").compilations.getByName("main")
301
+ classpath(desktopCompilation.output.allOutputs, desktopCompilation.runtimeDependencyFiles)
302
+ mainClass.set("__PACKAGE__.inspector.PreviewHarnessKt")
303
+ systemProperty("java.awt.headless", "true")
304
+ systemProperty("screen", providers.gradleProperty("screen").getOrElse("all"))
305
+ systemProperty(
306
+ "out",
307
+ providers.gradleProperty("previewOut")
308
+ .getOrElse(layout.buildDirectory.dir("previews").get().asFile.absolutePath),
309
+ )
310
+ systemProperty("pngScale", providers.gradleProperty("pngScale").getOrElse("2"))
311
+ }
312
+
313
+ // Resident preview daemon (phase 2): a long-lived headless JVM serving /render on
314
+ // 127.0.0.1:9601 so warm re-renders skip the task cycle. Preferred launch is under
315
+ // Compose Hot Reload (saves hot-swap into the running daemon; needs the dev-client
316
+ // feature's Compose Hot Reload plugin):
317
+ //
318
+ // ./gradlew :composeApp:hotRunDesktop --mainClass=__PACKAGE__.inspector.PreviewDaemonKt --auto
319
+ //
320
+ // This plain variant runs on any JDK with no hot swap (restart to pick up new code):
321
+ tasks.register<JavaExec>("runPreviewDaemon") {
322
+ group = "verification"
323
+ description = "Run the resident preview daemon (loopback HTTP /render, no hot reload)."
324
+ val desktopCompilation = kotlin.targets.getByName("desktop").compilations.getByName("main")
325
+ classpath(desktopCompilation.output.allOutputs, desktopCompilation.runtimeDependencyFiles)
326
+ mainClass.set("__PACKAGE__.inspector.PreviewDaemonKt")
327
+ systemProperty("java.awt.headless", "true")
328
+ }
329
+ // <<< cmp:feature inspector
330
+
266
331
  // Evidence integrity: golden-tree baselines (qa/golden) and the UPDATE_GOLDEN capture flag are
267
332
  // REAL inputs of the JVM test tier, but Gradle can't see either on its own — baselines are read
268
333
  // at runtime, not compiled, and env vars aren't tracked. Undeclared, the build cache will happily
@@ -0,0 +1,36 @@
1
+ package __PACKAGE__.core.format
2
+
3
+ /**
4
+ * KMP-safe formatting helpers.
5
+ *
6
+ * `String.format` / `"%02d".format(...)` are JVM-only — they compile in `androidMain` but do
7
+ * not exist in `commonMain`, and reaching for them is the single most common first-week
8
+ * porting mistake in a shared module. These cover the cases that actually come up; add here
9
+ * rather than sprinkling `padStart` call sites.
10
+ */
11
+
12
+ /** Pad an Int to two digits: 7 -> "07". The `%02d` you were about to write. */
13
+ fun pad2(n: Int): String = n.toString().padStart(2, '0')
14
+
15
+ /** "HH:mm" from minutes-since-midnight: 555 -> "09:15". */
16
+ fun clockLabel(minutesOfDay: Int): String {
17
+ val m = ((minutesOfDay % (24 * 60)) + 24 * 60) % (24 * 60) // wrap + never negative
18
+ return "${pad2(m / 60)}:${pad2(m % 60)}"
19
+ }
20
+
21
+ /**
22
+ * Fixed decimal places without java.text: 12.5 -> "12.5" (1 dp). Rounds half away from zero
23
+ * via floor(abs + 0.5) — deterministic on every backend (kotlin.math.round's tie behavior and
24
+ * `%.Nf` locale handling both vary). For layout-stable numeric UI text, not accounting math.
25
+ */
26
+ fun fixed(value: Double, decimals: Int = 1): String {
27
+ require(decimals >= 0) { "decimals must be >= 0" }
28
+ var factor = 1L
29
+ repeat(decimals) { factor *= 10 }
30
+ val scaled = kotlin.math.floor(kotlin.math.abs(value) * factor + 0.5).toLong()
31
+ val sign = if (value < 0 && scaled != 0L) "-" else ""
32
+ if (decimals == 0) return "$sign$scaled"
33
+ val whole = scaled / factor
34
+ val frac = (scaled % factor).toString().padStart(decimals, '0')
35
+ return "$sign$whole.$frac"
36
+ }
@@ -0,0 +1,36 @@
1
+ package __PACKAGE__.core.format
2
+
3
+ import kotlin.test.Test
4
+ import kotlin.test.assertEquals
5
+
6
+ class FormatTest {
7
+
8
+ @Test
9
+ fun pad2_pads_single_digits() {
10
+ assertEquals("07", pad2(7))
11
+ assertEquals("00", pad2(0))
12
+ assertEquals("15", pad2(15))
13
+ }
14
+
15
+ @Test
16
+ fun clockLabel_formats_minutes_of_day() {
17
+ assertEquals("09:15", clockLabel(9 * 60 + 15))
18
+ assertEquals("00:00", clockLabel(0))
19
+ assertEquals("23:59", clockLabel(23 * 60 + 59))
20
+ // wraps past midnight and never goes negative
21
+ assertEquals("00:30", clockLabel(24 * 60 + 30))
22
+ assertEquals("23:30", clockLabel(-30))
23
+ }
24
+
25
+ @Test
26
+ fun fixed_renders_stable_decimals() {
27
+ // Binary-exact inputs only — 0.1-style values are not representable and would make
28
+ // these assertions depend on the platform's double formatting.
29
+ assertEquals("12.5", fixed(12.5, 1))
30
+ assertEquals("0.3", fixed(0.25, 1)) // half rounds away from zero
31
+ assertEquals("-0.8", fixed(-0.75, 1))
32
+ assertEquals("3.00", fixed(3.0, 2))
33
+ assertEquals("13", fixed(12.5, 0))
34
+ assertEquals("0.0", fixed(0.0, 1))
35
+ }
36
+ }