create-cmp-cli 0.21.0 → 0.23.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 (31) hide show
  1. package/package.json +1 -1
  2. package/packages/harness/package.json +1 -1
  3. package/packages/harness/src/lib/device-provider.mjs +153 -0
  4. package/packages/harness/src/lib/e2e-coverage.mjs +60 -0
  5. package/packages/harness/src/lib/evidence-level.mjs +2 -1
  6. package/packages/harness/src/lib/feature-brief.mjs +16 -3
  7. package/packages/harness/src/lib/harness-lock.mjs +6 -1
  8. package/packages/harness/src/lib/harness-region.mjs +50 -2
  9. package/packages/harness/src/lib/spec-coverage.mjs +27 -3
  10. package/packages/harness/src/lib/step-outcomes.mjs +104 -0
  11. package/packages/harness/src/lib/steps-cmp.mjs +106 -40
  12. package/packages/harness/src/receipt-check.mjs +25 -0
  13. package/packages/harness/src/scaffold-feature.mjs +55 -0
  14. package/src/commands/upgrade.mjs +31 -2
  15. package/src/lib/tabs.mjs +18 -3
  16. package/src/lib/upgrade.mjs +50 -0
  17. package/template/.github/workflows/verify.yml +9 -0
  18. package/template/gitignore +9 -0
  19. package/template/qa/e2e/README.md +23 -4
  20. package/template/qa/e2e/smoke.yaml +9 -1
  21. package/template/qa/lib/device-provider.mjs +153 -0
  22. package/template/qa/lib/e2e-coverage.mjs +60 -0
  23. package/template/qa/lib/evidence-level.mjs +2 -1
  24. package/template/qa/lib/feature-brief.mjs +16 -3
  25. package/template/qa/lib/harness-lock.mjs +6 -1
  26. package/template/qa/lib/harness-region.mjs +50 -2
  27. package/template/qa/lib/spec-coverage.mjs +27 -3
  28. package/template/qa/lib/step-outcomes.mjs +104 -0
  29. package/template/qa/lib/steps-cmp.mjs +106 -40
  30. package/template/qa/receipt-check.mjs +25 -0
  31. package/template/qa/scaffold-feature.mjs +55 -0
@@ -21,16 +21,18 @@ import fs from "node:fs";
21
21
  import path from "node:path";
22
22
  import { compareTokenDrift } from "./token-drift.mjs";
23
23
  import { evaluateApprovalsGate } from "./approvals.mjs";
24
- import { TIERS_SATISFYING, clauseTierCoverage, scanCitations, scanSpecClauses, walkFiles } from "./spec-coverage.mjs";
24
+ import { E2E_FLOW_DIR, TIERS_SATISFYING, clauseTierCoverage, listFlowFiles, scanCitations, scanSpecClauses, walkFiles } from "./spec-coverage.mjs";
25
25
  import { evaluateComponentStoryParity } from "./component-stories.mjs";
26
26
  import { evaluateReachability } from "./reachability.mjs";
27
+ import { evaluateE2eCoverage } from "./e2e-coverage.mjs";
27
28
  import { memoizeStep } from "./step-cache.mjs";
28
29
  import { changedWorkingTreePaths, deriveAffectedFilter } from "./affected-tests.mjs";
29
30
  import { acquireDeviceLease, releaseDeviceLease, formatHolder } from "./device-lease.mjs";
30
31
  import { ARCH_DOC_REL_PATH, SECTION_IDS, regenerateArchDoc } from "./arch-doc.mjs";
31
32
  import { DETERMINISM_TIMEZONES, compareOutcomes, parseJUnitOutcomes } from "./determinism.mjs";
32
33
  import { evaluateAuditCadence } from "./audit-cadence.mjs";
33
- import { androidChecksOutcome } from "./step-outcomes.mjs";
34
+ import { androidChecksOutcome, deviceLogIncidents, maestroOutcome, parseMaestroJunit } from "./step-outcomes.mjs";
35
+ import { ensureDevice, releaseDevice } from "./device-provider.mjs";
34
36
  import { checkHarnessIntegrity, describeIntegrity, LOCK_PATH } from "./harness-lock.mjs";
35
37
  import { stepDisplayName } from "./lane-runner.mjs";
