create-cmp-cli 0.24.0 → 0.25.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 (141) hide show
  1. package/bin/create-cmp.mjs +33 -3
  2. package/package.json +10 -4
  3. package/packages/harness/package.json +18 -8
  4. package/packages/harness/src/approve.mjs +10 -11
  5. package/packages/harness/src/console/console-data.mjs +117 -0
  6. package/packages/harness/src/console/console-evidence.mjs +122 -0
  7. package/packages/harness/src/console/console-overview.mjs +642 -0
  8. package/packages/harness/src/console/console-shell.mjs +1139 -0
  9. package/packages/harness/src/console/console-tabs.mjs +2828 -0
  10. package/packages/harness/src/console/contrast.mjs +74 -0
  11. package/packages/harness/src/console/preview-service.mjs +1384 -0
  12. package/packages/harness/src/framework-check.mjs +271 -37
  13. package/packages/harness/src/lib/a11y.mjs +1 -1
  14. package/packages/harness/src/lib/affected-tests.mjs +99 -44
  15. package/packages/harness/src/lib/approvals.mjs +177 -350
  16. package/packages/harness/src/lib/audit-cadence.mjs +1 -1
  17. package/packages/harness/src/lib/comments.mjs +18 -4
  18. package/packages/harness/src/lib/determinism.mjs +275 -14
  19. package/packages/harness/src/lib/evidence-badge.mjs +34 -2
  20. package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
  21. package/packages/harness/src/lib/evidence-level.mjs +144 -59
  22. package/packages/harness/src/lib/feature-brief.mjs +53 -16
  23. package/packages/harness/src/lib/flight-recorder.mjs +72 -8
  24. package/packages/harness/src/lib/framework-check.mjs +434 -53
  25. package/packages/harness/src/lib/harness-lock.mjs +87 -19
  26. package/packages/harness/src/lib/harness-manifest.mjs +208 -0
  27. package/packages/harness/src/lib/harness-region.mjs +106 -2
  28. package/packages/harness/src/lib/harness-source.mjs +142 -0
  29. package/packages/harness/src/lib/inputs-hash.mjs +175 -22
  30. package/packages/harness/src/lib/lane-markers.mjs +78 -0
  31. package/packages/harness/src/lib/lane-narrator.mjs +4 -2
  32. package/packages/harness/src/lib/lane-runner.mjs +72 -6
  33. package/packages/harness/src/lib/plan.mjs +44 -11
  34. package/packages/harness/src/lib/plant-calibration.mjs +135 -0
  35. package/packages/harness/src/lib/profile-loader.mjs +323 -0
  36. package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
  37. package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
  38. package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
  39. package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  40. package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
  41. package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
  42. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  43. package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
  44. package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
  45. package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
  46. package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
  47. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
  48. package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
  49. package/packages/harness/src/lib/receipt-validate.mjs +84 -16
  50. package/packages/harness/src/lib/spec-coverage.mjs +179 -92
  51. package/packages/harness/src/lib/spec-model.mjs +262 -0
  52. package/packages/harness/src/lib/step-cache.mjs +11 -2
  53. package/packages/harness/src/lib/step-outcomes.mjs +84 -151
  54. package/packages/harness/src/lib/walk.mjs +1 -1
  55. package/packages/harness/src/preview-gallery.mjs +1 -1
  56. package/packages/harness/src/receipt-check.mjs +80 -24
  57. package/packages/harness/src/record-audit.mjs +1 -1
  58. package/packages/harness/src/scaffold-feature.mjs +10 -2
  59. package/packages/harness/src/verify.mjs +222 -142
  60. package/packages/harness/src/watch.mjs +146 -33
  61. package/packages/receipts/package.json +1 -1
  62. package/packages/receipts/src/index.mjs +2 -2
  63. package/packages/receipts/src/inputs-hash.mjs +175 -22
  64. package/packages/receipts/src/receipt-validate.mjs +84 -16
  65. package/src/commands/attach.mjs +128 -4
  66. package/src/commands/harden.mjs +7 -1
  67. package/src/commands/upgrade.mjs +7 -0
  68. package/src/lib/minimal.mjs +23 -1
  69. package/src/scaffold.mjs +8 -0
  70. package/template/.claude/settings.json +2 -2
  71. package/template/.claude/skills/add-feature/SKILL.md +7 -3
  72. package/template/.claude/skills/add-repository/SKILL.md +2 -2
  73. package/template/.claude/skills/add-screen/SKILL.md +8 -4
  74. package/template/.githooks/pre-push +9 -0
  75. package/template/CLAUDE.md +55 -186
  76. package/template/docs/TESTING.md +1 -1
  77. package/template/gitignore +3 -0
  78. package/template/qa/approve.mjs +10 -11
  79. package/template/qa/comments.json +1 -1
  80. package/template/qa/evidence/schema.json +258 -34
  81. package/template/qa/framework-check.mjs +271 -37
  82. package/template/qa/harness-manifest.json +10 -0
  83. package/template/qa/lib/a11y.mjs +1 -1
  84. package/template/qa/lib/affected-tests.mjs +99 -44
  85. package/template/qa/lib/approvals.mjs +177 -350
  86. package/template/qa/lib/audit-cadence.mjs +1 -1
  87. package/template/qa/lib/comments.mjs +18 -4
  88. package/template/qa/lib/determinism.mjs +275 -14
  89. package/template/qa/lib/evidence-badge.mjs +34 -2
  90. package/template/qa/lib/evidence-ladder.mjs +202 -0
  91. package/template/qa/lib/evidence-level.mjs +144 -59
  92. package/template/qa/lib/feature-brief.mjs +53 -16
  93. package/template/qa/lib/flight-recorder.mjs +72 -8
  94. package/template/qa/lib/framework-check.mjs +434 -53
  95. package/template/qa/lib/harness-lock.mjs +87 -19
  96. package/template/qa/lib/harness-manifest.mjs +208 -0
  97. package/template/qa/lib/harness-region.mjs +106 -2
  98. package/template/qa/lib/harness-source.mjs +142 -0
  99. package/template/qa/lib/inputs-hash.mjs +175 -22
  100. package/template/qa/lib/lane-markers.mjs +78 -0
  101. package/template/qa/lib/lane-narrator.mjs +4 -2
  102. package/template/qa/lib/lane-runner.mjs +72 -6
  103. package/template/qa/lib/plan.mjs +44 -11
  104. package/template/qa/lib/plant-calibration.mjs +135 -0
  105. package/template/qa/lib/profile-loader.mjs +323 -0
  106. package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
  107. package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
  108. package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
  109. package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  110. package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
  111. package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
  112. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  113. package/template/qa/lib/profiles/cmp/index.mjs +47 -0
  114. package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
  115. package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
  116. package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
  117. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
  118. package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
  119. package/template/qa/lib/receipt-validate.mjs +84 -16
  120. package/template/qa/lib/spec-coverage.mjs +179 -92
  121. package/template/qa/lib/spec-model.mjs +262 -0
  122. package/template/qa/lib/step-cache.mjs +11 -2
  123. package/template/qa/lib/step-outcomes.mjs +84 -151
  124. package/template/qa/lib/walk.mjs +1 -1
  125. package/template/qa/preview-gallery.mjs +1 -1
  126. package/template/qa/receipt-check.mjs +80 -24
  127. package/template/qa/record-audit.mjs +1 -1
  128. package/template/qa/scaffold-feature.mjs +10 -2
  129. package/template/qa/verified-surface.json +17 -0
  130. package/template/qa/verify.mjs +222 -142
  131. package/template/qa/watch.mjs +146 -33
  132. /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  133. /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  134. /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  135. /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  136. /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
  137. /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  138. /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  139. /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  140. /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  141. /package/template/qa/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
