@keepsnappy/mcp 0.1.0-alpha.15 → 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,12 +33,13 @@ 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=` |
@@ -53,7 +56,11 @@ An empty list is a clean result only when `measured.newest` exists and was judge
53
56
  KeepSnappy measurement on branch X" or "the newest run set was not judged" otherwise. Query parameters the API does not know
54
57
  are dropped, not refused (there is no `?commit=` filter).
55
58
 
56
- 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
57
64
  (4 = PR vs main, 5 = main over time), its certificate (null until plan 51 certifies the layer) and its limits.
58
65
 
59
66
  ## Words
@@ -14086,6 +14086,7 @@ async function get(t, path, query = {}) {
14086
14086
  return body;
14087
14087
  }
14088
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 });
14089
14090
  var getFinding = (t, id) => get(t, `v1/findings/${encodeURIComponent(id)}`);
14090
14091
  var compareRuns = (t, a, b) => get(t, "v1/compare", { a, b });
14091
14092
  var windowHistory = (t, q) => get(t, "v1/history", q);
@@ -23002,6 +23003,7 @@ var DIMENSIONS = {
23002
23003
  };
23003
23004
  var BREAKDOWN_MEASURES = {
23004
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 },
23005
23007
  total_ms: { unit: "ms", worse: "higher", label: "total render time", decimals: 1 },
23006
23008
  renders: { unit: "count", worse: "higher", label: "renders", decimals: 0 },
23007
23009
  mounts: { unit: "count", worse: "higher", label: "mounts", decimals: 0 },
@@ -23012,27 +23014,12 @@ var HEAT_METRIC = "sampler.thermal.heats_up";
23012
23014
  var HEAT_LABEL = MEASURES[HEAT_METRIC].label;
23013
23015
  // packages/facts/dist/src/frame.js
23014
23016
  var LIMIT_FRAME_MS = 1000 / 60;
23015
- // packages/present/dist/src/places.js
23016
- var PLACE_KIND_WORDS = {
23017
- screen: "Screen",
23018
- launch: "Launch screen",
23019
- tap: "Tap",
23020
- scroll: "Scroll",
23021
- step: "Test step",
23022
- journey: "Whole journey",
23023
- background: "Background",
23024
- after3s: "3 s after"
23025
- };
23026
- var KIND_WORD = {
23027
- journey: PLACE_KIND_WORDS.journey,
23028
- screen: PLACE_KIND_WORDS.screen,
23029
- launch: PLACE_KIND_WORDS.launch,
23030
- press: PLACE_KIND_WORDS.tap,
23031
- drag: PLACE_KIND_WORDS.scroll,
23032
- step: PLACE_KIND_WORDS.step,
23033
- background: PLACE_KIND_WORDS.background
23034
- };
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
23035
23021
  var GENERIC_GROUPS = new Set(["app", "tabs", "root"]);
23022
+
23036
23023
  // packages/present/dist/src/states.js
23037
23024
  var STATE_WORDS = {
23038
23025
  worse: "Worse",
@@ -23042,6 +23029,9 @@ var STATE_WORDS = {
23042
23029
  "cant-tell": "Can’t tell"
23043
23030
  };
23044
23031
  var INFO_WORDS = { heat: "Phone heated up" };
23032
+ var isWorseState = (state) => state === "worse";
23033
+ var NOT_BLOCKING = "not blocking";
23034
+
23045
23035
  // packages/present/dist/src/values.js
23046
23036
  var NO_VALUE = "–";
23047
23037
  var FALLBACK_DECIMALS = 1;
@@ -23097,6 +23087,43 @@ function fieldValueText(field, v) {
23097
23087
  const def = fieldDef(field.split(KEY_SEPARATOR)[0]);
23098
23088
  return format(def?.unit, def?.decimals ?? FALLBACK_DECIMALS, v);
23099
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
+ };
23100
23127
  // packages/present/dist/src/words.js
23101
23128
  var REASON_WORDS = {
23102
23129
  "no-baseline": "there are no main runs on this phone and journey yet",
@@ -23109,6 +23136,7 @@ var REASON_WORDS = {
23109
23136
  "platforms-differ": "the sides ran on different platforms (iOS and Android are never compared)",
23110
23137
  "not-enough-main": "main does not have enough sessions on this phone and journey yet"
23111
23138
  };
23139
+ var waitingWords = (runs) => `${runs} main ${runs === 1 ? "run waits" : "runs wait"} for a reprocess`;
23112
23140
  // packages/present/dist/src/stability.js
23113
23141
  var UNSTABLE_WORDS = {
23114
23142
  short: "main varies too much here",
@@ -23147,6 +23175,7 @@ function mainCantWords(reason, config) {
23147
23175
  return "";
23148
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}` : ""}`;
23149
23177
  }
23178
+ var waitingSessionsWords = (sessions) => `${sessions} older main ${sessions === 1 ? "session waits" : "sessions wait"} for a reprocess`;
23150
23179
  function mainPlaceReasonWords(reason, config) {
23151
23180
  switch (reason.kind) {
23152
23181
  case "no_baseline":
@@ -23176,37 +23205,113 @@ var SHORT_ASPECT_WORDS = {
23176
23205
  driverVersion: "Driver",
23177
23206
  journeyVersion: "Journey"
23178
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
+ }
23179
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
+ }
23180
23286
  var LABEL_W = 14;
23181
23287
  var line = (label, text) => ` ${label.padEnd(LABEL_W)}${text}`;
23182
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.";
23183
23290
  function formatFinding(f, opts = {}) {
23184
- const out = [
23185
- f.answer.headline,
23186
- line("What changed", f.answer.what_changed),
23187
- line("Where", f.answer.where),
23188
- line("How sure", f.answer.how_sure),
23189
- line("Why", f.answer.why),
23190
- line("What to do", f.answer.what_to_do),
23191
- line("Seen", f.answer.lifecycle),
23192
- line("Layer", `${f.layer.id} · ${f.layer.name} (${f.certificate ? "certified" : "uncertified"})${f.limits.length ? `; limits: ${f.limits.join("; ")}` : ""}`),
23193
- line("Id", f.id)
23194
- ];
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));
23195
23297
  if (f.why.components.length) {
23196
- out.push(line("Components", ""));
23298
+ r.full(line("Components", ""));
23197
23299
  for (const c of f.why.components) {
23198
- 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)"}`);
23199
23301
  }
