create-cmp-cli 0.6.1 → 0.7.1

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
@@ -4,8 +4,9 @@
4
4
 
5
5
  **The AI delivery harness for Kotlin/Compose Multiplatform.**
6
6
 
7
- Scaffold a green-building Android + iOS app in minutes then let AI extend it, held to a
8
- machine-enforced definition of done.
7
+ Gives AI coding agents *eyes* and a *machine-enforced definition of done* on mobile: scaffold a
8
+ green-building Android + iOS app in minutes, then let AI extend it — seeing every screen it
9
+ renders, and blocked from "done" without proof.
9
10
 
10
11
  [![CI](https://github.com/kvdm-co-pilot/create-cmp/actions/workflows/ci.yml/badge.svg)](https://github.com/kvdm-co-pilot/create-cmp/actions/workflows/ci.yml)
11
12
  [![npm version](https://img.shields.io/npm/v/create-cmp-cli.svg)](https://www.npmjs.com/package/create-cmp-cli)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cmp-cli",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "description": "Create production mobile apps (Android + iOS, one Kotlin codebase) with AI — the delivery harness for Compose Multiplatform, the current generation of cross-platform (Google-backed KMP, iOS stable since May 2025). A deterministic, non-interactive generator that scaffolds a green-building app 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": {
package/src/lib/tabs.mjs CHANGED
@@ -378,8 +378,10 @@ ${imports.join("\n")}
378
378
  *
379
379
  * The \`@Preview\` analog for the create-cmp inspector: the registry makes "render screen
380
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.
381
+ * configured \`tabs\`, and the feature stamper (\`qa/scaffold-feature.mjs\`, via the
382
+ * \`add-feature\`/\`add-screen\` skills) auto-appends a stamped screen at the
383
+ * \`// cmp:anchor preview-registry\` marker below; when you add a screen by hand, add it
384
+ * there too — the renderScreens harness, the gallery, and golden baselines pick it up by id.
383
385
  *
384
386
  * State variants (the Storybook "story" analog): a screen in a specific UI state is just
385
387
  * another entry with a derived id — e.g. \`ScreenPreview("home@empty", "Home — empty")\`
@@ -405,6 +407,7 @@ ${infos.map(previewTabArg).join("\n")}
405
407
  },
406
408
  ${infos.map(previewEntry).join("\n")}
407
409
  ScreenPreview("detail", "Detail (nav destination)") { DetailScreen(itemId = "1", onBack = {}) },
410
+ // cmp:anchor preview-registry
408
411
  )
409
412
 
410
413
  /**
@@ -62,6 +62,10 @@ at their `// cmp:anchor` markers, and writes `specs/<feature>.spec.md` with a de
62
62
  set (`<FEATURE>-01..06`: loading, success, error, reload-after-failure, tap-navigates, golden
63
63
  tree) — copied verbatim from the `home` exemplar's shape.
64
64
 
65
+ The stamped screen arrives **already wrapped in `BaseScreen { … }`** (SHELL-05): it is a
66
+ pushed NavHost destination, so unlike the tab exemplar it must handle its own insets — the
67
+ stamper does this for you; do not unwrap it.
68
+
65
69
  If it exits non-zero, read the message — it is actionable (name already taken, an anchor marker
66
70
  is missing, or a name isn't a valid Kotlin identifier). Do not hand-edit around a stamper
67
71
  failure; if an anchor is genuinely missing from a shared file, that is a template defect worth
@@ -75,8 +75,10 @@ and tells you exactly what your edit changed.
75
75
  `composeApp/build/previews/<id>/{screen.png, tree.json}` (`-Pscreen=<id>` for one);
76
76
  `node qa/preview-gallery.mjs` builds a self-contained gallery page from the output.
77
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"`).
78
+ Screens come from `inspector/PreviewRegistry.kt` (desktopMain). The `add-feature`/`add-screen`
79
+ stamper **auto-registers** a stamped screen there (at the `// cmp:anchor preview-registry`
80
+ marker). **When you add a screen by hand, register it there** — a forced-state variant is just
81
+ another entry (`"home@empty"`).
80
82
  Assert on the `tree.json` structure; never read PNG bytes (pixels are for humans).
81
83
  <!-- <<< cmp:feature inspector -->
82
84
  <!-- >>> cmp:feature dev-client -->
@@ -109,4 +109,4 @@ signal for a later one in CI.
109
109
 
110
110
  ---
111
111
 
112
- Built with [create-cmp](https://github.com/kvdm-co-pilot/create-cmp) — the AI delivery harness for Compose Multiplatform.
112
+ [![Built with create-cmp](https://img.shields.io/badge/Built%20with-create--cmp-7F52FF?logo=kotlin&logoColor=white)](https://github.com/kvdm-co-pilot/create-cmp) — the AI delivery harness for Compose Multiplatform. *(Just a static badge — delete this line if you prefer.)*
@@ -18,8 +18,10 @@ import __PACKAGE__.presentation.profile.ProfileScreen
18
18
  *
19
19
  * The `@Preview` analog for the create-cmp inspector: the registry makes "render screen
20
20
  * X" a closed, enumerable operation. The scaffolder regenerates the tab entries from the
21
- * configured `tabs`; when you add a screen by hand, add it here — the renderScreens
22
- * harness, the gallery, and golden baselines pick it up by id.
21
+ * configured `tabs`, and the feature stamper (`qa/scaffold-feature.mjs`, via the
22
+ * `add-feature`/`add-screen` skills) auto-appends a stamped screen at the
23
+ * `// cmp:anchor preview-registry` marker below; when you add a screen by hand, add it
24
+ * there too — the renderScreens harness, the gallery, and golden baselines pick it up by id.
23
25
  *
24
26
  * State variants (the Storybook "story" analog): a screen in a specific UI state is just
25
27
  * another entry with a derived id — e.g. `ScreenPreview("home@empty", "Home — empty")`
@@ -47,6 +49,7 @@ fun previewRegistry(): List<ScreenPreview> = listOf(
47
49
  ScreenPreview("home", "Home tab") { TabHost { HomeScreen(onItemClick = {}) } },
48
50
  ScreenPreview("profile", "Profile tab") { TabHost { ProfileScreen() } },
49
51
  ScreenPreview("detail", "Detail (nav destination)") { DetailScreen(itemId = "1", onBack = {}) },
52
+ // cmp:anchor preview-registry
50
53
  )
51
54
 
52
55
  /**
@@ -4,6 +4,12 @@
4
4
  // qa/receipt-check.mjs (recomputes it to test validity) import this module so
5
5
  // there is exactly one definition of the surface and the algorithm.
6
6
  //
7
+ // SINGLE SOURCE OF TRUTH: packages/receipts/src/inputs-hash.mjs in the
8
+ // create-cmp repo (the `cmp-receipts` package). The copy in a generated
9
+ // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
10
+ // test/receipts-parity.test.mjs — edit the package source, then run
11
+ // `node scripts/sync-receipts.mjs`.
12
+ //
7
13
  // See docs/adr/0005-evidence-binding-by-inputs-hash.md for the why.
8
14
 
9
15
  import { execSync } from "node:child_process";
@@ -49,14 +55,34 @@ function tryGitLsFiles(root) {
49
55
  }
50
56
  }
51
57
 
58
+ // Directory names the walk fallback must skip wherever they appear under a
59
+ // surface root. These mirror what the stamped .gitignore excludes: without
60
+ // this, a pre-`git init` hash (walk mode) includes composeApp/build/** and
61
+ // Gradle/Kotlin scratch that the post-`git init` hash (`git ls-files
62
+ // --exclude-standard`) excludes — so the stamp-time PASS receipt would read
63
+ // "INVALID — source changed" the moment the user runs `git init`, even though
64
+ // no source changed. Pre-git and post-git hashes must agree for identical
65
+ // source; that is the invariant the regression test pins.
66
+ const WALK_EXCLUDED_DIRS = new Set(["build", ".gradle", ".kotlin", ".git", ".idea", "node_modules"]);
67
+ // File-level mirror of the same principle (OS/editor junk the .gitignore covers).
68
+ const WALK_EXCLUDED_FILES = new Set([".DS_Store"]);
69
+ const WALK_EXCLUDED_SUFFIXES = [".iml", ".log"];
70
+
71
+ function walkIncludesFile(name) {
72
+ if (WALK_EXCLUDED_FILES.has(name)) return false;
73
+ return !WALK_EXCLUDED_SUFFIXES.some((suffix) => name.endsWith(suffix));
74
+ }
75
+
52
76
  // Dependency-free recursive walk, used when git is unavailable (non-git scaffold).
53
77
  function walkAllFiles(dir) {
54
78
  const out = [];
55
79
  if (!fs.existsSync(dir)) return out;
56
80
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
57
81
  const p = path.join(dir, entry.name);
58
- if (entry.isDirectory()) out.push(...walkAllFiles(p));
59
- else if (entry.isFile()) out.push(p);
82
+ if (entry.isDirectory()) {
83
+ if (WALK_EXCLUDED_DIRS.has(entry.name)) continue; // non-source scratch — see note above
84
+ out.push(...walkAllFiles(p));
85
+ } else if (entry.isFile() && walkIncludesFile(entry.name)) out.push(p);
60
86
  }
61
87
  return out;
62
88
  }
@@ -0,0 +1,234 @@
1
+ // The evidence-binding predicate and its service-grade extensions, as pure
2
+ // dependency-free functions. `evaluateReceipt` is the exact predicate the
3
+ // generated project's qa/receipt-check.mjs (and its Stop hook + CI) runs;
4
+ // the additional checks (freshness, execution plausibility, SKIP listing) are
5
+ // consumed by hosted validators that judge a receipt fetched from a repo
6
+ // tarball rather than the working tree.
7
+ //
8
+ // SINGLE SOURCE OF TRUTH: packages/receipts/src/receipt-validate.mjs in the
9
+ // create-cmp repo (the `cmp-receipts` package). The copy in a generated
10
+ // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
11
+ // test/receipts-parity.test.mjs — edit the package source, then run
12
+ // `node scripts/sync-receipts.mjs`.
13
+ //
14
+ // See docs/adr/0005-evidence-binding-by-inputs-hash.md for the why.
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+
19
+ import { computeInputsHash } from "./inputs-hash.mjs";
20
+
21
+ /** Where a generated project keeps its committed receipt, relative to root. */
22
+ export const RECEIPT_REL_PATH = "qa/evidence/latest.json";
23
+
24
+ /**
25
+ * Read and parse the committed receipt for the project rooted at `root`.
26
+ * @param {string} root absolute path to the project root
27
+ * @returns {object|null} the parsed receipt, or null when absent/unparsable
28
+ */
29
+ export function readReceipt(root, relPath = RECEIPT_REL_PATH) {
30
+ try {
31
+ return JSON.parse(fs.readFileSync(path.join(root, relPath), "utf8"));
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * The core predicate: does this receipt validly attest the tree whose inputs
39
+ * hash `recompute()` returns? Reasons are the exact refusal strings the
40
+ * generated project's receipt-check CLI (and Stop hook) prints.
41
+ *
42
+ * @param {object} receipt parsed receipt JSON
43
+ * @param {() => {hash: string, fileCount: number}} recompute lazily invoked —
44
+ * never called when the receipt fails structurally first (missing binding,
45
+ * FAIL verdict), so callers don't pay for a hash they don't need.
46
+ * @returns {{valid: boolean, reason: string, profile: (string|undefined), recomputed?: {hash: string, fileCount: number}}}
47
+ */
48
+ export function evaluateReceipt(receipt, recompute) {
49
+ const profile = receipt.profile;
50
+
51
+ if (!receipt.inputs || typeof receipt.inputs.hash !== "string") {
52
+ return {
53
+ valid: false,
54
+ reason: `receipt predates evidence binding — re-run the lane (attesting profile: ${profile ?? "unknown"})`,
55
+ profile,
56
+ };
57
+ }
58
+
59
+ if (receipt.verdict === "FAIL") {
60
+ return {
61
+ valid: false,
62
+ reason: `the committed receipt is a FAIL (attesting profile: ${profile ?? "unknown"})`,
63
+ profile,
64
+ };
65
+ }
66
+
67
+ const recomputed = recompute();
68
+
69
+ if (receipt.inputs.hash !== recomputed.hash) {
70
+ return {
71
+ valid: false,
72
+ reason: `source changed since the receipt — re-run the lane (attesting profile: ${profile ?? "unknown"})`,
73
+ profile,
74
+ recomputed,
75
+ };
76
+ }
77
+
78
+ if (receipt.verdict !== "PASS") {
79
+ return {
80
+ valid: false,
81
+ reason: `receipt verdict is "${receipt.verdict}", not PASS (attesting profile: ${profile ?? "unknown"})`,
82
+ profile,
83
+ };
84
+ }
85
+
86
+ return { valid: true, reason: `receipt is valid — PASS, attesting profile: ${profile ?? "unknown"}`, profile, recomputed };
87
+ }
88
+
89
+ // ── Service-grade checks (hosted validators; the local predicate above does
90
+ // not enforce these — the tree it checks is by definition "now") ─────────
91
+
92
+ /** Default policy for hosted validation. Every knob is overridable. */
93
+ export const DEFAULT_POLICY = {
94
+ /** A receipt older than this no longer counts as fresh (hosted check only). */
95
+ maxAgeMs: 30 * 24 * 60 * 60 * 1000, // 30 days
96
+ /**
97
+ * Executed (non-SKIP) gates must report at least this much total wall time.
98
+ * A "PASS" receipt whose executed gates sum to less cannot attest a real
99
+ * lane run — the tell for replayed/cached or hand-written verdicts
100
+ * (evidence must attest execution, not results).
101
+ */
102
+ minExecutedMs: 5000,
103
+ };
104
+
105
+ /**
106
+ * Freshness: is the receipt's generatedAt within maxAgeMs of `now`?
107
+ * @returns {{ok: boolean, detail: string, ageMs?: number}}
108
+ */
109
+ export function checkFreshness(receipt, { now = Date.now(), maxAgeMs = DEFAULT_POLICY.maxAgeMs } = {}) {
110
+ const generatedAt = Date.parse(receipt?.generatedAt ?? "");
111
+ if (Number.isNaN(generatedAt)) {
112
+ return { ok: false, detail: "receipt has no parsable generatedAt timestamp" };
113
+ }
114
+ const ageMs = now - generatedAt;
115
+ if (ageMs < -60_000) {
116
+ // A receipt from the future is a clock lie, not a rounding artifact.
117
+ return { ok: false, detail: `receipt claims a future generatedAt (${receipt.generatedAt})`, ageMs };
118
+ }
119
+ if (ageMs > maxAgeMs) {
120
+ const days = Math.floor(ageMs / 86_400_000);
121
+ return { ok: false, detail: `receipt is stale — generated ${days} day(s) ago, older than the ${Math.floor(maxAgeMs / 86_400_000)}-day freshness window`, ageMs };
122
+ }
123
+ return { ok: true, detail: `receipt generated ${receipt.generatedAt}`, ageMs };
124
+ }
125
+
126
+ /**
127
+ * Execution plausibility: do the executed (non-SKIP) gates report durations a
128
+ * real lane run could produce? Catches replayed/cached greens and hand-edited
129
+ * receipts whose numbers were never lived.
130
+ * @returns {{ok: boolean, detail: string, executedMs?: number, executedSteps?: number}}
131
+ */
132
+ export function checkExecutionPlausibility(receipt, { minExecutedMs = DEFAULT_POLICY.minExecutedMs } = {}) {
133
+ const steps = Array.isArray(receipt?.steps) ? receipt.steps : null;
134
+ if (!steps || steps.length === 0) {
135
+ return { ok: false, detail: "receipt lists no verify-lane steps — nothing was executed" };
136
+ }
137
+ const executed = steps.filter((s) => s && s.verdict !== "SKIP");
138
+ if (executed.length === 0) {
139
+ return { ok: false, detail: "every step in the receipt is a SKIP — the lane verified nothing" };
140
+ }
141
+ let total = 0;
142
+ for (const step of executed) {
143
+ if (typeof step.durationMs !== "number" || !Number.isFinite(step.durationMs) || step.durationMs < 0) {
144
+ return { ok: false, detail: `step "${step.name ?? "?"}" reports an invalid duration (${step.durationMs}) — durations must be real, non-negative numbers` };
145
+ }
146
+ total += step.durationMs;
147
+ }
148
+ if (total < minExecutedMs) {
149
+ return {
150
+ ok: false,
151
+ detail: `implausibly fast — executed gates report ${total}ms total, below the ${minExecutedMs}ms floor; a receipt this fast cannot attest a real lane run (evidence must attest execution)`,
152
+ executedMs: total,
153
+ executedSteps: executed.length,
154
+ };
155
+ }
156
+ return { ok: true, detail: `${executed.length} executed gate(s), ${total}ms total`, executedMs: total, executedSteps: executed.length };
157
+ }
158
+
159
+ /**
160
+ * List the SKIPped steps with their honest reasons. SKIPs are reported, not
161
+ * failed — green-with-gaps must be visible, never silently equated with
162
+ * fully-verified (or silently punished).
163
+ * @returns {Array<{name: string, reason: string}>}
164
+ */
165
+ export function listSkippedSteps(receipt) {
166
+ const steps = Array.isArray(receipt?.steps) ? receipt.steps : [];
167
+ return steps
168
+ .filter((s) => s && s.verdict === "SKIP")
169
+ .map((s) => ({ name: s.name ?? "?", reason: s.reason ?? "no reason recorded" }));
170
+ }
171
+
172
+ /**
173
+ * The hosted composite: validate the receipt found in an extracted repo tree
174
+ * (e.g. a tarball at a PR's head SHA) with the full service-grade policy.
175
+ *
176
+ * @param {object} args
177
+ * @param {string} args.root absolute path to the extracted tree's project root
178
+ * @param {number} [args.now] epoch ms, for freshness (defaults to Date.now())
179
+ * @param {object} [args.policy] overrides for DEFAULT_POLICY
180
+ * @returns {{
181
+ * status: "missing"|"valid"|"invalid",
182
+ * reason: string,
183
+ * profile?: string,
184
+ * checks: Array<{id: string, ok: boolean, detail: string}>,
185
+ * skips: Array<{name: string, reason: string}>,
186
+ * }}
187
+ */
188
+ export function validateReceiptForTree({ root, now = Date.now(), policy = {} } = {}) {
189
+ const effective = { ...DEFAULT_POLICY, ...policy };
190
+ const receipt = readReceipt(root);
191
+
192
+ if (receipt === null) {
193
+ return {
194
+ status: "missing",
195
+ reason: `no receipt at ${RECEIPT_REL_PATH} — this repo does not carry the create-cmp evidence harness (that is not a failure)`,
196
+ checks: [{ id: "receipt-present", ok: false, detail: `no parsable receipt at ${RECEIPT_REL_PATH}` }],
197
+ skips: [],
198
+ };
199
+ }
200
+
201
+ const checks = [{ id: "receipt-present", ok: true, detail: RECEIPT_REL_PATH }];
202
+ const skips = listSkippedSteps(receipt);
203
+
204
+ // The core predicate (binding + verdict + hash), verbatim local semantics.
205
+ const core = evaluateReceipt(receipt, () => computeInputsHash(root));
206
+ checks.push({ id: "binding-and-hash", ok: core.valid, detail: core.reason });
207
+
208
+ // Service-grade extensions run regardless, so a failing receipt reports
209
+ // every violated rule at once (refusals name what failed, all of it).
210
+ const freshness = checkFreshness(receipt, { now, maxAgeMs: effective.maxAgeMs });
211
+ checks.push({ id: "freshness", ok: freshness.ok, detail: freshness.detail });
212
+
213
+ const plausibility = checkExecutionPlausibility(receipt, { minExecutedMs: effective.minExecutedMs });
214
+ checks.push({ id: "execution-plausibility", ok: plausibility.ok, detail: plausibility.detail });
215
+
216
+ const failed = checks.filter((c) => !c.ok);
217
+ if (failed.length > 0) {
218
+ return {
219
+ status: "invalid",
220
+ reason: failed.map((c) => c.detail).join("; "),
221
+ profile: core.profile,
222
+ checks,
223
+ skips,
224
+ };
225
+ }
226
+
227
+ return {
228
+ status: "valid",
229
+ reason: core.reason,
230
+ profile: core.profile,
231
+ checks,
232
+ skips,
233
+ };
234
+ }
@@ -20,9 +20,9 @@ import path from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
 
22
22
  import { computeInputsHash } from "./lib/inputs-hash.mjs";
23
+ import { evaluateReceipt, readReceipt } from "./lib/receipt-validate.mjs";
23
24
 
24
25
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
25
- const RECEIPT_PATH = path.join(ROOT, "qa", "evidence", "latest.json");
26
26
 
27
27
  const args = process.argv.slice(2);
28
28
  const asHook = args.includes("--hook");
@@ -38,52 +38,15 @@ function readStdinJson() {
38
38
  }
39
39
  }
40
40
 
41
+ // The predicate itself lives in qa/lib/receipt-validate.mjs (vendored from the
42
+ // cmp-receipts package — one definition everywhere a receipt is judged); this
43
+ // CLI only reads the receipt and frames the exit codes.
41
44
  function evaluate() {
42
- let receipt;
43
- try {
44
- receipt = JSON.parse(fs.readFileSync(RECEIPT_PATH, "utf8"));
45
- } catch {
45
+ const receipt = readReceipt(ROOT);
46
+ if (receipt === null) {
46
47
  return { valid: false, reason: "no receipt — run `node qa/verify.mjs`", profile: undefined };
47
48
  }
48
-
49
- const profile = receipt.profile;
50
-
51
- if (!receipt.inputs || typeof receipt.inputs.hash !== "string") {
52
- return {
53
- valid: false,
54
- reason: `receipt predates evidence binding — re-run the lane (attesting profile: ${profile ?? "unknown"})`,
55
- profile,
56
- };
57
- }
58
-
59
- if (receipt.verdict === "FAIL") {
60
- return {
61
- valid: false,
62
- reason: `the committed receipt is a FAIL (attesting profile: ${profile ?? "unknown"})`,
63
- profile,
64
- };
65
- }
66
-
67
- const recomputed = computeInputsHash(ROOT);
68
-
69
- if (receipt.inputs.hash !== recomputed.hash) {
70
- return {
71
- valid: false,
72
- reason: `source changed since the receipt — re-run the lane (attesting profile: ${profile ?? "unknown"})`,
73
- profile,
74
- recomputed,
75
- };
76
- }
77
-
78
- if (receipt.verdict !== "PASS") {
79
- return {
80
- valid: false,
81
- reason: `receipt verdict is "${receipt.verdict}", not PASS (attesting profile: ${profile ?? "unknown"})`,
82
- profile,
83
- };
84
- }
85
-
86
- return { valid: true, reason: `receipt is valid — PASS, attesting profile: ${profile ?? "unknown"}`, profile, recomputed };
49
+ return evaluateReceipt(receipt, () => computeInputsHash(ROOT));
87
50
  }
88
51
 
89
52
  const result = evaluate();
@@ -212,7 +212,7 @@ const ALL_FILES = [
212
212
  { from: path.join(SRC("commonMain"), "domain/usecase/GetItemsUseCase.kt"), to: path.join(SRC("commonMain"), `domain/usecase/Get${E}sUseCase.kt`), presets: ["feature", "repository"] },
213
213
  { from: path.join(SRC("commonMain"), "data/remote/ItemRepositoryImpl.kt"), to: path.join(SRC("commonMain"), `data/remote/${E}RepositoryImpl.kt`), presets: ["feature", "repository"] },
214
214
  { from: path.join(SRC("commonTest"), "testing/fakes/FakeItemRepository.kt"), to: path.join(SRC("commonTest"), `testing/fakes/Fake${E}Repository.kt`), presets: ["feature", "repository"] },
215
- { from: path.join(SRC("commonMain"), "presentation/home/HomeScreen.kt"), to: path.join(SRC("commonMain"), `presentation/${f}/${F}Screen.kt`), presets: ["feature", "screen"] },
215
+ { from: path.join(SRC("commonMain"), "presentation/home/HomeScreen.kt"), to: path.join(SRC("commonMain"), `presentation/${f}/${F}Screen.kt`), presets: ["feature", "screen"], wrapInBaseScreen: true },
216
216
  { from: path.join(SRC("commonMain"), "presentation/home/HomeViewModel.kt"), to: path.join(SRC("commonMain"), `presentation/${f}/${F}ViewModel.kt`), presets: ["feature", "screen"] },
217
217
  { from: path.join(SRC("commonTest"), "presentation/home/HomeViewModelTest.kt"), to: path.join(SRC("commonTest"), `presentation/${f}/${F}ViewModelTest.kt`), presets: ["feature", "screen"] },
218
218
  { from: path.join(SRC("desktopTest"), "presentation/home/HomeScreenTest.kt"), to: path.join(SRC("desktopTest"), `presentation/${f}/${F}ScreenTest.kt`), presets: ["feature", "screen"] },
@@ -292,6 +292,69 @@ function defaultSpec() {
292
292
  `;
293
293
  }
294
294
 
295
+ // ── BaseScreen wrap (SHELL-05) ───────────────────────────────────────────────
296
+ // HomeScreen is a TAB — AppShell provides its BaseScreen at the shell layer.
297
+ // The stamped feature, however, is registered as a PUSHED NavHost destination,
298
+ // and SHELL-05 requires every such destination to compose inside BaseScreen
299
+ // (see DetailScreen for the pattern). Without this transform the stamped slice
300
+ // fails verify out of the box. Anchored on the exemplar's known shape; fails
301
+ // loudly if HomeScreen drifts (same discipline as the cmp:anchor markers).
302
+ function wrapScreenInBaseScreen(content, relPathForErrors) {
303
+ if (content.includes("BaseScreen")) return content; // already wrapped — idempotent
304
+
305
+ const lines = content.split("\n");
306
+
307
+ // 1. Import — mirror DetailScreen's ordering: presentation.components.BaseScreen
308
+ // sits immediately before the presentation.theme imports.
309
+ const themeImportIdx = lines.findIndex((l) => /^import .+\.presentation\.theme\./.test(l));
310
+ if (themeImportIdx === -1) {
311
+ die(
312
+ `no presentation.theme import found in ${relPathForErrors} — the HomeScreen exemplar ` +
313
+ "drifted from the shape this stamper wraps; cannot place the BaseScreen import.",
314
+ );
315
+ }
316
+ const importLine = lines[themeImportIdx].replace(
317
+ /^import (.+)\.presentation\.theme\..*$/,
318
+ "import $1.presentation.components.BaseScreen",
319
+ );
320
+ lines.splice(themeImportIdx, 0, importLine);
321
+
322
+ // 2. Root container start: the exemplar's body root is a top-level ` Column(`.
323
+ const rootIdx = lines.findIndex((l) => l === " Column(");
324
+ if (rootIdx === -1) {
325
+ die(
326
+ `root " Column(" not found in ${relPathForErrors} — the HomeScreen exemplar drifted ` +
327
+ "from the shape this stamper wraps in BaseScreen.",
328
+ );
329
+ }
330
+
331
+ // 3. Root container end: the ` }` immediately before the function's closing `}`.
332
+ let funCloseIdx = -1;
333
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
334
+ if (lines[i] === "}") {
335
+ funCloseIdx = i;
336
+ break;
337
+ }
338
+ }
339
+ if (funCloseIdx === -1 || lines[funCloseIdx - 1] !== " }") {
340
+ die(
341
+ `could not locate the root container's closing brace in ${relPathForErrors} — the ` +
342
+ "HomeScreen exemplar drifted from the shape this stamper wraps in BaseScreen.",
343
+ );
344
+ }
345
+ const rootCloseIdx = funCloseIdx - 1;
346
+
347
+ // 4. Wrap: indent the container block one level and enclose it in BaseScreen { }.
348
+ const indented = lines.slice(rootIdx, rootCloseIdx + 1).map((l) => (l.length ? ` ${l}` : l));
349
+ return [
350
+ ...lines.slice(0, rootIdx),
351
+ " BaseScreen {",
352
+ ...indented,
353
+ " }",
354
+ ...lines.slice(rootCloseIdx + 1),
355
+ ].join("\n");
356
+ }
357
+
295
358
  // ── Anchor injection (§5) ────────────────────────────────────────────────────
296
359
  // Idempotent (skip if the feature's line is already present); fails loudly if
297
360
  // an anchor marker is missing from the shared file. Each function is a pure
@@ -383,6 +446,11 @@ const plan = {
383
446
  const APP_MODULE = path.join(SRC("commonMain"), "di/AppModule.kt");
384
447
  const SCREEN_KT = path.join(SRC("commonMain"), "presentation/navigation/Screen.kt");
385
448
  const APP_NAV_HOST = path.join(SRC("commonMain"), "presentation/navigation/AppNavHost.kt");
449
+ // Optional (present only when the inspector feature is enabled): the preview
450
+ // registry lives in desktopMain. A stamped pushed-destination screen is
451
+ // registered here so `renderScreens`, the gallery, and golden baselines pick it
452
+ // up with zero hand edits — the same reason we wire nav/DI automatically.
453
+ const PREVIEW_REGISTRY = path.join(SRC("desktopMain"), "inspector/PreviewRegistry.kt");
386
454
 
387
455
  // Each step is tagged with the presets it belongs to, same mechanism as
388
456
  // FILES above: `repository` gets repo+usecase DI (+ imports) only; `screen`
@@ -415,14 +483,30 @@ const ALL_INJECTION_PLANS = [
415
483
  { presets: ["feature", "screen"], apply: (c) => injectAtAnchor(c, APP_NAV_HOST, "nav-destinations", `composable(Screen.${F}.route) { ${F}Screen(onItemClick = {}) }`) },
416
484
  ],
417
485
  },
486
+ {
487
+ // Optional: only wired when the inspector feature shipped PreviewRegistry.kt.
488
+ // Registers the stamped screen exactly as the NavHost hosts it (pushed
489
+ // destination → standalone, matching DetailScreen), so preview parity holds.
490
+ filePath: PREVIEW_REGISTRY,
491
+ optional: true,
492
+ steps: [
493
+ { presets: ["feature", "screen"], apply: (c) => injectImport(c, PREVIEW_REGISTRY, `import ${PACKAGE}.presentation.${f}.${F}Screen`) },
494
+ { presets: ["feature", "screen"], apply: (c) => injectAtAnchor(c, PREVIEW_REGISTRY, "preview-registry", `ScreenPreview("${f}", "${F} (nav destination)") { ${F}Screen(onItemClick = {}) },`) },
495
+ ],
496
+ },
418
497
  ];
419
498
 
420
499
  // Filter steps by active preset; drop any file plan left with zero steps
421
500
  // (e.g. Screen.kt / AppNavHost.kt entirely for `repository`).
422
501
  const fileInjectionPlans = ALL_INJECTION_PLANS.map((p) => ({
423
502
  filePath: p.filePath,
503
+ optional: p.optional === true,
424
504
  steps: p.steps.filter((s) => s.presets.includes(preset)).map((s) => s.apply),
425
- })).filter((p) => p.steps.length > 0);
505
+ }))
506
+ .filter((p) => p.steps.length > 0)
507
+ // An optional shared file (PreviewRegistry.kt when the inspector is disabled)
508
+ // simply isn't wired — a required file that's missing still dies in applyInjectionSteps.
509
+ .filter((p) => !(p.optional && !fs.existsSync(p.filePath)));
426
510
 
427
511
  const fileResults = fileInjectionPlans.map((p) => applyInjectionSteps(p.filePath, p.steps));
428
512
 
@@ -452,6 +536,16 @@ if (dryRun) {
452
536
  for (const line of inj.diff.split("\n").filter(Boolean)) console.log(` + ${line}`);
453
537
  }
454
538
  }
539
+ if (FILES.some((file) => file.wrapInBaseScreen)) {
540
+ console.log(
541
+ `\n${F}Screen.kt is stamped wrapped in BaseScreen (SHELL-05 — pushed destinations wrap their own content).`,
542
+ );
543
+ }
544
+ if (fileInjectionPlans.some((p) => p.filePath === PREVIEW_REGISTRY)) {
545
+ console.log(
546
+ `\n${F}Screen is auto-registered in inspector/PreviewRegistry.kt (renderScreens + gallery + golden baseline pick it up).`,
547
+ );
548
+ }
455
549
  if (writesSpec) {
456
550
  console.log(`\nspecs/${f}.spec.md will be written with default clauses ${F_UPPER}-01..06.`);
457
551
  } else {
@@ -465,7 +559,12 @@ if (dryRun) {
465
559
 
466
560
  let filesWritten = 0;
467
561
  for (const file of FILES) {
468
- const contents = file.isDefaultSpec ? defaultSpec() : applyRename(fs.readFileSync(file.from, "utf8"));
562
+ let contents = file.isDefaultSpec ? defaultSpec() : applyRename(fs.readFileSync(file.from, "utf8"));
563
+ if (file.wrapInBaseScreen) {
564
+ // Pushed destination: wrap the cloned tab-screen body so SHELL-05 passes
565
+ // out of the box (the tab exemplar relies on AppShell for its BaseScreen).
566
+ contents = wrapScreenInBaseScreen(contents, path.relative(ROOT, file.to));
567
+ }
469
568
  fs.mkdirSync(path.dirname(file.to), { recursive: true });
470
569
  fs.writeFileSync(file.to, contents);
471
570
  filesWritten += 1;