@@ -32,13 +32,17 @@ import path from "node:path";
32
32
  import { fileURLToPath } from "node:url";
33
33
 
34
34
  import { computeInputsHash, undeclaredTopLevel } from "./lib/inputs-hash.mjs";
35
- import { evidenceLevel } from "./lib/evidence-level.mjs";
35
+ import { gradeEvidence } from "./lib/evidence-level.mjs";
36
+ import { evidenceLadderFor } from "./lib/evidence-ladder.mjs";
36
37
  import { updateReadmeBadge, README_REL_PATH } from "./lib/evidence-badge.mjs";
37
38
  import { appendFlightRecord, buildFlightEntry, neverRunTiers, readFlightJournal } from "./lib/flight-recorder.mjs";
38
- import { StepTimeout, androidChecksOutcome, spawnTimedOut } from "./lib/step-outcomes.mjs";
39
- import { expectedDurations, runLane } from "./lib/lane-runner.mjs";
40
- import { createCmpSteps } from "./lib/steps-cmp.mjs";
39
+ import { StepTimeout, spawnTimedOut } from "./lib/step-outcomes.mjs";
40
+ import { expectedDurations, runLane, stepDisplayName } from "./lib/lane-runner.mjs";
41
+ import { resolveHarnessManifest } from "./lib/harness-manifest.mjs";
42
+ import { loadProfile, loadProfileSync } from "./lib/profile-loader.mjs";
43
+ import { laneMarkerPath } from "./lib/lane-markers.mjs";
41
44
  import { checkHarnessIntegrity, describeIntegrity, LOCK_PATH } from "./lib/harness-lock.mjs";
45
+ import { readHarnessSource } from "./lib/harness-source.mjs";
42
46
 
43
47
  const HERE = path.dirname(fileURLToPath(import.meta.url));
44
48
  const ROOT = path.resolve(HERE, "..");
@@ -62,22 +66,19 @@ Flags:
62
66
  --profile <smoke|scaffold|local|ci|nightly|release>
63
67
  which step set to run (default: local)
64
68
  --fast INNER LOOP ONLY — run the resolved profile
65
- minus the device/release tier (releaseBuild,
66
- tokenDrift, e2eSmoke, androidChecks,
67
- releaseSmoke), unconditionally, device
68
- attached or not. Also reuses the pure-Node
69
- steps' last PASS when their inputs are
70
- unchanged (verdict CACHED), lets Gradle's
71
- up-to-date checks stand (no --rerun), and
72
- scopes unit tests to the working-tree change
73
- (broad-impact changes run everything). The
69
+ minus this pack's expensive tier (named
70
+ under "This project" below), unconditionally,
71
+ device attached or not. Also reuses the
72
+ pure-Node steps' last PASS when their inputs
73
+ are unchanged (verdict CACHED), and lets the
74
+ pack scope its own build and test steps to
75
+ the working-tree change. The
74
76
  receipt records mode "fast", derives no