23200
23302
  }
23201
23303
  if (opts.prompt)
23202
- out.push("", f.what_to_do.prompt ?? "No prompt: this finding is an improvement.");
23203
- return out.join(`
23204
- `);
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
+ ]);
23205
23310
  }
23206
23311
  var when = (iso) => iso.slice(0, 16).replace("T", " ");
23207
23312
  var LANES_SHOWN = 8;
23208
23313
  var STATE_WORDS2 = { present: "still present", resolved: "resolved", "not-judged": "not judged in the newest run set" };
23209
- function measuredLine(l) {
23314
+ function measuredLines(l, r) {
23210
23315
  const f = l.filters;
23211
23316
  const scope = [f.branch ?? "all branches", f.journey, f.phone].filter(Boolean).join(" · ");
23212
23317
  const m = l.measured;
@@ -23214,21 +23319,25 @@ function measuredLine(l) {
23214
23319
  if (!n) {
23215
23320
  const on = f.branch ? f.branch === l.baseline_branch ? f.branch : `branch ${f.branch}` : "any branch";
23216
23321
  const narrow = [f.journey, f.phone ? `phone “${f.phone}”` : null].filter(Boolean).join(", ");
23217
- 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 [];
23218
23324
  }
23219
23325
  const lane = (x) => {
23220
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}` : "";
23221
- 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)}` : ""}`;
23222
23328
  };
23223
- if (m.lanes.length === 1)
23224
- 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
+ }
23225
23333
  const shown = m.lanes.slice(0, LANES_SHOWN);
23226
- return [
23227
- `${scope}: ${plural(m.run_sets, "run set")} measured; the newest of each journey × phone:`,
23228
- ...shown.map((x) => ` ${x.journey} on ${x.phone_name}${f.branch ? "" : ` (${x.branch})`}: ${lane(x)}`),
23229
- ...m.lanes.length > shown.length ? [` … ${m.lanes.length - shown.length} more (--journey / --phone narrow it)`] : []
23230
- ].join(`
23231
- `);
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"] : []];
23232
23341
  }
23233
23342
  function countsLine(l) {
23234
23343
  const c = l.counts;
@@ -23248,22 +23357,69 @@ function countsLine(l) {
23248
23357
  const line = `${head}${more.length ? `; ${more.join("; ")}` : ""}.`;
23249
23358
  return line.charAt(0).toUpperCase() + line.slice(1);
23250
23359
  }
23251
- function formatFindingList(l) {
23252
- const out = [measuredLine(l)];
23360
+ function formatFindingList(l, opts = {}) {
23361
+ const r = new Rows;
23362
+ const lanesLeft = measuredLines(l, r);
23253
23363
  if (l.measured.newest || l.total)
23254
- out.push(countsLine(l));
23364
+ r.both(countsLine(l));
23255
23365
  l.findings.forEach((f, i) => {
23256
- 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}`);
23257
23370
  });
