create-cmp-cli 0.10.1 → 0.12.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 (52) hide show
  1. package/README.md +11 -9
  2. package/package.json +1 -1
  3. package/src/lib/package-name.mjs +72 -0
  4. package/src/lib/tabs.mjs +26 -0
  5. package/src/scaffold.mjs +7 -2
  6. package/template/.claude/settings.json +30 -0
  7. package/template/.claude/skills/add-feature/SKILL.md +20 -0
  8. package/template/.claude/skills/add-repository/SKILL.md +6 -0
  9. package/template/.claude/skills/add-screen/SKILL.md +6 -0
  10. package/template/CLAUDE.md +129 -8
  11. package/template/composeApp/build.gradle.kts +69 -0
  12. package/template/composeApp/proguard-rules.pro +12 -0
  13. package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
  14. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
  15. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
  16. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
  17. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
  18. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
  19. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
  20. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
  21. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
  22. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
  23. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
  24. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
  25. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +16 -1
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +2 -5
  32. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
  33. package/template/docs/ARCHITECTURE.md +41 -2
  34. package/template/docs/TESTING.md +165 -0
  35. package/template/gradle/libs.versions.toml +15 -0
  36. package/template/manifest.json +1 -0
  37. package/template/qa/approve.mjs +119 -11
  38. package/template/qa/evidence/schema.json +20 -2
  39. package/template/qa/lib/affected-tests.mjs +147 -0
  40. package/template/qa/lib/approvals.mjs +602 -21
  41. package/template/qa/lib/device-lease.mjs +249 -0
  42. package/template/qa/lib/evidence-level.mjs +117 -0
  43. package/template/qa/lib/feature-brief.mjs +324 -0
  44. package/template/qa/lib/inputs-hash.mjs +43 -6
  45. package/template/qa/lib/reachability.mjs +211 -0
  46. package/template/qa/lib/spec-coverage.mjs +131 -0
  47. package/template/qa/lib/step-cache.mjs +221 -0
  48. package/template/qa/receipt-check.mjs +22 -2
  49. package/template/qa/scaffold-feature.mjs +20 -1
  50. package/template/qa/verify.mjs +776 -95
  51. package/template/qa/watch.mjs +622 -0
  52. package/template/specs/app-base.spec.md +11 -0
@@ -9,6 +9,7 @@ copy their shape.
9
9
  | Conformance gates (ARCH clauses) | `composeApp/src/desktopTest/…/conformance` | same task |
10
10
  | Screen behavior — Compose UI Test (spec-cited) | `composeApp/src/desktopTest/…/presentation` | same task |
11
11
  | Golden trees (structure) | `qa/golden/` + `HomeGoldenTreeTest` | same task |
12
+ | Instrumented behavior (platform facts) | `composeApp/src/androidInstrumentedTest` | `./gradlew :composeApp:connectedDebugAndroidTest` (device attached) |
12
13
  <!-- >>> cmp:feature e2e -->
13
14
  | E2E smoke (few) | `qa/e2e/*.yaml` (Maestro) | `maestro test qa/e2e/smoke.yaml` |
14
15
  <!-- <<< cmp:feature e2e -->
