@keepsnappy/agent 0.1.0-alpha.24 → 0.1.0-alpha.25

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.
@@ -560,10 +560,144 @@ async function readProjectConfig(read, explicit, cwd = process.cwd()) {
560
560
 
561
561
  // apps/agent/src/run-command.ts
562
562
  import { readFile } from "node:fs/promises";
563
- import { join as join9, resolve as resolve3 } from "node:path";
563
+ import { join as join10, resolve as resolve3 } from "node:path";
564
564
 
565
- // apps/agent/src/data-source.ts
565
+ // apps/agent/src/device-select.ts
566
+ import { basename } from "node:path";
567
+
568
+ // apps/agent/src/device-model.ts
569
+ import { tmpdir } from "node:os";
566
570
  import { join as join2 } from "node:path";
571
+ function parseDevicectlList(json) {
572
+ let data;
573
+ try {
574
+ data = JSON.parse(json);
575
+ } catch {
576
+ return [];
577
+ }
578
+ const devices = Array.isArray(data.result?.devices) ? data.result.devices : [];
579
+ return devices.flatMap((d) => {
580
+ const hw = d.hardwareProperties ?? {};
581
+ const conn = d.connectionProperties ?? {};
582
+ if (typeof hw.productType !== "string" || !hw.productType)
583
+ return [];
584
+ const text = (v) => typeof v === "string" ? v : "";
585
+ return [{ productType: hw.productType, udid: text(hw.udid), identifier: text(d.identifier), serial: text(hw.serialNumber), connected: conn.tunnelState === "connected" }];
586
+ });
587
+ }
588
+ var parseSimulators = (json) => {
589
+ try {
590
+ const data = JSON.parse(json);
591
+ return Object.values(data.devices ?? {}).flat();
592
+ } catch {
593
+ return [];
594
+ }
595
+ };
596
+ var withSimulatorWord = (name) => /simulator/i.test(name) ? name : `${name} Simulator`;
597
+ var simulatorModel = (s) => withSimulatorWord(s.name);
598
+ async function androidModel(deps, id) {
599
+ const r = await deps.spawner.run("adb", [...id ? ["-s", id] : [], "shell", "getprop", "ro.product.model"]);
600
+ const model = r.code === 0 ? (r.stdout.split(`
601
+ `)[0] ?? "").trim() : "";
602
+ return model || null;
603
+ }
604
+ async function simulators(deps, id) {
605
+ const r = await deps.spawner.run("xcrun", ["simctl", "list", "devices", ...id ? [] : ["booted"], "-j"]);
606
+ if (r.code !== 0)
607
+ return [];
608
+ return parseSimulators(r.stdout).filter((s) => id ? s.udid === id : s.state === "Booted");
609
+ }
610
+ async function phones(deps) {
611
+ const out = join2(tmpdir(), `keepsnappy-devicectl-${deps.clock.now()}.json`);
612
+ try {
613
+ const r = await deps.spawner.run("xcrun", ["devicectl", "list", "devices", "--quiet", "--json-output", out]);
614
+ const text = r.code === 0 ? await deps.fs.readFile(out).catch(() => "") : "";
615
+ return parseDevicectlList(text);
616
+ } finally {
617
+ await deps.spawner.run("rm", ["-f", out]);
618
+ }
619
+ }
620
+ async function iosModel(deps, id) {
621
+ const sims = await simulators(deps, id);
622
+ if (id) {
623
+ if (sims.length === 1)
624
+ return simulatorModel(sims[0]);
625
+ const found = (await phones(deps)).filter((p) => [p.udid, p.identifier, p.serial].some((x) => x.toLowerCase() === id.toLowerCase()));
626
+ return found.length === 1 ? found[0].productType : null;
627
+ }
628
+ const connected = (await phones(deps)).filter((p) => p.connected);
629
+ if (sims.length + connected.length !== 1)
630
+ return null;
631
+ return sims[0] ? simulatorModel(sims[0]) : connected[0].productType;
632
+ }
633
+ async function detectDeviceModel(deps, q) {
634
+ if (q.platform === "android")
635
+ return androidModel(deps, q.deviceId);
636
+ if (q.platform === "ios")
637
+ return iosModel(deps, q.deviceId);
638
+ return null;
639
+ }
640
+ async function detectSimulator(deps, udid) {
641
+ const sims = await simulators(deps, udid);
642
+ return sims.length === 1 ? simulatorModel(sims[0]) : null;
643
+ }
644
+
645
+ // apps/agent/src/device-select.ts
646
+ var CHOOSE = "name one with --device <UDID>, or `device: <UDID>` in keepsnappy.yml";
647
+ var label = (d) => `${d.name} (${d.udid})`;
648
+ var list = (ds) => ds.map(label).join(", ");
649
+ async function simulators2(deps) {
650
+ const r = await deps.spawner.run("xcrun", ["simctl", "list", "devices", "-j"]);
651
+ if (r.code !== 0)
652
+ throw new ConfigError([`cannot list simulators: ${(r.stderr || r.error || `xcrun simctl exited ${r.code}`).trim()}`]);
653
+ return parseSimulators(r.stdout);
654
+ }
655
+ async function resolveRunDevice(deps, platform, named) {
656
+ if (platform === "ios-device") {
657
+ if (!named)
658
+ throw new ConfigError([`no device named: ${CHOOSE}`]);
659
+ const phone = (await phones(deps)).find((p) => [p.udid, p.identifier, p.serial].some((x) => x.toLowerCase() === named.toLowerCase()));
660
+ if (!phone)
661
+ throw new ConfigError([`device ${named} not found: no connected iPhone has that id (xcrun devicectl list devices)`]);
662
+ if (!phone.connected)
663
+ throw new ConfigError([`device ${named} is not connected: plug it in and unlock it`]);
664
+ return named;
665
+ }
666
+ const all = await simulators2(deps);
667
+ const booted = all.filter((s) => s.state === "Booted");
668
+ if (named) {
669
+ const sim = all.find((s) => s.udid.toLowerCase() === named.toLowerCase());
670
+ if (!sim)
671
+ throw new ConfigError([`simulator ${named} not found${booted.length ? ` (booted: ${list(booted)})` : " (none is booted)"}`]);
672
+ if (sim.state !== "Booted")
673
+ throw new ConfigError([`simulator ${label(sim)} is not booted: xcrun simctl boot ${sim.udid}${booted.length ? `, or ${CHOOSE} (booted: ${list(booted)})` : ""}`]);
674
+ return sim.udid;
675
+ }
676
+ if (booted.length === 0)
677
+ throw new ConfigError([`no simulator is booted: boot one (xcrun simctl boot <UDID>) and ${CHOOSE}`]);
678
+ if (booted.length > 1)
679
+ throw new ConfigError([`several simulators are booted and none is named: ${list(booted)}. Maestro would take the first, so ${CHOOSE}`]);
680
+ return booted[0].udid;
681
+ }
682
+ var DEVICE_FLAG = /^--(?:device|udid)(?:=(.*))?$/;
683
+ function withDevice(flow, udid) {
684
+ const [cmd, ...args] = flow.command;
685
+ if (!cmd || basename(cmd) !== "maestro")
686
+ return flow;
687
+ for (const [i, a] of args.entries()) {
688
+ const m = DEVICE_FLAG.exec(a);
689
+ if (!m)
690
+ continue;
691
+ const given = m[1] ?? args[i + 1];
692
+ if (given === udid)
693
+ return flow;
694
+ throw new ConfigError([`the command names device ${given ?? "(none)"} but this run uses ${udid}: drop --device from the command, it is passed for you`]);
695
+ }
696
+ return { ...flow, command: [cmd, "--device", udid, ...args] };
697
+ }
698
+
699
+ // apps/agent/src/data-source.ts
700
+ import { join as join3 } from "node:path";
567
701
  var KEEPSNAPPY_LOG = "Documents/keepsnappy/log.txt";
568
702
  var SAFE_FILE = /^[\w.+:-]+$/;
569
703
  var profilePath = (fileName) => {
@@ -603,10 +737,10 @@ class IosSimulatorDataSource {
603
737
  return dir;
604
738
  }
605
739
  async fetchProfile(bundleId, fileName) {
606
- return this.deps.fs.readFile(join2(await this.dataDir(bundleId), profilePath(fileName)));
740
+ return this.deps.fs.readFile(join3(await this.dataDir(bundleId), profilePath(fileName)));
607
741
  }
608
742
  async fetchLog(bundleId) {
609
- return this.deps.fs.readFile(join2(await this.dataDir(bundleId), KEEPSNAPPY_LOG));
743
+ return this.deps.fs.readFile(join3(await this.dataDir(bundleId), KEEPSNAPPY_LOG));
610
744
  }
611
745
  async describeDevice() {
612
746
  const r = await this.deps.spawner.run("xcrun", ["simctl", "list", "devices", this.udid === "booted" ? "booted" : "", "-j"].filter(Boolean));
@@ -625,7 +759,7 @@ class IosDeviceDataSource {
625
759
  }
626
760
  async fetchLog(bundleId) {
627
761
  await this.deps.fs.mkdir(this.scratchDir);
628
- const dest = join2(this.scratchDir, "keepsnappy.log");
762
+ const dest = join3(this.scratchDir, "keepsnappy.log");
629
763
  const r = await this.deps.spawner.run("xcrun", [
630
764
  "devicectl",
631
765
  "device",
@@ -670,7 +804,7 @@ class IosDeviceDataSource {
670
804
  }
671
805
  async fetchProfile(bundleId, fileName) {
672
806
  await this.deps.fs.mkdir(this.scratchDir);
673
- const dest = join2(this.scratchDir, fileName);
807
+ const dest = join3(this.scratchDir, fileName);
674
808
  const r = await this.deps.spawner.run("xcrun", [
675
809
  "devicectl",
676
810
  "device",
@@ -694,10 +828,10 @@ class IosDeviceDataSource {
694
828
  }
695
829
 
696
830
  // apps/agent/src/flows.ts
697
- import { basename, dirname, join as join3 } from "node:path";
831
+ import { basename as basename2, dirname, join as join4 } from "node:path";
698
832
  var FLOW_FILE = /\.ya?ml$/i;
699
833
  var NOT_FLOWS = new Set(["config.yaml", "config.yml", "keepsnappy.yml", "keepsnappy.yaml", "keepsnappy.yml", "keepsnappy.yaml"]);
700
- var flowStem = (path) => basename(path).replace(FLOW_FILE, "");
834
+ var flowStem = (path) => basename2(path).replace(FLOW_FILE, "");
701
835
  function flowTags(text) {
702
836
  const head = text.split(/^---\s*$/m)[0] ?? "";
703
837
  const inline = /^tags:\s*\[([^\]]*)\]/m.exec(head);
@@ -713,11 +847,11 @@ async function isDirectory(path, listDir) {
713
847
  if (path === "." || path === ".." || path.endsWith("/"))
714
848
  return true;
715
849
  const entries = await listDir(dirname(path) || ".").catch(() => []);
716
- return entries.some((e) => e.name === basename(path) && e.isDir);
850
+ return entries.some((e) => e.name === basename2(path) && e.isDir);
717
851
  }
718
852
  function uniqueNames(files) {
719
853
  const stems = files.map(flowStem);
720
- return files.map((f, i) => stems.filter((s) => s === stems[i]).length > 1 ? `${basename(dirname(f))}-${stems[i]}` : stems[i]);
854
+ return files.map((f, i) => stems.filter((s) => s === stems[i]).length > 1 ? `${basename2(dirname(f))}-${stems[i]}` : stems[i]);
721
855
  }
722
856
  async function findFlowFiles(paths, info, listDir) {
723
857
  const out = new Map;
@@ -733,7 +867,7 @@ async function findFlowFiles(paths, info, listDir) {
733
867
  }
734
868
  const dir = p.replace(/\/+$/, "") || ".";
735
869
  const entries = await listDir(dir).catch(() => []);
736
- files.push(...entries.filter((e) => !e.isDir && FLOW_FILE.test(e.name) && !NOT_FLOWS.has(e.name)).map((e) => dir === "." ? e.name : join3(dir, e.name)).sort());
870
+ files.push(...entries.filter((e) => !e.isDir && FLOW_FILE.test(e.name) && !NOT_FLOWS.has(e.name)).map((e) => dir === "." ? e.name : join4(dir, e.name)).sort());
737
871
  }
738
872
  const meta = info[pat];
739
873
  if (meta && (meta.title || meta.description) && files.length !== 1) {
@@ -2379,7 +2513,7 @@ function withLaunchNames(windows) {
2379
2513
  }
2380
2514
 
2381
2515
  // packages/derive/dist/src/model.js
2382
- var DERIVATION_VERSION = 30;
2516
+ var DERIVATION_VERSION = 31;
2383
2517
  // packages/stats/dist/src/core/rng.js
2384
2518
  function splitmix32(seed) {
2385
2519
  let s = seed >>> 0;
@@ -4722,10 +4856,11 @@ function footprintAdded(windows) {
4722
4856
  return first && last ? footprintEnd(last) - footprintStart(first) : undefined;
4723
4857
  }
4724
4858
  var RECORD_EDGE_MS = 1;
4859
+ var isEndGcRead = (s) => typeof s.raw.reason === "string" && s.raw.reason.startsWith("endgc-read:");
4725
4860
  var SAME_PROCESS_MS = 1000;
4726
- function windowEndFootprints(placed, records) {
4861
+ function windowEndFootprints(placed, records, version) {
4727
4862
  const out = new Map;
4728
- const recs = (records ?? []).map((s) => ({ t: num7(s.raw.t), offset: num7(s.raw.t) === undefined ? undefined : s.wall - num7(s.raw.t), mb: num7(obj7(s.raw.mem).footprintMb) })).filter((r) => r.t !== undefined).sort((a, b) => a.t - b.t);
4863
+ const recs = (records ?? []).filter((s) => version < 31 || !isEndGcRead(s)).map((s) => ({ t: num7(s.raw.t), offset: num7(s.raw.t) === undefined ? undefined : s.wall - num7(s.raw.t), mb: num7(obj7(s.raw.mem).footprintMb) })).filter((r) => r.t !== undefined).sort((a, b) => a.t - b.t);
4729
4864
  for (const w of placed) {
4730
4865
  const own = footprintEnd(w);
4731
4866
  if (own !== undefined) {
@@ -4754,7 +4889,7 @@ function v7Metrics(windows, steps, records, version) {
4754
4889
  for (const [name, v] of added)
4755
4890
  out.set(`step:${name}.sampler_footprint_delta_mb`, median(v));
4756
4891
  const placed = onAppClock(windows, version).filter((w) => w.raw.endReason !== "timeout");
4757
- const ends = windowEndFootprints(placed, records);
4892
+ const ends = windowEndFootprints(placed, records, version);
4758
4893
  const withEnd = [...ends.keys()];
4759
4894
  const launchWindow = [...withEnd].sort((a, b) => a.startWall - b.startWall || a.endWall - b.endWall)[0];
4760
4895
  const lastWindow = [...withEnd].sort((a, b) => b.endWall - a.endWall)[0];
@@ -5134,7 +5269,7 @@ function keyFirstOpens(windows) {
5134
5269
  }
5135
5270
 
5136
5271
  // packages/derive/dist/src/derive.js
5137
- var DERIVATION_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 26, 27, 28, 29, 30];
5272
+ var DERIVATION_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 26, 27, 28, 29, 30, 31];
5138
5273
  var windowsForVersion = (windows, version = DERIVATION_VERSION, nav) => version >= 22 ? keyFirstOpens(navKeyed(windows, nav, version)) : windows;
5139
5274
  function stepWindowsFor(windows, version = DERIVATION_VERSION, nav) {
5140
5275
  return windowsForVersion(version >= 16 ? withLaunchNames(windows).filter((w) => !isBackgroundWindow(w)) : windows, version, nav);
@@ -5983,23 +6118,18 @@ function tip(headline, rows = [], foot) {
5983
6118
  return foot ? { headline, rows: made, foot } : { headline, rows: made };
5984
6119
  }
5985
6120
 
5986
- // packages/present/dist/src/slowerScreens.js
5987
- var SLOWER_CHART_WORDS = {
5988
- title: (dir) => dir === "faster" ? "Faster by" : "Slower by",
5989
- sub: "ms vs main, by screen · biggest first",
5990
- key: (dir) => `${dir === "faster" ? "Faster by" : "Slower by"} key`,
5991
- noChange: stateWordInSentence("no-change"),
5992
- moved: "moved in some runs",
5993
- cantTell: stateWordInSentence("cant-tell"),
5994
- cut: (ms) => `Bar cut at ${ms} to keep the other rows readable.`
6121
+ // packages/present/dist/src/not-compared.js
6122
+ var NOT_COMPARED_KIND_WORDS = {
6123
+ "new-place": "New place",
6124
+ "no-baseline": "No history",
6125
+ "measured-differently": "Measured differently",
6126
+ "too-few-runs": "Too few valid runs",
6127
+ overheated: "Phone started overheated",
6128
+ "main-unstable": "Not judged",
6129
+ other: "Not compared"
5995
6130
  };
5996
- var SLOWER_LEGEND = [
5997
- { look: "band", word: "normal range" },
5998
- { look: "slower", word: "slower" },
5999
- { look: "faster", word: "faster" },
6000
- { look: "cant", word: stateWordInSentence("cant-tell") }
6001
- ];
6002
- var PLACES_OF = { slower: `${STATE_WORDS.worse} places`, faster: `${STATE_WORDS.better} places` };
6131
+ var NEW_PLACE_WORDS = { branch: "main has no history for it yet", journey: "no earlier session has it yet" };
6132
+
6003
6133
  // packages/present/dist/src/verdict-section.js
6004
6134
  var places2 = (n) => `${n.toLocaleString("en-US")} ${n === 1 ? "place" : "places"}`;
6005
6135
  var of = (n, total) => `${n.toLocaleString("en-US")} of ${places2(total)}`;
@@ -6087,10 +6217,15 @@ var MAIN_TIER_RESULT_WORDS = {
6087
6217
  slight: STATE_WORDS.warning,
6088
6218
  worse: STATE_WORDS.worse
6089
6219
  };
6220
+ var STACK_AT = { same: 0, slight: 1, worse: 2, better: 3, cant: 4 };
6221
+ var MAIN_TIER_STACK = Object.keys(STACK_AT).sort((p, q) => STACK_AT[p] - STACK_AT[q]);
6222
+ var HAS_VERDICT = { same: false, slight: true, worse: true, better: true, cant: false };
6223
+ var MAIN_VERDICT_TIERS = Object.keys(HAS_VERDICT).filter((t) => HAS_VERDICT[t]);
6090
6224
  var MAIN_TIER_RANK = { worse: 0, slight: 1, cant: 2, better: 3, same: 4 };
6091
6225
  var earlierWords = (sessions, since, config) => `main has ${sessions}${config.baseline.sessions === undefined ? "" : ` of ${config.baseline.sessions}`} earlier sessions${since ? ` on ${since}` : ""}`;
6092
6226
  var UNRELIABLE_WORDS = {
6093
6227
  "overheated-start": "the phone started overheated; this session does not count",
6228
+ "overheated-slow": "the phone was overheated; left out of this comparison",
6094
6229
  "few-valid-runs": "too few valid runs; this session does not count"
6095
6230
  };
6096
6231
  function mainCantWords(reason, config) {
@@ -6160,29 +6295,67 @@ function mainCutWords(v) {
6160
6295
  return parts.length ? parts.join(" ") : "Measures alike; no baseline cut.";
6161
6296
  }
6162
6297
  var BY_RANK = Object.keys(MAIN_TIER_RANK).sort((a, b) => MAIN_TIER_RANK[a] - MAIN_TIER_RANK[b]);
6163
- var MEMORY_HELD_WORDS = { head: "Memory", rest: "still held after the journey", text: "Memory still held after the journey" };
6164
- var MAIN_CHART_TITLES = {
6165
- renders: "Places vs their normal range",
6166
- rendersTip: (baseline) => tip("Places vs their normal range", [
6167
- ["Bar", "The journey’s places in one session"],
6168
- ["Colour", `Result against main’s ${baseline} sessions before it`],
6169
- ["Dashed", `Not judged yet: main is collecting ${baseline} sessions on this setup`]
6170
- ], "Main collects them after its start or a change"),
6171
- memory: `${MEMORY_HELD_WORDS.text} · MB`,
6172
- memoryTip: tip(MEASURES["mem.retained_growth_mb"].label, [
6173
- ["Shows", "What it still holds after the clean-up at its end"],
6174
- ["Line", "The median of a session"],
6175
- ["Band", "The spread inside the session"]
6176
- ])
6298
+
6299
+ // packages/present/dist/src/app-cpu.js
6300
+ var APP_CPU_WORDS = {
6301
+ title: "App CPU · cores",
6302
+ titleTip: tip("App CPU", [
6303
+ ["Shows", "the app’s process, not the whole phone"],
6304
+ ["Line", "The median of a session’s runs"],
6305
+ ["Band", "From its lowest to its highest run"],
6306
+ ["1 core", "One CPU core fully busy; above 1 is usual on a multi-core phone"]
6307
+ ]),
6308
+ none: "No CPU reading yet",
6309
+ sessionNone: "No CPU reading for this session yet.",
6310
+ runTitle: "App CPU over the run · cores",
6311
+ runTip: tip("App CPU over the run", [
6312
+ ["Shows", "the app’s process, not the whole phone"],
6313
+ ["Block", "One step of the journey, in run order"],
6314
+ ["Width", "How long the step took"],
6315
+ ["Height", "The app’s average cores while it ran"],
6316
+ ["Time", "Steps laid end to end; launch and time between steps are not shown."]
6317
+ ]),
6318
+ noSteps: "No App CPU for this run: it has no declared steps with a CPU reading.",
6319
+ axisEnd: "s of steps",
6320
+ mean: "mean",
6321
+ loading: "Loading the run…",
6322
+ failed: "Couldn’t load the run.",
6323
+ medianRun: (k, n) => `Median run: Run ${k} of the ${n} runs with a CPU reading`,
6324
+ otherRuns: "Other runs",
6325
+ runChip: (run, median) => `Run ${run}${median ? " · median" : ""}`,
6326
+ runAria: (steps, seconds) => `App CPU over the run in cores: ${steps} steps in run order over ${seconds} seconds. Use left and right arrows for each step.`
6177
6327
  };
6328
+ var NOT_STORED_WORD = "not stored";
6329
+
6330
+ // packages/present/dist/src/tips.js
6331
+ var LEAD_TIP = tip("A lead, not a confirmed cause", [], "Read from what the app did");
6332
+
6333
+ // packages/present/dist/src/placeStrip.js
6334
+ var PLACE_STRIP_WORDS = {
6335
+ strip: "vs main",
6336
+ value: "This session",
6337
+ change: "Change",
6338
+ noRange: `${NOT_COMPARED_KIND_WORDS["new-place"]}: ${NEW_PLACE_WORDS.journey}`
6339
+ };
6340
+ // packages/present/dist/src/slowerScreens.js
6341
+ var SLOWER_CHART_WORDS = {
6342
+ title: (dir) => dir === "faster" ? "Faster by" : "Slower by",
6343
+ sub: "ms vs main, by screen · biggest first",
6344
+ noChange: stateWordInSentence("no-change"),
6345
+ moved: "moved in some runs",
6346
+ cantTell: stateWordInSentence("cant-tell")
6347
+ };
6348
+ var PLACES_OF = { slower: `${STATE_WORDS.worse} places`, faster: `${STATE_WORDS.better} places` };
6178
6349
  // packages/present/dist/src/rules-lines.js
6179
6350
  var RULES_WORDS = {
6180
6351
  action: "Recompute",
6181
6352
  older: "Computed with older rules",
6182
6353
  marker: "new rules",
6354
+ markerShort: "rules",
6183
6355
  blocksUnderPrevious: "blocks under the previous rules",
6184
6356
  bannerText: "New rules: pull requests wait for a recompute",
6185
6357
  bannerAction: "Recompute",
6358
+ bannerSeparator: "·",
6186
6359
  engine: "engine",
6187
6360
  engineUpdate: "engine update"
6188
6361
  };
@@ -6190,7 +6363,7 @@ var sameRulesRef = (a, b) => a.configId === b.configId && a.coreVersion === b.co
6190
6363
  var rulesChangeText = (computed, live) => sameRulesRef(computed, live) ? null : RULES_WORDS.older;
6191
6364
  var staleRulesText = (rules) => rules ? rulesChangeText(rules.computed, rules.live) : null;
6192
6365
  var blocksUnderPreviousRulesText = (r) => r.blocking && r.stale ? RULES_WORDS.blocksUnderPrevious : null;
6193
- var waitingForRecomputeBanner = (r) => r.waiting > 0 ? `${RULES_WORDS.bannerText} · ${RULES_WORDS.bannerAction}` : null;
6366
+ var waitingForRecomputeBanner = (r) => r.waiting > 0 ? `${RULES_WORDS.bannerText} ${RULES_WORDS.bannerSeparator} ${RULES_WORDS.bannerAction}` : null;
6194
6367
 
6195
6368
  // packages/present/dist/src/summary-message.js
6196
6369
  var SUMMARY_WORDS = {
@@ -6232,6 +6405,7 @@ var ORDER = [...STATE_ORDER.slice(0, 3), "not-computed", ...STATE_ORDER.slice(3)
6232
6405
  var rank = (l) => ORDER.indexOf(l.state);
6233
6406
  var WORD = { ...STATE_WORDS, "not-computed": SUMMARY_WORDS.notComputed };
6234
6407
  var shownLane = (lanes) => [...lanes].sort((a, b) => rank(a) - rank(b) || b.places - a.places)[0];
6408
+ var outcomeOf = (lanes) => new Set(lanes.map((x) => x.phone ?? "-")).size === 1 ? shownLane(lanes)?.outcome ?? null : null;
6235
6409
  var clip = (s, n) => s.length > n ? `${s.slice(0, n - 1)}…` : s;
6236
6410
  var NAME = { name: 32, button: 30 };
6237
6411
  var TOP2 = 3;
@@ -6323,8 +6497,9 @@ function summaryMessage(input) {
6323
6497
  const phones = new Set(input.lanes.map((x) => x.phone ?? "-")).size;
6324
6498
  const single = input.lanes.length === 1;
6325
6499
  const newestSlower = !input.pr && (l.state === "worse" || l.state === "warning");
6500
+ const said = outcomeOf(input.lanes);
6326
6501
  const lead = [
6327
- laneWord(l),
6502
+ said?.word ?? laneWord(l),
6328
6503
  ...input.app ? [input.app] : [],
6329
6504
  journeys === 1 ? l.journey ?? "journey" : SUMMARY_WORDS.journeys(journeys),
6330
6505
  input.pr && input.prNumber ? `${input.branch} (#${input.prNumber})` : input.branch,
@@ -6333,7 +6508,7 @@ function summaryMessage(input) {
6333
6508
  const label = input.pr ? SUMMARY_WORDS.link.comparison : SUMMARY_WORDS.link.session;
6334
6509
  const link = { label, url: (single ? l.url : null) ?? input.url };
6335
6510
  if (!input.slack)
6336
- return { state: l.state, newestSlower, lead, ...withLines(changeLines(l, input.pr, newestSlower)), link };
6511
+ return { state: l.state, newestSlower, lead, ...withLines(changeLines(l, input.pr, newestSlower, said)), link };
6337
6512
  const lines = [];
6338
6513
  if (l.state === "worse" || l.state === "warning") {
6339
6514
  const n = l.places || l.changes.length;
@@ -6356,10 +6531,12 @@ function summaryMessage(input) {
6356
6531
  if (context)
6357
6532
  lines.push(basis ? context : upper(context));
6358
6533
  lines.push(...rulesLines(l));
6534
+ if (said)
6535
+ lines[0] = `${said.line} · ${lines[0]}`;
6359
6536
  return { state: l.state, newestSlower, lead, ...withLines(lines), link };
6360
6537
  }
6361
- function changeLines(l, pr, newestSlower) {
6362
- const lines = [];
6538
+ function changeLines(l, pr, newestSlower, said) {
6539
+ const lines = said ? [said.line] : [];
6363
6540
  const basis = compared(l, pr, newestSlower);
6364
6541
  const extra = conditions(l, pr).join(", ");
6365
6542
  const heat = l.heatedRuns ? upper(SUMMARY_WORDS.heated(l.heatedRuns, l.runs)) : null;
@@ -6372,7 +6549,7 @@ function changeLines(l, pr, newestSlower) {
6372
6549
  const key = c.key ?? c.place ?? c.measure;
6373
6550
  return seen.has(key) ? false : (seen.add(key), true);
6374
6551
  });
6375
- const room = MAX_LINES - tail.length;
6552
+ const room = MAX_LINES - lines.length - tail.length;
6376
6553
  const top = places.length > room ? places.slice(0, Math.min(TOP2, room - 1)) : places.slice(0, TOP2);
6377
6554
  lines.push(...top.map((c) => changeLine(c, l.state)));
6378
6555
  if (!top.length)
@@ -6396,6 +6573,43 @@ function noResultMessage(branch, reason, url) {
6396
6573
  return { state: "not-computed", newestSlower: false, lead: `${SUMMARY_WORDS.noResult} · ${clip(branch, NAME.name)}`, ...withLines([reason.replace(/\.$/, "")]), link: url ? { label: SUMMARY_WORDS.link.runSet, url } : null };
6397
6574
  }
6398
6575
 
6576
+ // packages/present/dist/src/fix-words.js
6577
+ var FIX_VERDICT_WORDS = {
6578
+ fixed: "Fixed",
6579
+ "no-change": STATE_WORDS["no-change"],
6580
+ worse: STATE_WORDS.worse,
6581
+ waiting: "Waiting for the fix branch"
6582
+ };
6583
+ var ORDER2 = Object.keys(FIX_VERDICT_WORDS);
6584
+ var FIX_OUTCOME_WORDS = {
6585
+ fixed: FIX_VERDICT_WORDS.fixed,
6586
+ "partly-fixed": "Partly fixed",
6587
+ "no-change": FIX_VERDICT_WORDS["no-change"],
6588
+ worse: FIX_VERDICT_WORDS.worse,
6589
+ waiting: FIX_VERDICT_WORDS.waiting
6590
+ };
6591
+ var fixCardText = (c) => [`${c.outcome_word}: ${c.headline}`, ...c.lines.map((l) => ` ${l.text}`), ...c.notes.map((n) => ` ${n}`)].join(`
6592
+ `);
6593
+
6594
+ // packages/present/dist/src/branch-outcome.js
6595
+ var BRANCH_OUTCOME_WORDS = {
6596
+ title: "Since the first session",
6597
+ fixed: FIX_VERDICT_WORDS.fixed,
6598
+ "still-worse": "Still worse",
6599
+ "got-worse": "Got worse",
6600
+ reRun: "re-run",
6601
+ first: "First session",
6602
+ judging: "Judging",
6603
+ empty: ["No session of this branch yet.", "Sessions come from your CI or the perf: measure label."]
6604
+ };
6605
+ function saidOutcome(o) {
6606
+ return o && (o.id === "fixed" || o.id === "still-worse" || o.id === "got-worse") && o.word && o.line && o.state ? o : null;
6607
+ }
6608
+ function outcomeLines(o) {
6609
+ const said = saidOutcome(o);
6610
+ return [...said ? [`${said.word}: ${said.line}`] : [], ...o?.pending ? [o.pending] : []];
6611
+ }
6612
+
6399
6613
  // packages/present/dist/src/run-set-message.js
6400
6614
  var midLine = (label, metric) => !metric.startsWith("component:") && /^[A-Z][a-z]*(\s|$)/.test(label) ? `${label[0].toLowerCase()}${label.slice(1)}` : label;
6401
6615
  function movedItems(v, sc) {
@@ -6491,7 +6705,8 @@ function laneOf(v, sc, set) {
6491
6705
  fasterByMs: sc.headline?.faster_by_ms ?? null,
6492
6706
  toCheckCount: sc.headline?.to_check_count ?? null,
6493
6707
  rules: sc.headline?.rules ?? null,
6494
- waiting: sc.headline?.waiting_for_reprocess ?? 0
6708
+ waiting: sc.headline?.waiting_for_reprocess ?? 0,
6709
+ outcome: saidOutcome(sc.branch_outcome)
6495
6710
  };
6496
6711
  }
6497
6712
  var isPending = (s) => lanesOf(s).every((l) => l.state === "not-computed" && !l.word?.startsWith(`${NOT_JUDGED_WORD}: `));
@@ -6506,7 +6721,7 @@ function messageTone(s) {
6506
6721
  return { mark: "\uD83D\uDD34", color: MESSAGE_COLORS.error };
6507
6722
  if (lanes.some((l) => l.state === "warning"))
6508
6723
  return { mark: "\uD83D\uDFE1", color: MESSAGE_COLORS.warn };
6509
- switch (shownLane(lanes)?.state) {
6724
+ switch (outcomeOf(lanes)?.state ?? shownLane(lanes)?.state) {
6510
6725
  case "better":
6511
6726
  return { mark: "\uD83D\uDFE2", color: MESSAGE_COLORS.better };
6512
6727
  case "cant-tell":
@@ -6594,32 +6809,51 @@ function notificationBody(e) {
6594
6809
  return `${e.word}, ${e.headline.component} ${e.headline.change}`;
6595
6810
  return [e.word, e.journey, e.phone ? phoneModelName(e.phone) : e.phone].filter(Boolean).join(" · ");
6596
6811
  }
6812
+ var shellWord = (s) => /^[A-Za-z0-9_@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
6597
6813
  var FIX_END_WORDS = {
6598
6814
  "dry-run": (n) => `Dry run: ${n} ${n === 1 ? "commit" : "commits"} on a local branch; nothing was pushed or opened.`,
6599
6815
  opened: (n) => `Draft PR opened with ${n} ${n === 1 ? "commit" : "commits"}.`,
6600
- none: () => "Nothing to fix: the agent found nothing it could edit safely.",
6601
- refused: () => "The change was refused by the checks; nothing was committed.",
6602
- main: () => "Main has no branch to fix.",
6603
- fork: () => "A pull request from a fork is not supported here.",
6604
- closed: () => "The pull request is closed.",
6605
- exists: () => "A fix branch for this pull request already exists.",
6606
- "unsafe-checkout": () => "The checkout has a link that points outside it; nothing was started.",
6607
- timeout: () => "It ran out of time; nothing was committed.",
6608
- failed: () => "The fix did not complete.",
6609
- "not-json": () => "The agent’s answer was not the list of changes it made.",
6610
- schema: () => "The agent’s list of changes did not match what it changed.",
6816
+ none: () => "Nothing to fix: the agent found nothing it could edit safely — investigate again, then Propose fixes.",
6817
+ "no-findings": () => "Nothing to fix on this branch now — its findings were resolved or are no longer slower; nothing was started.",
6818
+ refused: () => "The change was refused by the checks; nothing was committed — the refused files are listed below.",
6819
+ main: () => "Main has no branch to fix — propose fixes on a pull request instead.",
6820
+ fork: () => "The pull request comes from a fork — push its branch to the repo itself, then Propose fixes.",
6821
+ closed: () => "The pull request is closed — reopen it, then Propose fixes.",
6822
+ exists: (_n, o = {}) => o.fixBranch && o.repo ? `The fix branch ${o.fixBranch} is left from an earlier run — delete it with \`git -C ${shellWord(o.repo)} branch -D ${shellWord(o.fixBranch)}\`, then Propose fixes.` : "A fix branch for this pull request already exists — review or delete it, then Propose fixes.",
6823
+ "unsafe-checkout": () => "The checkout has a link that points outside it; nothing was started — remove the link from the branch, then Propose fixes.",
6824
+ timeout: () => "It ran out of time; nothing was committed — pick fewer findings, then Propose fixes.",
6825
+ failed: () => "The fix did not complete — the log in KeepSnappy’s state folder says why; try again.",
6826
+ "not-json": () => "The agent’s answer was not the list of changes it made — try again.",
6827
+ schema: () => "The agent’s list of changes did not match what it changed — try again.",
6611
6828
  stopped: () => "It was stopped.",
6612
- push: () => "The branch could not be pushed.",
6613
- gh: () => "GitHub (gh) refused or failed.",
6614
- api: () => "The KeepSnappy API did not answer."
6829
+ push: () => "The branch could not be pushed — check that you can push to the repo, then try again.",
6830
+ gh: () => "GitHub (gh) could not read or open the pull request — check `gh pr view` in the repo folder, then try again.",
6831
+ "gh-auth": () => "GitHub CLI (gh) is not signed in — run `gh auth login`, then Propose fixes.",
6832
+ "no-pr": () => "No pull request on GitHub for this branch — open one, then Propose fixes.",
6833
+ "no-repo": () => "No local checkout of this app — set its folder in Settings › Apps & code, then Propose fixes.",
6834
+ api: () => "The KeepSnappy API did not answer — try again in a minute."
6615
6835
  };
6616
- var fixEndText = (status, commits) => Object.hasOwn(FIX_END_WORDS, status) ? FIX_END_WORDS[status](commits) : null;
6836
+ var fixEndText = (status, commits, o = {}) => Object.hasOwn(FIX_END_WORDS, status) ? FIX_END_WORDS[status](commits, o) : null;
6617
6837
  var FIX_WHY_NOT = {
6618
6838
  main: "Main has no branch to fix.",
6619
6839
  notWorse: "Only Worse and Slightly worse findings are fixed.",
6620
- notMeasured: "Not a measured change."
6840
+ notMeasured: "Not a measured change.",
6841
+ noPr: FIX_END_WORDS["no-pr"](),
6842
+ ghAuth: FIX_END_WORDS["gh-auth"](),
6843
+ gh: "GitHub didn’t answer — check the network, then try again.",
6844
+ fork: FIX_END_WORDS.fork(),
6845
+ closed: FIX_END_WORDS.closed()
6846
+ };
6847
+ var fixNoRepoText = (app) => `No local checkout of ${app} — set its folder in Settings › Apps & code.`;
6848
+ var FIX_REJECT_REASONS = ["too-many", "bad-command", "unknown-app", "duplicate"];
6849
+ var FIX_REJECT_WORDS = {
6850
+ "too-many": (o) => `Up to ${o.max} findings per fix, and this asked for ${o.count} — uncheck some, then Propose fixes.`,
6851
+ "bad-command": () => "The watcher could not read this fix command — update KeepSnappy, then try again.",
6852
+ "unknown-app": () => "The watcher has not loaded this app’s results yet — wait a moment, then Retry.",
6853
+ duplicate: () => "A fix of these findings is already running or queued — its result shows here when it ends."
6621
6854
  };
6622
- var FIX_NOTIFY_STATUSES = ["opened", "refused", "failed", "timeout", "not-json", "schema", "unsafe-checkout", "push", "gh", "api"];
6855
+ var fixRejectText = (reason, o = { count: 0, max: 0 }) => FIX_REJECT_REASONS.includes(reason) ? FIX_REJECT_WORDS[reason](o) : null;
6856
+ var FIX_NOTIFY_STATUSES = ["opened", "refused", "failed", "timeout", "not-json", "schema", "unsafe-checkout", "push", "gh", "gh-auth", "no-pr", "no-repo", "api"];
6623
6857
  // packages/present/dist/src/hypothesis-words.js
6624
6858
  var HYPOTHESIS_OUTCOME_WORDS = {
6625
6859
  open: "Open",
@@ -6627,10 +6861,10 @@ var HYPOTHESIS_OUTCOME_WORDS = {
6627
6861
  rejected: "Rejected",
6628
6862
  dismissed: "Dismissed"
6629
6863
  };
6630
- var ORDER2 = ["open", "confirmed", "rejected", "dismissed"];
6864
+ var ORDER3 = ["open", "confirmed", "rejected", "dismissed"];
6631
6865
  var HYPOTHESIS_NOTE = "Predictions from a coding agent: only a later measured session confirms or rejects one.";
6632
6866
  function hypothesesSummaryText(counts) {
6633
- const present = ORDER2.map((id) => [id, counts[id] ?? 0]).filter(([, n]) => n > 0);
6867
+ const present = ORDER3.map((id) => [id, counts[id] ?? 0]).filter(([, n]) => n > 0);
6634
6868
  const total = present.reduce((sum, [, n]) => sum + n, 0);
6635
6869
  if (total === 0)
6636
6870
  return "No hypotheses";
@@ -6687,48 +6921,8 @@ function runDurationText(ms) {
6687
6921
  return `${m}m ${s % 60}s`;
6688
6922
  return `${Math.floor(m / 60)}h ${m % 60}m`;
6689
6923
  }
6690
- // packages/present/dist/src/fix-words.js
6691
- var FIX_VERDICT_WORDS = {
6692
- fixed: "Fixed",
6693
- "no-change": STATE_WORDS["no-change"],
6694
- worse: STATE_WORDS.worse,
6695
- waiting: "Waiting for the fix branch"
6696
- };
6697
- var ORDER3 = Object.keys(FIX_VERDICT_WORDS);
6698
- // packages/present/dist/src/app-cpu.js
6699
- var APP_CPU_WORDS = {
6700
- title: "App CPU · cores",
6701
- titleTip: tip("App CPU", [
6702
- ["Shows", "the app’s process, not the whole phone"],
6703
- ["Line", "The median of a session’s runs"],
6704
- ["Band", "From its lowest to its highest run"],
6705
- ["1 core", "One CPU core fully busy; above 1 is usual on a multi-core phone"]
6706
- ]),
6707
- none: "No CPU reading yet",
6708
- sessionNone: "No CPU reading for this session yet.",
6709
- runTitle: "App CPU over the run · cores",
6710
- runTip: tip("App CPU over the run", [
6711
- ["Shows", "the app’s process, not the whole phone"],
6712
- ["Block", "One step of the journey, in run order"],
6713
- ["Width", "How long the step took"],
6714
- ["Height", "The app’s average cores while it ran"],
6715
- ["Time", "Steps laid end to end; launch and time between steps are not shown."]
6716
- ]),
6717
- noSteps: "No App CPU for this run: it has no declared steps with a CPU reading.",
6718
- axisEnd: "s of steps",
6719
- mean: "mean",
6720
- loading: "Loading the run…",
6721
- failed: "Couldn’t load the run.",
6722
- medianRun: (k, n) => `Median run: Run ${k} of the ${n} runs with a CPU reading`,
6723
- otherRuns: "Other runs",
6724
- runChip: (run, median) => `Run ${run}${median ? " · median" : ""}`,
6725
- runAria: (steps, seconds) => `App CPU over the run in cores: ${steps} steps in run order over ${seconds} seconds. Use left and right arrows for each step.`
6726
- };
6727
-
6728
- // packages/present/dist/src/tips.js
6729
- var LEAD_TIP = tip("A lead, not a confirmed cause", [], "Read from what the app did");
6730
6924
  // apps/agent/src/upload.ts
6731
- import { join as join4 } from "node:path";
6925
+ import { join as join5 } from "node:path";
6732
6926
  var MAX_RATE_RETRIES = 5;
6733
6927
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6734
6928
  var pause = (ms) => sleep(ms);
@@ -6777,7 +6971,7 @@ async function uploadRuns(deps, outDir, runs, opts) {
6777
6971
  skipped.push({ run_id: r.run_id, why: r.invalid_reason || "invalid" });
6778
6972
  continue;
6779
6973
  }
6780
- const log = await deps.fs.readFile(join4(outDir, r.run_id, "keepsnappy.log"));
6974
+ const log = await deps.fs.readFile(join5(outDir, r.run_id, "keepsnappy.log"));
6781
6975
  const windows = windowsInRun(parseWindows(log), r.t0, r.t1);
6782
6976
  if (windows.length === 0) {
6783
6977
  skipped.push({ run_id: r.run_id, why: "no-windows" });
@@ -6817,7 +7011,7 @@ async function uploadRuns(deps, outDir, runs, opts) {
6817
7011
  }
6818
7012
  let markers = [];
6819
7013
  try {
6820
- markers = parseMarkersNdjson(await deps.fs.readFile(join4(outDir, r.run_id, "markers.ndjson")));
7014
+ markers = parseMarkersNdjson(await deps.fs.readFile(join5(outDir, r.run_id, "markers.ndjson")));
6821
7015
  } catch {}
6822
7016
  for (const m of markers) {
6823
7017
  await call(f, opts.ingestUrl, "/v1/markers", { authorization: `Bearer ${token}` }, { step: m.step, phase: m.phase, t_host: m.t_host });
@@ -6865,17 +7059,17 @@ function summarize(input) {
6865
7059
  }
6866
7060
 
6867
7061
  // packages/verdict-core/dist/src/version.generated.js
6868
- var CORE_FINGERPRINT = "c1100da930fca9df";
7062
+ var CORE_FINGERPRINT = "ae0b19d47d41bbfe";
6869
7063
 
6870
7064
  // packages/verdict-core/dist/src/version.js
6871
7065
  var CORE_VERSION = parseInt(CORE_FINGERPRINT.slice(0, 7), 16);
6872
- // packages/verdict-core/dist/src/reliable.js
6873
- var prRunsNeeded = (config) => config.reliable?.minValidRuns ?? config.minRuns;
6874
-
6875
7066
  // packages/verdict-core/dist/src/window-count.js
6876
7067
  var WINDOW_DEPENDENT_JOURNEY_MEASURES = ["sampler.cpu_ms", "sampler.frames.longest_gap_ms"];
6877
7068
  var OFF = new WeakSet;
6878
7069
 
7070
+ // packages/verdict-core/dist/src/reliable.js
7071
+ var prRunsNeeded = (config) => config.reliable.minValidRuns;
7072
+
6879
7073
  // packages/verdict-core/dist/src/clauses.js
6880
7074
  var row = (id, what, unit, cmp, ruling, uncalibrated, value) => ({ id, what, unit, cmp, ruling, uncalibrated, value });
6881
7075
  var NO_RULING = "no ruling: a starting value of the deciding config (L2 audit F-13)";
@@ -6888,7 +7082,7 @@ var CLAUSES = {
6888
7082
  "S-UNCERTAIN": row("S-UNCERTAIN", "A worse result is uncertain and does not block while main has fewer sessions than this.", "sessions", "≥", 'REQ-VER-38, Arek 2026-10-16 "Nie blokuj przy małej bazie"', false, { belowMainSessions: 3 }),
6889
7083
  "G-JUDGE-FROM": row("G-JUDGE-FROM", "A phone or simulator is judged only with at least this many main sessions.", "sessions", "≥", "REQ-VER-41, Arek 2026-10-18", false, { mainSessions: 3 }),
6890
7084
  "W-WORKKIND": row("W-WORKKIND", "The share of render time spent mounting from which a place is an in-screen mount, not an update.", "share", "≥", "REQ-MET-25; TraceSift MOUNT_DOMINANT_SHARE", true, { mountShareAtLeast: 0.8 }),
6891
- "W-WINDOW-COUNT": row("W-WINDOW-COUNT", "A PR run that opened fewer windows on a screen than this share of main's median there leaves that screen (its taps, scrolls and steps) and the journey's window-dependent totals out of its comparisons.", "share", "≥", "plan 51 D2 floor (90 % of the usual window count), Arek 2026-10-22 ~07:20, per screen (plan 79 S8)", false, { below: 0.9 }),
7085
+ "W-WINDOW-COUNT": row("W-WINDOW-COUNT", "A PR run that opened fewer windows on a screen than this share of main's median there leaves that screen (its taps, scrolls and steps) and the journey's window-dependent totals out of its comparisons. Its scroll gestures count as main's median of them ([ctl-arek-gesture-bug]).", "share", "≥", "plan 51 D2 floor (90 % of the usual window count), Arek 2026-10-22 ~07:20, per screen (plan 79 S8); scrolls at main's median (Arek 2026-10-26, [ctl-arek-gesture-bug])", false, { below: 0.9 }),
6892
7086
  "T-FRAME": row("T-FRAME", "One frame, the unit of every frame limit, the same on every screen (a 120 Hz phone is not held to 8.3 ms).", "ms", ">", "REQ-MET-23, Arek 2026-10-16", false, { ms: LIMIT_FRAME_MS }),
6893
7087
  "C-DETAIL-MS": row("C-DETAIL-MS", "A component below this keeps key and ms only and is not listed as New or Removed.", "ms", "≥", "plan 79 S9 r2, Arek 2026-10-23 13:20; half the lowest component rule floor (judged and evidence)", false, { ms: Math.min(FLOOR.abs, COMPONENT_WARN_MS.update, COMPONENT_WARN_MS.work) / 2 }),
6894
7088
  "T-WARN-MS": row("T-WARN-MS", "A judged change counts as Slightly worse past at least this many ms (an update, or an entry / in-screen mount).", "ms", ">", "REQ-MET-26 (Arek 2026-10-16 ~14:30, ~17:30); followups [RULED 2026-10-18 ~09:40]", false, COMPONENT_WARN_MS),
@@ -7110,6 +7304,13 @@ var SDK_RELEASES = [
7110
7304
  renamed: { places: ["screen", "press", "drag"] },
7111
7305
  uncalibrated: false,
7112
7306
  source: "#277 (plan 74 step 4 SDK review): screen windows end at every touch delivered while open; late-mounting screens keep their component key (3-commit cap gone), so screen keys and the press/drag keys on them change; the run commit stream is cut at the flush, not inside a window (window times of long journeys and journey CPU change); a gesture whose touchEnd never reached JS moves press → drag; a late press takes its own open sample (press sampler measures). Flush answers lost data as error (validity only, no measure). Launch and journey memory stay compared."
7307
+ },
7308
+ {
7309
+ version: "0.1.0-alpha.18",
7310
+ previous: "0.1.0-alpha.17",
7311
+ declares: "nothing",
7312
+ uncalibrated: false,
7313
+ source: "packages/react-native since bb71e4ec (alpha.17 as published: its js/, ios/ and android/ equal that commit's). #578: the flush uploads 8 profile files at a time on native upload pools (Android: ThreadPoolExecutor, iOS: OperationQueue, both created at the first upload) and iOS streams the gzip to a temp file. All of it runs after the flush link, which ends every window first (core.ts flushOnce endAll('flush')), and the driver sends that link after the end GC and its memory read have been answered (session.ts endRun); the next run relaunches the app, so no pool, gzip or PUT lands in a measured window, the end GC or its memory read. Same files and lines go up; only the time from the flush link to its ack changes, which no measure reads. #615: EXPO_PUBLIC_KEEPSNAPPY_TAP_TEXT=0 (opt-in, default unchanged) drops visible text from the legacy tap name only (legacyName, touched); press and drag place keys are component keys (fiber.ts placeName) and stay the same. Were a key to change, it would be a new place with its own history (as with alpha.12 and alpha.16 `renamed`), not a measure change; here the only effect is that placeAliases (an information-only join of renamed places) finds fewer one-to-one legacy names. The rest is test-only (resetRunStateForTest, resetCurrentScreenForTest, @keepsnappy/test-support). REQ-FARM-35."
7113
7314
  }
7114
7315
  ];
7115
7316
  var DRIVER_RELEASES = [
@@ -7222,13 +7423,73 @@ var DRIVER_RELEASES = [
7222
7423
  declares: [
7223
7424
  { affects: MEASURES_AT("drag"), places: ["drag"] },
7224
7425
  { affects: MEASURES_AT("step"), places: ["step"] },
7225
- { affects: MEASURES_AT("journey"), places: ["journey"] }
7426
+ { affects: MEASURES_AT("journey"), places: ["journey"] },
7427
+ { affects: MEASURES_AT("screen"), places: ["screen"] },
7428
+ { affects: MEASURES_AT("press"), places: ["press"] }
7429
+ ],
7430
+ uncalibrated: false,
7431
+ source: "appium-driver CHANGELOG \"0.1.0-alpha.24\", 2026-10-30 (ctl 09:15, Arek: alpha.24 = the audience helpers with TapIn's 3-page audience, the freeze starts from it; D1 counts per [ctl-arek-p84] and [ctl-arek-nav-scroll]): pagedToEnd and topMeasured give lists a journey pages itself a fixed count of controlled gestures per measured step (per page exactly `gestures`, the top 2, fewer only when the first row already shows after the 1st), the way to the page's marker and back to the top being a navigation pass outside the step; before, TapIn's own audience loops sent a count that depended on where the list rested (Android to-end 10/10/8, iOS to-top 6/6/5). New audience step names are new step places; the audience drag places hold only the controlled gestures; the top step returns right after its gestures and navigation passes read rests faster, so step and journey places move too. Also #596/#606 (CHANGELOG `beginRun`): every iteration launches the app to home, sends the activation link, waits 2 s and relaunches to home, where alpha.23 sent the link into the process the journey had left running and the journey launched the app itself; the process each run measures starts later and from home, so the first screen windows after launch and the taps on them move (A24 gate, same app build c500cef as main: Pixel set a7ae0d0a Worse at screen HomeScreen#1, CommunityFeedScreen#1, JoinRoomScreen#1 and press HeroLiveContent›Touchable#1; iPhone set 16e7dbc5 only at an audience drag place, declared already; ctl 2026-10-31). Drag, step, journey, screen and press places are declared, on both platforms (REQ-FARM-35)."
7432
+ },
7433
+ {
7434
+ version: "0.1.0-alpha.25",
7435
+ previous: "0.1.0-alpha.24",
7436
+ declares: [
7437
+ { affects: MEASURES_AT("screen"), places: ["screen"] },
7438
+ { affects: MEASURES_AT("press"), places: ["press"] },
7439
+ { affects: MEASURES_AT("drag"), places: ["drag"] },
7440
+ { affects: MEASURES_AT("step"), places: ["step"] },
7441
+ { affects: MEASURES_AT("journey"), places: ["journey"] },
7442
+ { affects: MEASURES_AT("nav"), places: ["nav"] }
7226
7443
  ],
7227
7444
  uncalibrated: false,
7228
- source: "appium-driver CHANGELOG \"0.1.0-alpha.24\", 2026-10-30 (ctl 09:15, Arek: alpha.24 = the audience helpers with TapIn's 3-page audience, the freeze starts from it; D1 counts per [ctl-arek-p84] and [ctl-arek-nav-scroll]): pagedToEnd and topMeasured give lists a journey pages itself a fixed count of controlled gestures per measured step (per page exactly `gestures`, the top 2, fewer only when the first row already shows after the 1st), the way to the page's marker and back to the top being a navigation pass outside the step; before, TapIn's own audience loops sent a count that depended on where the list rested (Android to-end 10/10/8, iOS to-top 6/6/5). New audience step names are new step places; the audience drag places hold only the controlled gestures; the top step returns right after its gestures and navigation passes read rests faster, so step and journey places move too. Drag, step and journey places are declared, on both platforms (REQ-FARM-35)."
7445
+ source: "The instrument bundle of 2026-10-31 (ctl 13:30, Arek YES 10:30), both candidates of the scroll lab A/B. (1) #675 (L1a+N1, L2, L3): a tap is one request, with no DELETE /actions after its pointerUp, so the span from a tap to the next touch is one farm round trip shorter (press windows, the screen windows a tap opens, the steps holding taps, the run); a navigation pass reads its rests by cost (Android: the page source on a light tree, one held row on a heavy one, timed on the list's first read) and a held-row read is one command (the frame, no label), so the reads before a pass's first stroke, inside the touch window before it (a drag after a measured step, a press or screen before a pass), and the nav windows between strokes move. (2) L1b, included only if the A/B keeps it: on Android the end/top steps read the coast inside the measured step (drag and step, covered above). (3) #607 beforeLaunch: a per-iteration journey hook that runs after the activation link and before the measured launch, in the process the launch replaces; nothing measured. (4) The default Android device is the farm Pixel 9 Pro (Google Pixel 9 Pro-15.0, was the retired Pixel 7): a default only, a run naming its device sends the same session; nothing measured. Launch and background places are not declared: no change reaches the launch window or a backgrounded app (REQ-FARM-35)."
7229
7446
  }
7230
7447
  ];
7231
- var JOURNEY_RELEASES = {};
7448
+ var JOURNEY_RELEASES = {
7449
+ tapin: [
7450
+ {
7451
+ version: "01c42817b31f",
7452
+ previous: "9dc9b234b811",
7453
+ declares: [
7454
+ { affects: MEASURES_AT("journey"), places: ["journey"] },
7455
+ { affects: MEASURES_AT("launch"), places: ["launch"] },
7456
+ { affects: MEASURES_AT("screen"), places: ["screen"] },
7457
+ { affects: MEASURES_AT("press"), places: ["press"] },
7458
+ { affects: MEASURES_AT("drag"), places: ["drag"] },
7459
+ { affects: MEASURES_AT("step"), places: ["step"] }
7460
+ ],
7461
+ uncalibrated: false,
7462
+ source: "TapIn instrument bundle gate test/keepsnappy-bundle-gate @ca3e1fde2 (ctl 2026-10-31 13:30), three journey changes over 9dc9b234b811, both platforms. (1) #3873 (ff729dfd5): the waits between steps in profile, chat and front-row and the home check after the first join's launch are gone; they sat in the touch window before the next touch, so press, drag (the profile close swipe), screen and launch windows (the home check came before the first action) end sooner, and the run is shorter. (2) B5 (2ca5adac3, 499aed4e5): every launch starts in the reset mock world (#607 beforeLaunch, before the measured launch) and the fake room is filled at home before the community link, so the launch and home windows, the community and room screens, the taps and steps on them move. (3) Quick checks (6e0a0f56a, ca3e1fde2): after reactions.close the journey opens and closes 27 new places (live widgets, the viewer summary, community post/events/event/about/feed, home mine/settings) and ends on home; the new places are new keys with no baseline, not declared. Existing places that move: the reactions.close press and the live screen window it leaves open end at the next touch, not at the journey end; reactions.close is no longer the last step, so the end GC's step rows go to home.tab-home; the end GC and memory read now see home with the live left, so the journey's end, peak and kept memory (mem.end_mb, mem.end_after_gc_mb, mem.retained_growth_mb, mem.js_heap_end_mb, all at the journey place) and whole-run CPU change; sdk.journey_ends_home goes 0 -> 1, a setup fact (facts NOT_FACTS) that no verdict compares, so it has nothing to declare. nav and background places are not declared: no change reaches a navigation pass or a backgrounded app. A part the gate drops narrows this entry (REQ-FARM-35)."
7463
+ },
7464
+ {
7465
+ version: "9dc9b234b811",
7466
+ previous: "a02a95ce2c8d",
7467
+ declares: [
7468
+ { affects: MEASURES_AT("journey"), places: ["journey"] },
7469
+ { affects: MEASURES_AT("screen"), places: ["screen"] },
7470
+ { affects: MEASURES_AT("launch"), places: ["launch"] },
7471
+ { affects: MEASURES_AT("press"), places: ["press"] },
7472
+ { affects: MEASURES_AT("drag"), places: ["drag"] },
7473
+ { affects: MEASURES_AT("nav"), places: ["nav"] },
7474
+ { affects: MEASURES_AT("step"), places: ["step"] },
7475
+ { affects: MEASURES_AT("background"), places: ["background"] }
7476
+ ],
7477
+ uncalibrated: false,
7478
+ source: 'TapIn e2e/keepsnappy/live-ui-appium.mjs (the only file of the hash that changed), gigaverse-mobile bfd33ebb8 (main since #3852, a02a95ce2c8d) -> 0ac3879db "pin KeepSnappy to alpha.24" (TapIn PR #3872, 9dc9b234b811; ctl 2026-10-31). (1) The audience: one `audience.scroll-to-end` step over 5 pages, each a loop of long scrolls until "Show more" shows and its tap inside the step, and a `audience.scroll-to-top` loop capped by the way down, become driver alpha.24 `pagedToEnd` over 3 pages (steps `audience.scroll-to-more-<p>` with a fixed gesture count, the tap in its own step `audience.show-more-<p>`, the way to the marker a navigation pass) and `topMeasured` (`audience.scroll-to-top`, 2 gestures): step places are renamed and reshaped, the audience drag places hold other gestures, "Show more" is tapped 3 times, not 5, after other scrolls, navigation passes are new nav places, and two fewer pages load (step, drag, press, nav, journey). (2) `enterLive` no longer relaunches the app on its first join, as alpha.24 `beginRun` launches it (the setup changes of TapIn #3853, folded into the pin): the process each run measures, its launch and the first screens and taps after it come from `beginRun`, followed by its activation check, not from the journey (launch, screen, press, background, journey). (3) The waits for the newest chat row (30 s) and the event pill (10 s) before the first step are gone, so the first steps start from a less settled room (screen, press, step). The whole run changes, on both platforms: every place kind of a run is declared except `other` (REQ-FARM-35).'
7479
+ },
7480
+ {
7481
+ version: "a02a95ce2c8d",
7482
+ previous: "031769c140fd",
7483
+ declares: [
7484
+ { affects: MEASURES_AT("drag"), places: ["drag"] },
7485
+ { affects: MEASURES_AT("step"), places: ["step"] },
7486
+ { affects: MEASURES_AT("journey"), places: ["journey"] }
7487
+ ],
7488
+ uncalibrated: false,
7489
+ source: "TapIn gigaverse-mobile #3852 (6efd02c4bc, 2026-10-30 08:56 UTC), the driver alpha.23 pin: the only edit to the journey module since journey 031769c140fd (#3807, driver alpha.21) deletes the Android-only `scrolls` cap of the History scroll cycles (LONG_SCROLL_CAP + 2), so a History scroll step sends the driver's own count. That moves drag places, the step places holding them and the whole run; screens and taps send the same commands. Declared like the driver release it ships with: drag, step and journey places, on both platforms (REQ-FARM-35, [ctl-arek-p84] D2). Production: every TapIn main session from 5f8fae52 (iPhone 15 Pro) and d79ea5be (Pixel 9 Pro) on read Can't tell at all 46 places for 3 sessions while this change was undeclared."
7490
+ }
7491
+ ]
7492
+ };
7232
7493
  var CAPTURE_MEASURED_WITH = { aspect: "capture", affects: LOADED_MEMORY, marks: true };
7233
7494
  var UNSTABLE_NOISY = {
7234
7495
  reason: "noisy",
@@ -7282,8 +7543,6 @@ var noiseJudged = (id, on) => {
7282
7543
  var part = (b) => ({ ...b.rel !== undefined ? { rel: b.rel } : {}, ...b.abs !== undefined ? { abs: b.abs } : {} });
7283
7544
  var EVIDENCE_BARS = new Map(Object.values(CLAUSES).filter((c) => c.id.startsWith("E-")).flatMap((c) => Object.entries(c.value).map(([rule, bar]) => [rule, { bar, uncalibrated: c.uncalibrated }])));
7284
7545
  function withClauseValues(base) {
7285
- if (!base.better)
7286
- throw new ConfigError2(["better: missing from base config"]);
7287
7546
  const warnMs = CLAUSES["T-WARN-MS"].value;
7288
7547
  const errorMs = CLAUSES["TIER-ERROR"].value;
7289
7548
  const memory = CLAUSES["J-MEMORY-RETAINED"];
@@ -7328,7 +7587,7 @@ function withClauseValues(base) {
7328
7587
  uncertain: { ...base.uncertain, belowMainSessions: c("S-UNCERTAIN").value.belowMainSessions, uncalibrated: c("S-UNCERTAIN").uncalibrated },
7329
7588
  judgeFrom: { ...base.judgeFrom, mainSessions: c("G-JUDGE-FROM").value.mainSessions, uncalibrated: c("G-JUDGE-FROM").uncalibrated },
7330
7589
  work: { ...base.work, mountShareAtLeast: c("W-WORKKIND").value.mountShareAtLeast, uncalibrated: c("W-WORKKIND").uncalibrated },
7331
- ...base.windowCount ? { windowCount: { ...base.windowCount, below: c("W-WINDOW-COUNT").value.below, uncalibrated: c("W-WINDOW-COUNT").uncalibrated } } : {},
7590
+ windowCount: { ...base.windowCount, below: c("W-WINDOW-COUNT").value.below, uncalibrated: c("W-WINDOW-COUNT").uncalibrated },
7332
7591
  frame: { ...base.frame, ms: c("T-FRAME").value.ms, uncalibrated: c("T-FRAME").uncalibrated },
7333
7592
  tiers: {
7334
7593
  ...base.tiers,
@@ -7399,7 +7658,7 @@ var RELIABLE = {
7399
7658
  source: 'Arek via ctl 2026-10-28 ~11:40, corrected ~11:55 ("fair and moderate are not an overheated phone") [ctl-arek-thermal-unreliable]: a run that started at Android SEVERE (3) or above, or iOS serious (2) or above, makes its session unreliable; the SDK maps SEVERE to serious, so both scales read alike. NONE / LIGHT / MODERATE and nominal / fair are normal: listed as started warm, never unreliable. [ctl-arek-three-runs]: a session counts only with 3 valid runs, on main and on the PR (no earlier ruling on partial PR sessions: G-MINRUNS had none). Background: giga2-scratch/noise-p9p-g71/findings.md (P9P false Worse from LIGHT starts stay; the G71 1-run main session no longer counts)'
7400
7659
  };
7401
7660
  var SHAPE = {
7402
- id: "simple-4.30+noise95-99",
7661
+ id: "simple-4.31+noise95-99",
7403
7662
  minRuns: 2,
7404
7663
  baseline: { sessions: 3, ...GUESS("Q4 / REQ-VER-33: main's newest 3 sessions against one PR session (the quieter setting of the plan 58 simulation)") },
7405
7664
  repeats: { kind: "every-pr-beyond-every-main" },
@@ -7513,6 +7772,7 @@ var SHAPE = {
7513
7772
  noisy: NOISY_WIDEN,
7514
7773
  compConfirm: COMP_CONFIRM,
7515
7774
  reliable: RELIABLE,
7775
+ commitGrouping: { uncalibrated: true, source: "plan 81 P3; Arek 2026-10-26 [ctl-arek-p81-p3p4]: two windows that share one proven commit (the same slowest commit in every PR run) may be one finding; never merged without it; deciding since simple-4.31, Arek 2026-10-30 [ctl-arek-1030-per-rec] and [ctl-arek-1030-p4-back]" },
7516
7776
  judgeFrom: { mainSessions: 3, uncalibrated: false, source: 'Arek 2026-10-18 (REQ-VER-41): a phone is judged only when main has at least 3 sessions on that phone and journey; with fewer the result is "not enough main yet", not judged, not blocking, values shown. Replay (agents/release/replay, d22): 1 PR vs 2 main on the S23 night pool gave 13 z 60 false worse+warning, 1 vs 3 gave 0' },
7517
7777
  measuredWith: [
7518
7778
  { aspect: "renderer", affects: REACT_MEASURES },
@@ -7549,13 +7809,8 @@ var CONFIG = withClauseValues(SHAPE);
7549
7809
  var P4_EVEN = {
7550
7810
  ...CONFIG,
7551
7811
  id: `${CONFIG.id}+p4-even`,
7552
- evenSlowdown: { maxExcess: 0.3, renders: 0.1, commits: 0.25, uncalibrated: true, source: "plan 81 P4 (Opus O2 guesses); Arek 2026-10-23 ~12:45 direction (4); Arek 2026-10-26 ctl-arek-p81-p3p4: render and commit counts may block a downgrade as a guard, never colour a verdict alone" }
7812
+ evenSlowdown: { maxExcess: 0.3, renders: 0.1, commits: 0.25, uncalibrated: true, source: "plan 81 P4 (Opus O2 guesses); Arek 2026-10-23 ~12:45 direction (4); Arek 2026-10-26 ctl-arek-p81-p3p4: render and commit counts may block a downgrade as a guard, never colour a verdict alone; Arek 2026-10-30 [ctl-arek-1030-p4-back]: a candidate until the blind spot is closed" }
7553
7813
  };
7554
- var P3_COMMIT = withClauseValues({
7555
- ...CONFIG,
7556
- id: `${CONFIG.id}+p3-commit`,
7557
- commitGrouping: { uncalibrated: true, source: "plan 81 P3; Arek 2026-10-26 [ctl-arek-p81-p3p4]: two windows that share one proven commit (the same slowest commit in every PR run) may be one finding; never merged without it" }
7558
- });
7559
7814
  // apps/agent/src/remote.ts
7560
7815
  var isBlocking = (s) => !!s?.verdicts && (s.verdicts.blocking ?? s.verdicts.state === "worse");
7561
7816
  function heatNote(sc) {
@@ -7736,12 +7991,12 @@ Runs uploaded for \`${fallback.branch}\`. The result appears on the dashboard: [
7736
7991
  }
7737
7992
 
7738
7993
  // apps/agent/src/run.ts
7739
- import { join as join8 } from "node:path";
7994
+ import { join as join9 } from "node:path";
7740
7995
  // apps/agent/src/extract.ts
7741
- import { join as join6 } from "node:path";
7996
+ import { join as join7 } from "node:path";
7742
7997
 
7743
7998
  // apps/agent/src/profiles.ts
7744
- import { join as join5 } from "node:path";
7999
+ import { join as join6 } from "node:path";
7745
8000
 
7746
8001
  // packages/profile-analysis/dist/src/tracesift/lib/format.js
7747
8002
  function formatMs(ms) {
@@ -7958,7 +8213,7 @@ var dispatchNames = new Set(["processTicksAndRejections", "runMicrotasks", "proc
7958
8213
  function identity(frame) {
7959
8214
  return JSON.stringify([frame.functionName, frame.scriptId, frame.url, frame.lineNumber, frame.columnNumber]);
7960
8215
  }
7961
- function label(frame) {
8216
+ function label2(frame) {
7962
8217
  return `${frame.functionName || "(anonymous)"} (${frame.url || "(anonymous)"}:${frame.lineNumber + 1}:${frame.columnNumber + 1})`;
7963
8218
  }
7964
8219
  function groupBottlenecks(profile, durationMs) {
@@ -8025,7 +8280,7 @@ function groupBottlenecks(profile, durationMs) {
8025
8280
  continue;
8026
8281
  let group = groups.get(key);
8027
8282
  if (!group) {
8028
- group = { id: `b${groups.size + 1}`, title: owner.callFrame.functionName || "(anonymous)", combinedTimeMs: 0, percentOfTotal: 0, occurrences: 0, longestRunMs: 0, stack: meaningful.slice(meaningful.indexOf(owner)).map((node) => label(node.callFrame)), functions: [], members: new Map };
8283
+ group = { id: `b${groups.size + 1}`, title: owner.callFrame.functionName || "(anonymous)", combinedTimeMs: 0, percentOfTotal: 0, occurrences: 0, longestRunMs: 0, stack: meaningful.slice(meaningful.indexOf(owner)).map((node) => label2(node.callFrame)), functions: [], members: new Map };
8029
8284
  groups.set(key, group);
8030
8285
  }
8031
8286
  if (!runGroup) {
@@ -8037,7 +8292,7 @@ function groupBottlenecks(profile, durationMs) {
8037
8292
  const leafKey = identity(leaf.callFrame);
8038
8293
  let member = group.members.get(leafKey);
8039
8294
  if (!member) {
8040
- member = { id: `${group.id}-f${group.members.size + 1}`, title: leaf.callFrame.functionName || "(anonymous)", selfTimeMs: 0, percentOfGroup: 0, location: frameSourceLocation(leaf.callFrame), stack: meaningful.map((node) => label(node.callFrame)), frameworkInternal: isFrameworkInternalFrame(leaf.callFrame) };
8295
+ member = { id: `${group.id}-f${group.members.size + 1}`, title: leaf.callFrame.functionName || "(anonymous)", selfTimeMs: 0, percentOfGroup: 0, location: frameSourceLocation(leaf.callFrame), stack: meaningful.map((node) => label2(node.callFrame)), frameworkInternal: isFrameworkInternalFrame(leaf.callFrame) };
8041
8296
  group.members.set(leafKey, member);
8042
8297
  }
8043
8298
  member.selfTimeMs += sampleMs;
@@ -11023,20 +11278,20 @@ function deriveLocal(input) {
11023
11278
  async function writeProfiles(deps, runDir, derived) {
11024
11279
  if (!derived.profiles.length)
11025
11280
  return;
11026
- await deps.fs.writeFile(join5(runDir, "profiles.json"), `${JSON.stringify({ derivationVersion: derived.derivationVersion, profiles: derived.profiles }, null, 2)}
11281
+ await deps.fs.writeFile(join6(runDir, "profiles.json"), `${JSON.stringify({ derivationVersion: derived.derivationVersion, profiles: derived.profiles }, null, 2)}
11027
11282
  `);
11028
11283
  }
11029
11284
  async function saveCpuProfiles(deps, runDir, texts) {
11030
11285
  if (!texts.size)
11031
11286
  return;
11032
- await deps.fs.mkdir(join5(runDir, CPU_DIR));
11287
+ await deps.fs.mkdir(join6(runDir, CPU_DIR));
11033
11288
  for (const [name, text] of texts)
11034
- await deps.fs.writeFile(join5(runDir, CPU_DIR, name), text).catch(() => {});
11289
+ await deps.fs.writeFile(join6(runDir, CPU_DIR, name), text).catch(() => {});
11035
11290
  }
11036
11291
  async function readCpuProfiles(deps, runDir, logText) {
11037
11292
  const out = new Map;
11038
11293
  for (const name of cpuFileNames(logText)) {
11039
- const text = await deps.fs.readFile(join5(runDir, CPU_DIR, name)).catch(() => {
11294
+ const text = await deps.fs.readFile(join6(runDir, CPU_DIR, name)).catch(() => {
11040
11295
  return;
11041
11296
  });
11042
11297
  if (text !== undefined)
@@ -11059,7 +11314,7 @@ async function pullCpuProfiles(source, bundleId, logText, known = new Set) {
11059
11314
  }
11060
11315
  // apps/agent/src/extract.ts
11061
11316
  async function extract(deps, outDir, ri, memWindow, clockOffsetMs) {
11062
- const runs = (await deps.fs.readFile(join6(outDir, "runs.ndjson"))).split(`
11317
+ const runs = (await deps.fs.readFile(join7(outDir, "runs.ndjson"))).split(`
11063
11318
  `).filter((l) => l.trim()).map((l) => JSON.parse(l));
11064
11319
  const samples = ri ? await ri.load() : undefined;
11065
11320
  const rows = [];
@@ -11069,26 +11324,26 @@ async function extract(deps, outDir, ri, memWindow, clockOffsetMs) {
11069
11324
  let logText = "";
11070
11325
  let cpuProfiles = new Map;
11071
11326
  try {
11072
- const log = await deps.fs.readFile(join6(outDir, r.run_id, "keepsnappy.log"));
11327
+ const log = await deps.fs.readFile(join7(outDir, r.run_id, "keepsnappy.log"));
11073
11328
  logText = log;
11074
- cpuProfiles = await readCpuProfiles(deps, join6(outDir, r.run_id), log);
11329
+ cpuProfiles = await readCpuProfiles(deps, join7(outDir, r.run_id), log);
11075
11330
  windows = windowsInRun(parseWindows(log), r.t0, r.t1);
11076
11331
  runSamples = samplesInRun(parseSamples(log), r.t0, r.t1);
11077
11332
  } catch {}
11078
11333
  let markers = [];
11079
11334
  try {
11080
- markers = parseMarkersNdjson(await deps.fs.readFile(join6(outDir, r.run_id, "markers.ndjson")));
11335
+ markers = parseMarkersNdjson(await deps.fs.readFile(join7(outDir, r.run_id, "markers.ndjson")));
11081
11336
  } catch {}
11082
11337
  const derived = deriveLocal({ run: r, windows, markers, samples: runSamples, ...memWindow ? { memWindow } : {}, ...samples ? { ri: { samples, ...memWindow ? { memWindow } : {} } } : {}, ...clockOffsetMs === undefined ? {} : { clockOffsetMs }, cpuProfiles, logText });
11083
11338
  rows.push(...derived.rows);
11084
- await writeProfiles(deps, join6(outDir, r.run_id), derived);
11339
+ await writeProfiles(deps, join7(outDir, r.run_id), derived);
11085
11340
  }
11086
- await deps.fs.writeFile(join6(outDir, "metrics.csv"), toCsv(rows));
11341
+ await deps.fs.writeFile(join7(outDir, "metrics.csv"), toCsv(rows));
11087
11342
  return rows;
11088
11343
  }
11089
11344
 
11090
11345
  // apps/agent/src/runner.ts
11091
- import { join as join7 } from "node:path";
11346
+ import { join as join8 } from "node:path";
11092
11347
 
11093
11348
  // apps/agent/src/schedule.ts
11094
11349
  function schedule(arms, repeat) {
@@ -11142,9 +11397,9 @@ async function measure(deps, source, installer, opts) {
11142
11397
  if (flows.some((f) => !f.command[0]))
11143
11398
  throw new ConfigError(["no test command given (put it after `--`)"]);
11144
11399
  await fs.mkdir(opts.outDir);
11145
- const runsPath = join7(opts.outDir, "runs.ndjson");
11400
+ const runsPath = join8(opts.outDir, "runs.ndjson");
11146
11401
  await fs.writeFile(runsPath, "");
11147
- await fs.writeFile(join7(opts.outDir, "arms.json"), `${JSON.stringify(opts.arms, null, 2)}
11402
+ await fs.writeFile(join8(opts.outDir, "arms.json"), `${JSON.stringify(opts.arms, null, 2)}
11148
11403
  `);
11149
11404
  const targets = new Map(opts.arms.map((a) => [a.name, a.target]));
11150
11405
  const armEnv = new Map(opts.arms.map((a) => [a.name, a.env ?? {}]));
@@ -11155,7 +11410,7 @@ async function measure(deps, source, installer, opts) {
11155
11410
  const plan = schedule(opts.arms, opts.repeat).flatMap((p) => flows.map((f) => ({ ...p, runId: (opts.flows?.length ?? 0) > 1 ? `${p.runId}-${f.name}` : p.runId, flow: f })));
11156
11411
  for (const p of plan) {
11157
11412
  const [cmd, ...args] = p.flow.command;
11158
- const dir = join7(opts.outDir, p.runId);
11413
+ const dir = join8(opts.outDir, p.runId);
11159
11414
  await fs.mkdir(dir);
11160
11415
  let reason = "";
11161
11416
  const target = targets.get(p.arm);
@@ -11177,7 +11432,7 @@ async function measure(deps, source, installer, opts) {
11177
11432
  ...opts.testTimeoutMs ? { timeoutMs: opts.testTimeoutMs } : {}
11178
11433
  });
11179
11434
  exit = r.code;
11180
- await fs.writeFile(join7(dir, "test.log"), `${r.stdout}${r.stderr ? `
11435
+ await fs.writeFile(join8(dir, "test.log"), `${r.stdout}${r.stderr ? `
11181
11436
  --- stderr ---
11182
11437
  ${r.stderr}` : ""}`);
11183
11438
  if (r.error)
@@ -11193,7 +11448,7 @@ ${r.stderr}` : ""}`);
11193
11448
  try {
11194
11449
  const text = await source.fetchLog(opts.bundleId);
11195
11450
  logText = text;
11196
- await fs.writeFile(join7(dir, "keepsnappy.log"), text);
11451
+ await fs.writeFile(join8(dir, "keepsnappy.log"), text);
11197
11452
  windows = windowsInRun(parseWindows(text), t0, t1);
11198
11453
  samples = samplesInRun(parseSamples(text), t0, t1);
11199
11454
  cpuProfiles = await pullCpuProfiles(source, opts.bundleId, text, pulled);
@@ -11206,7 +11461,7 @@ ${r.stderr}` : ""}`);
11206
11461
  }
11207
11462
  const markers = opts.markers?.forRun(p.runId) ?? [];
11208
11463
  if (markers.length)
11209
- await fs.writeFile(join7(dir, "markers.ndjson"), markersToNdjson(markers));
11464
+ await fs.writeFile(join8(dir, "markers.ndjson"), markersToNdjson(markers));
11210
11465
  let riInput;
11211
11466
  if (opts.ri) {
11212
11467
  try {
@@ -11237,7 +11492,7 @@ ${r.stderr}` : ""}`);
11237
11492
  `);
11238
11493
  log(`${p.runId} ${final.valid ? "valid" : `INVALID (${final.invalid_reason})`} windows=${windows.length}${markers.length ? ` markers=${markers.length} steps=${steps.length}` : ""}`);
11239
11494
  }
11240
- await fs.writeFile(join7(opts.outDir, "metrics.csv"), toCsv(rows));
11495
+ await fs.writeFile(join8(opts.outDir, "metrics.csv"), toCsv(rows));
11241
11496
  return { runs, rows };
11242
11497
  }
11243
11498
 
@@ -11272,7 +11527,7 @@ async function keepsnappyRun(deps, source, opts) {
11272
11527
  ...opts.clockOffsetMs === undefined ? {} : { clockOffsetMs: opts.clockOffsetMs }
11273
11528
  });
11274
11529
  const rows = renameMetrics(raw, opts.config.steps.rename, deps.log);
11275
- await deps.fs.writeFile(join8(opts.outDir, "metrics.csv"), toCsv(rows));
11530
+ await deps.fs.writeFile(join9(opts.outDir, "metrics.csv"), toCsv(rows));
11276
11531
  const summary = summarize({ runs, outDir: opts.outDir });
11277
11532
  return { runs, rows, summary };
11278
11533
  }
@@ -11280,7 +11535,7 @@ async function replayRuns(deps, sources, outDir, scenario) {
11280
11535
  await deps.fs.mkdir(outDir);
11281
11536
  const runs = [];
11282
11537
  for (const src of sources) {
11283
- const text = await deps.fs.readFile(join8(src.dir, "runs.ndjson")).catch(() => {
11538
+ const text = await deps.fs.readFile(join9(src.dir, "runs.ndjson")).catch(() => {
11284
11539
  throw new ConfigError([`--replay ${src.dir}: no runs.ndjson (not a keepsnappy out dir)`]);
11285
11540
  });
11286
11541
  for (const line of text.split(`
@@ -11291,18 +11546,18 @@ async function replayRuns(deps, sources, outDir, scenario) {
11291
11546
  if (!r.valid || src.arm && r.arm !== src.arm)
11292
11547
  continue;
11293
11548
  const id = `${String(runs.length + 1).padStart(3, "0")}-A`;
11294
- await deps.fs.mkdir(join8(outDir, id));
11549
+ await deps.fs.mkdir(join9(outDir, id));
11295
11550
  for (const f of ["keepsnappy.log", "markers.ndjson", "test.log"]) {
11296
- const body = await deps.fs.readFile(join8(src.dir, r.run_id, f)).catch(() => null);
11551
+ const body = await deps.fs.readFile(join9(src.dir, r.run_id, f)).catch(() => null);
11297
11552
  if (body !== null)
11298
- await deps.fs.writeFile(join8(outDir, id, f), body);
11553
+ await deps.fs.writeFile(join9(outDir, id, f), body);
11299
11554
  }
11300
11555
  runs.push({ ...r, run_id: id, arm: "A", block: runs.length, scenario });
11301
11556
  }
11302
11557
  }
11303
11558
  if (runs.length === 0)
11304
11559
  throw new ConfigError([`--replay: no valid runs in ${sources.map((x) => x.dir + (x.arm ? `:${x.arm}` : "")).join(", ")}`]);
11305
- await deps.fs.writeFile(join8(outDir, "runs.ndjson"), runs.map((r) => JSON.stringify(r)).join(`
11560
+ await deps.fs.writeFile(join9(outDir, "runs.ndjson"), runs.map((r) => JSON.stringify(r)).join(`
11306
11561
  `) + `
11307
11562
  `);
11308
11563
  await extract(deps, outDir, undefined);
@@ -11526,8 +11781,11 @@ async function runCommand(deps, o, positionals, rt = { env: process.env, print:
11526
11781
  if (o["fail-on-worse"] && !o.wait)
11527
11782
  throw new ConfigError(["--fail-on-worse needs --wait"]);
11528
11783
  const replay = o.replay?.length ? parseReplay(o.replay) : null;
11529
- const flows = replay ? [await replayFlow(config, o.scenario)] : await resolveFlows(config, positionals, { ...o.scenario ? { scenario: o.scenario } : {}, readFile: (p) => deps.fs.readFile(p) });
11530
- const platformSource = () => config.platform === "ios-sim" ? new IosSimulatorDataSource(deps, config.device) : new IosDeviceDataSource(deps, config.device, resolve3(out, ".scratch"));
11784
+ const resolved = replay ? [await replayFlow(config, o.scenario)] : await resolveFlows(config, positionals, { ...o.scenario ? { scenario: o.scenario } : {}, readFile: (p) => deps.fs.readFile(p) });
11785
+ const named = o.device ?? (config.device === "booted" ? undefined : config.device);
11786
+ const udid = replay ? undefined : await resolveRunDevice(deps, config.platform, named);
11787
+ const flows = udid ? resolved.map((f) => withDevice(f, udid)) : resolved;
11788
+ const platformSource = () => config.platform === "ios-sim" ? new IosSimulatorDataSource(deps, udid) : new IosDeviceDataSource(deps, udid, resolve3(out, ".scratch"));
11531
11789
  const source = rt.source ?? (replay ? null : platformSource());
11532
11790
  const described = o["device-model"] ? null : await source?.describeDevice?.().catch(() => null);
11533
11791
  const device = {
@@ -11588,7 +11846,7 @@ async function runCommand(deps, o, positionals, rt = { env: process.env, print:
11588
11846
  ${outcome.summary}`);
11589
11847
  }
11590
11848
  const info = { device, ...git.branch ? { branch: git.branch } : {}, ...git.commit ? { commit: git.commit } : {} };
11591
- await deps.fs.writeFile(join9(out, RUN_INFO), `${JSON.stringify({ ...info, ...runSet ? { run_set_id: runSet.run_set_id } : {} }, null, 2)}
11849
+ await deps.fs.writeFile(join10(out, RUN_INFO), `${JSON.stringify({ ...info, ...runSet ? { run_set_id: runSet.run_set_id } : {} }, null, 2)}
11592
11850
  `);
11593
11851
  if (!api) {
11594
11852
  if (!runs.some((run) => run.valid)) {
@@ -11602,7 +11860,7 @@ ${outcome.summary}`);
11602
11860
  ingestUrl: api.url,
11603
11861
  key: api.key,
11604
11862
  externalPrefix: `${git.commit ? git.commit.slice(0, 12) : "local"}-${runSet ? runSet.run_set_id.slice(0, 8) : String(Date.now())}`,
11605
- device: { ...device, ...config.device && config.device !== "booted" ? { id: config.device } : {} },
11863
+ device: { ...device, ...named ? { id: named } : {} },
11606
11864
  meta: { device_provider: provider, ...git.commit ? { commit_sha: git.commit } : {}, ...git.branch ? { branch: git.branch } : {} },
11607
11865
  ...runSet ? { runSetId: runSet.run_set_id } : {},
11608
11866
  driver: config.flows.runner === "maestro" && positionals.length === 0 ? "maestro" : "keepsnappy-run",
@@ -11656,7 +11914,7 @@ var infra = (what) => (e) => {
11656
11914
  };
11657
11915
  async function writeResult(deps, out, rt, runSet, summary, branch, blocked = false) {
11658
11916
  const url = summary?.dashboard_url ?? runSet.dashboard_url;
11659
- await deps.fs.writeFile(join9(out, "keepsnappy-result.json"), `${JSON.stringify({ run_set_id: runSet.run_set_id, dashboard_url: url, state: blocked ? "blocked" : summary?.verdicts?.state ?? null, summary }, null, 2)}
11917
+ await deps.fs.writeFile(join10(out, "keepsnappy-result.json"), `${JSON.stringify({ run_set_id: runSet.run_set_id, dashboard_url: url, state: blocked ? "blocked" : summary?.verdicts?.state ?? null, summary }, null, 2)}
11660
11918
  `);
11661
11919
  const step = rt.env.GITHUB_STEP_SUMMARY;
11662
11920
  if (step)
@@ -11669,20 +11927,20 @@ import { parseArgs as parseArgs6 } from "node:util";
11669
11927
  // apps/agent/src/user-config.ts
11670
11928
  import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
11671
11929
  import { homedir } from "node:os";
11672
- import { dirname as dirname2, join as join11, resolve as resolve5 } from "node:path";
11930
+ import { dirname as dirname2, join as join12, resolve as resolve5 } from "node:path";
11673
11931
  import { parseArgs } from "node:util";
11674
11932
 
11675
11933
  // apps/agent/src/agent-path.ts
11676
11934
  import { constants } from "node:fs";
11677
11935
  import { access, stat } from "node:fs/promises";
11678
- import { join as join10, resolve as resolve4 } from "node:path";
11936
+ import { join as join11, resolve as resolve4 } from "node:path";
11679
11937
  var AGENTS = ["claude", "codex"];
11680
11938
  var PATH_KEYS = { claude: "claudePath", codex: "codexPath" };
11681
11939
  var DIR_KEYS = { claude: "claudeConfigDir", codex: "codexHome" };
11682
11940
  var DIR_FLAGS = { claude: "claude-config-dir", codex: "codex-home" };
11683
11941
  var DIR_ENV = { claude: "CLAUDE_CONFIG_DIR", codex: "CODEX_HOME" };
11684
11942
  var isAgent = (v) => AGENTS.includes(v);
11685
- var expandHome = (p, home) => home && (p === "~" || p.startsWith("~/")) ? join10(home, p.slice(1)) : p;
11943
+ var expandHome = (p, home) => home && (p === "~" || p.startsWith("~/")) ? join11(home, p.slice(1)) : p;
11686
11944
  async function checkAgentFile(given, o) {
11687
11945
  const path = resolve4(o.cwd, expandHome(given, o.home));
11688
11946
  let info;
@@ -11711,7 +11969,7 @@ async function checkFolder(given, o) {
11711
11969
  var agentEnv = (env, agent, folder) => folder === undefined ? env : { ...env, [DIR_ENV[agent]]: folder };
11712
11970
 
11713
11971
  // apps/agent/src/user-config.ts
11714
- var userConfigPath = (env) => join11(env.XDG_CONFIG_HOME?.trim() || join11(env.HOME?.trim() || homedir(), ".config"), "keepsnappy", "config.yml");
11972
+ var userConfigPath = (env) => join12(env.XDG_CONFIG_HOME?.trim() || join12(env.HOME?.trim() || homedir(), ".config"), "keepsnappy", "config.yml");
11715
11973
  var isObj2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
11716
11974
  function parseDoc(text, label) {
11717
11975
  try {
@@ -12086,9 +12344,10 @@ var listFindings = (t, f = {}) => get(t, "v1/findings", { branch: f.branch, main
12086
12344
  var branchStatus = (t, branch) => get(t, "v1/branches/status", { branch });
12087
12345
  var listProblems = (t, f = {}) => get(t, "v1/problems", { branch: f.branch, main: f.main ? "true" : undefined, journey: f.journey, phone: f.phone, limit: f.limit, offset: f.offset || undefined });
12088
12346
  var getFinding = (t, id) => get(t, `v1/findings/${encodeURIComponent(id)}`);
12089
- var getInvestigation = (t, id) => get(t, `v1/findings/${encodeURIComponent(id)}/investigation`);
12347
+ var getInvestigation = (t, id, q = {}) => get(t, `v1/findings/${encodeURIComponent(id)}/investigation`, q);
12090
12348
  var listEvents = (t, q = {}) => get(t, "v1/events", q);
12091
12349
  var listBranches = (t, q = {}) => get(t, "v1/branches", q);
12350
+ var listFixProposals = (t, q = {}) => get(t, "v1/fix-proposals", q);
12092
12351
  var postFixProposal = (t, submission) => get(t, "v1/fix-proposals", {}, { method: "POST", body: submission });
12093
12352
  var submitHypotheses = (t, id, submission) => get(t, `v1/findings/${encodeURIComponent(id)}/hypotheses`, {}, { method: "POST", body: submission });
12094
12353
  var postInvestigationRun = (t, id, run, opts = {}) => get(t, `v1/findings/${encodeURIComponent(id)}/investigation-runs`, {}, { method: "POST", body: run, ...opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {} });
@@ -16333,12 +16592,14 @@ class Rows {
16333
16592
  var LABEL_W = 14;
16334
16593
  var line = (label, text) => ` ${label.padEnd(LABEL_W)}${text}`;
16335
16594
  var plural2 = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
16595
+ var notComparedLines = (places, indent) => (places ?? []).map((p) => `${indent}${p.text}`);
16336
16596
  var promptText = (f) => f.what_to_do.prompt ?? "No prompt: this finding is an improvement.";
16337
16597
  function formatFinding(f, opts = {}) {
16338
16598
  const r = new Rows;
16339
16599
  r.both(f.answer.headline, line("What changed", f.answer.what_changed), line("Where", f.answer.where), line("How sure", f.answer.how_sure));
16340
16600
  if (f.lane)
16341
16601
  r.both(...laneContextLines(f.lane.context, f.lane.journey, f.lane.phone_name, opts.verbose, opts.form === "short"));
16602
+ r.both(...notComparedLines(f.not_compared, " "));
16342
16603
  r.full(line("Why", f.answer.why));
16343
16604
  r.both(line("What to do", f.answer.what_to_do));
16344
16605
  r.full(line("Seen", f.answer.lifecycle), ...f.lifecycle.unstable_note ? [line("", f.lifecycle.unstable_note)] : [], line("Layer", `${f.layer.id} · ${f.layer.name} (${f.certificate ? "certified" : "uncertified"})${f.limits.length ? `; limits: ${f.limits.join("; ")}` : ""}`));
@@ -16354,10 +16615,14 @@ function formatFinding(f, opts = {}) {
16354
16615
  if (memory.length) {
16355
16616
  r.both(` Memory evidence: ${f.where.on === "main" ? "earlier → latest" : "main → PR"} (values are not a verdict)`);
16356
16617
  for (const m of memory) {
16357
- const value = (n) => n === null ? "not stored" : fieldValueText(m.metric, n);
16618
+ const value = (n) => n === null ? NOT_STORED_WORD : fieldValueText(m.metric, n);
16358
16619
  r.add(m.metric === f.what.metric, ` ${m.label}: ${value(m.before)} → ${value(m.after)}${m.word ? ` (${m.word})` : ""}`);
16359
16620
  }
16360
16621
  }
16622
+ if (f.why.cpu?.place)
16623
+ r.both(` ${f.why.cpu.place.text}`);
16624
+ if (f.why.cpu?.session)
16625
+ r.both(` ${f.why.cpu.session.text}`);
16361
16626
  if (opts.prompt)
16362
16627
  r.full("", promptText(f));
16363
16628
  return r.render(opts.form, [
@@ -16387,7 +16652,7 @@ function measuredLines(l, r, opts) {
16387
16652
  };
16388
16653
  if (m.lanes.length === 1) {
16389
16654
  r.both(`${scope}: newest measured ${f.branch ? "" : `on ${n.branch}, `}${lane(n)}; ${plural2(m.run_sets, "run set")} measured.`);
16390
- r.both(...(n.comparison_notes ?? []).map((line) => ` ${line}`));
16655
+ r.both(...(n.comparison_notes ?? []).map((line) => ` ${line}`), ...notComparedLines(n.not_compared, " "));
16391
16656
  r.full(...(n.session_speed ?? []).map((line) => ` ${line}`));
16392
16657
  r.both(...laneContextLines(n.context, n.journey, n.phone_name, opts.verbose, opts.form === "short"));
16393
16658
  return [];
@@ -16395,10 +16660,10 @@ function measuredLines(l, r, opts) {
16395
16660
  const shown = m.lanes.slice(0, LANES_SHOWN);
16396
16661
  r.add(shown.some((x) => !x.judged || !!x.context), `${scope}: ${plural2(m.run_sets, "run set")} measured; the newest of each journey × phone:`);
16397
16662
  for (const x of shown) {
16398
- const keep = !x.judged || !!x.context || !!x.comparison_notes;
16663
+ const keep = !x.judged || !!x.context || !!x.comparison_notes || !!x.not_compared?.length;
16399
16664
  r.add(keep, ` ${x.journey} on ${x.phone_name}${f.branch ? "" : ` (${x.branch})`}: ${lane(x)}`);
16400
16665
  r.add(keep, ...resultContextLines(x.context, opts.verbose, opts.form === "short").map((line) => ` ${line}`));
16401
- r.add(keep, ...(x.comparison_notes ?? []).map((line) => ` ${line}`));
16666
+ r.add(keep, ...(x.comparison_notes ?? []).map((line) => ` ${line}`), ...notComparedLines(x.not_compared, " "));
16402
16667
  r.full(...(x.session_speed ?? []).map((line) => ` ${line}`));
16403
16668
  }
16404
16669
  if (m.lanes.length > shown.length)
@@ -16467,11 +16732,12 @@ function formatBranchStatus(s, opts = {}) {
16467
16732
  const shown = `${l.result === "blocks" ? `${l.word} (${blocks})` : badgedWord(l.word, l.blocking)}${l.stale && !l.rules ? ` (${STALE_FLAG})` : ""}`;
16468
16733
  const sha = l.commit?.sha?.slice(0, 7);
16469
16734
  r.both(` ${l.journey_title ?? l.journey} on ${l.phone_name}: ${shown}${feltWords(l)} · ${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)}` : ""}`);
16735
+ r.both(...outcomeLines(l.branch_outcome).map((line) => ` ${line}`));
16470
16736
  r.both(...resultContextLines(l.context, opts.verbose, opts.form === "short"));
16471
16737
  const rulesLine = staleRulesText(l.rules);
16472
16738
  if (rulesLine)
16473
16739
  r.both(` ${rulesLine}`);
16474
- r.both(...(l.comparison_notes ?? []).map((line) => ` ${line}`));
16740
+ r.both(...(l.comparison_notes ?? []).map((line) => ` ${line}`), ...notComparedLines(l.not_compared, " "));
16475
16741
  r.full(...(l.session_speed ?? []).map((line) => ` ${line}`));
16476
16742
  for (const f of l.worst)
16477
16743
  r.both(` ${f.id} ${f.headline}`);
@@ -16653,7 +16919,7 @@ function formatCommits(c) {
16653
16919
  ].join(`
16654
16920
  `);
16655
16921
  }
16656
- var list = (xs) => xs.join(", ");
16922
+ var list2 = (xs) => xs.join(", ");
16657
16923
  function versionText(label, counts) {
16658
16924
  const values = Object.keys(counts).sort((a, b) => a === "unknown" ? -1 : b === "unknown" ? 1 : a < b ? -1 : a > b ? 1 : 0);
16659
16925
  const name = (k) => k === "unknown" ? "not recorded" : k;
@@ -16669,7 +16935,7 @@ function formatSessionPlaces(p, opts = {}) {
16669
16935
  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";
16670
16936
  const v = s.versions;
16671
16937
  const codes = Object.keys(s.runs.invalid_codes);
16672
- 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)})` : ""}` : ""}. ${[
16938
+ 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 ? ` (${list2(codes)})` : ""}` : ""}. ${[
16673
16939
  versionText("SDK", v.sdk),
16674
16940
  versionText("driver", v.driver),
16675
16941
  versionText("driver version", v.driver_version),
@@ -16723,7 +16989,7 @@ function formatSessionPlaces(p, opts = {}) {
16723
16989
  }
16724
16990
  }
16725
16991
  if (comps.length) {
16726
- r.full(` Components: ${list(comps)}`);
16992
+ r.full(` Components: ${list2(comps)}`);
16727
16993
  componentLines++;
16728
16994
  }
16729
16995
  });
@@ -16758,6 +17024,7 @@ function formatMainSessions(h, opts = {}) {
16758
17024
  r.add(k, ` ${MAIN_SUMMARY_PENDING}${s.summary_reason ? ` (${s.summary_reason})` : ""}`);
16759
17025
  else
16760
17026
  r.add(k, ` ${MAIN_TIERS_WORST_FIRST.map((tier) => `${MAIN_TIER_WORDS[tier]}: ${s.places[tier]}`).join("; ")}${s.cant_reason ? `; ${mainSessionCantWords(s.places.cant, s.cant_reason, baseline)}` : s.cant_range ? `; ${mainSessionCantRangeWords(s.places.cant, s.cant_range, baseline)}` : ""}`);
17027
+ r.add(k, ...notComparedLines(s.not_compared, " "));
16761
17028
  if (s.waiting_sessions)
16762
17029
  r.add(k, ` ${waitingSessionsWords(s.waiting_sessions)}`);
16763
17030
  if (s.memory)
@@ -16897,9 +17164,13 @@ function formatInvestigation(p, opts = {}) {
16897
17164
  const sha = v.lane.commit?.sha?.slice(0, 7);
16898
17165
  r.both(`${v.lane.journey_title ?? v.lane.journey} on ${v.lane.phone_name}, branch ${v.lane.branch} (${v.lane.on === "main" ? "main" : "PR"}) · ${sha ? `commit ${sha}` : "commit not recorded"} (${when(v.lane.at)})`);
16899
17166
  r.both(`Verdict: ${v.stale ? `${v.word} (${STALE_FLAG})` : badgedWord(v.word, v.blocking)}${feltWords({ felt_cost: v.felt_cost ?? undefined, to_check: v.to_check_count ?? undefined })}${v.reason ? ` · ${v.reason}` : ""}`);
16900
- r.both(...v.comparison_notes.map((n) => ` ${n}`));
17167
+ r.both(...v.comparison_notes.map((n) => ` ${n}`), ...notComparedLines(v.not_compared, " "));
16901
17168
  if (v.waiting_for_reprocess)
16902
17169
  r.both(` ${waitingWords(v.waiting_for_reprocess)}`);
17170
+ if (f.why?.cpu?.place)
17171
+ r.both(` ${f.why.cpu.place.text}`);
17172
+ if (f.why?.cpu?.session)
17173
+ r.both(` ${f.why.cpu.session.text}`);
16903
17174
  } else
16904
17175
  r.both(`No stored verdict for this finding (${f.kind} finding): ${f.answer.headline}`);
16905
17176
  r.both(`Places with a worse result: ${heldWords(p.places)}`);
@@ -16954,6 +17225,14 @@ function formatInvestigation(p, opts = {}) {
16954
17225
  ...c.available && c.commits.items.length ? [plural2(c.commits.items.length, "commit line")] : []
16955
17226
  ]);
16956
17227
  }
17228
+ function formatFixProposals(a) {
17229
+ if (a.proposals.length === 0)
17230
+ return `No fix was proposed${a.branch ? ` for ${a.branch}` : ""} yet: \`keepsnappy fix <pr|branch>\` makes one.`;
17231
+ return a.proposals.map((p) => [`Fix for ${p.source_pr === null ? p.source_branch : `#${p.source_pr} (${p.source_branch})`}: ${p.fix_branch}${p.fix_pr_number === null ? "" : `, draft PR #${p.fix_pr_number}`}${p.fix_pr_url ? ` ${p.fix_pr_url}` : ""}`, fixCardText(p.card)].join(`
17232
+ `)).join(`
17233
+
17234
+ `);
17235
+ }
16957
17236
  function runLine(r) {
16958
17237
  return [
16959
17238
  r.created_at.slice(0, 16).replace("T", " "),
@@ -16999,6 +17278,7 @@ var FINDINGS_USAGE = `keepsnappy findings [--branch NAME | --main] [--journey KE
16999
17278
  main --journey KEY --phone MODEL [--sessions N | --session ID] [--vs-previous] [--measure REF] [--limit N] [--offset N] [--short] [--json]
17000
17279
  main --journey KEY --phone MODEL --changes [--limit N] [--json] (main's confirmed changes, newest first: commit range from the session before to the first session at the new level, and the PRs merged in it)
17001
17280
  hypotheses <finding-id> [--json] (the hypotheses stored about a finding by keepsnappy investigate, with their outcome, and the investigation runs that made them or did not, each with the tokens it used and what that would cost on an API key; predictions and cost, never a verdict.)
17281
+ fixes [--branch NAME | --pr N] [--json] (the fix PRs keepsnappy fix opened: the card of each, what it did to every finding it was for: Fixed, No change, Worse, or Waiting for the fix branch; --branch is the PR branch or the fix branch, --pr the PR or the draft PR)
17002
17282
  layers [--json]
17003
17283
  trend <journey> <window words...> --phone MODEL [--window PLACE ...] [--ref REF ...] [--sessions N] [--short] [--json]
17004
17284
  (the words after the journey are one window name; more windows with repeated --window)
@@ -17010,7 +17290,7 @@ ${READ_USAGE.detailFlag}
17010
17290
  var STATES = ["present", "not-judged", "resolved", "all"];
17011
17291
  var FINDING_ID = /^f_[0-9a-f]{16}$/;
17012
17292
  var SHORT_COMMANDS = new Set(["findings", "status", "problems", "finding", "compare", "history", "places", "main", "trend"]);
17013
- var FINDINGS_COMMANDS = new Set(["findings", "status", "problems", "finding", "compare", "history", "commits", "places", "layers", "main", "trend", "hypotheses"]);
17293
+ var FINDINGS_COMMANDS = new Set(["findings", "status", "problems", "finding", "compare", "history", "commits", "places", "layers", "main", "trend", "hypotheses", "fixes"]);
17014
17294
  function resolveReadTarget(o, rt) {
17015
17295
  const keyEnv = o["key-env"] ?? "KEEPSNAPPY_KEY";
17016
17296
  const key = projectKey(rt.env, keyEnv);
@@ -17032,6 +17312,7 @@ async function findingsCommand(cmd, argv, rt) {
17032
17312
  branch: { type: "string" },
17033
17313
  main: { type: "boolean" },
17034
17314
  journey: { type: "string" },
17315
+ pr: { type: "string" },
17035
17316
  phone: { type: "string" },
17036
17317
  state: { type: "string" },
17037
17318
  session: { type: "string" },
@@ -17171,6 +17452,14 @@ async function findingsCommand(cmd, argv, rt) {
17171
17452
  ${h.outcome_word}`), ...formatInvestigationRuns(v), v.note].join(`
17172
17453
  `));
17173
17454
  }
17455
+ case "fixes": {
17456
+ const pr = o.pr === undefined ? undefined : Number(o.pr);
17457
+ if (pr !== undefined && (!Number.isInteger(pr) || pr < 1))
17458
+ throw new UsageError(["--pr must be a PR number"]);
17459
+ if (o.branch && pr !== undefined)
17460
+ throw new UsageError(["give --branch or --pr, not both"]);
17461
+ return out(await listFixProposals(t, { ...o.branch ? { branch: o.branch } : {}, ...pr !== undefined ? { pr } : {} }), formatFixProposals);
17462
+ }
17174
17463
  case "history":
17175
17464
  need(2, "<journey> <window>");
17176
17465
  return out(await windowHistory(t, { journey: p[0], window: p.slice(1).join(" "), ...o.phone ? { phone: o.phone } : {}, ...limit ? { limit } : {} }), formatHistory);
@@ -17498,8 +17787,8 @@ import { StringDecoder } from "node:string_decoder";
17498
17787
  import { randomBytes } from "node:crypto";
17499
17788
  import { createRequire } from "node:module";
17500
17789
  import { chmod, mkdir as mkdir3, mkdtemp, readFile as readFile4, realpath, rm, writeFile as writeFile3 } from "node:fs/promises";
17501
- import { tmpdir } from "node:os";
17502
- import { dirname as dirname3, join as join12, resolve as resolve6 } from "node:path";
17790
+ import { tmpdir as tmpdir2 } from "node:os";
17791
+ import { dirname as dirname3, join as join13, resolve as resolve6 } from "node:path";
17503
17792
  import { parseArgs as parseArgs3 } from "node:util";
17504
17793
 
17505
17794
  // apps/agent/src/agent-usage.ts
@@ -17804,7 +18093,7 @@ async function falseAlarmFacts(pkg, reads) {
17804
18093
  ` Unstable or noisy marks of the lane:
17805
18094
  ${lines(pkg.verdict?.unstable.items.map((u) => `${u.place_name}: ${u.note}`))}`,
17806
18095
  ` Comparison notes:
17807
- ${lines(pkg.verdict?.comparison_notes)}`,
18096
+ ${lines([...pkg.verdict?.comparison_notes ?? [], ...(pkg.verdict?.not_compared ?? []).map((p) => p.text)])}`,
17808
18097
  " A/A results: not stored (no read exposes them)."
17809
18098
  ];
17810
18099
  const read = async (tool, run) => {
@@ -18078,7 +18367,7 @@ var usageLine = (v) => `${USAGE_PREFIX} ${JSON.stringify(usageJson(v))}`;
18078
18367
  var USAGE_LINE = /^KEEPSNAPPY-USAGE (\{[^\r\n]{2,12000}\})$/gm;
18079
18368
  var count2 = (v, max) => v === null || typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= max;
18080
18369
  var cost2 = (v) => v === null || typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= INVESTIGATION_RUN_LIMITS.costUsd;
18081
- var label2 = (v, max) => typeof v === "string" && v.length >= 1 && v.length <= max && !/[\u0000-\u001f]/.test(v);
18370
+ var label3 = (v, max) => typeof v === "string" && v.length >= 1 && v.length <= max && !/[\u0000-\u001f]/.test(v);
18082
18371
  var TOKEN_KEYS = ["input_tokens", "output_tokens", "cache_read_tokens", "cache_creation_tokens", "cache_creation_5m_tokens", "cache_creation_1h_tokens"];
18083
18372
  var sameKeys2 = (o, keys) => Object.keys(o).length === keys.length && keys.every((k) => (k in o));
18084
18373
  function readUsageLine(log) {
@@ -18093,14 +18382,15 @@ function readUsageLine(log) {
18093
18382
  }
18094
18383
  if (!isObj3(v) || !sameKeys2(v, KEYS))
18095
18384
  return null;
18096
- const ok = typeof v.run_id === "string" && /^[A-Za-z0-9._:-]{8,64}$/.test(v.run_id) && INVESTIGATION_RUN_OUTCOMES.includes(v.outcome) && label2(v.agent, INVESTIGATION_RUN_LIMITS.label) && (v.model === null || label2(v.model, INVESTIGATION_RUN_LIMITS.label)) && (v.by_model === null || Array.isArray(v.by_model) && v.by_model.length >= 1 && v.by_model.length <= INVESTIGATION_RUN_LIMITS.models && v.by_model.every((m) => isObj3(m) && sameKeys2(m, ["model", ...TOKEN_KEYS]) && label2(m.model, INVESTIGATION_RUN_LIMITS.label) && TOKEN_KEYS.every((k) => count2(m[k], INVESTIGATION_RUN_LIMITS.tokens)))) && TOKEN_KEYS.every((k) => count2(v[k], INVESTIGATION_RUN_LIMITS.tokens)) && count2(v.total_tokens, 6 * INVESTIGATION_RUN_LIMITS.tokens) && count2(v.turns, INVESTIGATION_RUN_LIMITS.turns) && count2(v.duration_ms, INVESTIGATION_RUN_LIMITS.durationMs) && cost2(v.reported_cost_usd) && cost2(v.estimated_cost_usd) && label2(v.price_table_version, 40) && typeof v.stored === "boolean";
18385
+ const ok = typeof v.run_id === "string" && /^[A-Za-z0-9._:-]{8,64}$/.test(v.run_id) && INVESTIGATION_RUN_OUTCOMES.includes(v.outcome) && label3(v.agent, INVESTIGATION_RUN_LIMITS.label) && (v.model === null || label3(v.model, INVESTIGATION_RUN_LIMITS.label)) && (v.by_model === null || Array.isArray(v.by_model) && v.by_model.length >= 1 && v.by_model.length <= INVESTIGATION_RUN_LIMITS.models && v.by_model.every((m) => isObj3(m) && sameKeys2(m, ["model", ...TOKEN_KEYS]) && label3(m.model, INVESTIGATION_RUN_LIMITS.label) && TOKEN_KEYS.every((k) => count2(m[k], INVESTIGATION_RUN_LIMITS.tokens)))) && TOKEN_KEYS.every((k) => count2(v[k], INVESTIGATION_RUN_LIMITS.tokens)) && count2(v.total_tokens, 6 * INVESTIGATION_RUN_LIMITS.tokens) && count2(v.turns, INVESTIGATION_RUN_LIMITS.turns) && count2(v.duration_ms, INVESTIGATION_RUN_LIMITS.durationMs) && cost2(v.reported_cost_usd) && cost2(v.estimated_cost_usd) && label3(v.price_table_version, 40) && typeof v.stored === "boolean";
18097
18386
  return ok ? v : null;
18098
18387
  }
18099
18388
 
18100
18389
  // apps/agent/src/investigate.ts
18101
- var INVESTIGATE_USAGE = `keepsnappy investigate <finding-id> [--repo PATH] [--agent claude|codex] [--claude-path FILE] [--codex-path FILE] [--claude-config-dir DIR] [--codex-home DIR] [--codex-model NAME] [--max-turns N] [--max-minutes N] [--save] [--dry-run] [--preflight] [--json] [--config keepsnappy.yml]
18390
+ var INVESTIGATE_USAGE = `keepsnappy investigate <finding-id> [--branch NAME] [--repo PATH] [--agent claude|codex] [--claude-path FILE] [--codex-path FILE] [--claude-config-dir DIR] [--codex-home DIR] [--codex-model NAME] [--max-turns N] [--max-minutes N] [--save] [--dry-run] [--preflight] [--json] [--config keepsnappy.yml]
18102
18391
  Reads the finding's investigation package, checks out the measured commit in a separate worktree of your app repo, runs your own claude or codex
18103
18392
  there read-only, validates its hypotheses against the schema and stores them. --dry-run does everything except the last step.
18393
+ --branch NAME: the package of that PR (its lane, its measured commit): one finding is one place, which can be worse on several PRs; without it, the PR it was seen on last.
18104
18394
  --preflight does everything before the agent starts and calls no model: reads the package, checks the commit is in your repo, adds and removes the worktree, runs
18105
18395
  the agent with --version. It ends with KEEPSNAPPY-STATUS preflight-ok, or fails the way a real run would.
18106
18396
  --repo defaults to investigate.repos.<app> in keepsnappy.yml; --agent, --claude-path, --codex-path, --max-turns and --max-minutes to investigate.agent,
@@ -18267,6 +18557,10 @@ async function readInvestigateSettings(explicit, env = process.env, cwd = proces
18267
18557
  const outDir = isObj4(output) && typeof output.dir === "string" && output.dir.trim() !== "" ? { outDir: output.dir } : {};
18268
18558
  return { ...user, ...project, ...outDir, repos: { ...user.repos, ...project.repos }, paths: { ...user.paths, ...project.paths }, dirs: { ...user.dirs, ...project.dirs } };
18269
18559
  }
18560
+ var realTimer = (ms, fire) => {
18561
+ const t = setTimeout(fire, ms);
18562
+ return () => clearTimeout(t);
18563
+ };
18270
18564
  var MAX_STDERR_CHARS = 64 * 1024;
18271
18565
  var MAX_STDOUT_HEAD_CHARS = 4096;
18272
18566
  function runAgentProcess(o) {
@@ -18291,7 +18585,7 @@ function runAgentProcess(o) {
18291
18585
  killTree("SIGTERM");
18292
18586
  hard = setTimeout(() => killTree("SIGKILL"), o.killGraceMs);
18293
18587
  };
18294
- const timer = setTimeout(() => halt("timeout"), o.timeoutMs);
18588
+ const cancelTimer = (o.timer ?? realTimer)(o.timeoutMs, () => halt("timeout"));
18295
18589
  const onAbort = () => halt("aborted");
18296
18590
  if (o.signal.aborted)
18297
18591
  onAbort();
@@ -18318,7 +18612,7 @@ function runAgentProcess(o) {
18318
18612
  });
18319
18613
  child.on("exit", () => killTree("SIGKILL"));
18320
18614
  child.on("close", (code) => {
18321
- clearTimeout(timer);
18615
+ cancelTimer();
18322
18616
  clearTimeout(hard);
18323
18617
  o.signal.removeEventListener("abort", onAbort);
18324
18618
  killTree("SIGKILL");
@@ -18381,7 +18675,7 @@ var rangeStart = (p) => p.main_change ? p.main_change.commits[0] : p.commits.ava
18381
18675
  function mcpServerCommand() {
18382
18676
  try {
18383
18677
  const entry = createRequire(import.meta.url).resolve("@keepsnappy/mcp");
18384
- return { command: process.execPath, args: [join12(dirname3(entry), "bin.js")] };
18678
+ return { command: process.execPath, args: [join13(dirname3(entry), "bin.js")] };
18385
18679
  } catch {
18386
18680
  return { command: "keepsnappy-mcp", args: [] };
18387
18681
  }
@@ -18427,6 +18721,7 @@ async function investigateRun(argv, rt, end, step) {
18427
18721
  allowPositionals: true,
18428
18722
  options: {
18429
18723
  finding: { type: "string" },
18724
+ branch: { type: "string" },
18430
18725
  repo: { type: "string" },
18431
18726
  agent: { type: "string" },
18432
18727
  "claude-path": { type: "string" },
@@ -18454,6 +18749,8 @@ async function investigateRun(argv, rt, end, step) {
18454
18749
  throw new UsageError(["keepsnappy investigate needs one <finding-id>", INVESTIGATE_USAGE]);
18455
18750
  if (o.preflight && o.save)
18456
18751
  throw new UsageError(["--preflight changes nothing: it cannot be combined with --save", INVESTIGATE_USAGE]);
18752
+ if (o.branch !== undefined && o.branch.trim() === "")
18753
+ throw new UsageError(["--branch needs a branch name", INVESTIGATE_USAGE]);
18457
18754
  const cwd = rt.cwd ?? process.cwd();
18458
18755
  const home = rt.env.HOME;
18459
18756
  const saved = await readInvestigateSettings(o.config, rt.env, cwd);
@@ -18517,7 +18814,7 @@ async function investigateRun(argv, rt, end, step) {
18517
18814
  }
18518
18815
  const target = resolveReadTarget(o, rt);
18519
18816
  step(1);
18520
- const pkg = await getInvestigation(target, id);
18817
+ const pkg = await getInvestigation(target, id, o.branch ? { branch: o.branch } : {});
18521
18818
  const sha = measuredCommit(pkg);
18522
18819
  const base = rangeStart(pkg);
18523
18820
  for (const c of [sha, base])
@@ -18571,8 +18868,8 @@ async function investigateRun(argv, rt, end, step) {
18571
18868
  process.once("SIGINT", stopped);
18572
18869
  process.once("SIGTERM", stopped);
18573
18870
  step(2);
18574
- scratchDir = await mkdtemp(join12(rt.tmpdir ?? tmpdir(), "keepsnappy-investigate-"));
18575
- worktree = join12(scratchDir, "code");
18871
+ scratchDir = await mkdtemp(join13(rt.tmpdir ?? tmpdir2(), "keepsnappy-investigate-"));
18872
+ worktree = join13(scratchDir, "code");
18576
18873
  const add = await git(["worktree", "add", "--detach", worktree, sha]);
18577
18874
  if (add.code !== 0)
18578
18875
  throw new Error(`git worktree add failed in ${root}: ${add.stderr.trim()}`);
@@ -18590,18 +18887,18 @@ async function investigateRun(argv, rt, end, step) {
18590
18887
  let lastMessage;
18591
18888
  let mcpConfig = "";
18592
18889
  if (agent === "claude") {
18593
- mcpConfig = join12(scratchDir, "mcp.json");
18890
+ mcpConfig = join13(scratchDir, "mcp.json");
18594
18891
  const server = rt.mcpServer ?? mcpServerCommand();
18595
18892
  await writeFile3(mcpConfig, JSON.stringify({ mcpServers: { keepsnappy: { ...server, env: { KEEPSNAPPY_KEY: target.key, KEEPSNAPPY_API: target.url } } } }), { mode: 384 });
18596
18893
  await chmod(mcpConfig, 384);
18597
18894
  args = claudeArgs({ maxTurns, mcpConfig });
18598
18895
  } else {
18599
- lastMessage = join12(scratchDir, "last-message.txt");
18896
+ lastMessage = join13(scratchDir, "last-message.txt");
18600
18897
  args = codexArgs({ lastMessage, worktree: await realpath(worktree), model: codexModel });
18601
18898
  }
18602
18899
  step(3);
18603
18900
  const startedAt = Date.now();
18604
- const run = await runAgentProcess({ agent, cmd: command, args, cwd: worktree, env: childEnv, stdin: prompt, timeoutMs: maxMinutes * 60000, killGraceMs: rt.killGraceMs ?? 5000, signal: abort.signal });
18901
+ const run = await runAgentProcess({ agent, cmd: command, args, cwd: worktree, env: childEnv, stdin: prompt, timeoutMs: maxMinutes * 60000, killGraceMs: rt.killGraceMs ?? 5000, signal: abort.signal, ...rt.runTimer ? { timer: rt.runTimer } : {} });
18605
18902
  let measuredMs = Date.now() - startedAt;
18606
18903
  if (run.stop === "not-found")
18607
18904
  throw new Error(given === undefined ? `${agent} not found on PATH: install it and log in once with \`${agent} login\`` : `${command} could not be started (not found)`);
@@ -18704,9 +19001,9 @@ async function investigateRun(argv, rt, end, step) {
18704
19001
  }
18705
19002
  const keep = async (attempts) => {
18706
19003
  try {
18707
- const dir = join12(resolve6(cwd, expandHome(saved.outDir ?? DEFAULT_OUT_DIR, home)), "investigate");
19004
+ const dir = join13(resolve6(cwd, expandHome(saved.outDir ?? DEFAULT_OUT_DIR, home)), "investigate");
18708
19005
  await mkdir3(dir, { recursive: true });
18709
- const file = join12(dir, `${id.replace(/[^A-Za-z0-9._-]/g, "_")}-${Date.now()}.json`);
19006
+ const file = join13(dir, `${id.replace(/[^A-Za-z0-9._-]/g, "_")}-${Date.now()}.json`);
18710
19007
  await writeFile3(file, `${JSON.stringify({ finding_id: id, prompt_version: INVESTIGATE_PROMPT_VERSION, attempts }, null, 2)}
18711
19008
  `, { mode: 384 });
18712
19009
  log(`The refused answer is kept in ${file} (local only, never uploaded).`);
@@ -18744,18 +19041,19 @@ async function investigateRun(argv, rt, end, step) {
18744
19041
  if (agent === "claude")
18745
19042
  secondArgs = claudeArgs({ maxTurns: REPAIR_TURNS, mcpConfig });
18746
19043
  else {
18747
- secondLast = join12(scratchDir, "last-message-repair.txt");
19044
+ secondLast = join13(scratchDir, "last-message-repair.txt");
18748
19045
  secondArgs = codexArgs({ lastMessage: secondLast, worktree: await realpath(worktree), model: codexModel });
18749
19046
  }
19047
+ const repairMinutes = rt.repairMs === undefined ? Math.min(maxMinutes, REPAIR_MINUTES) : rt.repairMs / 60000;
18750
19048
  const secondStart = Date.now();
18751
- const second = await runAgentProcess({ agent, cmd: command, args: secondArgs, cwd: worktree, env: childEnv, stdin: repairPrompt({ answerText: answer.text, errors: attempts[0].errors }), timeoutMs: Math.min(maxMinutes, REPAIR_MINUTES) * 60000, killGraceMs: rt.killGraceMs ?? 5000, signal: abort.signal });
19049
+ const second = await runAgentProcess({ agent, cmd: command, args: secondArgs, cwd: worktree, env: childEnv, stdin: repairPrompt({ answerText: answer.text, errors: attempts[0].errors }), timeoutMs: repairMinutes * 60000, killGraceMs: rt.killGraceMs ?? 5000, signal: abort.signal });
18752
19050
  measuredMs += Date.now() - secondStart;
18753
19051
  usage = addUsage(usage, second.stream.usage());
18754
19052
  if (second.stop === "aborted") {
18755
19053
  log("Stopped: the agent and the worktree are removed.");
18756
19054
  return endsWith("stopped", "stopped", SIGINT_EXIT);
18757
19055
  }
18758
- const again = second.stop === "timeout" ? { text: "", failed: `no result within ${Math.min(maxMinutes, REPAIR_MINUTES)} min` } : second.stop === "oversize" ? { text: "", failed: "it printed too much" } : await answerOf(second, secondLast);
19056
+ const again = second.stop === "timeout" ? { text: "", failed: `no result within ${repairMinutes} min` } : second.stop === "oversize" ? { text: "", failed: "it printed too much" } : await answerOf(second, secondLast);
18759
19057
  if (again.failed)
18760
19058
  notes.push(`The repair turn gave no answer: ${again.failed}.`);
18761
19059
  else {
@@ -18927,7 +19225,7 @@ function jsonNotifier(emit) {
18927
19225
  else if (a?.kind === "burst")
18928
19226
  emit({ type: "burst", app: a.app, count: a.count, body: a.body });
18929
19227
  else if (a?.kind === "ended")
18930
- emit({ type: "investigation", finding_id: a.findingId, state: "ended", status: a.status, count: a.count, text: a.text, ...a.usage ? { usage: a.usage } : {} });
19228
+ emit({ type: "investigation", finding_id: a.findingId, branch: a.branch, state: "ended", status: a.status, count: a.count, text: a.text, ...a.usage ? { usage: a.usage } : {} });
18931
19229
  return null;
18932
19230
  }
18933
19231
  };
@@ -18955,16 +19253,16 @@ async function detectNotifier(spawner, platform, print, preferred) {
18955
19253
 
18956
19254
  // apps/agent/src/setup-detect.ts
18957
19255
  import { readdir as readdir2, stat as stat3 } from "node:fs/promises";
18958
- import { join as join13 } from "node:path";
19256
+ import { join as join14 } from "node:path";
18959
19257
  function agentSearchDirs(home) {
18960
- return [...home ? [join13(home, ".local", "bin")] : [], "/opt/homebrew/bin"];
19258
+ return [...home ? [join14(home, ".local", "bin")] : [], "/opt/homebrew/bin"];
18961
19259
  }
18962
19260
  async function detectAgentPath(agent, o) {
18963
19261
  const shell = o.env.SHELL?.trim() || "/bin/zsh";
18964
19262
  const r = await o.spawner.run(shell, ["-lc", `command -v ${agent}`], { timeoutMs: 5000, env: stringEnv(o.env) });
18965
19263
  const fromShell = !r.error && r.code === 0 ? r.stdout.trim().split(`
18966
19264
  `).at(-1)?.trim() : undefined;
18967
- const candidates = [...fromShell && fromShell.startsWith("/") ? [fromShell] : [], ...(o.dirs ?? agentSearchDirs(o.home)).map((d) => join13(d, agent))];
19265
+ const candidates = [...fromShell && fromShell.startsWith("/") ? [fromShell] : [], ...(o.dirs ?? agentSearchDirs(o.home)).map((d) => join14(d, agent))];
18968
19266
  for (const c of candidates) {
18969
19267
  const checked = await checkAgentFile(c, { home: o.home, cwd: "/" });
18970
19268
  if ("path" in checked)
@@ -19004,7 +19302,7 @@ async function scanForRepos(o) {
19004
19302
  if (seen.has(dir) || o.now() - start > budget)
19005
19303
  return;
19006
19304
  seen.add(dir);
19007
- if (self && await isDir(join13(dir, ".git"))) {
19305
+ if (self && await isDir(join14(dir, ".git"))) {
19008
19306
  found.add(dir);
19009
19307
  return;
19010
19308
  }
@@ -19019,11 +19317,11 @@ async function scanForRepos(o) {
19019
19317
  for (const n of names) {
19020
19318
  if (!n.isDirectory() || n.name.startsWith(".") || n.name === "node_modules" || skip?.has(n.name))
19021
19319
  continue;
19022
- await walk(join13(dir, n.name), depth - 1);
19320
+ await walk(join14(dir, n.name), depth - 1);
19023
19321
  }
19024
19322
  };
19025
19323
  for (const r of ROOTS)
19026
- await walk(join13(o.home, r), ROOT_DEPTH);
19324
+ await walk(join14(o.home, r), ROOT_DEPTH);
19027
19325
  await walk(o.home, HOME_DEPTH, false, PROTECTED);
19028
19326
  return [...found].sort();
19029
19327
  }
@@ -19050,10 +19348,10 @@ async function findRepoCandidates(o) {
19050
19348
 
19051
19349
  // apps/agent/src/watch.ts
19052
19350
  import { createHash as createHash2 } from "node:crypto";
19053
- import { closeSync, openSync, realpathSync } from "node:fs";
19054
- import { mkdir as mkdir6, readFile as readFile8, rename as rename2, writeFile as writeFile6 } from "node:fs/promises";
19055
- import { homedir as homedir2 } from "node:os";
19056
- import { join as join18 } from "node:path";
19351
+ import { closeSync, openSync, realpathSync, writeSync } from "node:fs";
19352
+ import { mkdir as mkdir6, readdir as readdir5, readFile as readFile8, rename as rename2, writeFile as writeFile6 } from "node:fs/promises";
19353
+ import { homedir as homedir2, uptime } from "node:os";
19354
+ import { basename as basename4, join as join19 } from "node:path";
19057
19355
  import { spawn as spawn6 } from "node:child_process";
19058
19356
  import { createInterface } from "node:readline";
19059
19357
  import { parseArgs as parseArgs5 } from "node:util";
@@ -19280,7 +19578,7 @@ var NO_WAIT_MS = 0;
19280
19578
  var NEW_COMMAND_TIMEOUT_S = 300;
19281
19579
  var ANDROID = {
19282
19580
  name: "android",
19283
- defaultDevice: "Google Pixel 7-13.0",
19581
+ defaultDevice: "Google Pixel 9 Pro-15.0",
19284
19582
  caps: (device) => ({
19285
19583
  platformName: "Android",
19286
19584
  "appium:automationName": "UiAutomator2",
@@ -19357,15 +19655,15 @@ class WdError extends Error {
19357
19655
  // packages/appium-driver/dist/src/version.js
19358
19656
  import { createHash } from "node:crypto";
19359
19657
  import { existsSync, readFileSync, statSync } from "node:fs";
19360
- import { dirname as dirname4, join as join14, relative as relative2, resolve as resolve7 } from "node:path";
19658
+ import { dirname as dirname4, join as join15, relative as relative2, resolve as resolve7 } from "node:path";
19361
19659
  import { fileURLToPath } from "node:url";
19362
19660
  function driverVersion() {
19363
- const built = "0.1.0-alpha.24";
19661
+ const built = "0.1.0-alpha.25";
19364
19662
  if (built)
19365
19663
  return built;
19366
19664
  let dir = dirname4(fileURLToPath(import.meta.url));
19367
19665
  for (let i = 0;i < 3; i++, dir = dirname4(dir)) {
19368
- const file = join14(dir, "package.json");
19666
+ const file = join15(dir, "package.json");
19369
19667
  if (!existsSync(file))
19370
19668
  continue;
19371
19669
  try {
@@ -19662,8 +19960,11 @@ function fixableItem(f, lane, hypotheses) {
19662
19960
  return { finding_id: f.id, title: f.what.window_name, word: f.word, state: f.state, ms: lane?.felt_cost_ms ?? 0, felt_cost: lane?.felt_cost ?? null, hypotheses, fixable: why === undefined, ...why ? { why_not: why } : {} };
19663
19961
  }
19664
19962
  var byFeltCost = (a, b) => Number(b.fixable) - Number(a.fixable) || b.ms - a.ms || (a.finding_id < b.finding_id ? -1 : 1);
19665
- function formatFixable(l) {
19963
+ function formatFixable(l, o = {}) {
19666
19964
  const where = l.branch === null ? l.pr === null ? "the app" : `PR #${l.pr}` : `${l.branch}${l.pr === null ? "" : ` (PR #${l.pr})`}`;
19965
+ if (o.blocked && l.findings.length)
19966
+ return [`Nothing on ${where} can be fixed now: ${o.blocked}`, ...l.findings.map((f) => ` ${f.finding_id} ${f.word} ${f.title} ${f.felt_cost ?? "—"} not fixable: ${f.why_not}`)].join(`
19967
+ `);
19667
19968
  if (!l.findings.some((f) => f.fixable))
19668
19969
  return `No Worse or Slightly worse finding is present on ${where} now; nothing to fix.`;
19669
19970
  return [`Findings of ${where}, what can be fixed first and the biggest felt cost first:`, ...l.findings.map((f) => ` ${f.finding_id} ${f.word} ${f.title} ${f.felt_cost ?? "—"} ${f.fixable ? `${f.hypotheses} ${f.hypotheses === 1 ? "hypothesis" : "hypotheses"}` : `not fixable: ${f.why_not}`}`)].join(`
@@ -19674,14 +19975,14 @@ function formatFixable(l) {
19674
19975
  import { execFile as execFile2 } from "node:child_process";
19675
19976
  import { randomBytes as randomBytes2 } from "node:crypto";
19676
19977
  import { chmod as chmod2, mkdir as mkdir5, mkdtemp as mkdtemp3, readFile as readFile7, readdir as readdir4, realpath as realpath4, rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
19677
- import { tmpdir as tmpdir2 } from "node:os";
19678
- import { basename as basename2, join as join17, resolve as resolve9 } from "node:path";
19978
+ import { tmpdir as tmpdir3 } from "node:os";
19979
+ import { basename as basename3, join as join18, resolve as resolve9 } from "node:path";
19679
19980
  import { parseArgs as parseArgs4 } from "node:util";
19680
19981
 
19681
19982
  // apps/agent/src/fix-agent.ts
19682
19983
  import { execFile } from "node:child_process";
19683
19984
  import { mkdir as mkdir4, mkdtemp as mkdtemp2, readFile as readFile5, realpath as realpath2, rm as rm2, stat as stat4, writeFile as writeFile4 } from "node:fs/promises";
19684
- import { join as join15 } from "node:path";
19985
+ import { join as join16 } from "node:path";
19685
19986
 
19686
19987
  // apps/agent/src/fix-env.ts
19687
19988
  var NEVER = /^(GH_|GITHUB_|GITLAB_|SSH_|GPG_|AWS_|AZURE_|GOOGLE_|GCLOUD_|NPM_|YARN_|KEEPSNAPPY_|VERCEL_|EXPO_|EAS_)|(_TOKEN|_SECRET|_PASSWORD)$/;
@@ -19800,17 +20101,17 @@ async function codexFixGate(o) {
19800
20101
  else if (on)
19801
20102
  problems.push(`${name} is still on after --disable ${name}: codex may give the model a shell`);
19802
20103
  }
19803
- const dir = await realpath2(await mkdtemp2(join15(await realpath2(o.tmpdir), "keepsnappy-codex-gate-")));
20104
+ const dir = await realpath2(await mkdtemp2(join16(await realpath2(o.tmpdir), "keepsnappy-codex-gate-")));
19804
20105
  try {
19805
- const wt = join15(dir, "code");
20106
+ const wt = join16(dir, "code");
19806
20107
  await mkdir4(wt);
19807
- await writeFile4(join15(wt, ".git"), `gitdir: /nonexistent
20108
+ await writeFile4(join16(wt, ".git"), `gitdir: /nonexistent
19808
20109
  `);
19809
- await mkdir4(join15(dir, "home"));
19810
- const decoy = join15(dir, "home", ".zshrc");
20110
+ await mkdir4(join16(dir, "home"));
20111
+ const decoy = join16(dir, "home", ".zshrc");
19811
20112
  await writeFile4(decoy, `HOME-SECRET-decoy
19812
20113
  `);
19813
- const outside = join15(dir, "outside.txt");
20114
+ const outside = join16(dir, "outside.txt");
19814
20115
  const sh = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
19815
20116
  const script = [
19816
20117
  '( echo x > in.txt ) 2>/dev/null; echo "in:$?"',
@@ -19849,12 +20150,12 @@ async function codexFixGate(o) {
19849
20150
  const outsideExists = await stat4(outside).then(() => true, () => false);
19850
20151
  if (outsideExists)
19851
20152
  problems.push("a file was written outside the worktree by the sandbox probe");
19852
- if (await stat4(join15(wt, "hardlink.txt")).then(() => true, () => false))
20153
+ if (await stat4(join16(wt, "hardlink.txt")).then(() => true, () => false))
19853
20154
  problems.push("a hard link to a home file stand-in exists in the worktree after the sandbox probe");
19854
20155
  if (await readFile5(decoy, "utf8") !== `HOME-SECRET-decoy
19855
20156
  `)
19856
20157
  problems.push("a home file stand-in was changed by the sandbox probe");
19857
- if (await readFile5(join15(wt, ".git"), "utf8") !== `gitdir: /nonexistent
20158
+ if (await readFile5(join16(wt, ".git"), "utf8") !== `gitdir: /nonexistent
19858
20159
  `)
19859
20160
  problems.push("the worktree’s .git file was changed by the sandbox probe");
19860
20161
  } finally {
@@ -19977,7 +20278,7 @@ function packageScriptsChanged(before, after) {
19977
20278
  // apps/agent/src/fix-git.ts
19978
20279
  import { spawn as spawn4 } from "node:child_process";
19979
20280
  import { lstat, readFile as readFile6, readdir as readdir3, readlink, realpath as realpath3 } from "node:fs/promises";
19980
- import { dirname as dirname5, join as join16, resolve as resolve8, sep as sep2 } from "node:path";
20281
+ import { dirname as dirname5, join as join17, resolve as resolve8, sep as sep2 } from "node:path";
19981
20282
  var SAFE = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false"];
19982
20283
  var CAP = 16 * 1024 * 1024;
19983
20284
  function makeFixGit(prefix, env) {
@@ -20003,10 +20304,10 @@ function makeFixGit(prefix, env) {
20003
20304
  });
20004
20305
  }
20005
20306
  async function worktreeGitDir(worktree) {
20006
- const gitFile = await readFile6(join16(worktree, ".git"), "utf8");
20307
+ const gitFile = await readFile6(join17(worktree, ".git"), "utf8");
20007
20308
  const m = /^gitdir: (.+)\n?$/.exec(gitFile);
20008
20309
  if (!m)
20009
- throw new Error(`${join16(worktree, ".git")} is not a worktree file`);
20310
+ throw new Error(`${join17(worktree, ".git")} is not a worktree file`);
20010
20311
  return { gitDir: resolve8(worktree, m[1]), gitFile };
20011
20312
  }
20012
20313
  var MAX_ENTRIES = 500000;
@@ -20015,7 +20316,7 @@ async function resolveLoose(path) {
20015
20316
  let at = path;
20016
20317
  for (;; ) {
20017
20318
  try {
20018
- return join16(await realpath3(at), ...rest.reverse());
20319
+ return join17(await realpath3(at), ...rest.reverse());
20019
20320
  } catch (e) {
20020
20321
  const code = e.code;
20021
20322
  if (code === "ELOOP")
@@ -20038,7 +20339,7 @@ async function scanSymlinks(worktree) {
20038
20339
  continue;
20039
20340
  if (++seen > MAX_ENTRIES)
20040
20341
  throw new Error("the checkout has too many files to scan");
20041
- const full = join16(dir, e.name);
20342
+ const full = join17(dir, e.name);
20042
20343
  const rel = full.slice(root.length + 1);
20043
20344
  if (e.isSymbolicLink()) {
20044
20345
  const target = await readlink(full);
@@ -20089,7 +20390,7 @@ async function collectChanges(git, worktree) {
20089
20390
  const sizes = new Map;
20090
20391
  for (const rec of raw.stdout.split("\x00"))
20091
20392
  if (rec && !rec.startsWith(":"))
20092
- sizes.set(rec, (await lstat(join16(worktree, rec)).catch(() => {
20393
+ sizes.set(rec, (await lstat(join17(worktree, rec)).catch(() => {
20093
20394
  return;
20094
20395
  }))?.size ?? 0);
20095
20396
  return parseChanges(raw.stdout, num.stdout, (path) => sizes.get(path) ?? 0);
@@ -20292,21 +20593,22 @@ var FIX_USAGE = `keepsnappy fix <pr-number|branch> all present Wors
20292
20593
  a binary or more than ${DEFAULT_CAPS.files} files / ${DEFAULT_CAPS.lines} lines (fix.max_files, fix.max_lines), and commits one commit per fix, then checks the committed tree again.
20293
20594
  A workflow file, .gitattributes, .npmrc, a hook or the config of an AI tool is refused even with --allow-path; a build or install script is allowed and flagged in the PR.
20294
20595
  Then, with your own git and gh login, it pushes the branch and opens a draft PR into the PR's branch (never the default branch; a fork is refused) and records it.
20295
- --dry-run keeps the branch local: nothing is pushed, opened or recorded. --list prints the fixable findings, biggest felt cost first, and exits (no git, no agent).
20596
+ --dry-run keeps the branch local: nothing is pushed, opened or recorded. --list prints the fixable findings, biggest felt cost first, and exits
20597
+ (no agent, no git write: it reads git rev-parse and one gh pr view, so a finding the run would refuse shows as not fixable and why).
20296
20598
  --json prints one event per line, each with the run's fix_id (started, commit, skipped_finding, flagged, refused, ended with status, text, pr_url, proposal_id and notify); the steps are the
20297
20599
  KEEPSNAPPY-STEP lines on stderr. --fix-id ID names the run (fix-<ms>-<6 hex>); watch gives each run one. --branch B names the PR branch (the same as the positional).
20298
20600
  Budget: ${FIX_BUDGET.toolCalls + 1} turns, ${FIX_BUDGET.minutes} minutes (fix.max_turns, fix.max_minutes). Claude must be ${MIN_CLAUDE_VERSION} or newer. Codex is used only when its sandbox probe passes.
20299
20601
  (key: KEEPSNAPPY_KEY or --key-env NAME; API: --api URL or KEEPSNAPPY_API; gh must be logged in)`;
20300
20602
  var FIX_STEP_LABELS = ["reading the findings", "checking out", "editing", "checking the diff", "committing", "opening the draft PR"];
20301
20603
  var STEPS2 = FIX_STEP_LABELS.length;
20302
- var FIX_END_STATUSES = ["dry-run", "opened", "none", "refused", "main", "fork", "closed", "exists", "unsafe-checkout", "timeout", "failed", "not-json", "schema", "stopped", "push", "gh", "api"];
20604
+ var FIX_END_STATUSES = ["dry-run", "opened", "none", "refused", "main", "fork", "closed", "exists", "unsafe-checkout", "timeout", "failed", "not-json", "schema", "stopped", "push", "gh", "gh-auth", "no-pr", "no-repo", "no-findings", "api"];
20303
20605
  var FINDING_ID2 = /^f_[0-9a-f]{16}$/;
20304
20606
  var REF = /^[A-Za-z0-9_][A-Za-z0-9_./-]{0,254}$/;
20305
20607
  var REMOTE2 = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}$/;
20306
20608
  var MAX_TURNS_LIMIT2 = 200;
20307
20609
  var MAX_MINUTES_LIMIT2 = 120;
20308
20610
  var SIGINT_EXIT2 = 130;
20309
- var MAX_FINDINGS = 20;
20611
+ var MAX_FIX_FINDINGS = 20;
20310
20612
  var SECTION_KEYS = ["max_turns", "max_minutes", "max_files", "max_lines"];
20311
20613
  async function readFixSettings(explicit, env, cwd) {
20312
20614
  const layers = await readConfigLayers({ explicit, env, cwd });
@@ -20353,7 +20655,7 @@ async function readFixSettings(explicit, env, cwd) {
20353
20655
  var runTool = (cmd, args, o) => new Promise((done) => {
20354
20656
  execFile2(cmd, args, { cwd: o.cwd, env: o.env, timeout: o.timeoutMs ?? 60000, maxBuffer: 4 << 20 }, (err, stdout, stderr) => {
20355
20657
  const code = err ? typeof err.code === "number" ? err.code : null : 0;
20356
- done({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? err?.message ?? "") });
20658
+ done({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? err?.message ?? ""), timedOut: err?.killed === true });
20357
20659
  });
20358
20660
  });
20359
20661
  function parsePr(text) {
@@ -20370,6 +20672,28 @@ function parsePr(text) {
20370
20672
  return;
20371
20673
  return { headRefName, isCrossRepository, state, url, number };
20372
20674
  }
20675
+ var GH_AUTH_REQUIRED = 4;
20676
+ async function describePr(ref, o) {
20677
+ const view = await runTool("gh", ["pr", "view", ref, "--json", "headRefName,isCrossRepository,state,url,number"], o);
20678
+ const pr = view.code === 0 ? parsePr(view.stdout) : undefined;
20679
+ if (pr)
20680
+ return { pr };
20681
+ if (view.timedOut)
20682
+ return { status: "gh", text: `gh did not describe the pull request ${ref} in ${Math.round((o.timeoutMs ?? 60000) / 1000)} s` };
20683
+ const text = `gh could not describe the pull request ${ref}${view.stderr.trim() ? `: ${oneLine(view.stderr.trim().slice(0, 200))}` : ""}`;
20684
+ if (view.code === GH_AUTH_REQUIRED)
20685
+ return { status: "gh-auth", text };
20686
+ return { status: view.code !== 0 && /no pull requests found|could not resolve to a pullrequest/i.test(view.stderr) ? "no-pr" : "gh", text };
20687
+ }
20688
+ function prBlock(pr) {
20689
+ if (pr.isCrossRepository)
20690
+ return { status: "fork", text: `PR #${pr.number} comes from a fork: the fix branch could not be pushed to it` };
20691
+ if (pr.state !== "OPEN")
20692
+ return { status: "closed", text: `PR #${pr.number} is ${pr.state.toLowerCase()}` };
20693
+ return;
20694
+ }
20695
+ var LIST_WHY_NOT = { "gh-auth": FIX_WHY_NOT.ghAuth, "no-pr": FIX_WHY_NOT.noPr, gh: FIX_WHY_NOT.gh, fork: FIX_WHY_NOT.fork, closed: FIX_WHY_NOT.closed };
20696
+ var LIST_GH_TIMEOUT_MS = 1e4;
20373
20697
  function expectedText(e) {
20374
20698
  if (!isObj4(e) || typeof e.metric !== "string")
20375
20699
  return "";
@@ -20388,7 +20712,7 @@ var sanitizeComponent = (c, files) => {
20388
20712
  const clean = c.replace(/[^A-Za-z0-9_.$-]/g, "").slice(0, 40);
20389
20713
  if (clean)
20390
20714
  return clean;
20391
- const fromFile = basename2(files[0] ?? "").replace(/\.[^.]*$/, "").replace(/[^A-Za-z0-9_.$-]/g, "").slice(0, 40);
20715
+ const fromFile = basename3(files[0] ?? "").replace(/\.[^.]*$/, "").replace(/[^A-Za-z0-9_.$-]/g, "").slice(0, 40);
20392
20716
  return fromFile || "app";
20393
20717
  };
20394
20718
  var oneLineText = plainLine;
@@ -20543,42 +20867,54 @@ async function fixCommand(argv, rt) {
20543
20867
  list = await listFindings(target, { limit: 1 });
20544
20868
  app = list.app;
20545
20869
  }
20546
- let repoPath = o.repo;
20547
- const needsRepo = !o.list || sourceBranch === undefined;
20548
- if (needsRepo && !repoPath) {
20549
- repoPath = saved.repos?.[app];
20550
- if (!repoPath)
20551
- throw new ConfigError([`no repo for the app ${app}: give --repo PATH, or add investigate.repos.${app}: <path> to keepsnappy.yml`]);
20552
- }
20870
+ const listing = o.list === true && sourceBranch !== undefined;
20871
+ let blocked;
20872
+ const repoPath = o.repo ?? saved.repos?.[app];
20553
20873
  let root = "";
20554
20874
  let userGit;
20555
- if (repoPath) {
20556
- const repo = resolve9(cwd, expandHome(repoPath, home));
20557
- if (o.list)
20558
- root = repo;
20559
- else {
20560
- const top = await makeFixGit(["-C", repo], rt.env)(["rev-parse", "--show-toplevel"]);
20561
- if (top.code !== 0)
20562
- throw new ConfigError([`${repo} is not a git repository (--repo, or investigate.repos in keepsnappy.yml)`]);
20563
- root = top.stdout.trim();
20564
- userGit = makeFixGit(["-C", root], rt.env);
20565
- }
20875
+ const top = repoPath ? await makeFixGit(["-C", resolve9(cwd, expandHome(repoPath, home))], rt.env)(["rev-parse", "--show-toplevel"]) : undefined;
20876
+ if (!top || top.code !== 0) {
20877
+ const text = repoPath ? `${resolve9(cwd, expandHome(repoPath, home))} is not a git repository (--repo, or investigate.repos in keepsnappy.yml)` : `no repo for the app ${app}: give --repo PATH, or add investigate.repos.${app}: <path> to keepsnappy.yml`;
20878
+ if (!listing)
20879
+ return finishEarly("no-repo", [{ path: app, rule: "no-repo" }], text);
20880
+ blocked = fixNoRepoText(app);
20881
+ } else {
20882
+ root = top.stdout.trim();
20883
+ userGit = makeFixGit(["-C", root], rt.env);
20566
20884
  }
20567
20885
  let pr;
20568
- if (!o.list || sourceBranch === undefined) {
20569
- const view = await runTool("gh", ["pr", "view", String(sourcePr ?? sourceBranch), "--json", "headRefName,isCrossRepository,state,url,number"], { cwd: root, env: rt.env });
20570
- pr = view.code === 0 ? parsePr(view.stdout) : undefined;
20571
- if (!pr)
20572
- return finishEarly("gh", [{ path: String(sourcePr ?? sourceBranch), rule: "no-pull-request" }], `gh could not describe the pull request ${sourcePr ?? sourceBranch}${view.stderr.trim() ? `: ${oneLine(view.stderr.trim().slice(0, 200))}` : ""}`);
20573
- if (sourceBranch !== undefined && sourceBranch !== pr.headRefName)
20574
- return finishEarly("gh", [{ path: sourceBranch, rule: "not-the-pr-branch" }], `the pull request ${pr.number} is on ${pr.headRefName}, not ${sourceBranch}`);
20886
+ if (!blocked) {
20887
+ const ref = String(sourcePr ?? sourceBranch);
20888
+ const described = await describePr(ref, { cwd: root, env: rt.env, ...o.list ? { timeoutMs: rt.listGhTimeoutMs ?? LIST_GH_TIMEOUT_MS } : {} });
20889
+ if ("pr" in described)
20890
+ pr = described.pr;
20891
+ else if (listing)
20892
+ blocked = LIST_WHY_NOT[described.status];
20893
+ else
20894
+ return finishEarly(described.status, [{ path: ref, rule: "no-pull-request" }], described.text);
20895
+ }
20896
+ if (pr) {
20897
+ if (sourceBranch !== undefined && sourceBranch !== pr.headRefName) {
20898
+ if (!listing)
20899
+ return finishEarly("gh", [{ path: sourceBranch, rule: "not-the-pr-branch" }], `the pull request ${pr.number} is on ${pr.headRefName}, not ${sourceBranch}`);
20900
+ blocked = FIX_WHY_NOT.noPr;
20901
+ }
20575
20902
  if (sourceBranch === undefined) {
20576
20903
  sourceBranch = pr.headRefName;
20577
20904
  list = await listOf(sourceBranch);
20578
20905
  if (sourceBranch === list.baseline_branch || list.findings.some((f) => f.where.on === "main"))
20579
20906
  return finishEarly("main", [{ path: sourceBranch, rule: "main" }], `${sourceBranch} is the baseline branch: there is no branch off main to fix`);
20580
20907
  }
20581
- sourcePr = pr.number;
20908
+ const refused = prBlock(pr);
20909
+ if (o.list && !blocked && refused)
20910
+ blocked = LIST_WHY_NOT[refused.status];
20911
+ if (sourceBranch === pr.headRefName)
20912
+ sourcePr = pr.number;
20913
+ if (o.list && !blocked && userGit) {
20914
+ const left = `${FIX_BRANCH_PREFIX}${pr.number}`;
20915
+ if ((await userGit(["rev-parse", "--verify", "--quiet", `refs/heads/${left}`])).code === 0)
20916
+ blocked = fixEndText("exists", 0, { fixBranch: left, repo: root }) ?? undefined;
20917
+ }
20582
20918
  }
20583
20919
  const branch = sourceBranch;
20584
20920
  if (auto && branch.startsWith(FIX_BRANCH_PREFIX))
@@ -20597,10 +20933,14 @@ async function fixCommand(argv, rt) {
20597
20933
  }
20598
20934
  picks.sort((a, b) => b.feltMs - a.feltMs || (a.finding.id < b.finding.id ? -1 : 1));
20599
20935
  const hypothesesOfFinding = new Map(picks.map((p) => [p.finding.id, p.hypotheses.length]));
20600
- const dropped = picks.length > MAX_FINDINGS ? picks.splice(MAX_FINDINGS) : [];
20936
+ const dropped = picks.length > MAX_FIX_FINDINGS ? picks.splice(MAX_FIX_FINDINGS) : [];
20601
20937
  if (o.list) {
20602
- const shown = { version: 1, app: appName, pr: prNumber, branch, findings: list.findings.map((f) => fixableItem(f, laneOf2(list, f), hypothesesOfFinding.get(f.id) ?? 0)).sort(byFeltCost) };
20603
- rt.print(json ? JSON.stringify(shown) : formatFixable(shown));
20938
+ const item = (f) => {
20939
+ const one = fixableItem(f, laneOf2(list, f), hypothesesOfFinding.get(f.id) ?? 0);
20940
+ return blocked && one.fixable ? { ...one, fixable: false, why_not: blocked } : one;
20941
+ };
20942
+ const shown = { version: 1, app: appName, pr: prNumber, branch, findings: list.findings.map(item).sort(byFeltCost) };
20943
+ rt.print(json ? JSON.stringify({ ...shown, max_findings: MAX_FIX_FINDINGS }) : formatFixable(shown, { blocked }));
20604
20944
  return 0;
20605
20945
  }
20606
20946
  const findingIds = picks.map((p) => p.finding.id);
@@ -20609,18 +20949,17 @@ async function fixCommand(argv, rt) {
20609
20949
  for (const id of notFixable)
20610
20950
  emit("skipped_finding", { finding_id: id, reason: "not present on the branch now (resolved, not Worse, or not on this PR)" });
20611
20951
  log(picks.length === 0 ? `Nothing to fix: no Worse or Slightly worse finding is present on ${branch} now.` : `${notFixable.join(", ")}: not fixable now; nothing was started.`);
20612
- return finishDone("none", { commits: 0, fixBranch: "", text: "nothing to fix" }, notFixable.length ? 1 : 0);
20952
+ return finishDone("no-findings", { commits: 0, fixBranch: "", text: "nothing to fix" }, notFixable.length ? 1 : 0);
20613
20953
  }
20614
20954
  for (const d of dropped)
20615
- emit("skipped_finding", { finding_id: d.finding.id, reason: `more than ${MAX_FINDINGS} findings: the smaller ones were left out` });
20955
+ emit("skipped_finding", { finding_id: d.finding.id, reason: `more than ${MAX_FIX_FINDINGS} findings: the smaller ones were left out` });
20616
20956
  const git = userGit;
20617
20957
  const info = pr;
20618
- if (info.isCrossRepository)
20619
- return finishEarly("fork", [{ path: info.url, rule: "fork" }], `PR #${info.number} comes from a fork: the fix branch could not be pushed to it`);
20620
- if (info.state !== "OPEN")
20621
- return finishEarly("closed", [{ path: info.url, rule: "closed" }], `PR #${info.number} is ${info.state.toLowerCase()}`);
20958
+ const refused = prBlock(info);
20959
+ if (refused)
20960
+ return finishEarly(refused.status, [{ path: info.url, rule: refused.status }], refused.text);
20622
20961
  if (agent === "codex") {
20623
- const gate = await (rt.codexGate ?? ((g) => codexFixGate({ command: g.command, env: rt.env, tmpdir: rt.tmpdir ?? tmpdir2() })))({ command });
20962
+ const gate = await (rt.codexGate ?? ((g) => codexFixGate({ command: g.command, env: rt.env, tmpdir: rt.tmpdir ?? tmpdir3() })))({ command });
20624
20963
  if (!gate.ok) {
20625
20964
  log(["codex is not used for fix: its sandbox probe did not pass.", ...gate.problems.map((p) => ` ${p}`)].join(`
20626
20965
  `));
@@ -20628,7 +20967,7 @@ async function fixCommand(argv, rt) {
20628
20967
  }
20629
20968
  }
20630
20969
  if (agent === "claude") {
20631
- const seen = await checkClaudeVersion({ command, env: fixAgentEnv("claude", rt.env, { scratch: rt.tmpdir ?? tmpdir2(), extra: rt.agentEnvExtra ?? [] }) });
20970
+ const seen = await checkClaudeVersion({ command, env: fixAgentEnv("claude", rt.env, { scratch: rt.tmpdir ?? tmpdir3(), extra: rt.agentEnvExtra ?? [] }) });
20632
20971
  if (!seen.ok)
20633
20972
  return finishEarly("refused", [{ path: "claude", rule: "agent-version" }], seen.problem);
20634
20973
  }
@@ -20640,10 +20979,10 @@ async function fixCommand(argv, rt) {
20640
20979
  const taken = async (name) => {
20641
20980
  const local = await git(["rev-parse", "--verify", "--quiet", `refs/heads/${name}`]);
20642
20981
  if (local.code === 0)
20643
- return true;
20982
+ return "local";
20644
20983
  const far = await git(["ls-remote", "--exit-code", "--heads", remote, `refs/heads/${name}`]);
20645
20984
  if (far.code === 0)
20646
- return true;
20985
+ return "remote";
20647
20986
  return far.code === 2 ? false : undefined;
20648
20987
  };
20649
20988
  let fixBranch = `${FIX_BRANCH_PREFIX}${info.number}`;
@@ -20654,7 +20993,7 @@ async function fixCommand(argv, rt) {
20654
20993
  if (!t)
20655
20994
  break;
20656
20995
  if (!o.new || n > 20)
20657
- return finishEarly("exists", [{ path: fixBranch, rule: "exists" }], `${fixBranch} already exists${o.new ? " (and so do the next suffixes)" : "; --new takes a fresh suffix"}`);
20996
+ return finishEarly("exists", [{ path: fixBranch, rule: "exists" }], `${fixBranch} already exists${o.new ? " (and so do the next suffixes)" : "; --new takes a fresh suffix"}`, { fixBranch, ...t === "local" ? { repo: root } : {} });
20658
20997
  fixBranch = `${FIX_BRANCH_PREFIX}${info.number}-${n}`;
20659
20998
  }
20660
20999
  const abort = new AbortController;
@@ -20669,8 +21008,8 @@ async function fixCommand(argv, rt) {
20669
21008
  process.once("SIGINT", stopped);
20670
21009
  process.once("SIGTERM", stopped);
20671
21010
  step(2);
20672
- scratch = await mkdtemp3(join17(await realpath4(rt.tmpdir ?? tmpdir2()), "keepsnappy-fix-"));
20673
- worktree = join17(scratch, "code");
21011
+ scratch = await mkdtemp3(join18(await realpath4(rt.tmpdir ?? tmpdir3()), "keepsnappy-fix-"));
21012
+ worktree = join18(scratch, "code");
20674
21013
  const add = await git(["worktree", "add", "-b", fixBranch, worktree, baseSha]);
20675
21014
  if (add.code !== 0)
20676
21015
  throw new Error(`git worktree add failed: ${add.stderr.trim()}`);
@@ -20682,26 +21021,26 @@ async function fixCommand(argv, rt) {
20682
21021
  if (links.length) {
20683
21022
  return await refuse("unsafe-checkout", links, "the checkout has a symlink that points outside the worktree: nothing was started");
20684
21023
  }
20685
- await mkdir5(join17(scratch, "tmp"));
21024
+ await mkdir5(join18(scratch, "tmp"));
20686
21025
  const expected = new Set(["code", "tmp", "mcp.json", "last-message.txt"]);
20687
21026
  let mcpConfig = "";
20688
21027
  let mcpBefore = "";
20689
21028
  let args;
20690
21029
  let lastMessage;
20691
21030
  if (agent === "claude") {
20692
- mcpConfig = join17(scratch, "mcp.json");
21031
+ mcpConfig = join18(scratch, "mcp.json");
20693
21032
  const server = rt.mcpServer ?? mcpServerCommand();
20694
21033
  await writeFile5(mcpConfig, JSON.stringify({ mcpServers: { keepsnappy: { ...server, env: { KEEPSNAPPY_KEY: target.key, KEEPSNAPPY_API: target.url } } } }), { mode: 384 });
20695
21034
  await chmod2(mcpConfig, 384);
20696
21035
  mcpBefore = await readFile7(mcpConfig, "utf8");
20697
21036
  args = claudeFixArgs({ maxTurns, mcpConfig });
20698
21037
  } else {
20699
- lastMessage = join17(scratch, "last-message.txt");
21038
+ lastMessage = join18(scratch, "last-message.txt");
20700
21039
  args = codexFixArgs({ lastMessage, worktree: await realpath4(worktree), model: codexModel });
20701
21040
  }
20702
21041
  const packages = [];
20703
21042
  for (const p of picks)
20704
- packages.push({ id: p.finding.id, pkg: await getInvestigation(target, p.finding.id) });
21043
+ packages.push({ id: p.finding.id, pkg: await getInvestigation(target, p.finding.id, { branch }) });
20705
21044
  const prompt = fixPrompt({
20706
21045
  agent,
20707
21046
  budget: { toolCalls: maxTurns - 1, minutes: maxMinutes },
@@ -20709,7 +21048,7 @@ async function fixCommand(argv, rt) {
20709
21048
  });
20710
21049
  step(3);
20711
21050
  log(`Fixing with ${agent}: ${baseSha.slice(0, 7)} checked out as ${fixBranch} in a separate worktree (your working copy is not touched).`);
20712
- const env = fixAgentEnv(agent, rt.env, { scratch: join17(scratch, "tmp"), extra: rt.agentEnvExtra ?? [], accountDir });
21051
+ const env = fixAgentEnv(agent, rt.env, { scratch: join18(scratch, "tmp"), extra: rt.agentEnvExtra ?? [], accountDir });
20713
21052
  const startedAt = Date.now();
20714
21053
  const run = await runAgentProcess({ agent, cmd: command, args, cwd: worktree, env, stdin: prompt, timeoutMs: maxMinutes * 60000, killGraceMs: rt.killGraceMs ?? 5000, signal: abort.signal });
20715
21054
  const measuredMs = Date.now() - startedAt;
@@ -20743,7 +21082,7 @@ async function fixCommand(argv, rt) {
20743
21082
  return ended("failed", "error", "The agent printed more than it may and was stopped; nothing was committed.");
20744
21083
  step(4);
20745
21084
  const problems = [];
20746
- if (await readFile7(join17(worktree, ".git"), "utf8").catch(() => "") !== gitFile)
21085
+ if (await readFile7(join18(worktree, ".git"), "utf8").catch(() => "") !== gitFile)
20747
21086
  problems.push({ path: ".git", rule: "git-dir-changed" });
20748
21087
  for (const name of await readdir4(scratch))
20749
21088
  if (!expected.has(name))
@@ -20867,7 +21206,7 @@ async function fixCommand(argv, rt) {
20867
21206
  }
20868
21207
  const flagged = [];
20869
21208
  for (const c of committed) {
20870
- const rule = flagPath(c.path) ?? (basename2(c.path) === "package.json" && packageScriptsChanged(await showBlob(wt, baseSha, c.path, worktree), await showBlob(wt, headSha, c.path, worktree)) ? "package-scripts" : undefined);
21209
+ const rule = flagPath(c.path) ?? (basename3(c.path) === "package.json" && packageScriptsChanged(await showBlob(wt, baseSha, c.path, worktree), await showBlob(wt, headSha, c.path, worktree)) ? "package-scripts" : undefined);
20871
21210
  if (rule) {
20872
21211
  flagged.push({ path: c.path, rule });
20873
21212
  emit("flagged", { path: plainLine(c.path, 300), rule }, `Note: ${plainLine(c.path, 300)} is a build script (${rule}); the draft PR says so.`);
@@ -20984,19 +21323,19 @@ ${shown.map((p) => ` ${p.path}: ${p.rule}`).join(`
20984
21323
  `)}`);
20985
21324
  return finishDone(status, { commits: 0, fixBranch: "", text, ...usage ? { usage } : {} }, 1);
20986
21325
  }
20987
- function finishEarly(status, problems, text) {
21326
+ function finishEarly(status, problems, text, at = {}) {
20988
21327
  if (status === "main") {
20989
21328
  emit("skipped", { app: app ?? null, pr: sourcePr ?? null, branch: sourceBranch ?? problems[0]?.path ?? null, finding_id: ids[0] ?? null, reason: "main" }, text);
20990
21329
  } else
20991
21330
  emit("refused", { problems: plainProblems(problems) }, text);
20992
21331
  log(plainLine(text, 600));
20993
- return finishDone(status, { commits: 0, fixBranch: "", text }, 1);
21332
+ return finishDone(status, { commits: 0, fixBranch: at.fixBranch ?? "", text, ...at.repo ? { repo: at.repo } : {} }, 1);
20994
21333
  }
20995
21334
  function finishDone(status, d, code, errorText) {
20996
21335
  if (errorText)
20997
21336
  log(plainLine(errorText, 600));
20998
21337
  const detail = plainLine(d.text, 300);
20999
- emit("ended", { status, text: fixEndText(status, d.commits), detail, pr_url: d.prUrl ?? null, proposal_id: d.proposalId ?? null, fix_branch: d.fixBranch, commits: d.commits, usage: d.usage ?? null, notify: auto && FIX_NOTIFY_STATUSES.includes(status) }, d.text);
21338
+ emit("ended", { status, text: fixEndText(status, d.commits, { fixBranch: d.fixBranch, ...d.repo ? { repo: d.repo } : {} }), detail, repo: d.repo ?? null, pr_url: d.prUrl ?? null, proposal_id: d.proposalId ?? null, fix_branch: d.fixBranch, commits: d.commits, usage: d.usage ?? null, notify: auto && FIX_NOTIFY_STATUSES.includes(status) }, d.text);
21000
21339
  end(status, d.commits || undefined);
21001
21340
  return code;
21002
21341
  }
@@ -21023,8 +21362,10 @@ var WATCH_USAGE = `keepsnappy watch [--app NAME ...] [--branch GLOB] [--mine] [-
21023
21362
  --once poll once and exit
21024
21363
  --json for the macOS menu bar app: no notifications are shown; one JSON object per line on stdout (hello, connected, key_rejected, history, poll, event, burst,
21025
21364
  investigation, fix, log; a poll line says when a poll worked, or why it did not, and how long until the next), {"cmd":"investigate","finding_id":"f_…"} lines on stdin start an
21026
- investigation like a click, {"cmd":"refresh"} polls now (at most one extra poll per 5 s), and {"cmd":"fix","finding_ids":["f_…"]}, {"cmd":"fix","branch":"feat/x"} or {"cmd":"fix","pr":N} runs
21027
- \`keepsnappy fix\` and streams its lines (started, progress, commit, skipped_finding, flagged, refused, ended); the end of stdin stops watching (docs/plans/82a)
21365
+ investigation like a click (with "branch":"<PR branch>": that PR's result, as investigate --branch), {"cmd":"refresh"} polls now (at most one extra poll per 5 s), and
21366
+ {"cmd":"fix","finding_ids":["f_…"]}, {"cmd":"fix","branch":"feat/x"} or {"cmd":"fix","pr":N} runs \`keepsnappy fix\` and streams its lines (started, progress, commit,
21367
+ skipped_finding, flagged, refused, ended); the end of stdin stops watching (docs/plans/82a); an investigation still running from a watcher before this one (same app,
21368
+ since the last boot) is announced again at the start ("resumed":true on its started line) and followed to its end
21028
21369
  --agent, --claude-path, --codex-path, --claude-config-dir, --codex-home which agent an [Investigate] click runs, where its executable is and which account folder it runs on: handed to \`keepsnappy investigate\`, which reads
21029
21370
  the same keys (investigate.agent, investigate.claudePath, investigate.codexPath, investigate.claudeConfigDir, investigate.codexHome) from keepsnappy.yml and from your own config file
21030
21371
  (keepsnappy config set investigate.claudePath ~/.local/bin/claude); --auto-on-worse runs claude whatever the agent is
@@ -21064,6 +21405,23 @@ var isStep = (m) => {
21064
21405
  };
21065
21406
  var FIX_STEP_LINE = /^KEEPSNAPPY-STEP (\d{1,2})\/(\d{1,2}) ([^\r\n]{1,60})$/;
21066
21407
  var PROGRESS_POLL_MS = 1000;
21408
+ var PID_PREFIX = "KEEPSNAPPY-PID";
21409
+ var PID_LINE = /^KEEPSNAPPY-PID (\d+)$/m;
21410
+ var APP_PREFIX = "KEEPSNAPPY-APP";
21411
+ var APP_LINE = /^KEEPSNAPPY-APP (\S+)$/m;
21412
+ var BRANCH_PREFIX = "KEEPSNAPPY-BRANCH";
21413
+ var BRANCH_LINE = /^KEEPSNAPPY-BRANCH ([^\r\n]+)$/m;
21414
+ var INVESTIGATE_LOG = /^investigate-(f_[0-9a-f]{16})(?:-([0-9a-f]{8}))?-(\d+)\.log$/;
21415
+ var branchHash = (branch) => createHash2("sha256").update(branch).digest("hex").slice(0, 8);
21416
+ var RESUME_WINDOW_MS = (MAX_MINUTES_LIMIT + 10) * 60000;
21417
+ var processAlive = (pid) => {
21418
+ try {
21419
+ process.kill(pid, 0);
21420
+ return true;
21421
+ } catch {
21422
+ return false;
21423
+ }
21424
+ };
21067
21425
  var REFRESH_MIN_GAP_MS = 5000;
21068
21426
  var SHOWN_KEPT = 200;
21069
21427
  var AUTO_FIXED_KEPT = 200;
@@ -21245,7 +21603,7 @@ function selfCommand(env, proc = process, real = realpathSync) {
21245
21603
  }
21246
21604
  }
21247
21605
  function fileStore(dir, apiUrl) {
21248
- const file = join18(dir, `watch-${createHash2("sha256").update(apiUrl.replace(/\/+$/, "")).digest("hex").slice(0, 12)}.json`);
21606
+ const file = join19(dir, `watch-${createHash2("sha256").update(apiUrl.replace(/\/+$/, "")).digest("hex").slice(0, 12)}.json`);
21249
21607
  return {
21250
21608
  async load() {
21251
21609
  try {
@@ -21273,13 +21631,14 @@ function fileStore(dir, apiUrl) {
21273
21631
  await writeFile6(tmp, JSON.stringify({ cursor: s.cursor, seen: s.seen.slice(-SEEN_KEPT), ...s.savedAt !== undefined ? { saved_at: s.savedAt } : {}, ...s.shown ? { shown: Object.fromEntries(Object.entries(s.shown).slice(-SHOWN_KEPT)) } : {}, ...s.autoFixed ? { auto_fixed: s.autoFixed.slice(-AUTO_FIXED_KEPT) } : {} }), { mode: 384 });
21274
21632
  await rename2(tmp, file);
21275
21633
  },
21276
- logPath: (name) => join18(dir, `${name}.log`),
21277
- readLog: (p) => readFile8(p, "utf8").catch(() => "")
21634
+ logPath: (name) => join19(dir, `${name}.log`),
21635
+ readLog: (p) => readFile8(p, "utf8").catch(() => ""),
21636
+ investigationLogs: async () => (await readdir5(dir).catch(() => [])).filter((n) => INVESTIGATE_LOG.test(n)).map((n) => join19(dir, n))
21278
21637
  };
21279
21638
  }
21280
- var stateDir = (env) => env.KEEPSNAPPY_STATE_DIR?.trim() || join18(env.XDG_STATE_HOME?.trim() || join18(env.HOME ?? homedir2(), ".local", "state"), "keepsnappy");
21639
+ var stateDir = (env) => env.KEEPSNAPPY_STATE_DIR?.trim() || join19(env.XDG_STATE_HOME?.trim() || join19(env.HOME ?? homedir2(), ".local", "state"), "keepsnappy");
21281
21640
  var nodeBackground = {
21282
- start(cmd, args, logFile, env) {
21641
+ start(cmd, args, logFile, env, header = []) {
21283
21642
  const exits = [];
21284
21643
  let done = null;
21285
21644
  const finish = (code, error) => {
@@ -21293,6 +21652,9 @@ var nodeBackground = {
21293
21652
  try {
21294
21653
  fd = openSync(logFile, "a", 384);
21295
21654
  const child = spawn6(cmd, [...args], { detached: true, stdio: ["ignore", fd, fd], env: { ...process.env, ...env } });
21655
+ if (child.pid !== undefined)
21656
+ writeSync(fd, [`${PID_PREFIX} ${child.pid}`, ...header.map((l) => l.replace(/[\r\n]+/g, " "))].map((l) => `${l}
21657
+ `).join(""));
21296
21658
  child.on("error", (e) => finish(null, e.message));
21297
21659
  child.on("exit", (code) => finish(code));
21298
21660
  child.unref();
@@ -21305,7 +21667,8 @@ var nodeBackground = {
21305
21667
  return { onExit: (cb) => done ? cb(done.code, done.error) : void exits.push(cb) };
21306
21668
  }
21307
21669
  };
21308
- var MAX_FIX_FINDINGS = 20;
21670
+ var CLIENT_ID = /^[A-Za-z0-9_-]{1,64}$/;
21671
+ var MAX_ECHOED_IDS = 200;
21309
21672
  var GITHUB_PR_URL = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+$/;
21310
21673
  var BRANCH_REF = /^[A-Za-z0-9_][A-Za-z0-9_./-]{0,254}$/;
21311
21674
  var CONTROL4 = /[\u0000-\u001f\u007f]/g;
@@ -21372,7 +21735,8 @@ function relayedFixLine(line, run) {
21372
21735
  const used = v.usage === null || v.usage === undefined ? null : readUsageLine(`${USAGE_PREFIX} ${JSON.stringify(v.usage)}`);
21373
21736
  if (!status || prUrl === undefined || proposalId === undefined || branch === null || commits === null)
21374
21737
  return null;
21375
- return endedLine(run, { status, commits, prUrl, proposalId, fixBranch: branch, usage: used ? usageJson(used) : null });
21738
+ const repo = typeof v.repo === "string" && v.repo.startsWith("/") && !v.repo.split("/").includes("..") ? plainText(v.repo, 1000) ?? undefined : undefined;
21739
+ return endedLine(run, { status, commits, prUrl, proposalId, fixBranch: branch, usage: used ? usageJson(used) : null, ...repo && branch ? { repo } : {} });
21376
21740
  }
21377
21741
  default:
21378
21742
  return null;
@@ -21384,7 +21748,7 @@ function endedLine(run, e) {
21384
21748
  fix_id: run.fixId,
21385
21749
  state: "ended",
21386
21750
  status: e.status,
21387
- text: fixEndText(e.status, e.commits),
21751
+ text: fixEndText(e.status, e.commits, { fixBranch: e.fixBranch, ...e.repo ? { repo: e.repo } : {} }),
21388
21752
  pr_url: e.status === "dry-run" ? null : e.prUrl,
21389
21753
  proposal_id: e.proposalId,
21390
21754
  fix_branch: e.fixBranch,
@@ -21393,7 +21757,8 @@ function endedLine(run, e) {
21393
21757
  notify: run.auto && FIX_NOTIFY_STATUSES.includes(e.status)
21394
21758
  };
21395
21759
  }
21396
- var jobOf = (ev, findingId, title) => ({ findingId, app: ev.app, branch: ev.branch, url: ev.url, title });
21760
+ var jobOf = (ev, findingId, title) => ({ findingId, app: ev.app, branch: ev.branch, url: ev.url, title, about: ev.on === "main" ? "main" : ev.branch });
21761
+ var jobKey = (j) => `${j.findingId}\x00${j.about ?? ""}`;
21397
21762
  async function readEventPages(events, each, o = {}) {
21398
21763
  let next = null;
21399
21764
  let more = true;
@@ -21477,6 +21842,7 @@ async function runWatch(s, d, outer) {
21477
21842
  const appNameOf = (ev) => ev.app === pageApp ? pageAppName : null;
21478
21843
  const titleOf = (ev) => notificationTitle({ app: appNameOf(ev) ?? ev.app, branch: ev.branch });
21479
21844
  const investigating = (app) => [...running.values()].some((j) => j.app === app);
21845
+ const busy = (job) => [...running.values(), ...queue].some((q) => q.findingId === job.findingId && (q.about === null || job.about === null || q.about === job.about));
21480
21846
  const fixRunning = new Map;
21481
21847
  const fixQueue = [];
21482
21848
  const autoFixed = new Set;
@@ -21505,16 +21871,23 @@ async function runWatch(s, d, outer) {
21505
21871
  await d.open(ev.url).catch((e) => log(`keepsnappy watch: cannot open ${ev.url}: ${e.message}`));
21506
21872
  }, (e) => log(`keepsnappy watch: the notification failed: ${e.message}`));
21507
21873
  };
21508
- const later = (job, o) => track(d.notifier.notify({ title: job.title, body: o.text, url: job.url, findingId: null, about: { kind: "ended", findingId: o.findingId, status: o.status, count: o.count, text: o.text, ...o.usage ? { usage: usageJson(o.usage) } : {} } }).then(() => {
21874
+ const later = (job, o) => track(d.notifier.notify({ title: job.title, body: o.text, url: job.url, findingId: null, about: { kind: "ended", findingId: o.findingId, branch: job.about, status: o.status, count: o.count, text: o.text, ...o.usage ? { usage: usageJson(o.usage) } : {} } }).then(() => {
21509
21875
  return;
21510
21876
  }, (e) => log(`keepsnappy watch: the notification failed: ${e.message}`)));
21511
21877
  function start(job, auto) {
21512
- if (running.has(job.findingId) || queue.some((q) => q.findingId === job.findingId))
21878
+ if (busy(job))
21513
21879
  return;
21514
- const logFile = d.store.logPath(`investigate-${job.findingId}-${d.now()}`);
21515
- running.set(job.findingId, job);
21880
+ const on = job.onBranch ? `-${branchHash(job.onBranch)}` : "";
21881
+ const logFile = d.store.logPath(`investigate-${job.findingId}${on}-${d.now()}`);
21516
21882
  log(`keepsnappy watch: investigating ${job.findingId} (${job.app} · ${job.branch})${auto ? " [auto]" : ""}`);
21517
- d.json?.emit({ type: "investigation", finding_id: job.findingId, state: "started", auto, log: logFile });
21883
+ const header = [`${APP_PREFIX} ${job.app}`, ...job.about ? [`${BRANCH_PREFIX} ${job.about}`] : []];
21884
+ const args = [...d.binArgs ?? [], "investigate", ...d.forward, ...auto ? ["--agent", "claude"] : [], ...job.onBranch ? [`--branch=${job.onBranch}`] : [], job.findingId];
21885
+ follow(job, logFile, { auto, resumed: false }, (exited) => d.background.start(d.bin, args, logFile, undefined, header).onExit(exited));
21886
+ }
21887
+ function follow(job, logFile, o, run) {
21888
+ const branch = job.about;
21889
+ running.set(jobKey(job), job);
21890
+ d.json?.emit({ type: "investigation", finding_id: job.findingId, branch, state: "started", auto: o.auto, log: logFile, ...o.resumed ? { resumed: true } : {} });
21518
21891
  let steps = 0;
21519
21892
  let over = false;
21520
21893
  const drain = async () => {
@@ -21522,7 +21895,7 @@ async function runWatch(s, d, outer) {
21522
21895
  return;
21523
21896
  const found = [...(await d.store.readLog(logFile)).matchAll(STEP_LINE)].filter(isStep);
21524
21897
  for (const m of found.slice(steps))
21525
- d.json.emit({ type: "investigation", finding_id: job.findingId, state: "progress", step: Number(m[1]), of: Number(m[2]), label: m[3] });
21898
+ d.json.emit({ type: "investigation", finding_id: job.findingId, branch, state: "progress", step: Number(m[1]), of: Number(m[2]), label: m[3] });
21526
21899
  steps = Math.max(steps, found.length);
21527
21900
  };
21528
21901
  if (d.json) {
@@ -21537,10 +21910,10 @@ async function runWatch(s, d, outer) {
21537
21910
  }
21538
21911
  })();
21539
21912
  }
21540
- d.background.start(d.bin, [...d.binArgs ?? [], "investigate", ...d.forward, ...auto ? ["--agent", "claude"] : [], job.findingId], logFile).onExit((code, error) => {
21913
+ run((code, error) => {
21541
21914
  track((async () => {
21542
21915
  over = true;
21543
- running.delete(job.findingId);
21916
+ running.delete(jobKey(job));
21544
21917
  await drain().catch(() => {
21545
21918
  return;
21546
21919
  });
@@ -21555,6 +21928,62 @@ async function runWatch(s, d, outer) {
21555
21928
  })());
21556
21929
  });
21557
21930
  }
21931
+ async function resume(watches) {
21932
+ if (!d.json || !d.store.investigationLogs)
21933
+ return;
21934
+ const json = d.json;
21935
+ const alive = d.alive ?? processAlive;
21936
+ const bootedAt = d.bootedAt?.() ?? Date.now() - uptime() * 1000;
21937
+ const newest = new Map;
21938
+ for (const logFile of await d.store.investigationLogs()) {
21939
+ const m = INVESTIGATE_LOG.exec(basename4(logFile));
21940
+ if (!m)
21941
+ continue;
21942
+ const at = Number(m[3]);
21943
+ if (at < bootedAt || d.now() - at > RESUME_WINDOW_MS)
21944
+ continue;
21945
+ const key = `${m[1]}-${m[2] ?? ""}`;
21946
+ const seen = newest.get(key);
21947
+ if (!seen || seen.at < at)
21948
+ newest.set(key, { findingId: m[1], hash: m[2], logFile, at });
21949
+ }
21950
+ for (const { findingId, hash, logFile, at } of newest.values()) {
21951
+ const text = await d.store.readLog(logFile);
21952
+ const pid = Number(PID_LINE.exec(text)?.[1] ?? 0);
21953
+ const app = APP_LINE.exec(text)?.[1];
21954
+ if (!pid || !app || !watches(app) || [...text.matchAll(STATUS_LINE)].length > 0 || !alive(pid))
21955
+ continue;
21956
+ const about = BRANCH_LINE.exec(text)?.[1]?.trim() || null;
21957
+ if (hash !== undefined && (about === null || branchHash(about) !== hash))
21958
+ continue;
21959
+ const known = shown.get(findingId);
21960
+ const branch = about ?? known?.branch ?? "";
21961
+ const job = {
21962
+ findingId,
21963
+ app,
21964
+ branch,
21965
+ url: known?.url ?? null,
21966
+ title: known?.title ?? notificationTitle({ app: appNameOf({ app }) ?? app, branch }),
21967
+ about,
21968
+ ...hash !== undefined && about !== null ? { onBranch: about } : {}
21969
+ };
21970
+ if (busy(job))
21971
+ continue;
21972
+ follow(job, logFile, { auto: false, resumed: true }, (exited) => void (async () => {
21973
+ while (!signal.aborted) {
21974
+ await json.wait(PROGRESS_POLL_MS, signal);
21975
+ if (signal.aborted)
21976
+ return;
21977
+ if (!alive(pid) || d.now() > at + RESUME_WINDOW_MS)
21978
+ return exited(null);
21979
+ }
21980
+ })());
21981
+ }
21982
+ }
21983
+ const resumeFor = (watches) => {
21984
+ if (!s.once)
21985
+ track(resume(watches).catch((e) => log(`keepsnappy watch: running investigations could not be read: ${e.message}`)));
21986
+ };
21558
21987
  let wakeNow = null;
21559
21988
  let lastRefreshAt = -Infinity;
21560
21989
  let refreshDue = null;
@@ -21587,49 +22016,65 @@ async function runWatch(s, d, outer) {
21587
22016
  const id = c.finding_id;
21588
22017
  if (typeof id !== "string" || !FINDING_ID.test(id))
21589
22018
  return log("keepsnappy watch: investigate needs a finding_id (f_ and 16 hex characters); nothing started");
21590
- let known = shown.get(id);
21591
- if (!known) {
21592
- let found;
21593
- try {
21594
- found = await d.api.finding(id);
21595
- } catch (e) {
21596
- return log(`keepsnappy watch: cannot look up ${id}: ${e.message}; nothing started`);
21597
- }
21598
- if (!found)
21599
- return log(`keepsnappy watch: ${id} is not a finding the API knows; nothing started`);
21600
- const app = pageApp ?? s.apps[0];
21601
- if (!app)
21602
- return log(`keepsnappy watch: ${id} is not a result shown by this watcher and the app it belongs to is not known yet; nothing started`);
21603
- known = { app, branch: found.branch, url: found.url, title: notificationTitle({ app: appNameOf({ app }) ?? app, branch: found.branch }) };
22019
+ const onBranch = c.branch;
22020
+ if (onBranch !== undefined && (typeof onBranch !== "string" || onBranch.trim() === ""))
22021
+ return log(`keepsnappy watch: investigate needs a branch name in branch, or no branch; ${id} not started`);
22022
+ const job = await jobOfFinding(id);
22023
+ if (typeof job === "string")
22024
+ return log(job);
22025
+ start(onBranch ? { ...job, branch: onBranch, onBranch, about: onBranch } : job, false);
22026
+ }
22027
+ async function jobOfFinding(id) {
22028
+ const known = shown.get(id);
22029
+ if (known)
22030
+ return { findingId: id, ...known, about: null };
22031
+ let found;
22032
+ try {
22033
+ found = await d.api.finding(id);
22034
+ } catch (e) {
22035
+ return `keepsnappy watch: cannot look up ${id}: ${e.message}; nothing started`;
21604
22036
  }
21605
- start({ findingId: id, ...known }, false);
22037
+ if (!found)
22038
+ return `keepsnappy watch: ${id} is not a finding the API knows; nothing started`;
22039
+ const app = pageApp ?? s.apps[0];
22040
+ if (!app)
22041
+ return `keepsnappy watch: ${id} is not a result shown by this watcher and the app it belongs to is not known yet; nothing started`;
22042
+ return { findingId: id, app, branch: found.branch, url: found.url, title: notificationTitle({ app: appNameOf({ app }) ?? app, branch: found.branch }), about: null };
21606
22043
  }
21607
22044
  function fixClick(c) {
21608
- const none = (why) => log(`keepsnappy watch: fix ${why}; nothing started`);
21609
- if ([c.pr, c.branch, c.finding_ids].filter((x) => x !== undefined).length !== 1)
21610
- return none("needs exactly one of pr (a whole number), branch or finding_ids");
22045
+ const clientId = typeof c.client_id === "string" && CLIENT_ID.test(c.client_id) ? c.client_id : null;
22046
+ const sentIds = Array.isArray(c.finding_ids) && c.finding_ids.length <= MAX_ECHOED_IDS && c.finding_ids.every((x) => typeof x === "string" && FINDING_ID.test(x)) ? c.finding_ids : null;
21611
22047
  const appOf = (id) => (id ? shown.get(id)?.app : undefined) ?? pageApp ?? s.apps[0] ?? null;
22048
+ const asked = { client_id: clientId, app: appOf(sentIds?.[0]), pr: wholeNumber(c.pr, 1, 2000000000), branch: typeof c.branch === "string" && BRANCH_REF.test(c.branch) && !c.branch.includes("..") ? c.branch : null, finding_ids: sentIds };
22049
+ const none = (reason, why) => {
22050
+ log(`keepsnappy watch: fix ${why}; nothing started`);
22051
+ d.json?.emit({ type: "fix", fix_id: newFixId(`rejected|${clientId}|${reason}`), state: "rejected", ...asked, reason, text: fixRejectText(reason, { count: sentIds?.length ?? 0, max: MAX_FIX_FINDINGS }) });
22052
+ };
22053
+ if ([c.pr, c.branch, c.finding_ids].filter((x) => x !== undefined).length !== 1)
22054
+ return none("bad-command", "needs exactly one of pr (a whole number), branch or finding_ids");
21612
22055
  const make = (o) => ({ ...o, fixId: newFixId(o.key), auto: false });
22056
+ const duplicate = () => none("duplicate", "is already running or queued for these findings");
21613
22057
  if (c.pr !== undefined || c.branch !== undefined) {
21614
- const pr = c.pr !== undefined ? wholeNumber(c.pr, 1, 2000000000) : null;
21615
- const branch = c.branch !== undefined ? typeof c.branch === "string" && BRANCH_REF.test(c.branch) && !c.branch.includes("..") ? c.branch : null : null;
22058
+ const { pr, branch } = asked;
21616
22059
  if (c.pr !== undefined && pr === null)
21617
- return none("needs a pr that is a whole number of 1 or more");
22060
+ return none("bad-command", "needs a pr that is a whole number of 1 or more");
21618
22061
  if (c.branch !== undefined && branch === null)
21619
- return none("needs a branch name (letters, digits and ._/-, not starting with a dash)");
22062
+ return none("bad-command", "needs a branch name (letters, digits and ._/-, not starting with a dash)");
21620
22063
  const owner = appOf();
21621
22064
  if (!owner)
21622
- return none("does not know the app yet");
21623
- return requestFix(make({ key: pr !== null ? `pr:${pr}` : `branch:${branch}`, app: owner, branch, pr, findingId: null, target: [pr !== null ? String(pr) : branch] }));
22065
+ return none("unknown-app", "does not know the app yet");
22066
+ return requestFix(make({ key: pr !== null ? `pr:${pr}` : `branch:${branch}`, app: owner, branch, pr, findingId: null, target: [pr !== null ? String(pr) : branch] }), duplicate);
21624
22067
  }
22068
+ if (sentIds && sentIds.length > MAX_FIX_FINDINGS)
22069
+ return none("too-many", `takes at most ${MAX_FIX_FINDINGS} finding ids, and got ${sentIds.length}`);
21625
22070
  const ids = findingIdList(c.finding_ids);
21626
22071
  if (!ids)
21627
- return none(`needs 1 to ${MAX_FIX_FINDINGS} finding ids (f_ and 16 hex characters)`);
22072
+ return none("bad-command", `needs 1 to ${MAX_FIX_FINDINGS} finding ids (f_ and 16 hex characters)`);
21628
22073
  const unique = [...new Set(ids)];
21629
22074
  const owner = appOf(unique[0]);
21630
22075
  if (!owner)
21631
- return none("does not know the app yet");
21632
- requestFix(make({ key: `ids:${[...unique].sort().join(",")}`, app: owner, branch: shown.get(unique[0])?.branch ?? null, pr: null, findingId: unique[0], target: unique.flatMap((id) => ["--finding", id]) }));
22076
+ return none("unknown-app", "does not know the app yet");
22077
+ requestFix(make({ key: `ids:${[...unique].sort().join(",")}`, app: owner, branch: shown.get(unique[0])?.branch ?? null, pr: null, findingId: unique[0], target: unique.flatMap((id) => ["--finding", id]) }), duplicate);
21633
22078
  }
21634
22079
  let fixSeq = 0;
21635
22080
  const newFixId = (tag) => `fix-${d.now()}-${createHash2("sha256").update(`${tag}|${d.now()}|${fixSeq++}`).digest("hex").slice(0, 6)}`;
@@ -21637,9 +22082,9 @@ async function runWatch(s, d, outer) {
21637
22082
  log(`keepsnappy watch: fix of ${j.pr !== null ? `PR #${j.pr}` : j.branch ?? j.findingId ?? "the findings"} (${j.app}) skipped: ${reason}`);
21638
22083
  d.json?.emit({ type: "fix", fix_id: j.fixId ?? newFixId(`skip|${j.app}|${j.pr ?? j.branch}|${reason}`), state: "skipped", app: j.app, pr: j.pr, branch: j.branch, finding_id: j.findingId, reason });
21639
22084
  };
21640
- function requestFix(job) {
22085
+ function requestFix(job, onDuplicate = () => {}) {
21641
22086
  if ([...fixRunning.values()].some((j) => j.key === job.key) || fixQueue.some((q) => q.key === job.key))
21642
- return;
22087
+ return onDuplicate();
21643
22088
  if (fixRunning.has(job.app)) {
21644
22089
  fixQueue.push(job);
21645
22090
  return skipLine(job, "busy");
@@ -21780,7 +22225,16 @@ async function runWatch(s, d, outer) {
21780
22225
  }
21781
22226
  if (!found || found.state !== "worse" || found.on !== "pr" && found.on !== "main")
21782
22227
  return;
21783
- await autoFix({ app: job.app, branch: found.branch, pr: found.pr ?? null, on: found.on, findingId: job.findingId, sha: found.sha ?? null, open: o.status === "stored" && o.count !== null ? o.count : null });
22228
+ const other = job.onBranch !== undefined && job.onBranch !== found.branch;
22229
+ await autoFix({
22230
+ app: job.app,
22231
+ branch: other ? job.onBranch : found.branch,
22232
+ pr: other ? null : found.pr ?? null,
22233
+ on: other ? "pr" : found.on,
22234
+ findingId: job.findingId,
22235
+ sha: other ? null : found.sha ?? null,
22236
+ open: o.status === "stored" && o.count !== null ? o.count : null
22237
+ });
21784
22238
  }
21785
22239
  async function outcome(findingId, logFile, code, error, branch) {
21786
22240
  if (error)
@@ -21805,7 +22259,7 @@ async function runWatch(s, d, outer) {
21805
22259
  return [WATCH_WORDS.autoNotWithCodex];
21806
22260
  }
21807
22261
  const job = jobOf(ev, ev.finding_id, titleOf(ev));
21808
- if (running.has(job.findingId) || queue.some((q) => q.findingId === job.findingId))
22262
+ if (busy(job))
21809
22263
  return [];
21810
22264
  if (investigating(ev.app)) {
21811
22265
  queue.push(job);
@@ -21866,6 +22320,8 @@ async function runWatch(s, d, outer) {
21866
22320
  if (connected)
21867
22321
  return;
21868
22322
  connected = true;
22323
+ if (s.apps.length === 0)
22324
+ resumeFor((app) => app === page.app);
21869
22325
  d.json?.emit({ type: "connected", app: page.app, app_name: page.app_name ?? null, org: page.org_name ?? null });
21870
22326
  }
21871
22327
  async function fromNow() {
@@ -21911,6 +22367,8 @@ async function runWatch(s, d, outer) {
21911
22367
  seen.add(id);
21912
22368
  for (const [id, f] of Object.entries(prior?.shown ?? {}))
21913
22369
  shown.set(id, f);
22370
+ if (s.apps.length > 0)
22371
+ resumeFor((app) => s.apps.includes(app));
21914
22372
  for (const head of prior?.autoFixed ?? [])
21915
22373
  autoFixed.add(head);
21916
22374
  const header = () => log(`keepsnappy watch: ${s.apps.length ? `apps ${s.apps.join(", ")}` : "every app"}${s.branch ? `, branches ${s.branch}` : ""}${s.mine ? ", my PRs + main" : ", all PRs + main"}, every ${s.intervalSeconds} s, notifications by ${d.notifier.name}${s.autoOnWorse ? ", auto-investigating Worse" : ""}${s.autoFix ? ", auto-fixing PRs" : ""}`);
@@ -22503,7 +22961,7 @@ class FileRiSource {
22503
22961
  }
22504
22962
 
22505
22963
  // apps/agent/src/aa.ts
22506
- import { join as join19 } from "node:path";
22964
+ import { join as join20 } from "node:path";
22507
22965
  var g = (x, d = 4) => x === undefined || !Number.isFinite(x) ? "-" : Number(x.toPrecision(d)).toString();
22508
22966
  var pad = (rows) => {
22509
22967
  const w = rows[0]?.map((_, i) => Math.max(...rows.map((r) => (r[i] ?? "").length))) ?? [];
@@ -22511,7 +22969,7 @@ var pad = (rows) => {
22511
22969
  `);
22512
22970
  };
22513
22971
  async function load(deps, outDir) {
22514
- return readRunDataset(await deps.fs.readFile(join19(outDir, "metrics.csv")));
22972
+ return readRunDataset(await deps.fs.readFile(join20(outDir, "metrics.csv")));
22515
22973
  }
22516
22974
  async function aa(deps, outDir, opts = {}) {
22517
22975
  const dataset = await load(deps, outDir);
@@ -22524,14 +22982,14 @@ async function aa(deps, outDir, opts = {}) {
22524
22982
  rows.push([r.metric, String(r.pool.n), g(r.pool.median), g(r.pool.robustCv, 3), r.rows.map((x) => `${x.n}:${x.mde === null ? ">max" : g(x.mde, 2)}`).join(" "), r.suggestedMargin.reason]);
22525
22983
  }
22526
22984
  const table = pad(rows);
22527
- await deps.fs.writeFile(join19(outDir, "aa.json"), `${JSON.stringify({ kind: "aa", results }, null, 2)}
22985
+ await deps.fs.writeFile(join20(outDir, "aa.json"), `${JSON.stringify({ kind: "aa", results }, null, 2)}
22528
22986
  `);
22529
22987
  return { results, table };
22530
22988
  }
22531
22989
 
22532
22990
  // apps/agent/src/browserstack.ts
22533
22991
  import { randomUUID } from "node:crypto";
22534
- import { basename as basename3, join as join20 } from "node:path";
22992
+ import { basename as basename5, join as join21 } from "node:path";
22535
22993
  import { readFile as readFile9 } from "node:fs/promises";
22536
22994
  var BASE2 = "https://api-cloud.browserstack.com/app-automate/maestro/v2";
22537
22995
  var TERMINAL = new Set(["passed", "failed", "error", "timedout", "skipped", "stopped"]);
@@ -22662,22 +23120,22 @@ class BrowserStack {
22662
23120
  if (app.startsWith("bs://") || app.startsWith("custom_id:"))
22663
23121
  return app;
22664
23122
  const form = new FormData;
22665
- form.append("file", new Blob([await this.bytes(app)]), basename3(app));
23123
+ form.append("file", new Blob([await this.bytes(app)]), basename5(app));
22666
23124
  if (customId)
22667
23125
  form.append("custom_id", customId);
22668
23126
  const r = await this.call("/app", { method: "POST", form });
22669
23127
  if (typeof r.app_url !== "string")
22670
- throw new FarmApiError(`app upload of ${basename3(app)} returned no app_url`);
23128
+ throw new FarmApiError(`app upload of ${basename5(app)} returned no app_url`);
22671
23129
  return r.app_url;
22672
23130
  }
22673
23131
  async runMaestro(b) {
22674
23132
  const appUrl = await this.uploadApp(b.app, b.customId);
22675
- const work = join20(this.o.tmpDir ?? "/tmp", `keepsnappy-suite-${(this.o.uniqueId ?? randomUUID)()}`);
23133
+ const work = join21(this.o.tmpDir ?? "/tmp", `keepsnappy-suite-${(this.o.uniqueId ?? randomUUID)()}`);
22676
23134
  let suiteUrl;
22677
23135
  try {
22678
23136
  const zip = await this.zipSuite(b, work);
22679
23137
  const form = new FormData;
22680
- form.append("file", new Blob([await this.bytes(zip)]), basename3(zip));
23138
+ form.append("file", new Blob([await this.bytes(zip)]), basename5(zip));
22681
23139
  const r = await this.call("/test-suite", { method: "POST", form });
22682
23140
  if (typeof r.test_suite_url !== "string")
22683
23141
  throw new FarmApiError("suite upload returned no test_suite_url");
@@ -22705,17 +23163,17 @@ class BrowserStack {
22705
23163
  return this.poll(buildId, b);
22706
23164
  }
22707
23165
  async zipSuite(b, work) {
22708
- const zipPath = join20(work, "suite.zip");
22709
- const stage = join20(work, "stage");
23166
+ const zipPath = join21(work, "suite.zip");
23167
+ const stage = join21(work, "stage");
22710
23168
  await this.deps.fs.mkdir(stage);
22711
23169
  const cp = await this.deps.spawner.run("cp", ["-R", b.suiteDir.replace(/\/$/, ""), stage]);
22712
23170
  if (cp.code !== 0)
22713
23171
  throw new FarmApiError(`cannot copy the suite ${b.suiteDir}: ${cp.stderr || cp.error}`);
22714
- const folder = basename3(b.suiteDir.replace(/\/$/, ""));
22715
- if (!await this.deps.fs.exists(join20(stage, folder, "config.yaml")))
23172
+ const folder = basename5(b.suiteDir.replace(/\/$/, ""));
23173
+ if (!await this.deps.fs.exists(join21(stage, folder, "config.yaml")))
22716
23174
  throw new FarmApiError(`${b.suiteDir} has no config.yaml: is that the Maestro workspace?`);
22717
- const target = join20(stage, folder, b.tokensPath);
22718
- await this.deps.fs.mkdir(join20(target, ".."));
23175
+ const target = join21(stage, folder, b.tokensPath);
23176
+ await this.deps.fs.mkdir(join21(target, ".."));
22719
23177
  await this.deps.fs.writeFile(target, await this.deps.fs.readFile(b.tokensJs));
22720
23178
  const include = SUITE_EXTENSIONS.flatMap((e) => ["-i", `${folder}/*${e}`, "-i", `${folder}/**/*${e}`]);
22721
23179
  const z = await this.deps.spawner.run("sh", ["-c", 'cd "$1" && shift && zip -qr "$@"', "sh", stage, zipPath, folder, ...include, "-x", `${folder}/runs/*`, "-x", "*/node_modules/*"]);
@@ -22774,9 +23232,9 @@ class BrowserStackLocal {
22774
23232
  const url = TUNNEL_ZIP[this.o.platform ?? process.platform];
22775
23233
  if (!url)
22776
23234
  throw new FarmApiError(`no BrowserStackLocal binary known for ${this.o.platform ?? process.platform}`);
22777
- const dir = join20(this.o.dir, identifier);
22778
- const bin = join20(dir, "BrowserStackLocal");
22779
- const zip = join20(dir, "bs-local.zip");
23235
+ const dir = join21(this.o.dir, identifier);
23236
+ const bin = join21(dir, "BrowserStackLocal");
23237
+ const zip = join21(dir, "bs-local.zip");
22780
23238
  await this.deps.fs.mkdir(dir);
22781
23239
  for (const [cmd, args] of [
22782
23240
  ["curl", ["-fsSL", "-o", zip, url]],
@@ -22855,83 +23313,6 @@ async function maestroCliVersion(deps) {
22855
23313
  return /^\s*v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\s*$/m.exec(r.stdout)?.[1];
22856
23314
  }
22857
23315
 
22858
- // apps/agent/src/device-model.ts
22859
- import { tmpdir as tmpdir3 } from "node:os";
22860
- import { join as join21 } from "node:path";
22861
- function parseDevicectlList(json) {
22862
- let data;
22863
- try {
22864
- data = JSON.parse(json);
22865
- } catch {
22866
- return [];
22867
- }
22868
- const devices = Array.isArray(data.result?.devices) ? data.result.devices : [];
22869
- return devices.flatMap((d) => {
22870
- const hw = d.hardwareProperties ?? {};
22871
- const conn = d.connectionProperties ?? {};
22872
- if (typeof hw.productType !== "string" || !hw.productType)
22873
- return [];
22874
- const text = (v) => typeof v === "string" ? v : "";
22875
- return [{ productType: hw.productType, udid: text(hw.udid), identifier: text(d.identifier), serial: text(hw.serialNumber), connected: conn.tunnelState === "connected" }];
22876
- });
22877
- }
22878
- var parseSimulators = (json) => {
22879
- try {
22880
- const data = JSON.parse(json);
22881
- return Object.values(data.devices ?? {}).flat();
22882
- } catch {
22883
- return [];
22884
- }
22885
- };
22886
- var withSimulatorWord = (name) => /simulator/i.test(name) ? name : `${name} Simulator`;
22887
- var simulatorModel = (s) => withSimulatorWord(s.name);
22888
- async function androidModel(deps, id) {
22889
- const r = await deps.spawner.run("adb", [...id ? ["-s", id] : [], "shell", "getprop", "ro.product.model"]);
22890
- const model = r.code === 0 ? (r.stdout.split(`
22891
- `)[0] ?? "").trim() : "";
22892
- return model || null;
22893
- }
22894
- async function simulators(deps, id) {
22895
- const r = await deps.spawner.run("xcrun", ["simctl", "list", "devices", ...id ? [] : ["booted"], "-j"]);
22896
- if (r.code !== 0)
22897
- return [];
22898
- return parseSimulators(r.stdout).filter((s) => id ? s.udid === id : s.state === "Booted");
22899
- }
22900
- async function phones(deps) {
22901
- const out = join21(tmpdir3(), `keepsnappy-devicectl-${deps.clock.now()}.json`);
22902
- try {
22903
- const r = await deps.spawner.run("xcrun", ["devicectl", "list", "devices", "--quiet", "--json-output", out]);
22904
- const text = r.code === 0 ? await deps.fs.readFile(out).catch(() => "") : "";
22905
- return parseDevicectlList(text);
22906
- } finally {
22907
- await deps.spawner.run("rm", ["-f", out]);
22908
- }
22909
- }
22910
- async function iosModel(deps, id) {
22911
- const sims = await simulators(deps, id);
22912
- if (id) {
22913
- if (sims.length === 1)
22914
- return simulatorModel(sims[0]);
22915
- const found = (await phones(deps)).filter((p) => [p.udid, p.identifier, p.serial].some((x) => x.toLowerCase() === id.toLowerCase()));
22916
- return found.length === 1 ? found[0].productType : null;
22917
- }
22918
- const connected = (await phones(deps)).filter((p) => p.connected);
22919
- if (sims.length + connected.length !== 1)
22920
- return null;
22921
- return sims[0] ? simulatorModel(sims[0]) : connected[0].productType;
22922
- }
22923
- async function detectDeviceModel(deps, q) {
22924
- if (q.platform === "android")
22925
- return androidModel(deps, q.deviceId);
22926
- if (q.platform === "ios")
22927
- return iosModel(deps, q.deviceId);
22928
- return null;
22929
- }
22930
- async function detectSimulator(deps, udid) {
22931
- const sims = await simulators(deps, udid);
22932
- return sims.length === 1 ? simulatorModel(sims[0]) : null;
22933
- }
22934
-
22935
23316
  // apps/agent/src/links.ts
22936
23317
  import { parseArgs as parseArgs7 } from "node:util";
22937
23318
  var LINK_COMMANDS = new Set(["activation-link", "flush-link", "gc-link"]);
@@ -23845,8 +24226,8 @@ async function farmRun(deps, f, rt, ports) {
23845
24226
 
23846
24227
  // apps/agent/src/sourcemaps.ts
23847
24228
  import { createHash as createHash4, randomUUID as randomUUID2 } from "node:crypto";
23848
- import { access as access2, readFile as readFile10, readdir as readdir5, rename as rename3, rm as rm4, writeFile as writeFile7 } from "node:fs/promises";
23849
- import { basename as basename4, join as join25, resolve as resolve13 } from "node:path";
24229
+ import { access as access2, readFile as readFile10, readdir as readdir6, rename as rename3, rm as rm4, writeFile as writeFile7 } from "node:fs/promises";
24230
+ import { basename as basename6, join as join25, resolve as resolve13 } from "node:path";
23850
24231
  import { parseArgs as parseArgs8 } from "node:util";
23851
24232
  import { gzipSync } from "node:zlib";
23852
24233
  var MAX_COMPLETE_ROUNDS = 20;
@@ -23854,7 +24235,7 @@ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
23854
24235
  async function prepareComponents(projectRoot, options = {}) {
23855
24236
  const root = resolve13(projectRoot);
23856
24237
  const directory = join25(root, ".keepsnappy", "components");
23857
- const configurations = await readdir5(directory, { withFileTypes: true }).catch((error) => {
24238
+ const configurations = await readdir6(directory, { withFileTypes: true }).catch((error) => {
23858
24239
  if (error.code === "ENOENT")
23859
24240
  return [];
23860
24241
  throw error;
@@ -23883,7 +24264,7 @@ async function prepareComponents(projectRoot, options = {}) {
23883
24264
  if (!isComponentConfiguration(config) || config.directory !== selectedConfiguration) {
23884
24265
  throw new ConfigError(["invalid KeepSnappy component fragment configuration"]);
23885
24266
  }
23886
- const files = (await readdir5(selected)).filter((file) => file.endsWith(".json") && !file.startsWith(".")).sort();
24267
+ const files = (await readdir6(selected)).filter((file) => file.endsWith(".json") && !file.startsWith(".")).sort();
23887
24268
  const components = [];
23888
24269
  const hashes = new Map;
23889
24270
  let matched = 0;
@@ -23973,7 +24354,7 @@ async function uploadSourcemaps(opts) {
23973
24354
  const text = Buffer.from(JSON.stringify(map));
23974
24355
  const gz = gzipSync(text);
23975
24356
  const sha256 = createHash4("sha256").update(gz).digest("hex");
23976
- const name = basename4(file);
24357
+ const name = basename6(file);
23977
24358
  const announced = await call(f, opts.url, `/v1/builds/${encodeURIComponent(opts.build)}/sourcemaps`, headers, {
23978
24359
  platform: opts.platform,
23979
24360
  name,
@@ -24354,10 +24735,12 @@ var BRANCHES_USAGE = `keepsnappy branches [--days N] [--sort needs-look|newest]
24354
24735
  current result and its phones; branches still running or waiting for main last.
24355
24736
  --sort needs-look (default: the worst result first) or newest (the newest result first)
24356
24737
  --app the app the key must be for (a key reads one app); another app is an error
24357
- --json one JSON object: {"version":1,"app","app_name","days","sort","generated_at","more","branches":[…]} in the order asked for (the Mac app reads it);
24738
+ --json one JSON object: {"version":1,"app","app_name","days","sort","generated_at","more","branches":[…],"main":[…]} in the order asked for (the Mac app reads it);
24358
24739
  more: the window held more pushes than one answer reads, the oldest are left out
24359
24740
  each phone: "finding_id" (the finding its result is about, or null) and "hypotheses" ({count, latest_submission_id, latest_at}: what the
24360
24741
  ledger holds about that finding, from any runner, or null)
24742
+ main: one row per phone that ran main in the window, its newest session, newest first (the sort does not change it):
24743
+ phone, phone_name, scenario_name, word, state, last_run_at, url, run_set_id, finding_id, hypotheses
24361
24744
  --config accepted for the Mac app, which passes it to every command; nothing in it changes the list
24362
24745
  (key: KEEPSNAPPY_KEY or --key-env NAME, read or run scope; API: --api URL or KEEPSNAPPY_API)`;
24363
24746
  var BRANCHES_COMMANDS = new Set(["branches"]);
@@ -24383,7 +24766,7 @@ function settingsOf(argv) {
24383
24766
  return { days, sort, app: o.app?.trim().toLowerCase() || null, json: o.json ?? false, api: o.api ?? null, keyEnv: o["key-env"] ?? "KEEPSNAPPY_KEY", help: o.help ?? false };
24384
24767
  }
24385
24768
  function branchesJson(page, generatedAt) {
24386
- return { version: BRANCHES_JSON_VERSION, app: page.app, app_name: page.app_name, days: page.days, sort: page.sort, generated_at: new Date(generatedAt).toISOString(), more: page.more, branches: page.branches };
24769
+ return { version: BRANCHES_JSON_VERSION, app: page.app, app_name: page.app_name, days: page.days, sort: page.sort, generated_at: new Date(generatedAt).toISOString(), more: page.more, branches: page.branches, main: page.main ?? [] };
24387
24770
  }
24388
24771
  function branchesText(page) {
24389
24772
  const n = page.branches.length;
@@ -24417,7 +24800,7 @@ async function branchesCommand(argv, rt) {
24417
24800
  var USAGE = `keepsnappy <command>
24418
24801
  run [--config keepsnappy.yml] [--repeat N] [--out DIR]
24419
24802
  [--upload [--wait] [--fail-on-worse] [--no-notify] [--ingest-url URL] [--commit SHA] [--branch NAME] [--base-commit SHA]]
24420
- [--device-model NAME --device-os VERSION] [--scenario NAME] [--replay <out dir>[:ARM] ...]
24803
+ [--device UDID] [--device-model NAME --device-os VERSION] [--scenario NAME] [--replay <out dir>[:ARM] ...]
24421
24804
  [--no-markers] [-- <e2e command...>]
24422
24805
  (--fail-on-worse: exit 3 only when the verdict computed on that run set is Worse and blocking; closing never computes one,
24423
24806
  so with no computed verdict it checks nothing: "${UNCHECKED_GATE}", exit 0)