23258
23371
  const shown = l.findings.length ? `Showing ${l.offset + 1}–${l.offset + l.findings.length} of ${l.total}` : "";
23259
23372
  if (l.next_offset !== null)
23260
- 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}).`);
23261
23374
  else if (l.offset > 0)
23262
- 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}.`);
23263
23376
  if (l.findings.length)
23264
- out.push("", "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt");
23265
- return out.join(`
23266
- `);
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"] : []]);
23267
23423
  }
23268
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";
23269
23425
  function formatCompare(r) {
@@ -23285,19 +23441,20 @@ function formatCompare(r) {
23285
23441
  return out.join(`
23286
23442
  `);
23287
23443
  }
23288
- function formatHistory(h) {
23289
- 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"}`);
23290
23447
  const ms = h.measures.slice(0, 4);
23291
23448
  if (h.points.length) {
23292
- out.push(` ${"when".padEnd(17)}${ms.map((m) => (m.label.length > 22 ? `${m.label.slice(0, 21)}…` : m.label).padStart(24)).join("")} commit`);
23293
- for (const p of h.points) {
23294
- 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)}`);
23295
- }
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
+ });
23296
23453
  }
23297
23454
  if (h.findings.length)
23298
- out.push("", "Findings on this window:", ...h.findings.map((f) => ` ${f.answer.headline} — ${f.where.branch} (${f.id})`));
23299
- return out.join(`
23300
- `);
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")] : []);
23301
23458
  }
23302
23459
  function formatCommits(c) {
23303
23460
  return [
@@ -23317,50 +23474,44 @@ function versionText(label, counts) {
23317
23474
  return `${label} ${name(values[0])}`;
23318
23475
  return `${label} mixed: ${values.map((k) => `${name(k)} ×${counts[k]}`).join(", ")}`;
23319
23476
  }
23320
- function formatSessionPlaces(r) {
23321
- const s = r.session;
23477
+ function formatSessionPlaces(p, opts = {}) {
23478
+ const s = p.session;
23322
23479
  const sha = s.commit?.sha?.slice(0, 7);
23323
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";
23324
23481
  const v = s.versions;
23325
23482
  const codes = Object.keys(s.runs.invalid_codes);
23326
- const head = [
23327
- `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})`}.`,
23328
- `Runs: ${s.runs.valid} valid${s.runs.invalid ? `, ${s.runs.invalid} invalid${codes.length ? ` (${list(codes)})` : ""}` : ""}. ${[
23329
- versionText("SDK", v.sdk),
23330
- versionText("driver", v.driver),
23331
- versionText("driver version", v.driver_version),
23332
- versionText("app", v.app_build),
23333
- versionText("journey version", v.journey_version),
23334
- `derivation ${v.derivation}`,
23335
- `stats ${v.stats}`
23336
- ].filter(Boolean).join(", ")}.`
23337
- ];
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(", ")}.`);
23338
23492
  if (s.not_computed)
23339
- return [...head, "Not computed: this session has no stored statistics yet; nothing is known about its places (not zero)."].join(`
23340
- `);
23493
+ return r.both("Not computed: this session has no stored statistics yet; nothing is known about its places (not zero).").render(opts.form);
23341
23494
  if (s.stale)
23342
- return [...head, "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)."].join(`
23343
- `);
23344
- const by = r.filters.measure;
23345
- const byLabel = r.places.flatMap((p) => p.measures).find((m) => m.measure === by)?.label ?? by;
23346
- const ranked = r.places.map((p, i) => {
23347
- const m = p.measures.find((x) => x.measure === by);
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);
23348
23502
  const value = m ? `${fieldValueText(by, m.median)} (p90 ${fieldValueText(by, m.p90)}, n ${m.n})` : `no ${by}`;
23349
- const comps = p.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")}` : ""})`);
23350
- return [`${r.offset + i + 1}. ${p.window_name}: ${value}`, ...comps.length ? [` Components: ${list(comps)}`] : []];
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
+ }
23351
23509
  });
23352
- const out = [
23353
- ...head,
23354
- `Stored per-session statistics: no verdict, no comparison. Places by ${byLabel} (${r.filters.sort}), highest first${r.filters.window ? `, matching “${r.filters.window}”` : ""}${r.without_measure ? `; ${r.without_measure} of ${r.total} places ${r.without_measure === 1 ? "has" : "have"} no value for it` : ""}.`,
23355
- "",
23356
- ...ranked.flat()
23357
- ];
23358
- if (!r.places.length)
23359
- out.push(r.total ? `Offset ${r.offset} is past the last of ${r.total} places.` : "No place with stored statistics in this session matches.");
23360
- else if (r.next_offset !== null)
23361
- out.push("", `Showing ${r.offset + 1}–${r.offset + r.places.length} of ${r.total}; the rest from offset ${r.next_offset} (--offset ${r.next_offset}).`);
23362
- return out.join(`
23363
- `);
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")] : []);
23364
23515
  }
23365
23516
  function formatLayers(l) {
23366
23517
  return [
@@ -23372,81 +23523,98 @@ function formatLayers(l) {
23372
23523
  `);