@@ -47,6 +48,82 @@ Every durable test cites the spec clause it verifies (`// SPEC: HOME-02` — see
47
48
  Never delete, weaken, or `@Ignore` a failing test to get green. Fix the behavior — or if the
48
49
  test is genuinely wrong, change it and say so explicitly in your PR/summary.
49
50
 
51
+ ## The instrumented tier — platform behavior
52
+
53
+ This seam exists because platform behavior escapes every desktop tier. Alarms,
54
+ notifications, lock-screen/full-screen intents, notification channels, PendingIntent
55
+ identity, audio routing, process death, and runtime permissions are OS facts:
56
+ `desktopTest` runs on a JVM, golden trees pin structure, the conformance suite is static,
57
+ and the E2E smoke taps UI without asserting anything about the shade or the alarm table.
58
+ A feature whose whole point is "the phone alerts" can ship fully green from every other
59
+ tier and never alert — that class of defect escaped to production repeatedly before this
60
+ tier existed. When your feature touches alarms, notifications, or locks, its behavior
61
+ test lives here.
62
+
63
+ **What belongs here:** claims only the OS can witness — a notification actually reached
64
+ the shade, a channel holds the importance the feature needs, N logical alarms occupy N
65
+ PendingIntent slots, the alert path plays with `USAGE_ALARM` on a silenced ringer, the
66
+ app survives process death, a permission-gated path degrades correctly.
67
+
68
+ **What does not:** logic (→ `commonTest`), rendered structure (→ golden trees),
69
+ architecture rules (→ conformance), UI journeys (→ E2E flows). If a JVM test can prove
70
+ it, a JVM test is where it goes — this tier is the most expensive seat in the house.
71
+
72
+ **Cost model:** needs a device/emulator, so it runs at checkpoint cadence — the lane's
73
+ `androidChecks` step (`connectedDebugAndroidTest`) SKIPs honestly when no device is
74
+ attached and runs in `local`/`ci` when one is, with the `release` profile adding the
75
+ release-APK smoke on top. It is not an inner-loop tier; don't reach for it per-edit.
76
+
77
+ **The helpers** (`androidInstrumentedTest/…/testing/`), small and composable — see
78
+ `PlatformBehaviorSeamTest` for the exemplar shape:
79
+
80
+ - `NotificationAsserts` — bounded-poll wait for a posted notification (id/tag or
81
+ predicate), channel-exists with an importance floor, full-screen-intent capability
82
+ (API-aware).
83
+ - `AlarmAsserts` — the OS alarm table (`dumpsys alarm`) parsed per-package;
84
+ alarm-registered and N-distinct-alarms assertions (the PendingIntent-identity
85
+ collision, caught mechanically).
86
+ - `SystemState` — snapshot/restore of ringer mode and DND so audio-routing claims are
87
+ testable; its header is honest about what instrumentation cannot control (OEM sound
88
+ policy, "a human heard it") — those stay a documented manual tier.
89
+
90
+ ### Runtime state control — put the system into the state the claim is about
91
+
92
+ The observation helpers above answer "what did the OS do?"; this organ family answers
93
+ the prior question — "can the test even reach the state the claim is about?" Most
94
+ unprovable mobile claims are unprovable exactly because the state is hard to reach: you
95
+ would have to wait hours for Doze, ship to a user who denies the permission, drop the
96
+ network by hand, or hope the OS reclaims your process while you watch. Each organ
97
+ reaches one such state on demand — emulator-only (every entry point SKIPs on a real
98
+ device), root-free on stock user-build images (every shell command verified from the
99
+ shell uid), and bracket-shaped (`withX { }` — snapshot, act, restore in `finally`) so
100
+ organs compose:
101
+
102
+ - `TimeWarp` — the device clock and time zone ("the 08:00 alarm actually arrives", DST).
103
+ - `DozeControl` — forced light/deep idle; composed with TimeWarp it proves
104
+ `setExactAndAllowWhileIdle` does what its name promises (the flagship exemplar in
105
+ `RuntimeStateSeamTest`).
106
+ - `PermissionControl` — the denied state (the fresh-install default) as a test input;
107
+ one-way grants. Its header documents the trap that shapes it: revoking a held runtime
108
+ permission kills the holding process — this seam's own process.
109
+ - `ProcessControl` — real OS-driven activity destruction and saved-state rebuild
110
+ (don't-keep-activities — flipped live via the same binder call the Developer-options
111
+ toggle uses, because `settings put global always_finish_activities` alone is read only
112
+ at boot; verified on API 35); honest about why in-process "kill my own process" is
113
+ structurally impossible under instrumentation.
114
+ - `NetworkControl` — offline (airplane mode) and per-transport wifi/data brackets; the
115
+ path to proving `core/connectivity/NetworkMonitor` tracks the world.
116
+ - `ConfigControl` — dark mode, font scale, per-app locale (API 33+); configuration
117
+ change as the other state-loss lever.
118
+
119
+ What device-state control cannot reproduce, honestly: the path INTO a state (real Doze's
120
+ descent ladder and maintenance windows — forced idle teleports and holds), OEM policy
121
+ (battery managers, sound routing, skinned lock screens), app-standby buckets, real-network
122
+ character (bandwidth, captive portals, flaky RSSI — emulator transports are the host's
123
+ connection wearing costumes), true cold-process start under instrumentation, and anything
124
+ whose proof is a human's senses. Each organ's header states its own gaps; a green test is
125
+ evidence for exactly the state the organ produced, nothing wider.
126
+
50
127
  <!-- >>> cmp:feature e2e -->
51
128
  ## E2E
52
129
 
@@ -70,3 +147,91 @@ asserts are for static post-navigation elements only.
70
147
  attached. It writes the evidence receipt to `qa/evidence/latest.json`; **commit the receipt
71
148
  with your change.** SKIPped steps are recorded honestly — green-with-gaps is visible, never
72
149
  silent.
150
+
151
+ **`--fast` is the lane's inner loop.** `node qa/verify.mjs --fast` runs the resolved
152
+ profile minus the device/release tier (`releaseBuild`, `tokenDrift`, `e2eSmoke`,
153
+ `androidChecks`, `releaseSmoke`) — unconditionally, device attached or not. It exists
154
+ because device/release evidence is the scarce, slow tier (R8 compile, emulator, Maestro,
155
+ instrumented runner): batch it at the checkpoint, don't pay for it on every small edit.
156
+ The rest of the profile runs cheaply, three ways:
157
+
158
+ - **Unchanged pure-Node steps are reused, not re-run.** `specCoverage`, `approvals`,
159
+ `componentStories`, `reachability`, and `archDoc` are pure functions of files on disk;
160
+ a fast run reuses each one's last PASS when a content hash of its declared inputs is
161
+ unchanged, shown as `⚡ name: CACHED (unchanged since …)`. Only a PASS is ever reused —
162
+ a FAIL or SKIP always re-runs — and the cache
163
+ (`composeApp/build/.cmp-step-cache.json`) is a gitignored cache, never evidence: the
164
+ full lane never reads it (it always executes every step; it only writes entries so the
165
+ next fast run benefits).
166
+ - **Gradle's up-to-date checks stand.** The full lane forces test execution with
167
+ `--rerun` (evidence integrity — a receipt must attest tests that ran). A fast receipt
168
+ is already declared non-evidence, so fast mode omits the flag and unchanged test tasks
169
+ cost nothing.
170
+ - **Unit tests are scoped to the change.** Changed `.kt` files (git diff + untracked)
171
+ map to `--tests "*<feature>*"` filters, reported honestly in the step line and the
172
+ receipt. Broad-impact changes — build files, DI, theme/tokens, shared components,
173
+ `qa/` itself, anything outside `composeApp/src` — disable filtering and run the whole
174
+ suite, as does any uncertain case (no git, unmappable change): fail open, never fail
175
+ silent. A filtered fast run can miss a cross-feature regression by design; the full,
176
+ unfiltered suite at the checkpoint is what decides done.
177
+
178
+ A fast run is mechanically unable to claim done — its receipt records `"mode": "fast"`,
179
+ derives **no** evidence rung, and `qa/receipt-check.mjs` (the Stop hook, CI, pre-push)
180
+ refuses it by name. Iterate on `--fast`; run the full lane once, deliberately, when the
181
+ change is done.
182
+
183
+ **Watch mode makes the inner loop resident.** `node qa/watch.mjs` watches
184
+ `composeApp/src`, `specs/`, and `qa/` and re-runs `node qa/verify.mjs --fast` (as a
185
+ subprocess — it inherits every fast-mode economy above for free) on every save, debounced
186
+ so a multi-file save storm triggers one run and changes landing mid-run coalesce into
187
+ exactly one follow-up. Each run re-prints the step table with any failing step's reason
188
+ verbatim — the errors-on-save loop an IDE gives a human, for the agent. What it is **not**:
189
+ evidence. It runs the fast tier only, so nothing it produces can satisfy the done-gate,
190
+ and every run says so in a standing footer; the checkpoint remains one deliberate full
191
+ `node qa/verify.mjs` run. Coordination: it never launches while a verify lane
192
+ (`.cmp-lane-in-progress`) or a preview-daemon render (`.cmp-render-in-progress`) holds the
193
+ project — it waits and coalesces, so watch mode, the preview daemon, and the lane can all
194
+ be resident on one project without two Gradle invocations ever colliding. `--once` runs a
195
+ single coordinated pass for scripting; `--json` emits one JSON object per run for
196
+ programmatic consumption.
197
+
198
+ ## One device, one driver
199
+
200
+ The machine typically has ONE Android device/emulator, and it is the scarcest, slowest,
201
+ most fragile resource the harness touches — two concurrent drivers produce wedged adbd,
202
+ `device offline` while `adb devices` looks fine, crossed app state, and false reds that
203
+ have nothing to do with the app. So device evidence is **batched, never an inner loop**:
204
+ the lane sequences its device steps (`tokenDrift` live tier, `e2eSmoke`, `androidChecks`,
205
+ `releaseSmoke`) once, last — don't hand-run `connectedDebugAndroidTest`, `maestro test`,
206
+ or `adb install` mid-task to "check something".
207
+
208
+ Mechanically, the first device step takes a **machine-global lease** on the device's adb
209
+ serial (`<os tmpdir>/create-cmp/device-leases/<serial>.json` — `qa/lib/device-lease.mjs`
210
+ documents the contract), held until the lane exits. It is machine-global on purpose: two
211
+ different projects (a scratch app in /tmp, the real one) share the same emulator, and
212
+ per-project markers cannot see each other.
213
+
214
+ **Contention is a SKIP, never a FAIL** — nothing is broken; another run legitimately holds
215
+ the device, and the reason names it: `held by "verify lane e2eSmoke" (pid 4711,
216
+ /tmp/scratch-x, 2m ago)`. A SKIPped device step does not buy its rung, so contention
217
+ visibly *lowers* the evidence level (L2 falls back to L1) instead of corrupting the run —
218
+ re-run when the holder finishes to earn the full rung. To see who holds a device:
219
+ `cat "${TMPDIR:-/tmp}/create-cmp/device-leases/<serial>.json"`. A crashed holder never
220
+ wedges the machine: a lease whose pid is dead, or older than 30 minutes, is silently
221
+ reclaimed by the next run. The live inspector tier (`connect_live`,
222
+ `navigate_and_inspect`) checks the same lease and refuses to drive a leased device by
223
+ naming the holder instead of failing with a mysterious transport error.
224
+
225
+ ## The evidence ladder
226
+
227
+ Every PASS receipt names its rung (`evidenceLevel` in the receipt; derived by
228
+ `qa/lib/evidence-level.mjs` from which steps actually ran and PASSed — never declared, and
229
+ a SKIPped step never buys a rung: an unsigned-keystore `releaseSmoke` SKIP is not L3). The
230
+ rung is the coarse grade; the per-step list stays the fine print. A FAILed lane has no rung.
231
+
232
+ | Rung | What it proves | What it does NOT prove |
233
+ |---|---|---|
234
+ | **L0 scaffold** | Stamp-time green: the build compiles and the unit tests pass. | Nothing about conformance, rendered structure, a11y, or the release variant — and nothing on a device. |
235
+ | **L1 desktop** | Full static + JVM evidence: build, unit tests, conformance, golden trees, a11y, release COMPILE, and the pure-Node gates. | That the app runs on a device at all — no APK was installed or driven; platform behavior (alarms, notifications) is invisible from this rung. |
236
+ | **L2 device** | L1 plus executed on-device evidence: the debug APK installed and driven (`e2eSmoke`), instrumented platform assertions (`androidChecks`), and/or live token drift. | That the release variant behaves (R8 differs from debug — that is L3's job), nor that alarms/notifications actually land unless an instrumented behavior test asserts them. |
237
+ | **L3 release** | L2 plus `releaseSmoke` PASSed: the signed release APK installed and driven on a device. | Real-backend behavior (the emulator/dev backend is a documented tier boundary — see the instrumented-tier section) and store-review compliance. |
@@ -22,6 +22,14 @@ compose-hot-reload = "1.1.1"
22
22
  coil = "3.1.0"
23
23
  androidx-core = "1.15.0"
24
24
  androidx-activity = "1.10.1"
25
+ # ── Android instrumentation tier (androidInstrumentedTest). The alarm table, the
26
+ # ── notification shade, channels, and audio routing are OS facts no JVM test can see —
27
+ # ── this tier is the only evidence that crosses the process boundary onto a real device.
28
+ androidx-test-runner = "1.6.2"
29
+ androidx-test-core = "1.6.1"
30
+ androidx-test-ext-junit = "1.2.1"
31
+ androidx-uiautomator = "2.3.0"
32
+ junit4 = "4.13.2"
25
33
 
26
34
  [libraries]
27
35
  # Compose Multiplatform
@@ -82,6 +90,13 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx
82
90
  androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
83
91
  android-desugar-jdk = { module = "com.android.tools:desugar_jdk_libs", version = "2.1.3" }
84
92
 
93
+ # Android instrumentation (androidInstrumentedTest — the on-device behavior tier)
94
+ androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
95
+ androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test-core" }
96
+ androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" }
97
+ androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" }
98
+ junit4 = { module = "junit:junit", version.ref = "junit4" }
99
+
85
100
  # Test
86
101
  kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
87
102
  koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }
@@ -30,6 +30,7 @@
30
30
  "composeApp/src/androidMain/kotlin",
31
31
  "composeApp/src/androidDebug/kotlin",
32
32
  "composeApp/src/androidRelease/kotlin",
33
+ "composeApp/src/androidInstrumentedTest/kotlin",
33
34
  "composeApp/src/iosMain/kotlin",
34
35
  "composeApp/src/desktopMain/kotlin"
35
36
  ],
