create-cmp-cli 0.10.1 → 0.11.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/package.json +1 -1
- package/src/lib/tabs.mjs +26 -0
- package/template/.claude/skills/add-feature/SKILL.md +20 -0
- package/template/.claude/skills/add-repository/SKILL.md +6 -0
- package/template/.claude/skills/add-screen/SKILL.md +6 -0
- package/template/CLAUDE.md +81 -2
- package/template/composeApp/build.gradle.kts +25 -0
- package/template/composeApp/proguard-rules.pro +12 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +16 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +2 -5
- package/template/qa/approve.mjs +119 -11
- package/template/qa/lib/approvals.mjs +602 -21
- package/template/qa/lib/feature-brief.mjs +324 -0
- package/template/qa/lib/inputs-hash.mjs +43 -6
- package/template/qa/lib/reachability.mjs +211 -0
- package/template/qa/lib/spec-coverage.mjs +80 -0
- package/template/qa/verify.mjs +148 -48
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-cmp-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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": {
|
package/src/lib/tabs.mjs
CHANGED
|
@@ -527,6 +527,8 @@ const PLACEHOLDER_REL =
|
|
|
527
527
|
const PREVIEW_REGISTRY_REL =
|
|
528
528
|
"composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt";
|
|
529
529
|
const SMOKE_REL = "qa/e2e/smoke.yaml";
|
|
530
|
+
const PROFILE_FEATURE_REL =
|
|
531
|
+
"composeApp/src/commonMain/kotlin/com/example/app/presentation/profile";
|
|
530
532
|
|
|
531
533
|
/**
|
|
532
534
|
* Rewrite every tab-driven surface present in the copied project dir. Runs
|
|
@@ -576,4 +578,28 @@ export function rewriteTabSurfaces(projectDir, tabs, log = () => {}) {
|
|
|
576
578
|
fs.writeFileSync(smokePath, renderSmokeYaml(infos));
|
|
577
579
|
log(` tabs → ${SMOKE_REL}`);
|
|
578
580
|
}
|
|
581
|
+
|
|
582
|
+
// A shipped default feature the config did NOT ask for must not be left on
|
|
583
|
+
// disk: rewriteNavHost drops its import and its appTabs() entry, and
|
|
584
|
+
// renderPreviewRegistryKt drops its preview, so the files would sit there
|
|
585
|
+
// wired to nothing — dead code the reachability gate (qa/lib/reachability.mjs)
|
|
586
|
+
// correctly FAILs at `--verify` time. Don't ship what you don't wire.
|
|
587
|
+
//
|
|
588
|
+
// `profile` is the only one: it is a self-contained stub screen (one file, no
|
|
589
|
+
// ViewModel, no spec clauses, no DI entry, no tests), so removing it leaves
|
|
590
|
+
// nothing dangling.
|
|
591
|
+
//
|
|
592
|
+
// `home` is deliberately NEVER stripped, even when it is not a configured tab.
|
|
593
|
+
// It is the governed EXEMPLAR (`qa/approvals.json`'s exemplarFeature — the file
|
|
594
|
+
// set `qa/scaffold-feature.mjs` clones and the genesis walk approves), and it
|
|
595
|
+
// owns `DetailScreen`, which AppNavHost registers as a destination
|
|
596
|
+
// unconditionally. So it stays both governed and reachable with no tab of its
|
|
597
|
+
// own — removing it would break the genesis walk and dangle the Detail route.
|
|
598
|
+
if (!infos.some((t) => t.slug === "profile")) {
|
|
599
|
+
const profilePath = path.join(projectDir, PROFILE_FEATURE_REL);
|
|
600
|
+
if (fs.existsSync(profilePath)) {
|
|
601
|
+
fs.rmSync(profilePath, { recursive: true, force: true });
|
|
602
|
+
log(` tabs → removed ${PROFILE_FEATURE_REL} (no profile tab configured)`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
579
605
|
}
|
|
@@ -19,6 +19,26 @@ description: >-
|
|
|
19
19
|
> feature to its real shape. You are not done until `node qa/verify.mjs` PASSes and the receipt
|
|
20
20
|
> is committed — see this project's `CLAUDE.md`.
|
|
21
21
|
|
|
22
|
+
## Step 0 — name the lane, out loud, before anything else
|
|
23
|
+
|
|
24
|
+
Every post-genesis change enters through one of two lanes (`CLAUDE.md` §"After genesis"),
|
|
25
|
+
and **the human must be told which one this request is taking, in your first reply** — one
|
|
26
|
+
or two plain sentences before any tool runs: what you understood the change to be, which
|
|
27
|
+
lane, and why. Never route silently; the human can overrule the triage in a word.
|
|
28
|
+
|
|
29
|
+
- **Brief lane** — the request carries decisions a future contributor could plausibly
|
|
30
|
+
"simplify" away, or blast radius into other governed artifacts. Say so, e.g.: *"This
|
|
31
|
+
carries real decisions (day-boundary rules, scheduling semantics) — I'll draft a feature
|
|
32
|
+
brief at `docs/features/<name>.md` with the open decisions for you to close and sign
|
|
33
|
+
BEFORE I stamp anything."* Only after the brief is signed does this skill's stamping
|
|
34
|
+
start — and if the feature has a UI surface, the **design gate comes next, before the
|
|
35
|
+
spec**: draft the screens on stub data, render them, and stop for the human's signature
|
|
36
|
+
on `feature-design:<name>` (brief → design → spec → build). A human signs rendered
|
|
37
|
+
screens, never a description of screens.
|
|
38
|
+
- **Direct lane** — an ordinary feature with no decisions worth recording. Say so, e.g.:
|
|
39
|
+
*"Straightforward slice, no decisions worth a brief — direct lane: spec clauses for your
|
|
40
|
+
confirmation, then I stamp and prove."* Then continue with Step 1 below.
|
|
41
|
+
|
|
22
42
|
## The clone source is configurable
|
|
23
43
|
|
|
24
44
|
The stamper clones from the project's **configured exemplar** — `qa/approvals.json`'s
|
|
@@ -24,6 +24,12 @@ to just the 5 data/domain files. Use it when you want the data layer to exist be
|
|
|
24
24
|
consumes it (e.g. you're modeling several entities up front, or a screen for this entity will
|
|
25
25
|
come later via `add-screen`).
|
|
26
26
|
|
|
27
|
+
**Name the lane first, out loud.** Before any tool runs, tell the human in a sentence which
|
|
28
|
+
lane this request takes (`CLAUDE.md` §"After genesis"): an entity carrying decisions worth
|
|
29
|
+
recording (data-model semantics, boundary rules) goes through a feature brief
|
|
30
|
+
(`docs/features/<name>.md`, signed before code); an ordinary entity is direct lane — say so,
|
|
31
|
+
then continue here. Never route silently.
|
|
32
|
+
|
|
27
33
|
## Why a stamper and not hand-written files
|
|
28
34
|
|
|
29
35
|
Every hand-written file is a drift chance in this project's architecture. `qa/scaffold-feature.mjs
|
|
@@ -25,6 +25,12 @@ the 6 presentation+tests+spec files, **composed on top of an entity that already
|
|
|
25
25
|
`add-repository` or a prior `add-feature` run). If the entity doesn't exist yet, this will refuse
|
|
26
26
|
to stamp anything — see step 2.
|
|
27
27
|
|
|
28
|
+
**Name the lane first, out loud.** Before any tool runs, tell the human in a sentence which
|
|
29
|
+
lane this request takes (`CLAUDE.md` §"After genesis"): a screen carrying decisions worth
|
|
30
|
+
recording or blast into signed artifacts goes through a feature brief
|
|
31
|
+
(`docs/features/<name>.md`, signed before code); an ordinary screen is direct lane — say
|
|
32
|
+
so, then continue here. Never route silently.
|
|
33
|
+
|
|
28
34
|
## Why a stamper and not hand-written files
|
|
29
35
|
|
|
30
36
|
Every hand-written file is a drift chance in this project's architecture. `qa/scaffold-feature.mjs
|
package/template/CLAUDE.md
CHANGED
|
@@ -111,6 +111,12 @@ a provisional palette carries the build until then).
|
|
|
111
111
|
the `cmp-new` interview; the seed's placeholder prose is marked unfilled. Its
|
|
112
112
|
`## Glossary` section is lifted verbatim into `docs/ARCHITECTURE.md` §8 — write it there
|
|
113
113
|
in the exact form you want published.
|
|
114
|
+
0.5. **First feature brief** — `docs/features/<exemplar>.md`, drafted from the interview's
|
|
115
|
+
"first screens" answer the moment intent is signed: the first feature's decisions and
|
|
116
|
+
their why, signed BEFORE its spec is written. Genesis runs the same decide → contract →
|
|
117
|
+
build loop as every later change; the app's first feature gets the same decide step
|
|
118
|
+
every later feature gets. (Express lane: skipped honestly, never fabricated;
|
|
119
|
+
placeholders earn a brief when they become real.)
|
|
114
120
|
1. **Architecture + structure** — `specs/app-base.spec.md` **and**
|
|
115
121
|
[`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) (`cmp:generated` sections stripped
|
|
116
122
|
before hashing, so a mechanical regeneration never invalidates the approval — only an
|
|
@@ -131,16 +137,88 @@ a provisional palette carries the build until then).
|
|
|
131
137
|
6. **Per-feature spec** — `specs/<feature>.spec.md`, one governed artifact per feature,
|
|
132
138
|
added as features land.
|
|
133
139
|
|
|
140
|
+
### After genesis — every change is the same loop
|
|
141
|
+
|
|
142
|
+
Genesis governs the app's birth. Everything after runs the SAME loop — decide →
|
|
143
|
+
contract → build → prove → sign — over a subset (`docs/CHANGE-FLOW-DESIGN.md` in the
|
|
144
|
+
create-cmp repo is the doc of record). Two lanes, one triage rule — and **the triage is
|
|
145
|
+
always visible**: your FIRST reply to any change request (new feature, edit, bug fix, copy
|
|
146
|
+
tweak, redesign — every entry point) states in one or two plain sentences what you
|
|
147
|
+
understood the change to be, which lane it takes, and why, before any tool runs. The human
|
|
148
|
+
can overrule the lane in a word; a silent route is a routing error even when the lane was
|
|
149
|
+
right.
|
|
150
|
+
|
|
151
|
+
**Brief lane** — when the change carries **decisions a future contributor could plausibly
|
|
152
|
+
"simplify" away** ("the day boundary is configurable, default 04:00 — not midnight") OR
|
|
153
|
+
**blast radius into other governed artifacts**. After naming the lane:
|
|
154
|
+
|
|
155
|
+
| Step | What |
|
|
156
|
+
|---|---|
|
|
157
|
+
| 1 | **Feature brief** — `docs/features/<name>.md`: the decisions with their why, research, rejected options, an **Open decisions** section until the human closes each. Signed BEFORE code. |
|
|
158
|
+
| 2 | **Design** — iff the feature has a UI surface (`"screens": true`, or screen files exist): draft the screens on STUB data, register them in the PreviewRegistry, render, and STOP. The human judges the rendered screens and signs `feature-design:<name>`. Never ask a human to approve a described UI. |
|
|
159
|
+
| 3 | **Contract** — reopen any signed spec the brief amends (`--reopen feature-spec:<surface> --reason "…"`); write the clauses where the behavior lives — about the form that now exists; the human signs |
|
|
160
|
+
| 4 | Build the slice (`add-feature` / preview loop). Declared blast lands "as declared"; re-approve touched visual artifacts on rendered output (wiring the signed screens from stub to real state drifts `feature-design:<name>` — its re-approval is that pass) |
|
|
161
|
+
| 5 | Prove — nothing to do: the lane's gates + receipt ARE the proof |
|
|
162
|
+
| 6 | The human's `--accept` — enabled only at provenDone AND a signed design |
|
|
163
|
+
|
|
164
|
+
**Direct lane** — everything else (bug fix, copy edit, tweak): confirm in chat, reopen →
|
|
165
|
+
amend clause → re-approve if a signed contract is touched, build, lane once at done.
|
|
166
|
+
Legacy features never get retro-briefs; spikes are ungoverned until they become real.
|
|
167
|
+
|
|
168
|
+
**A brief's LOCATION is the governance opt-in**: every `docs/features/*.md` is a governed
|
|
169
|
+
`feature-brief:<name>` artifact (hash-bound at signing; `<name>` pairs with
|
|
170
|
+
`specs/<name>.spec.md`). `docs/proposals/` stays ungoverned. The brief carries at most one
|
|
171
|
+
machine-read block, and it **declares — it never gates**:
|
|
172
|
+
|
|
173
|
+
```json cmp:feature
|
|
174
|
+
{ "touches": ["components", "design-system"], "screens": true }
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`touches` is the declared blast radius — the artifact hashes already enforce; declaring
|
|
178
|
+
lets the console show "components re-approval, as planned" instead of an unexplained
|
|
179
|
+
failure, and surface **undeclared blast** when something drifted that no open brief
|
|
180
|
+
accounted for. `screens: true` declares a UI surface: it holds the design gate
|
|
181
|
+
(`feature-design:<name>` — the feature's own `presentation/<name>/*Screen.kt`, signed on
|
|
182
|
+
rendered output) before any screen file exists; once files exist, disk is ground truth
|
|
183
|
+
regardless. Both declare — neither gates.
|
|
184
|
+
|
|
185
|
+
**Doneness is DERIVED, never claimed.** There is no `--deliver` and no checks block —
|
|
186
|
+
deliberately (they existed and were removed as a weaker parallel truth). A feature is
|
|
187
|
+
`provenDone` when, mechanically: its spec has live clauses **and** every one is cited by a
|
|
188
|
+
test **and** the latest receipt is PASS **and** the receipt's `inputs.hash` attests the
|
|
189
|
+
tree as it stands. `--status` and the console print the same one-line `doneReason` either
|
|
190
|
+
way. What remains for humans is judgment: signing the brief (before code) and accepting
|
|
191
|
+
the feature (after proof — "the proven thing is what I wanted"); `--accept` is refused
|
|
192
|
+
until the derivation holds. Acceptance lives on the ledger row, never in the doc (the
|
|
193
|
+
signed bytes must not move when the human accepts).
|
|
194
|
+
|
|
195
|
+
| Command | What |
|
|
196
|
+
|---|---|
|
|
197
|
+
| `node qa/approve.mjs feature-brief:<name>` | the human signs the brief (before code) |
|
|
198
|
+
| `node qa/approve.mjs --accept <name>` | the human's bookend; refused until provenDone |
|
|
199
|
+
|
|
200
|
+
Editing a feature is the same brief **reopened** — `--reopen-feature <name> --reason "…"`
|
|
201
|
+
reopens the brief + its spec + its design + the set it declared, as ONE recorded change;
|
|
202
|
+
re-approval is one walk back. Every reopen **requires a `--reason`** (it walks back a
|
|
203
|
+
signature; the signer reads why from the ledger itself) and is journaled with `via` — the
|
|
204
|
+
append-only journal (`qa/approvals.log.jsonl`, printed by `--log`, shown in the console
|
|
205
|
+
strip's History) is how "what happened while I was away" stays answerable.
|
|
206
|
+
|
|
134
207
|
| Command | What |
|
|
135
208
|
|---|---|
|
|
136
209
|
| `node qa/approve.mjs --status` | Every governed artifact with live state (`unreviewed` / `approved` / `changed-since-approval` / `reopened`), short hash, mode badge |
|
|
137
210
|
| `node qa/approve.mjs <artifact>` | Record approval — hashes the artifact's files now, stamps the time; also clears a `defaults-accepted` mode |
|
|
138
211
|
| `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. |
|
|
139
|
-
| `node qa/approve.mjs --reopen <artifact
|
|
212
|
+
| `node qa/approve.mjs --reopen <artifact> --reason "…"` | Move an *approved* artifact (shaped or defaults-accepted) back to `reopened` for deliberate redesign. `--reason` is REQUIRED and recorded (`reopenedAt`, `via`, `reason` — on the row and in the journal). Refuses unknown ids and anything not currently approved. |
|
|
213
|
+
| `node qa/approve.mjs --reopen-feature <name> --reason "…"` | ONE recorded change: reopens the brief + `feature-spec:<name>` + `feature-design:<name>` + every declared `touches` artifact (each only if currently approved), all under one reason. |
|
|
214
|
+
| `node qa/approve.mjs --log` | The governance journal — every approve/reopen/accept with when, which surface (`via`), and why. |
|
|
140
215
|
|
|
141
216
|
With the create-cmp plugin, the same decisions can be made from the preview console
|
|
142
217
|
(`preview {projectDir}`'s URL) — it calls the same library, so the CLI and the console
|
|
143
|
-
never disagree.
|
|
218
|
+
never disagree. The console's rail is the human's work queue (colour = something waits
|
|
219
|
+
on them), and a drifted artifact shows, in its own section, exactly **what changed
|
|
220
|
+
against the signed bytes and which files are still exactly as signed**, with re-approval
|
|
221
|
+
in place. An agent blocks on a pending decision with
|
|
144
222
|
`approval_status {waitForDecision:true}`.
|
|
145
223
|
|
|
146
224
|
The verify lane's `approvals` gate (a step like any other, in every profile) resolves each
|
|
@@ -267,4 +345,5 @@ conventions) · [`CONTRIBUTING.md`](./CONTRIBUTING.md) (workflow, Conventional C
|
|
|
267
345
|
| `./gradlew :composeApp:desktopTest` | Unit tests only (fast inner loop) |
|
|
268
346
|
| `node qa/setup-hooks.mjs` | Enable the pre-push receipt gate (one-time, after `git init`) |
|
|
269
347
|
| `./gradlew :composeApp:assembleDebug` | Android debug build |
|
|
348
|
+
| `./gradlew :composeApp:assembleRelease` | Android release build — R8 + `lintVital`, the variant the lane's `releaseBuild` step proves. Produces an **unsigned** APK; signing needs a keystore, which is yours to create and keep out of the repo. |
|
|
270
349
|
| `./gradlew :composeApp:hotRunDesktop --auto` | Desktop dev-client with hot reload |
|
|
@@ -202,7 +202,19 @@ android {
|
|
|
202
202
|
getByName("release") {
|
|
203
203
|
isMinifyEnabled = true
|
|
204
204
|
isShrinkResources = true
|
|
205
|
+
// Every field debug declares, release must declare too. BuildConfig is generated
|
|
206
|
+
// PER BUILD TYPE, so a field only debug carries simply does not exist in release —
|
|
207
|
+
// and `if (!USE_FIREBASE_EMULATORS) return` is a RUNTIME guard that does nothing
|
|
208
|
+
// for a compile-time symbol. Declaring the flag alone made release the one build
|
|
209
|
+
// nobody could produce: the code reading the host and ports failed to resolve them.
|
|
210
|
+
// The values below are never used (the flag is false); they exist so the shape of
|
|
211
|
+
// BuildConfig is the same in both build types.
|
|
205
212
|
buildConfigField("boolean", "USE_FIREBASE_EMULATORS", "false")
|
|
213
|
+
buildConfigField("String", "FIREBASE_EMULATOR_HOST", "\"\"")
|
|
214
|
+
buildConfigField("int", "FIREBASE_AUTH_PORT", "0")
|
|
215
|
+
buildConfigField("int", "FIREBASE_FIRESTORE_PORT", "0")
|
|
216
|
+
buildConfigField("int", "FIREBASE_FUNCTIONS_PORT", "0")
|
|
217
|
+
buildConfigField("int", "FIREBASE_STORAGE_PORT", "0")
|
|
206
218
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
|
207
219
|
proguardFiles(
|
|
208
220
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
@@ -226,6 +238,19 @@ android {
|
|
|
226
238
|
// <<< cmp:feature !firebase
|
|
227
239
|
}
|
|
228
240
|
|
|
241
|
+
lint {
|
|
242
|
+
// ONE disabled check, not the gate. `lintVitalRelease` runs on every release build and
|
|
243
|
+
// it is worth keeping; what is not worth keeping is a detector that CRASHES on it.
|
|
244
|
+
// NullSafeMutableLiveData's detector throws IncompatibleClassChangeError against this
|
|
245
|
+
// Kotlin version (AGP's bug, not ours) and takes the whole release build with it.
|
|
246
|
+
//
|
|
247
|
+
// Disabling it costs nothing beyond the crash: an app on this template has no LiveData —
|
|
248
|
+
// state is Compose + Flow throughout — so the check has nothing to inspect. The blunt
|
|
249
|
+
// alternatives, `checkReleaseBuilds = false` or `abortOnError = false`, would switch off
|
|
250
|
+
// every real lint finding to dodge one broken one.
|
|
251
|
+
disable += "NullSafeMutableLiveData"
|
|
252
|
+
}
|
|
253
|
+
|
|
229
254
|
compileOptions {
|
|
230
255
|
sourceCompatibility = JavaVersion.VERSION_17
|
|
231
256
|
targetCompatibility = JavaVersion.VERSION_17
|
|
@@ -20,6 +20,18 @@
|
|
|
20
20
|
-keep class dev.gitlive.firebase.** { *; }
|
|
21
21
|
-dontwarn com.google.firebase.**
|
|
22
22
|
|
|
23
|
+
# GitLive's RemoteConfig module is compiled against kotlinx-datetime's OWN Instant, which
|
|
24
|
+
# this version set no longer has: on Kotlin 2.2 Instant moved into the stdlib as
|
|
25
|
+
# kotlin.time.Instant. R8 hits an unresolvable reference and FAILS the release build.
|
|
26
|
+
#
|
|
27
|
+
# Suppressed rather than resolved, deliberately. The reference lives in
|
|
28
|
+
# FirebaseRemoteConfigInfo, and RemoteConfig is not a service this template wires up, so no
|
|
29
|
+
# code path reaches it. Adding kotlinx-datetime back purely to satisfy a class nobody calls
|
|
30
|
+
# would put two Instant types in the graph — the more expensive mistake. When GitLive ships
|
|
31
|
+
# a build against kotlin.time, delete these two lines; the release build will say if it is time.
|
|
32
|
+
-dontwarn kotlinx.datetime.Instant$Companion
|
|
33
|
+
-dontwarn kotlinx.datetime.Instant
|
|
34
|
+
|
|
23
35
|
# Coroutines
|
|
24
36
|
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
|
25
37
|
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
|
@@ -2,6 +2,7 @@ package __PACKAGE__.presentation.navigation
|
|
|
2
2
|
|
|
3
3
|
import androidx.compose.runtime.Composable
|
|
4
4
|
import androidx.compose.runtime.LaunchedEffect
|
|
5
|
+
import androidx.compose.ui.Modifier
|
|
5
6
|
import androidx.navigation.NavType
|
|
6
7
|
import androidx.navigation.compose.NavHost
|
|
7
8
|
import androidx.navigation.compose.composable
|
|
@@ -10,6 +11,9 @@ import androidx.navigation.navArgument
|
|
|
10
11
|
// Nav 2.9 (multiplatform): backStackEntry.arguments is a SavedState, not an Android Bundle.
|
|
11
12
|
// Read it via the androidx.savedstate.read extension, NOT Bundle.getString().
|
|
12
13
|
import androidx.savedstate.read
|
|
14
|
+
// Kept OUTSIDE the screen-imports block below: the tab rewriter (src/lib/tabs.mjs) replaces
|
|
15
|
+
// exactly those two lines with the configured tabs' screen imports.
|
|
16
|
+
import __PACKAGE__.presentation.components.exposeTestTagsForAutomation
|
|
13
17
|
import __PACKAGE__.presentation.home.HomeScreen
|
|
14
18
|
import __PACKAGE__.presentation.profile.ProfileScreen
|
|
15
19
|
|
|
@@ -38,7 +42,18 @@ fun AppNavHost() {
|
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
|
|
45
|
+
// Expose Compose testTags to the platform automation layer (Android resource-ids / iOS
|
|
46
|
+
// accessibilityIdentifiers) for the WHOLE graph. The property is inherited by descendants,
|
|
47
|
+
// so it belongs on the graph root, not on a destination: applied inside AppShell it would
|
|
48
|
+
// cover the tabs and nothing else, leaving every destination registered directly here
|
|
49
|
+
// (detail screens, trays) with testTags no id-selector can see — e2e flows could not
|
|
50
|
+
// assert arrival on them at all. Here, a destination added later inherits it without
|
|
51
|
+
// anyone remembering to. Desktop: no-op.
|
|
52
|
+
NavHost(
|
|
53
|
+
navController = navController,
|
|
54
|
+
startDestination = Screen.Shell.route,
|
|
55
|
+
modifier = Modifier.exposeTestTagsForAutomation(),
|
|
56
|
+
) {
|
|
42
57
|
composable(Screen.Shell.route) {
|
|
43
58
|
val tabs = appTabs(
|
|
44
59
|
home = {
|
|
@@ -10,7 +10,6 @@ import androidx.compose.runtime.setValue
|
|
|
10
10
|
import androidx.compose.ui.Modifier
|
|
11
11
|
import __PACKAGE__.presentation.components.AppBottomBar
|
|
12
12
|
import __PACKAGE__.presentation.components.BaseScreen
|
|
13
|
-
import __PACKAGE__.presentation.components.exposeTestTagsForAutomation
|
|
14
13
|
|
|
15
14
|
/**
|
|
16
15
|
* Generic bottom-nav shell. Parameterized by a [tabs] list — NOT role-hardcoded.
|
|
@@ -27,10 +26,8 @@ fun AppShell(tabs: List<AppTab>) {
|
|
|
27
26
|
var selected by rememberSaveable { mutableIntStateOf(0) }
|
|
28
27
|
|
|
29
28
|
BaseScreen(
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// cmp-test-generated suites find them; covers the whole subtree. Desktop: no-op.
|
|
33
|
-
modifier = Modifier.exposeTestTagsForAutomation(),
|
|
29
|
+
// testTag exposure for automation is applied once on the NavHost in AppNavHost.kt —
|
|
30
|
+
// the graph root, so every destination inherits it, not just these tabs.
|
|
34
31
|
// The bottom bar owns the navigation-bar inset; the body must not also pad it.
|
|
35
32
|
applyNavBarPadding = false,
|
|
36
33
|
bottomBar = {
|
package/template/qa/approve.mjs
CHANGED
|
@@ -9,8 +9,25 @@
|
|
|
9
9
|
// node qa/approve.mjs --accept-defaults express lane (GENESIS-FLOW-DESIGN.md §2): approves
|
|
10
10
|
// every currently-resolvable, not-yet-approved
|
|
11
11
|
// artifact, each stamped "defaults-accepted"
|
|
12
|
-
// node qa/approve.mjs --reopen <artifact>
|
|
13
|
-
//
|
|
12
|
+
// node qa/approve.mjs --reopen <artifact> --reason "…"
|
|
13
|
+
// moves an approved artifact back to "reopened" for
|
|
14
|
+
// redesign (refuses anything not currently approved).
|
|
15
|
+
// --reason is REQUIRED — a reopen walks back a
|
|
16
|
+
// signature, and the signer must be able to read why
|
|
17
|
+
// from the ledger itself (2026-07-28 flow audit)
|
|
18
|
+
// node qa/approve.mjs --reopen-feature <name> --reason "…"
|
|
19
|
+
// one recorded change, not N reopen commands: reopens
|
|
20
|
+
// the brief + its spec + its design + every artifact
|
|
21
|
+
// the brief declares in `touches` (each only if
|
|
22
|
+
// currently approved), all under one reason
|
|
23
|
+
// node qa/approve.mjs --log the journal — every approve/reopen/accept with
|
|
24
|
+
// when, which surface, and why (newest last)
|
|
25
|
+
// node qa/approve.mjs --accept <name> the HUMAN's bookend on a feature brief
|
|
26
|
+
// (feature-brief:<name>) — refused until the feature
|
|
27
|
+
// is provenDone (every live clause cited + receipt
|
|
28
|
+
// PASS + receipt attests this tree). There is no
|
|
29
|
+
// --deliver: doneness is DERIVED, never claimed
|
|
30
|
+
// (CHANGE-FLOW-DESIGN.md §2).
|
|
14
31
|
//
|
|
15
32
|
// This file has NO logic of its own — every decision (the registry, hashing,
|
|
16
33
|
// state, the transitions) lives in qa/lib/approvals.mjs. That's deliberate: the
|
|
@@ -22,12 +39,16 @@ import path from "node:path";
|
|
|
22
39
|
import { fileURLToPath } from "node:url";
|
|
23
40
|
|
|
24
41
|
import {
|
|
42
|
+
acceptFeature,
|
|
25
43
|
approveAllDefaults,
|
|
26
44
|
approveArtifact,
|
|
27
45
|
getApprovalStatuses,
|
|
46
|
+
getFeatureBoard,
|
|
28
47
|
isPackageResolvable,
|
|
29
48
|
listGovernedArtifacts,
|
|
49
|
+
readJournal,
|
|
30
50
|
reopenArtifact,
|
|
51
|
+
reopenFeature,
|
|
31
52
|
} from "./lib/approvals.mjs";
|
|
32
53
|
|
|
33
54
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -52,22 +73,44 @@ function printStatus() {
|
|
|
52
73
|
// approvable — approval over an unresolved file set is refused.
|
|
53
74
|
const hashInfo =
|
|
54
75
|
s.status === "reopened"
|
|
55
|
-
? `reopened at ${s.reopenedAt} (was approved ${shortHash(s.storedHash)})`
|
|
76
|
+
? `reopened at ${s.reopenedAt} (was approved ${shortHash(s.storedHash)})${s.reason ? ` — ${s.reason}` : ""}`
|
|
56
77
|
: s.status === "changed-since-approval"
|
|
57
78
|
? s.resolvable
|
|
58
79
|
? `approved ${shortHash(s.storedHash)} -> now ${shortHash(s.hash)}`
|
|
59
80
|
: `approved ${shortHash(s.storedHash)} -> unresolvable (${s.fileCount} of expected files resolved)`
|
|
60
81
|
: s.status === "approved"
|
|
61
|
-
?
|
|
82
|
+
? // storedHash is the signature; s.hash is the live recompute. They
|
|
83
|
+
// agree except on a legacy raw-bytes row, where printing the live
|
|
84
|
+
// value would name a hash nobody signed.
|
|
85
|
+
`${shortHash(s.storedHash) || shortHash(s.hash)}${
|
|
86
|
+
s.hashBasis === "raw-bytes" ? ", signed pre-strip — bytes unchanged since" : ""
|
|
87
|
+
}`
|
|
62
88
|
: s.resolvable
|
|
63
89
|
? `would approve at ${shortHash(s.hash)}`
|
|
64
90
|
: `unresolvable (${s.fileCount} of expected files resolved) — not approvable`;
|
|
65
91
|
const modeInfo = s.mode ? ` [${s.mode}]` : "";
|
|
66
|
-
|
|
92
|
+
// A feature brief's acceptance lives on the ledger row, never in the doc
|
|
93
|
+
// (the signed bytes must not move when the human accepts).
|
|
94
|
+
const lifecycle = s.accepted ? ` · accepted ${s.acceptedAt}` : "";
|
|
95
|
+
console.log(`${mark} ${s.id}: ${s.status} (${hashInfo})${modeInfo}${lifecycle} — ${s.label}`);
|
|
67
96
|
if (s.missing.length > 0) {
|
|
68
97
|
console.log(` missing: ${s.missing.join(", ")}`);
|
|
69
98
|
}
|
|
70
99
|
}
|
|
100
|
+
|
|
101
|
+
// Per-feature DERIVED doneness + next step — the same getFeatureBoard the
|
|
102
|
+
// console renders, so --status and the console never tell different
|
|
103
|
+
// stories. The next step names its owner: a signature HANDS OFF, it never
|
|
104
|
+
// commands — the agent drafts/builds/proves, the human signs/accepts.
|
|
105
|
+
const { features } = getFeatureBoard(ROOT);
|
|
106
|
+
if (features.length > 0) {
|
|
107
|
+
console.log("\nFeatures (doneness is derived, never claimed):");
|
|
108
|
+
for (const f of features) {
|
|
109
|
+
const mark = f.provenDone ? "✓" : "…";
|
|
110
|
+
console.log(` ${mark} ${f.name}: ${f.doneReason}${f.blockError ? ` — BLOCK ERROR: ${f.blockError}` : ""}`);
|
|
111
|
+
console.log(` next → ${f.nextStep.label}${f.nextStep.owner ? ` · ${f.nextStep.owner}` : ""}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
71
114
|
}
|
|
72
115
|
|
|
73
116
|
if (args.includes("--status")) {
|
|
@@ -102,27 +145,92 @@ if (args.includes("--accept-defaults")) {
|
|
|
102
145
|
process.exit(0);
|
|
103
146
|
}
|
|
104
147
|
|
|
148
|
+
// --reason is shared by --reopen and --reopen-feature: one plain sentence for
|
|
149
|
+
// the human who signed, recorded on the ledger row AND the journal. The library
|
|
150
|
+
// refuses without it; parsing it here just makes the usage line honest.
|
|
151
|
+
const reasonFlagIdx = args.indexOf("--reason");
|
|
152
|
+
const reason = reasonFlagIdx !== -1 ? args[reasonFlagIdx + 1] : undefined;
|
|
153
|
+
|
|
154
|
+
if (args.includes("--log")) {
|
|
155
|
+
const events = readJournal(ROOT);
|
|
156
|
+
if (events.length === 0) {
|
|
157
|
+
console.log("No journal yet (qa/approvals.log.jsonl) — it starts recording with the next approve/reopen/accept.");
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
160
|
+
console.log("Governance journal (oldest first):\n");
|
|
161
|
+
for (const e of events) {
|
|
162
|
+
const mark = e.verb === "approve" ? "✓" : e.verb === "reopen" ? "↺" : e.verb === "accept" ? "◆" : "·";
|
|
163
|
+
const bits = [e.via ? `via ${e.via}` : null, e.mode ? `[${e.mode}]` : null, e.feature ? `feature ${e.feature}` : null]
|
|
164
|
+
.filter(Boolean)
|
|
165
|
+
.join(" · ");
|
|
166
|
+
console.log(`${mark} ${e.at} ${e.verb} ${e.artifact}${bits ? ` (${bits})` : ""}${e.reason ? ` — ${e.reason}` : ""}`);
|
|
167
|
+
}
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|
|
170
|
+
|
|
105
171
|
const reopenFlagIdx = args.indexOf("--reopen");
|
|
106
172
|
if (reopenFlagIdx !== -1) {
|
|
107
173
|
refuseIfUnresolvable();
|
|
108
174
|
const artifactId = args[reopenFlagIdx + 1];
|
|
109
|
-
if (!artifactId) {
|
|
110
|
-
console.error(
|
|
175
|
+
if (!artifactId || artifactId === "--reason") {
|
|
176
|
+
console.error('usage: node qa/approve.mjs --reopen <artifact> --reason "why, in one sentence"');
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
const result = reopenArtifact(ROOT, artifactId, { reason, via: "cli" });
|
|
180
|
+
if (!result.ok) {
|
|
181
|
+
console.error(`error: ${result.reason}`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
console.log(`↺ reopened ${result.artifact} for redesign — at ${result.reopenedAt}\n reason: ${reason.trim()}`);
|
|
185
|
+
process.exit(0);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// One recorded change, not N reopen commands (2026-07-28 flow audit, fix 4):
|
|
189
|
+
// the brief is the container the human thinks in — reopen its whole declared
|
|
190
|
+
// set under one reason, each journal event grouped by the feature's name.
|
|
191
|
+
const reopenFeatureFlagIdx = args.indexOf("--reopen-feature");
|
|
192
|
+
if (reopenFeatureFlagIdx !== -1) {
|
|
193
|
+
refuseIfUnresolvable();
|
|
194
|
+
const name = args[reopenFeatureFlagIdx + 1];
|
|
195
|
+
if (!name || name === "--reason") {
|
|
196
|
+
console.error('usage: node qa/approve.mjs --reopen-feature <name> --reason "why, in one sentence"');
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
const result = reopenFeature(ROOT, name, { reason, via: "cli" });
|
|
200
|
+
if (!result.ok) {
|
|
201
|
+
console.error(`error: ${result.reason}`);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
console.log(`↺ reopened feature "${result.feature}" as one change — reason: ${reason.trim()}`);
|
|
205
|
+
for (const id of result.reopened) console.log(` ↺ ${id}`);
|
|
206
|
+
for (const s of result.skipped) console.log(` → skipped ${s.id} (${s.status})`);
|
|
207
|
+
process.exit(0);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The human's bookend: --accept takes the brief NAME, not the full artifact
|
|
211
|
+
// id — the docs/features/<name>.md filename is what a human knows. The library
|
|
212
|
+
// refuses until the feature is provenDone; there is no agent claim in between.
|
|
213
|
+
const acceptFlagIdx = args.indexOf("--accept");
|
|
214
|
+
if (acceptFlagIdx !== -1) {
|
|
215
|
+
refuseIfUnresolvable();
|
|
216
|
+
const name = args[acceptFlagIdx + 1];
|
|
217
|
+
if (!name) {
|
|
218
|
+
console.error("usage: node qa/approve.mjs --accept <name> (the brief's name — docs/features/<name>.md)");
|
|
111
219
|
process.exit(1);
|
|
112
220
|
}
|
|
113
|
-
const result =
|
|
221
|
+
const result = acceptFeature(ROOT, name, { via: "cli" });
|
|
114
222
|
if (!result.ok) {
|
|
115
223
|
console.error(`error: ${result.reason}`);
|
|
116
224
|
process.exit(1);
|
|
117
225
|
}
|
|
118
|
-
console.log(
|
|
226
|
+
console.log(`✓ accepted ${result.artifact} at ${result.acceptedAt} — the feature's card closes; the brief is its doc-of-record.`);
|
|
119
227
|
process.exit(0);
|
|
120
228
|
}
|
|
121
229
|
|
|
122
230
|
if (args.length === 0) {
|
|
123
231
|
const ids = listGovernedArtifacts(ROOT).map((a) => a.id);
|
|
124
232
|
console.error(
|
|
125
|
-
|
|
233
|
+
'usage: node qa/approve.mjs <artifact> | --status | --log | --accept-defaults | --reopen <artifact> --reason "…" | --reopen-feature <name> --reason "…" | --accept <name>\n' +
|
|
126
234
|
` valid artifacts: ${ids.length > 0 ? ids.join(", ") : "(none resolved in this project)"}`,
|
|
127
235
|
);
|
|
128
236
|
process.exit(1);
|
|
@@ -131,7 +239,7 @@ if (args.length === 0) {
|
|
|
131
239
|
refuseIfUnresolvable();
|
|
132
240
|
|
|
133
241
|
const artifactId = args[0];
|
|
134
|
-
const result = approveArtifact(ROOT, artifactId);
|
|
242
|
+
const result = approveArtifact(ROOT, artifactId, { via: "cli" });
|
|
135
243
|
if (!result.ok) {
|
|
136
244
|
console.error(`error: ${result.reason}`);
|
|
137
245
|
process.exit(1);
|