create-cmp-cli 0.13.0 → 0.14.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 (54) hide show
  1. package/package.json +6 -2
  2. package/packages/harness/package.json +38 -0
  3. package/packages/harness/src/approve.mjs +247 -0
  4. package/packages/harness/src/arch-doc.mjs +69 -0
  5. package/packages/harness/src/comment.mjs +76 -0
  6. package/packages/harness/src/lib/a11y.mjs +113 -0
  7. package/packages/harness/src/lib/affected-tests.mjs +147 -0
  8. package/packages/harness/src/lib/approvals.mjs +1403 -0
  9. package/packages/harness/src/lib/arch-doc.mjs +451 -0
  10. package/packages/harness/src/lib/audit-cadence.mjs +290 -0
  11. package/packages/harness/src/lib/comments.mjs +252 -0
  12. package/packages/harness/src/lib/component-stories.mjs +183 -0
  13. package/packages/harness/src/lib/determinism.mjs +179 -0
  14. package/packages/harness/src/lib/device-lease.mjs +249 -0
  15. package/packages/harness/src/lib/evidence-badge.mjs +158 -0
  16. package/packages/harness/src/lib/evidence-level.mjs +117 -0
  17. package/packages/harness/src/lib/feature-brief.mjs +324 -0
  18. package/packages/harness/src/lib/flight-recorder.mjs +332 -0
  19. package/packages/harness/src/lib/harness-lock.mjs +147 -0
  20. package/packages/harness/src/lib/harness-region.mjs +159 -0
  21. package/packages/harness/src/lib/inputs-hash.mjs +194 -0
  22. package/packages/harness/src/lib/reachability.mjs +211 -0
  23. package/packages/harness/src/lib/receipt-validate.mjs +234 -0
  24. package/packages/harness/src/lib/render.mjs +254 -0
  25. package/packages/harness/src/lib/spec-coverage.mjs +131 -0
  26. package/packages/harness/src/lib/step-cache.mjs +221 -0
  27. package/packages/harness/src/lib/token-drift.mjs +94 -0
  28. package/packages/harness/src/lib/tree.mjs +108 -0
  29. package/packages/harness/src/preview-gallery.mjs +122 -0
  30. package/packages/harness/src/receipt-check.mjs +96 -0
  31. package/packages/harness/src/record-audit.mjs +83 -0
  32. package/packages/harness/src/refusal-demo.mjs +498 -0
  33. package/packages/harness/src/retrospective.mjs +51 -0
  34. package/packages/harness/src/scaffold-feature.mjs +723 -0
  35. package/packages/harness/src/setup-hooks.mjs +33 -0
  36. package/packages/harness/src/verify.mjs +1709 -0
  37. package/packages/harness/src/walkthrough.mjs +499 -0
  38. package/packages/harness/src/watch.mjs +622 -0
  39. package/packages/receipts/package.json +36 -0
  40. package/packages/receipts/src/index.mjs +16 -0
  41. package/packages/receipts/src/inputs-hash.mjs +194 -0
  42. package/packages/receipts/src/receipt-validate.mjs +234 -0
  43. package/src/commands/upgrade.mjs +96 -0
  44. package/src/lib/harness-upgrade.mjs +159 -2
  45. package/src/scaffold.mjs +60 -1
  46. package/template/AGENTS.md +5 -0
  47. package/template/CLAUDE.md +30 -0
  48. package/template/gitignore +8 -0
  49. package/template/qa/lib/harness-lock.mjs +147 -0
  50. package/template/qa/lib/harness-region.mjs +159 -0
  51. package/template/qa/lib/inputs-hash.mjs +1 -1
  52. package/template/qa/lib/receipt-validate.mjs +1 -1
  53. package/template/qa/preview-gallery.mjs +17 -2
  54. package/template/qa/verify.mjs +95 -1
