create-cmp-cli 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +72 -11
  2. package/llms.txt +6 -2
  3. package/package.json +1 -1
  4. package/src/commands/upgrade.mjs +8 -1
  5. package/src/lib/adr-seed.mjs +178 -0
  6. package/src/lib/registry.mjs +15 -2
  7. package/src/lib/tabs.mjs +91 -4
  8. package/src/lib/upgrade.mjs +49 -5
  9. package/src/scaffold.mjs +52 -1
  10. package/src/versions/candidates.json +4 -0
  11. package/src/versions/registry.json +88 -0
  12. package/template/.claude/skills/add-feature/SKILL.md +35 -10
  13. package/template/.claude/skills/add-repository/SKILL.md +1 -1
  14. package/template/.claude/skills/add-screen/SKILL.md +13 -7
  15. package/template/.githooks/pre-push +24 -0
  16. package/template/CLAUDE.md +196 -48
  17. package/template/README.md +23 -27
  18. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
  19. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
  20. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +69 -2
  21. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  22. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +54 -0
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  43. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  44. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +13 -0
  45. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  46. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +21 -0
  47. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  48. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
  49. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
  50. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
  51. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
  52. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
  53. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +269 -0
  54. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  55. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +207 -15
  56. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  57. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  58. package/template/docs/ARCHITECTURE.md +317 -34
  59. package/template/docs/TESTING.md +6 -5
  60. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  61. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  62. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  63. package/template/qa/approvals.json +42 -0
  64. package/template/qa/approve.mjs +139 -0
  65. package/template/qa/arch-doc.mjs +69 -0
  66. package/template/qa/comment.mjs +76 -0
  67. package/template/qa/comments.json +4 -0
  68. package/template/qa/golden/home.json +3 -3
  69. package/template/qa/lib/approvals.mjs +806 -0
  70. package/template/qa/lib/arch-doc.mjs +451 -0
  71. package/template/qa/lib/comments.mjs +252 -0
  72. package/template/qa/lib/component-stories.mjs +183 -0
  73. package/template/qa/lib/inputs-hash.mjs +5 -1
  74. package/template/qa/scaffold-feature.mjs +184 -67
  75. package/template/qa/setup-hooks.mjs +33 -0
  76. package/template/qa/verify.mjs +118 -9
  77. package/template/specs/app-base.spec.md +44 -7
  78. package/template/specs/home.spec.md +7 -4
  79. package/template/specs/intent.md +50 -0
@@ -1,85 +1,232 @@
1
1
  # __APP_NAME__ — AI delivery contract
2
2
 
