create-cmp-cli 0.8.0 → 0.10.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 (85) hide show
  1. package/README.md +83 -9
  2. package/llms.txt +5 -1
  3. package/package.json +1 -1
  4. package/src/lib/adr-seed.mjs +178 -0
  5. package/src/lib/tabs.mjs +97 -4
  6. package/src/scaffold.mjs +52 -1
  7. package/template/.claude/skills/add-feature/SKILL.md +35 -10
  8. package/template/.claude/skills/add-repository/SKILL.md +1 -1
  9. package/template/.claude/skills/add-screen/SKILL.md +13 -7
  10. package/template/.githooks/pre-push +24 -0
  11. package/template/CLAUDE.md +213 -47
  12. package/template/README.md +32 -27
  13. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
  14. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
  15. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +19 -0
  16. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +177 -21
  17. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  18. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
  19. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +62 -0
  20. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  21. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  22. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt +75 -0
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +63 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  43. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +22 -0
  44. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  45. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +31 -0
  46. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  47. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
  48. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
  49. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
  50. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
  51. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
  52. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
  53. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +300 -0
  54. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
  55. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
  56. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  57. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
  58. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +250 -16
  59. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  60. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  61. package/template/docs/ARCHITECTURE.md +372 -34
  62. package/template/docs/TESTING.md +13 -5
  63. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  64. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  65. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  66. package/template/qa/approvals.json +42 -0
  67. package/template/qa/approve.mjs +139 -0
  68. package/template/qa/arch-doc.mjs +69 -0
  69. package/template/qa/comment.mjs +76 -0
  70. package/template/qa/comments.json +4 -0
  71. package/template/qa/e2e/smoke.yaml +6 -0
  72. package/template/qa/golden/home.json +3 -3
  73. package/template/qa/lib/a11y.mjs +17 -8
  74. package/template/qa/lib/approvals.mjs +822 -0
  75. package/template/qa/lib/arch-doc.mjs +451 -0
  76. package/template/qa/lib/comments.mjs +252 -0
  77. package/template/qa/lib/component-stories.mjs +183 -0
  78. package/template/qa/lib/inputs-hash.mjs +5 -1
  79. package/template/qa/scaffold-feature.mjs +184 -67
  80. package/template/qa/setup-hooks.mjs +33 -0
  81. package/template/qa/verify.mjs +181 -15
  82. package/template/qa/walkthrough.mjs +499 -0
  83. package/template/specs/app-base.spec.md +49 -7
  84. package/template/specs/home.spec.md +7 -4
  85. package/template/specs/intent.md +50 -0
@@ -26,6 +26,9 @@ import { fileURLToPath } from "node:url";
26
26
 
27
27
  import { computeInputsHash } from "./lib/inputs-hash.mjs";
28
28
  import { compareTokenDrift } from "./lib/token-drift.mjs";
29
+ import { evaluateApprovalsGate } from "./lib/approvals.mjs";
30
+ import { evaluateComponentStoryParity } from "./lib/component-stories.mjs";
31
+ import { ARCH_DOC_REL_PATH, SECTION_IDS, regenerateArchDoc } from "./lib/arch-doc.mjs";
29
32
 
30
33
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
31
34
  const EVIDENCE_DIR = path.join(ROOT, "qa", "evidence");
@@ -46,6 +49,30 @@ function sh(cmd, opts = {}) {
46
49
  return { ok, status: res.status, error: res.error?.message, out: `${res.stdout ?? ""}${res.stderr ?? ""}`, durationMs: Date.now() - started };
47
50
  }
48
51
 