@@ -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> moves an approved artifact back to "reopened" for
13
- // redesign (refuses anything not currently approved)
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
- ? shortHash(s.hash)
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
- console.log(`${mark} ${s.id}: ${s.status} (${hashInfo})${modeInfo} ${s.label}`);
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("usage: node qa/approve.mjs --reopen <artifact>");
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 = reopenArtifact(ROOT, artifactId);
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(`↺ reopened ${result.artifact} for redesign — at ${result.reopenedAt}`);
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
- "usage: node qa/approve.mjs <artifact> | --status | --accept-defaults | --reopen <artifact>\n" +
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);
@@ -6,7 +6,11 @@
6
6
  "required": ["schema", "profile", "verdict", "commit", "steps", "artifacts", "toolVersions", "generatedAt"],
7
7
  "properties": {
8
8
  "schema": { "const": "cmp-evidence/1" },
9
- "profile": { "enum": ["scaffold", "local", "ci"] },
9
+ "profile": { "enum": ["scaffold", "local", "ci", "release"] },
10
+ "mode": {
11
+ "enum": ["full", "fast"],
12
+ "description": "How the lane was run. \"full\" is the done-gate. \"fast\" (verify --fast) excluded the device/release tier (releaseBuild, tokenDrift, e2eSmoke, androidChecks, releaseSmoke): an inner-loop signal whose receipt derives no evidence rung and is REFUSED by qa/receipt-check.mjs — it can never satisfy done. Absent on receipts predating the flag — treated as full."
13
+ },
10
14
  "verdict": { "enum": ["PASS", "FAIL"] },
11
15
  "commit": {
12
16
  "type": "object",
@@ -32,13 +36,27 @@
32
36
  "required": ["name", "verdict", "durationMs"],
33
37
  "properties": {
34
38
  "name": { "type": "string" },
35
- "verdict": { "enum": ["PASS", "FAIL", "SKIP"] },
39
+ "verdict": {
40
+ "enum": ["PASS", "FAIL", "SKIP", "CACHED"],
41
+ "description": "CACHED appears ONLY on mode:\"fast\" receipts: a pure-Node step's last PASS reused because its content-hashed input set is unchanged (qa/lib/step-cache.mjs). Counts as PASS for the lane verdict but stays visibly distinct — the full lane never consults the cache, so a full receipt never carries it."
42
+ },
36
43
  "reason": { "type": "string" },
44
+ "note": { "type": "string", "description": "Honest fine print on a non-FAIL step (fast mode): which unit-test filter ran, or when a CACHED verdict was originally earned." },
37
45
  "durationMs": { "type": "number" },
38
46
  "details": { "type": "object" }
39
47
  }
40
48
  }
41
49
  },