3
- This project was generated by [create-cmp](https://github.com/kvdm-co-pilot/create-cmp) with a
4
- verification harness. Any AI session working in this repo follows this contract.
3
+ Generated by [create-cmp](https://github.com/kvdm-co-pilot/create-cmp) with a verification
4
+ harness. Every AI session in this repo works under this contract.
5
5
 
6
6
  ## Definition of done
7
7
 
8
- You are **not done until `node qa/verify.mjs` reports PASS** and the receipt it writes
9
- (`qa/evidence/latest.json`) is included in your commit. Claiming completion without a PASS
10
- receipt is a failure. SKIPped steps are recorded in the receipt never present green-with-gaps
11
- as fully verified.
8
+ Done means `node qa/verify.mjs` reports PASS and the receipt it writes
9
+ (`qa/evidence/latest.json`) is in your commit. Claiming completion without a PASS receipt is
10
+ a failure. SKIPped steps are recorded in the receipt; never present green-with-gaps as fully
11
+ verified.
12
+
13
+ **Verify in two tiers — the full lane is a checkpoint, not an inner loop.** It builds,
14
+ tests, and gates the whole tree to produce the receipt, so it is slow by design; running it
15
+ after every edit wastes the minutes it takes. Iterate on the fast tier, and run the lane
16
+ once — when you believe the change is done.
17
+
18
+ - **Inner loop — run continuously (seconds):** the preview loop (below) for UI, and
19
+ `./gradlew :composeApp:desktopTest` for the unit tests your change touches. This is where
20
+ you catch your own mistakes.
21
+ - **Checkpoint — run once, at done:** `node qa/verify.mjs`. It writes the receipt; commit
22
+ the receipt with your change. The Stop hook (`qa/receipt-check.mjs`) then confirms — with a
23
+ cheap hash check, not another lane run — that a valid receipt attests your commit, and CI
24
+ re-runs the full lane on push. After a green checkpoint, do not re-run the lane unless you
25
+ change the tree again.
26
+
27
+ Humans get the same gate at push time: run `node qa/setup-hooks.mjs` once (after `git init`)
28
+ to enable the shipped pre-push hook. It blocks a push whose committed receipt doesn't attest
29
+ HEAD — the same cheap check, before code leaves the machine (`git push --no-verify` bypasses
30
+ it; CI still enforces it).
12
31
 
13
32
  ## Specifications — behavior starts here
14
33
 
15
- **New behavior begins as a spec clause** in `specs/<feature>.spec.md` (Given/When/Then with a
16
- stable id see [`specs/README.md`](./specs/README.md)). Propose the clause, get it confirmed,
34
+ New behavior begins as a spec clause in `specs/<feature>.spec.md`: Given/When/Then with a
35
+ stable id (see [`specs/README.md`](./specs/README.md)). Propose the clause, get it confirmed,
17
36
  then implement. Durable tests cite their clause (`// SPEC: HOME-02`).
18
37
  [`specs/app-base.spec.md`](./specs/app-base.spec.md) states the architecture and shell
19
38
  invariants the conformance gates enforce.
20
39
 
21
- ## Architecture (violations will be named by the conformance gates)
40
+ ## Architecture
41
+
42
+ [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) is the doc of record. Every normative
43
+ sentence in it carries a tier tag: `[enforced: CLAUSE-ID]`, `[governed]`, or `[advisory]`.
44
+ Its `cmp:generated` sections (expect/actual table, layer inventory, ADR index, glossary) are
45
+ derived from a tree walk, never hand-maintained — `node qa/arch-doc.mjs` regenerates them,
46
+ and `node qa/arch-doc.mjs --check`, a verify-lane step, fails the lane when they drift from
47
+ the tree. The governed `architecture` artifact (below) hashes the document alongside
48
+ `specs/app-base.spec.md`, so approving it is consent to this document. The summary:
22
49
 
23
50
  - **Layers:** `presentation` → `domain` ← `data`. `domain` imports nothing app-internal;
24
51
  `presentation` never imports `data`. Koin wires implementations in `di/`.
25
- - **Every screen:** a `*Screen` composable with a `testTag`ged root, a ViewModel with a test.
26
- - **Design values** (colors / spacing / typography / radii) come from the theme's token catalog
27
- (`presentation/theme/`). Never hardcode literals in screens.
28
- - The `home` feature is the **exemplar** it shows the full pattern through every layer,
29
- including the tests. When you add a feature, mirror it exactly: Screen ViewModel (+ test) →
30
- UseCase (+ test) Repository interface in `domain` + impl in `data` (+ test) → DI module
31
- entry → navigation route.
32
-
33
- ## Testing (the pyramid this project uses)
52
+ - **Screens compose the registry vocabulary** (`presentation/components/*.kt`
53
+ `ScreenColumn`, `AppHeader`, `ContentStateContainer`, `ListItemCard`, ). Do not hand-roll
54
+ a header, loading state, or list row; the exemplar's `HomeScreen.kt` shows the pattern.
55
+ `ScreenColumn(screenTag = "<feature>")` tags the root, and ARCH-04 accepts that wiring as
56
+ tag provenance a literal `testTag` is only needed for content the registry does not
57
+ already tag (per-row ids, for example). Every screen is a `*Screen` composable with a
58
+ tested ViewModel.
59
+ - **Errors are typed, never thrown across layers.** Repositories return `AppResult`
60
+ (`Failure` carries a `DomainError` kind). The data layer's `suspendRunCatching` is the
61
+ only catch point and always rethrows `CancellationException`. ViewModels contain no
62
+ `try`/`catch`; they fold results into a sealed UiState, and presentation maps error kinds
63
+ to user copy.
64
+ - **Design values** (colors, spacing, typography, radii) come from the token catalog in
65
+ `presentation/theme/`. No hardcoded literals in screens.
66
+ - **One feature is the exemplar** — the full pattern through every layer, tests included.
67
+ It ships as `home`; `qa/approvals.json`'s `exemplarFeature` key retargets it to your own
68
+ first feature once shaped (see "Configurable exemplar"). New features mirror the exemplar
69
+ exactly: Screen → ViewModel (+ test) → UseCase (+ test) → Repository interface in `domain`
70
+ + impl in `data` (+ test) → DI entry → navigation route.
71
+
72
+ ## Testing
34
73
 
35
74
  - **Unit** (`composeApp/src/commonTest`, run via `./gradlew :composeApp:desktopTest`):
36
- kotlin-test + coroutines-test + Turbine. **Hand-written fakes** in `testing/fakes/` — never
75
+ kotlin-test + coroutines-test + Turbine. Hand-written fakes in `testing/fakes/`; no
37
76
  mocking frameworks. Every new ViewModel/UseCase/Repository gets a test in the exemplar's
38
77
  style: Arrange-Act-Assert, behavior-named backtick tests, one behavior per test.
39
- - **Conformance + screen tests** (`composeApp/src/desktopTest`): dependency-free
40
- source-scanning architecture gates (they enforce `specs/app-base.spec.md`'s ARCH clauses) + Compose UI Tests (durable,
41
- spec-cited, testTag selectors) + the golden-tree structural baseline (`qa/golden/`). Golden
42
- drift you did not intend = fix your change; intended drift = regenerate explicitly
43
- (`UPDATE_GOLDEN=1`) and declare it.
44
- - **E2E** (`qa/e2e/*.yaml`): Maestro flows; smoke covers boot + bottom nav. Selectors by
45
- testTag never by display text.
46
- - Do not delete, weaken, or `@Ignore` a failing test to get to green. Fix the behavior, or if
47
- the test itself is wrong, say so explicitly in your summary and justify the change.
78
+ - **Conformance + screen tests** (`composeApp/src/desktopTest`): source-scanning
79
+ architecture gates enforcing `specs/app-base.spec.md`'s ARCH clauses, Compose UI Tests
80
+ (spec-cited, testTag selectors), and the golden-tree baseline (`qa/golden/`). Unintended
81
+ golden drift means fix your change; intended drift is regenerated explicitly
82
+ (`UPDATE_GOLDEN=1`) and declared.
83
+ - **E2E** (`qa/e2e/*.yaml`): Maestro flows; smoke covers boot + bottom nav. Select by
84
+ testTag, never by display text.
85
+ - Never delete, weaken, or `@Ignore` a failing test to reach green. Fix the behavior or,
86
+ if the test itself is wrong, say so in your summary and justify the change.
48
87
 
49
88
  ## Evidence
50
89
 
51
90
  `node qa/verify.mjs` writes `qa/evidence/latest.json` (schema: `qa/evidence/schema.json`).
52
- Commit it with your change git history is the audit ledger. Binary artifacts under
53
- `qa-artifacts/` are hashed into the receipt; never commit them.
91
+ Commit it with your change; git history is the audit ledger. Binary artifacts under
92
+ `qa-artifacts/` are hashed into the receipt, never committed. The studio console's Evidence
93
+ page reconstructs the full audit trail from the git log of `latest.json` — every commit is
94
+ one verified, attributed state — so committing each receipt is what builds the record.
95
+
96
+ ## Approvals — governed artifacts need a human's sign-off
97
+
98
+ Some artifacts are **governed**: a human approves them, and the approval is bound to the
99
+ artifact's content by hash (`qa/approvals.json`) — the evidence-receipt idea, applied to a
100
+ human decision. The ordered walk is a **definition order**, not just an approval order:
101
+ each artifact is the vocabulary the next is written in, so on a fresh app each step is a
102
+ conversation that ends in an approval — the genesis walk, six conversations:
103
+
104
+ 0. **Intent** — `specs/intent.md`, the root brief everything else traces to (purpose,
105
+ audience, platforms, brand feel, reference apps, first screens, **glossary**). Filled by
106
+ the `cmp-new` interview; the seed's placeholder prose is marked unfilled. Its
107
+ `## Glossary` section is lifted verbatim into `docs/ARCHITECTURE.md` §8 — write it there
108
+ in the exact form you want published.
109
+ 1. **Design system** — `presentation/theme/Theme.kt`, `presentation/theme/Tokens.kt`.
110
+ 2. **Architecture + structure** — `specs/app-base.spec.md` **and**
111
+ [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) (`cmp:generated` sections stripped
112
+ before hashing, so a mechanical regeneration never invalidates the approval — only an
113
+ authored-prose edit does).
114
+ 3. **Components** — every `presentation/components/*.kt` (a dynamic, sorted glob). Once
115
+ approved, the registry is law: adding or changing a common component invalidates the
116
+ approval until a human re-approves.
117
+ 4. **Exemplar feature** — the **configured** exemplar's 11-file set the `add-feature`
118
+ stamper clones from (see "Configurable exemplar").
119
+ 5. **Exemplar spec** — `specs/<exemplar>.spec.md`.
120
+ 6. **Per-feature spec** — `specs/<feature>.spec.md`, one governed artifact per feature,
121
+ added as features land.
122
+
123
+ | Command | What |
124
+ |---|---|
125
+ | `node qa/approve.mjs --status` | Every governed artifact with live state (`unreviewed` / `approved` / `changed-since-approval` / `reopened`), short hash, mode badge |
126
+ | `node qa/approve.mjs <artifact>` | Record approval — hashes the artifact's files now, stamps the time; also clears a `defaults-accepted` mode |
127
+ | `node qa/approve.mjs --accept-defaults` | **Express lane**: approve every currently-resolvable artifact in one visible act, each stamped `"mode": "defaults-accepted"` — build now, walk the definition later. Unresolvable artifacts are skipped with the standard refusal printed. The ledger never pretends the defaults were designed. |
128
+ | `node qa/approve.mjs --reopen <artifact>` | Move an *approved* artifact (shaped or defaults-accepted) back to `reopened` for deliberate redesign — recorded (`reopenedAt`). Refuses unknown ids and anything not currently approved. |
129
+
130
+ With the create-cmp plugin, the same decisions can be made from the preview console
131
+ (`preview {projectDir}`'s URL) — it calls the same library, so the CLI and the console
132
+ never disagree. An agent blocks on a pending decision with
133
+ `approval_status {waitForDecision:true}`.
134
+
135
+ The verify lane's `approvals` gate (a step like any other, in every profile) resolves each
136
+ artifact's live status against `qa/approvals.json`:
137
+
138
+ - **`unreviewed`** → SKIP with a warning line. Nothing fails until a human opts in by
139
+ approving.
140
+ - **`reopened`** → SKIP with a warning line, exactly like `unreviewed`. Sanctioned redesign
141
+ never trips the gate; edits made while reopened are never drift. Re-approve when the
142
+ redesign lands.
143
+ - **`approved`, hash still matches** → PASS.
144
+ - **`approved`, hash no longer matches** → FAIL, naming the artifact and the re-approval
145
+ command. The artifact changed after sign-off — re-approve it or revert the change.
146
+ Invalidation is mechanical, like golden-tree drift, not a judgment call.
147
+
148
+ That asymmetry is the point: **redesign is a decision; drift is an accident** — the ledger
149
+ records which was which. A run with one reopened artifact and one drifted artifact FAILs
150
+ naming only the drifted one.
151
+
152
+ A gate FAIL fails the lane verdict, which fails `qa/receipt-check.mjs` (the Stop hook) by
153
+ the same mechanism as any other FAIL — no separate enforcement to maintain. `add-feature`
154
+ seeds each new feature's spec as `unreviewed` and prints the approval reminder; it never
155
+ refuses to stamp over this.
156
+
157
+ ### Configurable exemplar — the DNA features are cloned from
158
+
159
+ `qa/approvals.json` carries a top-level `"exemplarFeature"` key (absent means `"home"`, so
160
+ older ledgers keep meaning what they meant). It names the feature whose 11-file set is the
161
+ governed **exemplar-feature** artifact and the clone source `qa/scaffold-feature.mjs`
162
+ stamps new features from. The genesis walk's endgame is pointing it at *your* first real
163
+ feature: stamp it (`add-feature`), shape it, then set `exemplarFeature` — from then on the
164
+ stamper clones your pattern in your domain language, and `home` demotes to an ordinary
165
+ feature spec. If the configured exemplar has grown files beyond the canonical 11-file
166
+ shape, the stamper clones the canonical set and warns, listing exactly what it skipped.
167
+
168
+ ## Comments — review feedback flows back through the agent
169
+
170
+ Approvals are binding (they gate the verify lane); **comments are advisory** — a human's
171
+ running commentary, with a defined path back into your plan, spec, and code.
172
+ `qa/comments.json` is the ledger; `qa/lib/comments.mjs` is the library, mirroring
173
+ `qa/lib/approvals.mjs`'s shape: state, validation, transitions, nothing fabricated.
174
+
175
+ **The loop of record:**
176
+
177
+ 1. A human adds a comment from the preview console — on a screen, a spec clause, a
178
+ design-system token or component, or an architecture tree node.
179
+ 2. You observe it — `review_comments { waitForComment: true }` (plugin) blocks until a new
180
+ one lands; without the plugin, `node qa/comment.mjs --list --open`.
181
+ 3. You act on it — update the plan, the spec clause, or the code it points at.
182
+ 4. You resolve it **after** acting, with a note saying what you did —
183
+ `resolve_comment { id, note }` (plugin) or `node qa/comment.mjs --resolve <id> --note
184
+ "..."` (CLI, records author `agent-cli`). The console then shows `resolved` plus your
185
+ note. The console never edits code: humans comment, agents resolve.
186
+
187
+ | Command | What |
188
+ |---|---|
189
+ | `node qa/comment.mjs --list` | Every comment, open and resolved, with resolution notes |
190
+ | `node qa/comment.mjs --list --open` | Only open comments |
191
+ | `node qa/comment.mjs --resolve <id> --note "..."` | Resolve a comment, recording what changed |
192
+
193
+ A comment targets one of: a **screen**, an **element** (screen + testTag), a **spec-line**
194
+ (file + clause id), a **design-system** token, an **architecture** path, or **general**.
195
+ `addComment` refuses empty text and a target missing the fields its type requires — the
196
+ same refusal-over-fabrication stance as approvals. A ledger that exists but cannot be
197
+ parsed is never treated as empty (that would hide real feedback); reads and writes surface
198
+ the honest error instead.
54
199
 
55
200
  ## UI feedback loop — see what you build, without a device
56
201
 
57
202
  <!-- >>> cmp:feature inspector -->
58
- While building or changing ANY screen, use the preview loop instead of an emulator: it
59
- renders this app's REAL screens (real DI, real theme, seeded data) headlessly in seconds
60
- and tells you exactly what your edit changed.
203
+ While building or changing any screen, use the preview loop instead of an emulator. It
204
+ renders this app's real screens (real DI, real theme, seeded data) headlessly in seconds
205
+ and tells you what your edit changed.
61
206
 
62
207
  **With the create-cmp plugin (cmp-inspector MCP tools):**
63
208
 
64
- 1. `preview { projectDir }` — once per session. Returns a live gallery URL (give it to
65
- the human; it re-renders itself on every save) plus per-screen structural summaries
66
- for you. Sources are watched; you never run Gradle by hand.
67
- 2. After each edit: `preview_status { waitForRender: true }` blocks until the
68
- render/compile outcome. `changedLastRender` names the screens your edit touched
69
- (empty = the edit reached no screen); `lastErrorSource: "compile"` means the edit
70
- didn't even build (the compiler's `e:` lines are in `lastError`).
71
- 3. `preview_diff { screen }` one call proves the change: verdict `proven-clean` /
72
- `changed-with-regressions` / `no-change`. No snapshot bookkeeping needed.
209
+ 1. `preview { projectDir }` — once per session. Returns a live gallery URL for the human
210
+ (it re-renders on every save) and per-screen structural summaries for you. Sources are
211
+ watched; you never run Gradle by hand.
212
+ 2. After each edit: `preview_status { waitForRender: true }` blocks until the outcome.
213
+ `changedLastRender` names the screens your edit touched (empty = the edit reached no
214
+ screen); `lastErrorSource: "compile"` means the edit did not build — the compiler's `e:`
215
+ lines are in `lastError`.
216
+ 3. `preview_diff { screen }` proves the change in one call: `proven-clean` /
217
+ `changed-with-regressions` / `no-change`. No snapshot bookkeeping.
73
218
 
74
219
  **Without the plugin:** `./gradlew :composeApp:renderScreens` renders every screen to
75
220
  `composeApp/build/previews/<id>/{screen.png, tree.json}` (`-Pscreen=<id>` for one);
76
221
  `node qa/preview-gallery.mjs` builds a self-contained gallery page from the output.
77
222
 
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"`).
82
- Assert on the `tree.json` structure; never read PNG bytes (pixels are for humans).
223
+ Screens come from `inspector/PreviewRegistry.kt` (desktopMain). The `add-feature` and
224
+ `add-screen` stampers auto-register stamped screens at the `// cmp:anchor preview-registry`
225
+ marker; when you add a screen by hand, register it there — a forced-state variant is just
226
+ another entry (`"home@empty"`). Every common component also carries a story entry
227
+ (`"component.<kebab-name>"` in `inspector/ComponentStories.kt`); when you add a component,
228
+ add its story — the lane's `componentStories` step fails naming the missing id otherwise.
229
+ Assert on `tree.json` structure; never read PNG bytes. Pixels are for humans.
83
230
  <!-- <<< cmp:feature inspector -->
84
231
  <!-- >>> cmp:feature dev-client -->
85
232
  For one interactive window instead of stills of every screen:
@@ -98,7 +245,8 @@ conventions) · [`CONTRIBUTING.md`](./CONTRIBUTING.md) (workflow, Conventional C
98
245
 
99
246
  | Command | What |
100
247
  |---|---|
101
- | `node qa/verify.mjs` | The verify lane (profile `local`) — your definition of done |
248
+ | `node qa/verify.mjs` | The verify lane (profile `local`) — the done checkpoint, run once |
102
249
  | `./gradlew :composeApp:desktopTest` | Unit tests only (fast inner loop) |
250
+ | `node qa/setup-hooks.mjs` | Enable the pre-push receipt gate (one-time, after `git init`) |
103
251
  | `./gradlew :composeApp:assembleDebug` | Android debug build |
104
252
  | `./gradlew :composeApp:hotRunDesktop --auto` | Desktop dev-client with hot reload |
@@ -1,6 +1,6 @@
1
1
  # __APP_NAME__
2
2
 
3
- A Kotlin / Compose Multiplatform app generated by
3
+ A Kotlin / Compose Multiplatform app, generated by
4
4
  [create-cmp](https://github.com/kvdm-co-pilot/create-cmp) with a **verification harness**:
5
5
  the architecture, testing conventions, and definition of done are enforced mechanically, not
6
6
  by convention. Start with [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md); AI collaborators
@@ -80,32 +80,28 @@ Every change must pass the verify lane (`node qa/verify.mjs`) and commit its upd
80
80
 
81
81
  ## Verification enforcement
82
82
 
83
- This project ships a **Stop hook** (`.claude/settings.json`) that makes `CLAUDE.md`'s definition
84
- of done mechanical instead of honor-system, for AI sessions using Claude Code.
85
-
86
- **What it does:** when a session tries to end, the hook runs `node qa/receipt-check.mjs --hook`.
87
- That script recomputes a sha256 hash over this project's "verified surface" (`composeApp/`,
88
- `specs/`, `qa/`, and the Gradle build files — see `qa/lib/inputs-hash.mjs`) and compares it to the
89
- `inputs.hash` recorded in the committed `qa/evidence/latest.json`. If the receipt is a `PASS` and
90
- its hash matches the current tree, the session ends silently. If source changed without a fresh
91
- `PASS` receipt — or the receipt is missing, a `FAIL`, or predates this mechanism — the hook blocks
92
- with the specific reason and asks you to run `node qa/verify.mjs` and commit the receipt. **It runs
93
- no build and no tests only file hashing —** so it costs milliseconds, and it never fires twice in
94
- a row for the same stop.
95
-
96
- Doc-only edits (`*.md`, `README`, `.github/`, `.claude/`) are deliberately **outside** the verified
97
- surface, so editing docs never invalidates a good receipt or forces a needless re-run — the intent
98
- is transparent enforcement, not a hostile one.
99
-
100
- **Why:** `CLAUDE.md` already says a change is "not done" without a `PASS` receipt committed. The
101
- Stop hook is what makes that check happen automatically instead of relying on the AI session to
102
- remember to run it.
103
-
104
- **Escape hatch:** this is your project. If you don't want the hook, delete or comment out the
105
- `Stop` block in [`.claude/settings.json`](./.claude/settings.json) — nothing else depends on it
106
- locally. Note that CI independently enforces the same "receipt attests HEAD" check on every push
107
- (see `.github/workflows/verify.yml`), so disabling the local hook only trades an immediate local
108
- signal for a later one in CI.
83
+ For AI sessions using Claude Code, a **Stop hook** (`.claude/settings.json`) makes
84
+ `CLAUDE.md`'s definition of done mechanical instead of honor-system.
85
+
86
+ **What it does:** when a session tries to end, the hook runs
87
+ `node qa/receipt-check.mjs --hook`. That script recomputes a sha256 hash over the project's
88
+ verified surface (`composeApp/`, `specs/`, `qa/`, and the Gradle build files — see
89
+ `qa/lib/inputs-hash.mjs`) and compares it to the `inputs.hash` in the committed
90
+ `qa/evidence/latest.json`. A `PASS` receipt whose hash matches the tree ends the session
91
+ silently. Source changed without a fresh `PASS` — or a missing, failed, or pre-mechanism
92
+ receipt — blocks with the specific reason and asks for `node qa/verify.mjs` plus a committed
93
+ receipt. It runs no build and no tests, only file hashing, so it costs milliseconds, and it
94
+ never fires twice in a row for the same stop.
95
+
96
+ Doc-only edits (`*.md`, `README`, `.github/`, `.claude/`) are deliberately outside the
97
+ verified surface: editing docs never invalidates a good receipt. The intent is transparent
98
+ enforcement, not a hostile one.
99
+
100
+ **Escape hatch:** this is your project. If you don't want the hook, delete the `Stop` block
101
+ in [`.claude/settings.json`](./.claude/settings.json) nothing else depends on it locally.
102
+ CI independently enforces the same "receipt attests HEAD" check on every push
103
+ (`.github/workflows/verify.yml`), so disabling the local hook only trades an immediate
104
+ signal for a later one.
109
105
 
110
106
  ---
111
107
 
@@ -0,0 +1,99 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import java.io.File
6
+ import java.text.SimpleDateFormat
7
+ import java.util.Date
8
+ import java.util.Locale
9
+ import java.util.TimeZone
10
+ import kotlinx.serialization.json.Json
11
+ import kotlinx.serialization.json.JsonElement
12
+ import kotlinx.serialization.json.JsonNull
13
+ import kotlinx.serialization.json.JsonPrimitive
14
+ import kotlinx.serialization.json.buildJsonArray
15
+ import kotlinx.serialization.json.buildJsonObject
16
+
17
+ /**
18
+ * Debug-only crash capture: [install] sets a process-wide
19
+ * [Thread.UncaughtExceptionHandler] that persists crash JSON to
20
+ * `filesDir/inspector/crashes/` (bounded to the last [MAX_CRASHES]) so a crash survives the
21
+ * process death that follows it — an in-memory ring buffer would not.
22
+ *
23
+ * MUST NEVER SWALLOW THE CRASH: after persisting, it always hands off to whatever handler was
24
+ * installed before it (chained, not replaced) so system crash dialogs, `System.exit`, and any
25
+ * other crash-reporting tool still behave exactly as if this class did not exist.
26
+ */
27
+ object CrashRecorder {
28
+
29
+ private const val TAG = "CmpInspector"
30
+ private const val MAX_CRASHES = 20
31
+
32
+ fun install(context: Context) {
33
+ val crashDir = File(context.filesDir, "inspector/crashes").apply { mkdirs() }
34
+ val previous = Thread.getDefaultUncaughtExceptionHandler()
35
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
36
+ try {
37
+ persist(crashDir, throwable)
38
+ } catch (t: Throwable) {
39
+ // Persisting the crash record must never itself crash the crash handler.
40
+ Log.w(TAG, "failed to persist crash record", t)
41
+ } finally {
42
+ if (previous != null) {
43
+ previous.uncaughtException(thread, throwable)
44
+ } else {
45
+ // No previous handler installed: fall back to the JVM's own default so the
46
+ // process still dies the normal way instead of hanging.
47
+ Runtime.getRuntime().exit(10)
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ private fun persist(crashDir: File, throwable: Throwable) {
54
+ val doc = buildJsonObject {
55
+ put("timestamp", JsonPrimitive(isoNow()))
56
+ put("exception", JsonPrimitive(throwable::class.qualifiedName ?: throwable.javaClass.name))
57
+ put("message", throwable.message?.let { JsonPrimitive(it) } ?: JsonNull)
58
+ put("frames", buildJsonArray {
59
+ throwable.stackTrace.forEach { el ->
60
+ add(buildJsonObject {
61
+ put("className", JsonPrimitive(el.className))
62
+ put("methodName", JsonPrimitive(el.methodName))
63
+ put("fileName", el.fileName?.let { JsonPrimitive(it) } ?: JsonNull)
64
+ put("lineNumber", JsonPrimitive(el.lineNumber))
65
+ })
66
+ }
67
+ })
68
+ }
69
+ File(crashDir, "crash-${System.currentTimeMillis()}.json")
70
+ .writeText(Json.encodeToString(JsonElement.serializer(), doc))
71
+ prune(crashDir)
72
+ }
73
+
74
+ /** Keep only the most recent [MAX_CRASHES] crash files (current boot + previous ones). */
75
+ private fun prune(crashDir: File) {
76
+ val files = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: return
77
+ if (files.size <= MAX_CRASHES) return
78
+ files.sortedBy { it.lastModified() }
79
+ .take(files.size - MAX_CRASHES)
80
+ .forEach { it.delete() }
81
+ }
82
+
83
+ /**
84
+ * Every persisted crash's raw JSON text, newest first — spans the current boot AND any
85
+ * previous ones ([install] never clears the directory, only [prune] bounds it).
86
+ */
87
+ fun readAll(context: Context): List<String> {
88
+ val crashDir = File(context.filesDir, "inspector/crashes")
89
+ val files = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: return emptyList()
90
+ return files.sortedByDescending { it.lastModified() }
91
+ .mapNotNull { runCatching { it.readText() }.getOrNull() }
92
+ }
93
+
94
+ private fun isoNow(): String {
95
+ val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
96
+ fmt.timeZone = TimeZone.getTimeZone("UTC")
97
+ return fmt.format(Date())
98
+ }
99
+ }
@@ -0,0 +1,144 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ // >>> cmp:feature room
4
+ import __PACKAGE__.data.local.AppDatabase
5
+ import androidx.room.useReaderConnection
6
+ import kotlinx.coroutines.runBlocking
7
+ import kotlinx.serialization.json.Json
8
+ import kotlinx.serialization.json.JsonElement
9
+ import kotlinx.serialization.json.JsonNull
10
+ import kotlinx.serialization.json.JsonPrimitive
11
+ import kotlinx.serialization.json.buildJsonArray
12
+ import kotlinx.serialization.json.buildJsonObject
13
+ import org.koin.core.context.GlobalContext
14
+ // <<< cmp:feature room
15
+
16
+ /**
17
+ * `GET /inspect/db` (schema: tables via `sqlite_master`) and `GET /inspect/db?table=<name>&limit=<n>`
18
+ * (rows). Read-only, off the main thread. 404 when this project's local-database feature is off
19
+ * (there is nothing to query) — see the `room`-gated implementation below for the real path.
20
+ */
21
+ object DbInspector {
22
+
23
+ // >>> cmp:feature room
24
+ // Reads go through the project's Room database (a Koin single). Injection-safe by
25
+ // construction: a requested `table` is only ever used in a query after it is proven to be
26
+ // a real name returned by `sqlite_master` in THIS call — the raw wire value never reaches
27
+ // SQL beyond that validated identifier.
28
+ //
29
+ // This project's Room config uses the KMP driver architecture (BundledSQLiteDriver, see
30
+ // data/local/DatabaseBuilder.kt), not the legacy Android-only SupportSQLiteDatabase — so
31
+ // reads go through Room 2.8's public `useReaderConnection { transactor -> ... }` whose
32
+ // receiver is a pooled connection exposing `usePrepared(sql) { stmt -> ... }` (the
33
+ // statement is created and closed by Room; binds are 1-based, column reads 0-based).
34
+ private const val DEFAULT_ROW_LIMIT = 50
35
+ private const val MAX_ROW_LIMIT = 500
36
+ private val VALID_IDENTIFIER = Regex("^[A-Za-z_][A-Za-z0-9_]*$")
37
+ private val prettyJson = Json { prettyPrint = true }
38
+
39
+ private fun appDatabaseOrNull(): AppDatabase? =
40
+ try {
41
+ // GlobalContext.getOrNull() IS the Koin instance (or null before startKoin).
42
+ GlobalContext.getOrNull()?.getOrNull<AppDatabase>()
43
+ } catch (t: Throwable) {
44
+ null
45
+ }
46
+
47
+ fun schema(): Pair<Int, String> {
48
+ val db = appDatabaseOrNull()
49
+ ?: return 503 to errorJson("database not available yet (Room not initialised — is Koin started?).")
50
+ return try {
51
+ val tables = runBlocking {
52
+ db.useReaderConnection { connection ->
53
+ connection.usePrepared(
54
+ "SELECT name, sql FROM sqlite_master WHERE type = 'table' " +
55
+ "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'room_%' ORDER BY name"
56
+ ) { stmt ->
57
+ val out = mutableListOf<Pair<String, String?>>()
58
+ while (stmt.step()) {
59
+ out += stmt.getText(0) to (if (stmt.isNull(1)) null else stmt.getText(1))
60
+ }
61
+ out
62
+ }
63
+ }
64
+ }
65
+ 200 to prettyJson.encodeToString(JsonElement.serializer(), buildJsonObject {
66
+ put("tables", buildJsonArray {
67
+ tables.forEach { (name, sql) ->
68
+ add(buildJsonObject {
69
+ put("name", JsonPrimitive(name))
70
+ put("sql", sql?.let { JsonPrimitive(it) } ?: JsonNull)
71
+ })
72
+ }
73
+ })
74
+ })
75
+ } catch (t: Throwable) {
76
+ 500 to errorJson("failed to read schema: ${t.message}")
77
+ }
78
+ }
79
+
80
+ /** Row page for one table: columns + stringified values, capped. */
81
+ private class TableRows(val columns: List<String>, val rows: List<JsonElement>)
82
+
83
+ fun rows(table: String, limitParam: String?): Pair<Int, String> {
84
+ if (!VALID_IDENTIFIER.matches(table)) {
85
+ return 400 to errorJson("invalid table name '$table' — expected a plain SQL identifier.")
86
+ }
87
+ val db = appDatabaseOrNull()
88
+ ?: return 503 to errorJson("database not available yet (Room not initialised — is Koin started?).")
89
+ val limit = (limitParam?.toIntOrNull() ?: DEFAULT_ROW_LIMIT).coerceIn(1, MAX_ROW_LIMIT)
90
+ return try {
91
+ val result: TableRows? = runBlocking {
92
+ db.useReaderConnection { connection ->
93
+ // STRICT validation: `table` is only used in the row query below once THIS
94
+ // check proves it is a real sqlite_master identifier — never the raw wire value.
95
+ val exists = connection.usePrepared(
96
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?"
97
+ ) { stmt ->
98
+ stmt.bindText(1, table)
99
+ stmt.step()
100
+ }
101
+ if (!exists) return@useReaderConnection null
102
+
103
+ connection.usePrepared("SELECT * FROM \"$table\" LIMIT ?") { stmt ->
104
+ stmt.bindLong(1, limit.toLong())
105
+ val colCount = stmt.getColumnCount()
106
+ val columns = (0 until colCount).map { stmt.getColumnName(it) }
107
+ val rows = mutableListOf<JsonElement>()
108
+ while (stmt.step()) {
109
+ rows += buildJsonObject {
110
+ for (i in 0 until colCount) {
111
+ put(columns[i], if (stmt.isNull(i)) JsonNull else JsonPrimitive(stmt.getText(i)))
112
+ }
113
+ }
114
+ }
115
+ TableRows(columns, rows)
116
+ }
117
+ }
118
+ }
119
+ if (result == null) {
120
+ return 404 to errorJson("unknown table '$table' — not present in sqlite_master.")
121
+ }
122
+ 200 to prettyJson.encodeToString(JsonElement.serializer(), buildJsonObject {
123
+ put("table", JsonPrimitive(table))
124
+ put("columns", buildJsonArray { result.columns.forEach { add(JsonPrimitive(it)) } })
125
+ put("rows", buildJsonArray { result.rows.forEach { add(it) } })
126
+ put("rowCount", JsonPrimitive(result.rows.size))
127
+ })
128
+ } catch (t: Throwable) {
129
+ 500 to errorJson("failed to read table '$table': ${t.message}")
130
+ }
131
+ }
132
+
133
+ private fun errorJson(message: String): String =
134
+ """{"error":${JsonPrimitive(message)}}"""
135
+ // <<< cmp:feature room
136
+ // >>> cmp:feature !room
137
+ private const val DISABLED_MESSAGE =
138
+ "the 'room' feature is disabled in this project — /inspect/db is unavailable."
139
+
140
+ fun schema(): Pair<Int, String> = 404 to """{"error":"$DISABLED_MESSAGE"}"""
141
+
142
+ fun rows(table: String, limitParam: String?): Pair<Int, String> = schema()
143
+ // <<< cmp:feature !room
144
+ }