@@ -0,0 +1,723 @@
1
+ #!/usr/bin/env node
2
+ // The `add-feature` stamper — deterministic vertical-slice generator.
3
+ //
4
+ // node qa/scaffold-feature.mjs <FeatureName> [--entity <EntityName>] [--dry-run]
5
+ // node qa/scaffold-feature.mjs <Entity> --preset repository [--dry-run]
6
+ // node qa/scaffold-feature.mjs <FeatureName> --entity <EntityName> --preset screen [--dry-run]
7
+ //
8
+ // Copies the `home` exemplar file set, applies a curated WHOLE-WORD identifier
9
+ // rename (never a blind substring replace — see the rename map below), injects
10
+ // the new feature into the three shared files at their `// cmp:anchor` markers,
11
+ // and writes a default spec clause set. Pure Node, no dependencies.
12
+ //
13
+ // Philosophy: skills instruct, scripts stamp (HARNESS-ROADMAP M3). The AI only
14
+ // refines spec wording after this runs; the file set + wiring are mechanical.
15
+ //
16
+ // --preset (default `feature`, unchanged behavior): one stamping mechanic,
17
+ // three front-doors. Every FILES entry and every injection step below is
18
+ // tagged with the set of presets it belongs to; the active preset filters
19
+ // both lists before anything is written. There is no forked copy of this
20
+ // script per preset — `feature` is simply `repository` + `screen` + nav wiring
21
+ // that spans both, applied together.
22
+ //
23
+ // feature (default) — all 11 files; DI repo+usecase+viewModel; nav
24
+ // route+import; spec FEATURE-01..07.
25
+ // repository <Entity> — ONLY the 5 data/domain files; DI repo+usecase ONLY;
26
+ // no nav, no viewModel, no spec file, zero SPEC tags. The
27
+ // positional arg IS the entity (no --entity, no feature name).
28
+ // screen <Feature> --entity <E> — ONLY presentation + tests + spec (3
29
+ // test files carry all 6 SPEC tags); DI viewModel ONLY; nav
30
+ // route+import. Requires the entity's data layer to already
31
+ // exist (validated before anything is written — see below).
32
+
33
+ import fs from "node:fs";
34
+ import path from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+
37
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
38
+
39
+ function die(message) {
40
+ console.error(`error: ${message}`);
41
+ process.exit(1);
42
+ }
43
+
44
+ // ── Argument parsing ────────────────────────────────────────────────────────
45
+
46
+ const args = process.argv.slice(2);
47
+ const positional = args.filter((a) => !a.startsWith("--"));
48
+ const dryRun = args.includes("--dry-run");
49
+ const entityFlagIdx = args.indexOf("--entity");
50
+ const entityArg = entityFlagIdx !== -1 ? args[entityFlagIdx + 1] : undefined;
51
+
52
+ const PRESETS = new Set(["feature", "screen", "repository"]);
53
+ const presetFlagIdx = args.indexOf("--preset");
54
+ const preset = presetFlagIdx !== -1 ? args[presetFlagIdx + 1] : "feature";
55
+ if (!PRESETS.has(preset)) {
56
+ die(`"${preset}" is not a valid --preset — choose one of: feature, screen, repository.`);
57
+ }
58
+
59
+ const USAGE =
60
+ "usage:\n" +
61
+ " node qa/scaffold-feature.mjs <FeatureName> [--entity <EntityName>] [--dry-run]\n" +
62
+ " node qa/scaffold-feature.mjs <Entity> --preset repository [--dry-run]\n" +
63
+ " node qa/scaffold-feature.mjs <FeatureName> --entity <EntityName> --preset screen [--dry-run]";
64
+
65
+ const positionalName = positional[0];
66
+ if (!positionalName) {
67
+ die(`${USAGE}\n The positional name is required, e.g. \`Favorites\`.`);
68
+ }
69
+
70
+ const IDENTIFIER_RE = /^[A-Z][A-Za-z0-9]*$/;
71
+ if (!IDENTIFIER_RE.test(positionalName)) {
72
+ die(
73
+ `"${positionalName}" is not a valid PascalCase Kotlin identifier. Use e.g. "Favorites", "Bookmarks".`,
74
+ );
75
+ }
76
+
77
+ function defaultEntity(feature) {
78
+ // Naive de-pluralization — the skill's interview step should let a human
79
+ // override this via --entity when it's wrong (Categories -> Category, etc).
80
+ if (feature.endsWith("ies") && feature.length > 3) return `${feature.slice(0, -3)}y`;
81
+ if (feature.endsWith("s") && !feature.endsWith("ss")) return feature.slice(0, -1);
82
+ return feature;
83
+ }
84
+
85
+ // `repository` preset: the positional arg IS the entity — no feature name, no
86
+ // nav/presentation slice at all. `feature`/`screen`: positional is the feature
87
+ // name; --entity defaults via de-pluralization if omitted.
88
+ const featureName = preset === "repository" ? undefined : positionalName;
89
+ const entityName = preset === "repository" ? positionalName : (entityArg ?? defaultEntity(positionalName));
90
+ if (!IDENTIFIER_RE.test(entityName)) {
91
+ die(`"${entityName}" is not a valid PascalCase Kotlin identifier for --entity.`);
92
+ }
93
+
94
+ // `repository` preset has no feature name (no nav/presentation/spec slice), so
95
+ // F/f/F_UPPER are never read for it — the rename map still needs harmless
96
+ // values to build (its feature-shaped entries never match repository-preset
97
+ // file contents, which only reference the exemplar's own entity, e.g.
98
+ // Item/ItemRepository/GetItemsUseCase for the default `home` exemplar).
99
+ const F = featureName ?? entityName; // PascalCase feature, e.g. Favorites
100
+ const f = F[0].toLowerCase() + F.slice(1); // camelCase/package segment, e.g. favorites
101
+ const F_UPPER = F.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); // FAVORITES
102
+ const E = entityName; // PascalCase entity, e.g. Favorite
103
+
104
+ // ── Resolve the target project's real package ───────────────────────────────
105
+ // This script runs POST-scaffold, so the package placeholder token is already
106
+ // resolved in the target project. Parse it from composeApp/build.gradle.kts
107
+ // (namespace) or, failing that, from any source file's `package` line.
108
+ //
109
+ // Detect "still unresolved" by TOKEN SHAPE (`/^__[A-Z_]+__$/`), never by
110
+ // comparing against the literal placeholder string: this script is itself a
111
+ // template file that goes through the scaffold's own token-replacement pass,
112
+ // which does a blind text substitution of that literal placeholder -> the
113
+ // real package wherever it appears in file CONTENT — including a string
114
+ // literal sitting right here. A literal comparison would silently become
115
+ // `m[1] !== "<the real package>"` post-stamp (always true for the very
116
+ // project it should detect as unresolved) and never fire. A shape regex never
117
+ // spells the placeholder out, so the pipeline has nothing to match.
118
+ const UNRESOLVED_TOKEN_RE = /^__[A-Z_]+__$/;
119
+
120
+ function resolvePackage() {
121
+ const gradleFile = path.join(ROOT, "composeApp", "build.gradle.kts");
122
+ if (fs.existsSync(gradleFile)) {
123
+ const contents = fs.readFileSync(gradleFile, "utf8");
124
+ const m = contents.match(/namespace\s*=\s*"([^"]+)"/);
125
+ if (m && !UNRESOLVED_TOKEN_RE.test(m[1])) return m[1];
126
+ }
127
+ const homeViewModel = path.join(
128
+ ROOT,
129
+ "composeApp/src/commonMain/kotlin",
130
+ ...guessPackageDirFromDisk(),
131
+ "presentation/home/HomeViewModel.kt",
132
+ );
133
+ if (fs.existsSync(homeViewModel)) {
134
+ const m = fs.readFileSync(homeViewModel, "utf8").match(/^package\s+([\w.]+)\.presentation\.home\s*$/m);
135
+ if (m) return m[1];
136
+ }
137
+ die(
138
+ "could not resolve the project's package — expected a resolved `namespace = \"...\"` in " +
139
+ "composeApp/build.gradle.kts (found __PACKAGE__ unresolved, or the file is missing). " +
140
+ "Run this script POST-scaffold, in a project that has already been stamped.",
141
+ );
142
+ }
143
+
144
+ // Best-effort directory walk to find the HomeViewModel.kt under some package
145
+ // path when build.gradle.kts didn't yield an answer (fallback path only).
146
+ function guessPackageDirFromDisk() {
147
+ const base = path.join(ROOT, "composeApp/src/commonMain/kotlin");
148
+ let dir = base;
149
+ const segments = [];
150
+ // Walk down single-child directories until we hit `presentation` or run out.
151
+ while (fs.existsSync(dir)) {
152
+ const entries = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory());
153
+ if (entries.length !== 1) break;
154
+ if (entries[0].name === "presentation") break;
155
+ segments.push(entries[0].name);
156
+ dir = path.join(dir, entries[0].name);
157
+ }
158
+ return segments;
159
+ }
160
+
161
+ const PACKAGE = resolvePackage();
162
+ const PACKAGE_DIR = PACKAGE.split(".").join("/");
163
+
164
+ // ── The file set (§4) ───────────────────────────────────────────────────────
165
+ // Source paths are relative to composeApp/src/<sourceSet>/kotlin/<PACKAGE_DIR>.
166
+
167
+ const SRC = (sourceSet) => path.join(ROOT, "composeApp/src", sourceSet, "kotlin", PACKAGE_DIR);
168
+
169
+ // ── Configurable exemplar (GENESIS-FLOW-DESIGN.md §1) ───────────────────────
170
+ // This stamper clones from the CONFIGURED exemplar (qa/approvals.json's
171
+ // `exemplarFeature`, defaulting to `home`) — the same resolution
172
+ // qa/lib/approvals.mjs's governed-artifact registry uses, so a stamped feature
173
+ // always matches what the registry hashes. Tolerant of a missing/pre-genesis-flow
174
+ // qa/lib/approvals.mjs (mirrors the seedUnreviewed step further down): a broken
175
+ // or absent import falls back to the hardcoded `home`/`Item` shape that predates
176
+ // configurability — this script must never refuse to stamp because the approvals
177
+ // ledger's tooling isn't present.
178
+ let approvalsLib = null;
179
+ try {
180
+ approvalsLib = await import("./lib/approvals.mjs");
181
+ } catch {
182
+ approvalsLib = null;
183
+ }
184
+
185
+ function resolveSourceNames() {
186
+ if (approvalsLib && typeof approvalsLib.resolveExemplarNames === "function") {
187
+ return approvalsLib.resolveExemplarNames(ROOT);
188
+ }
189
+ return { f: "home", F: "Home", F_UPPER: "HOME", E: "Item" };
190
+ }
191
+
192
+ const { f: SOURCE_f, F: SOURCE_F, F_UPPER: SOURCE_F_UPPER, E: SOURCE_E } = resolveSourceNames();
193
+
194
+ // Extras beyond the canonical 11-file shape (§1: "clone the canonical set and
195
+ // WARN, listing what it skipped — never silently"). Scoped by NAME, not by
196
+ // directory membership: the exemplar's presentation directory can legitimately
197
+ // hold files that aren't "this feature's own" (e.g. the shipped `home`
198
+ // exemplar's directory also holds `DetailScreen.kt` — a deliberately
199
+ // NOT-cloned, permanent fixture per add-feature's SKILL.md, not drift) — so a
200
+ // file only counts as an extra when its name is actually built FROM the
201
+ // exemplar's own identifiers (starts with `${sourceF}` in presentation/, or
202
+ // contains the entity as a whole word in the shared domain/data directories).
203
+ function escapeRegExp(s) {
204
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
205
+ }
206
+
207
+ function scanDirForExtras(sourceSet, dirRel, canonicalBasenames, matches) {
208
+ const dirAbs = path.join(SRC(sourceSet), dirRel);
209
+ if (!fs.existsSync(dirAbs)) return [];
210
+ return fs
211
+ .readdirSync(dirAbs, { withFileTypes: true })
212
+ .filter((e) => e.isFile() && e.name.endsWith(".kt") && matches(e.name) && !canonicalBasenames.has(e.name))
213
+ .map((e) => path.relative(ROOT, path.join(dirAbs, e.name)));
214
+ }
215
+
216
+ function findExemplarExtras(sourceF, sourceLowerF, sourceEntity) {
217
+ const namedForFeature = (n) => n.startsWith(sourceF);
218
+ const entityWholeWord = new RegExp(`\\b${escapeRegExp(sourceEntity)}\\b`);
219
+ return [
220
+ ...scanDirForExtras("commonMain", `presentation/${sourceLowerF}`, new Set([`${sourceF}Screen.kt`, `${sourceF}ViewModel.kt`]), namedForFeature),
221
+ ...scanDirForExtras("commonTest", `presentation/${sourceLowerF}`, new Set([`${sourceF}ViewModelTest.kt`]), namedForFeature),
222
+ ...scanDirForExtras("desktopTest", `presentation/${sourceLowerF}`, new Set([`${sourceF}ScreenTest.kt`, `${sourceF}GoldenTreeTest.kt`]), namedForFeature),
223
+ ...scanDirForExtras("commonMain", "domain/model", new Set([`${sourceEntity}.kt`]), (n) => entityWholeWord.test(n)),
224
+ ...scanDirForExtras("commonMain", "domain/repository", new Set([`${sourceEntity}Repository.kt`]), (n) => entityWholeWord.test(n)),
225
+ ...scanDirForExtras("commonMain", "domain/usecase", new Set([`Get${sourceEntity}sUseCase.kt`]), (n) => entityWholeWord.test(n)),
226
+ ...scanDirForExtras("commonMain", "data/remote", new Set([`${sourceEntity}RepositoryImpl.kt`]), (n) => entityWholeWord.test(n)),
227
+ ...scanDirForExtras("commonTest", "testing/fakes", new Set([`Fake${sourceEntity}Repository.kt`]), (n) => entityWholeWord.test(n)),
228
+ ];
229
+ }
230
+
231
+ const exemplarExtras = findExemplarExtras(SOURCE_F, SOURCE_f, SOURCE_E);
232
+ if (exemplarExtras.length > 0) {
233
+ console.log(
234
+ `WARNING: the "${SOURCE_f}" exemplar has files beyond the canonical 11-file shape — only the canonical set is cloned, these are SKIPPED:\n` +
235
+ exemplarExtras.map((p) => ` - ${p}`).join("\n"),
236
+ );
237
+ }
238
+
239
+ // ── The rename map (§3) ──────────────────────────────────────────────────────
240
+ // Whole-word (\b-delimited), applied LONGEST KEY FIRST so compound entries
241
+ // (<Entity>RepositoryImpl) resolve before their substrings (<Entity>Repository,
242
+ // <Entity>). Generated from the CLONE-SOURCE's own names (SOURCE_F/SOURCE_f/
243
+ // SOURCE_E/SOURCE_F_UPPER) rather than hardcoded `Home`/`home`/`Item`/`HOME` — when
244
+ // the source is the shipped `home` exemplar these resolve to exactly those
245
+ // literals, so stamping from `home` is byte-identical to before configurability
246
+ // existed (pinned by test/genesis-flow.test.mjs's parity pin). Anything not in this
247
+ // list is left untouched by design (see design doc §3 "LEAVE GENERIC" —
248
+ // awaitItem, items, item, goldenItems, itemId, onItemClick, id, title, subtitle,
249
+ // and every androidx./kotlinx./org.koin./kotlin. token).
250
+
251
+ const RENAME_MAP = [
252
+ [`${SOURCE_F}ScreenTest`, `${F}ScreenTest`],
253
+ [`${SOURCE_F}ViewModelTest`, `${F}ViewModelTest`],
254
+ [`${SOURCE_F}GoldenTreeTest`, `${F}GoldenTreeTest`],
255
+ [`${SOURCE_F}Screen`, `${F}Screen`],
256
+ [`${SOURCE_F}ViewModel`, `${F}ViewModel`],
257
+ [`${SOURCE_f}_title`, `${f}_title`],
258
+ [`${SOURCE_f}_error`, `${f}_error`],
259
+ [`${SOURCE_f}_empty`, `${f}_empty`],
260
+ [`${SOURCE_f}_loading`, `${f}_loading`],
261
+ [`${SOURCE_f}_retry`, `${f}_retry`],
262
+ [`${SOURCE_f}_item_`, `${f}_item_`],
263
+ [`Fake${SOURCE_E}Repository`, `Fake${E}Repository`],
264
+ [`${SOURCE_E}RepositoryImpl`, `${E}RepositoryImpl`],
265
+ [`${SOURCE_E}Repository`, `${E}Repository`],
266
+ [`Get${SOURCE_E}sUseCase`, `Get${E}sUseCase`],
267
+ [`get${SOURCE_E}sCallCount`, `get${E}sCallCount`],
268
+ [`get${SOURCE_E}s`, `get${E}s`],
269
+ [SOURCE_E, E],
270
+ // Spec + test SPEC-tag retargeting (§6): <SOURCE_F_UPPER>-0N -> <F_UPPER>-0N,
271
+ // then the bare <SOURCE_F_UPPER> -> <F_UPPER> (must run AFTER the -0 form or
272
+ // "<SOURCE_F_UPPER>-0" would be partially consumed oddly — longest-key-first
273
+ // already orders this).
274
+ [`${SOURCE_F_UPPER}-0`, `${F_UPPER}-0`],
275
+ [SOURCE_F_UPPER, F_UPPER],
276
+ // Package segment / path / golden filename / display text. Order matters:
277
+ // must run after <SOURCE_F>Xxx / <SOURCE_f>_xxx above so those compounds are
278
+ // already resolved; the bare source-feature word only matches the standalone
279
+ // package segment, golden filename stem, and prose by this point.
280
+ [SOURCE_f, f],
281
+ [SOURCE_F, F],
282
+ ].sort((a, b) => b[0].length - a[0].length);
283
+
284
+ const COMPILED_RENAMES = RENAME_MAP.map(([from, to]) => [new RegExp(`\\b${escapeRegExp(from)}\\b`, "g"), to]);
285
+
286
+ function applyRename(text) {
287
+ let out = text;
288
+ for (const [re, to] of COMPILED_RENAMES) out = out.replace(re, to);
289
+ return out;
290
+ }
291
+
292
+ // Every entry is tagged with the presets it belongs to. `feature` gets all 11
293
+ // (the union); `repository` gets just the 5 data/domain files; `screen` gets
294
+ // just the 6 presentation+tests+spec files. Filtered by the active preset
295
+ // right after definition — nothing below this point sees the untagged list.
296
+ //
297
+ // `from:` sides are built from the CLONE-SOURCE names (SOURCE_F/SOURCE_f/
298
+ // SOURCE_E) instead of hardcoded `home`/`Item` literals — this is exactly the
299
+ // canonical-11-file SHAPE `exemplarKotlinFileSet` in qa/lib/approvals.mjs
300
+ // encodes (that function is the registry's copy; this one is the stamper's,
301
+ // kept independently so the stamper works even without qa/lib/approvals.mjs —
302
+ // see the resolveSourceNames() fallback above). test/approvals-exemplar-list.test.mjs
303
+ // pins that the two never disagree for a real project.
304
+ const ALL_FILES = [
305
+ { from: path.join(SRC("commonMain"), `domain/model/${SOURCE_E}.kt`), to: path.join(SRC("commonMain"), `domain/model/${E}.kt`), presets: ["feature", "repository"] },
306
+ { from: path.join(SRC("commonMain"), `domain/repository/${SOURCE_E}Repository.kt`), to: path.join(SRC("commonMain"), `domain/repository/${E}Repository.kt`), presets: ["feature", "repository"] },
307
+ { from: path.join(SRC("commonMain"), `domain/usecase/Get${SOURCE_E}sUseCase.kt`), to: path.join(SRC("commonMain"), `domain/usecase/Get${E}sUseCase.kt`), presets: ["feature", "repository"] },
308
+ { from: path.join(SRC("commonMain"), `data/remote/${SOURCE_E}RepositoryImpl.kt`), to: path.join(SRC("commonMain"), `data/remote/${E}RepositoryImpl.kt`), presets: ["feature", "repository"] },
309
+ { from: path.join(SRC("commonTest"), `testing/fakes/Fake${SOURCE_E}Repository.kt`), to: path.join(SRC("commonTest"), `testing/fakes/Fake${E}Repository.kt`), presets: ["feature", "repository"] },
310
+ { from: path.join(SRC("commonMain"), `presentation/${SOURCE_f}/${SOURCE_F}Screen.kt`), to: path.join(SRC("commonMain"), `presentation/${f}/${F}Screen.kt`), presets: ["feature", "screen"], wrapInBaseScreen: true },
311
+ { from: path.join(SRC("commonMain"), `presentation/${SOURCE_f}/${SOURCE_F}ViewModel.kt`), to: path.join(SRC("commonMain"), `presentation/${f}/${F}ViewModel.kt`), presets: ["feature", "screen"] },
312
+ { from: path.join(SRC("commonTest"), `presentation/${SOURCE_f}/${SOURCE_F}ViewModelTest.kt`), to: path.join(SRC("commonTest"), `presentation/${f}/${F}ViewModelTest.kt`), presets: ["feature", "screen"] },
313
+ { from: path.join(SRC("desktopTest"), `presentation/${SOURCE_f}/${SOURCE_F}ScreenTest.kt`), to: path.join(SRC("desktopTest"), `presentation/${f}/${F}ScreenTest.kt`), presets: ["feature", "screen"] },
314
+ { from: path.join(SRC("desktopTest"), `presentation/${SOURCE_f}/${SOURCE_F}GoldenTreeTest.kt`), to: path.join(SRC("desktopTest"), `presentation/${f}/${F}GoldenTreeTest.kt`), presets: ["feature", "screen"] },
315
+ { from: path.join(ROOT, `specs/${SOURCE_f}.spec.md`), to: path.join(ROOT, `specs/${f}.spec.md`), isDefaultSpec: true, presets: ["feature", "screen"] },
316
+ ];
317
+
318
+ const FILES = ALL_FILES.filter((file) => file.presets.includes(preset));
319
+
320
+ // Golden baseline: NOT copied (a feature's golden tree is captured fresh via
321
+ // UPDATE_GOLDEN=1, per the skill's step 5), but we still verify the source
322
+ // files above genuinely exist before doing anything.
323
+ for (const file of FILES) {
324
+ if (!fs.existsSync(file.from)) {
325
+ die(
326
+ `exemplar source file missing: ${path.relative(ROOT, file.from)}\n` +
327
+ `This script must run in an unmodified (or already-featured) create-cmp scaffold ` +
328
+ `where the "${SOURCE_f}" exemplar (qa/approvals.json's "exemplarFeature") still exists in its canonical 11-file shape.`,
329
+ );
330
+ }
331
+ }
332
+
333
+ // `screen` preset composes on top of an existing entity's data layer — the
334
+ // stamped ViewModel test references Get<E>sUseCase/Fake<E>Repository and the
335
+ // screen references <E>, so those must already exist. Validate BEFORE writing
336
+ // anything (die early, no half-stamp).
337
+ if (preset === "screen") {
338
+ const requiredExisting = [
339
+ path.join(SRC("commonMain"), `domain/usecase/Get${E}sUseCase.kt`),
340
+ path.join(SRC("commonTest"), `testing/fakes/Fake${E}Repository.kt`),
341
+ path.join(SRC("commonMain"), `domain/model/${E}.kt`),
342
+ ];
343
+ const missing = requiredExisting.filter((p) => !fs.existsSync(p));
344
+ if (missing.length > 0) {
345
+ die(
346
+ `entity "${E}" not found — run \`node qa/scaffold-feature.mjs ${E} --preset repository\` first, ` +
347
+ "or use --preset feature to generate the data layer too.",
348
+ );
349
+ }
350
+ }
351
+
352
+ // Name-taken check.
353
+ const existing = FILES.filter((file) => fs.existsSync(file.to) && !file.isDefaultSpec).map((file) =>
354
+ path.relative(ROOT, file.to),
355
+ );
356
+ if (existing.length > 0) {
357
+ die(
358
+ `"${preset === "repository" ? E : featureName}" appears to already exist — these target files are already present:\n` +
359
+ existing.map((p) => ` ${p}`).join("\n"),
360
+ );
361
+ }
362
+ if (FILES.some((file) => file.isDefaultSpec) && fs.existsSync(path.join(ROOT, `specs/${f}.spec.md`))) {
363
+ die(`specs/${f}.spec.md already exists — feature "${featureName}" appears to already exist.`);
364
+ }
365
+
366
+ // ── Default spec clause set (§6) ────────────────────────────────────────────
367
+
368
+ function defaultSpec() {
369
+ return `# Spec: ${f}
370
+
371
+ > Generated by \`scaffold-feature.mjs\` from the \`${SOURCE_f}\` exemplar shape. Refine the clause
372
+ > prose below for ${F}'s real behavior (ids stay fixed) before running the verify lane.
373
+ > Platform-behavior clauses (alarms, notifications, lock screen, audio routing) cannot be
374
+ > proven by any desktop tier — cite them from an instrumented test in
375
+ > \`composeApp/src/androidInstrumentedTest\` (see docs/TESTING.md, "The instrumented tier").
376
+
377
+ - **${F_UPPER}-01** — Given the ${F} screen opens, When ${f} are being loaded, Then a loading
378
+ indicator is shown and no ${f} are visible.
379
+ - **${F_UPPER}-02** — Given the repository returns ${f}, When loading completes, Then the ${f}
380
+ are listed with their title and subtitle, and no error is shown.
381
+ - **${F_UPPER}-03** — Given the repository fails, When loading completes, Then a human-readable
382
+ error message is shown (\`${f}_error\`) and no ${f} are visible — the copy is mapped in
383
+ presentation from the failure's \`DomainError\` kind, never a raw exception message.
384
+ - **${F_UPPER}-04** — Given a load has failed, When the data source recovers and the user
385
+ triggers a reload (\`${f}_retry\`), Then the error clears and the ${f} render.
386
+ - **${F_UPPER}-05** — Given ${f} are listed, When the user taps an item, Then the app navigates
387
+ to that item's detail.
388
+ - **${F_UPPER}-06** — Given the ${F} screen renders, When its structure is inspected, Then the
389
+ screen matches its committed golden tree (\`qa/golden/${f}.json\`) — structural regressions
390
+ are intentional, declared changes only.
391
+ - **${F_UPPER}-07** — Given the repository succeeds with zero ${f}, When loading completes, Then
392
+ the empty state is shown (\`${f}_empty\`) and neither ${f} nor an error are visible.
393
+ `;
394
+ }
395
+
396
+ // ── BaseScreen wrap (SHELL-05) ───────────────────────────────────────────────
397
+ // HomeScreen is a TAB — AppShell provides its BaseScreen at the shell layer.
398
+ // The stamped feature, however, is registered as a PUSHED NavHost destination,
399
+ // and SHELL-05 requires every such destination to compose inside BaseScreen
400
+ // (see DetailScreen for the pattern). Without this transform the stamped slice
401
+ // fails verify out of the box. Anchored on the exemplar's known shape; fails
402
+ // loudly if HomeScreen drifts (same discipline as the cmp:anchor markers).
403
+ function wrapScreenInBaseScreen(content, relPathForErrors) {
404
+ if (content.includes("BaseScreen")) return content; // already wrapped — idempotent
405
+
406
+ const lines = content.split("\n");
407
+
408
+ // 1. Import — mirror DetailScreen's ordering: presentation.components.BaseScreen
409
+ // sits immediately before the presentation.theme imports.
410
+ const themeImportIdx = lines.findIndex((l) => /^import .+\.presentation\.theme\./.test(l));
411
+ if (themeImportIdx === -1) {
412
+ die(
413
+ `no presentation.theme import found in ${relPathForErrors} — the ${SOURCE_F}Screen exemplar ` +
414
+ "drifted from the shape this stamper wraps; cannot place the BaseScreen import.",
415
+ );
416
+ }
417
+ const importLine = lines[themeImportIdx].replace(
418
+ /^import (.+)\.presentation\.theme\..*$/,
419
+ "import $1.presentation.components.BaseScreen",
420
+ );
421
+ lines.splice(themeImportIdx, 0, importLine);
422
+
423
+ // 2. Root container start: the exemplar's body root is a top-level ` ScreenColumn(`
424
+ // call (the component-vocabulary rewrite — see docs/proposals/component-system-deep-dive.md
425
+ // §5). The tag literal inside the call (`screenTag = "home"`) is renamed separately by
426
+ // RENAME_MAP, so match on the call shape only, not the full line.
427
+ const rootIdx = lines.findIndex((l) => /^ {4}ScreenColumn\(/.test(l));
428
+ if (rootIdx === -1) {
429
+ die(
430
+ `root " ScreenColumn(" not found in ${relPathForErrors} — the ${SOURCE_F}Screen exemplar drifted ` +
431
+ "from the shape this stamper wraps in BaseScreen.",
432
+ );
433
+ }
434
+
435
+ // 3. Root container end: the ` }` immediately before the function's closing `}`.
436
+ let funCloseIdx = -1;
437
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
438
+ if (lines[i] === "}") {
439
+ funCloseIdx = i;
440
+ break;
441
+ }
442
+ }
443
+ if (funCloseIdx === -1 || lines[funCloseIdx - 1] !== " }") {
444
+ die(
445
+ `could not locate the root container's closing brace in ${relPathForErrors} — the ` +
446
+ `${SOURCE_F}Screen exemplar drifted from the shape this stamper wraps in BaseScreen.`,
447
+ );
448
+ }
449
+ const rootCloseIdx = funCloseIdx - 1;
450
+
451
+ // 4. Wrap: indent the container block one level and enclose it in BaseScreen { }.
452
+ const indented = lines.slice(rootIdx, rootCloseIdx + 1).map((l) => (l.length ? ` ${l}` : l));
453
+ return [
454
+ ...lines.slice(0, rootIdx),
455
+ " BaseScreen {",
456
+ ...indented,
457
+ " }",
458
+ ...lines.slice(rootCloseIdx + 1),
459
+ ].join("\n");
460
+ }
461
+
462
+ // ── Anchor injection (§5) ────────────────────────────────────────────────────
463
+ // Idempotent (skip if the feature's line is already present); fails loudly if
464
+ // an anchor marker is missing from the shared file. Each function is a pure
465
+ // string -> string transform so multiple injections into the SAME file can be
466
+ // chained (each one sees the previous one's output) before a single write.
467
+
468
+ function injectAtAnchor(content, filePathForErrors, anchorName, lineToInsert) {
469
+ const anchorLine = `// cmp:anchor ${anchorName}`;
470
+ const lines = content.split("\n");
471
+ const anchorLineIdx = lines.findIndex((l) => l.trim() === anchorLine);
472
+ if (anchorLineIdx === -1) {
473
+ die(
474
+ `anchor "${anchorName}" not found in ${path.relative(ROOT, filePathForErrors)}. ` +
475
+ "The template shared file may be out of date with this stamper — " +
476
+ "check for the `// cmp:anchor` marker comments.",
477
+ );
478
+ }
479
+
480
+ // Idempotency: if the line is already present verbatim (ignoring leading
481
+ // whitespace), skip — running the stamper twice for the same feature must
482
+ // not duplicate wiring.
483
+ const alreadyPresent = lines.some((l) => l.trim() === lineToInsert.trim());
484
+ if (alreadyPresent) return { content, skipped: true, diff: "" };
485
+
486
+ // Match the anchor comment's own indentation so the inserted line sits at
487
+ // the same nesting level as its sibling lines (e.g. inside a `module { }`
488
+ // block, or a `NavHost { }` block).
489
+ const anchorIndent = lines[anchorLineIdx].match(/^\s*/)[0];
490
+ const insertedLine = `${anchorIndent}${lineToInsert}`;
491
+ lines.splice(anchorLineIdx, 0, insertedLine);
492
+ return { content: lines.join("\n"), skipped: false, diff: `${insertedLine}\n` };
493
+ }
494
+
495
+ function injectImport(content, filePathForErrors, importLine) {
496
+ if (content.split("\n").some((l) => l.trim() === importLine.trim())) {
497
+ return { content, skipped: true, diff: "" };
498
+ }
499
+
500
+ const diImportsAnchor = "// cmp:anchor di-imports";
501
+ const lines = content.split("\n");
502
+ const anchorLineIdx = lines.findIndex((l) => l.trim() === diImportsAnchor);
503
+ if (anchorLineIdx !== -1) {
504
+ lines.splice(anchorLineIdx, 0, importLine);
505
+ return { content: lines.join("\n"), skipped: false, diff: `${importLine}\n` };
506
+ }
507
+
508
+ // Fallback: append after the last existing `import ` line (used by
509
+ // AppNavHost.kt, which has no dedicated imports anchor).
510
+ let lastImportIdx = -1;
511
+ lines.forEach((line, i) => {
512
+ if (line.startsWith("import ")) lastImportIdx = i;
513
+ });
514
+ if (lastImportIdx === -1) {
515
+ die(`no import block found in ${path.relative(ROOT, filePathForErrors)} to inject "${importLine}" near.`);
516
+ }
517
+ lines.splice(lastImportIdx + 1, 0, importLine);
518
+ return { content: lines.join("\n"), skipped: false, diff: `${importLine}\n` };
519
+ }
520
+
521
+ // Applies an ordered list of (content -> result) steps to one file, chaining
522
+ // each step's output into the next, and returns the final content plus a flat
523
+ // diff log. Reads the file once; the caller writes it once.
524
+ function applyInjectionSteps(filePath, steps) {
525
+ if (!fs.existsSync(filePath)) {
526
+ die(`shared file missing: ${path.relative(ROOT, filePath)} — cannot inject wiring for the new feature.`);
527
+ }
528
+ let content = fs.readFileSync(filePath, "utf8");
529
+ const log = [];
530
+ for (const step of steps) {
531
+ const result = step(content);
532
+ content = result.content;
533
+ log.push({ skipped: result.skipped, diff: result.diff });
534
+ }
535
+ return { filePath, content, log };
536
+ }
537
+
538
+ // ── Plan ─────────────────────────────────────────────────────────────────────
539
+
540
+ const plan = {
541
+ feature: F,
542
+ entity: E,
543
+ package: PACKAGE,
544
+ files: FILES.map((file) => ({
545
+ from: path.relative(ROOT, file.from),
546
+ to: path.relative(ROOT, file.to),
547
+ })),
548
+ };
549
+
550
+ const APP_MODULE = path.join(SRC("commonMain"), "di/AppModule.kt");
551
+ const SCREEN_KT = path.join(SRC("commonMain"), "presentation/navigation/Screen.kt");
552
+ const APP_NAV_HOST = path.join(SRC("commonMain"), "presentation/navigation/AppNavHost.kt");
553
+ // Optional (present only when the inspector feature is enabled): the preview
554
+ // registry lives in desktopMain. A stamped pushed-destination screen is
555
+ // registered here so `renderScreens`, the gallery, and golden baselines pick it
556
+ // up with zero hand edits — the same reason we wire nav/DI automatically.
557
+ const PREVIEW_REGISTRY = path.join(SRC("desktopMain"), "inspector/PreviewRegistry.kt");
558
+
559
+ // ARCH-14: ViewModels are registered with EXPLICIT `viewModel { … }` factories, never
560
+ // reflection-based `viewModelOf` (it silently ignores Kotlin constructor default
561
+ // parameter values — a compile-time wiring error becomes a runtime resolution crash).
562
+ // The stamped registration is cloned from the EXEMPLAR's own factory line in
563
+ // AppModule.kt (rename applied), so its get() arity always matches the cloned
564
+ // ViewModel's constructor whatever shape the configured exemplar has. The canonical
565
+ // single-use-case shape is the fallback when the exemplar's line isn't found.
566
+ function explicitViewModelRegistration(appModuleContent) {
567
+ const exemplarFactory = new RegExp(
568
+ `viewModel\\s*\\{\\s*${escapeRegExp(SOURCE_F)}ViewModel\\([^)]*\\)\\s*\\}`,
569
+ );
570
+ const m = appModuleContent.match(exemplarFactory);
571
+ if (m) return applyRename(m[0].replace(/\s+/g, " "));
572
+ return `viewModel { ${F}ViewModel(get()) }`;
573
+ }
574
+
575
+ // Each step is tagged with the presets it belongs to, same mechanism as
576
+ // FILES above: `repository` gets repo+usecase DI (+ imports) only; `screen`
577
+ // gets viewModel DI (+ import) + nav route + import only; `feature` gets the
578
+ // union (unchanged).
579
+ const ALL_INJECTION_PLANS = [
580
+ {
581
+ filePath: APP_MODULE,
582
+ steps: [
583
+ { presets: ["feature", "repository"], apply: (c) => injectImport(c, APP_MODULE, `import ${PACKAGE}.data.remote.${E}RepositoryImpl`) },
584
+ { presets: ["feature", "repository"], apply: (c) => injectImport(c, APP_MODULE, `import ${PACKAGE}.domain.repository.${E}Repository`) },
585
+ { presets: ["feature", "repository"], apply: (c) => injectImport(c, APP_MODULE, `import ${PACKAGE}.domain.usecase.Get${E}sUseCase`) },
586
+ { presets: ["feature", "screen"], apply: (c) => injectImport(c, APP_MODULE, `import ${PACKAGE}.presentation.${f}.${F}ViewModel`) },
587
+ { presets: ["feature", "repository"], apply: (c) => injectAtAnchor(c, APP_MODULE, "di-repositories", `single<${E}Repository> { ${E}RepositoryImpl() }`) },
588
+ { presets: ["feature", "repository"], apply: (c) => injectAtAnchor(c, APP_MODULE, "di-usecases", `factory { Get${E}sUseCase(get()) }`) },
589
+ { presets: ["feature", "screen"], apply: (c) => injectAtAnchor(c, APP_MODULE, "di-viewmodels", explicitViewModelRegistration(c)) },
590
+ ],
591
+ },
592
+ {
593
+ filePath: SCREEN_KT,
594
+ steps: [
595
+ { presets: ["feature", "screen"], apply: (c) => injectAtAnchor(c, SCREEN_KT, "screen-objects", `data object ${F} : Screen(Routes.${F_UPPER})`) },
596
+ { presets: ["feature", "screen"], apply: (c) => injectAtAnchor(c, SCREEN_KT, "route-consts", `const val ${F_UPPER} = "${f}"`) },
597
+ ],
598
+ },
599
+ {
600
+ filePath: APP_NAV_HOST,
601
+ steps: [
602
+ { presets: ["feature", "screen"], apply: (c) => injectImport(c, APP_NAV_HOST, `import ${PACKAGE}.presentation.${f}.${F}Screen`) },
603
+ { presets: ["feature", "screen"], apply: (c) => injectAtAnchor(c, APP_NAV_HOST, "nav-destinations", `composable(Screen.${F}.route) { ${F}Screen(onItemClick = {}) }`) },
604
+ ],
605
+ },
606
+ {
607
+ // Optional: only wired when the inspector feature shipped PreviewRegistry.kt.
608
+ // Registers the stamped screen exactly as the NavHost hosts it (pushed
609
+ // destination → standalone, matching DetailScreen), so preview parity holds.
610
+ filePath: PREVIEW_REGISTRY,
611
+ optional: true,
612
+ steps: [
613
+ { presets: ["feature", "screen"], apply: (c) => injectImport(c, PREVIEW_REGISTRY, `import ${PACKAGE}.presentation.${f}.${F}Screen`) },
614
+ { presets: ["feature", "screen"], apply: (c) => injectAtAnchor(c, PREVIEW_REGISTRY, "preview-registry", `ScreenPreview("${f}", "${F} (nav destination)") { ${F}Screen(onItemClick = {}) },`) },
615
+ ],
616
+ },
617
+ ];
618
+
619
+ // Filter steps by active preset; drop any file plan left with zero steps
620
+ // (e.g. Screen.kt / AppNavHost.kt entirely for `repository`).
621
+ const fileInjectionPlans = ALL_INJECTION_PLANS.map((p) => ({
622
+ filePath: p.filePath,
623
+ optional: p.optional === true,
624
+ steps: p.steps.filter((s) => s.presets.includes(preset)).map((s) => s.apply),
625
+ }))
626
+ .filter((p) => p.steps.length > 0)
627
+ // An optional shared file (PreviewRegistry.kt when the inspector is disabled)
628
+ // simply isn't wired — a required file that's missing still dies in applyInjectionSteps.
629
+ .filter((p) => !(p.optional && !fs.existsSync(p.filePath)));
630
+
631
+ const fileResults = fileInjectionPlans.map((p) => applyInjectionSteps(p.filePath, p.steps));
632
+
633
+ plan.injections = fileResults.flatMap((r) =>
634
+ r.log.map((entry) => ({ file: path.relative(ROOT, r.filePath), skipped: entry.skipped, diff: entry.diff })),
635
+ );
636
+
637
+ // ── Dry-run: print the plan and exit ────────────────────────────────────────
638
+
639
+ const writesSpec = FILES.some((file) => file.isDefaultSpec);
640
+ const planLabel =
641
+ preset === "repository"
642
+ ? `entity "${E}"`
643
+ : `feature "${F}" (entity "${E}")`;
644
+
645
+ if (dryRun) {
646
+ console.log(`Plan for ${planLabel}, package "${PACKAGE}", preset "${preset}":\n`);
647
+ console.log("Files to create:");
648
+ for (const pf of plan.files) console.log(` ${pf.from}\n -> ${pf.to}`);
649
+ console.log("\nAnchor injections:");
650
+ if (plan.injections.length === 0) console.log(" (none for this preset)");
651
+ for (const inj of plan.injections) {
652
+ if (inj.skipped) {
653
+ console.log(` ${inj.file}: (already present, skip)`);
654
+ } else {
655
+ console.log(` ${inj.file}:`);
656
+ for (const line of inj.diff.split("\n").filter(Boolean)) console.log(` + ${line}`);
657
+ }
658
+ }
659
+ if (FILES.some((file) => file.wrapInBaseScreen)) {
660
+ console.log(
661
+ `\n${F}Screen.kt is stamped wrapped in BaseScreen (SHELL-05 — pushed destinations wrap their own content).`,
662
+ );
663
+ }
664
+ if (fileInjectionPlans.some((p) => p.filePath === PREVIEW_REGISTRY)) {
665
+ console.log(
666
+ `\n${F}Screen is auto-registered in inspector/PreviewRegistry.kt (renderScreens + gallery + golden baseline pick it up).`,
667
+ );
668
+ }
669
+ if (writesSpec) {
670
+ console.log(`\nspecs/${f}.spec.md will be written with default clauses ${F_UPPER}-01..07.`);
671
+ } else {
672
+ console.log("\nNo spec file written by this preset (zero SPEC clauses/tags added).");
673
+ }
674
+ console.log("\n(dry run — nothing written)");
675
+ process.exit(0);
676
+ }
677
+
678
+ // ── Execute ──────────────────────────────────────────────────────────────────
679
+
680
+ let filesWritten = 0;
681
+ for (const file of FILES) {
682
+ let contents = file.isDefaultSpec ? defaultSpec() : applyRename(fs.readFileSync(file.from, "utf8"));
683
+ if (file.wrapInBaseScreen) {
684
+ // Pushed destination: wrap the cloned tab-screen body so SHELL-05 passes
685
+ // out of the box (the tab exemplar relies on AppShell for its BaseScreen).
686
+ contents = wrapScreenInBaseScreen(contents, path.relative(ROOT, file.to));
687
+ }
688
+ fs.mkdirSync(path.dirname(file.to), { recursive: true });
689
+ fs.writeFileSync(file.to, contents);
690
+ filesWritten += 1;
691
+ }
692
+
693
+ let injectionsApplied = 0;
694
+ for (const result of fileResults) {
695
+ const anyApplied = result.log.some((entry) => !entry.skipped);
696
+ if (!anyApplied) continue;
697
+ fs.writeFileSync(result.filePath, result.content);
698
+ injectionsApplied += result.log.filter((entry) => !entry.skipped).length;
699
+ }
700
+
701
+ console.log(`✓ Scaffolded ${planLabel} [preset: ${preset}] — ${filesWritten} files written, ${injectionsApplied} anchor injections applied.`);
702
+ if (writesSpec) {
703
+ console.log(` specs/${f}.spec.md written with default clauses ${F_UPPER}-01..07 — refine the prose next.`);
704
+
705
+ // Approvals seeding (VERIFICATION-LAYER-DESIGN.md §2): the new feature's spec
706
+ // is a governed artifact (`feature-spec:<name>`) — seed it unreviewed so the
707
+ // verify lane's `approvals` gate SKIP-warns until a human signs off. v1 does
708
+ // NOT refuse to stamp over this (warn-then-enforce-at-verify is the honest
709
+ // default) — reuses `approvalsLib` (already resolved above, tolerantly, for
710
+ // clone-source resolution) so a missing or out-of-date qa/lib/approvals.mjs
711
+ // (an older, pre-approvals scaffold) never blocks the stamp itself.
712
+ try {
713
+ if (!approvalsLib || typeof approvalsLib.seedUnreviewed !== "function") throw new Error("no seedUnreviewed export");
714
+ const artifactId = `feature-spec:${f}`;
715
+ approvalsLib.seedUnreviewed(ROOT, artifactId);
716
+ console.log(` Approval: specs/${f}.spec.md is unreviewed — run \`node qa/approve.mjs ${artifactId}\` once you've reviewed it.`);
717
+ } catch {
718
+ console.log(" (approvals seeding skipped — qa/lib/approvals.mjs not found in this scaffold)");
719
+ }
720
+ } else {
721
+ console.log(" No spec file written by this preset (zero SPEC clauses/tags added).");
722
+ }
723
+ console.log(` Next: ${preset === "repository" ? "customize the entity + repository impl, then" : "capture the golden tree, then"} run node qa/verify.mjs.`);