36
38
  import { CMP_LADDER } from "./evidence-level.mjs";
@@ -115,6 +117,33 @@ function deviceAttached() {
115
117
  // honest and visible is exactly why SKIP is the right verdict.
116
118
  let laneDeviceLease = null;
117
119
 
120
+ // ── The lane's device (qa/lib/device-provider.mjs) ──────────────────────────
121
+ // Provisioned ONCE per run by the first device step that needs it: an attached
122
+ // device is used as-is; with none attached the lane boots a headless emulator
123
+ // (bounded) and shuts it down in the runner's finally. A device that cannot
124
+ // be provisioned is an ERROR row — "could not check" — never a SKIP that
125
+ // reads as an honest gap and is then ignored forever (2026-09-03: the whole
126
+ // tier had SKIPped on every receipt anyone looked at). The one SKIP left is
127
+ // the explicit opt-out CMP_DEVICE=none, marked skipKind "environment" so the
128
+ // receipt check refuses it as done-evidence.
129
+ let laneDevice = null;
130
+
131
+ function deviceRow(stepName, d) {
132
+ if (d.optOut) return { name: stepName, verdict: "SKIP", skipKind: "environment", reason: d.reason, durationMs: 0 };
133
+ return { name: stepName, verdict: "ERROR", reason: `could not provision a device: ${d.reason}`, durationMs: 0 };
134
+ }
135
+
136
+ /** null when the lane has a device; otherwise the row the step returns verbatim. */
137
+ function ensureLaneDevice(stepName) {
138
+ if (!laneDevice) {
139
+ laneDevice = ensureDevice({ sh, log: (line) => console.error(`· ${line}`) });
140
+ if (laneDevice.ok && laneDevice.booted) {
141
+ console.error(`· booted ${laneDevice.avd} headless (${laneDevice.serial}) in ${Math.round(laneDevice.bootMs / 1000)} s — shut down when the lane exits (CMP_KEEP_DEVICE=1 keeps it)`);
142
+ }
143
+ }
144
+ return laneDevice.ok ? null : deviceRow(stepName, laneDevice);
145
+ }
146
+
118
147
  /** Serials of devices currently in `device` state (same parse as deviceAttached). */
119
148
  function attachedDeviceSerials() {
120
149
  const res = sh("adb devices", { timeout: 10_000 });
@@ -150,6 +179,7 @@ function leaseDeviceForStep(stepName) {
150
179
  return {
151
180
  name: stepName,
152
181
  verdict: "SKIP",
182
+ skipKind: "environment",
153
183
  reason: `${serials.length} devices attached (${serials.join(", ")}) — the lane cannot tell which one it would drive, so it leases none rather than guessing. Set ANDROID_SERIAL to the device this lane should own, or detach the extras.`,
154
184
  durationMs: 0,
155
185
  };
@@ -160,6 +190,7 @@ function leaseDeviceForStep(stepName) {
160
190
  return {
161
191
  name: stepName,
162
192
  verdict: "SKIP",
193
+ skipKind: "environment",
163
194
  reason: `device ${serial} is held by ${formatHolder(res.heldBy)} — device evidence is batched, not concurrent; wait for it or run once when it finishes`,
164
195
  durationMs: 0,
165
196
  };
@@ -383,6 +414,16 @@ function stepReachability() {
383
414
  // (regenerateArchDoc); this step only adds the name/duration bookkeeping every
384
415
  // step in this file carries, plus wording the FAIL reason for an AI
385
416
  // collaborator (name the stale/missing section, name the fix command).
417
+ // Every real feature has a device journey (qa/lib/e2e-coverage.mjs): a screen
418
+ // plus a spec means at least one live clause cited from a flow the lane runs.
419
+ // Pure Node. This is the gate that makes "write the Maestro flow" a lane
420
+ // verdict instead of a habit (Karel, 2026-09-03).
421
+ function stepE2eCoverage() {
422
+ const started = Date.now();
423
+ const { verdict, reason, details } = evaluateE2eCoverage(ROOT);
424
+ return { name: "e2eCoverage", verdict, reason, durationMs: Date.now() - started, details };
425
+ }
426
+
386
427
  function stepArchDoc() {
387
428
  const started = Date.now();
388
429
  const elapsed = () => Date.now() - started;
@@ -789,14 +830,8 @@ function stepTokenDrift() {
789
830
  const started = Date.now();
790
831
  const elapsed = () => Date.now() - started;
791
832
 
792
- if (!deviceAttached()) {
793
- return {
794
- name: "tokenDrift",
795
- verdict: "SKIP",
796
- reason: "no Android device/emulator attached (adb) — runtime token drift needs the live inspector tier",
797
- durationMs: elapsed(),
798
- };
799
- }
833
+ const device = ensureLaneDevice("tokenDrift");
834
+ if (device) return { ...device, durationMs: elapsed() };
800
835
 
801
836
  const unreachable = () => ({
802
837
  name: "tokenDrift",
@@ -870,15 +905,17 @@ function maestroAvailable() {
870
905
  // The e2e guard trio, shared by every step that drives the smoke flow on a device.
871
906
  // Returns null when the harness is fully available, else the SKIP result for [name].
872
907
  function maestroGuards(name) {
873
- if (!fs.existsSync(path.join(ROOT, "qa/e2e"))) {
874
- return { name, verdict: "SKIP", reason: "e2e harness not included in this project (--no-e2e)", durationMs: 0 };
908
+ if (!fs.existsSync(path.join(ROOT, E2E_FLOW_DIR))) {
909
+ return { name, verdict: "SKIP", skipKind: "structure", reason: "e2e harness not included in this project (--no-e2e)", durationMs: 0 };
875
910
  }
876
- if (!deviceAttached()) {
877
- return { name, verdict: "SKIP", reason: "no Android device/emulator attached (adb)", durationMs: 0 };
911
+ if (listFlowFiles(ROOT).length === 0) {
912
+ return { name, verdict: "SKIP", skipKind: "structure", reason: `${E2E_FLOW_DIR}/ holds no flows (*.yaml) — nothing to drive`, durationMs: 0 };
878
913
  }
879
914
  if (!maestroAvailable()) {
880
- return { name, verdict: "SKIP", reason: "maestro CLI not installed — curl -fsSL https://get.maestro.mobile.dev | bash", durationMs: 0 };
915
+ return { name, verdict: "SKIP", skipKind: "environment", reason: "maestro CLI not installed — curl -fsSL https://get.maestro.mobile.dev | bash", durationMs: 0 };
881
916
  }
917
+ const device = ensureLaneDevice(name);
918
+ if (device) return device;
882
919
  return null;
883
920
  }
884
921
 
@@ -897,32 +934,59 @@ function maestroGuards(name) {
897
934
  // hide_error_dialogs suppresses the OS dialog, NEVER the underlying event — so after the
898
935
  // run we grep the device log for ANR/crash lines the dialog would have shown, and FAIL on
899
936
  // them. The eyes must report what automation stability had to hide.
937
+ // EVERY flow runs (2026-09-03). This used to run qa/e2e/smoke.yaml by name
938
+ // while spec coverage counted a citation from ANY yaml under qa/e2e — four
939
+ // hand-written per-feature flows on the showcase satisfied clauses without
940
+ // ever executing. The directory runs in ONE Maestro session (one driver
941
+ // start-up, per-flow rows from the JUnit report); listFlowFiles is the same
942
+ // list the coverage scan reads, so cited ⊆ executed holds by construction.
943
+ /** Driver start-up plus three minutes per flow: the Maestro run's own bound, whatever the journal says. */
944
+ const E2E_RUN_BOUND_MS = (flowCount) => 120_000 + 180_000 * Math.max(1, flowCount);
945
+
900
946
  function runMaestroSmoke(name, priorDurationMs) {
947
+ const flows = listFlowFiles(ROOT);
948
+ const report = path.join(ROOT, "qa-artifacts", `maestro-${name}.xml`);
949
+ fs.mkdirSync(path.dirname(report), { recursive: true });
950
+ fs.rmSync(report, { force: true });
901
951
  const prevHideErrorDialogs = sh("adb shell settings get global hide_error_dialogs").out.trim();
902
952
  sh("adb shell settings put global hide_error_dialogs 1");
903
953
  sh("adb logcat -c"); // clear so the post-run dump only reflects this run
904
954
  try {
905
- const res = sh("maestro test qa/e2e/smoke.yaml", { env: { ...process.env, MAESTRO_DRIVER_STARTUP_TIMEOUT: "120000" } });
906
- if (!res.ok) {
907
- return {
908
- name,
909
- verdict: "FAIL",
910
- reason: `Maestro smoke failed (flow cites the SHELL spec clauses it proves):\n${res.out.split("\n").slice(-15).join("\n")}`,
911
- durationMs: priorDurationMs + res.durationMs,
912
- };
955
+ // Bounded on its own, not only by the step deadline: a FIRST run has no
956
+ // journal, so its deadline is the 30-minute ceiling — and on 2026-09-03
957
+ // Maestro selected the device and then sat, driver never started, app
958
+ // never in the foreground, under another lane's build load. Two
959
+ // minutes of driver start-up plus three per flow is generous for a
960
+ // healthy device; past it the run is killed and the row reads ERROR
961
+ // (StepTimeout), which is what "did not get to check" should say.
962
+ const runBoundMs = E2E_RUN_BOUND_MS(flows.length);
963
+ const res = sh(`maestro test ${E2E_FLOW_DIR} --format junit --output "${report}"`, { env: { ...process.env, MAESTRO_DRIVER_STARTUP_TIMEOUT: "120000" }, timeout: runBoundMs });
964
+ let xml = null;
965
+ try {
966
+ xml = fs.readFileSync(report, "utf8");
967
+ } catch {
968
+ xml = null;
913
969
  }
970
+ const outcome = maestroOutcome(res, parseMaestroJunit(xml), flows);
971
+ if (outcome.verdict !== "PASS") {
972
+ return { name, verdict: outcome.verdict, reason: outcome.reason, durationMs: priorDurationMs + res.durationMs, details: outcome.details };
973
+ }
974
+ // The post-run sweep is scoped to THIS app's process(es): the flows' own
975
+ // appId lines say which. Another app misbehaving on a shared emulator is
976
+ // not this lane's red (deviceLogIncidents).
977
+ const appIds = [...new Set(flows.map((rel) => (fs.readFileSync(path.join(ROOT, rel), "utf8").match(/^appId:\s*(\S+)/m) || [])[1]).filter(Boolean))];
914
978
  const anrDump = sh("adb logcat -d -b system,crash,main");
915
- const anrRe = /ANR in |FATAL EXCEPTION/i;
916
- if (anrDump.ok && anrRe.test(anrDump.out)) {
917
- const anrLines = anrDump.out.split("\n").filter((l) => anrRe.test(l)).slice(0, 10).join("\n");
979
+ const incidents = anrDump.ok ? deviceLogIncidents(anrDump.out, appIds) : { lines: [], scoped: appIds.length > 0 };
980
+ if (incidents.lines.length) {
918
981
  return {
919
982
  name,
920
983
  verdict: "FAIL",
921
- reason: `Maestro smoke passed, but the device log shows an ANR/crash during the run (hide_error_dialogs only suppresses the OS dialog, never the underlying event):\n${anrLines}`,
984
+ reason: `Maestro flows passed, but the device log shows an ANR/crash in ${incidents.scoped ? appIds.join(", ") : "some process (no appId known to scope by)"} during the run (hide_error_dialogs only suppresses the OS dialog, never the underlying event):\n${incidents.lines.slice(0, 10).join("\n")}`,
922
985
  durationMs: priorDurationMs + res.durationMs,
986
+ details: outcome.details,
923
987
  };
924
988
  }
925
- return { name, verdict: "PASS", durationMs: priorDurationMs + res.durationMs };
989
+ return { name, verdict: "PASS", durationMs: priorDurationMs + res.durationMs, note: `${flows.length} flow${flows.length === 1 ? "" : "s"}`, details: outcome.details, ...(outcome.reason ? { reason: outcome.reason } : {}) };
926
990
  } finally {
927
991
  if (prevHideErrorDialogs && prevHideErrorDialogs !== "null") {
928
992
  sh(`adb shell settings put global hide_error_dialogs ${prevHideErrorDialogs}`);
@@ -968,18 +1032,13 @@ function stepAndroidChecks() {
968
1032
  return {
969
1033
  name: "androidChecks",
970
1034
  verdict: "SKIP",
1035
+ skipKind: "structure",
971
1036
  reason: "no instrumented tests (composeApp/src/androidInstrumentedTest has no Kotlin sources)",
972
1037
  durationMs: Date.now() - started,
973
1038
  };
974
1039
  }
975
- if (!deviceAttached()) {
976
- return {
977
- name: "androidChecks",
978
- verdict: "SKIP",
979
- reason: "no Android device/emulator attached (adb) — instrumented behavior needs the real process boundary",
980
- durationMs: Date.now() - started,
981
- };
982
- }
1040
+ const device = ensureLaneDevice("androidChecks");
1041
+ if (device) return { ...device, durationMs: Date.now() - started };
983
1042
  // Machine-global lease before the first device touch (contention = SKIP).
984
1043
  const leaseSkip = leaseDeviceForStep("androidChecks");
985
1044
  if (leaseSkip) return { ...leaseSkip, durationMs: Date.now() - started };
@@ -1154,6 +1213,7 @@ const MEMOIZED_STEP_INPUTS = {
1154
1213
  approvals: ["qa/approvals.json", "specs", "docs/features", "docs/ARCHITECTURE.md", "composeApp/src"],
1155
1214
  componentStories: ["composeApp/src"],
1156
1215
  reachability: ["composeApp/src", "docs/features"],
1216
+ e2eCoverage: ["composeApp/src", "docs/features", "specs", "qa/e2e"],
1157
1217
  archDoc: ["docs/ARCHITECTURE.md", "docs/adr", "specs", "composeApp/src"],
1158
1218
  };
1159
1219
 
@@ -1173,12 +1233,13 @@ const stepSpecCoverageMemo = memoized("specCoverage", stepSpecCoverage);
1173
1233
  const stepApprovalsMemo = memoized("approvals", stepApprovals);
1174
1234
  const stepComponentStoriesMemo = memoized("componentStories", stepComponentStories);
1175
1235
  const stepReachabilityMemo = memoized("reachability", stepReachability);
1236
+ const stepE2eCoverageMemo = memoized("e2eCoverage", stepE2eCoverage);
1176
1237
  const stepArchDocMemo = memoized("archDoc", stepArchDoc);
1177
1238
 
1178
1239
  const stepsForProfile = {
1179
1240
  // scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
1180
1241
  // the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
1181
- scaffold: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
1242
+ scaffold: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepE2eCoverageMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
1182
1243
  // smoke (docs/GATE-RULES.md Rule 0, docs/PRINCIPLES.md #2): the smallest
1183
1244
  // end-to-end lane — every pure-Node step through the REAL runner, marker,
1184
1245
  // receipt and journal, and NO Gradle, no device, no network. Its job is to
@@ -1187,7 +1248,7 @@ const stepsForProfile = {
1187
1248
  // fresh scaffold, then FAIL BY NAME on one planted spec edit, each bounded
1188
1249
  // in seconds. Its receipt is refused as done-evidence (qa/receipt-check.mjs)
1189
1250
  // exactly like --fast: it proves the instrument, never the change.
1190
- smoke: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory],
1251
+ smoke: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepE2eCoverageMemo, stepArchDocMemo, stepSchemaHistory],
1191
1252
  local: [
1192
1253
  // First, always: every verdict below is only worth what the lane issuing
1193
1254
  // it is worth.
@@ -1196,6 +1257,7 @@ const stepsForProfile = {
1196
1257
  stepApprovalsMemo,
1197
1258
  stepComponentStoriesMemo,
1198
1259
  stepReachabilityMemo,
1260
+ stepE2eCoverageMemo,
1199
1261
  stepArchDocMemo,
1200
1262
  stepSchemaHistory,
1201
1263
  stepBuild,
@@ -1248,7 +1310,7 @@ stepsForProfile.release = [...stepsForProfile.ci, stepAuditCadence, stepReleaseS
1248
1310
  // nightly (evidence-economics S6 / proposal P4): the stage for proofs whose cost
1249
1311
  // scales with the SUITE rather than with the change — the determinism probe
1250
1312
  // today (forced on above; `--determinism` is implied), and the place any
1251
- // future mutation / load / chaos step lands, so the placement decision is made
1313
+ // future load / chaos step lands, so the placement decision is made
1252
1314
  // once instead of per expensive step. It proves the harness and the tree's
1253
1315
  // invariants, not a change: qa/receipt-check.mjs refuses its receipt as
1254
1316
  // done-evidence, exactly as it refuses --fast. Same step set as ci on purpose —
@@ -1266,7 +1328,7 @@ stepsForProfile.nightly = [...stepsForProfile.ci];
1266
1328
  // physical device. Layer names are free-form strings on the wire; these are
1267
1329
  // this pack's. Derived by NAME after the lists are built so a step listed in
1268
1330
  // two profiles is tagged once, and a step nobody listed is never tagged.
1269
- const SPINE_STEP_NAMES = new Set(["harnessIntegrity", "specCoverage", "approvals", "componentStories", "reachability", "archDoc", "schemaHistory", "auditCadence", "determinism"]);
1331
+ const SPINE_STEP_NAMES = new Set(["harnessIntegrity", "specCoverage", "approvals", "componentStories", "reachability", "e2eCoverage", "archDoc", "schemaHistory", "auditCadence", "determinism"]);
1270
1332
  function layerForStep(name) {
1271
1333
  if (DEVICE_STEPS.includes(name)) return "device";
1272
1334
  if (SPINE_STEP_NAMES.has(name)) return "spine";
@@ -1307,6 +1369,10 @@ for (const name of FAST_EXCLUDED_NAMES) {
1307
1369
  // decision above); the spine releases it in the runner's finally.
1308
1370
  releaseLease: () => {
1309
1371
  if (laneDeviceLease) releaseDeviceLease(laneDeviceLease);
1372
+ // The emulator this lane booted goes down with the lane; an attached
1373
+ // device is never touched (device-provider.mjs).
1374
+ const down = releaseDevice(laneDevice, { sh });
1375
+ if (down.shutdown) console.error(`· shut down ${laneDevice.avd} (${laneDevice.serial})`);
1310
1376
  },
1311
1377
  };
1312
1378
  }
@@ -108,6 +108,31 @@ function evaluate() {
108
108
  profile: receipt.profile,
109
109
  };
110
110
  }
111
+ // The device tier must have RUN (2026-09-03). The lane boots a headless
112
+ // emulator itself, so e2eSmoke/androidChecks only SKIP for two kinds of
113
+ // reason: the project's own structure (no qa/e2e harness, no instrumented
114
+ // sources — honest, allowed) or the ENVIRONMENT (CMP_DEVICE=none, maestro
115
+ // not installed, a lease held elsewhere, an ambiguous serial). The second
116
+ // kind is a gap a human can close, and a change is not done while it stands.
117
+ // Receipts predating `skipKind` are read by their reason text.
118
+ const DEVICE_TIER = ["e2eSmoke", "androidChecks"];
119
+ const envSkipped = (Array.isArray(receipt.steps) ? receipt.steps : []).filter(
120
+ (s) =>
121
+ s &&
122
+ DEVICE_TIER.includes(s.name) &&
123
+ s.verdict === "SKIP" &&
124
+ (s.skipKind === "environment" ||
125
+ (!s.skipKind && /no Android device|maestro CLI not installed|is held by|devices attached|CMP_DEVICE=none/.test(String(s.reason ?? "")))),
126
+ );
127
+ if (envSkipped.length) {
128
+ return {
129
+ valid: false,
130
+ reason:
131
+ `the device tier did not run — ${envSkipped.map((s) => `${s.name}: ${String(s.reason ?? "").split("\n")[0]}`).join("; ")}. ` +
132
+ "The lane boots a headless emulator itself (set CMP_AVD if it cannot choose one); fix the cause and run `node qa/verify.mjs` again before finishing",
133
+ profile: receipt.profile,
134
+ };
135
+ }
111
136
  // A surface this project cannot resolve is a REFUSAL with an explanation,
112
137
  // never an unhandled stack trace: this runs as the Stop hook on every turn
113
138
  // end, and a crash there reads as a broken harness rather than as the
@@ -315,6 +315,55 @@ const ALL_FILES = [
315
315
  { from: path.join(ROOT, `specs/${SOURCE_f}.spec.md`), to: path.join(ROOT, `specs/${f}.spec.md`), isDefaultSpec: true, presets: ["feature", "screen"] },
316
316
  ];
317
317
 
318
+ // The feature's Maestro flow (2026-09-03): every feature with a screen gets
319
+ // qa/e2e/<feature>.yaml, because the lane now runs EVERY flow in that
320
+ // directory and a feature with no flow has no device-tier journey by
321
+ // construction. It is a SKELETON that passes — launch + shell — with the
322
+ // feature's screen id and its spec clauses named as the work to do; it cites
323
+ // nothing (a citation it has not earned would read as coverage). Demand comes
324
+ // from the spec: a clause marked `[tier: e2e]` fails specCoverage until a
325
+ // flow cites them. Written only when the project carries the e2e harness.
326
+ const E2E_FLOW_PATH = path.join(ROOT, "qa", "e2e", `${f}.yaml`);
327
+ const WRITE_E2E_FLOW = ["feature", "screen"].includes(preset) && fs.existsSync(path.join(ROOT, "qa", "e2e")) && !fs.existsSync(E2E_FLOW_PATH);
328
+ function e2eFlowSkeleton() {
329
+ const appId = (() => {
330
+ try {
331
+ const cfg = JSON.parse(fs.readFileSync(path.join(ROOT, "create-cmp.json"), "utf8"));
332
+ if (typeof cfg.package === "string" && cfg.package) return cfg.package;
333
+ } catch {
334
+ /* fall through to the smoke flow's own appId */
335
+ }
336
+ try {
337
+ const m = fs.readFileSync(path.join(ROOT, "qa", "e2e", "smoke.yaml"), "utf8").match(/^appId:\s*(\S+)/m);
338
+ if (m) return m[1];
339
+ } catch {
340
+ /* no smoke flow */
341
+ }
342
+ return "__PACKAGE__";
343
+ })();
344
+ return `# E2E flow — ${f}. Maestro. The lane runs every flow in qa/e2e/ (e2eSmoke).
345
+ #
346
+ # SKELETON stamped by qa/scaffold-feature.mjs: it launches the app and proves the
347
+ # shell, then stops. Make it the ${f} journey:
348
+ # 1. navigate to the screen (its title carries testTag "${f}_title");
349
+ # 2. assert the behaviour the clauses in specs/${f}.spec.md promise — one
350
+ # "# SPEC: ${F_UPPER}-NN" line above the steps that prove each clause;
351
+ # 3. mark clauses only a device can observe "[tier: e2e]" in the spec — until a
352
+ # flow cites them, specCoverage FAILs by name, which is the point.
353
+ # Selectors by testTag (id:), never display text. After any interaction that
354
+ # triggers async state, assert with extendedWaitUntil (see smoke.yaml's SETTLE RULE).
355
+ appId: ${appId}
356
+ ---
357
+ - launchApp:
358
+ clearState: true
359
+ - extendedWaitUntil:
360
+ visible:
361
+ id: "app_bottom_nav"
362
+ timeout: 60000
363
+ # TODO(${f}): navigate to ${f} and assertVisible id: "${f}_title", then the ${F_UPPER}-NN journeys.
364
+ `;
365
+ }
366
+
318
367
  const FILES = ALL_FILES.filter((file) => file.presets.includes(preset));
319
368
 
320
369
  // Golden baseline: NOT copied (a feature's golden tree is captured fresh via
@@ -689,6 +738,12 @@ for (const file of FILES) {
689
738
  fs.writeFileSync(file.to, contents);
690
739
  filesWritten += 1;
691
740
  }
741
+ if (WRITE_E2E_FLOW) {
742
+ fs.mkdirSync(path.dirname(E2E_FLOW_PATH), { recursive: true });
743
+ fs.writeFileSync(E2E_FLOW_PATH, e2eFlowSkeleton());
744
+ console.log(` wrote ${path.relative(ROOT, E2E_FLOW_PATH)} — the feature's Maestro flow (skeleton; the lane runs it)`);
745
+ console.log(` NOTE: the lane's e2eCoverage gate FAILs for ${f} until that flow is the journey and cites a ${F_UPPER}-NN clause it proves — that is the point.`);
746
+ }
692
747
 
693
748
  let injectionsApplied = 0;
694
749
  for (const result of fileResults) {