50
+ "evidenceLevel": {
51
+ "type": ["object", "null"],
52
+ "description": "The evidence ladder rung (qa/lib/evidence-level.mjs), DERIVED from which steps actually ran and PASSed — never declared. L0 scaffold / L1 desktop / L2 device / L3 release; a SKIPped step never upgrades a rung. null when the lane FAILed (a failed lane has no rung). Absent on receipts predating the ladder.",
53
+ "required": ["rung", "name", "satisfiedBy"],
54
+ "properties": {
55
+ "rung": { "enum": ["L0", "L1", "L2", "L3"] },
56
+ "name": { "enum": ["scaffold", "desktop", "device", "release"] },
57
+ "satisfiedBy": { "type": "array", "items": { "type": "string" }, "description": "The PASSed step names the rung counts as its evidence, in lane order" }
58
+ }
59
+ },
42
60
  "artifacts": {
43
61
  "type": "array",
44
62
  "items": {
@@ -0,0 +1,147 @@
1
+ // affected-tests.mjs — FAST-MODE-ONLY scoping of the unit-test suite to the
2
+ // tests plausibly affected by the working-tree change.
3
+ //
4
+ // The full lane always runs the whole suite; this module exists so the inner
5
+ // loop (`verify --fast`) doesn't pay for every test on a one-file edit. Its
6
+ // honesty contract:
7
+ //
8
+ // - FALSE NEGATIVES ARE ACCEPTABLE HERE — AND ONLY HERE. A filtered fast
9
+ // run can miss a cross-feature regression; that is tolerable purely
10
+ // because the full, unfiltered suite runs at the checkpoint (the full
11
+ // lane), where done is actually decided. No other gate gets this license.
12
+ // - FAIL OPEN, NEVER FAIL SILENT. No git, a failed git command, an unmapped
13
+ // change, a broad-impact change — every uncertain case runs EVERYTHING,
14
+ // and the caller reports which case it was in the step's output and the
15
+ // receipt, so a filtered run can never be mistaken for the full suite.
16
+ // - The BLAST-RADIUS ESCAPE HATCH is mandatory: some paths fan out too
17
+ // widely to subset safely (build files rewire compilation, DI rewires
18
+ // object graphs, theme/tokens and shared components render into every
19
+ // screen, qa/ is the harness judging itself, and anything outside
20
+ // composeApp/src is by definition not a scoped source edit). Any one such
21
+ // change disables filtering for the run.
22
+ //
23
+ // Pure functions over path lists — git access is injected/separate so the
24
+ // engine suite can test every branch with no repo state.
25
+
26
+ import { execSync } from "node:child_process";
27
+ import path from "node:path";
28
+
29
+ /**
30
+ * Lane OUTPUTS, excluded from the changed-set before any classification.
31
+ * The receipt (qa/evidence/) and hashed artifacts (qa-artifacts/) change on
32
+ * every lane run by design; counting them as "changes" would make the qa/**
33
+ * escape hatch self-triggering forever — run N's receipt forcing run N+1 to
34
+ * the full suite, permanently. They cannot affect a test outcome (the same
35
+ * principle as inputs-hash.mjs's EXCLUDED_PREFIXES: lane outputs are not
36
+ * verdict inputs).
37
+ */
38
+ export const LANE_OUTPUT_PREFIXES = ["qa/evidence", "qa-artifacts"];
39
+
40
+ function isLaneOutput(p) {
41
+ return LANE_OUTPUT_PREFIXES.some((prefix) => p === prefix || p.startsWith(`${prefix}/`));
42
+ }
43
+
44
+ /**
45
+ * The mandatory blast-radius escape hatch: paths whose change fans out too
46
+ * widely to subset the suite safely. Returns the human-readable category when
47
+ * `p` is broad-impact, else null. Checked in order; the first match names the
48
+ * reason.
49
+ * @param {string} p POSIX relpath from the project root
50
+ * @returns {string|null}
51
+ */
52
+ export function broadImpactReason(p) {
53
+ if (p.endsWith(".gradle.kts") || p === "gradle.properties" || p === "gradle/libs.versions.toml") {
54
+ return "build files rewire compilation";
55
+ }
56
+ if (/(^|\/)di\//.test(p)) return "DI rewires the object graph";
57
+ if (/(^|\/)theme\//.test(p)) return "theme/tokens render into every screen";
58
+ if (p.includes("presentation/components/")) return "shared components render into every screen";
59
+ if (p === "qa" || p.startsWith("qa/")) return "qa/ is the harness itself";
60
+ if (!p.startsWith("composeApp/src/")) return "outside composeApp/src";
61
+ return null;
62
+ }
63
+
64
+ /**
65
+ * Derive the fast-mode unit-test filter from a list of changed paths.
66
+ *
67
+ * Mapping (deliberately simple and defensible): each changed `.kt` file under
68
+ * composeApp/src contributes its package's last segment — the parent
69
+ * directory name (`…/presentation/home/HomeViewModel.kt` → `home`, which the
70
+ * template's package-mirrors-path conformance makes a package segment) — and
71
+ * the union becomes Gradle `--tests "*<seg>*"` patterns matched against test
72
+ * class FQNs. Coarse on purpose: `*home*` runs every test whose FQN mentions
73
+ * the feature, which over-selects a little and under-maintains nothing.
74
+ *
75
+ * @param {string[]} changedPaths relpaths (either separator style) — tracked
76
+ * diffs plus untracked files, as from changedWorkingTreePaths()
77
+ * @returns {{mode: "filtered", patterns: string[], sourcePaths: string[]} |
78
+ * {mode: "all", reason: string, patterns: [], sourcePaths: string[]}}
79
+ * mode "all" ALWAYS carries the honest reason to report.
80
+ */
81
+ export function deriveAffectedFilter(changedPaths) {
82
+ const paths = [...new Set((changedPaths ?? [])
83
+ .filter((p) => typeof p === "string" && p.length > 0)
84
+ .map((p) => p.split(path.sep).join("/")))]
85
+ .filter((p) => !isLaneOutput(p))
86
+ .sort();
87
+
88
+ if (paths.length === 0) {
89
+ return { mode: "all", reason: "no working-tree changes to scope by", patterns: [], sourcePaths: [] };
90
+ }
91
+
92
+ for (const p of paths) {
93
+ const broad = broadImpactReason(p);
94
+ if (broad) {
95
+ return { mode: "all", reason: `broad-impact change — ${broad} (${p})`, patterns: [], sourcePaths: paths };
96
+ }
97
+ }
98
+
99
+ // Every remaining path is a scoped file under composeApp/src. Only .kt
100
+ // files map to test patterns; a change that maps to nothing (resources,
101
+ // manifests) falls open to the full suite below.
102
+ const ktPaths = paths.filter((p) => p.endsWith(".kt"));
103
+ const segments = new Set();
104
+ for (const p of ktPaths) {
105
+ const seg = path.posix.basename(path.posix.dirname(p));
106
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(seg)) segments.add(seg);
107
+ }
108
+
109
+ if (segments.size === 0) {
110
+ return { mode: "all", reason: "changed files map to no test filter", patterns: [], sourcePaths: paths };
111
+ }
112
+
113
+ return {
114
+ mode: "filtered",
115
+ patterns: [...segments].sort().map((s) => `*${s}*`),
116
+ sourcePaths: ktPaths,
117
+ };
118
+ }
119
+
120
+ function defaultRunGit(args, root) {
121
+ try {
122
+ return execSync(`git ${args}`, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * The working-tree change: tracked files differing from HEAD (staged or not)
130
+ * plus untracked-but-not-ignored files — the same "what will this commit
131
+ * touch" surface inputs-hash.mjs hashes.
132
+ *
133
+ * Returns null when git is unavailable or either command fails — the caller
134
+ * MUST treat null as "run everything" (fail open) and say so (never fail
135
+ * silent).
136
+ *
137
+ * @param {string} root project root
138
+ * @param {(args: string, root: string) => string|null} [runGit] injectable for tests
139
+ * @returns {string[]|null}
140
+ */
141
+ export function changedWorkingTreePaths(root, runGit = defaultRunGit) {
142
+ const diff = runGit("diff --name-only HEAD", root);
143
+ const untracked = runGit("ls-files --others --exclude-standard", root);
144
+ if (diff === null || untracked === null) return null;
145
+ const lines = (out) => out.replace(/\n+$/, "").split("\n").filter(Boolean);
146
+ return [...new Set([...lines(diff), ...lines(untracked)])];
147
+ }