@keepsnappy/agent 0.1.0-alpha.18 → 0.1.0-alpha.19

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.
package/README.md CHANGED
@@ -70,7 +70,7 @@ Annotated example: `examples/keepsnappy.yml`. Every setting:
70
70
 
71
71
  Unknown settings and wrong types are errors, all listed together.
72
72
 
73
- Results and exit codes: results never change the exit code by default (ruling D9, no blocking PR checks): `0` also for "More work". `--fail-on-worse` (needs `--wait`) fails the job (exit `3`) only when the verdict computed on that run set is Worse and blocking. Closing a run set never computes its verdict (decision 0031: the operator computes verdicts on demand), so with no computed verdict `--fail-on-worse` checks nothing: the CLI prints `Verdict not computed yet (computed on demand): --fail-on-worse checked nothing` and exits `0`. The CLI exits `2` for a configuration problem (bad or missing `keepsnappy.yml`, unknown or misspelt flags, a glob matching nothing, `--wait` without a branch or key) and `1` for an infrastructure problem (cannot read the device log, an API request failing, an API URL that does not answer as the KeepSnappy API, `--upload` that sent no run: "Blocked"; results stay in the out dir). Failed runs are marked invalid in `runs.ndjson` / `metrics.csv` and ignored by the statistics. The ending summary reads like:
73
+ Results and exit codes: results never change the exit code by default (ruling D9, no blocking PR checks): `0` also for "More work". `--fail-on-worse` (needs `--wait`) fails the job (exit `3`) only when the verdict computed on that run set is Worse and blocking. Closing a run set never computes its verdict (decision 0031: the operator computes verdicts on demand), so with no computed verdict `--fail-on-worse` checks nothing: the CLI prints `Verdict not computed yet (computed on demand): --fail-on-worse checked nothing` and exits `0`. The CLI exits `2` for a configuration problem (bad or missing `keepsnappy.yml`, unknown or misspelt flags, a glob matching nothing, `--wait` without a branch or key) and `1` for an infrastructure problem (cannot read the device log, an API request failing, an API URL that does not answer as the KeepSnappy API, `--upload` that sent no run: "Blocked", or a local run with no valid run; results stay in the out dir). For a local run with no valid run, it prints `No valid run was recorded` with the invalid reasons when known. Failed runs are marked invalid in `runs.ndjson` / `metrics.csv` and ignored by the statistics. The ending summary reads like:
74
74
 