23373
23524
  }
23374
23525
  var MAIN_TIERS_WORST_FIRST = Object.keys(MAIN_TIER_RANK).sort((a, b) => MAIN_TIER_RANK[a] - MAIN_TIER_RANK[b]);
23375
- function formatMainSessions(h) {
23526
+ function formatMainSessions(h, opts = {}) {
23376
23527
  const baseline = { baseline: { sessions: h.baseline_sessions } };
23377
- const out = [`Main sessions: tiers are verdicts vs ${h.baseline_sessions === undefined ? "earlier sessions" : `the ${h.baseline_sessions} sessions before`}.`];
23528
+ const r = new Rows().both(`Main sessions: tiers are verdicts vs ${h.baseline_sessions === undefined ? "earlier sessions" : `the ${h.baseline_sessions} sessions before`}.`);
23378
23529
  if (!h.sessions.length)
23379
- out.push("No main sessions measured for this journey and phone.");
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;
23380
23534
  for (const s of h.sessions) {
23381
- out.push(`${when(s.at)} ${s.run_set_id} ${commitText(s.commit ? { ...s.commit, author: null } : null)} (${plural(s.runs, "run")})`);
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")})`);
23382
23537
  if (!s.places)
23383
- out.push(` ${MAIN_SUMMARY_PENDING}${s.summary_reason ? ` (${s.summary_reason})` : ""}`);
23538
+ r.add(k, ` ${MAIN_SUMMARY_PENDING}${s.summary_reason ? ` (${s.summary_reason})` : ""}`);
23384
23539
  else
23385
- out.push(` ${MAIN_TIERS_WORST_FIRST.map((tier) => `${MAIN_TIER_WORDS[tier]}: ${s.places[tier]}`).join("; ")}${s.cant_reason ? `; ${mainCantWords(s.cant_reason, baseline)}` : ""}`);
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)}`);
23386
23543
  if (s.memory)
23387
- out.push(` 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)}` : ""}`);
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)}` : ""}`);
23388
23545
  for (const v of h.versions.filter((v) => v.from_run_set_id === s.run_set_id))