75
77
  evidence rung, and can NEVER satisfy the
76
78
  done-gate — run the full lane once before
77
79
  you call it done
78
- --determinism run the timezone determinism probe: the JVM
79
- test tier (unit + golden + the other
80
- desktop suites) executes TWICE, under
80
+ --determinism run the timezone determinism probe: the
81
+ pack's host test tier executes TWICE, under
81
82
  TZ=Etc/GMT+12 (UTC-12) and TZ=Etc/GMT-14
82
83
  (UTC+14), and the probe FAILs naming every
83
84
  test whose verdict or failure output
@@ -100,29 +101,66 @@ Flags:
100
101
  --help, -h print this usage and exit 0 without
101
102
  running anything
102
103
 
103
- Profiles:
104
- smoke the smallest end-to-end lane: every pure-Node gate through the real
105
- runner, receipt and journal no Gradle, no device. Seconds. Proves the
106
- FRAMEWORK returns, both ways; never the change (its receipt is refused
107
- as done-evidence). Driven by qa/framework-check.mjs.
108
- scaffold spec coverage + build + unit tests (what \`create-cmp --verify\`
109
- proves at stamp time)
110
- local everything; device-dependent steps SKIP when no device is
111
- attached
104
+ Profiles — WHAT EACH ONE IS FOR. Which steps each runs is your profile's, and
105
+ they are listed under "This project" below.
106
+ smoke the smallest end-to-end lane: the pack's pure-Node gates only,
107
+ through the real runner, receipt and journal. Seconds. Proves the
108
+ FRAMEWORK returns, both ways; never the change (its receipt is
109
+ refused as done-evidence). Driven by qa/framework-check.mjs.
110
+ scaffold what a stamp-time verify proves — enough to trust a fresh tree
111
+ local everything; steps needing a resource this host lacks SKIP, and the
112
+ reason is recorded rather than swallowed
112
113
  ci everything; SKIPs are recorded so the pipeline stays honest
113
- nightly everything ci proves with the determinism probe FORCED ON (it doubles
114
- the JVM test tier — the budget a scheduled run has and a per-change run
115
- does not). Proves the HARNESS, not a change: its receipt is refused as
116
- done-evidence by qa/receipt-check.mjs. Schedule it; never wait on it.
117
- release everything ci proves PLUS the release-APK smoke (releaseSmoke) —
118
- the ship-time profile; run it before cutting a release, never
119
- per-change
114
+ nightly everything ci proves with the determinism probe FORCED ON (it
115
+ doubles the host test tier — the budget a scheduled run has and a
116
+ per-change run does not). Proves the HARNESS, not a change: its
117
+ receipt is refused as done-evidence by qa/receipt-check.mjs.
118
+ Schedule it; never wait on it.
119
+ release everything ci proves PLUS the pack's ship-time step; run it before
120
+ cutting a release, never per-change
120
121
  `;
121
122
 
123
+ /**
124
+ * What THIS project's lane actually runs, appended to the neutral usage above.
125
+ *
126
+ * The help text used to enumerate a Compose app's step names — releaseBuild,
127
+ * tokenDrift, e2eSmoke, androidChecks — so `--help` in any other repo
128
+ * described a lane that repo does not have. The step names belong to the pack,
129
+ * so they are read from it. A project with no usable manifest still gets full
130
+ * help plus the one line that says why the rest is missing: `--help` must
131
+ * never refuse, and must never invent a lane either.
132
+ * @param {string} root
133
+ * @returns {string}
134
+ */
135
+ function projectSection(root) {
136
+ const manifest = resolveHarnessManifest(root);
137
+ if (!manifest.ok) return `\nThis project:\n ${manifest.reason}\n`;
138
+ const loaded = loadProfileSync(root, manifest.manifest.profile);
139
+ if (!loaded.ok) return `\nThis project:\n ${loaded.reason}\n`;
140
+ let pack;
141
+ try {
142
+ pack = loaded.profile.steps({
143
+ ROOT: root, HERE: path.join(root, "qa"), fast: false, determinism: false, profile: "local", mode: "full",
144
+ sh: () => ({ ok: true, out: "" }), tryGit: () => null, tryGitLines: () => [], DEGRADED_PATHS: [],
145
+ });
146
+ } catch (err) {
147
+ return `\nThis project:\n profile "${loaded.profile.id}" could not describe its steps: ${err && err.message ? err.message : String(err)}\n`;
148
+ }
149
+ const names = (fns) => (Array.isArray(fns) ? fns.map((fn) => stepDisplayName(fn)).filter(Boolean) : []);
150
+ const lines = [`\nThis project (profile "${loaded.profile.id}"):`];
151
+ for (const [name, fns] of Object.entries(pack.stepsForProfile ?? {})) {
152
+ const list = names(fns);
153
+ if (list.length) lines.push(` ${name.padEnd(9)} ${list.join(", ")}`);
154
+ }
155
+ const excluded = Array.isArray(pack.FAST_EXCLUDED_NAMES) ? pack.FAST_EXCLUDED_NAMES : [];
156
+ if (excluded.length) lines.push(` --fast omits: ${excluded.join(", ")}`);
157
+ return `${lines.join("\n")}\n`;
158
+ }
159
+
122
160
  const rawArgs = process.argv.slice(2);
123
161
 
124
162
  if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
125
- console.log(USAGE);
163
+ console.log(USAGE + projectSection(ROOT));
126
164
  process.exit(0);
127
165
  }