75
75
  ```
76
76
  7/8 runs valid (dropped: 1 test-failed: exit 1)
@@ -507,23 +507,29 @@ class Driver {
507
507
  });
508
508
  await this.cmd("DELETE", "/actions").catch(() => null);
509
509
  }
510
- async drag(from, to, durationMs) {
510
+ async drag(from, to, durationMs, note) {
511
511
  to = { x: to.x, y: Math.min(to.y, await this.safeBottomY()) };
512
- await this.cmd("POST", "/actions", {
513
- actions: [
514
- {
515
- type: "pointer",
516
- id: "finger1",
517
- parameters: { pointerType: "touch" },
518
- actions: [
519
- { type: "pointerMove", duration: 0, x: from.x, y: from.y },
520
- { type: "pointerDown", button: 0 },
521
- { type: "pointerMove", duration: durationMs, x: to.x, y: to.y },
522
- { type: "pointerUp", button: 0 }
523
- ]
524
- }
525
- ]
526
- });
512
+ const logged = this.log.length;
513
+ try {
514
+ await this.cmd("POST", "/actions", {
515
+ actions: [
516
+ {
517
+ type: "pointer",
518
+ id: "finger1",
519
+ parameters: { pointerType: "touch" },
520
+ actions: [
521
+ { type: "pointerMove", duration: 0, x: from.x, y: from.y },
522
+ { type: "pointerDown", button: 0 },
523
+ { type: "pointerMove", duration: durationMs, x: to.x, y: to.y },
524
+ { type: "pointerUp", button: 0 }
525
+ ]
526
+ }
527
+ ]
528
+ });
529
+ } finally {
530
+ if (note && this.log[logged])
531
+ Object.assign(this.log[logged], note);
532
+ }
527
533
  await this.cmd("DELETE", "/actions").catch(() => null);
528
534
  }
529
535
  async relaunch(bundleId = this.bundleId) {
@@ -726,7 +732,7 @@ import { dirname, join, relative, resolve } from "node:path";
726
732
  import { fileURLToPath } from "node:url";
727
733
  var DRIVER_NAME = "appium";
728
734
  function driverVersion() {
729
- const built = "0.1.0-alpha.18";
735
+ const built = "0.1.0-alpha.19";
730
736
  if (built)
731
737
  return built;
732
738
  let dir = dirname(fileURLToPath(import.meta.url));
@@ -1953,6 +1953,16 @@ var MEASURES = {
1953
1953
  decimals: 1,
1954
1954
  what: "The part of the slowest screen update that React spent outside every component's own render, in its own bookkeeping"
1955
1955
  },
1956
+ jank_ms: {
1957
+ unit: "ms",
1958
+ worse: "higher",
1959
+ at: ["screen", "launch", "press", "drag", "step", "other", "background"],
1960
+ producer: "tracesift",
1961
+ label: "Jank time",
1962
+ short: "jank time",
1963
+ decimals: 1,
1964
+ what: "The time the window's screen updates spent past one 60 Hz frame, added up, read from the full React export"
1965
+ },
1956
1966
  commits_over_2_frames: {
1957
1967
  unit: "count",
1958
1968
  worse: "higher",
@@ -5203,6 +5213,7 @@ var markerEnv = (url, runId) => ({
5203
5213
 
5204
5214
  // packages/present/dist/src/capital.js
5205
5215
  var upperFirst = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
5216
+ var lowerFirst = (s) => s ? s.charAt(0).toLowerCase() + s.slice(1) : s;
5206
5217
 
5207
5218
  // packages/present/dist/src/placeWords.js
5208
5219
  var GENERIC_GROUPS = new Set(["app", "tabs", "root"]);
@@ -5247,6 +5258,7 @@ var notBlockingBadge = (word, blocking) => word === STATE_WORDS.worse && blockin
5247
5258
  var BLOCKED_WORD = "Blocked";
5248
5259
  var NOT_COMPUTED_WORD = "Verdict not computed yet (computed on demand)";
5249
5260
  var NO_BASELINE_WORD = "Waiting for main";
5261
+ var NOT_COMPARED_WORD = "Not compared with main";
5250
5262
  var NOT_JUDGED_WORD = "Not judged";
5251
5263
 
5252
5264
  // packages/present/dist/src/values.js
@@ -5310,6 +5322,13 @@ function fieldValueText(field, v) {
5310
5322
  const def = fieldDef(field.split(KEY_SEPARATOR)[0]);
5311
5323
  return format(def?.unit, def?.decimals ?? FALLBACK_DECIMALS, v);
5312
5324
  }
5325
+ function timeMove(field, a, b) {
5326
+ if (a === null || b === null || !(a > 0))
5327
+ return null;
5328
+ const breakdown = breakdownMeasure(field);
5329
+ const unit = breakdown ? BREAKDOWN_MEASURES[breakdown].unit : fieldDef(field.split(KEY_SEPARATOR)[0])?.unit;
5330
+ return unit === "ms" ? Math.abs(b - a) / a : null;
5331
+ }
5313
5332
 
5314
5333
  // packages/present/dist/src/rowWords.js
5315
5334
  var GONE_TAILS = {
@@ -5364,9 +5383,6 @@ function leadBody(subject, measure, main, pr) {
5364
5383
  var screenLeadText = (tier, subject, measure, main, pr) => `${STATE_WORDS[TIER_STATE[tier]]}: ${leadBody(subject, measure, main, pr)}`;
5365
5384
  var FOR_INFORMATION = "For information";
5366
5385
  var informationLeadText = (subject, measure, main, pr) => `${FOR_INFORMATION}: ${leadBody(subject, measure, main, pr)}`;
5367
- function foldedText(n, rows, page) {
5368
- return `${n} ${GONE_TAILS[page][rows === "components" ? "removed" : "notInRun"]}`;
5369
- }
5370
5386
  function placeShownName(p, o = {}) {
5371
5387
  const target = p.target ? componentShownName(p.target.name, p.target.file) : libraryViewName(p.kind, p.host ?? "");
5372
5388
  const named = p.target && p.parent ? `${target} in ${componentShownName(p.parent.name, p.parent.file)}${p.k ? `, ${ordinal(p.k)}` : ""}` : target;
@@ -5427,6 +5443,19 @@ var placeOpenWord = (id) => {
5427
5443
  const open = placeOpen(id);
5428
5444
  return open ? OPEN_WORDS[open] : null;
5429
5445
  };
5446
+ var TWIN2 = /\+3s$/;
5447
+ function foldTwins(rows, placeOf, also = () => "") {
5448
+ const seen = new Set;
5449
+ const out = rows.filter((r) => {
5450
+ const key = `${placeOf(r).replace(TWIN2, "")}\x00${also(r)}`;
5451
+ if (seen.has(key))
5452
+ return false;
5453
+ seen.add(key);
5454
+ return true;
5455
+ });
5456
+ return { rows: out, folded: rows.length - out.length };
5457
+ }
5458
+ var foldedTwinsText = (n) => `${n} “${PLACE_KIND_WORDS.after3s}” ${n === 1 ? "twin" : "twins"} folded into ${n === 1 ? "its place" : "their places"}`;
5430
5459
  function placeIdShownName(id, o = {}) {
5431
5460
  const p = placeParts(id);
5432
5461
  if (!p)
@@ -5513,6 +5542,42 @@ function shortestPaths(files) {
5513
5542
  }
5514
5543
  return out;
5515
5544
  }
5545
+ // packages/present/dist/src/problems.js
5546
+ var FAR_OVER_BUDGET_WORD = "Far over budget";
5547
+ var PROBLEM_TAG_WORDS = { new: NEW_IN_PR_CHIP, main: "Also on main", none: NOT_COMPARED_WORD };
5548
+ var PROBLEM_TAG_LINE_WORDS = { new: "new in this PR", main: "also on main", none: "not compared with main" };
5549
+ var problems = (n) => `${n.toLocaleString("en-US")} ${n === 1 ? "problem" : "problems"}`;
5550
+ var places = (n) => `${n.toLocaleString("en-US")} ${n === 1 ? "place" : "places"}`;
5551
+ function problemCountsText(c, own) {
5552
+ const tags = c.tags;
5553
+ const split = tags ? `: ${[`${tags.new} ${PROBLEM_TAG_LINE_WORDS.new}`, ...["main", "none"].filter((t) => tags[t]).map((t) => `${tags[t]} ${PROBLEM_TAG_LINE_WORDS[t]}`)].join(", ")}` : "";
5554
+ return `${problems(c.count)} at ${places(c.places)} in ${own} own runs${split}${c.far_count ? `; ${c.far_count} of them ${FAR_OVER_BUDGET_WORD.toLowerCase()}` : ""}`;
5555
+ }
5556
+ var newProblems = (n) => `${n.toLocaleString("en-US")} new ${n === 1 ? "problem" : "problems"}`;
5557
+ var PROBLEMS_BLOCK_WORDS = {
5558
+ branch: {
5559
+ heading: "New problems in this branch",
5560
+ sub: "too slow on their own, whatever main does; they don’t block the merge",
5561
+ seeAll: (n) => `See all ${newProblems(n)}`,
5562
+ alsoLine: (n) => `${problems(n)} also on main`,
5563
+ notComparedLine: (n) => `${problems(n)} not compared with main`,
5564
+ choices: { new: PROBLEM_TAG_WORDS.new, also: PROBLEM_TAG_WORDS.main, not_compared: PROBLEM_TAG_WORDS.none },
5565
+ panelTitle: "Problems in this branch",
5566
+ panelNote: "Each one is too slow in this branch’s own runs, whatever main does. Problems never block.",
5567
+ filter: "Which problems"
5568
+ },
5569
+ main: {
5570
+ heading: "New problems in this session",
5571
+ sub: "too slow on their own, whatever earlier sessions did",
5572
+ seeAll: (n) => `See all ${newProblems(n)}`,
5573
+ alsoLine: (n) => `${problems(n)} also in earlier sessions`,
5574
+ notComparedLine: (n) => `${problems(n)} not compared with earlier sessions`,
5575
+ choices: { new: "New in this session", also: "Also in earlier sessions", not_compared: "Not compared with earlier sessions" },
5576
+ panelTitle: "Problems in this session",
5577
+ panelNote: "Each one is too slow in this session’s own runs, whatever earlier sessions did. Problems never block.",
5578
+ filter: "Which problems"
5579
+ }
5580
+ };
5516
5581
  // packages/present/dist/src/stability.js
5517
5582
  var UNSTABLE_WORDS = {
5518
5583
  short: "main varies too much here",
@@ -5619,8 +5684,6 @@ function resultContextLines(c, verbose = false, short = false) {
5619
5684
  for (const [screen, rows] of screens) {
5620
5685
  const line = notInRunRowsText({ screens: rows.filter((p) => p.kind === "screen").length, places: rows.filter((p) => p.kind !== "screen").length }, c.page);
5621
5686
  out.push(`${screen ? `${screen}: ` : ""}${line.lead}${line.rest}`);
5622
- if (rows.length > 3)
5623
- out.push(` ${foldedText(rows.length, "places", c.page)}`);
5624
5687
  if (!short || rows.length <= 3)
5625
5688
  out.push(...rows.map((p) => ` ${windowQualified(p.place)}`));
5626
5689
  }
@@ -5630,8 +5693,6 @@ function resultContextLines(c, verbose = false, short = false) {
5630
5693
  for (const [place, rows] of removed) {
5631
5694
  const line = removedRowsText(rows.length, c.page);
5632
5695
  out.push(`${windowQualified(place)}: ${line.lead}${line.rest}`);
5633
- if (rows.length > 3)
5634
- out.push(` ${foldedText(rows.length, "components", c.page)}`);
5635
5696
  if (!short || rows.length <= 3)
5636
5697
  out.push(...rows.map((r) => ` ${r.ref.kind === "breakdown" ? componentReadName({ key: r.ref.key, name: r.ref.name }) : measureWords(r.ref.measure)}`));
5637
5698
  }
@@ -5706,11 +5767,13 @@ var MAIN_TIER_RESULT_WORDS = {
5706
5767
  worse: STATE_WORDS.worse
5707
5768
  };
5708
5769
  var MAIN_TIER_RANK = { worse: 0, slight: 1, cant: 2, better: 3, same: 4 };
5770
+ var earlierWords = (sessions, since, config) => `main has ${sessions}${config.baseline.sessions === undefined ? "" : ` of ${config.baseline.sessions}`} earlier sessions${since ? ` on ${since}` : ""}`;
5709
5771
  function mainCantWords(reason, config) {
5710
5772
  if (!reason)
5711
5773
  return "";
5712
- return reason.kind === "noisy_main" ? "main’s runs vary too much there" : `main has ${reason.earlier_sessions}${config.baseline.sessions === undefined ? "" : ` of ${config.baseline.sessions}`} earlier sessions${reason.since_label ? ` on ${reason.since_label}` : ""}`;
5774
+ return reason.kind === "noisy_main" ? "main’s runs vary too much there" : earlierWords(String(reason.earlier_sessions), reason.since_label, config);
5713
5775
  }
5776
+ var mainCantRangeWords = (r, config) => earlierWords(`${r.min}–${r.max}`, r.since_label, config);
5714
5777
  var waitingSessionsWords = (sessions) => `${sessions} older main ${sessions === 1 ? "session waits" : "sessions wait"} for a reprocess`;
5715
5778
  function mainPlaceReasonWords(reason, config) {
5716
5779
  switch (reason.kind) {
@@ -5740,9 +5803,11 @@ var SHORT_ASPECT_WORDS = {
5740
5803
  driverVersion: "Driver",
5741
5804
  journeyVersion: "Journey"
5742
5805
  };
5806
+ var cantAt = (places) => `${STATE_WORDS["cant-tell"]} at ${places} place${places === 1 ? "" : "s"}`;
5743
5807
  function mainSessionCantWords(places, reason, config) {
5744
- return `${STATE_WORDS["cant-tell"]} at ${places} place${places === 1 ? "" : "s"}: ${mainCantWords(reason, config)}`;
5808
+ return `${cantAt(places)}: ${mainCantWords(reason, config)}`;
5745
5809
  }
5810
+ var mainSessionCantRangeWords = (places, r, config) => `${cantAt(places)}: ${mainCantRangeWords(r, config)}`;
5746
5811
  var KIND_PLURAL = {
5747
5812
  screen: "screens",
5748
5813
  press: "taps",
@@ -5768,8 +5833,8 @@ function mainCutWords(v) {
5768
5833
  }
5769
5834
  var BY_RANK = Object.keys(MAIN_TIER_RANK).sort((a, b) => MAIN_TIER_RANK[a] - MAIN_TIER_RANK[b]);
5770
5835
  // packages/present/dist/src/verdict-section.js
5771
- var places = (n) => `${n.toLocaleString("en-US")} ${n === 1 ? "place" : "places"}`;
5772
- var of = (n, total) => `${n.toLocaleString("en-US")} of ${places(total)}`;
5836
+ var places2 = (n) => `${n.toLocaleString("en-US")} ${n === 1 ? "place" : "places"}`;
5837
+ var of = (n, total) => `${n.toLocaleString("en-US")} of ${places2(total)}`;
5773
5838
  var runs = (n) => `${n} valid ${n === 1 ? "run" : "runs"}`;
5774
5839
  var VERDICT_SECTION_WORDS = {
5775
5840
  slower: (n, total, slight) => `${slight ? "A little slower" : "Slower"} at ${of(n, total)}`,
@@ -5777,7 +5842,7 @@ var VERDICT_SECTION_WORDS = {
5777
5842
  faster: (n, total) => `Faster at ${of(n, total)}, slower at none`,
5778
5843
  mixed: (slower, faster, total) => `Slower at ${of(slower, total)}, faster at ${faster.toLocaleString("en-US")}`,
5779
5844
  unpublishedSdk: "ran an unpublished SDK build",
5780
- noneMoved: (total, judged = total) => judged < total ? `No place moved out of its normal range (${judged.toLocaleString("en-US")} of ${total.toLocaleString("en-US")} judged)` : `No place moved out of its normal range (${places(total)})`,
5845
+ noneMoved: (total, judged = total) => judged < total ? `No place moved out of its normal range (${judged.toLocaleString("en-US")} of ${total.toLocaleString("en-US")} judged)` : `No place moved out of its normal range (${places2(total)})`,
5781
5846
  noneMovedMeasured: (measured, same) => same ? `Nothing moved at ${of(same, same + measured)}; ${measured} measured differently` : `Nothing moved; ${measured} ${measured === 1 ? "place was" : "places were"} measured differently`,
5782
5847
  tooFewRuns: (valid, needed) => `Not compared: ${runs(valid)} of ${needed} needed`,
5783
5848
  tooFewMainRuns: (valid, needed) => `Not compared: main has ${runs(valid)} of ${needed} needed`,
@@ -5785,11 +5850,29 @@ var VERDICT_SECTION_WORDS = {
5785
5850
  noBaseline: { branch: "Nothing to compare with: no main runs on this phone yet", main: "Nothing to compare with: no earlier sessions on this phone" },
5786
5851
  noisy: "Not judged: main’s own runs vary too much at every place here",
5787
5852
  versionMismatch: "Not compared: the sides were computed with different analysis versions",
5788
- unconfirmed: (n) => `Can’t confirm ${places(n)}: a run has no value there`,
5853
+ unconfirmed: (n) => `Can’t confirm ${places2(n)}: a run has no value there`,
5789
5854
  notCompared: (reason) => `Not compared: ${reason}`,
5790
5855
  notJudgedYet: (why) => `Not judged yet: ${why} on this phone`,
5791
5856
  collecting: (collected, baseline, after) => after ? `Main is collecting a new baseline after ${after}: ${collected} of ${baseline} sessions` : `Main is collecting its baseline: ${collected} of ${baseline} sessions`,
5792
5857
  doesNotBlock: "doesn’t block",
5858
+ fixes: {
5859
+ branch: {
5860
+ "no-baseline": "Add this journey on this phone to main’s nightly runs.",
5861
+ "too-few-runs": "Run this branch again on this phone.",
5862
+ "judged-measures-missing": "Record the judged measures, then run again.",
5863
+ "runs-unknown": "Run this branch again; a full set of runs decides it.",
5864
+ "facts-version-mismatch": "Recompute both sides in Setup; no new runs are needed.",
5865
+ "platforms-differ": "Add this phone’s platform to main’s nightly runs."
5866
+ },
5867
+ main: {
5868
+ "no-baseline": "Nothing to do: main’s next 3 nightly sessions make a result possible.",
5869
+ "too-few-runs": "Main’s next nightly session decides it.",
5870
+ "runs-unknown": "Main’s next nightly session decides it.",
5871
+ "judged-measures-missing": "Record the judged measures on main; its next nightly session is compared.",
5872
+ "facts-version-mismatch": "Recompute both sides in Setup; no new runs are needed."
5873
+ }
5874
+ },
5875
+ waitForMain: { branch: "Nothing to do on this branch; main’s next nightly session decides it.", main: "Main’s next nightly session decides it." },
5793
5876
  sides: {
5794
5877
  branch: { left: "Main", right: "This branch", rightShort: "Branch" },
5795
5878
  main: { left: "Sessions before", right: "Newest session", rightShort: "Newest" },
@@ -5808,7 +5891,7 @@ var VERDICT_SECTION_WORDS = {
5808
5891
  countsLabel: "Places by result",
5809
5892
  links: {
5810
5893
  openChart: "Open chart",
5811
- seeAll: (n) => `See all ${places(n)}`,
5894
+ seeAll: (n) => `See all ${places2(n)}`,
5812
5895
  whichPlaces: "Which places",
5813
5896
  whichChanges: "Which changes",
5814
5897
  howToFix: "How to fix",
@@ -5832,9 +5915,19 @@ var SUMMARY_WORDS = {
5832
5915
  fewRuns: "few runs",
5833
5916
  measuredDifferently: "measured differently",
5834
5917
  namesChanged: (sdk, mainSessions, needed) => `Main: ${mainSessions} of ${needed} sessions on SDK ${sdkTag(sdk)}`,
5918
+ morePlaces: (n) => `+${n} more ${n === 1 ? "place" : "places"} changed`,
5919
+ compared: {
5920
+ branch: (sessions, days) => `vs main’s last ${sessions}${days}`,
5921
+ main: (sessions, days) => `newest session vs main’s ${sessions} before${days}`,
5922
+ mainSlower: (sessions, days) => `newest session slower than main’s ${sessions} before${days}`
5923
+ },
5924
+ sessions: (n) => `${n} ${n === 1 ? "session" : "sessions"}`,
5925
+ days: (days) => days ? ` (${days})` : "",
5926
+ runs: (runs, each) => each ? `${each} ${each === 1 ? "run" : "runs"} each` : `${runs} ${runs === 1 ? "run" : "runs"}`,
5927
+ unblock: (why, fix) => `${why} → ${fix}`,
5835
5928
  link: {
5836
- lane: (journey, phone) => `Open ${journey} on ${phone}`,
5837
- phone: (phone) => `Open on ${phone}`,
5929
+ comparison: "See the comparison",
5930
+ session: "See the session",
5838
5931
  branch: (branch) => `Open ${branch}`,
5839
5932
  runSet: "Open the run set"
5840
5933
  }
@@ -5844,26 +5937,25 @@ var rank = (l) => ORDER.indexOf(l.state);
5844
5937
  var WORD = { ...STATE_WORDS, "not-computed": SUMMARY_WORDS.notComputed };
5845
5938
  var shownLane = (lanes) => [...lanes].sort((a, b) => rank(a) - rank(b) || b.places - a.places)[0];
5846
5939
  var clip = (s, n) => s.length > n ? `${s.slice(0, n - 1)}…` : s;
5847
- var NAME = { name: 32, beside: 18, place: 16, link: 60, button: 30 };
5848
- var DETAIL = 64;
5940
+ var NAME = { name: 32, button: 30 };
5941
+ var TOP2 = 3;
5942
+ var MAX_LINES = 5;
5849
5943
  var upper = (s) => s ? `${s[0].toUpperCase()}${s.slice(1)}` : s;
5850
5944
  var fromTo = (a, b) => {
5851
5945
  const unit = (v) => / [^\d\s]+$/.exec(v)?.[0];
5852
5946
  return unit(a) && unit(a) === unit(b) ? `${a.slice(0, -unit(a).length)} → ${b}` : `${a} → ${b}`;
5853
5947
  };
5854
- function clipPlace(place, n) {
5855
- if (place.length <= n)
5856
- return place;
5857
- let cut = place.slice(0, n);
5858
- if (place[n] !== " " && cut.includes(" "))
5859
- cut = cut.slice(0, cut.lastIndexOf(" "));
5860
- cut = cut.trimEnd();
5861
- const open = (cut.match(/“/g) ?? []).length > (cut.match(/”/g) ?? []).length;
5862
- return `${cut}…${open ? "”" : ""}`;
5863
- }
5864
- var changeText2 = (c, room) => {
5865
- const what = `${c.measure} ${c.main !== null && c.now !== null ? fromTo(c.main, c.now) : c.change}`;
5866
- return c.place ? `${clipPlace(c.place, Math.max(NAME.place, room - what.length - 3))} · ${what}` : upper(what);
5948
+ var days = (labels) => {
5949
+ const a = labels[0], b = labels.at(-1);
5950
+ if (!a || !b || a === b)
5951
+ return a ?? "";
5952
+ const [ma] = a.split(" ");
5953
+ const [mb, db] = b.split(" ");
5954
+ return ma === mb ? `${a}–${db}` : `${a}–${b}`;
5955
+ };
5956
+ var changeLine = (c, tier) => {
5957
+ const values = c.main !== null && c.now !== null ? `${fromTo(c.main, c.now)}${c.change ? ` (${c.change})` : ""}` : c.change;
5958
+ return [c.place ?? upper(c.measure), ...c.place ? [c.measure] : [], values, WORD[c.tier ?? tier]].filter(Boolean).join(" · ");
5867
5959
  };
5868
5960
  var wordReason = (word) => word?.includes(": ") ? word.slice(word.indexOf(": ") + 2) : null;
5869
5961
  var notJudgedWhy = (l) => l.state === "not-computed" && l.word?.startsWith(`${NOT_JUDGED_WORD}: `) ? upper(l.word.slice(NOT_JUDGED_WORD.length + 2)) : null;
@@ -5880,19 +5972,30 @@ function why(l) {
5880
5972
  return upper(SUMMARY_WORDS.placesOnPage);
5881
5973
  return upper(wordReason(l.word) ?? (l.word === NO_BASELINE_WORD ? l.word : REASON_WORDS["judged-measures-missing"]));
5882
5974
  }
5975
+ function unblock(l, pr) {
5976
+ const page = pr ? "branch" : "main";
5977
+ const reason = l.namesChanged ? "not-enough-main" : l.reason;
5978
+ if (!reason)
5979
+ return null;
5980
+ const fix = reason === "not-enough-main" ? VERDICT_SECTION_WORDS.waitForMain[page] : VERDICT_SECTION_WORDS.fixes[page][reason];
5981
+ return fix ? lowerFirst(fix.replace(/\.$/, "")) : null;
5982
+ }
5883
5983
  var noneMoved = VERDICT_SECTION_WORDS.noneMoved(1).replace(/ \(.*\)$/, "");
5884
5984
  function conditions(l, pr) {
5885
5985
  const moved = l.state === "worse" || l.state === "warning";
5886
- const heat = l.heatedRuns ? SUMMARY_WORDS.heated(l.heatedRuns, l.runs) : "";
5887
- const heatIsNews = l.state === "no-change" && !!heat;
5888
- return {
5889
- always: [pr && l.state === "worse" ? l.blocking ? VERDICT_SECTION_WORDS.blocksMerge : NOT_BLOCKING : "", heatIsNews ? heat : ""].filter(Boolean),
5890
- optional: [
5891
- moved && l.measuredDifferently ? SUMMARY_WORDS.measuredDifferently : "",
5892
- (moved || l.state === "better") && l.fewRuns ? SUMMARY_WORDS.fewRuns : "",
5893
- heatIsNews ? "" : heat
5894
- ].filter(Boolean)
5895
- };
5986
+ return [
5987
+ pr && l.state === "worse" ? l.blocking ? VERDICT_SECTION_WORDS.blocksMerge : NOT_BLOCKING : "",
5988
+ moved && l.measuredDifferently ? SUMMARY_WORDS.measuredDifferently : "",
5989
+ (moved || l.state === "better") && l.fewRuns ? SUMMARY_WORDS.fewRuns : ""
5990
+ ].filter(Boolean);
5991
+ }
5992
+ function compared(l, pr, slower) {
5993
+ const judged = l.state === "worse" || l.state === "warning" || l.state === "better" || l.state === "no-change";
5994
+ if (!judged || !l.main?.sessions)
5995
+ return null;
5996
+ const args = [SUMMARY_WORDS.sessions(l.main.sessions), SUMMARY_WORDS.days(days(l.main.labels))];
5997
+ const what = pr ? SUMMARY_WORDS.compared.branch(...args) : slower ? SUMMARY_WORDS.compared.mainSlower(...args) : SUMMARY_WORDS.compared.main(...args);
5998
+ return `${what} · ${SUMMARY_WORDS.runs(l.main.runs, l.main.each)}`;
5896
5999
  }
5897
6000
  function summaryMessage(input) {
5898
6001
  const l = shownLane(input.lanes);
@@ -5901,37 +6004,55 @@ function summaryMessage(input) {
5901
6004
  state: "not-computed",
5902
6005
  newestSlower: false,
5903
6006
  lead: `${SUMMARY_WORDS.notComputed} · ${clip(input.branch, NAME.name)}`,
5904
- detail: upper(SUMMARY_WORDS.notComputedWhy),
6007
+ ...withLines([upper(SUMMARY_WORDS.notComputedWhy)]),
5905
6008
  link: { label: SUMMARY_WORDS.link.branch(clip(input.branch, NAME.button - 5)), url: input.url }
5906
6009
  };
5907
6010
  }
5908
6011
  const journeys = new Set(input.lanes.map((x) => x.journey ?? "-")).size;
6012
+ const phones = new Set(input.lanes.map((x) => x.phone ?? "-")).size;
5909
6013
  const single = input.lanes.length === 1;
5910
- const phone = l.phone ?? "unknown phone";
5911
- const several = journeys === 1 ? SUMMARY_WORDS.phones(new Set(input.lanes.map((x) => x.phone ?? "-")).size) : SUMMARY_WORDS.journeys(journeys);
5912
6014
  const newestSlower = !input.pr && (l.state === "worse" || l.state === "warning");
5913
- const named = (subject, rest) => [clip(subject, rest.length ? NAME.beside : NAME.name), ...rest];
5914
- const what = input.pr ? named(input.branch, single ? [] : [several]) : single || journeys === 1 ? named(l.journey ?? "journey", single ? [] : [several]) : [several];
5915
- const lead = newestSlower ? [SUMMARY_WORDS.main, ...what, SUMMARY_WORDS.newestSlower].join(" · ") : [notJudgedWhy(l) ? NOT_JUDGED_WORD : WORD[l.state], ...what].join(" · ");
5916
- const top = l.state === "worse" || l.state === "warning" || l.state === "better" ? l.changes[0] : undefined;
5917
- const { always, optional } = conditions(l, input.pr);
5918
- const answer = top ? null : always.includes(SUMMARY_WORDS.heated(l.heatedRuns, l.runs)) ? noneMoved : why(l);
5919
- let extra = [...always];
5920
- for (const c of optional)
5921
- if ((answer ?? changeText2(top, 0)).length + `; ${[...extra, c].join(", ")}`.length <= DETAIL)
5922
- extra = [...extra, c];
5923
- const tail = extra.length ? `; ${extra.join(", ")}` : "";
5924
- const detail = `${top ? changeText2(top, DETAIL - tail.length) : answer}${tail}`;
5925
- const journey = clip(l.journey ?? "journey", NAME.link);
5926
- const short = (full, fallback) => full.length <= NAME.button ? full : fallback;
5927
- const label = single ? short(SUMMARY_WORDS.link.lane(journey, phone), SUMMARY_WORDS.link.phone(phone)) : input.pr ? SUMMARY_WORDS.link.branch(clip(input.branch, NAME.button - 5)) : journeys === 1 ? short(SUMMARY_WORDS.link.lane(journey, several), SUMMARY_WORDS.link.phone(several)) : SUMMARY_WORDS.link.runSet;
5928
- return { state: l.state, newestSlower, lead, detail, link: { label, url: (single ? l.url : null) ?? input.url } };
6015
+ const lead = [
6016
+ notJudgedWhy(l) ? NOT_JUDGED_WORD : WORD[l.state],
6017
+ ...input.app ? [input.app] : [],
6018
+ journeys === 1 ? l.journey ?? "journey" : SUMMARY_WORDS.journeys(journeys),
6019
+ input.pr && input.prNumber ? `${input.branch} (#${input.prNumber})` : input.branch,
6020
+ phones === 1 ? l.phone ?? "unknown phone" : SUMMARY_WORDS.phones(phones)
6021
+ ].join(" · ");
6022
+ const lines = [];
6023
+ const basis = compared(l, input.pr, newestSlower);
6024
+ const extra = conditions(l, input.pr).join(", ");
6025
+ const heat = l.heatedRuns ? upper(SUMMARY_WORDS.heated(l.heatedRuns, l.runs)) : null;
6026
+ const tail = [...basis || extra ? [basis ? `${basis}${extra ? `; ${extra}` : ""}` : upper(extra)] : [], ...heat ? [heat] : []];
6027
+ if (l.state === "worse" || l.state === "warning" || l.state === "better") {
6028
+ const seen = new Set;
6029
+ const places = l.changes.filter((c) => {
6030
+ const key = c.key ?? c.place ?? c.measure;
6031
+ return seen.has(key) ? false : (seen.add(key), true);
6032
+ });
6033
+ const room = MAX_LINES - tail.length;
6034
+ const top = places.length > room ? places.slice(0, Math.min(TOP2, room - 1)) : places.slice(0, TOP2);
6035
+ lines.push(...top.map((c) => changeLine(c, l.state)));
6036
+ if (!top.length)
6037
+ lines.push(why(l));
6038
+ const more = l.places - top.length;
6039
+ if (top.length && more > 0)
6040
+ lines.push(SUMMARY_WORDS.morePlaces(more));
6041
+ } else {
6042
+ const fix = l.state === "cant-tell" ? unblock(l, input.pr) : null;
6043
+ lines.push(fix ? SUMMARY_WORDS.unblock(why(l), fix) : why(l));
6044
+ }
6045
+ lines.push(...tail);
6046
+ const label = input.pr ? SUMMARY_WORDS.link.comparison : SUMMARY_WORDS.link.session;
6047
+ return { state: l.state, newestSlower, lead, ...withLines(lines), link: { label, url: (single ? l.url : null) ?? input.url } };
5929
6048
  }
