create-cmp-cli 0.6.1 → 0.7.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
@@ -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.0",
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": {
@@ -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
@@ -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.)*
@@ -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
@@ -452,6 +515,11 @@ if (dryRun) {
452
515
  for (const line of inj.diff.split("\n").filter(Boolean)) console.log(` + ${line}`);
453
516
  }
454
517
  }
518
+ if (FILES.some((file) => file.wrapInBaseScreen)) {
519
+ console.log(
520
+ `\n${F}Screen.kt is stamped wrapped in BaseScreen (SHELL-05 — pushed destinations wrap their own content).`,
521
+ );
522
+ }
455
523
  if (writesSpec) {
456
524
  console.log(`\nspecs/${f}.spec.md will be written with default clauses ${F_UPPER}-01..06.`);
457
525
  } else {
@@ -465,7 +533,12 @@ if (dryRun) {
465
533
 
466
534
  let filesWritten = 0;
467
535
  for (const file of FILES) {
468
- const contents = file.isDefaultSpec ? defaultSpec() : applyRename(fs.readFileSync(file.from, "utf8"));
536
+ let contents = file.isDefaultSpec ? defaultSpec() : applyRename(fs.readFileSync(file.from, "utf8"));
537
+ if (file.wrapInBaseScreen) {
538
+ // Pushed destination: wrap the cloned tab-screen body so SHELL-05 passes
539
+ // out of the box (the tab exemplar relies on AppShell for its BaseScreen).
540
+ contents = wrapScreenInBaseScreen(contents, path.relative(ROOT, file.to));
541
+ }
469
542
  fs.mkdirSync(path.dirname(file.to), { recursive: true });
470
543
  fs.writeFileSync(file.to, contents);
471
544
  filesWritten += 1;