create-cmp-cli 0.12.0 → 0.14.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 (63) hide show
  1. package/bin/create-cmp.mjs +3 -0
  2. package/package.json +6 -2
  3. package/packages/harness/package.json +38 -0
  4. package/packages/harness/src/approve.mjs +247 -0
  5. package/packages/harness/src/arch-doc.mjs +69 -0
  6. package/packages/harness/src/comment.mjs +76 -0
  7. package/packages/harness/src/lib/a11y.mjs +113 -0
  8. package/packages/harness/src/lib/affected-tests.mjs +147 -0
  9. package/packages/harness/src/lib/approvals.mjs +1403 -0
  10. package/packages/harness/src/lib/arch-doc.mjs +451 -0
  11. package/packages/harness/src/lib/audit-cadence.mjs +290 -0
  12. package/packages/harness/src/lib/comments.mjs +252 -0
  13. package/packages/harness/src/lib/component-stories.mjs +183 -0
  14. package/packages/harness/src/lib/determinism.mjs +179 -0
  15. package/packages/harness/src/lib/device-lease.mjs +249 -0
  16. package/packages/harness/src/lib/evidence-badge.mjs +158 -0
  17. package/packages/harness/src/lib/evidence-level.mjs +117 -0
  18. package/packages/harness/src/lib/feature-brief.mjs +324 -0
  19. package/packages/harness/src/lib/flight-recorder.mjs +332 -0
  20. package/packages/harness/src/lib/harness-lock.mjs +147 -0
  21. package/packages/harness/src/lib/harness-region.mjs +159 -0
  22. package/packages/harness/src/lib/inputs-hash.mjs +194 -0
  23. package/packages/harness/src/lib/reachability.mjs +211 -0
  24. package/packages/harness/src/lib/receipt-validate.mjs +234 -0
  25. package/packages/harness/src/lib/render.mjs +254 -0
  26. package/packages/harness/src/lib/spec-coverage.mjs +131 -0
  27. package/packages/harness/src/lib/step-cache.mjs +221 -0
  28. package/packages/harness/src/lib/token-drift.mjs +94 -0
  29. package/packages/harness/src/lib/tree.mjs +108 -0
  30. package/packages/harness/src/preview-gallery.mjs +122 -0
  31. package/packages/harness/src/receipt-check.mjs +96 -0
  32. package/packages/harness/src/record-audit.mjs +83 -0
  33. package/packages/harness/src/refusal-demo.mjs +498 -0
  34. package/packages/harness/src/retrospective.mjs +51 -0
  35. package/packages/harness/src/scaffold-feature.mjs +723 -0
  36. package/packages/harness/src/setup-hooks.mjs +33 -0
  37. package/packages/harness/src/verify.mjs +1709 -0
  38. package/packages/harness/src/walkthrough.mjs +499 -0
  39. package/packages/harness/src/watch.mjs +622 -0
  40. package/packages/receipts/package.json +36 -0
  41. package/packages/receipts/src/index.mjs +16 -0
  42. package/packages/receipts/src/inputs-hash.mjs +194 -0
  43. package/packages/receipts/src/receipt-validate.mjs +234 -0
  44. package/src/commands/upgrade.mjs +383 -0
  45. package/src/lib/harness-upgrade.mjs +521 -0
  46. package/src/scaffold.mjs +60 -1
  47. package/template/AGENTS.md +5 -0
  48. package/template/CLAUDE.md +34 -1
  49. package/template/README.md +4 -0
  50. package/template/gitignore +8 -0
  51. package/template/qa/lib/audit-cadence.mjs +290 -0
  52. package/template/qa/lib/determinism.mjs +179 -0
  53. package/template/qa/lib/evidence-badge.mjs +158 -0
  54. package/template/qa/lib/flight-recorder.mjs +332 -0
  55. package/template/qa/lib/harness-lock.mjs +147 -0
  56. package/template/qa/lib/harness-region.mjs +159 -0
  57. package/template/qa/lib/inputs-hash.mjs +17 -2
  58. package/template/qa/lib/receipt-validate.mjs +1 -1
  59. package/template/qa/preview-gallery.mjs +17 -2
  60. package/template/qa/record-audit.mjs +83 -0
  61. package/template/qa/retrospective.mjs +51 -0
  62. package/template/qa/verify.mjs +400 -10
  63. package/template/qa/watch.mjs +2 -2
@@ -37,10 +37,15 @@ import { clauseTierCoverage, scanCitations, scanSpecClauses, walkFiles } from ".
37
37
  import { evaluateComponentStoryParity } from "./lib/component-stories.mjs";
38
38
  import { evaluateReachability } from "./lib/reachability.mjs";