6049
+ var withLines = (lines) => ({ lines, detail: lines.join(`
6050
+ `) });
5930
6051
  function pendingMessage(branch, url) {
5931
- return { state: "not-computed", newestSlower: false, lead: `${SUMMARY_WORDS.notComputed} · ${clip(branch, NAME.name)}`, detail: upper(SUMMARY_WORDS.notComputedWhy), link: { label: SUMMARY_WORDS.link.runSet, url } };
6052
+ return { state: "not-computed", newestSlower: false, lead: `${SUMMARY_WORDS.notComputed} · ${clip(branch, NAME.name)}`, ...withLines([upper(SUMMARY_WORDS.notComputedWhy)]), link: { label: SUMMARY_WORDS.link.runSet, url } };
5932
6053
  }
5933
6054
  function noResultMessage(branch, reason, url) {
5934
- return { state: "not-computed", newestSlower: false, lead: `${SUMMARY_WORDS.noResult} · ${clip(branch, NAME.name)}`, detail: reason.replace(/\.$/, ""), link: url ? { label: SUMMARY_WORDS.link.runSet, url } : null };
6055
+ return { state: "not-computed", newestSlower: false, lead: `${SUMMARY_WORDS.noResult} · ${clip(branch, NAME.name)}`, ...withLines([reason.replace(/\.$/, "")]), link: url ? { label: SUMMARY_WORDS.link.runSet, url } : null };
5935
6056
  }