23389
- out.push(` Version: ${v.label}${v.cuts.length ? `; history restart: ${v.cuts.join(", ")}` : ""}`);
23390
- for (const c of h.changes.filter((c) => c.from_run_set_id === s.run_set_id))
23391
- out.push(` Main change: ${c.place} ${c.ref}, ${c.before_median ?? "–"} → ${c.after_median ?? "–"}`);
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
+ }
23392
23552
  }
23393
- return out.join(`
23394
- `);
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"] : []]);
23395
23555
  }
23396
- function formatMainSessionPlaces(p) {
23556
+ function formatMainSessionPlaces(p, opts = {}) {
23557
+ const r = new Rows;
23397
23558
  if (p.status !== "ok")
23398
- return `${MAIN_SUMMARY_PENDING}${p.summary_reason ? ` (${p.summary_reason})` : ""}`;
23559
+ return r.both(`${MAIN_SUMMARY_PENDING}${p.summary_reason ? ` (${p.summary_reason})` : ""}`).render(opts.form);
23399
23560
  const baselineSessions = p.baseline_sessions;
23400
- return [
23401
- `Main session places: tiers are verdicts vs ${p.baseline_sessions === undefined ? "earlier sessions" : `the ${p.baseline_sessions} sessions before`}.`,
23402
- ...p.places.map((p) => ` ${p.place} ${p.ref}: ${MAIN_TIER_WORDS[p.tier]}${p.reason ? `; ${mainPlaceReasonWords(p.reason, { baseline: { sessions: baselineSessions } })}` : ""}`)
23403
- ].join(`
23404
- `);
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"] : []);
23405
23566
  }
23406
23567
  var signed = (n) => `${n > 0 ? "+" : ""}${Number(n.toFixed(3))}`;
23407
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 ?? "–"}`;
23408
- function formatMainVsPrevious(r) {
23409
- const out = [`Information only: main session ${r.run_set_id} vs ${r.previous_run_set_id ?? "no previous session"}.`, `Measure: ${r.measure}; ${r.status}.`];
23410
- if (r.cut === "unknown")
23411
- out.push("History restart: unknown; current summaries unavailable.");
23412
- else if (r.cut)
23413
- out.push(`History restart: ${r.cut.label}; ${r.cut.cuts.join(", ")}. Numbers cross this restart.`);
23414
- if (r.not_computed.length)
23415
- out.push(`not_computed: ${r.not_computed.join(", ")}`);
23416
- if (r.stale.length)
23417
- out.push(`stale: ${r.stale.join(", ")}`);
23418
- if (r.journey)
23419
- out.push("Whole journey:", previousLine(r.journey));
23420
- out.push(...r.places.map(previousLine));
23421
- if (r.next_offset !== null)
23422
- out.push(`Showing ${r.offset + 1}–${r.offset + r.places.length} of ${r.total}; next offset ${r.next_offset}.`);
23423
- return out.join(`
23424
- `);
23425
- }
23426
- function formatPlaceTrend(t) {
23427
- const out = [`${t.journey} · ${t.phone.model} · ${t.baseline_branch}: stored per-session statistics`];
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;
23428
23592
  for (const session of t.sessions) {
23593
+ const k = newest.has(session.run_set_id);
23429
23594
  if (session.restarts_known === false)
23430
- out.push(`History restarts unknown: ${session.run_set_id} (no current main summary in the history window)`);
23431
- for (const restart of t.restarts.filter((r) => r.from_run_set_id === session.run_set_id)) {
23432
- out.push(`History restarts: ${restart.label} (${restart.cuts.join(", ")})`);
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++;
23433
23600
  }
23434
23601
  for (const place of t.places) {
23435
23602
  const value = place.sessions.find((s) => s.run_set_id === session.run_set_id);
23436
23603
  if (!value)
23437
23604
  continue;
23438
23605
  const unit = place.unit ? ` ${place.unit}` : "";
23439
- out.push(`${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}`);
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}`);
23440
23609
  }