52
+ // ── Preview-daemon coexistence ──────────────────────────────────────────────
53
+ // The preview daemon (the eyes) and this lane both spawn Gradle against this
54
+ // project and share composeApp/build/kspCaches, whose KSP incremental storage
55
+ // is single-owner — two concurrent builds throw "Storage for [...] is already
56
+ // registered" and one side dies. Two defenses, both automatic:
57
+ // 1. COORDINATE: this lane stamps a marker file for its duration; the preview
58
+ // service defers renders while it exists (mtime-bounded, so a crashed lane
59
+ // never wedges the eyes for long).
60
+ // 2. SELF-HEAL: a Gradle step that still hits the collision clears kspCaches
61
+ // and retries once — the manual recovery that always worked, automated.
62
+ const LANE_MARKER = path.join(ROOT, "composeApp", "build", ".cmp-lane-in-progress");
63
+ const KSP_COLLISION_RE = /Storage for \[[^\]]*\] is already registered/;
64
+
65
+ function shGradle(cmd, opts = {}) {
66
+ const first = sh(cmd, opts);
67
+ if (first.ok || !KSP_COLLISION_RE.test(first.out)) return first;
68
+ console.error("· KSP cache collision (concurrent Gradle — the preview daemon?) — clearing kspCaches, retrying once");
69
+ fs.rmSync(path.join(ROOT, "composeApp", "build", "kspCaches"), { recursive: true, force: true });
70
+ const retry = sh(cmd, opts);
71
+ retry.durationMs += first.durationMs;
72
+ retry.selfHealed = "ksp-cache-collision";
73
+ return retry;
74
+ }
75
+
49
76
  function tryGit(cmd) {
50
77
  try {
51
78
  return execSync(`git ${cmd}`, { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
@@ -54,6 +81,22 @@ function tryGit(cmd) {
54
81
  }
55
82
  }
56
83
 
84
+ /**
85
+ * Line-oriented git output, WITHOUT [tryGit]'s trim. `git status --porcelain`
86
+ * has significant leading whitespace: an unstaged modification is `" M path"`,
87
+ * so trimming the whole blob eats the first line's leading space — and a fixed
88
+ * `slice(3)` then swallows that path's first character. The receipt would name
89
+ * a file that does not exist. Only trailing newlines are dropped here.
90
+ */
91
+ function tryGitLines(cmd) {
92
+ try {
93
+ const out = execSync(`git ${cmd}`, { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
94
+ return out.replace(/\n+$/, "").split("\n").filter(Boolean);
95
+ } catch {
96
+ return [];
97
+ }
98
+ }
99
+
57
100
  function junitSummary(dir) {
58
101
  if (!fs.existsSync(dir)) return null;
59
102
  let tests = 0, failures = 0, errors = 0, skipped = 0;
@@ -176,8 +219,79 @@ function stepSpecCoverage() {
176
219
  };
177
220
  }
178
221
 
222
+ // Human-approval gate (VERIFICATION-LAYER-DESIGN.md §2) — pure Node, no Gradle,
223
+ // same grouping as specCoverage. The decision itself lives in
224
+ // qa/lib/approvals.mjs (evaluateApprovalsGate); this step only adds the
225
+ // name/duration bookkeeping every step in this file carries.
226
+ function stepApprovals() {
227
+ const started = Date.now();
228
+ const { verdict, reason, statuses } = evaluateApprovalsGate(ROOT);
229
+ return {
230
+ name: "approvals",
231
+ verdict,
232
+ reason,
233
+ durationMs: Date.now() - started,
234
+ details: { artifacts: statuses.map((s) => ({ id: s.id, status: s.status, hash: s.hash })) },
235
+ };
236
+ }
237
+
238
+ // Component ↔ story parity gate (STUDIO-REDESIGN.md §3.3) — pure Node, no
239
+ // Gradle, same grouping as specCoverage/approvals. The decision itself lives
240
+ // in qa/lib/component-stories.mjs (evaluateComponentStoryParity); this step
241
+ // only adds the name/duration bookkeeping every step in this file carries.
242
+ function stepComponentStories() {
243
+ const started = Date.now();
244
+ const { verdict, reason, details } = evaluateComponentStoryParity(ROOT);
245
+ return { name: "componentStories", verdict, reason, durationMs: Date.now() - started, details };
246
+ }
247
+
248
+ // Architecture-doc freshness gate (Wave B, docs/proposals/architecture-document-
249
+ // standard.md §6) — pure Node, no Gradle, same grouping as specCoverage/
250
+ // approvals. The decision itself lives in qa/lib/arch-doc.mjs
251
+ // (regenerateArchDoc); this step only adds the name/duration bookkeeping every
252
+ // step in this file carries, plus wording the FAIL reason for an AI
253
+ // collaborator (name the stale/missing section, name the fix command).
254
+ function stepArchDoc() {
255
+ const started = Date.now();
256
+ const elapsed = () => Date.now() - started;
257
+
258
+ const result = regenerateArchDoc(ROOT);
259
+ if (!result.ok) {
260
+ return { name: "archDoc", verdict: "SKIP", reason: `${result.reason} — nothing to check`, durationMs: elapsed() };
261
+ }
262
+ if (result.unknownSections.length > 0) {
263
+ return {
264
+ name: "archDoc",
265
+ verdict: "FAIL",
266
+ reason: `${ARCH_DOC_REL_PATH} has cmp:generated marker(s) with no registered generator: ${result.unknownSections.join(", ")} — add a generator in qa/lib/arch-doc.mjs or remove the marker.`,
267
+ durationMs: elapsed(),
268
+ };
269
+ }
270
+
271
+ const stale = result.changed || result.missingSections.length > 0;
272
+ if (!stale) {
273
+ return { name: "archDoc", verdict: "PASS", durationMs: elapsed(), details: { sectionsChecked: SECTION_IDS.length } };
274
+ }
275
+
276
+ const lines = [`${ARCH_DOC_REL_PATH} is stale — a generated section no longer matches the tree:`];
277
+ for (const id of result.changedSections) {
278
+ lines.push(` [${id}] regenerating would change this section.`);
279
+ }
280
+ for (const id of result.missingSections) {
281
+ lines.push(` [${id}] marker missing from the doc entirely — never generated.`);
282
+ }
283
+ lines.push("Run: node qa/arch-doc.mjs");
284
+ return {
285
+ name: "archDoc",
286
+ verdict: "FAIL",
287
+ reason: lines.join("\n"),
288
+ durationMs: elapsed(),
289
+ details: { changedSections: result.changedSections, missingSections: result.missingSections },
290
+ };
291
+ }
292
+
179
293
  function stepBuild() {
180
- const res = sh(`${GRADLEW} :composeApp:assembleDebug --console=plain`);
294
+ const res = shGradle(`${GRADLEW} :composeApp:assembleDebug --console=plain`);
181
295
  return {
182
296
  name: "build",
183
297
  verdict: res.ok ? "PASS" : "FAIL",
@@ -191,7 +305,7 @@ function stepBuild() {
191
305
  // cached) while `--rerun` forces the tests themselves to EXECUTE — see stepUnitTests.
192
306
  function gradleTestStep(name, testsFilter, failHint) {
193
307
  return () => {
194
- const res = sh(`${GRADLEW} :composeApp:desktopTest --rerun --tests "${testsFilter}" --console=plain`);
308
+ const res = shGradle(`${GRADLEW} :composeApp:desktopTest --rerun --tests "${testsFilter}" --console=plain`);
195
309
  return {
196
310
  name,
197
311
  verdict: res.ok ? "PASS" : "FAIL",
@@ -209,7 +323,7 @@ function stepUnitTests() {
209
323
  // produce byte-identical sources, and golden baselines aren't compile inputs), so the
210
324
  // receipt would attest tests that never executed. Compilation stays cached — only the
211
325
  // test execution is forced.
212
- const res = sh(`${GRADLEW} :composeApp:desktopTest --rerun --console=plain`);
326
+ const res = shGradle(`${GRADLEW} :composeApp:desktopTest --rerun --console=plain`);
213
327
  const summary = junitSummary(path.join(ROOT, "composeApp/build/test-results/desktopTest"));
214
328
  return {
215
329
  name: "unitTests",
@@ -362,7 +476,7 @@ function stepE2eSmoke() {
362
476
  if (!maestroAvailable()) {
363
477
  return { name: "e2eSmoke", verdict: "SKIP", reason: "maestro CLI not installed — curl -fsSL https://get.maestro.mobile.dev | bash", durationMs: 0 };
364
478
  }
365
- const install = sh(`${GRADLEW} :composeApp:installDebug --console=plain`);
479
+ const install = shGradle(`${GRADLEW} :composeApp:installDebug --console=plain`);
366
480
  if (!install.ok) {
367
481
  return { name: "e2eSmoke", verdict: "FAIL", reason: "installDebug failed — the APK could not be installed on the attached device", durationMs: install.durationMs };
368
482
  }
@@ -372,15 +486,44 @@ function stepE2eSmoke() {
372
486
  // that steal focus over the app — a Maestro assert would then see only the dialog;
373
487
  // - MAESTRO_DRIVER_STARTUP_TIMEOUT gives the UiAutomator2 driver a generous budget to come
374
488
  // up on a slow emulator (the built-in default gives up too early under load).
375
- // Both are benign, reversible, and only touch the device while the lane is driving it.
489
+ // Both are benign, reversible, and only touch the device while the lane is driving it
490
+ // hide_error_dialogs is restored to its pre-run value (or deleted, returning the device
491
+ // to its default) in the finally below, on every exit path.
492
+ // hide_error_dialogs suppresses the OS dialog, NEVER the underlying event — so after the
493
+ // run we grep the device log for ANR/crash lines the dialog would have shown, and FAIL on
494
+ // them. The eyes must report what automation stability had to hide.
495
+ const prevHideErrorDialogs = sh("adb shell settings get global hide_error_dialogs").out.trim();
376
496
  sh("adb shell settings put global hide_error_dialogs 1");
377
- const res = sh("maestro test qa/e2e/smoke.yaml", { env: { ...process.env, MAESTRO_DRIVER_STARTUP_TIMEOUT: "120000" } });
378
- return {
379
- name: "e2eSmoke",
380
- verdict: res.ok ? "PASS" : "FAIL",
381
- reason: res.ok ? undefined : `Maestro smoke failed (flow cites the SHELL spec clauses it proves):\n${res.out.split("\n").slice(-15).join("\n")}`,
382
- durationMs: install.durationMs + res.durationMs,
383
- };
497
+ sh("adb logcat -c"); // clear so the post-run dump only reflects this run
498
+ try {
499
+ const res = sh("maestro test qa/e2e/smoke.yaml", { env: { ...process.env, MAESTRO_DRIVER_STARTUP_TIMEOUT: "120000" } });
500
+ if (!res.ok) {
501
+ return {
502
+ name: "e2eSmoke",
503
+ verdict: "FAIL",
504
+ reason: `Maestro smoke failed (flow cites the SHELL spec clauses it proves):\n${res.out.split("\n").slice(-15).join("\n")}`,
505
+ durationMs: install.durationMs + res.durationMs,
506
+ };
507
+ }
508
+ const anrDump = sh("adb logcat -d -b system,crash,main");
509
+ const anrRe = /ANR in |FATAL EXCEPTION/i;
510
+ if (anrDump.ok && anrRe.test(anrDump.out)) {
511
+ const anrLines = anrDump.out.split("\n").filter((l) => anrRe.test(l)).slice(0, 10).join("\n");
512
+ return {
513
+ name: "e2eSmoke",
514
+ verdict: "FAIL",
515
+ 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}`,
516
+ durationMs: install.durationMs + res.durationMs,
517
+ };
518
+ }
519
+ return { name: "e2eSmoke", verdict: "PASS", durationMs: install.durationMs + res.durationMs };
520
+ } finally {
521
+ if (prevHideErrorDialogs && prevHideErrorDialogs !== "null") {
522
+ sh(`adb shell settings put global hide_error_dialogs ${prevHideErrorDialogs}`);
523
+ } else {
524
+ sh("adb shell settings delete global hide_error_dialogs");
525
+ }
526
+ }
384
527
  }
385
528
 
386
529
  // ── Lane ───────────────────────────────────────────────────────────────────
@@ -388,9 +531,12 @@ function stepE2eSmoke() {
388
531
  const stepsForProfile = {
389
532
  // scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
390
533
  // the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
391
- scaffold: [stepSpecCoverage, stepBuild, stepUnitTests],
534
+ scaffold: [stepSpecCoverage, stepApprovals, stepComponentStories, stepArchDoc, stepBuild, stepUnitTests],
392
535
  local: [
393
536
  stepSpecCoverage,
537
+ stepApprovals,
538
+ stepComponentStories,
539
+ stepArchDoc,
394
540
  stepBuild,
395
541
  stepUnitTests,
396
542
  stepConformance,
@@ -407,7 +553,12 @@ if (!stepsForProfile[profile]) {
407
553
  process.exit(2);
408
554
  }
409
555
 
556
+ // Stamp the lane marker for the run's duration (coexistence defense 1 above);
557
+ // always removed, even on a failing step, so the eyes only ever defer briefly.
558
+ fs.mkdirSync(path.dirname(LANE_MARKER), { recursive: true });
559
+ fs.writeFileSync(LANE_MARKER, `${process.pid} ${new Date().toISOString()}\n`);
410
560
  const steps = [];
561
+ try {
411
562
  for (const step of stepsForProfile[profile]) {
412
563
  const result = step();
413
564
  steps.push(result);
@@ -417,9 +568,20 @@ for (const step of stepsForProfile[profile]) {
417
568
  }
418
569
  if (result.name === "build" && result.verdict === "FAIL") break; // nothing downstream is meaningful
419
570
  }
571
+ } finally {
572
+ fs.rmSync(LANE_MARKER, { force: true });
573
+ }
420
574
 
421
575
  const verdict = steps.some((s) => s.verdict === "FAIL") ? "FAIL" : "PASS";
422
576
 
577
+ // Receipt STRENGTH — a desktop-only green and an on-device green are different
578
+ // claims, and the difference should never live only in the SKIP lines. Device-
579
+ // dependent steps that actually RAN (PASSed) are named on the receipt and in the
580
+ // verdict line: "PASS (on-device: e2eSmoke)" vs "PASS (desktop-only)".
581
+ const DEVICE_STEPS = ["e2eSmoke", "tokenDrift"];
582
+ const onDeviceSteps = steps.filter((s) => DEVICE_STEPS.includes(s.name) && s.verdict === "PASS").map((s) => s.name);
583
+ const strengthLabel = onDeviceSteps.length ? `on-device: ${onDeviceSteps.join("+")}` : "desktop-only";
584
+
423
585
  // Artifacts: hash whatever the run left under qa-artifacts/ (never committed).
424
586
  const artifacts = [];
425
587
  if (fs.existsSync(ARTIFACTS_DIR)) {
@@ -448,13 +610,14 @@ const receipt = {
448
610
  verdict,
449
611
  commit: {
450
612
  sha: tryGit("rev-parse HEAD"),
451
- dirty: (tryGit("status --porcelain") ?? "").split("\n").filter(Boolean).map((l) => l.slice(3)).sort(),
613
+ dirty: tryGitLines("status --porcelain").map((l) => l.slice(3)).sort(),
452
614
  },
453
615
  inputs: {
454
616
  hash: inputs.hash,
455
617
  fileCount: inputs.fileCount,
456
618
  },
457
619
  steps,
620
+ strength: { onDeviceSteps },
458
621
  artifacts,
459
622
  toolVersions: {
460
623
  node: process.version,
@@ -465,8 +628,11 @@ const receipt = {
465
628
 
466
629
  fs.mkdirSync(EVIDENCE_DIR, { recursive: true });
467
630
  fs.writeFileSync(path.join(EVIDENCE_DIR, "latest.json"), `${JSON.stringify(receipt, null, 2)}\n`);
631
+ // latest.json is the single receipt-of-record. Commit it with your change: the
632
+ // studio console's Evidence audit trail reconstructs the full history from the
633
+ // git log of this file — every commit is one verified, attributed state.
468
634
 
469
635
  if (asJson) console.log(JSON.stringify(receipt, null, 2));
470
- else console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict} — receipt written to qa/evidence/latest.json (commit it with your change)`);
636
+ else console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict} (${strengthLabel}) — receipt written to qa/evidence/latest.json (commit it with your change)`);
471
637
 
472
638
  process.exit(verdict === "PASS" ? 0 : 1);