5936
6057
  // packages/present/dist/src/run-set-message.js
5937
6058
  var midLine = (label, metric) => !metric.startsWith("component:") && /^[A-Z][a-z]*(\s|$)/.test(label) ? `${label[0].toLowerCase()}${label.slice(1)}` : label;
@@ -6006,6 +6127,8 @@ function laneOf(v, sc, set) {
6006
6127
  changes: shown.map((i) => ({
6007
6128
  place: placeOf(i.step),
6008
6129
  measure: measureWord(i),
6130
+ key: i.finding ?? placeRef(i.step ?? "*").stem,
6131
+ tier: i.state === "better" ? "better" : i.grade === "warn" ? "warning" : "worse",
6009
6132
  main: i.baseline === null ? null : atMost(fieldValueText(i.metric, i.baseline), i.baseline_is_bound),
6010
6133
  now: i.candidate === null ? null : fieldValueText(i.metric, i.candidate),
6011
6134
  change: changeWords(i)
@@ -6017,17 +6140,20 @@ function laneOf(v, sc, set) {
6017
6140
  heatedRuns: sc.heated_runs ?? 0,
6018
6141
  runs: sc.runs.valid + sc.runs.invalid,
6019
6142
  namesChanged: sc.context?.namesChanged ?? null,
6020
- url: linkOf(sc, set)
6143
+ url: linkOf(sc, set),
6144
+ main: sc.main ?? null,
6145
+ reason: sc.headline?.reason ?? sc.headline?.reasons?.[0] ?? null
6021
6146
  };
6022
6147
  }
6023
6148
  var isPending = (s) => lanesOf(s).every((l) => l.state === "not-computed" && !l.word?.startsWith(`${NOT_JUDGED_WORD}: `));
6024
6149
  var lanesOf = (s) => s.verdicts ? s.verdicts.scenarios.map((sc) => laneOf(s.verdicts, sc, s)) : [];
6025
6150
  var isPr = (s) => !!s.verdicts?.scenarios.some((sc) => sc.role === "comparison");
6026
- var runSetMessage = (s) => summaryMessage({ branch: s.branch, pr: isPr(s), lanes: lanesOf(s), url: linkOf(s) ?? s.dashboard_url });
6151
+ var runSetMessage = (s) => summaryMessage({ branch: s.branch, pr: isPr(s), lanes: lanesOf(s), url: linkOf(s) ?? s.dashboard_url, app: s.app ?? null, prNumber: s.pull_request?.number ?? null });
6027
6152
  var MESSAGE_COLORS = { error: "#D6334A", warn: "#C47A00", better: "#238359", none: "#7C8492" };
6028
6153
  function messageTone(s) {
6029
6154
  const lanes = s ? lanesOf(s) : [];
6030
- if (lanes.some((l) => l.state === "worse" && l.blocking))
6155
+ const pr = !!s && isPr(s);
6156
+ if (lanes.some((l) => l.state === "worse" && (l.blocking || !pr)))
6031
6157
  return { mark: "\uD83D\uDD34", color: MESSAGE_COLORS.error };
6032
6158
  if (lanes.some((l) => l.state === "warning"))
6033
6159
  return { mark: "\uD83D\uDFE1", color: MESSAGE_COLORS.warn };
@@ -6069,18 +6195,43 @@ function resultSlackPayload(s, o = {}) {
6069
6195
  var noResultSlackPayload = (branch, reason, url) => slackPayload(noResultMessage(branch, reason, url), { mark: "⚪", color: MESSAGE_COLORS.none });
6070
6196
  var markdown = (m, mark) => `### ${mark} ${m.lead}
6071
6197
 
6072
- ${m.detail}
6198
+ ${m.lines.join(`
6199
+ `)}
6073
6200
  ${m.link ? `
6074
6201
  [${m.link.label} ›](${m.link.url})
6075
6202
  ` : ""}`;
6076
6203
  var resultMarkdown = (s) => markdown(runSetMessage(s), messageTone(s).mark);
6077
6204
  var noResultMarkdown = (branch, reason, url) => markdown(noResultMessage(branch, reason, url), "⚪");
6078
6205
  var pendingMarkdown = (branch, url) => markdown(pendingMessage(branch, url), "⚪");
6206
+ // packages/present/dist/src/stale.js
6207
+ function staleNoteText(why) {
6208
+ const newer = why.includes("newer-run-set");
6209
+ const rules = why.some((w) => w === "core-version" || w === "config");
6210
+ if (newer && rules)
6211
+ return "Result from an older run set and older rules.";
6212
+ if (newer)
6213
+ return "Result from an older run set; a newer one has arrived.";
6214
+ if (rules)
6215
+ return "Result from older rules; it is not recomputed yet.";
6216
+ return "Result from older inputs.";
6217
+ }
6218
+ var staleFlagText = (why) => {
6219
+ const note = staleNoteText(why).replace(/\.$/, "");
6220
+ return `${note.charAt(0).toLowerCase()}${note.slice(1)}`;
6221
+ };
6222
+ var STALE_MARK = "stale result";
6079
6223
  // apps/agent/src/upload.ts
6080
6224
  import { join as join4 } from "node:path";
6081
6225
  var MAX_RATE_RETRIES = 5;
6082
6226
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6227
+ var pause = (ms) => sleep(ms);
6228
+
6083
6229
  class UploadError extends Error {
6230
+ status;
6231
+ constructor(message, status) {
6232
+ super(message);
6233
+ this.status = status;
6234
+ }
6084
6235
  }
6085
6236
  async function call(f, base, path, headers, body, method = "POST") {
6086
6237
  let res;
@@ -6092,7 +6243,7 @@ async function call(f, base, path, headers, body, method = "POST") {
6092
6243
  ...method === "POST" ? { body: JSON.stringify(body ?? {}) } : {}
6093
6244
  });
6094
6245
  } catch (e) {
6095
- throw new UploadError(`${method} ${path}: ${e.message}`);
6246
+ throw new UploadError(`${method} ${path}: ${e.message}`, 0);
6096
6247
  }
6097
6248
  if (res.status !== 429 || attempt >= MAX_RATE_RETRIES)
6098
6249
  break;
@@ -6102,7 +6253,7 @@ async function call(f, base, path, headers, body, method = "POST") {
6102
6253
  }
6103
6254
  const text = await res.text();
6104
6255
  if (res.status < 200 || res.status >= 300)
6105
- throw new UploadError(`${method} ${path} -> ${res.status} ${text.slice(0, 200)}`);
6256
+ throw new UploadError(`${method} ${path} -> ${res.status} ${text.slice(0, 200)}`, res.status);
6106
6257
  try {
6107
6258
  return text ? JSON.parse(text) : {};
6108
6259
  } catch {
@@ -6239,13 +6390,35 @@ async function createRunSet(t, body) {
6239
6390
  throw new UploadError("POST /v1/run-sets: response has no run_set_id (is the URL the KeepSnappy API?)");
6240
6391
  return { run_set_id: r.run_set_id, dashboard_url: String(r.dashboard_url ?? "") };
6241
6392
  }
6393
+ var UNKNOWN_OUTCOME = new Set([0, 502, 503, 504]);
6394
+ var CONFIRM_READS = 6;
6395
+ var CONFIRM_WAIT_MS = 1e4;
6242
6396
  async function completeRunSet(t, id, verdicts) {
6243
6397
  const path = `/v1/run-sets/${encodeURIComponent(id)}/complete`;
6244
- const r = await call(t.fetch ?? fetch, t.url, path, keyHeader(t), { verdicts });
6398
+ let r;
6399
+ try {
6400
+ r = await call(t.fetch ?? fetch, t.url, path, keyHeader(t), { verdicts });
6401
+ } catch (e) {
6402
+ const closed = e instanceof UploadError && UNKNOWN_OUTCOME.has(e.status ?? -1) ? await confirmedClose(t, id) : null;
6403
+ if (closed)
6404
+ return closed;
6405
+ throw e;
6406
+ }
6245
6407
  if (!r.run_set_id)
6246
6408
  throw new UploadError(`POST ${path}: response has no run_set_id`);
6247
6409
  return r;
6248
6410
  }
6411
+ async function confirmedClose(t, id) {
6412
+ const path = `/v1/run-sets/${encodeURIComponent(id)}`;
6413
+ for (let i = 0;i < CONFIRM_READS; i++) {
6414
+ if (i)
6415
+ await pause(CONFIRM_WAIT_MS);
6416
+ const s = await call(t.fetch ?? fetch, t.url, path, keyHeader(t), undefined, "GET").catch(() => null);
6417
+ if (s?.run_set_id && (s.status === "processed" || s.status === "failed"))
6418
+ return s;
6419
+ }
6420
+ return null;
6421
+ }
6249
6422
  async function createIngestRun(t, body) {
6250
6423
  const r = await call(t.fetch ?? fetch, t.url, "/v1/runs", keyHeader(t), body);
6251
6424
  if (!r.run_id || !r.run_token)
@@ -10188,8 +10361,14 @@ ${outcome.summary}`);
10188
10361
  const info = { device, ...git.branch ? { branch: git.branch } : {}, ...git.commit ? { commit: git.commit } : {} };
10189
10362
  await deps.fs.writeFile(join9(out, RUN_INFO), `${JSON.stringify({ ...info, ...runSet ? { run_set_id: runSet.run_set_id } : {} }, null, 2)}
10190
10363
  `);
10191
- if (!api)
10364
+ if (!api) {
10365
+ if (!runs.some((run) => run.valid)) {
10366
+ const reasons = [...new Set(runs.map((run) => run.invalid_reason).filter(Boolean))];
10367
+ rt.print(`No valid run was recorded: ${runs.length === 0 ? "no runs were attempted" : `${runs.length} ${runs.length === 1 ? "run" : "runs"} invalid${reasons.length ? ` (${reasons.join(", ")})` : ""}`}.`);
10368
+ return EXIT.infra;
10369
+ }
10192
10370
  return EXIT.ok;
10371
+ }
10193
10372
  const up = await uploadRuns(deps, out, runs, {
10194
10373
  ingestUrl: api.url,
10195
10374
  key: api.key,
@@ -10714,7 +10893,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
10714
10893
  import { dirname as dirname2, join as join11, relative as relative2, resolve as resolve4 } from "node:path";
10715
10894
  import { fileURLToPath } from "node:url";
10716
10895
  function driverVersion() {
10717
- const built = "0.1.0-alpha.18";
10896
+ const built = "0.1.0-alpha.19";
10718
10897
  if (built)
10719
10898
  return built;
10720
10899
  let dir = dirname2(fileURLToPath(import.meta.url));
@@ -10795,6 +10974,9 @@ var CLAUSES = {
10795
10974
  commit: { c: 0.8326, k95: 1.7675, k99: 2.8108 },
10796
10975
  component: { c: 0.6481, k95: 1.8075, k99: 2.6648 }
10797
10976
  }),
10977
+ "T-NOISE-JANK": row("T-NOISE-JANK", "The judged `jank` rule: the no-change noise curve of jank time, as T-WARN-NOISE; at a main median of 0, Slightly worse past zeroWarn and Worse past zeroError.", "ms", ">", "W8 option A, Arek 2026-10-26 [ctl-arek-jank-time]; fitted by the 2026-10-16 method (dedup-nomi, PR 3x3 vs main 3x3, every split) on pack v3 aa-ni (S23 TapIn, 10 sessions, 4200 splits, 19 places with a median above 0, R² -0.27; the 10-16 S23 and iPhone sessions are no longer stored); zeroWarn = the largest d99 at a median of 0 over the A/A pools with an export (farm16 Pixel 7), zeroError = zeroWarn x k99 / k95", true, {
10978
+ jank: { c: 0.9159, k95: 2.5757, k99: 3.2072, zeroWarn: 10.38, zeroError: 12.92 }
10979
+ }),
10798
10980
  "T-REL": row("T-REL", "The relative part of both tiers when a rule has no noise curve; the deciding config's tiers cap it at the error ms, so no input reaches it.", "share", ">", "REQ-MET-26, controller 2026-10-16", false, { rel: 0.3 }),
10799
10981
  "J-MEMORY-RETAINED": row("J-MEMORY-RETAINED", "Memory the journey kept after the end GC: Slightly worse past max(warn MB, warn % of main), Worse past max(error MB, error % of main), on every device.", "MB", ">", "REQ-MET-4, Arek 2026-10-20 ~08:00, pair B (standing-rulings.md); provisional", true, { warn: { abs: 20, rel: 0.1 }, error: { abs: 150, rel: 0.3 } }),
10800
10982
  "E-REACT-COUNTS": row("E-REACT-COUNTS", "Commit and render counts are evidence past max(floor, +50 % of main).", "count", ">", "REQ-MET-13, Arek 2026-10-20 (morning rulings, Layer 2); provisional", true, { commits: { rel: 0.5, abs: 2 }, renders: { rel: 0.5, abs: 10 } }),
@@ -10802,6 +10984,7 @@ var CLAUSES = {
10802
10984
  "E-BACKGROUND": row("E-BACKGROUND", "Background places are evidence past max(floor, % of main).", "ms", ">", "REQ-MEAS-32; the number has no ruling", true, { background_commit: FLOOR, background_render: FLOOR, background_component: FLOOR }),
10803
10985
  "E-LAUNCH": row("E-LAUNCH", "The launch screen is evidence past max(5 ms, +30 % of main).", "ms", ">", "REQ-VER-15; floor adopted 2026-10-21 from config.ts F-13, [ctl-stab-noold-1625]", true, { launch_commit: FLOOR, launch_render: FLOOR, launch_component: FLOOR }),
10804
10986
  "E-STEP": row("E-STEP", "A declared step is evidence past max(5 ms, +30 % of main).", "ms", ">", "REQ-MET-14; floor adopted 2026-10-21 from config.ts F-13, [ctl-stab-noold-1625]", true, { step_commit: FLOOR, step_render: FLOOR, step_component: FLOOR }),
10987
+ "E-COMMIT-MAX": row("E-COMMIT-MAX", "The slowest commit at a judged window is evidence past max(5 ms, +30 % of main) while jank time is judged.", "ms", ">", "W8 option A, Arek 2026-10-26 [ctl-arek-jank-time]: commit_max_ms stays shown as evidence", true, { commit_max: FLOOR }),
10805
10988
  "E-TOUCH": row("E-TOUCH", "Touch response is evidence past max(10 ms, +20 % of main).", "ms", ">", 'ruling "touch response is not judged" (REQ-MET-24)', true, { touch: { rel: 0.2, abs: 10 } }),
10806
10989
  "E-FREEZE": row("E-FREEZE", "The longest freeze (window, journey) is evidence past max(17 ms, +50 % of main).", "ms", ">", NO_RULING, true, { freeze: { rel: 0.5, abs: 17 }, freeze_journey: { rel: 0.5, abs: 17 } }),
10807
10990
  "E-MEMORY-PLACE": row("E-MEMORY-PLACE", "Memory added at a place is evidence past this many MB (screens have their own), on every device.", "MB", ">", 'REQ-MET-4 amendment 2026-10-18 ~21:10 and ~22:20 ("abs 2 MB, screens 10 MB")', true, { memory_added: { abs: 2 }, memory_added_screen: { abs: 10 } }),
@@ -10857,7 +11040,8 @@ var REACT_MEASURES = [
10857
11040
  "self_ms_top",
10858
11041
  "component_ms",
10859
11042
  "dimension:component",
10860
- "dimension:updater"
11043
+ "dimension:updater",
11044
+ "jank_ms"
10861
11045
  ];
10862
11046
  var WINDOW_MODEL_AFFECTS = [
10863
11047
  "commits",
@@ -10882,7 +11066,8 @@ var WINDOW_MODEL_AFFECTS = [
10882
11066
  "memory_left_mb",
10883
11067
  "retained_mb_per_cycle",
10884
11068
  "dimension:component",
10885
- "dimension:updater"
11069
+ "dimension:updater",
11070
+ "jank_ms"
10886
11071
  ];
10887
11072
  var MEASURES_AT = (kind) => [...Object.keys(MEASURES).filter((id) => MEASURES[id].at.includes(kind)), "dimension:component", "dimension:updater"];
10888
11073
  var SPLIT_AFFECTS = [
@@ -11048,6 +11233,16 @@ var DRIVER_RELEASES = [
11048
11233
  declares: [{ affects: DRAG_TIME_AND_CPU, places: ["drag"], platforms: ["ios"] }],
11049
11234
  uncalibrated: false,
11050
11235
  source: "R-SCROLL-BUILD (#355 one scroll primitive and the counted gesture retry, #356 the scroll fault matrix). Android declares nothing: on a phone that takes every gesture the driver sends the same requests, bodies and waits as alpha.17 (the whole command stream is pinned). iOS: each checked drag (every to-top drag of flingUntilShown/passChecked and the first drag of each scrollToEnd pass) gains two frame reads of the held row, one right before and one right after it, so a drag window's time and CPU can change; its React render, commits and memory stay compared. A phone that drops a gesture (BrowserStack Galaxy S23, S25 Ultra) gets that gesture once more, as a counted harness defect (REQ-FARM-35)."
11236
+ },
11237
+ {
11238
+ version: "0.1.0-alpha.19",
11239
+ previous: "0.1.0-alpha.18",
11240
+ declares: [
11241
+ { affects: MEASURES_AT("drag"), places: ["drag"], platforms: ["ios"] },
11242
+ { affects: MEASURES_AT("step"), places: ["step"], platforms: ["ios"] }
11243
+ ],
11244
+ uncalibrated: false,
11245
+ source: "#393 (fix/keepsnappy-clamped-row-drag-travel, appium-driver CHANGELOG 2026-10-27): on iOS a row-to-top drag whose end safeBottomY clamps (chat history on every iPhone with a home indicator) starts 33 pt higher, so it travels 33 pt further in the same 400 ms (a faster release, a longer coast), with one list lookup and two frame reads right before it. Every measure of that drag window can move, React work included (more rows scroll by), and so can the step whose scroll steps contain it. Android and unclamped drags send the same requests as alpha.18 (pinned): declares nothing there (REQ-FARM-35)."
11051
11246
  }
11052
11247
  ];
11053
11248
  var UNSTABLE_NOISY = {
@@ -11075,7 +11270,7 @@ class ConfigError2 extends Error {
11075
11270
  var noRow = (id) => {
11076
11271
  throw new ConfigError2([`${id}: no row in the clause table`]);
11077
11272
  };
11078
- var NOISE_FITS = CLAUSES["T-WARN-NOISE"].value;
11273
+ var NOISE_FITS = { ...CLAUSES["T-WARN-NOISE"].value, ...CLAUSES["T-NOISE-JANK"].value };
11079
11274
  var TIER_REL = 0.3;
11080
11275
  var TIER_UPDATE_MS = { warnMs: 5, errorMs: 15 };
11081
11276
  var TIER_WORK_MS = { warnMs: 10, errorMs: 30 };
@@ -11107,17 +11302,16 @@ function withClauseValues(base) {
11107
11302
  throw new ConfigError2(["better: missing from base config"]);
11108
11303
  const warnMs = CLAUSES["T-WARN-MS"].value;
11109
11304
  const errorMs = CLAUSES["TIER-ERROR"].value;
11110
- const noise = CLAUSES["T-WARN-NOISE"];
11111
11305
  const memory = CLAUSES["J-MEMORY-RETAINED"];
11112
11306
  const judgedByNoise = (r) => {
11113
11307
  const fit = NOISE_FITS[r.id] ?? noRow(r.id);
11114
- const n = { a: 0, b: fit.c, power: 0.5, lift: fit.k95 };
11308
+ const n = { a: 0, b: fit.c, power: 0.5, lift: fit.k95, ...fit.zeroWarn !== undefined ? { atZero: fit.zeroWarn } : {} };
11115
11309
  return {
11116
11310
  ...r,
11117
11311
  threshold: [{ abs: warnMs.update, noise: n }],
11118
11312
  thresholdByWork: { entry: [{ abs: warnMs.work, noise: n }], mount: [{ abs: warnMs.work, noise: n }] },
11119
- errorNoise: { ...n, lift: fit.k99 },
11120
- uncalibrated: noise.uncalibrated
11313
+ errorNoise: { a: 0, b: fit.c, power: 0.5, lift: fit.k99, ...fit.zeroError !== undefined ? { atZero: fit.zeroError } : {} },
11314
+ uncalibrated: (r.id in CLAUSES["T-NOISE-JANK"].value ? CLAUSES["T-NOISE-JANK"] : CLAUSES["T-WARN-NOISE"]).uncalibrated
11121
11315
  };
11122
11316
  };
11123
11317
  const evidence = (r) => {
@@ -11125,11 +11319,12 @@ function withClauseValues(base) {
11125
11319
  const at = r.thresholdAt && Object.fromEntries(Object.keys(r.thresholdAt).map((kind) => [kind, [part((EVIDENCE_BARS.get(`${r.id}_${kind}`) ?? noRow(`${r.id}_${kind}`)).bar)]]));
11126
11320
  return { ...r, threshold: [part(e.bar)], ...at ? { thresholdAt: at } : {}, uncalibrated: e.uncalibrated };
11127
11321
  };
11128
- const rules = base.rules.map((r) => {
11322
+ const valued = (r) => {
11129
11323
  if (r.id === "memory_retained")
11130
11324
  return { ...r, threshold: [part(memory.value.warn)], uncalibrated: memory.uncalibrated };
11131
11325
  return r.role === "judged" ? judgedByNoise(r) : evidence(r);
11132
- });
11326
+ };
11327
+ const rules = base.rules.map(valued);
11133
11328
  const frames = (id) => ({ above: { frames: CLAUSES[id].value.frames }, uncalibrated: CLAUSES[id].uncalibrated });
11134
11329
  const entry = CLAUSES["P-HEAVY-ENTRY"];
11135
11330
  const unstable = CLAUSES["U-NOISY"];
@@ -11171,12 +11366,27 @@ function withClauseValues(base) {
11171
11366
  minMainRuns: unstable.value.minMainRuns,
11172
11367
  minSessions: unstable.value.minSessions,
11173
11368
  uncalibrated: unstable.uncalibrated,
11174
- curve: Object.fromEntries(Object.keys(u.curve).map((id) => [id, (NOISE_FITS[id] ?? noRow(`unstable curve ${id}`)).c]))
11369
+ curve: Object.fromEntries(Object.keys(u.curve).map((id) => [id, (NOISE_FITS[id] ?? noRow(`unstable curve ${id}`)).c])),
11370
+ ...u.reads ? { reads: u.reads.map(valued) } : {}
11175
11371
  }))
11176
11372
  };
11177
11373
  }
11374
+ var COMMIT_MAX_JUDGED = noiseJudged("commit", { measure: "commit_max_ms" });
11375
+ var RENDER_JUDGED = noiseJudged("render", { measure: "render_ms" });
11376
+ var COMPONENT_JUDGED = noiseJudged("component", { dimension: "component", measure: "self_ms" });
11377
+ var MEMORY_RETAINED = {
11378
+ id: "memory_retained",
11379
+ role: "judged",
11380
+ on: { measure: "mem.retained_growth_mb" },
11381
+ places: JOURNEY,
11382
+ devices: ANY_DEVICE,
11383
+ threshold: [{ rel: 0.1, abs: 20 }],
11384
+ uncalibrated: true,
11385
+ source: MEMORY_PAIR_SOURCE
11386
+ };
11387
+ var JANK_SOURCE = `W8 option A, Arek 2026-10-26 ("Opcja A jest git", [ctl-arek-jank-time]): jank time (the window's commit times past one 60 Hz frame, added up) is judged in place of the slowest commit, so one spike no longer decides and jitter below a frame counts 0; curve T-NOISE-JANK; deciding since simple-4.26 after the pack v3 replay (A/A Slightly worse 58 -> 46 of 840 on aa-ni, doses 54 of 54)`;
11178
11388
  var SHAPE = {
11179
- id: "simple-4.25+noise95-99",
11389
+ id: "simple-4.26+noise95-99",
11180
11390
  minRuns: 2,
11181
11391
  baseline: { sessions: 3, ...GUESS("Q4 / REQ-VER-33: main's newest 3 sessions against one PR session (the quieter setting of the plan 58 simulation)") },
11182
11392
  repeats: { kind: "every-pr-beyond-every-main" },
@@ -11202,9 +11412,10 @@ var SHAPE = {
11202
11412
  },
11203
11413
  calibrated: { platforms: ["ios", "android"], uncalibrated: false, source: 'REQ-FARM-26: Android moved in by Arek ("Ok", 2026-10-17) after the S23 A/A, 5 sessions × 3 runs: 0 z 25 false worse under the deciding rule (stats-android/aa/aa-report.md)' },
11204
11414
  rules: [
11205
- noiseJudged("commit", { measure: "commit_max_ms" }),
11206
- noiseJudged("render", { measure: "render_ms" }),
11207
- noiseJudged("component", { dimension: "component", measure: "self_ms" }),
11415
+ { ...COMMIT_MAX_JUDGED, id: "jank", on: { measure: "jank_ms" }, source: JANK_SOURCE },
11416
+ evidenceRule("commit_max", { measure: "commit_max_ms" }, JUDGED_WINDOWS, [], "W8 option A: the slowest commit is shown, jank time is judged", { devices: ANY_DEVICE }),
11417
+ RENDER_JUDGED,
11418
+ COMPONENT_JUDGED,
11208
11419
  { ...evidenceRule("commits", { measure: "commits" }, REACT_PLACES, [{ rel: 0.5, abs: 2 }], "render and commit counts are evidence only (ruling a)"), source: COUNT_SOURCE },
11209
11420
  { ...evidenceRule("renders", { measure: "renders" }, REACT_PLACES, [{ rel: 0.5, abs: 10 }], "render and commit counts are evidence only (ruling a)"), source: COUNT_SOURCE },
11210
11421
  evidenceRule("render_upper", { measure: "render_ms_upper" }, WINDOWS, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "REQ-MEAS-32 timers, option A (Arek 2026-10-17): judged plus timer-carried render time is evidence with the render threshold; render_ms decides"),
@@ -11230,16 +11441,7 @@ var SHAPE = {
11230
11441
  evidenceRule("gc", { measure: "gc_ms" }, SHOWN2, [{ rel: 0.5, abs: 2 }], "v5.3 warn level"),
11231
11442
  evidenceRule("requests", { measure: "net_requests" }, SHOWN2, [{ rel: 0.5, abs: 1 }], "v5.3 warn level"),
11232
11443
  evidenceRule("js_cpu", { measure: "js_cpu_ms" }, SHOWN2, [{ rel: 0.5, abs: 5 }], "v5.3 warn level"),
11233
- {
11234
- id: "memory_retained",
11235
- role: "judged",
11236
- on: { measure: "mem.retained_growth_mb" },
11237
- places: JOURNEY,
11238
- devices: ANY_DEVICE,
11239
- threshold: [{ rel: 0.1, abs: 20 }],
11240
- uncalibrated: true,
11241
- source: MEMORY_PAIR_SOURCE
11242
- }
11444
+ MEMORY_RETAINED
11243
11445
  ],
11244
11446
  problems: [
11245
11447
  {
@@ -11286,7 +11488,8 @@ var SHAPE = {
11286
11488
  unstable: [{
11287
11489
  ...UNSTABLE_NOISY,
11288
11490
  step: "own",
11289
- source: `${UNSTABLE_NOISY.source}; Arek 2026-10-26 ("A wygląda kusząco. Oddaj do impl.", ctl-arek-met29-own-step): the step is the place's own work kind's (entry and in-screen mount 10 ms, update 5 ms), the smallest only when the kind is unknown`
11491
+ reads: [COMMIT_MAX_JUDGED, RENDER_JUDGED, COMPONENT_JUDGED, MEMORY_RETAINED],
11492
+ source: `${UNSTABLE_NOISY.source}; Arek 2026-10-26 ("A wygląda kusząco. Oddaj do impl.", ctl-arek-met29-own-step): the step is the place's own work kind's (entry and in-screen mount 10 ms, update 5 ms), the smallest only when the kind is unknown; ctl-arek-jank-time: noisiness still reads the slowest commit, so a place is unstable exactly when it was before jank time was judged`
11290
11493
  }],
11291
11494
  judgeFrom: { mainSessions: 3, uncalibrated: false, source: 'Arek 2026-10-18 (REQ-VER-41): a phone is judged only when main has at least 3 sessions on that phone and journey; with fewer the result is "not enough main yet", not judged, not blocking, values shown. Replay (agents/release/replay, d22): 1 PR vs 2 main on the S23 night pool gave 13 z 60 false worse+warning, 1 vs 3 gave 0' },
11292
11495
  measuredWith: [
@@ -12743,6 +12946,7 @@ import { parseArgs as parseArgs3 } from "node:util";
12743
12946
  var SHORT_SESSIONS = 3;
12744
12947
  var SHORT_POINTS = 5;
12745
12948
  var SHORT_PLACES = 5;
12949
+ var SHORT_COMPARE_MEASURES = 3;
12746
12950
  var SHORT_FORM_NOTES = {
12747
12951
  list_findings: "the how-sure lines, the place lines of findings that are not Worse and the lanes that were judged",
12748
12952
  get_finding: "the why, seen and layer lines, the component lines, the memory evidence lines other than the judged measure and the prompt",
@@ -12752,7 +12956,8 @@ var SHORT_FORM_NOTES = {
12752
12956
  place_trend: `the sessions older than the newest ${SHORT_SESSIONS} and the history restarts among them (unknown restarts always stay)`,
12753
12957
  window_history: `the runs older than the newest ${SHORT_POINTS}`,
12754
12958
  pr_status: "the per-journey dashboard links and the heat note",
12755
- list_problems: `the work and component lines, the dashboard links and the problems after the first ${SHORT_PLACES}`
12959
+ list_problems: `the work and component lines, the dashboard links and the problems after the first ${SHORT_PLACES}`,
12960
+ compare_runs: `the component lines and the measures beyond each place's ${SHORT_COMPARE_MEASURES} times that moved most`
12756
12961
  };
12757
12962
  var andList = (xs) => xs.length < 2 ? xs[0] ?? "" : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`;
12758
12963
 
@@ -12859,6 +13064,7 @@ function measuredLines(l, r, opts) {
12859
13064
  };
12860
13065
  if (m.lanes.length === 1) {
12861
13066
  r.both(`${scope}: newest measured ${f.branch ? "" : `on ${n.branch}, `}${lane(n)}; ${plural2(m.run_sets, "run set")} measured.`);
13067
+ r.both(...(n.comparison_notes ?? []).map((line) => ` ${line}`));
12862
13068
  r.full(...(n.session_speed ?? []).map((line) => ` ${line}`));
12863
13069
  r.both(...laneContextLines(n.context, n.journey, n.phone_name, opts.verbose, opts.form === "short"));
12864
13070
  return [];
@@ -12866,9 +13072,10 @@ function measuredLines(l, r, opts) {
12866
13072
  const shown = m.lanes.slice(0, LANES_SHOWN);
12867
13073
  r.add(shown.some((x) => !x.judged || !!x.context), `${scope}: ${plural2(m.run_sets, "run set")} measured; the newest of each journey × phone:`);
12868
13074
  for (const x of shown) {
12869
- const keep = !x.judged || !!x.context;
13075
+ const keep = !x.judged || !!x.context || !!x.comparison_notes;
12870
13076
  r.add(keep, ` ${x.journey} on ${x.phone_name}${f.branch ? "" : ` (${x.branch})`}: ${lane(x)}`);
12871
13077
  r.add(keep, ...resultContextLines(x.context, opts.verbose, opts.form === "short").map((line) => ` ${line}`));
13078
+ r.add(keep, ...(x.comparison_notes ?? []).map((line) => ` ${line}`));
12872
13079
  r.full(...(x.session_speed ?? []).map((line) => ` ${line}`));
12873
13080
  }
12874
13081
  if (m.lanes.length > shown.length)
@@ -12932,10 +13139,11 @@ function formatBranchStatus(s, opts = {}) {
12932
13139
  if (s.lanes.length)
12933
13140
  r.both("");
12934
13141
  for (const l of s.lanes) {
12935
- const shown = l.result === "blocks" ? `${l.word} (blocks)` : badgedWord(l.word, l.blocking);
13142
+ const shown = `${l.result === "blocks" ? `${l.word} (blocks)` : badgedWord(l.word, l.blocking)}${l.stale ? ` (${STALE_FLAG})` : ""}`;
12936
13143
  const sha = l.commit?.sha?.slice(0, 7);
12937
13144
  r.both(` ${l.journey_title ?? l.journey} on ${l.phone_name}: ${shown} · ${sha ? `commit ${sha}` : "commit not recorded"} (${when(l.at)})${l.in_newest_run_set ? "" : " · older run set"}${l.waiting_for_reprocess ? `; ${waitingWords(l.waiting_for_reprocess)}` : ""}`);
12938
13145
  r.both(...resultContextLines(l.context, opts.verbose, opts.form === "short"));
13146
+ r.both(...(l.comparison_notes ?? []).map((line) => ` ${line}`));
12939
13147
  r.full(...(l.session_speed ?? []).map((line) => ` ${line}`));
12940
13148
  for (const f of l.worst)
12941
13149
  r.both(` ${f.id} ${f.headline}`);
@@ -12945,6 +13153,7 @@ function formatBranchStatus(s, opts = {}) {
12945
13153
  r.full(` ${l.link}`);
12946
13154
  }
12947
13155
  const links = s.lanes.filter((l) => l.link).length;
13156
+ const staleLanes = s.problems?.stale_lanes ?? 0;
12948
13157
  const heated = s.counts.heat_runs ?? 0;
12949
13158
  const more = [
12950
13159
  [false, heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural2(heated, "run")} (information, never blocks)` : ""],
@@ -12953,6 +13162,7 @@ function formatBranchStatus(s, opts = {}) {
12953
13162
  s.not_judged_findings ? `${plural2(s.not_judged_findings, "finding")} not judged in the newest run set; they may still be there: keepsnappy findings --branch ${s.branch} --state not-judged` : ""
12954
13163
  ],
12955
13164
  [true, statusProblemsLine(s)],
13165
+ [true, staleLanes ? `${staleWords(staleLanes)}: ${staleLanes === 1 ? "its word and problems are" : "their words and problems are"} of older rules` : ""],
12956
13166
  [true, s.problems?.unread_lanes ? `${unreadWords(s.problems.unread_lanes)} for problems: ${staleResult(s.problems.unread_lanes)}` : ""],
12957
13167
  [true, s.links.overview ? `Dashboard: ${s.links.overview}` : ""],
12958
13168
  [true, s.worst.length ? "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt" : ""]
@@ -12968,9 +13178,11 @@ function statusProblemsLine(s) {
12968
13178
  const p = s.problems;
12969
13179
  if (!p?.count)
12970
13180
  return "";
12971
- return `${plural2(p.places, "place")} over budget in this branch’s own runs (${p.far_count ? `${p.far_count} far over budget; ` : ""}not a regression, never blocks): list_problems, or keepsnappy problems --branch ${s.branch}`;
13181
+ return `${problemCountsText(p, "this branch’s")}. Not a regression, never blocks: list_problems, or keepsnappy problems --branch ${s.branch}`;
12972
13182
  }
12973
13183
  var unreadWords = (n) => `${n} journey × phone not read`;
13184
+ var STALE_FLAG = staleFlagText(["config"]);
13185
+ var staleWords = (n) => `${n} journey × phone from a stale result (${STALE_FLAG})`;
12974
13186
  var staleResult = (n) => `${n === 1 ? "its stored result is" : "their stored results are"} not current`;
12975
13187
  function formatProblemList(l, opts = {}) {
12976
13188
  const r = new Rows;
@@ -12987,7 +13199,7 @@ function formatProblemList(l, opts = {}) {
12987
13199
  return r.render(opts.form);
12988
13200
  }
12989
13201
  if (c.count)
12990
- r.both(`${b}: ${plural2(c.places, "place")} over budget in ${b === l.baseline_branch ? "main’s" : "this branch’s"} own runs${c.far_count ? ` (${c.far_count} far over budget)` : ""}`);
13202
+ r.both(`${b}: ${problemCountsText(c, b === l.baseline_branch ? "main’s" : "this branch’s")}`);
12991
13203
  else if (read)
12992
13204
  r.both(`${b}: no problem over budget in the newest run set (${read} journey × phone read)`);
12993
13205
  else
@@ -12995,10 +13207,12 @@ function formatProblemList(l, opts = {}) {
12995
13207
  r.both(l.note);
12996
13208
  if (read && unread)
12997
13209
  r.both(`${unreadWords(unread)}: ${staleResult(unread)}, so nothing is known about ${unread === 1 ? "its" : "their"} problems.`);
13210
+ if (c.stale_lanes)
13211
+ r.both(`${staleWords(c.stale_lanes)}: ${c.stale_lanes === 1 ? "its" : "their"} problems are listed, each marked “${STALE_MARK}”.`);
12998
13212
  l.problems.forEach((p, i) => {
12999
13213
  const keep = i < SHORT_PLACES;
13000
13214
  r.add(keep, "", `${l.offset + i + 1}. ${p.text}: ${p.place_name}${opts.verbose ? ` (${p.place})` : ""}${p.intermittent ? ` — ${p.note}, not counted` : ""}`);
13001
- r.add(keep, ` ${p.journey_title ?? p.journey} on ${p.phone_name}, ${p.runs_text}${p.tag_word ? `, ${p.tag_word}` : ""}`);
13215
+ r.add(keep, ` ${p.journey_title ?? p.journey} on ${p.phone_name}, ${p.runs_text}${p.tag_word ? `, ${p.tag_word}` : ""}${p.stale ? `, ${STALE_MARK}` : ""}`);
13002
13216
  const top = p.top_components[0];
13003
13217
  const topName = componentReadNames(p.top_components)[0];
13004
13218
  const detail = [p.work, top ? `heaviest ${topName}${opts.verbose && top.key ? ` (${top.key})` : ""} ${top.self_text}` : null, p.new_component ? NEW_IN_PR_CHIP : null, p.range_text ? `${p.range_text} between runs` : null].filter(Boolean);
@@ -13021,56 +13235,59 @@ function formatProblemList(l, opts = {}) {
13021
13235
  }
13022
13236
  var commitText = (c) => !c ? "commit not recorded" : c.title ? `“${c.title}”${c.author ? ` by ${c.author}` : ""}${c.sha ? ` (${c.sha.slice(0, 7)})` : ""}` : c.sha ? c.sha.slice(0, 7) : "commit not recorded";
13023
13237
  function formatCompare(r, opts = {}) {
13024
- const out = [
13025
- `Run A: ${r.a.external_id} on ${r.a.branch ?? "?"}, ${commitText(r.a.commit ? { sha: r.a.commit_sha, ...r.a.commit } : { sha: r.a.commit_sha, title: null, author: null })}`,
13026
- `Run B: ${r.b.external_id} on ${r.b.branch ?? "?"}, ${commitText(r.b.commit ? { sha: r.b.commit_sha, ...r.b.commit } : { sha: r.b.commit_sha, title: null, author: null })}`,
13027
- `${r.same_journey ? "Same journey" : "Different journeys"}, ${r.same_phone ? "same phone" : "different phones"}. ${r.note}`
13028
- ];
13238
+ const rows = new Rows;
13239
+ rows.both(`Run A: ${r.a.external_id} on ${r.a.branch ?? "?"}, ${commitText(r.a.commit ? { sha: r.a.commit_sha, ...r.a.commit } : { sha: r.a.commit_sha, title: null, author: null })}`, `Run B: ${r.b.external_id} on ${r.b.branch ?? "?"}, ${commitText(r.b.commit ? { sha: r.b.commit_sha, ...r.b.commit } : { sha: r.b.commit_sha, title: null, author: null })}`, `${r.same_journey ? "Same journey" : "Different journeys"}, ${r.same_phone ? "same phone" : "different phones"}. ${r.note}`);
13029
13240
  if (r.setup_differs.length)
13030
- out.push(`Measured differently: ${r.setup_differs.map((d) => `${d.field} ${JSON.stringify(d.a)} → ${JSON.stringify(d.b)}`).join(", ")}`);
13241
+ rows.both(`Measured differently: ${r.setup_differs.map((d) => `${d.field} ${JSON.stringify(d.a)} → ${JSON.stringify(d.b)}`).join(", ")}`);
13031
13242
  for (const w of r.windows) {
13032
13243
  const ms = w.metrics.filter((m) => m.a !== null || m.b !== null);
13033
13244
  const c = w.component_page ?? w.components;
13034
13245
  if (!ms.length && !c)
13035
13246
  continue;
13036
- out.push("", `${w.window_name}${opts.verbose ? ` (${w.window})` : ""}`);
13247
+ rows.both("", `${w.window_name}${opts.verbose ? ` (${w.window})` : ""}`);
13037
13248
  const singles = [...c?.added ?? [], ...c?.removed ?? [], ...w.component_page?.uncompared?.a ?? [], ...w.component_page?.uncompared?.b ?? []];
13038
13249
  const components = [...c?.rows ?? [], ...singles];
13039
13250
  const names = componentReadNames(components.map((row) => ({ key: row.key, name: row.component })));
13040
13251
  const shown = new Map(components.map((row, i) => [row.key, names[i]]));
13041
13252
  for (const row of c?.rows ?? [])
13042
- out.push(` ${shown.get(row.key)}: ${row.self_ms_a === null ? "–" : exactComponentMs(row.self_ms_a)} → ${row.self_ms_b === null ? "–" : exactComponentMs(row.self_ms_b)}${opts.verbose ? ` (${row.key})` : ""}`);
13043
- for (const [label, rows] of [
13253
+ rows.full(` ${shown.get(row.key)}: ${row.self_ms_a === null ? "–" : exactComponentMs(row.self_ms_a)} → ${row.self_ms_b === null ? "–" : exactComponentMs(row.self_ms_b)}${opts.verbose ? ` (${row.key})` : ""}`);
13254
+ for (const [label, list] of [
13044
13255
  [READ_WORDS.onlyB, c?.added ?? []],
13045
13256
  [READ_WORDS.onlyA, c?.removed ?? []]
13046
13257
  ]) {
13047
- for (const row of rows)
13048
- out.push(` ${shown.get(row.key)}: ${label}; ${row.self_ms === null ? "–" : exactComponentMs(row.self_ms)}${opts.verbose ? ` (${row.key})` : ""}`);
13258
+ for (const row of list)
13259
+ rows.full(` ${shown.get(row.key)}: ${label}; ${row.self_ms === null ? "–" : exactComponentMs(row.self_ms)}${opts.verbose ? ` (${row.key})` : ""}`);
13049
13260
  }
13050
13261
  const page = w.component_page;
13051
13262
  if (page) {
13052
13263
  for (const side of ["a", "b"]) {
13053
13264
  if (!page.measured[side])
13054
- out.push(` ${notMeasuredText(side)}`);
13265
+ rows.full(` ${notMeasuredText(side)}`);
13055
13266
  for (const row of page.uncompared?.[side] ?? [])
13056
- out.push(` ${shown.get(row.key)}: ${side === "a" ? READ_WORDS.runA : READ_WORDS.runB} ${row.self_ms === null ? "–" : exactComponentMs(row.self_ms)}${opts.verbose ? ` (${row.key})` : ""}`);
13267
+ rows.full(` ${shown.get(row.key)}: ${side === "a" ? READ_WORDS.runA : READ_WORDS.runB} ${row.self_ms === null ? "–" : exactComponentMs(row.self_ms)}${opts.verbose ? ` (${row.key})` : ""}`);
13057
13268
  }
13058
- out.push(` ${componentPageText(page.page, components.length)}`);
13269
+ rows.full(` ${componentPageText(page.page, components.length)}`);
13059
13270
  } else if (c?.totals)
13060
- out.push(` ${componentListsText(c.totals, { rows: c.rows.length, added: c.added?.length ?? 0, removed: c.removed?.length ?? 0 })}`);
13271
+ rows.full(` ${componentListsText(c.totals, { rows: c.rows.length, added: c.added?.length ?? 0, removed: c.removed?.length ?? 0 })}`);
13061
13272
  if (c?.omissions)
13062
13273
  for (const side of ["a", "b"]) {
13063
13274
  if (page && !page.measured[side])
13064
13275
  continue;
13065
13276
  const o = c.omissions[side];
13066
13277
  if (o.sdkOmitted === null || o.omitted === null || o.sdkOmitted > 0 || o.omitted > 0)
13067
- out.push(` ${omissionText({ run_id: side === "a" ? READ_WORDS.runA : READ_WORDS.runB, ...o })}`);
13278
+ rows.full(` ${omissionText({ run_id: side === "a" ? READ_WORDS.runA : READ_WORDS.runB, ...o })}`);
13068
13279
  }
13280
+ const top = new Set(ms.map((m) => ({ m, by: timeMove(m.metric, m.a, m.b) })).filter((x) => x.by !== null && x.by > 0).sort((x, y) => y.by - x.by).slice(0, SHORT_COMPARE_MEASURES).map((x) => x.m));
13069
13281
  for (const m of ms)
13070
- out.push(` ${m.label.padEnd(28)}${m.a_text.padStart(10)} → ${m.b_text}`);
13282
+ rows.add(top.has(m), ` ${m.label.padEnd(28)}${m.a_text.padStart(10)} → ${m.b_text}`);
13071
13283
  }
13072
- return out.join(`
13073
- `);
13284
+ const p = r.windows_page;
13285
+ if (p) {
13286
+ const order = p.order === "moved" ? ", the ones whose time moved most first" : "";
13287
+ const shownText = r.windows.length ? `Showing places ${p.offset + 1}–${p.offset + r.windows.length} of ${p.total}${order}` : `Showing none of ${p.total} places`;
13288
+ rows.both("", p.next_offset !== null ? `${shownText}; the rest from offset ${p.next_offset} (--offset ${p.next_offset}).` : `${shownText}.`);
13289
+ }
13290
+ return rows.render(opts.form, ["the component lines", `the measures beyond each place's ${SHORT_COMPARE_MEASURES} times that moved most`]);
13074
13291
  }
13075
13292
  function formatHistory(h, opts = {}) {
13076
13293
  const r = new Rows;
@@ -13102,8 +13319,8 @@ function formatHistory(h, opts = {}) {
13102
13319
  }
13103
13320
  function formatCommits(c) {
13104
13321
  return [
13105
- `${plural2(c.commits.length, "measured commit")} (${plural2(c.sessions, "session")}) on ${c.branch}, oldest first:`,
13106
- ...c.commits.map((x) => ` ${when(x.first_measured_at)} ${commitText(x)}${x.pull_request?.number ? `, PR #${x.pull_request.number}` : ""}${x.sessions > 1 ? `; ${x.sessions} sessions, the last ${when(x.last_measured_at)}` : ""}`),
13322
+ `${plural2(c.commits.length, "measured commit")} (${plural2(c.sessions, "session")}) ${c.from_branch ? `from ${c.from_branch} to ${c.branch}` : `on ${c.branch}`}, oldest first:`,
13323
+ ...c.commits.map((x) => ` ${when(x.first_measured_at)} ${commitText(x)}${c.from_branch && x.branch ? ` on ${x.branch}` : ""}${x.pull_request?.number ? `, PR #${x.pull_request.number}` : ""}${x.sessions > 1 ? `; ${x.sessions} sessions, the last ${when(x.last_measured_at)}` : ""}`),
13107
13324
  c.note
13108
13325
  ].join(`
13109
13326
  `);
@@ -13139,7 +13356,7 @@ function formatSessionPlaces(p, opts = {}) {
13139
13356
  return r.both("Stale: its statistics are of another derivation or stats version and are not served until the next recompute rewrites them; nothing is known about its places (not zero).").render(opts.form);
13140
13357
  const by = p.filters.measure;
13141
13358
  const byLabel = p.places.flatMap((x) => x.measures).find((m) => m.measure === by)?.label ?? by;
13142
- r.both(`Stored per-session statistics: no verdict, no comparison. Places by ${byLabel} (${p.filters.sort}), highest first${p.filters.window ? `, matching “${p.filters.window}”` : ""}${p.without_measure ? `; ${p.without_measure} of ${p.total} places ${p.without_measure === 1 ? "has" : "have"} no value for it` : ""}.`, "");
13359
+ r.both(`Stored per-session statistics: no verdict, no comparison. Places by ${byLabel} (${p.filters.sort}), highest first${p.filters.window ? `, matching “${p.filters.window}”` : ""}${p.without_measure ? `; ${p.without_measure} of ${p.total} places ${p.without_measure === 1 ? "has" : "have"} no value for it` : ""}${p.folded_twins ? `; ${foldedTwinsText(p.folded_twins)}` : ""}.`, "");
13143
13360
  let componentLines = 0;
13144
13361
  p.places.forEach((place, i) => {
13145
13362
  const m = place.measures.find((x) => x.measure === by);
@@ -13212,7 +13429,7 @@ function formatMainSessions(h, opts = {}) {
13212
13429
  if (!s.places)
13213
13430
  r.add(k, ` ${MAIN_SUMMARY_PENDING}${s.summary_reason ? ` (${s.summary_reason})` : ""}`);
13214
13431
  else
13215
- r.add(k, ` ${MAIN_TIERS_WORST_FIRST.map((tier) => `${MAIN_TIER_WORDS[tier]}: ${s.places[tier]}`).join("; ")}${s.cant_reason ? `; ${mainSessionCantWords(s.places.cant, s.cant_reason, baseline)}` : ""}`);
13432
+ r.add(k, ` ${MAIN_TIERS_WORST_FIRST.map((tier) => `${MAIN_TIER_WORDS[tier]}: ${s.places[tier]}`).join("; ")}${s.cant_reason ? `; ${mainSessionCantWords(s.places.cant, s.cant_reason, baseline)}` : s.cant_range ? `; ${mainSessionCantRangeWords(s.places.cant, s.cant_range, baseline)}` : ""}`);
13216
13433
  if (s.waiting_sessions)
13217
13434
  r.add(k, ` ${waitingSessionsWords(s.waiting_sessions)}`);
13218
13435
  if (s.memory)
@@ -13254,9 +13471,13 @@ function formatMainSessionPlaces(p, opts = {}) {
13254
13471
  return r.both(`${MAIN_SUMMARY_PENDING}${p.summary_reason ? ` (${p.summary_reason})` : ""}`).render(opts.form);
13255
13472
  const baselineSessions = p.baseline_sessions;
13256
13473
  r.both(`Main session places: tiers are verdicts vs ${p.baseline_sessions === undefined ? "earlier sessions" : `the ${p.baseline_sessions} sessions before`}.`);
13257
- for (const x of p.places)
13258
- r.add(x.tier !== "same", ` ${windowQualified(x.place)}${opts.verbose ? ` (${x.place}; ${x.ref})` : ""} ${storedRefWords(x.ref, p.places.filter((row) => row.place === x.place).map((row) => row.ref))}: ${MAIN_TIER_WORDS[x.tier]}${x.reason ? `; ${mainPlaceReasonWords(x.reason, { baseline: { sessions: baselineSessions } })}` : ""}${x.reason && mainUnstableWords(x.reason) ? `. ${mainUnstableWords(x.reason)}` : ""}`);
13259
- const inNorm = p.places.filter((x) => x.tier === "same").length;
13474
+ const kept = new Set(foldTwins([...p.places].sort((a, b) => MAIN_TIER_RANK[a.tier] - MAIN_TIER_RANK[b.tier]), (x) => x.place, (x) => x.ref).rows);
13475
+ const places = p.places.filter((x) => kept.has(x));
13476
+ for (const x of places)
13477
+ r.add(x.tier !== "same", ` ${windowQualified(x.place)}${opts.verbose ? ` (${x.place}; ${x.ref})` : ""} ${storedRefWords(x.ref, places.filter((row) => row.place === x.place).map((row) => row.ref))}: ${MAIN_TIER_WORDS[x.tier]}${x.reason ? `; ${mainPlaceReasonWords(x.reason, { baseline: { sessions: baselineSessions } })}` : ""}${x.reason && mainUnstableWords(x.reason) ? `. ${mainUnstableWords(x.reason)}` : ""}`);
13478
+ if (places.length < p.places.length)
13479
+ r.both(` ${foldedTwinsText(p.places.length - places.length)}`);
13480
+ const inNorm = places.filter((x) => x.tier === "same").length;
13260
13481
  return r.render(opts.form, inNorm ? [plural2(inNorm, "place") + " in norm"] : []);
13261
13482
  }
13262
13483
  var signed = (n) => `${n > 0 ? "+" : ""}${Number(n.toFixed(3))}`;
@@ -13295,6 +13516,12 @@ function renamedLines(renamed, places) {
13295
13516
  ...component ? [` ${componentNotJoinedText(renamed.sdk)}`] : []
13296
13517
  ];
13297
13518
  }
13519
+ function storedValueText(ref, v, unit) {
13520
+ const rest = ref.slice(2);
13521
+ const field = ref.startsWith("b:") ? `${rest.slice(0, rest.lastIndexOf(":"))}.${rest.slice(rest.lastIndexOf(":") + 1)}` : rest;
13522
+ const text = fieldValueText(field, v);
13523
+ return unit && !/[^\d.,\s-]/.test(text) ? `${text} ${unit}` : text;
13524
+ }
13298
13525
  function formatPlaceTrend(t, opts = {}) {
13299
13526
  const r = new Rows().both(`${t.journey} · ${t.phone.model} · ${t.baseline_branch}: stored per-session statistics`);
13300
13527
  const newest = new Set(t.sessions.slice(-SHORT_SESSIONS).map((s) => s.run_set_id));
@@ -13315,10 +13542,10 @@ function formatPlaceTrend(t, opts = {}) {
13315
13542
  const value = place.sessions.find((s) => s.run_set_id === session.run_set_id);
13316
13543
  if (!value)
13317
13544
  continue;
13318
- const unit = place.unit ? ` ${place.unit}` : "";
13545
+ const value_ = (v) => storedValueText(place.ref, v, place.unit);
13319
13546
  if (!k)
13320
13547
  olderSessions.add(session.run_set_id);
13321
- r.add(k, `${session.completed_at?.slice(0, 10) ?? "date unknown"} ${session.commit.sha?.slice(0, 7) ?? "commit unknown"} · ${windowQualified(place.window)}${opts.verbose ? ` (${value.window ?? place.window}; ${place.ref})` : ""} · ${place.label} (${storedRefWords(place.ref, t.places.filter((row) => row.window === place.window).map((row) => row.ref))}): median ${value.median}${unit}, MAD ${value.mad}${unit}, n=${value.n}`);
13548
+ r.add(k, `${session.completed_at?.slice(0, 10) ?? "date unknown"} ${session.commit.sha?.slice(0, 7) ?? "commit unknown"} · ${windowQualified(place.window)}${opts.verbose ? ` (${value.window ?? place.window}; ${place.ref})` : ""} · ${place.label} (${storedRefWords(place.ref, t.places.filter((row) => row.window === place.window).map((row) => row.ref))}): median ${value_(value.median)}, MAD ${value_(value.mad)}, n=${value.n}`);
13322
13549
  }
13323
13550
  }
13324
13551
  if (!t.sessions.length)
@@ -13375,6 +13602,8 @@ var branchStatus = (t, branch) => get(t, "v1/branches/status", { branch });
13375
13602
  var listProblems = (t, f = {}) => get(t, "v1/problems", { branch: f.branch, main: f.main ? "true" : undefined, journey: f.journey, phone: f.phone, limit: f.limit, offset: f.offset || undefined });
13376
13603
  var getFinding = (t, id) => get(t, `v1/findings/${encodeURIComponent(id)}`);
13377
13604
  var compareRuns = (t, a, b, page = {}) => get(t, "v1/compare", { a, b, ...page });
13605
+ var SHORT_COMPARE_PLACES = 10;
13606
+ var compareQuery = (short, page) => short ? { order: "moved", limit: SHORT_COMPARE_PLACES, ...page } : page;
13378
13607
  var windowHistory = (t, q) => get(t, "v1/history", q);
13379
13608
  var commitsBetween = (t, q) => get(t, "v1/commits", q);
13380
13609
  var layers = (t) => get(t, "v1/layers");
@@ -13409,7 +13638,7 @@ var FINDINGS_USAGE = `keepsnappy findings [--branch NAME | --main] [--journey KE
13409
13638
  problems --branch NAME | --main [--journey KEY] [--phone MODEL] [--limit N] [--offset N] [--short] [--json]
13410
13639
  (the single problems "Over budget" / "Far over budget" in the branch's own runs, whatever main does: not a regression, never blocking)
13411
13640
  finding <id> [--prompt] [--short] [--json] (--prompt: only the hand-off for a coding agent; with --json the whole finding)
13412
- compare <runA> <runB> [--json] (run ids or external ids; one run against one, no statistics)
13641
+ compare <runA> <runB> [--short] [--limit N] [--offset N] [--json] (run ids or external ids; one run against one, no statistics; --short: the places whose time moved most)
13413
13642
  history <journey> <window> [--phone MODEL] [--short] [--json] (exact window id, e.g. "press:live.chat.send")
13414
13643
  commits <from> [<to>] [--branch NAME] [--json] (measured commits between two run sets or commit hashes)
13415
13644
  places [--run-set ID | --journey KEY [--phone MODEL] [--branch NAME]] [--measure ID] [--sort median|p90|mad] [--window TEXT] [--limit N] [--offset N] [--top-components N] [--short] [--json]
@@ -13425,7 +13654,7 @@ ${READ_USAGE.detailFlag}
13425
13654
  --short (findings, status, problems, finding, history, places, main, trend: what decides, without the evidence detail; the same as the MCP tools' form short; --json is unchanged)
13426
13655
  (key: KEEPSNAPPY_KEY or --key-env NAME; API: --api URL or KEEPSNAPPY_API)`;
13427
13656
  var STATES = ["present", "not-judged", "resolved", "all"];
13428
- var SHORT_COMMANDS = new Set(["findings", "status", "problems", "finding", "history", "places", "main", "trend"]);
13657
+ var SHORT_COMMANDS = new Set(["findings", "status", "problems", "finding", "compare", "history", "places", "main", "trend"]);
13429
13658
  var FINDINGS_COMMANDS = new Set(["findings", "status", "problems", "finding", "compare", "history", "commits", "places", "layers", "main", "trend"]);
13430
13659
  async function findingsCommand(cmd, argv, rt) {
13431
13660
  const args = (() => {
@@ -13564,7 +13793,8 @@ async function findingsCommand(cmd, argv, rt) {
13564
13793
  const size = o["top-components"] === undefined ? 12 : Number(o["top-components"]);
13565
13794
  if (!Number.isInteger(size) || size < 1 || size > 200)
13566
13795
  throw new UsageError([READ_USAGE.compareTop]);
13567
- const query = page || o.all || o["top-components"] !== undefined ? { component_offset: ((page ?? 1) - 1) * size, component_limit: size } : {};
13796
+ const components = page || o.all || o["top-components"] !== undefined ? { component_offset: ((page ?? 1) - 1) * size, component_limit: size } : {};
13797
+ const query = compareQuery(o.short === true, { ...components, ...limit ? { limit } : {}, ...offset !== undefined ? { offset } : {} });
13568
13798
  const compared = await compareRuns(t, p[0], p[1], query);
13569
13799
  return out(o.all ? await allCompare(t, compared, size) : compared, formatCompare);
13570
13800
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keepsnappy/agent",
3
- "version": "0.1.0-alpha.18",
3
+ "version": "0.1.0-alpha.19",
4
4
  "description": "KeepSnappy CLI: runs your app journeys, measures from inside the app and reports More work / Less work / No change per step.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",