39
39
  import { evidenceLevel } from "./lib/evidence-level.mjs";
40
+ import { updateReadmeBadge, README_REL_PATH } from "./lib/evidence-badge.mjs";
40
41
  import { memoizeStep } from "./lib/step-cache.mjs";
41
42
  import { changedWorkingTreePaths, deriveAffectedFilter } from "./lib/affected-tests.mjs";
42
43
  import { acquireDeviceLease, releaseDeviceLease, formatHolder } from "./lib/device-lease.mjs";
43
44
  import { ARCH_DOC_REL_PATH, SECTION_IDS, regenerateArchDoc } from "./lib/arch-doc.mjs";
45
+ import { DETERMINISM_TIMEZONES, compareOutcomes, parseJUnitOutcomes } from "./lib/determinism.mjs";
46
+ import { evaluateAuditCadence } from "./lib/audit-cadence.mjs";
47
+ import { appendFlightRecord, buildFlightEntry } from "./lib/flight-recorder.mjs";
48
+ import { checkHarnessIntegrity, describeIntegrity, LOCK_PATH } from "./lib/harness-lock.mjs";
44
49
 
45
50
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
46
51
  const EVIDENCE_DIR = path.join(ROOT, "qa", "evidence");
@@ -76,6 +81,18 @@ Flags:
76
81
  evidence rung, and can NEVER satisfy the
77
82
  done-gate — run the full lane once before
78
83
  you call it done
84
+ --determinism run the timezone determinism probe: the JVM
85
+ test tier (unit + golden + the other
86
+ desktop suites) executes TWICE, under
87
+ TZ=Etc/GMT+12 (UTC-12) and TZ=Etc/GMT-14
88
+ (UTC+14), and the probe FAILs naming every
89
+ test whose verdict or failure output
90
+ differs — a nondeterminism leak ARCH-13's
91
+ static net missed. Bare (no --profile) it
92
+ runs JUST the probe and writes no receipt;
93
+ with --profile ci (or release) it runs
94
+ inside the lane and lands on the receipt.
95
+ Never combinable with --fast
79
96
  --json print the receipt as JSON instead of the
80
97
  human-readable step-by-step log
81
98
  --help, -h print this usage and exit 0 without
@@ -99,7 +116,7 @@ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
99
116
  process.exit(0);
100
117
  }
101
118
 