128
166
 
@@ -180,19 +218,9 @@ if (determinism && profileExplicit && profile !== "ci" && profile !== "release")
180
218
  process.exit(2);
181
219
  }
182
220
 
183
- const GRADLEW = process.platform === "win32" ? "gradlew.bat" : "./gradlew";
184
-
185
- // ── `--rerun` is scoped to FULL mode ────────────────────────────────────────
186
- // `--rerun` exists for evidence integrity (see stepUnitTests's comment): it
187
- // stops Gradle's build cache replaying a PASS recorded against a different
188
- // tree into a receipt that claims tests executed. That mechanism belongs to
189
- // the runs that produce integrity-bearing artifacts — and a --fast run does
190
- // not: its receipt already declares itself non-evidence (mode "fast", no
191
- // evidence rung, refused by qa/receipt-check.mjs), so forcing execution there
192
- // paid an integrity tax to protect an artifact with nothing to protect. Fast
193
- // mode therefore omits the flag and lets Gradle's up-to-date/cache machinery
194
- // do its job; full mode keeps it, byte-identical to before.
195
- const RERUN = fast ? "" : " --rerun";
221
+ // `--rerun` (evidence integrity: no build cache replaying a PASS from a
222
+ // different tree) is the pack's to apply — it knows its build tool. The pack
223
+ // reads `fast` from ctx and scopes the flag to FULL mode itself.
196
224
 
197
225
  // The running step's deadline (evidence-economics S4). Set by the step loop
198
226
  // before each step from the journal's measured duration for it; every
@@ -204,8 +232,8 @@ let CURRENT_STEP_DEADLINE_MS = 30 * 60_000;
204
232
 