23441
23610
  }
23442
23611
  if (!t.sessions.length)
23443
- out.push("No main sessions.");
23612
+ r.both("No main sessions.");
23444
23613
  if (t.not_computed.length)
23445
- out.push(`Not computed: ${t.not_computed.join(", ")}`);
23614
+ r.both(`Not computed: ${t.not_computed.join(", ")}`);
23446
23615
  if (t.stale.length)
23447
- out.push(`Stale: ${t.stale.join(", ")}`);
23448
- return out.join(`
23449
- `);
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")] : []]);
23450
23618
  }
23451
23619
 
23452
23620
  // packages/mcp/src/server.ts
@@ -23467,7 +23635,7 @@ function createServer(target) {
23467
23635
  const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
23468
23636
  server.registerTool("list_findings", {
23469
23637
  title: "List performance findings",
23470
- 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.",
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")}`,
23471
23639
  inputSchema: {
23472
23640
  branch: string2().optional().describe("A PR branch, e.g. feat/keepsnappy-integration"),
23473
23641
  main: boolean2().optional().describe("Findings of main over time instead of a branch"),
@@ -23475,16 +23643,17 @@ function createServer(target) {
23475
23643
  phone: string2().optional().describe('Part of the phone model, e.g. "iPhone 15"'),
23476
23644
  state: _enum(["present", "resolved", "not-judged", "all"]).optional(),
23477
23645
  limit: number2().int().min(1).max(200).optional(),
23478
- 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
23479
23648
  },
23480
23649
  annotations: READ_ONLY
23481
- }, async (a) => answer(() => listFindings(target, a), formatFindingList));
23650
+ }, async ({ form, ...a }) => answer(() => listFindings(target, a), (l) => formatFindingList(l, { form })));
23482
23651
  server.registerTool("get_finding", {
23483
23652
  title: "One finding with evidence and a prompt",
23484
- 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.",
23485
- 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 },
23486
23655
  annotations: READ_ONLY
23487
- }, 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 })));
23488
23657
  server.registerTool("compare_runs", {
23489
23658
  title: "Compare two runs",
23490
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.",
@@ -23493,15 +23662,16 @@ function createServer(target) {
23493
23662
  }, async ({ a, b }) => answer(() => compareRuns(target, a, b), formatCompare));
23494
23663
  server.registerTool("window_history", {
23495
23664
  title: "One window on main over time",
23496
- 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")}`,
23497
23666
  inputSchema: {
23498
23667
  journey: string2().describe("Journey key, e.g. live-ui"),
23499
- 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"),
23500
23669
  phone: string2().optional(),
23501
- limit: number2().int().min(1).max(200).optional()
23670
+ limit: number2().int().min(1).max(200).optional(),
23671
+ form: formInput
23502
23672
  },
23503
23673
  annotations: READ_ONLY
23504
- }, async (a) => answer(() => windowHistory(target, a), formatHistory));
23674
+ }, async ({ form, ...a }) => answer(() => windowHistory(target, a), (h) => formatHistory(h, { form })));
23505
23675
  server.registerTool("commits_between", {
23506
23676
  title: "Measured commits between two points",
23507
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.",
@@ -23510,7 +23680,7 @@ function createServer(target) {
23510
23680
  }, async (a) => answer(() => commitsBetween(target, a), formatCommits));
23511
23681
  server.registerTool("session_places", {
23512
23682
  title: "Every place of one session",
23513
- 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.",
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")}`,
23514
23684
  inputSchema: {
23515
23685
  run_set: string2().optional().describe("Run set id (the answer of other tools names it); otherwise journey picks the newest closed one"),
23516
23686
  journey: string2().optional().describe("Journey key, e.g. live-ui"),
@@ -23521,10 +23691,11 @@ function createServer(target) {
23521
23691
  window: string2().optional().describe('Only places whose id or name contains this, e.g. "chat send"'),
23522
23692
  limit: number2().int().min(1).max(200).optional(),
23523
23693
  offset: number2().int().min(0).optional().describe("Skip this many places; the answer gives next_offset"),
23524
- top_components: number2().int().min(0).max(20).optional().describe("Components with the most own time listed per place (default 5)")
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
23525
23696
  },
23526
23697
  annotations: READ_ONLY
23527
- }, async (a) => answer(() => sessionPlaces(target, a), formatSessionPlaces));
23698
+ }, async ({ form, ...a }) => answer(() => sessionPlaces(target, a), (r) => formatSessionPlaces(r, { form })));
23528
23699
  server.registerTool("explain_layers", {
23529
23700
  title: "Which layer answers which question",
23530
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.",
@@ -23538,40 +23709,50 @@ function createServer(target) {
23538
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.";
23539
23710
  server.registerTool("main_sessions", {
23540
23711
  title: "Main sessions and their tiers",
23541
- 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.`,
23542
- inputSchema: { ...mainLane, sessions: number2().int().min(1).max(20).optional(), run_set: string2().min(1).optional() },
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 },
23543
23714
  annotations: READ_ONLY
23544
- }, async ({ run_set, ...q }) => {
23715
+ }, async ({ run_set, form, ...q }) => {
23545
23716
  if (run_set && q.sessions !== undefined)
23546
23717
  return { isError: true, content: [{ type: "text", text: "400: sessions cannot be combined with run_set" }] };
23547
- return run_set ? answer(() => mainSessionPlaces(target, run_set, { journey: q.journey, phone: q.phone }), formatMainSessionPlaces) : answer(() => mainSessions(target, q), formatMainSessions);
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 }));
23548
23719
  });
23549
23720
  server.registerTool("main_vs_previous", {
23550
23721
  title: "Main session vs previous: stored medians",
23551
- 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.`,
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")}`,
23552
23723
  inputSchema: {
23553
23724
  ...mainLane,
23554
23725
  run_set: string2().min(1).optional(),
23555
23726
  measure: string2().min(1).max(300).optional(),
23556
23727
  limit: number2().int().min(1).max(200).optional(),
23557
- offset: number2().int().min(0).optional()
23728
+ offset: number2().int().min(0).optional(),
23729
+ form: formInput
23558
23730
  },
23559
23731
  annotations: READ_ONLY
23560
- }, async (q) => answer(() => mainVsPrevious(target, q), formatMainVsPrevious));
23732
+ }, async ({ form, ...q }) => answer(() => mainVsPrevious(target, q), (m) => formatMainVsPrevious(m, { form })));
23561
23733
  server.registerTool("place_trend", {
23562
23734
  title: "One place over main sessions",
23563
- 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.",
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")}`,
23564
23736
  inputSchema: {
23565
23737
  journey: string2().min(1).describe("Journey key or id"),
23566
23738
  phone: string2().min(1).describe("Device id or part of the phone model; ambiguous matches are rejected"),
23567
23739
  window: array(string2().min(1)).min(1).max(50).describe("Place ids or names"),
23568
23740
  ref: array(string2().min(1)).min(1).max(200).optional().describe("Refs, e.g. m:render_ms or b:component:<key>:self_ms"),
23569
- sessions: number2().int().min(1).max(30).optional().describe("Newest main sessions, default 10")
23741
+ sessions: number2().int().min(1).max(30).optional().describe("Newest main sessions, default 10"),
23742
+ form: formInput
23570
23743
  },
23571
23744
  annotations: READ_ONLY
23572
- }, async (a) => answer(() => placeTrend(target, a), formatPlaceTrend));
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 })));
23573
23752
  return server;
23574
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.`;
23575
23756
 
23576
23757
  // packages/mcp/src/bin.ts
23577
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.15",
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",