@keepsnappy/mcp 0.1.0-alpha.13 → 0.1.0-alpha.16

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
@@ -7,6 +7,8 @@ writes or recomputes anything (plan 48 §4).
7
7
  It gives the same answers as the `keepsnappy` CLI (`keepsnappy findings | finding | compare | history | commits | layers`):
8
8
  both use this package's API client and answer layout, so a person and an agent read the same words.
9
9
 
10
+ Which question needs which tool, and how to read the answers: [docs/agents.md](../../docs/agents.md) ("KeepSnappy for coding agents").
11
+
10
12
  ## Set it up in an app repo (TapIn)
11
13
 
12
14
  1. Ask the KeepSnappy owner for a project key with **read** scope (`bun run create-key -- <org> <app> read` in
@@ -20,7 +22,7 @@ both use this package's API client and answer layout, so a person and an agent r
20
22
  "mcpServers": {
21
23
  "keepsnappy": {
22
24
  "command": "npx",
23
- "args": ["-y", "@keepsnappy/mcp"],
25
+ "args": ["-y", "@keepsnappy/mcp@0.1.0-alpha.16"],
24
26
  "env": {
25
27
  "KEEPSNAPPY_KEY": "${KEEPSNAPPY_KEY}",
26
28
  "KEEPSNAPPY_API": "https://keepsnappy-api-922918702810.europe-west1.run.app"
@@ -31,16 +33,18 @@ both use this package's API client and answer layout, so a person and an agent r
31
33
  ```
32
34
 
33
35
  Claude Code expands `${KEEPSNAPPY_KEY}` from your environment. `KEEPSNAPPY_API` is optional (the hosted API is the default).
34
- From a KeepSnappy checkout instead of npm: `"command": "bun", "args": ["<keepsnappy>/packages/mcp/src/bin.ts"]`.
36
+ Use 0.1.0-alpha.16 or later (all eleven tools, with `pr_status` and `form`; alpha.15 has ten, alpha.13 six). From a KeepSnappy checkout instead of npm: `"command": "bun", "args": ["<keepsnappy>/packages/mcp/src/bin.ts"]`.
35
37
 
36
38
  ## Tools
37
39
 
38
40
  | Tool | Answers | API |
39
41
  |---|---|---|
42
+ | `pr_status` | Is this branch ok, in one call: blocked, nothing blocks with results to look at, or ok; per journey × phone the deciding word, whether it blocks, the worst present findings (ids for `get_finding`) and the dashboard link. A branch never measured, whose newest run set was not judged, or that could not be compared yet is not ok: the answer says which, with its reason. | `GET /v1/branches/status` |
40
43
  | `list_findings` | Whether and when the branch was measured (`measured`: run sets, the newest set of each journey × phone with its commit, word and runs), then what got worse (Worse / Warning) or better, worst first. Filters: `branch` or `main`, `journey`, `phone`, `state` (`present` default, `not-judged`, `resolved`, `all`), `limit` (≤ 200), `offset` (the answer gives `next_offset`). | `GET /v1/findings` |
41
44
  | `get_finding` | One finding by id: what changed, where (journey, phone, branch, commit title and author, PR), how sure (runs per side, session spread, measured differently / not blocking, setup notes), why (components that rendered longer with their cause, counts that moved), what to do (a prompt for a coding agent), lifecycle (first and last seen; present, resolved, or not judged when the newest run set did not judge the measure; seen in N PRs). The prompt ends with the finding's id and page. | `GET /v1/findings/:id` |
42
45
  | `compare_runs` | Two runs side by side, window by window, and what differs in how they were measured. No statistics. | `GET /v1/compare?a=&b=` |
43
46
  | `window_history` | One window (screen, tap, scroll) of a journey on main over time, run by run, with commit titles, plus the findings on it. | `GET /v1/history` |
47
+ | `session_places` | Every place (screen, tap, scroll) of one session (a run set of one journey on one phone) from its stored statistics: per measure the median, MAD, p10/p90 and range, and the components with the most own time. Not a verdict, not a comparison. Pick with `run_set`, or `journey` (+ `phone`, `branch`; default: the app's baseline branch (main)) for the newest closed run set with a valid run; rank by `measure` (default `render_ms`) and `sort` (`median`, `p90`, `mad`), narrow with `window`, page with `limit` / `offset` (the answer gives `next_offset`), `top_components` (default 5). A session with no stored statistics says `not_computed`, one with another version's `stale`: never zero. | `GET /v1/sessions/places` |
44
48
  | `commits_between` | The commits KeepSnappy measured between two points (run set ids or commit hashes), one entry per commit with its sessions. | `GET /v1/commits` |
45
49
  | `explain_layers` | Which of KeepSnappy's layers answers which question, and each layer's certificate (none issued yet). | `GET /v1/layers` |
46
50
 
@@ -52,14 +56,17 @@ An empty list is a clean result only when `measured.newest` exists and was judge
52
56
  KeepSnappy measurement on branch X" or "the newest run set was not judged" otherwise. Query parameters the API does not know
53
57
  are dropped, not refused (there is no `?commit=` filter).
54
58
 
55
- Every tool returns the text the CLI prints and the API's JSON as structured content. Every finding names its layer
59
+ Every tool returns the text the CLI prints and the API's JSON as structured content. The answer tools (`pr_status`, `list_findings`, `get_finding`, `session_places`, `main_sessions`, `main_vs_previous`,
60
+ `place_trend`, `window_history`) take `form`: `full` (the default, the whole text) or `short` (the CLI's `--short`). Short is
61
+ the full text with lines left out, in the same order, and ends with one line of its own that says what it left out (with
62
+ counts); nothing left out, no line. Each tool's description says what its short form leaves out. The JSON is the same in
63
+ both forms. Every finding names its layer
56
64
  (4 = PR vs main, 5 = main over time), its certificate (null until plan 51 certifies the layer) and its limits.
57
65
 
58
66
  ## Words
59
67
 
60
- The one word table of `packages/verdicts/src/headline.ts` (REQ-VER-24): **Worse / Warning / Better / No change /
61
- Can't compare / Not enough data yet**, and "not blocking" as a badge on a Worse measured differently from main
62
- ("Worse (not blocking)"). The evidence ranks likely causes; it does not prove them.
68
+ The one word table of `packages/present/src/states.ts` (REQ-VER-24): **Worse / Slightly worse / Better / No change /
69
+ Can’t tell**, and "not blocking" as a badge on a Worse measured differently from main ("Worse (not blocking)"). The evidence ranks likely causes; it does not prove them.
63
70
 
64
71
  ## Develop
65
72
 
@@ -14067,9 +14067,13 @@ class ApiError extends Error {
14067
14067
  }
14068
14068
  async function get(t, path, query = {}) {
14069
14069
  const u = new URL(path, t.url.endsWith("/") ? t.url : `${t.url}/`);
14070
- for (const [k, v] of Object.entries(query))
14071
- if (v !== undefined && v !== "")
14070
+ for (const [k, v] of Object.entries(query)) {
14071
+ if (Array.isArray(v))
14072
+ for (const value of v)
14073
+ u.searchParams.append(k, value);
14074
+ else if (v !== undefined && v !== "")
14072
14075
  u.searchParams.set(k, String(v));
14076
+ }
14073
14077
  const r = await (t.fetch ?? fetch)(u, { headers: { "x-keepsnappy-key": t.key, accept: "application/json" } });
14074
14078
  const text = await r.text();
14075
14079
  const body = text ? JSON.parse(text) : {};
@@ -14082,11 +14086,32 @@ async function get(t, path, query = {}) {
14082
14086
  return body;
14083
14087
  }
14084
14088
  var listFindings = (t, f = {}) => get(t, "v1/findings", { branch: f.branch, main: f.main ? "true" : undefined, journey: f.journey, phone: f.phone, state: f.state, limit: f.limit, offset: f.offset || undefined });
14089
+ var branchStatus = (t, branch) => get(t, "v1/branches/status", { branch });
14085
14090
  var getFinding = (t, id) => get(t, `v1/findings/${encodeURIComponent(id)}`);
14086
14091
  var compareRuns = (t, a, b) => get(t, "v1/compare", { a, b });
14087
14092
  var windowHistory = (t, q) => get(t, "v1/history", q);
14088
14093
  var commitsBetween = (t, q) => get(t, "v1/commits", q);
14089
14094
  var layers = (t) => get(t, "v1/layers");
14095
+ var sessionPlaces = (t, f) => get(t, "v1/sessions/places", {
14096
+ run_set: f.run_set,
14097
+ journey: f.journey,
14098
+ phone: f.phone,
14099
+ branch: f.branch,
14100
+ measure: f.measure,
14101
+ sort: f.sort,
14102
+ window: f.window,
14103
+ limit: f.limit,
14104
+ offset: f.offset || undefined,
14105
+ top_components: f.top_components
14106
+ });
14107
+ var SESSION_SORTS = ["median", "p90", "mad"];
14108
+ var mainSessions = (t, q) => get(t, "v1/main/sessions", { ...q });
14109
+ var mainSessionPlaces = (t, runSetId, q) => get(t, `v1/main/sessions/${encodeURIComponent(runSetId)}/places`, { ...q });
14110
+ var mainVsPrevious = (t, q) => {
14111
+ const { run_set, ...query } = q;
14112
+ return get(t, `v1/main/sessions/${encodeURIComponent(run_set ?? "latest")}/vs-previous`, query);
14113
+ };
14114
+ var placeTrend = (t, q) => get(t, "v1/places/trend", { ...q });
14090
14115
 
14091
14116
  // node_modules/.bun/zod@4.6.7/node_modules/zod/v3/helpers/util.js
14092
14117
  var util;
@@ -22924,6 +22949,13 @@ var NOT_FACTS = {
22924
22949
  decimals: 0,
22925
22950
  what: "Whether the test cleaned up the app’s memory once at the end of the journey, outside any measured window, before the end reading"
22926
22951
  },
22952
+ "sdk.end_read": {
22953
+ unit: "flag",
22954
+ label: "End memory read taken on request after the clean-up (1 = yes)",
22955
+ short: "end memory read on request",
22956
+ decimals: 0,
22957
+ what: "Whether the end memory reading is the one the app took when the test asked for it right after the clean-up (1), or the clean-up’s own reading (0, an app or run without the request)"
22958
+ },
22927
22959
  "sdk.appstate_changes": {
22928
22960
  unit: "count",
22929
22961
  label: "App went to the background or came back",
@@ -22963,29 +22995,43 @@ var NOT_FACTS = {
22963
22995
  };
22964
22996
  var isNotFactId = (id) => Object.hasOwn(NOT_FACTS, id);
22965
22997
  var fieldDef = (id) => isMeasureId(id) ? MEASURES[id] : isNotFactId(id) ? NOT_FACTS[id] : undefined;
22966
- // packages/facts/dist/src/frame.js
22967
- var LIMIT_FRAME_MS = 1000 / 60;
22968
- // packages/present/dist/src/places.js
22969
- var PLACE_KIND_WORDS = {
22970
- screen: "Screen",
22971
- launch: "Launch screen",
22972
- tap: "Tap",
22973
- scroll: "Scroll",
22974
- step: "Test step",
22975
- journey: "Whole journey",
22976
- background: "Background",
22977
- after3s: "3 s after"
22998
+ var DIMENSIONS = {
22999
+ component: { label: "Component", rankBy: "self_ms" },
23000
+ updater: { label: "Update scheduled by", rankBy: "share_of_commits" },
23001
+ slow_commit_component: { label: "Component in the slowest commit", rankBy: "self_ms" },
23002
+ slow_commit_updater: { label: "Update of the slowest commit scheduled by", rankBy: "scheduled" }
22978
23003
  };
22979
- var KIND_WORD = {
22980
- journey: PLACE_KIND_WORDS.journey,
22981
- screen: PLACE_KIND_WORDS.screen,
22982
- launch: PLACE_KIND_WORDS.launch,
22983
- press: PLACE_KIND_WORDS.tap,
22984
- drag: PLACE_KIND_WORDS.scroll,
22985
- step: PLACE_KIND_WORDS.step,
22986
- background: PLACE_KIND_WORDS.background
23004
+ var BREAKDOWN_MEASURES = {
23005
+ self_ms: { unit: "ms", worse: "higher", label: "render time", decimals: 1 },
23006
+ rolled_up_ms: { unit: "ms", worse: "none", label: "render time from libraries", decimals: 1 },
23007
+ total_ms: { unit: "ms", worse: "higher", label: "total render time", decimals: 1 },
23008
+ renders: { unit: "count", worse: "higher", label: "renders", decimals: 0 },
23009
+ mounts: { unit: "count", worse: "higher", label: "mounts", decimals: 0 },
23010
+ share_of_commits: { unit: "share", worse: "none", label: "share of commits", decimals: 0 },
23011
+ scheduled: { unit: "count", worse: "none", label: "updates scheduled", decimals: 0 }
22987
23012
  };
23013
+ var HEAT_METRIC = "sampler.thermal.heats_up";
23014
+ var HEAT_LABEL = MEASURES[HEAT_METRIC].label;
23015
+ // packages/facts/dist/src/frame.js
23016
+ var LIMIT_FRAME_MS = 1000 / 60;
23017
+ // packages/present/dist/src/capital.js
23018
+ var upperFirst = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
23019
+
23020
+ // packages/present/dist/src/placeWords.js
22988
23021
  var GENERIC_GROUPS = new Set(["app", "tabs", "root"]);
23022
+
23023
+ // packages/present/dist/src/states.js
23024
+ var STATE_WORDS = {
23025
+ worse: "Worse",
23026
+ warning: "Slightly worse",
23027
+ better: "Better",
23028
+ "no-change": "No change",
23029
+ "cant-tell": "Can’t tell"
23030
+ };
23031
+ var INFO_WORDS = { heat: "Phone heated up" };
23032
+ var isWorseState = (state) => state === "worse";
23033
+ var NOT_BLOCKING = "not blocking";
23034
+
22989
23035
  // packages/present/dist/src/values.js
22990
23036
  var NO_VALUE = "–";
22991
23037
  var FALLBACK_DECIMALS = 1;
@@ -23022,42 +23068,250 @@ function format(unit, decimals, v) {
23022
23068
  const text = String(roundTo(v * shown.scale, decimals) + 0);
23023
23069
  return shown.unit ? `${text} ${shown.unit}` : text;
23024
23070
  }
23071
+ function valueText(measure, v) {
23072
+ const def = isMeasureId(measure) ? MEASURES[measure] : BREAKDOWN_MEASURES[measure];
23073
+ return format(def.unit, def.decimals, v);
23074
+ }
23025
23075
  var KEY_SEPARATOR = ":";
23076
+ function breakdownMeasure(field) {
23077
+ const at = field.indexOf(KEY_SEPARATOR);
23078
+ if (at < 0 || !Object.hasOwn(DIMENSIONS, field.slice(0, at)))
23079
+ return null;
23080
+ const measure = field.slice(field.lastIndexOf(".") + 1);
23081
+ return Object.hasOwn(BREAKDOWN_MEASURES, measure) ? measure : null;
23082
+ }
23026
23083
  function fieldValueText(field, v) {
23084
+ const breakdown = breakdownMeasure(field);
23085
+ if (breakdown)
23086
+ return valueText(breakdown, v);
23027
23087
  const def = fieldDef(field.split(KEY_SEPARATOR)[0]);
23028
23088
  return format(def?.unit, def?.decimals ?? FALLBACK_DECIMALS, v);
23029
23089
  }
23090
+
23091
+ // packages/present/dist/src/rowWords.js
23092
+ var GONE_TAILS = {
23093
+ branch: { notInRun: "on main, not in this run", removed: "on main, not rendered in this run" },
23094
+ journey: { notInRun: "in the sessions before, not in the newest session", removed: "in the sessions before, not rendered in the newest session" }
23095
+ };
23096
+ var ROW_STATE_WORDS = {
23097
+ new: "New in this PR: not compared with main",
23098
+ notInRun: upperFirst(GONE_TAILS.branch.notInRun),
23099
+ removed: upperFirst(GONE_TAILS.branch.removed)
23100
+ };
23101
+ var JOURNEY_ROW_WORDS = {
23102
+ new: "New in the newest session: not compared with the sessions before",
23103
+ notInRun: upperFirst(GONE_TAILS.journey.notInRun),
23104
+ removed: upperFirst(GONE_TAILS.journey.removed)
23105
+ };
23106
+
23107
+ // packages/present/dist/src/places.js
23108
+ var PLACE_KIND_WORDS = {
23109
+ screen: "Screen",
23110
+ launch: "Launch screen",
23111
+ tap: "Tap",
23112
+ scroll: "Scroll",
23113
+ step: "Test step",
23114
+ journey: "Whole journey",
23115
+ background: "Background",
23116
+ after3s: "3 s after"
23117
+ };
23118
+ var KIND_WORD = {
23119
+ journey: PLACE_KIND_WORDS.journey,
23120
+ screen: PLACE_KIND_WORDS.screen,
23121
+ launch: PLACE_KIND_WORDS.launch,
23122
+ press: PLACE_KIND_WORDS.tap,
23123
+ drag: PLACE_KIND_WORDS.scroll,
23124
+ step: PLACE_KIND_WORDS.step,
23125
+ background: PLACE_KIND_WORDS.background
23126
+ };
23127
+ // packages/present/dist/src/words.js
23128
+ var REASON_WORDS = {
23129
+ "no-baseline": "there are no main runs on this phone and journey yet",
23130
+ "too-few-runs": "there are too few valid runs on a side",
23131
+ "judged-measures-missing": "the judged measures are missing on a side",
23132
+ "measured-differently": "the sides were measured differently",
23133
+ "runs-unknown": "a change that would count has no value in some runs, so it cannot be confirmed in every run",
23134
+ "facts-version-mismatch": "the sides were derived with different versions",
23135
+ "not-judged-on-simulators": "timing is not judged on simulators",
23136
+ "platforms-differ": "the sides ran on different platforms (iOS and Android are never compared)",
23137
+ "not-enough-main": "main does not have enough sessions on this phone and journey yet"
23138
+ };
23139
+ var waitingWords = (runs) => `${runs} main ${runs === 1 ? "run waits" : "runs wait"} for a reprocess`;
23140
+ // packages/present/dist/src/stability.js
23141
+ var UNSTABLE_WORDS = {
23142
+ short: "main varies too much here",
23143
+ label: `${STATE_WORDS["cant-tell"]} · main varies too much here`,
23144
+ line: "Main varies too much here to tell",
23145
+ ratioTip: "1.0 = main’s run-to-run noise alone would pass the warning step about once in 100 comparisons.",
23146
+ what: "Main’s own runs vary too much here to tell, so a comparison there would mostly measure noise. It is shown with its values, never judged, never blocks and is never counted. It is judged again once main settles."
23147
+ };
23148
+ // packages/present/dist/src/present.js
23149
+ var ASPECT_WORDS = {
23150
+ renderer: "React renderer",
23151
+ reactExport: "React export",
23152
+ causeSplit: "work split by its cause",
23153
+ windowModel: "window model",
23154
+ screenWindowModel: "screen window model",
23155
+ cpuSource: "CPU source",
23156
+ sdk: "SDK version",
23157
+ driver: "driver",
23158
+ driverVersion: "driver version",
23159
+ journeyVersion: "journey version",
23160
+ actionGc: "memory clean-up before each step",
23161
+ endGc: "memory clean-up at the journey end",
23162
+ cpuProfiling: "CPU profiling"
23163
+ };
23164
+ // packages/present/dist/src/main-history.js
23165
+ var MAIN_TIER_WORDS = {
23166
+ same: "In norm",
23167
+ better: STATE_WORDS.better,
23168
+ cant: STATE_WORDS["cant-tell"],
23169
+ slight: STATE_WORDS.warning,
23170
+ worse: STATE_WORDS.worse
23171
+ };
23172
+ var MAIN_TIER_RANK = { worse: 0, slight: 1, cant: 2, better: 3, same: 4 };
23173
+ function mainCantWords(reason, config) {
23174
+ if (!reason)
23175
+ return "";
23176
+ 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}` : ""}`;
23177
+ }
23178
+ var waitingSessionsWords = (sessions) => `${sessions} older main ${sessions === 1 ? "session waits" : "sessions wait"} for a reprocess`;
23179
+ function mainPlaceReasonWords(reason, config) {
23180
+ switch (reason.kind) {
23181
+ case "no_baseline":
23182
+ case "noisy_main":
23183
+ return mainCantWords(reason, config);
23184
+ case "higher":
23185
+ return "Higher than main’s earlier sessions";
23186
+ case "lower":
23187
+ return "Lower than main’s earlier sessions";
23188
+ case "missing_value":
23189
+ return REASON_WORDS["runs-unknown"];
23190
+ case "measured_differently":
23191
+ return REASON_WORDS["measured-differently"];
23192
+ case "core":
23193
+ return reason.codes.map((code) => REASON_WORDS[code]).join("; ");
23194
+ }
23195
+ }
23196
+ var MAIN_SUMMARY_PENDING = "Runs received; the summary is not computed yet.";
23197
+ var MAIN_NEWEST_SUMMARY_PENDING = `Newest: ${MAIN_SUMMARY_PENDING}`;
23198
+ var SHORT_ASPECT_WORDS = {
23199
+ ...ASPECT_WORDS,
23200
+ sdk: "SDK",
23201
+ renderer: "Renderer",
23202
+ endGc: "End GC",
23203
+ causeSplit: "Cause split",
23204
+ driver: "Driver",
23205
+ driverVersion: "Driver",
23206
+ journeyVersion: "Journey"
23207
+ };
23208
+ function mainSessionCantWords(places, reason, config) {
23209
+ return `${STATE_WORDS["cant-tell"]} at ${places} place${places === 1 ? "" : "s"}: ${mainCantWords(reason, config)}`;
23210
+ }
23211
+ var KIND_PLURAL = {
23212
+ screen: "screens",
23213
+ press: "taps",
23214
+ drag: "scrolls",
23215
+ launch: "launch screens",
23216
+ step: "test steps",
23217
+ background: "background work",
23218
+ other: "other places"
23219
+ };
23220
+ var listWords = (kinds) => {
23221
+ const words = kinds.map((k) => KIND_PLURAL[k] ?? "other places");
23222
+ return words.length < 2 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
23223
+ };
23224
+ function mainCutWords(v) {
23225
+ const parts = [];
23226
+ if (v.cuts.length)
23227
+ parts.push(`Starts a new ${v.cuts.join(" and ")} baseline.`);
23228
+ if (v.partial_render) {
23229
+ const cut = listWords(v.partial_render.cut);
23230
+ parts.push(`${cut[0].toUpperCase()}${cut.slice(1)} start a new render baseline; ${listWords(v.partial_render.kept)} keep theirs.`);
23231
+ }
23232
+ return parts.length ? parts.join(" ") : "Measures alike; no baseline cut.";
23233
+ }
23030
23234
  // packages/mcp/src/answer.ts
23235
+ var ANSWER_FORMS = ["short", "full"];
23236
+ var SHORT_SESSIONS = 3;
23237
+ var SHORT_POINTS = 5;
23238
+ var SHORT_PLACES = 5;
23239
+ var SHORT_FORM_NOTES = {
23240
+ list_findings: "the how-sure lines, the place lines of findings that are not Worse and the lanes that were judged",
23241
+ get_finding: "the why, seen and layer lines, the component lines and the prompt",
23242
+ session_places: "the component lines",
23243
+ main_sessions: `the sessions older than the newest ${SHORT_SESSIONS}, the main changes of all but the newest session and, for one run_set, the places in norm`,
23244
+ main_vs_previous: `the movers after the first ${SHORT_PLACES} (every new, gone or multi-device place stays)`,
23245
+ place_trend: `the sessions older than the newest ${SHORT_SESSIONS} and the history restarts among them (unknown restarts always stay)`,
23246
+ window_history: `the runs older than the newest ${SHORT_POINTS}`,
23247
+ pr_status: "the per-journey dashboard links and the heat note"
23248
+ };
23249
+ var andList = (xs) => xs.length < 2 ? xs[0] ?? "" : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`;
23250
+
23251
+ class Rows {
23252
+ rows = [];
23253
+ add(short, ...texts) {
23254
+ for (const t of texts)
23255
+ for (const l of t.split(`
23256
+ `))
23257
+ this.rows.push({ text: l, short });
23258
+ return this;
23259
+ }
23260
+ both(...texts) {
23261
+ return this.add(true, ...texts);
23262
+ }
23263
+ full(...texts) {
23264
+ return this.add(false, ...texts);
23265
+ }
23266
+ render(form, left = []) {
23267
+ if (form !== "short")
23268
+ return this.rows.map((r) => r.text).join(`
23269
+ `);
23270
+ const kept = [];
23271
+ for (const r of this.rows) {
23272
+ if (!r.short)
23273
+ continue;
23274
+ if (r.text === "" && (!kept.length || kept[kept.length - 1] === ""))
23275
+ continue;
23276
+ kept.push(r.text);
23277
+ }
23278
+ if (kept[kept.length - 1] === "")
23279
+ kept.pop();
23280
+ if (this.rows.some((r) => !r.short && r.text.trim() !== ""))
23281
+ kept.push(`Short form: ${andList(left.length ? left : ["other lines"])} left out; the full form shows them.`);
23282
+ return kept.join(`
23283
+ `);
23284
+ }
23285
+ }
23031
23286
  var LABEL_W = 14;
23032
23287
  var line = (label, text) => ` ${label.padEnd(LABEL_W)}${text}`;
23033
23288
  var plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
23289
+ var promptText = (f) => f.what_to_do.prompt ?? "No prompt: this finding is an improvement.";
23034
23290
  function formatFinding(f, opts = {}) {
23035
- const out = [
23036
- f.answer.headline,
23037
- line("What changed", f.answer.what_changed),
23038
- line("Where", f.answer.where),
23039
- line("How sure", f.answer.how_sure),
23040
- line("Why", f.answer.why),
23041
- line("What to do", f.answer.what_to_do),
23042
- line("Seen", f.answer.lifecycle),
23043
- line("Layer", `${f.layer.id} · ${f.layer.name} (${f.certificate ? "certified" : "uncertified"})${f.limits.length ? `; limits: ${f.limits.join("; ")}` : ""}`),
23044
- line("Id", f.id)
23045
- ];
23046
- if (f.why.components.length > 1) {
23047
- out.push(line("Components", ""));
23291
+ const r = new Rows;
23292
+ r.both(f.answer.headline, line("What changed", f.answer.what_changed), line("Where", f.answer.where), line("How sure", f.answer.how_sure));
23293
+ r.full(line("Why", f.answer.why));
23294
+ r.both(line("What to do", f.answer.what_to_do));
23295
+ r.full(line("Seen", f.answer.lifecycle), line("Layer", `${f.layer.id} · ${f.layer.name} (${f.certificate ? "certified" : "uncertified"})${f.limits.length ? `; limits: ${f.limits.join("; ")}` : ""}`));
23296
+ r.both(line("Id", f.id));
23297
+ if (f.why.components.length) {
23298
+ r.full(line("Components", ""));
23048
23299
  for (const c of f.why.components) {
23049
- out.push(` ${c.component}: ${c.self_ms_before} → ${c.self_ms_after} ms own time, renders ${c.renders_before} → ${c.renders_after}${c.cause ? `; ${c.cause}` : ""}${c.flagged ? "" : " (not certain)"}`);
23300
+ r.full(` ${c.component}: ${c.self_ms_before ?? "–"} → ${c.self_ms_after} ms own time, renders ${c.renders_before ?? "–"} → ${c.renders_after ?? "–"}${c.cause ? `; ${c.cause}` : ""}${c.flagged ? "" : " (not certain)"}`);
23050
23301
  }
23051
23302
  }
23052
23303
  if (opts.prompt)
23053
- out.push("", f.what_to_do.prompt ?? "No prompt: this finding is an improvement.");
23054
- return out.join(`
23055
- `);
23304
+ r.full("", promptText(f));
23305
+ return r.render(opts.form, [
23306
+ "the why, seen and layer lines",
23307
+ ...f.why.components.length ? [plural(f.why.components.length, "component line")] : [],
23308
+ ...opts.prompt ? ["the prompt"] : []
23309
+ ]);
23056
23310
  }
23057
23311
  var when = (iso) => iso.slice(0, 16).replace("T", " ");
23058
23312
  var LANES_SHOWN = 8;
23059
23313
  var STATE_WORDS2 = { present: "still present", resolved: "resolved", "not-judged": "not judged in the newest run set" };
23060
- function measuredLine(l) {
23314
+ function measuredLines(l, r) {
23061
23315
  const f = l.filters;
23062
23316
  const scope = [f.branch ?? "all branches", f.journey, f.phone].filter(Boolean).join(" · ");
23063
23317
  const m = l.measured;
@@ -23065,58 +23319,107 @@ function measuredLine(l) {
23065
23319
  if (!n) {
23066
23320
  const on = f.branch ? f.branch === l.baseline_branch ? f.branch : `branch ${f.branch}` : "any branch";
23067
23321
  const narrow = [f.journey, f.phone ? `phone “${f.phone}”` : null].filter(Boolean).join(", ");
23068
- return m.run_sets ? `${scope}: ${plural(m.run_sets, "run set")} measured, but no verdict yet: nothing is known about its performance.` : `No KeepSnappy measurement on ${on}${narrow ? ` for ${narrow}` : ""}: nothing is known about its performance.`;
23322
+ r.both(m.run_sets ? `${scope}: ${plural(m.run_sets, "run set")} measured, but no verdict yet: nothing is known about its performance.` : `No KeepSnappy measurement on ${on}${narrow ? ` for ${narrow}` : ""}: nothing is known about its performance.`);
23323
+ return [];
23069
23324
  }
23070
23325
  const lane = (x) => {
23071
23326
  const runs = x.runs.after !== null && x.runs.before !== null ? x.branch === l.baseline_branch ? `, ${x.runs.after} latest runs vs ${x.runs.before} earlier` : `, ${x.runs.after} branch runs vs ${x.runs.before} on ${l.baseline_branch}` : "";
23072
- return `${x.commit?.sha ? `commit ${x.commit.sha.slice(0, 7)}` : "commit not recorded"} (${when(x.at)}): ${x.word}${runs}`;
23327
+ return `${x.commit?.sha ? `commit ${x.commit.sha.slice(0, 7)}` : "commit not recorded"} (${when(x.at)}): ${x.word}${runs}${x.waiting_for_reprocess ? `; ${waitingWords(x.waiting_for_reprocess)}` : ""}`;
23073
23328
  };
23074
- if (m.lanes.length === 1)
23075
- return `${scope}: newest measured ${f.branch ? "" : `on ${n.branch}, `}${lane(n)}; ${plural(m.run_sets, "run set")} measured.`;
23329
+ if (m.lanes.length === 1) {
23330
+ r.both(`${scope}: newest measured ${f.branch ? "" : `on ${n.branch}, `}${lane(n)}; ${plural(m.run_sets, "run set")} measured.`);
23331
+ return [];
23332
+ }
23076
23333
  const shown = m.lanes.slice(0, LANES_SHOWN);
23077
- return [
23078
- `${scope}: ${plural(m.run_sets, "run set")} measured; the newest of each journey × phone:`,
23079
- ...shown.map((x) => ` ${x.journey} on ${x.phone_name}${f.branch ? "" : ` (${x.branch})`}: ${lane(x)}`),
23080
- ...m.lanes.length > shown.length ? [` … ${m.lanes.length - shown.length} more (--journey / --phone narrow it)`] : []
23081
- ].join(`
23082
- `);
23334
+ const unjudged = shown.filter((x) => !x.judged);
23335
+ r.add(unjudged.length > 0, `${scope}: ${plural(m.run_sets, "run set")} measured; the newest of each journey × phone:`);
23336
+ for (const x of shown)
23337
+ r.add(!x.judged, ` ${x.journey} on ${x.phone_name}${f.branch ? "" : ` (${x.branch})`}: ${lane(x)}`);
23338
+ if (m.lanes.length > shown.length)
23339
+ r.full(` … ${m.lanes.length - shown.length} more (--journey / --phone narrow it)`);
23340
+ return [...shown.length > unjudged.length ? [plural(shown.length - unjudged.length, "judged lane")] : [], ...m.lanes.length > shown.length ? [plural(m.lanes.length - shown.length, "lane") + " beyond the eighth"] : []];
23083
23341
  }
23084
23342
  function countsLine(l) {
23085
23343
  const c = l.counts;
23086
23344
  const parts = [
23087
- c.worse ? `${c.worse} worse${c.not_blocking ? ` (${c.not_blocking} not blocking)` : ""}` : "",
23088
- c.warn ? plural(c.warn, "warning") : "",
23089
- c.better ? `${c.better} better` : ""
23345
+ c.worse ? `${c.worse} ${STATE_WORDS.worse.toLowerCase()}${c.not_blocking ? ` (${c.not_blocking} not blocking)` : ""}` : "",
23346
+ c.warn ? `${c.warn} ${STATE_WORDS.warning.toLowerCase()}` : "",
23347
+ c.better ? `${c.better} ${STATE_WORDS.better.toLowerCase()}` : ""
23090
23348
  ].filter(Boolean);
23091
23349
  const state = l.filters.state;
23092
23350
  const unjudged = l.measured.lanes.filter((x) => !x.judged);
23093
- const head = parts.length ? `${parts.join(", ")}${state === "all" ? "" : `, ${STATE_WORDS2[state] ?? state}`}` : state !== "present" && state !== "all" ? `No finding ${STATE_WORDS2[state] ?? state}` : unjudged.length === l.measured.lanes.length ? `No finding, but nothing was compared yet (${[...new Set(unjudged.map((x) => x.word))].join(", ")}): measure again before calling it clean` : "Nothing worse, no warning, nothing better";
23351
+ const head = parts.length ? `${parts.join(", ")}${state === "all" ? "" : `, ${STATE_WORDS2[state] ?? state}`}` : state !== "present" && state !== "all" ? `No finding ${STATE_WORDS2[state] ?? state}` : unjudged.length === l.measured.lanes.length ? `No finding, but nothing was compared yet (${[...new Set(unjudged.map((x) => x.word))].join(", ")}): measure again before calling it clean` : `Nothing ${STATE_WORDS.worse.toLowerCase()}, nothing ${STATE_WORDS.warning.toLowerCase()}, nothing ${STATE_WORDS.better.toLowerCase()}`;
23094
23352
  const heated = c.heat_runs ?? 0;
23095
- const never = c.unsettled ?? 0;
23096
23353
  const more = [
23097
- never ? `${plural(never, "finding")} about taps or scrolls that never settle, so their time cannot be measured (information, layer 3)` : "",
23098
- heated ? `${plural(heated, "run")} heated up the phone (run health, layer 3)` : "",
23354
+ heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural(heated, "run")} (run health, layer 3)` : "",
23099
23355
  state === "present" && l.states.not_judged ? `${l.states.not_judged} more not judged in the newest run set (state not-judged)` : ""
23100
23356
  ].filter(Boolean);
23101
23357
  const line = `${head}${more.length ? `; ${more.join("; ")}` : ""}.`;
23102
23358
  return line.charAt(0).toUpperCase() + line.slice(1);
23103
23359
  }
23104
- function formatFindingList(l) {
23105
- const out = [measuredLine(l)];
23360
+ function formatFindingList(l, opts = {}) {
23361
+ const r = new Rows;
23362
+ const lanesLeft = measuredLines(l, r);
23106
23363
  if (l.measured.newest || l.total)
23107
- out.push(countsLine(l));
23364
+ r.both(countsLine(l));
23108
23365
  l.findings.forEach((f, i) => {
23109
- out.push("", `${l.offset + i + 1}. ${f.answer.headline}`, ` ${f.answer.where}`, ` ${f.answer.how_sure}`, ` ${f.answer.lifecycle} Id ${f.id}`);
23366
+ r.both("", `${l.offset + i + 1}. ${f.answer.headline}`);
23367
+ r.add(isWorseState(f.state), ` ${f.answer.where}`);
23368
+ r.full(` ${f.answer.how_sure}`);
23369
+ r.both(` ${f.answer.lifecycle} Id ${f.id}`);
23110
23370
  });
23111
23371
  const shown = l.findings.length ? `Showing ${l.offset + 1}–${l.offset + l.findings.length} of ${l.total}` : "";
23112
23372
  if (l.next_offset !== null)
23113
- out.push("", `${shown || `Showing none of ${l.total}`}; the rest from offset ${l.next_offset} (--offset ${l.next_offset}).`);
23373
+ r.both("", `${shown || `Showing none of ${l.total}`}; the rest from offset ${l.next_offset} (--offset ${l.next_offset}).`);
23114
23374
  else if (l.offset > 0)
23115
- out.push("", shown ? `${shown}.` : `Offset ${l.offset} is past the last of ${l.total}.`);
23375
+ r.both("", shown ? `${shown}.` : `Offset ${l.offset} is past the last of ${l.total}.`);
23116
23376
  if (l.findings.length)
23117
- out.push("", "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt");
23118
- return out.join(`
23119
- `);
23377
+ r.both("", "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt");
23378
+ const notWorse = l.findings.filter((f) => !isWorseState(f.state)).length;
23379
+ return r.render(opts.form, [
23380
+ ...l.findings.length ? [plural(l.findings.length, "how-sure line")] : [],
23381
+ ...notWorse ? [plural(notWorse, "place line")] : [],
23382
+ ...lanesLeft
23383
+ ]);
23384
+ }
23385
+ var STATUS_LEAD = {
23386
+ ok: "ok",
23387
+ warns: "nothing blocks, results to look at",
23388
+ blocks: "blocked",
23389
+ "cant-tell": "not ok yet, nothing was compared",
23390
+ "not-judged": "not ok yet, the newest run set was not judged",
23391
+ "never-measured": "not ok yet, never measured"
23392
+ };
23393
+ function formatBranchStatus(s, opts = {}) {
23394
+ const r = new Rows;
23395
+ r.both(`${s.branch}: ${STATUS_LEAD[s.result]}`, s.reason);
23396
+ if (s.lanes.length)
23397
+ r.both("");
23398
+ for (const l of s.lanes) {
23399
+ const badge = l.result === "blocks" ? " (blocks)" : l.word === STATE_WORDS.worse ? ` (${NOT_BLOCKING})` : "";
23400
+ const sha = l.commit?.sha?.slice(0, 7);
23401
+ r.both(` ${l.journey_title ?? l.journey} on ${l.phone_name}: ${l.word}${badge} · ${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)}` : ""}`);
23402
+ for (const f of l.worst)
23403
+ r.both(` ${f.id} ${f.headline}`);
23404
+ if (l.present_findings > l.worst.length)
23405
+ r.both(` … ${l.present_findings - l.worst.length} more: keepsnappy findings --branch ${s.branch}`);
23406
+ if (l.link)
23407
+ r.full(` ${l.link}`);
23408
+ }
23409
+ const links = s.lanes.filter((l) => l.link).length;
23410
+ const heated = s.counts.heat_runs ?? 0;
23411
+ const more = [
23412
+ [false, heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural(heated, "run")} (information, never blocks)` : ""],
23413
+ [true, s.not_judged_findings ? `${plural(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` : ""],
23414
+ [true, s.links.overview ? `Dashboard: ${s.links.overview}` : ""],
23415
+ [true, s.worst.length ? "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt" : ""]
23416
+ ];
23417
+ const shownMore = more.filter(([, text]) => text);
23418
+ if (shownMore.length)
23419
+ r.both("");
23420
+ for (const [short, text] of shownMore)
23421
+ r.add(short, text);
23422
+ return r.render(opts.form, [...links ? [plural(links, "dashboard link")] : [], ...heated ? ["the heat note"] : []]);
23120
23423
  }
23121
23424
  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";
23122
23425
  function formatCompare(r) {
@@ -23138,19 +23441,20 @@ function formatCompare(r) {
23138
23441
  return out.join(`
23139
23442
  `);
23140
23443
  }
23141
- function formatHistory(h) {
23142
- const out = [`${h.window_name} in ${h.journey_title ?? h.journey} on ${h.branch}: ${h.points.length ? `${plural(h.points.length, "run")}, oldest first` : h.note ?? "no runs"}`];
23444
+ function formatHistory(h, opts = {}) {
23445
+ const r = new Rows;
23446
+ r.both(`${h.window_name} in ${h.journey_title ?? h.journey} on ${h.branch}: ${h.points.length ? `${plural(h.points.length, "run")}, oldest first` : h.note ?? "no runs"}`);
23143
23447
  const ms = h.measures.slice(0, 4);
23144
23448
  if (h.points.length) {
23145
- out.push(` ${"when".padEnd(17)}${ms.map((m) => (m.label.length > 22 ? `${m.label.slice(0, 21)}…` : m.label).padStart(24)).join("")} commit`);
23146
- for (const p of h.points) {
23147
- out.push(` ${(p.at ?? "").slice(0, 16).replace("T", " ").padEnd(17)}${ms.map((m) => fieldValueText(m.metric, p.values[m.metric]).padStart(24)).join("")} ${commitText(p.commit)}`);
23148
- }
23449
+ r.both(` ${"when".padEnd(17)}${ms.map((m) => (m.label.length > 22 ? `${m.label.slice(0, 21)}…` : m.label).padStart(24)).join("")} commit`);
23450
+ h.points.forEach((p, i) => {
23451
+ r.add(i >= h.points.length - SHORT_POINTS, ` ${(p.at ?? "").slice(0, 16).replace("T", " ").padEnd(17)}${ms.map((m) => fieldValueText(m.metric, p.values[m.metric]).padStart(24)).join("")} ${commitText(p.commit)}`);
23452
+ });
23149
23453
  }
23150
23454
  if (h.findings.length)
23151
- out.push("", "Findings on this window:", ...h.findings.map((f) => ` ${f.answer.headline} — ${f.where.branch} (${f.id})`));
23152
- return out.join(`
23153
- `);
23455
+ r.both("", "Findings on this window:", ...h.findings.map((f) => ` ${f.answer.headline} — ${f.where.branch} (${f.id})`));
23456
+ const older = Math.max(0, h.points.length - SHORT_POINTS);
23457
+ return r.render(opts.form, older ? [plural(older, "older run")] : []);
23154
23458
  }
23155
23459
  function formatCommits(c) {
23156
23460
  return [
@@ -23160,6 +23464,55 @@ function formatCommits(c) {
23160
23464
  ].join(`
23161
23465
  `);
23162
23466
  }
23467
+ var list = (xs) => xs.join(", ");
23468
+ function versionText(label, counts) {
23469
+ const values = Object.keys(counts).sort((a, b) => a === "unknown" ? -1 : b === "unknown" ? 1 : a < b ? -1 : a > b ? 1 : 0);
23470
+ const name = (k) => k === "unknown" ? "not recorded" : k;
23471
+ if (values.length === 0)
23472
+ return "";
23473
+ if (values.length === 1)
23474
+ return `${label} ${name(values[0])}`;
23475
+ return `${label} mixed: ${values.map((k) => `${name(k)} ×${counts[k]}`).join(", ")}`;
23476
+ }
23477
+ function formatSessionPlaces(p, opts = {}) {
23478
+ const s = p.session;
23479
+ const sha = s.commit?.sha?.slice(0, 7);
23480
+ const commit = s.commit ? `${s.commit.title ? `“${s.commit.title}”${s.commit.author ? ` by ${s.commit.author}` : ""}` : sha ?? "commit not recorded"}${s.commit.title && sha ? ` (${sha})` : ""}` : "commit not recorded";
23481
+ const v = s.versions;
23482
+ const codes = Object.keys(s.runs.invalid_codes);
23483
+ const r = new Rows().both(`Session ${s.completed_at ? `${when(s.completed_at)} ` : ""}on ${s.branch}: ${commit}${s.pull_request?.number ? `, PR #${s.pull_request.number}` : ""}; ${s.journey.title ?? s.journey.key} on ${s.phone.model}${s.phone.os ? ` · ${s.phone.os}` : ""}${s.status === "processed" ? "" : ` (run set ${s.status})`}.`, `Runs: ${s.runs.valid} valid${s.runs.invalid ? `, ${s.runs.invalid} invalid${codes.length ? ` (${list(codes)})` : ""}` : ""}. ${[
23484
+ versionText("SDK", v.sdk),
23485
+ versionText("driver", v.driver),
23486
+ versionText("driver version", v.driver_version),
23487
+ versionText("app", v.app_build),
23488
+ versionText("journey version", v.journey_version),
23489
+ `derivation ${v.derivation}`,
23490
+ `stats ${v.stats}`
23491
+ ].filter(Boolean).join(", ")}.`);
23492
+ if (s.not_computed)
23493
+ return r.both("Not computed: this session has no stored statistics yet; nothing is known about its places (not zero).").render(opts.form);
23494
+ if (s.stale)
23495
+ 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);
23496
+ const by = p.filters.measure;
23497
+ const byLabel = p.places.flatMap((x) => x.measures).find((m) => m.measure === by)?.label ?? by;
23498
+ 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` : ""}.`, "");
23499
+ let componentLines = 0;
23500
+ p.places.forEach((place, i) => {
23501
+ const m = place.measures.find((x) => x.measure === by);
23502
+ const value = m ? `${fieldValueText(by, m.median)} (p90 ${fieldValueText(by, m.p90)}, n ${m.n})` : `no ${by}`;
23503
+ const comps = place.components.map((c) => `${c.name} ${valueText("self_ms", c.self_ms.median)} (p90 ${valueText("self_ms", c.self_ms.p90)}${c.renders_median !== null ? `, ${plural(c.renders_median, "render")}` : ""})`);
23504
+ r.both(`${p.offset + i + 1}. ${place.window_name}: ${value}`);
23505
+ if (comps.length) {
23506
+ r.full(` Components: ${list(comps)}`);
23507
+ componentLines++;
23508
+ }
23509
+ });
23510
+ if (!p.places.length)
23511
+ r.both(p.total ? `Offset ${p.offset} is past the last of ${p.total} places.` : "No place with stored statistics in this session matches.");
23512
+ else if (p.next_offset !== null)
23513
+ r.both("", `Showing ${p.offset + 1}–${p.offset + p.places.length} of ${p.total}; the rest from offset ${p.next_offset} (--offset ${p.next_offset}).`);
23514
+ return r.render(opts.form, componentLines ? [plural(componentLines, "component line")] : []);
23515
+ }
23163
23516
  function formatLayers(l) {
23164
23517
  return [
23165
23518
  l.note,
@@ -23169,6 +23522,100 @@ function formatLayers(l) {
23169
23522
  ].join(`
23170
23523
  `);
23171
23524
  }
23525
+ var MAIN_TIERS_WORST_FIRST = Object.keys(MAIN_TIER_RANK).sort((a, b) => MAIN_TIER_RANK[a] - MAIN_TIER_RANK[b]);
23526
+ function formatMainSessions(h, opts = {}) {
23527
+ const baseline = { baseline: { sessions: h.baseline_sessions } };
23528
+ const r = new Rows().both(`Main sessions: tiers are verdicts vs ${h.baseline_sessions === undefined ? "earlier sessions" : `the ${h.baseline_sessions} sessions before`}.`);
23529
+ if (!h.sessions.length)
23530
+ r.both("No main sessions measured for this journey and phone.");
23531
+ const recent = new Set(h.sessions.slice(-SHORT_SESSIONS));
23532
+ const latest = h.sessions[h.sessions.length - 1];
23533
+ let otherChanges = 0;
23534
+ for (const s of h.sessions) {
23535
+ const k = recent.has(s);
23536
+ r.add(k, `${when(s.at)} ${s.run_set_id} ${commitText(s.commit ? { ...s.commit, author: null } : null)} (${plural(s.runs, "run")})`);
23537
+ if (!s.places)
23538
+ r.add(k, ` ${MAIN_SUMMARY_PENDING}${s.summary_reason ? ` (${s.summary_reason})` : ""}`);
23539
+ else
23540
+ 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)}` : ""}`);
23541
+ if (s.waiting_sessions)
23542
+ r.add(k, ` ${waitingSessionsWords(s.waiting_sessions)}`);
23543
+ if (s.memory)
23544
+ r.add(k, ` Memory: ${MAIN_TIER_WORDS[s.memory.tier]}, ${s.memory.median} MB (n ${s.memory.n})${s.memory.cant_reason ? `; ${mainCantWords(s.memory.cant_reason, baseline)}` : ""}`);
23545
+ for (const v of h.versions.filter((v) => v.from_run_set_id === s.run_set_id))
23546
+ r.add(k, ` Version: ${v.label}; ${mainCutWords(v)}`);
23547
+ for (const c of h.changes.filter((c) => c.from_run_set_id === s.run_set_id)) {
23548
+ r.add(s === latest, ` Main change: ${c.place} ${c.ref}, ${c.before_median ?? "–"} → ${c.after_median ?? "–"}`);
23549
+ if (k && s !== latest)
23550
+ otherChanges++;
23551
+ }
23552
+ }
23553
+ const older = h.sessions.length - recent.size;
23554
+ return r.render(opts.form, [...older ? [plural(older, "older session")] : [], ...otherChanges ? [plural(otherChanges, "main change") + " of the other recent sessions"] : []]);
23555
+ }
23556
+ function formatMainSessionPlaces(p, opts = {}) {
23557
+ const r = new Rows;
23558
+ if (p.status !== "ok")
23559
+ return r.both(`${MAIN_SUMMARY_PENDING}${p.summary_reason ? ` (${p.summary_reason})` : ""}`).render(opts.form);
23560
+ const baselineSessions = p.baseline_sessions;
23561
+ r.both(`Main session places: tiers are verdicts vs ${p.baseline_sessions === undefined ? "earlier sessions" : `the ${p.baseline_sessions} sessions before`}.`);
23562
+ for (const x of p.places)
23563
+ r.add(x.tier !== "same", ` ${x.place} ${x.ref}: ${MAIN_TIER_WORDS[x.tier]}${x.reason ? `; ${mainPlaceReasonWords(x.reason, { baseline: { sessions: baselineSessions } })}` : ""}`);
23564
+ const inNorm = p.places.filter((x) => x.tier === "same").length;
23565
+ return r.render(opts.form, inNorm ? [plural(inNorm, "place") + " in norm"] : []);
23566
+ }
23567
+ var signed = (n) => `${n > 0 ? "+" : ""}${Number(n.toFixed(3))}`;
23568
+ var previousLine = (p) => ` ${p.place} ${p.ref}${p.devices ? ` [devices ${p.devices.before.join(", ") || "–"} → ${p.devices.after.join(", ") || "–"}]` : ""}: ${p.status === "compared" ? "" : `${p.status}; `}${p.before ?? "–"} → ${p.after ?? "–"}; delta ${p.delta === null ? "–" : signed(p.delta)}; ${p.percent_change === null ? "change undefined" : `${signed(p.percent_change)}%`}; n ${p.n.before ?? "–"} → ${p.n.after ?? "–"}`;
23569
+ function formatMainVsPrevious(m, opts = {}) {
23570
+ const r = new Rows().both(`Information only: main session ${m.run_set_id} vs ${m.previous_run_set_id ?? "no previous session"}.`, `Measure: ${m.measure}; ${m.status}.`);
23571
+ if (m.cut === "unknown")
23572
+ r.both("History restart: unknown; current summaries unavailable.");
23573
+ else if (m.cut)
23574
+ r.both(`History restart: ${m.cut.label}; ${m.cut.cuts.join(", ")}. Numbers cross this restart.`);
23575
+ if (m.not_computed.length)
23576
+ r.both(`not_computed: ${m.not_computed.join(", ")}`);
23577
+ if (m.stale.length)
23578
+ r.both(`stale: ${m.stale.join(", ")}`);
23579
+ if (m.journey)
23580
+ r.both("Whole journey:", previousLine(m.journey));
23581
+ m.places.forEach((p, i) => r.add(i < SHORT_PLACES || p.status !== "compared", previousLine(p)));
23582
+ if (m.next_offset !== null)
23583
+ r.both(`Showing ${m.offset + 1}–${m.offset + m.places.length} of ${m.total}; next offset ${m.next_offset}.`);
23584
+ const smaller = m.places.filter((p, i) => i >= SHORT_PLACES && p.status === "compared").length;
23585
+ return r.render(opts.form, smaller ? [plural(smaller, "smaller mover")] : []);
23586
+ }
23587
+ function formatPlaceTrend(t, opts = {}) {
23588
+ const r = new Rows().both(`${t.journey} · ${t.phone.model} · ${t.baseline_branch}: stored per-session statistics`);
23589
+ const newest = new Set(t.sessions.slice(-SHORT_SESSIONS).map((s) => s.run_set_id));
23590
+ const olderSessions = new Set;
23591
+ let olderRestarts = 0;
23592
+ for (const session of t.sessions) {
23593
+ const k = newest.has(session.run_set_id);
23594
+ if (session.restarts_known === false)
23595
+ r.both(`History restarts unknown: ${session.run_set_id} (no current main summary in the history window)`);
23596
+ for (const restart of t.restarts.filter((x) => x.from_run_set_id === session.run_set_id)) {
23597
+ r.add(k, `History restarts: ${restart.label} (${restart.cuts.join(", ")})`);
23598
+ if (!k)
23599
+ olderRestarts++;
23600
+ }
23601
+ for (const place of t.places) {
23602
+ const value = place.sessions.find((s) => s.run_set_id === session.run_set_id);
23603
+ if (!value)
23604
+ continue;
23605
+ const unit = place.unit ? ` ${place.unit}` : "";
23606
+ if (!k)
23607
+ olderSessions.add(session.run_set_id);
23608
+ r.add(k, `${session.completed_at?.slice(0, 10) ?? "date unknown"} ${session.commit.sha?.slice(0, 7) ?? "commit unknown"} · ${place.window} · ${place.label} (${place.ref}): median ${value.median}${unit}, MAD ${value.mad}${unit}, n=${value.n}`);
23609
+ }
23610
+ }
23611
+ if (!t.sessions.length)
23612
+ r.both("No main sessions.");
23613
+ if (t.not_computed.length)
23614
+ r.both(`Not computed: ${t.not_computed.join(", ")}`);
23615
+ if (t.stale.length)
23616
+ r.both(`Stale: ${t.stale.join(", ")}`);
23617
+ return r.render(opts.form, [...olderSessions.size ? [plural(olderSessions.size, "older session")] : [], ...olderRestarts ? [plural(olderRestarts, "older restart marker")] : []]);
23618
+ }
23172
23619
 
23173
23620
  // packages/mcp/src/server.ts
23174
23621
  var SERVER_NAME = "keepsnappy";
@@ -23188,7 +23635,7 @@ function createServer(target) {
23188
23635
  const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
23189
23636
  server.registerTool("list_findings", {
23190
23637
  title: "List performance findings",
23191
- description: "What got worse (Worse / Warning) or better in the app, worst first, as KeepSnappy measured it on real phones. The answer starts with whether and when the branch was measured and how its newest run set read: an empty list on a branch that was never measured, or whose newest run set was not judged, is not a clean result. Filter by a PR branch or main, journey, phone, and state (present by default; not-judged = the newest run set did not judge it, so it may still be there). Each finding says what changed, where, how sure, and has an id for get_finding. Page with offset.",
23638
+ description: "What got worse (Worse / Slightly worse) or better in the app, worst first, as KeepSnappy measured it on real phones. The answer starts with whether and when the branch was measured and how its newest run set read: an empty list on a branch that was never measured, or whose newest run set was not judged, is not a clean result. Filter by a PR branch or main, journey, phone, and state (present by default; not-judged = the newest run set did not judge it, so it may still be there). Each finding says what changed, where, how sure, and has an id for get_finding. Page with offset." + ` ${shortNote("list_findings")}`,
23192
23639
  inputSchema: {
23193
23640
  branch: string2().optional().describe("A PR branch, e.g. feat/keepsnappy-integration"),
23194
23641
  main: boolean2().optional().describe("Findings of main over time instead of a branch"),
@@ -23196,16 +23643,17 @@ function createServer(target) {
23196
23643
  phone: string2().optional().describe('Part of the phone model, e.g. "iPhone 15"'),
23197
23644
  state: _enum(["present", "resolved", "not-judged", "all"]).optional(),
23198
23645
  limit: number2().int().min(1).max(200).optional(),
23199
- offset: number2().int().min(0).optional().describe("Skip this many findings; the answer gives next_offset")
23646
+ offset: number2().int().min(0).optional().describe("Skip this many findings; the answer gives next_offset"),
23647
+ form: formInput
23200
23648
  },
23201
23649
  annotations: READ_ONLY
23202
- }, async (a) => answer(() => listFindings(target, a), formatFindingList));
23650
+ }, async ({ form, ...a }) => answer(() => listFindings(target, a), (l) => formatFindingList(l, { form })));
23203
23651
  server.registerTool("get_finding", {
23204
23652
  title: "One finding with evidence and a prompt",
23205
- description: "One finding by id: what changed, where (journey, phone, branch, commit, PR), how sure (runs, spread, measured differently / not blocking), why (components that rendered longer and their causes, counts that moved), what to do (a prompt for a coding agent), and since when it is seen. The evidence ranks likely causes; it does not prove them.",
23206
- inputSchema: { id: string2().regex(/^f_[0-9a-f]{16}$/).describe("Finding id from list_findings") },
23653
+ description: `One finding by id: what changed, where (journey, phone, branch, commit, PR), how sure (runs, spread, measured differently / not blocking), why (components that rendered longer and their causes, counts that moved), what to do (a prompt for a coding agent), and since when it is seen. The evidence ranks likely causes; it does not prove them. why.status: ok = stored evidence is attached; none = no stored evidence for this place, or a memory/heat finding where React evidence does not explain the measure; no-profiles = the input runs explicitly recorded React export off; no-timings = profiles have no usable component timings; not-enough-runs = too few runs for component evidence. The last two values are reserved: the current stored-judgement reader does not infer them from missing evidence. Null component values were not stored; a new component has a null baseline. Counts are evidence, never a verdict. ${shortNote("get_finding")}`,
23654
+ inputSchema: { id: string2().regex(/^f_[0-9a-f]{16}$/).describe("Finding id from list_findings"), form: formInput },
23207
23655
  annotations: READ_ONLY
23208
- }, async ({ id }) => answer(() => getFinding(target, id), (f) => formatFinding(f, { prompt: true })));
23656
+ }, async ({ id, form }) => answer(() => getFinding(target, id), (f) => formatFinding(f, { prompt: form !== "short", form })));
23209
23657
  server.registerTool("compare_runs", {
23210
23658
  title: "Compare two runs",
23211
23659
  description: "Two runs side by side, window by window, with what differs in how they were measured. One run against one run: no statistics; use findings for verdicts.",
@@ -23214,29 +23662,97 @@ function createServer(target) {
23214
23662
  }, async ({ a, b }) => answer(() => compareRuns(target, a, b), formatCompare));
23215
23663
  server.registerTool("window_history", {
23216
23664
  title: "One window on main over time",
23217
- description: "Every recent main run of one window (a screen, tap or scroll) of a journey, oldest first, with commit titles, and the findings on that window.",
23665
+ description: `Every recent main run of one window (a screen, tap or scroll) of a journey, oldest first, with commit titles, and the findings on that window. ${shortNote("window_history")}`,
23218
23666
  inputSchema: {
23219
23667
  journey: string2().describe("Journey key, e.g. live-ui"),
23220
- window: string2().describe("Window id (press:live.chat.send) or name (Tap: live chat send)"),
23668
+ window: string2().describe("Exact window id (press:live.chat.send); display names are not identifiers"),
23221
23669
  phone: string2().optional(),
23222
- limit: number2().int().min(1).max(200).optional()
23670
+ limit: number2().int().min(1).max(200).optional(),
23671
+ form: formInput
23223
23672
  },
23224
23673
  annotations: READ_ONLY
23225
- }, async (a) => answer(() => windowHistory(target, a), formatHistory));
23674
+ }, async ({ form, ...a }) => answer(() => windowHistory(target, a), (h) => formatHistory(h, { form })));
23226
23675
  server.registerTool("commits_between", {
23227
23676
  title: "Measured commits between two points",
23228
23677
  description: "The commits KeepSnappy measured between two points of a branch (run set ids or commit hashes), one entry per commit with its measuring sessions, title, author and time. Only measured commits; git log gives the rest.",
23229
23678
  inputSchema: { from: string2().min(7), to: string2().min(7).optional(), branch: string2().optional().describe("Default: main") },
23230
23679
  annotations: READ_ONLY
23231
23680
  }, async (a) => answer(() => commitsBetween(target, a), formatCommits));
23681
+ server.registerTool("session_places", {
23682
+ title: "Every place of one session",
23683
+ description: `The overall performance of one measuring session (a run set of one journey on one phone), place by place (a screen, tap or scroll), from the statistics stored for that session: per measure its median, spread (MAD), p10/p90 and range over the session's valid runs, and the components with the most own render time. Use it to find places worth optimising. It is not a verdict and not a comparison: for what got worse use list_findings, for one place over time window_history. Pick the session with run_set, or with journey (+ phone, + branch; default: the app's baseline branch (main)) for its newest closed run set that has a valid run. Places are ranked by measure (default render_ms) and sort (median, p90 or mad, highest first); window narrows by id or name; places without the measure are listed last. Page with offset (the answer gives next_offset). A session with no stored statistics says not_computed, one with statistics of another version says stale: both mean nothing is known about its places, not that they are fine. ${shortNote("session_places")}`,
23684
+ inputSchema: {
23685
+ run_set: string2().optional().describe("Run set id (the answer of other tools names it); otherwise journey picks the newest closed one"),
23686
+ journey: string2().optional().describe("Journey key, e.g. live-ui"),
23687
+ phone: string2().optional().describe('Part of the phone model, e.g. "iPhone 15", or a device id'),
23688
+ branch: string2().optional().describe("With journey: the branch of the run set (default: the app's baseline branch); not with run_set"),
23689
+ measure: string2().optional().describe("The measure places are ranked by (default render_ms), e.g. commit_max_ms, renders, dur_ms"),
23690
+ sort: _enum(SESSION_SORTS).optional().describe("The statistic of the measure to rank by (default median)"),
23691
+ window: string2().optional().describe('Only places whose id or name contains this, e.g. "chat send"'),
23692
+ limit: number2().int().min(1).max(200).optional(),
23693
+ offset: number2().int().min(0).optional().describe("Skip this many places; the answer gives next_offset"),
23694
+ top_components: number2().int().min(0).max(20).optional().describe("Components with the most own time listed per place (default 5)"),
23695
+ form: formInput
23696
+ },
23697
+ annotations: READ_ONLY
23698
+ }, async ({ form, ...a }) => answer(() => sessionPlaces(target, a), (r) => formatSessionPlaces(r, { form })));
23232
23699
  server.registerTool("explain_layers", {
23233
23700
  title: "Which layer answers which question",
23234
23701
  description: "KeepSnappy’s decision layers (instrument, execution, single value, run health, PR vs main, main over time, access), the questions each answers, where to read it, and each layer’s certificate.",
23235
23702
  inputSchema: {},
23236
23703
  annotations: READ_ONLY
23237
23704
  }, async () => answer(() => layers(target), formatLayers));
23705
+ const mainLane = {
23706
+ journey: string2().min(1).describe("Journey key or id"),
23707
+ phone: string2().min(1).describe("Exact device model, as in the dashboard, e.g. iPhone 15")
23708
+ };
23709
+ const mainDescription = "Main session tiers are verdicts vs the earlier sessions (baseline_sessions in the API answer); vs-previous is information, not a verdict.";
23710
+ server.registerTool("main_sessions", {
23711
+ title: "Main sessions and their tiers",
23712
+ description: `${mainDescription} List sessions with tier counts, memory and history restarts, or give run_set for that session’s places and reasons. sessions cannot be combined with run_set. ${shortNote("main_sessions")}`,
23713
+ inputSchema: { ...mainLane, sessions: number2().int().min(1).max(20).optional(), run_set: string2().min(1).optional(), form: formInput },
23714
+ annotations: READ_ONLY
23715
+ }, async ({ run_set, form, ...q }) => {
23716
+ if (run_set && q.sessions !== undefined)
23717
+ return { isError: true, content: [{ type: "text", text: "400: sessions cannot be combined with run_set" }] };
23718
+ return run_set ? answer(() => mainSessionPlaces(target, run_set, { journey: q.journey, phone: q.phone }), (p) => formatMainSessionPlaces(p, { form })) : answer(() => mainSessions(target, q), (h) => formatMainSessions(h, { form }));
23719
+ });
23720
+ server.registerTool("main_vs_previous", {
23721
+ title: "Main session vs previous: stored medians",
23722
+ description: `${mainDescription} Before, after, delta, percent change and n from stored medians in one phone-model lane, with device ids per side; multiple devices on a side return devices_differ without pooling. new/gone are missing on one side, never zero. Flags history restarts. Latest session by default. Sorted by absolute percent change for measure. ${shortNote("main_vs_previous")}`,
23723
+ inputSchema: {
23724
+ ...mainLane,
23725
+ run_set: string2().min(1).optional(),
23726
+ measure: string2().min(1).max(300).optional(),
23727
+ limit: number2().int().min(1).max(200).optional(),
23728
+ offset: number2().int().min(0).optional(),
23729
+ form: formInput
23730
+ },
23731
+ annotations: READ_ONLY
23732
+ }, async ({ form, ...q }) => answer(() => mainVsPrevious(target, q), (m) => formatMainVsPrevious(m, { form })));
23733
+ server.registerTool("place_trend", {
23734
+ title: "One place over main sessions",
23735
+ description: `Stored per-session statistics of one place over main’s sessions, oldest first, with history restart markers. No verdict: use list_findings for a verdict, or session_places for every place in one session. Defaults to every stored measure at the place; request component refs explicitly. ${shortNote("place_trend")}`,
23736
+ inputSchema: {
23737
+ journey: string2().min(1).describe("Journey key or id"),
23738
+ phone: string2().min(1).describe("Device id or part of the phone model; ambiguous matches are rejected"),
23739
+ window: array(string2().min(1)).min(1).max(50).describe("Place ids or names"),
23740
+ ref: array(string2().min(1)).min(1).max(200).optional().describe("Refs, e.g. m:render_ms or b:component:<key>:self_ms"),
23741
+ sessions: number2().int().min(1).max(30).optional().describe("Newest main sessions, default 10"),
23742
+ form: formInput
23743
+ },
23744
+ annotations: READ_ONLY
23745
+ }, async ({ form, ...a }) => answer(() => placeTrend(target, a), (t) => formatPlaceTrend(t, { form })));
23746
+ server.registerTool("pr_status", {
23747
+ title: "Is my PR ok?",
23748
+ description: `Is this branch ok, in one call. The answer is blocked, nothing blocks with results to look at, or ok; per journey × phone it gives the deciding word and whether it blocks, the worst present findings with ids for get_finding, and dashboard links. A branch that was never measured, whose newest run set was not judged, or that could not be compared yet is not ok: the answer says which, with its reason. Use list_findings for every finding. ${shortNote("pr_status")}`,
23749
+ inputSchema: { branch: string2().min(1).describe("The PR branch, e.g. feat/keepsnappy-integration"), form: formInput },
23750
+ annotations: READ_ONLY
23751
+ }, async ({ branch, form }) => answer(() => branchStatus(target, branch), (s) => formatBranchStatus(s, { form })));
23238
23752
  return server;
23239
23753
  }
23754
+ var formInput = _enum(ANSWER_FORMS).optional().describe("full (default): the whole answer. short: what decides, without the evidence detail; its last line says what it left out. The same JSON either way");
23755
+ var shortNote = (tool) => `form short leaves out ${SHORT_FORM_NOTES[tool]}, and ends with a line saying so.`;
23240
23756
 
23241
23757
  // packages/mcp/src/bin.ts
23242
23758
  var key = process.env.KEEPSNAPPY_KEY;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keepsnappy/mcp",
3
- "version": "0.1.0-alpha.13",
3
+ "version": "0.1.0-alpha.16",
4
4
  "description": "KeepSnappy MCP server (stdio, read-only): performance findings, compare, window history and commits for coding agents.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",