205
233
  function sh(cmd, opts = {}) {
206
234
  const started = Date.now();
207
- // maxBuffer: first-run Gradle output easily exceeds spawnSync's 1MB default,
208
- // which would surface as a bogus FAIL (status null / ENOBUFS).
235
+ // maxBuffer: a build tool's first-run output easily exceeds spawnSync's 1MB
236
+ // default, which would surface as a bogus FAIL (status null / ENOBUFS).
209
237
  const res = spawnSync(cmd, {
210
238
  shell: true,
211
239
  cwd: ROOT,
@@ -223,23 +251,13 @@ function sh(cmd, opts = {}) {
223
251
  return { ok, status: res.status, error: res.error?.message, out: `${res.stdout ?? ""}${res.stderr ?? ""}`, durationMs: Date.now() - started };
224
252
  }
225
253
 
226
- // ── Preview-daemon coexistence ──────────────────────────────────────────────
227
- // The preview daemon (the eyes) and this lane both spawn Gradle against this
228
- // project and share composeApp/build/kspCaches, whose KSP incremental storage
229
- // is single-owner two concurrent builds throw "Storage for [...] is already
230
- // registered" and one side dies. Three defenses, all automatic:
231
- // 1. COORDINATE (this lane -> the daemon): this lane stamps a marker file
232
- // for its duration; the preview service defers renders while it exists
233
- // (mtime-bounded, so a crashed lane never wedges the eyes for long).
234
- // 2. COORDINATE (the daemon -> this lane), the symmetric half: the daemon
235
- // stamps its OWN marker for the duration of a render's Gradle build;
236
- // shGradle waits for it to clear (or go stale) before launching this
237
- // lane's own Gradle command — same mtime-bounded shape, so a crashed
238
- // daemon never wedges the lane for long either.
239
- // 3. SELF-HEAL: a Gradle step that still hits the collision clears kspCaches
240
- // and retries once — the manual recovery that always worked, automated.
241
- const LANE_MARKER = path.join(ROOT, "composeApp", "build", ".cmp-lane-in-progress");
242
- const KSP_COLLISION_RE = /Storage for \[[^\]]*\] is already registered/;
254
+ // ── The lane marker ─────────────────────────────────────────────────────────
255
+ // Stamped for the run's duration and rewritten at every step start (the
256
+ // narration the narrator, the watcher, the Stop hook and the chain view read).
257
+ // Core state, under qa/, beside the agent hold qa/lib/lane-markers.mjs.
258
+ // Coexistence with the eyes' own builds (the render marker, the KSP self-heal)
259
+ // is the pack's: it knows its build tool and its build directory.
260
+ const LANE_MARKER = laneMarkerPath(ROOT);
243
261
 
244
262
  // Degraded-path activations observed during this run — self-heals and
245
263
  // fallbacks that kept the lane moving without failing it. Collected for the
@@ -249,49 +267,6 @@ const KSP_COLLISION_RE = /Storage for \[[^\]]*\] is already registered/;
249
267
  // two apart.
250
268
  const DEGRADED_PATHS = [];
251
269
 
252
- // The daemon's half of defense 2 above — pid + ISO timestamp, mirroring
253
- // LANE_MARKER's own content shape (see where LANE_MARKER is stamped, below).
254
- const RENDER_MARKER = path.join(ROOT, "composeApp", "build", ".cmp-render-in-progress");
255
- const RENDER_MARKER_FRESH_MS = 5 * 60 * 1000; // older than this = a crashed daemon's stale marker, ignore it
256
- const RENDER_WAIT_TIMEOUT_MS = 3 * 60 * 1000; // give up waiting after this long regardless
257
- const RENDER_WAIT_POLL_MS = 2000;
258
-
259
- /**
260
- * Defer this lane's next Gradle command while the preview daemon's render
261
- * marker is present AND fresh (mtime younger than RENDER_MARKER_FRESH_MS).
262
- * Polls every RENDER_WAIT_POLL_MS; gives up and proceeds anyway after
263
- * RENDER_WAIT_TIMEOUT_MS, or the moment the marker disappears or goes stale —
264
- * whichever comes first. A missing/unreadable marker returns immediately:
265
- * this is a coexistence courtesy, never a hard dependency on the daemon.
266
- */
267
- function waitForRenderMarker() {
268
- const deadline = Date.now() + RENDER_WAIT_TIMEOUT_MS;
269
- for (;;) {
270
- let stat;
271
- try {
272
- stat = fs.statSync(RENDER_MARKER);
273
- } catch {
274
- return; // no render in flight
275
- }
276
- if (Date.now() - stat.mtimeMs >= RENDER_MARKER_FRESH_MS) return; // gone stale
277
- if (Date.now() >= deadline) return; // waited long enough — proceed regardless
278
- sh(`sleep ${RENDER_WAIT_POLL_MS / 1000}`);
279
- }
280
- }
281
-
282
- function shGradle(cmd, opts = {}) {
283
- waitForRenderMarker();
284
- const first = sh(cmd, opts);
285
- if (first.ok || !KSP_COLLISION_RE.test(first.out)) return first;
286
- console.error("· KSP cache collision (concurrent Gradle — the preview daemon?) — clearing kspCaches, retrying once");
287
- fs.rmSync(path.join(ROOT, "composeApp", "build", "kspCaches"), { recursive: true, force: true });
288
- const retry = sh(cmd, opts);
289
- retry.durationMs += first.durationMs;
290
- retry.selfHealed = "ksp-cache-collision";
291
- DEGRADED_PATHS.push("ksp-cache-collision: cleared kspCaches and retried the Gradle step");
292
- return retry;
293
- }
294
-
295
270
  function tryGit(cmd) {
296
271
  try {
297
272
  return execSync(`git ${cmd}`, { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
@@ -316,13 +291,42 @@ function tryGitLines(cmd) {
316
291
  }
317
292
  }
318
293
 
319
- // ── The step pack (qa/lib/steps-cmp.mjs, evidence-economics S8b) ─────────────
320
- // Every step this lane runs, behind one factory that borrows the spine's
321
- // helpers explicitly. Swap the pack and the same spine verifies a different
322
- // kind of project.
323
- const pack = createCmpSteps({ ROOT, HERE, GRADLEW, RERUN, fast, determinism, profile, mode, sh, shGradle, tryGit, tryGitLines, DEGRADED_PATHS });
294
+ // ── The stack profile (qa/harness-manifest.json qa/lib/profiles/<id>/) ─────
295
+ // Every step this lane runs comes from the profile the MANIFEST names, loaded
296
+ // by id never imported by name. This runner does not know what Compose is;
297
+ // it knows the shape of a profile (qa/lib/profile-loader.mjs) and asks the
298
+ // project which one it uses. No manifest, no profile, no lane: there is no
299
+ // default, and each refusal names the command that fixes it. Refused before a
300
+ // single step runs, on the same exit code as an unknown argument — the lane
301
+ // was asked to do something it cannot honestly do.
302
+ const manifest = resolveHarnessManifest(ROOT);
303
+ if (!manifest.ok) {
304
+ console.error(manifest.reason);
305
+ process.exit(2);
306
+ }
307
+ const loaded = await loadProfile(ROOT, manifest.manifest.profile);
308
+ if (!loaded.ok) {
309
+ console.error(loaded.reason);
310
+ process.exit(2);
311
+ }
312
+ const pack = loaded.profile.steps({ ROOT, HERE, fast, determinism, profile, mode, sh, tryGit, tryGitLines, DEGRADED_PATHS });
324
313
  const { stepsForProfile, DEVICE_STEPS, FAST_EXCLUDED_NAMES, STEP_FN_BY_NAME } = pack;
325
314
 
315
+ // ── The evidence ladder, resolved once, before a step runs ──────────────────
316
+ // The ladder has TWO spellings — the profile's top-level `ladder` and the
317
+ // pack's `evidenceLadder` — and this line used to read only the second one,
318
+ // while the Stop hook read only the first. Both spellings are legitimate and
319
+ // qa/lib/evidence-ladder.mjs carries the argument for the precedence; what was
320
+ // not legitimate is that a profile declaring only the spelling `harness init`
321
+ // SEEDS was graded at no rung with nothing said about it. Resolved here rather
322
+ // than at the grading call so the refusal — two declarations that disagree —
323
+ // lands before any work is done, and so the rung the receipt records and the
324
+ // rung a no-lane reader would compute come from the same bytes.
325
+ const resolvedLadder = evidenceLadderFor(loaded.profile, pack);
326
+ if (!resolvedLadder.ok) {
327
+ console.error(resolvedLadder.reason);
328
+ process.exit(2);
329
+ }
326
330
 
327
331
  if (!stepsForProfile[profile]) {
328
332
  console.error(`Unknown profile "${profile}" — use smoke | scaffold | local | ci | nightly | release.`);
@@ -334,8 +338,8 @@ if (!stepsForProfile[profile]) {
334
338
  // qa/evidence/latest.json. The done-gate (qa/receipt-check.mjs) validates a
335
339
  // receipt by verdict + content hash — a receipt whose steps are one probe
336
340
  // would satisfy it while attesting almost nothing, so a probe-only run must
337
- // never mint one. The lane marker IS still stamped: the probe runs Gradle
338
- // and owes the preview daemon the same coexistence courtesy as the lane.
341
+ // never mint one. The lane marker IS still stamped: the probe runs the pack's
342
+ // own build steps and owes every other watcher the same courtesy as the lane.
339
343
  if (determinism && !profileExplicit) {
340
344
  fs.mkdirSync(path.dirname(LANE_MARKER), { recursive: true });
341
345
  fs.writeFileSync(LANE_MARKER, `${process.pid} ${new Date().toISOString()}\n`);
@@ -356,19 +360,17 @@ if (determinism && !profileExplicit) {
356
360
  }
357
361
 
358
362
  // ── --fast: the inner loop, mechanically unable to claim done ───────────────
359
- // The genuinely slow tier is device/release work every DEVICE_STEPS entry
360
- // (Gradle install + emulator + Maestro + instrumented runner) plus
361
- // releaseBuild (R8 + lintVital, the slow release COMPILE). --fast filters
362
- // that tier out of whatever profile resolved, UNCONDITIONALLY — device
363
- // attached or not so a small change gets its did-I-break-anything-obvious
364
- // signal in JVM time. The rest of the profile still runs but cheaply: the
365
- // pure-Node steps reuse an unchanged PASS from the step cache (CACHED see
366
- // the memoization block above), the Gradle test steps drop --rerun (see
367
- // RERUN above), and unitTests scopes itself to the working-tree change
368
- // (see stepUnitTests). The loophole is closed at the receipt, not by
369
- // convention: mode "fast" is
370
- // recorded, no evidence rung is derived (qa/lib/evidence-level.mjs), and
371
- // qa/receipt-check.mjs refuses a fast receipt as done evidence.
363
+ // The genuinely slow tier is the pack's own: whatever it names in
364
+ // FAST_EXCLUDED_NAMES (on mobile, the device and release steps). --fast
365
+ // filters that tier out of whatever profile resolved, UNCONDITIONALLY — so a
366
+ // small change gets its did-I-break-anything-obvious signal without paying
367
+ // for the expensive half. The rest of the profile still runs, but cheaply:
368
+ // the pure-Node steps reuse an unchanged PASS from the step cache (CACHED
369
+ // see the memoization block above), and the pack scopes its own test steps to
370
+ // the working-tree change (it reads `fast` from ctx). The loophole is closed
371
+ // at the receipt, not by convention: mode "fast" is recorded, no evidence
372
+ // rung is derived (qa/lib/evidence-level.mjs), and qa/receipt-check.mjs
373
+ // refuses a fast receipt as done evidence.
372
374
  const FAST_EXCLUDED_FNS = new Set(FAST_EXCLUDED_NAMES.map((name) => STEP_FN_BY_NAME[name]));
373
375
  const laneSteps = fast
374
376
  ? stepsForProfile[profile].filter((fn) => !FAST_EXCLUDED_FNS.has(fn))
@@ -428,6 +430,19 @@ const lane = runLane({
428
430
  // run — see the scope decision at leaseDeviceForStep. Release is idempotent
429
431
  // and never deletes a foreign holder's lease.
430
432
  onFinally: () => pack.releaseLease(),
433
+ // WHICH step's failure makes every verdict behind it meaningless is the
434
+ // PACK's to name, never the spine's to guess (lane-runner.mjs's
435
+ // compileShortCircuit). Passed unconditionally, so the KEY is always present:
436
+ // presence is what tells the runner "this caller knows about the
437
+ // declaration", and a pack that declares nothing then short-circuits on
438
+ // NOTHING rather than inheriting another stack's step name. Writing
439
+ // `pack.compileStepName ?? "build"` here would put the literal back and
440
+ // undo the fix.
441
+ compileStepName: pack.compileStepName,
442
+ // How long a step may take before it is wedged — the pack's judgement about
443
+ // its own toolchain, never the spine's about somebody else's. A pack that
444
+ // declares none gets the fallback and its ERROR rows say so.
445
+ stepDeadlines: pack.stepDeadlines,
431
446
  });
432
447
  const steps = lane.steps;
433
448
  // CACHED counts as PASS for the lane verdict (it IS a prior PASS, reused only
@@ -436,13 +451,21 @@ const steps = lane.steps;
436
451
  // not green; only the ACCUSATION is withheld. (laneVerdict, qa/lib/lane-runner.mjs)
437
452
  const verdict = lane.verdict;
438
453
 
439
- // Receipt STRENGTH — a desktop-only green and an on-device green are different
440
- // claims, and the difference should never live only in the SKIP lines. Device-
441
- // dependent steps that actually RAN (PASSed) are named on the receipt and in the
442
- // verdict line: "PASS (on-device: e2eSmoke)" vs "PASS (desktop-only)".
443
- // (DEVICE_STEPS itself is defined above the lane — it also drives --fast.)
454
+ // Receipt STRENGTH — a green that reached the harder tier and a green that did
455
+ // not are different claims, and the difference should never live only in the
456
+ // SKIP lines. Which steps needed a resource the host may not have is the pack's
457
+ // (DEVICE_STEPS, which also drives --fast); those that actually RAN are named on
458
+ // the receipt.
459
+ //
460
+ // THE LABEL IS THE PACK'S TOO. The core used to compose it here, and its
461
+ // negative case was the word "desktop-only" — printed on the verdict line of
462
+ // every lane in every repo, including a backend service with no desktop and no
463
+ // device. A profile could name its device steps and still could not name the
464
+ // CATEGORY, so the most-read string the lane emits asserted a stack fact the
465
+ // core has no way to know. Now the core prints what it is handed and prints
466
+ // NOTHING when it is handed nothing: silence is honest, a borrowed noun is not.
444
467
  const onDeviceSteps = steps.filter((s) => DEVICE_STEPS.includes(s.name) && s.verdict === "PASS").map((s) => s.name);
445
- const strengthLabel = onDeviceSteps.length ? `on-device: ${onDeviceSteps.join("+")}` : "desktop-only";
468
+ const strengthLabel = typeof pack.strengthLabel === "function" ? pack.strengthLabel(onDeviceSteps) : null;
446
469
 
447
470
  // Receipt RUNG — the evidence ladder (qa/lib/evidence-level.mjs): the coarse,
448
471
  // named grade (L0 scaffold / L1 desktop / L2 device / L3 release) DERIVED from
@@ -450,10 +473,20 @@ const strengthLabel = onDeviceSteps.length ? `on-device: ${onDeviceSteps.join("+
450
473
  // fine print; the rung is added alongside, never in place of it. null on FAIL —
451
474
  // a failed lane has no rung. null on a --fast run too: the inner loop is a
452
475
  // signal, never evidence, so a fast receipt derives NO rung at all.
453
- // The ladder is the PACK's: a pack that declares none earns no rung (a
454
- // backend graded by Compose step names was L0 by construction — wrong, not
455
- // conservative).
456
- const level = evidenceLevel(steps, profile, { mode, ladder: pack.evidenceLadder ?? null });
476
+ // The ladder is the PROFILE's, in either of the two places a profile may
477
+ // declare it (resolved above, qa/lib/evidence-ladder.mjs): a profile that
478
+ // declares none in either earns no rung (a backend graded by another stack's
479
+ // step names was L0 by construction wrong, not conservative).
480
+ //
481
+ // AND THE BADGE FLOOR: the profile's `plants` declaration goes to the grader
482
+ // too, because a ladder is a vocabulary and plants are what prove the steps
483
+ // under it still bite. A profile shipping none earns NO rung however green its
484
+ // lane (NORTH-STAR §8.9, §6.7, §3's third *never*) — measured on two adopters
485
+ // differing in exactly one export, both of which earned L1 until 2026-09-08.
486
+ // `.why` is the sentence for a rung that is absent: derived by the same call,
487
+ // so the grade and its explanation can never disagree.
488
+ const grade = gradeEvidence(steps, profile, { mode, ladder: resolvedLadder.ladder, plants: loaded.profile.plants });
489
+ const level = grade.level;
457
490
 
458
491
  // Artifacts: hash whatever the run left under qa-artifacts/ (never committed).
459
492
  const artifacts = [];
@@ -488,6 +521,13 @@ function harnessForReceipt() {
488
521
  status: r.status,
489
522
  intact: r.status === "intact",
490
523
  };
524
+ // PROVENANCE (ADR-0008): where these bytes came from, when the tree records
525
+ // it. Omitted entirely when unrecorded — absent means "not known", and every
526
+ // receipt minted before the record existed is in that state. Never defaulted
527
+ // to `local`, which would invent an origin for all of them. It is reported,
528
+ // never consulted: no verdict, gate or level reads this field.
529
+ const provenance = readHarnessSource(ROOT);
530
+ if (provenance?.source) summary.source = provenance.source;
491
531
  if (r.status === "modified") {
492
532
  summary.modified = r.modified;
493
533
  summary.missing = r.missing;
@@ -516,8 +556,15 @@ if (undeclared.length) {
516
556
  // ci → merge, nightly → nightly (proves the harness, never a change), release →
517
557
  // release. Receipts predating this field are read as their profile's stage.
518
558
  const STAGE_OF_PROFILE = { smoke: "smoke", scaffold: "scaffold", local: "change", ci: "merge", nightly: "nightly", release: "release" };
559
+ // Computed once: `harness` and `pack` both read it, and checkHarnessIntegrity
560
+ // hashes the whole region.
561
+ const harnessSummary = harnessForReceipt();
519
562
  const receipt = {
520
- schema: "cmp-evidence/1",
563
+ // ADR-0007: the format name is ROUTING METADATA, not part of the claim, so
564
+ // this rename asserts nothing new and expires nothing old. Readers accept
565
+ // both names for the life of /1; `cmp-evidence/1` stopped being WRITTEN here
566
+ // and never stops being READ.
567
+ schema: "prooflane-evidence/1",
521
568
  profile,
522
569
  stage: STAGE_OF_PROFILE[profile] ?? profile,
523
570
  // "full" is the done-gate; "fast" (--fast) excluded the device/release tier
@@ -547,7 +594,20 @@ const receipt = {
547
594
  // checksum, not a signature, and someone who edits the lane can edit this
548
595
  // too. What they cannot edit is what the registry published under that
549
596
  // version, which is why `version` + `sha256` travel together.
550
- harness: harnessForReceipt(),
597
+ harness: harnessSummary,
598
+ // WHICH PACK produced these rows. `harness` says which lane ran; `pack` says
599
+ // which step pack the lane loaded — and the two can differ once a profile is
600
+ // versioned on its own. Without this a cmp L2 and a backend pack's L2 are the
601
+ // same bytes on the wire and an auditor cannot tell "device e2e passed" from
602
+ // "integration tests passed" (AGNOSTIC-HARNESS-ARCHITECTURE.md §8). Named
603
+ // `pack` because `profile` is taken by the RUN profile (scaffold/local/ci/…);
604
+ // the collision is resolved at schema/2, not here. The pack declares its id;
605
+ // its version is the lock's until the profile loader gives it its own.
606
+ // ADR-0008 (accepted 2026-09-08): the pack's version is the PROFILE'S OWN —
607
+ // `export const version` — or null. Never the harness lock's number, which is
608
+ // a version of the wrong thing: a profile declaring 0.3.1 was minting receipts
609
+ // that said 0.20.0 (NORTH-STAR §9.2).
610
+ pack: { id: pack.id, version: typeof loaded.profile?.version === "string" ? loaded.profile.version : null },
551
611
  strength: { onDeviceSteps },
552
612
  evidenceLevel: level,
553
613
  artifacts,
@@ -559,7 +619,13 @@ const receipt = {
559
619
  };
560
620
 
561
621
  fs.mkdirSync(EVIDENCE_DIR, { recursive: true });
562
- fs.writeFileSync(path.join(EVIDENCE_DIR, "latest.json"), `${JSON.stringify(receipt, null, 2)}\n`);
622
+ // A fast run writes its own file. Before 2026-09-08 it wrote latest.json, so a
623
+ // resident watcher's next pass overwrote the checkpoint's receipt with a fast
624
+ // one the Stop hook refuses — the agent had done everything right and was told
625
+ // it had not. latest-fast.json is never committed (template gitignore) and is
626
+ // read by nothing that grades; latest.json stays the single receipt-of-record.
627
+ const RECEIPT_FILE = fast ? "latest-fast.json" : "latest.json";
628
+ fs.writeFileSync(path.join(EVIDENCE_DIR, RECEIPT_FILE), `${JSON.stringify(receipt, null, 2)}\n`);
563
629
  // latest.json is the single receipt-of-record. Commit it with your change: the
564
630
  // studio console's Evidence audit trail reconstructs the full history from the
565
631
  // git log of this file — every commit is one verified, attributed state.
@@ -598,6 +664,9 @@ const flight = noJournal
598
664
  mode,
599
665
  verdict,
600
666
  evidenceLevel: level,
667
+ // The grader travels with the grade — §8.9's comparability rule is
668
+ // unenforceable in a journal that records rungs without packs.
669
+ pack: { id: pack.id },
601
670
  steps,
602
671
  sha: receipt.commit.sha,
603
672
  durationMs: Date.now() - laneStartedAt,
@@ -621,7 +690,18 @@ if (asJson) {
621
690
  `\n${verdict === "PASS" ? "⚡⚡" : "❌"} verify lane [FAST — INNER LOOP ONLY, NOT DONE]: ${verdict} (skipped device/release tier: ${fastExcluded.join(", ") || "none"}) — this fast receipt satisfies no done-gate; run the full lane (node qa/verify.mjs) once before you finish`,
622
691
  );
623
692
  } else {
624
- console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict}${level ? ` · ${level.rung} ${level.name}` : ""} (${strengthLabel}) receipt written to qa/evidence/latest.json${badge.changed ? ` and ${README_REL_PATH}'s evidence badge refreshed` : ""} (commit ${badge.changed ? "them" : "it"} with your change)`);
693
+ // The rung NEVER appears without the pack that defines it. §8.9 makes one
694
+ // pack's L2 and another's different claims that must be "shown as such", and
695
+ // this is the line an agent reads on every single run — the surface where a
696
+ // bare rung would do the most quiet damage. Only the pack's ID: `pack.version`
697
+ // is the profile's own or null (ADR-0008), and a null is not worth a column.
698
+ console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict}${level ? ` · ${level.rung} ${level.name} · pack ${pack.id}` : ""}${strengthLabel ? ` (${strengthLabel})` : ""} — receipt written to qa/evidence/latest.json${badge.changed ? ` and ${README_REL_PATH}'s evidence badge refreshed` : ""} (commit ${badge.changed ? "them" : "it"} with your change)`);
699
+ // A GREEN LANE THAT EARNED NO RUNG SAYS WHY, on the line a human is already
700
+ // reading. Silence here is the exact shape of the defect that cost a foreign
701
+ // author their grade — a lane that passed, a rung that never appeared, and
702
+ // nothing anywhere connecting the two (NORTH-STAR §9.2). On a FAILed lane the
703
+ // absence explains itself, so this stays quiet and lets the red row speak.
704
+ if (verdict === "PASS" && !level && grade.why) console.log(` ⓘ ${grade.why}`);
625
705
  }
626
706
 
627
707
  // A TIER THAT HAS NEVER RUN HERE. A SKIP is non-fatal by design — absence of a