102
- const RECOGNIZED_FLAGS = new Set(["--profile", "--json", "--fast"]);
119
+ const RECOGNIZED_FLAGS = new Set(["--profile", "--json", "--fast", "--determinism"]);
103
120
  for (let i = 0; i < rawArgs.length; i += 1) {
104
121
  const arg = rawArgs[i];
105
122
  if (arg === "--profile") {
@@ -115,8 +132,36 @@ const args = rawArgs;
115
132
  const profile = args.includes("--profile") ? args[args.indexOf("--profile") + 1] : "local";
116
133
  const asJson = args.includes("--json");
117
134
  const fast = args.includes("--fast");
135
+ // --no-journal suppresses the flight-recorder append (qa/watch.mjs passes it).
136
+ // See the append site below for why the inner loop must not write here.
137
+ const noJournal = args.includes("--no-journal");
118
138
  const mode = fast ? "fast" : "full";
119
139
 
140
+ // ── --determinism: the timezone double-run probe (roadmap §10 item 8) ───────
141
+ // Refusals up front, by name (same stance as unknown arguments above):
142
+ // - never with --fast: the probe deliberately runs the JVM test tier twice,
143
+ // and --fast is the inner loop that exists to not pay such costs — the
144
+ // combination is a contradiction, so it is refused rather than silently
145
+ // resolved either way.
146
+ // - only the ci profile (and release, which inherits ci) carries the probe's
147
+ // lane row; asking for it in local/scaffold is refused with the two ways
148
+ // that DO work, instead of silently running a step the requested profile
149
+ // does not own.
150
+ const determinism = args.includes("--determinism");
151
+ const profileExplicit = args.includes("--profile");
152
+ if (determinism && fast) {
153
+ console.error(
154
+ "--determinism cannot be combined with --fast: the probe runs the JVM test tier twice by design, and --fast is the inner loop. Run it alone (node qa/verify.mjs --determinism) or inside a full ci/release lane (--profile ci --determinism).",
155
+ );
156
+ process.exit(2);
157
+ }
158
+ if (determinism && profileExplicit && profile !== "ci" && profile !== "release") {
159
+ console.error(
160
+ `--determinism belongs to the ci profile (release inherits it), not "${profile}" — run --profile ci --determinism, or bare --determinism to run the probe alone.`,
161
+ );
162
+ process.exit(2);
163
+ }
164
+
120
165
  const GRADLEW = process.platform === "win32" ? "gradlew.bat" : "./gradlew";
121
166
 
122
167
  // ── `--rerun` is scoped to FULL mode ────────────────────────────────────────
@@ -158,6 +203,14 @@ function sh(cmd, opts = {}) {
158
203
  const LANE_MARKER = path.join(ROOT, "composeApp", "build", ".cmp-lane-in-progress");
159
204
  const KSP_COLLISION_RE = /Storage for \[[^\]]*\] is already registered/;
160
205
 
206
+ // Degraded-path activations observed during this run — self-heals and
207
+ // fallbacks that kept the lane moving without failing it. Collected for the
208
+ // flight recorder (qa/lib/flight-recorder.mjs): a degradation that fires
209
+ // once is a shrug, one that fires every run for a month is the tooling
210
+ // quietly rotting under a green lane — and only a journal can tell those
211
+ // two apart.
212
+ const DEGRADED_PATHS = [];
213
+
161
214
  // The daemon's half of defense 2 above — pid + ISO timestamp, mirroring
162
215
  // LANE_MARKER's own content shape (see where LANE_MARKER is stamped, below).
163
216
  const RENDER_MARKER = path.join(ROOT, "composeApp", "build", ".cmp-render-in-progress");
@@ -197,6 +250,7 @@ function shGradle(cmd, opts = {}) {
197
250
  const retry = sh(cmd, opts);
198
251
  retry.durationMs += first.durationMs;
199
252
  retry.selfHealed = "ksp-cache-collision";
253
+ DEGRADED_PATHS.push("ksp-cache-collision: cleared kspCaches and retried the Gradle step");
200
254
  return retry;
201
255
  }
202
256
 
@@ -363,6 +417,62 @@ function settleAdb() {
363
417
  // itself lives in qa/lib/spec-coverage.mjs — the SAME scan feature-brief.mjs
364
418
  // derives doneness from, so this gate and the Features view can never disagree
365
419
  // about a clause. This step owns only the orphan decision + bookkeeping.
420
+ // The first question any verdict depends on: is the lane that is about to
421
+ // issue it the lane this app was given?
422
+ //
423
+ // Without this the receipt is unfalsifiable in one specific way — edit
424
+ // qa/verify.mjs to force every step PASS and the receipt still validates,
425
+ // because the edited file is simply part of the hashed input surface. Hashing
426
+ // the machine-owned region against qa/harness.lock.json closes that: the lane
427
+ // cannot vouch for itself while modified.
428
+ //
429
+ // DELIBERATELY NOT MEMOIZED. Every other pure-Node step can serve a cached
430
+ // PASS when its inputs are unchanged; a cached PASS on an integrity check is
431
+ // precisely the failure it exists to prevent, and 34 file reads are too cheap
432
+ // to be worth the risk.
433
+ //
434
+ // Three states, three verdicts:
435
+ // intact PASS
436
+ // modified FAIL — named files, with the command that restores them
437
+ // unlocked SKIP — an app stamped before locks existed. Nothing is known to
438
+ // be wrong, but nothing is proven either; recording the gap keeps
439
+ // the pipeline honest instead of quietly passing.
440
+ function stepHarnessIntegrity() {
441
+ const started = Date.now();
442
+ const r = checkHarnessIntegrity(ROOT);
443
+ const base = { name: "harnessIntegrity", durationMs: Date.now() - started, harness: r };
444
+
445
+ if (r.status === "intact") {
446
+ return { ...base, verdict: "PASS", note: describeIntegrity(r) };
447
+ }
448
+ if (r.status === "unlocked") {
449
+ return {
450
+ ...base,
451
+ verdict: "SKIP",
452
+ reason: `no ${LOCK_PATH} — this app was stamped before harness locks existed. ` +
453
+ "`npx create-cmp-cli upgrade --harness` records one.",
454
+ };
455
+ }
456
+
457
+ const named = [
458
+ ...r.modified.map((f) => `modified ${f}`),
459
+ ...r.missing.map((f) => `missing ${f}`),
460
+ ...r.extra.map((f) => `unrecorded ${f}`),
461
+ ];
462
+ return {
463
+ ...base,
464
+ verdict: "FAIL",
465
+ reason:
466
+ `the verify lane has been modified since it was installed — ${describeIntegrity(r)}. ` +
467
+ "Lane code is machine-owned: it is byte-identical in every create-cmp app and carries " +
468
+ "no app content, so a local edit is either an accident, a half-applied upgrade, or an " +
469
+ "attempt to make this receipt say something the lane would not. Restore it with " +
470
+ "`npx create-cmp-cli upgrade --harness`, which also reports any genuine local patch " +
471
+ "instead of discarding it.",
472
+ files: named,
473
+ };
474
+ }
475
+
366
476
  function stepSpecCoverage() {
367
477
  const started = Date.now();
368
478
  const specsDir = path.join(ROOT, "specs");
@@ -696,6 +806,7 @@ function stepUnitTests() {
696
806
  res = retry;
697
807
  note = "full suite — the affected-test filter matched no tests (fell back)";
698
808
  affected = null;
809
+ DEGRADED_PATHS.push("affected-test filter matched no tests — fell back to the full desktopTest suite");
699
810
  }
700
811
  const summary = junitSummary(path.join(ROOT, "composeApp/build/test-results/desktopTest"));
701
812
  let details = summary ?? undefined;
@@ -728,6 +839,115 @@ const stepA11y = gradleTestStep(
728
839
  "A11y gate failed (SHELL-04): interactive nodes must expose a testTag, text, or contentDescription:",
729
840
  );
730
841
 
842
+ // ── Determinism probe (roadmap §10 item 8) — opt-in, ci-profile ────────────
843
+ // ARCH-13 statically bans ambient time reads — in APP code. A library the
844
+ // app calls can still read the wall clock, and a golden can still depend on
845
+ // the machine's timezone through a seam the static net cannot see (a
846
+ // ViewModel constructed without its injected clock already caused one
847
+ // overnight golden-tree drift). This probe closes that gap DYNAMICALLY: it
848
+ // runs the JVM test tier twice, under two timezones whose local calendar
849
+ // dates never agree — the offsets are 26 hours apart, so any date-derived
850
+ // value differs between the legs at every instant (see DETERMINISM_TIMEZONES
851
+ // in qa/lib/determinism.mjs for why UTC-12/UTC+14 and not UTC/UTC+14) — and
852
+ // FAILs naming every test whose outcome differs between the legs.
853
+ //
854
+ // Mechanics that carry the honesty:
855
+ // - TZ reaches the test JVM through the environment: Gradle forwards the
856
+ // client's environment to the daemon on every build, and test workers
857
+ // fork from the daemon — so the child env below is inherited all the way
858
+ // down to the JVM whose default timezone the tests see.
859
+ // - BOTH legs force --rerun. Without it Gradle would mark the second leg
860
+ // up-to-date (TZ is not a declared build input) and replay the first
861
+ // leg's results — the probe would then compare a run against its own
862
+ // echo and certify a determinism it never tested (the build-cache-replay
863
+ // lesson, again). The legs use the mode-scoped RERUN like every other
864
+ // desktopTest invocation — and because --determinism is refused alongside
865
+ // --fast up front, RERUN is always " --rerun" by the time a leg runs.
866
+ // - Only verdicts and failure output are compared; durations are never even
867
+ // parsed (qa/lib/determinism.mjs), so a timing wobble is structurally
868
+ // unable to trip the probe.
869
+ function stepDeterminism() {
870
+ const started = Date.now();
871
+ const elapsed = () => Date.now() - started;
872
+ if (!determinism) {
873
+ return {
874
+ name: "determinism",
875
+ verdict: "SKIP",
876
+ reason: "determinism probe is opt-in (it runs the JVM test tier twice) — add --determinism to this lane, or run the probe alone: node qa/verify.mjs --determinism",
877
+ durationMs: elapsed(),
878
+ };
879
+ }
880
+
881
+ const resultsDir = path.join(ROOT, "composeApp/build/test-results/desktopTest");
882
+ const legs = [];
883
+ for (const { tz, label } of DETERMINISM_TIMEZONES) {
884
+ const res = shGradle(`${GRADLEW} :composeApp:desktopTest${RERUN} --console=plain`, { env: { ...process.env, TZ: tz } });
885
+ // Parsed NOW, before the next leg overwrites the same results directory.
886
+ const outcomes = parseJUnitOutcomes(resultsDir);
887
+ legs.push({ tz, label, ok: res.ok, outcomes, tail: res.out.split("\n").slice(-8).join("\n") });
888
+ }
889
+ const [a, b] = legs;
890
+ const labelA = `TZ=${a.tz} (${a.label})`;
891
+ const labelB = `TZ=${b.tz} (${b.label})`;
892
+ const countA = Object.keys(a.outcomes).length;
893
+ const countB = Object.keys(b.outcomes).length;
894
+
895
+ if (countA === 0 && countB === 0) {
896
+ // Neither leg produced a single test result: the suite failed before
897
+ // running anything (build error). The probe measured nothing — that is
898
+ // a FAIL that says so, never a PASS by absence of differences.
899
+ return {
900
+ name: "determinism",
901
+ verdict: "FAIL",
902
+ reason: `determinism probe could not execute: desktopTest produced no test results under either timezone — the suite fails before running (fix the build first; this is not a timezone difference):\n${a.tail}`,
903
+ durationMs: elapsed(),
904
+ };
905
+ }
906
+ if (countA === 0 || countB === 0) {
907
+ const ran = countA > 0 ? { label: labelA, n: countA } : { label: labelB, n: countB };
908
+ const empty = countA > 0 ? b : a;
909
+ return {
910
+ name: "determinism",
911
+ verdict: "FAIL",
912
+ reason: `Nondeterminism under timezone shift: desktopTest ran ${ran.n} test(s) under ${ran.label} but produced no results at all under TZ=${empty.tz} (${empty.label}) — the suite itself dies under that zone:\n${empty.tail}`,
913
+ durationMs: elapsed(),
914
+ details: { timezones: DETERMINISM_TIMEZONES.map((t) => t.tz) },
915
+ };
916
+ }
917
+
918
+ const diffs = compareOutcomes(a.outcomes, b.outcomes, labelA, labelB);
919
+ if (diffs.length > 0) {
920
+ const lines = [
921
+ `Nondeterminism under timezone shift — the same tree produced different outcomes under ${labelA} vs ${labelB}. Something reads ambient time or zone past the ARCH-13 net (a library default, an uninjected clock, a golden that captures "today"):`,
922
+ ];
923
+ for (const d of diffs.slice(0, 20)) lines.push(` [${d.step}] ${d.test} — ${d.detail}`);
924
+ if (diffs.length > 20) lines.push(` … and ${diffs.length - 20} more differing test(s)`);
925
+ return {
926
+ name: "determinism",
927
+ verdict: "FAIL",
928
+ reason: lines.join("\n"),
929
+ durationMs: elapsed(),
930
+ details: { timezones: DETERMINISM_TIMEZONES.map((t) => t.tz), diffs: diffs.slice(0, 50) },
931
+ };
932
+ }
933
+
934
+ const failedIdentically = Object.values(a.outcomes).filter((o) => o.status !== "pass" && o.status !== "skip").length;
935
+ return {
936
+ name: "determinism",
937
+ verdict: "PASS",
938
+ // Identical red is DETERMINISTIC red: the probe's claim ("no timezone
939
+ // dependence") holds, and the failing tests already belong to
940
+ // unitTests/goldenTrees, which fail the lane on their own merits — a
941
+ // second FAIL here would report the same defect twice under a wrong name.
942
+ note:
943
+ failedIdentically > 0
944
+ ? `${failedIdentically} test(s) failed identically under both timezones — deterministic, but red (the owning test steps report it)`
945
+ : undefined,
946
+ durationMs: elapsed(),
947
+ details: { timezones: DETERMINISM_TIMEZONES.map((t) => t.tz), testsCompared: countA },
948
+ };
949
+ }
950
+
731
951
  // Live tokenDrift tier (harness M4-D): when a debug app + device are available,
732
952
  // fetches the declared catalog and the live semantics tree off the debug-only
733
953
  // inspector server (127.0.0.1:9500, see composeApp/src/androidDebug/.../
@@ -1052,6 +1272,43 @@ function stepReleaseSmoke() {
1052
1272
  return runMaestroSmoke("releaseSmoke", install.durationMs);
1053
1273
  }
1054
1274
 
1275
+ // ── Audit cadence (roadmap §10 item 9) — a REPORT, never a gate ────────────
1276
+ // cmp-audit (the adversarial platform-semantics audit) found six latent
1277
+ // defects the first time a human happened to ask for it — which is exactly
1278
+ // why it must not depend on someone remembering to ask. This step is the
1279
+ // cheapest honest replacement for that memory: at ship time (release
1280
+ // profile) the receipt lists which androidMain subsystems changed since
1281
+ // their last RECORDED audit (qa/audits.jsonl, appended by
1282
+ // node qa/record-audit.mjs). The derivation lives in
1283
+ // qa/lib/audit-cadence.mjs; this step adds only the bookkeeping every step
1284
+ // carries — and by construction it maps every outcome to PASS or SKIP,
1285
+ // never FAIL: audit debt is a judgment call (a rename is not six latent
1286
+ // defects), and a gate here would teach people to game the ledger, which
1287
+ // would destroy the only value it has.
1288
+ function stepAuditCadence() {
1289
+ const started = Date.now();
1290
+ const report = evaluateAuditCadence(ROOT);
1291
+ if (!report.ok) {
1292
+ return { name: "auditCadence", verdict: "SKIP", reason: report.reason, durationMs: Date.now() - started };
1293
+ }
1294
+ return {
1295
+ name: "auditCadence",
1296
+ verdict: "PASS",
1297
+ note: report.summary,
1298
+ durationMs: Date.now() - started,
1299
+ details: {
1300
+ packageRoot: report.packageRoot,
1301
+ subsystems: report.subsystems.map((s) => ({
1302
+ name: s.name,
1303
+ status: s.status,
1304
+ changedFiles: s.changedFiles,
1305
+ lastAudit: s.audit ? { sha: s.audit.sha, at: s.audit.at, by: s.audit.by } : null,
1306
+ })),
1307
+ lines: report.lines,
1308
+ },
1309
+ };
1310
+ }
1311
+
1055
1312
  // ── Lane ───────────────────────────────────────────────────────────────────
1056
1313
 
1057
1314
  // Device-dependent steps, in lane order. Used twice: receipt STRENGTH (which
@@ -1107,8 +1364,11 @@ const stepArchDocMemo = memoized("archDoc", stepArchDoc);
1107
1364
  const stepsForProfile = {
1108
1365
  // scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
1109
1366
  // the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
1110
- scaffold: [stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
1367
+ scaffold: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
1111
1368
  local: [
1369
+ // First, always: every verdict below is only worth what the lane issuing
1370
+ // it is worth.
1371
+ stepHarnessIntegrity,
1112
1372
  stepSpecCoverageMemo,
1113
1373
  stepApprovalsMemo,
1114
1374
  stepComponentStoriesMemo,
@@ -1137,19 +1397,57 @@ const stepsForProfile = {
1137
1397
  stepAndroidChecks,
1138
1398
  ],
1139
1399
  };
1140
- stepsForProfile.ci = stepsForProfile.local;
1141
- // release = everything ci proves PLUS the release-APK behavior smoke. The expensive
1142
- // proofs are profile-tiered by decision: per-change stays fast (local/ci pay for the
1143
- // release COMPILE via releaseBuild, already in the set), and the release-variant
1144
- // *behavior* cost lands once, at ship time. releaseSmoke runs last so the device ends
1145
- // the run holding the exact build that was proven.
1146
- stepsForProfile.release = [...stepsForProfile.ci, stepReleaseSmoke];
1400
+ // ci = local + the determinism probe's row — the first place ci diverges
1401
+ // from local. The probe is OPT-IN (the step SKIPs unless --determinism was
1402
+ // passed: it doubles the JVM test tier's cost), but its row lives in the ci
1403
+ // profile so a ci receipt always records whether the probe ran an honest,
1404
+ // visible gap beats an invisible one ("SKIPs are recorded so the pipeline
1405
+ // stays honest", per the profile's own contract). local deliberately does
1406
+ // NOT carry the row: the per-change developer profile is not where a
1407
+ // deliberate double-run belongs.
1408
+ stepsForProfile.ci = [...stepsForProfile.local, stepDeterminism];
1409
+ // release = everything ci proves PLUS the audit-cadence report and the
1410
+ // release-APK behavior smoke. The expensive proofs are profile-tiered by
1411
+ // decision: per-change stays fast (local/ci pay for the release COMPILE via
1412
+ // releaseBuild, already in the set), and the release-variant *behavior* cost
1413
+ // lands once, at ship time. auditCadence (a report, never a gate) also
1414
+ // belongs to ship time — "what moved in androidMain since its last
1415
+ // adversarial audit?" is the question asked before shipping, not per edit.
1416
+ // releaseSmoke runs last so the device ends the run holding the exact build
1417
+ // that was proven.
1418
+ stepsForProfile.release = [...stepsForProfile.ci, stepAuditCadence, stepReleaseSmoke];
1147
1419
 
1148
1420
  if (!stepsForProfile[profile]) {
1149
1421
  console.error(`Unknown profile "${profile}" — use scaffold | local | ci | release.`);
1150
1422
  process.exit(2);
1151
1423
  }
1152
1424
 
1425
+ // ── Bare --determinism: the probe, nothing else, and NO receipt ─────────────
1426
+ // "Run it alone" means alone: no other steps, and deliberately no
1427
+ // qa/evidence/latest.json. The done-gate (qa/receipt-check.mjs) validates a
1428
+ // receipt by verdict + content hash — a receipt whose steps are one probe
1429
+ // would satisfy it while attesting almost nothing, so a probe-only run must
1430
+ // never mint one. The lane marker IS still stamped: the probe runs Gradle
1431
+ // and owes the preview daemon the same coexistence courtesy as the lane.
1432
+ if (determinism && !profileExplicit) {
1433
+ fs.mkdirSync(path.dirname(LANE_MARKER), { recursive: true });
1434
+ fs.writeFileSync(LANE_MARKER, `${process.pid} ${new Date().toISOString()}\n`);
1435
+ let probe;
1436
+ try {
1437
+ probe = stepDeterminism();
1438
+ } finally {
1439
+ fs.rmSync(LANE_MARKER, { force: true });
1440
+ }
1441
+ if (asJson) {
1442
+ console.log(JSON.stringify(probe, null, 2));
1443
+ } else {
1444
+ const mark = probe.verdict === "PASS" ? "✓" : "✗";
1445
+ console.log(`${mark} determinism: ${probe.verdict}${probe.note ? ` (${probe.note})` : ""}${probe.reason ? ` — ${probe.reason}` : ""}`);
1446
+ console.log("\n(probe-only run — no receipt written; the full lane is where evidence is earned)");
1447
+ }
1448
+ process.exit(probe.verdict === "FAIL" ? 1 : 0);
1449
+ }
1450
+
1153
1451
  // ── --fast: the inner loop, mechanically unable to claim done ───────────────
1154
1452
  // The genuinely slow tier is device/release work — every DEVICE_STEPS entry
1155
1453
  // (Gradle install + emulator + Maestro + instrumented runner) plus
@@ -1202,6 +1500,7 @@ if (fast) {
1202
1500
  // always removed, even on a failing step, so the eyes only ever defer briefly.
1203
1501
  fs.mkdirSync(path.dirname(LANE_MARKER), { recursive: true });
1204
1502
  fs.writeFileSync(LANE_MARKER, `${process.pid} ${new Date().toISOString()}\n`);
1503
+ const laneStartedAt = Date.now(); // for the flight-recorder entry's durationMs
1205
1504
  const steps = [];
1206
1505
  try {
1207
1506
  for (const step of laneSteps) {
@@ -1263,6 +1562,30 @@ if (fs.existsSync(ARTIFACTS_DIR)) {
1263
1562
  // Bind the receipt to the content of the verified surface (ADR-0005), NOT the
1264
1563
  // parent SHA (rebase/merge-fragile). Must be computed before latest.json is
1265
1564
  // written — the receipt is an output and must never hash itself.
1565
+ /**
1566
+ * The receipt's harness summary — compact by design. The per-file detail lives
1567
+ * on the harnessIntegrity step; this is the part a receipt-holder needs to
1568
+ * identify the lane, plus the names of any modified files (an auditor told
1569
+ * "not intact" and not told which files has been given a rumour, not a fact).
1570
+ */
1571
+ function harnessForReceipt() {
1572
+ const row = steps.find((st) => st.name === "harnessIntegrity");
1573
+ const r = row?.harness ?? checkHarnessIntegrity(ROOT);
1574
+ const summary = {
1575
+ name: r.name,
1576
+ version: r.version,
1577
+ sha256: r.sha256,
1578
+ status: r.status,
1579
+ intact: r.status === "intact",
1580
+ };
1581
+ if (r.status === "modified") {
1582
+ summary.modified = r.modified;
1583
+ summary.missing = r.missing;
1584
+ summary.extra = r.extra;
1585
+ }
1586
+ return summary;
1587
+ }
1588
+
1266
1589
  const inputs = computeInputsHash(ROOT);
1267
1590
 
1268
1591
  // The receipt. Deterministic key order; ONE volatile timestamp field.
@@ -1285,6 +1608,16 @@ const receipt = {
1285
1608
  fileCount: inputs.fileCount,
1286
1609
  },
1287
1610
  steps,
1611
+ // WHICH LANE issued this verdict. A receipt that cannot name its own harness
1612
+ // can only be checked against the tree it came from; naming the version and
1613
+ // the region digest lets a third party who holds the receipt ask the harder
1614
+ // question — was this the real published lane? — without the tree at all.
1615
+ //
1616
+ // `intact` is the LOCAL claim only: unmodified since installed. It is a
1617
+ // checksum, not a signature, and someone who edits the lane can edit this
1618
+ // too. What they cannot edit is what the registry published under that
1619
+ // version, which is why `version` + `sha256` travel together.
1620
+ harness: harnessForReceipt(),
1288
1621
  strength: { onDeviceSteps },
1289
1622
  evidenceLevel: level,
1290
1623
  artifacts,
@@ -1301,6 +1634,51 @@ fs.writeFileSync(path.join(EVIDENCE_DIR, "latest.json"), `${JSON.stringify(recei
1301
1634
  // studio console's Evidence audit trail reconstructs the full history from the
1302
1635
  // git log of this file — every commit is one verified, attributed state.
1303
1636
 
1637
+ // The README's evidence badge is DERIVED from the receipt just written — an
1638
+ // output, never a gate, so it runs after the verdict and cannot change it. It
1639
+ // renders the rung together with the commit it was attested against, so the
1640
+ // sentence stays true as the tree moves on (qa/lib/evidence-badge.mjs).
1641
+ const badge = updateReadmeBadge(ROOT);
1642
+
1643
+ // ── Flight recorder (roadmap §10 item 5) — the lane journals its own run ────
1644
+ // One JSON line per run into qa/flight-recorder.jsonl (committed, and
1645
+ // excluded from the receipt's hashed surface — qa/lib/flight-recorder.mjs
1646
+ // carries the whole rationale). Appended AFTER the receipt so the entry
1647
+ // records the final verdict and rung. A failed append must never fail the
1648
+ // lane — a recorder that breaks the thing it observes is worse than no
1649
+ // recorder — so the failure degrades to a note in the lane's own output,
1650
+ // which is itself the honest record of the degradation.
1651
+ //
1652
+ // --no-journal is the ONE exemption, and qa/watch.mjs passes it on every
1653
+ // save-triggered run. Same rule the README badge obeys, for the same reason:
1654
+ // THE INNER LOOP DOES NOT WRITE TO COMMITTED FILES. A watcher journaling every
1655
+ // save would add hundreds of lines a day to a committed file — turning the
1656
+ // app's history into keystroke noise and leaving a permanently-dirty tree in
1657
+ // the loop the recorder exists to observe. What survives is every full lane
1658
+ // and every DELIBERATE fast run, which is what the retrospective's questions
1659
+ // actually rest on (SKIP reasons, degraded paths, the longest stretch with no
1660
+ // full lane). qa/retrospective.mjs discloses the exemption in its own output
1661
+ // so the fast-vs-full ratio is never read as a complete census.
1662
+ const flight = noJournal
1663
+ ? { ok: true, skipped: true }
1664
+ : appendFlightRecord(
1665
+ ROOT,
1666
+ buildFlightEntry({
1667
+ profile,
1668
+ mode,
1669
+ verdict,
1670
+ evidenceLevel: level,
1671
+ steps,
1672
+ sha: receipt.commit.sha,
1673
+ durationMs: Date.now() - laneStartedAt,
1674
+ onDeviceSteps,
1675
+ degraded: DEGRADED_PATHS,
1676
+ }),
1677
+ );
1678
+ if (!flight.ok) {
1679
+ console.error(`· flight recorder: journal append failed (${flight.reason}) — the lane verdict is unaffected, but this run is missing from qa/flight-recorder.jsonl`);
1680
+ }
1681
+
1304
1682
  if (asJson) {
1305
1683
  console.log(JSON.stringify(receipt, null, 2));
1306
1684
  if (fast) {
@@ -1313,7 +1691,19 @@ if (asJson) {
1313
1691
  `\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`,
1314
1692
  );
1315
1693
  } else {
1316
- console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict}${level ? ` · ${level.rung} ${level.name}` : ""} (${strengthLabel}) — receipt written to qa/evidence/latest.json (commit it with your change)`);
1694
+ 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)`);
1695
+ }
1696
+
1697
+ // The audit-cadence nudges print in the human path, not only inside the
1698
+ // receipt JSON — a ship-time report that lives only in a JSON field is a
1699
+ // report nobody reads at ship time. Nudges only; a gate this is not.
1700
+ if (!asJson) {
1701
+ const auditStep = steps.find((s) => s.name === "auditCadence");
1702
+ const auditLines = auditStep?.details?.lines ?? [];
1703
+ if (auditLines.length > 0) {
1704
+ console.log("\naudit cadence (report, never a gate):");
1705
+ for (const l of auditLines) console.log(` ${l}`);
1706
+ }
1317
1707
  }
1318
1708
 
1319
1709
  process.exit(verdict === "PASS" ? 0 : 1);
@@ -358,8 +358,8 @@ function main() {
358
358
  const n = runCounter;
359
359
  const startedAtIso = new Date().toISOString();
360
360
  const started = Date.now();
361
- say(`── watch run #${n} starting (node qa/verify.mjs --fast) …`);
362
- const child = spawn(process.execPath, [path.join(ROOT, "qa", "verify.mjs"), "--fast", "--json"], {
361
+ say(`── watch run #${n} starting (node qa/verify.mjs --fast --no-journal) …`);
362
+ const child = spawn(process.execPath, [path.join(ROOT, "qa", "verify.mjs"), "--fast", "--json", "--no-journal"], {
363
363
  cwd: ROOT,
364
364
  stdio: ["ignore", "pipe", "pipe"],
365
365
  // Its own process GROUP: verify spawns Gradle through a shell, and a