bugpatrol 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -948,6 +948,7 @@ var paths = {
948
948
  issue: (root2, id2) => data(root2, "issues", `${recordId(id2)}.json`),
949
949
  fixes: (root2) => data(root2, "fixes"),
950
950
  fix: (root2, id2) => data(root2, "fixes", `${recordId(id2)}.json`),
951
+ fixAttemptDiff: (root2, id2, n) => data(root2, "fixes", recordId(id2), `attempt-${n}.diff`),
951
952
  publish: (root2) => data(root2, "publish"),
952
953
  review: (root2, pr) => data(root2, "reviews", `pr-${pr}.json`),
953
954
  /** What one review keeps besides its record: the claim routines and their replays. Nothing else reads it. */
@@ -973,9 +974,8 @@ function findProjectRoot(start) {
973
974
  }
974
975
  function instructionsPath(root2, configured) {
975
976
  if (configured)
976
- return resolve(root2, configured);
977
- const fallback = join(dir(root2), "instructions.md");
978
- return existsSync(fallback) ? fallback : void 0;
977
+ return { path: resolve(root2, configured), required: true };
978
+ return { path: join(dir(root2), "instructions.md"), required: false };
979
979
  }
980
980
  function legacyLayout(root2) {
981
981
  if (!existsSync(join(root2, LEGACY_CONFIG_FILENAME)))
@@ -1178,7 +1178,6 @@ var InfrastructureError = class extends BugpatrolError {
1178
1178
 
1179
1179
  // ../core/dist/config/schema.js
1180
1180
  import { z } from "zod";
1181
- var secretRefString = z.string().describe("A ${ENV_VAR} reference. Never a literal credential (spec 11.4).");
1182
1181
  var viewportSchema = z.object({
1183
1182
  name: z.string(),
1184
1183
  width: z.number().int().positive(),
@@ -1196,27 +1195,6 @@ var runSchema = z.object({
1196
1195
  }).default({}),
1197
1196
  seeds: z.array(z.string()).default([])
1198
1197
  });
1199
- var authSchema = z.discriminatedUnion("kind", [
1200
- z.object({ kind: z.literal("none") }),
1201
- z.object({
1202
- kind: z.literal("form"),
1203
- loginUrl: z.string(),
1204
- credentials: secretRefString.optional(),
1205
- steps: z.array(actionSchema).default([])
1206
- }),
1207
- z.object({ kind: z.literal("storageState"), path: z.string(), expiresAt: z.string().optional() }),
1208
- z.object({
1209
- kind: z.literal("seededUser"),
1210
- seedCommand: z.string(),
1211
- credentials: secretRefString.optional()
1212
- }),
1213
- z.object({
1214
- kind: z.literal("ssoBypass"),
1215
- header: z.string().optional(),
1216
- token: secretRefString.optional()
1217
- }),
1218
- z.object({ kind: z.literal("manual"), path: z.string() })
1219
- ]);
1220
1198
  var maskSchema = z.object({
1221
1199
  selector: z.string(),
1222
1200
  screen: z.string().optional(),
@@ -1445,12 +1423,18 @@ var agentsSchema = z.object({
1445
1423
  enabled: z.boolean().default(false),
1446
1424
  /** Run after the change; a non-zero exit marks the fix failed. */
1447
1425
  verify: z.string().optional(),
1426
+ /**
1427
+ * Fix attempts on one issue before the fixer gives up on it: first
1428
+ * fixes, reruns after a failure, and refixes. CI fixes have their own
1429
+ * limit. `bugpatrol fix --issue` still runs an issue at the limit.
1430
+ */
1431
+ attempts: z.number().int().positive().default(3),
1448
1432
  /** After a fix, start the app from the fix worktree and repeat the issue's flow. */
1449
1433
  retest: z.object({
1450
1434
  enabled: z.boolean().default(true),
1451
1435
  /** Shell command run in the worktree before the app starts, e.g. `bun install`. */
1452
1436
  prepare: z.string().optional(),
1453
- /** Fix attempts in total; each attempt after the first gets the last verdict as feedback. */
1437
+ /** Retest verdicts in total; a not-fixed verdict gets a refix with the verdict as feedback. */
1454
1438
  attempts: z.number().int().positive().default(2),
1455
1439
  maxSteps: z.number().int().positive().default(30),
1456
1440
  budgetUsd: z.number().nonnegative().optional()
@@ -1484,6 +1468,12 @@ var agentsSchema = z.object({
1484
1468
  }).default({}),
1485
1469
  /** `bugpatrol review <pr>`. */
1486
1470
  review: z.object({
1471
+ /**
1472
+ * Tells this review apart from another Bugpatrol review of the same pull
1473
+ * request, for example one per app of a monorepo. Each name keeps its own
1474
+ * review and its own check run, and never replaces the other's.
1475
+ */
1476
+ name: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Use lowercase letters, digits and dashes").optional(),
1487
1477
  /** The claim check: test what the pull request says it does. Off until a team opts in. */
1488
1478
  claims: z.boolean().default(false),
1489
1479
  /**
@@ -1538,7 +1528,14 @@ var bugpatrolConfigSchema = z.object({
1538
1528
  run: runSchema.optional(),
1539
1529
  app: appSchema,
1540
1530
  agents: agentsSchema,
1541
- auth: authSchema.default({ kind: "none" }),
1531
+ /** Never read. Accept the `kind: none` older `init` wrote; reject a login flow instead of ignoring it. */
1532
+ auth: z.object({
1533
+ kind: z.literal("none", {
1534
+ errorMap: () => ({
1535
+ message: "Bugpatrol does not read `auth`. Sign in through `app.setup` and `app.instructions`."
1536
+ })
1537
+ })
1538
+ }).optional(),
1542
1539
  viewports: z.array(viewportSchema).min(1).default([
1543
1540
  { name: "desktop", width: 1440, height: 900, deviceScaleFactor: 1 },
1544
1541
  { name: "mobile", width: 390, height: 844, deviceScaleFactor: 1 }
@@ -1606,9 +1603,28 @@ function requireRun(config) {
1606
1603
  }
1607
1604
 
1608
1605
  // ../core/dist/types/agents.js
1606
+ var LESSON_MAX_LENGTH = 200;
1607
+ function fitLesson(text3) {
1608
+ const trimmed = text3.trim();
1609
+ if (trimmed.length <= LESSON_MAX_LENGTH)
1610
+ return trimmed;
1611
+ const space = trimmed.lastIndexOf(" ", LESSON_MAX_LENGTH - 1);
1612
+ return `${trimmed.slice(0, space > 0 ? space : LESSON_MAX_LENGTH - 1).trimEnd()}\u2026`;
1613
+ }
1614
+ function lessonTooLong(text3) {
1615
+ const length = text3.trim().length;
1616
+ if (length > LESSON_MAX_LENGTH)
1617
+ return `The lesson has ${length} characters, and the limit is ${LESSON_MAX_LENGTH}. Nothing was saved. Write it shorter.`;
1618
+ }
1609
1619
  function judgedRetests(retests = []) {
1610
1620
  return retests.filter((retest) => retest.outcome !== "error" && retest.outcome !== "skipped");
1611
1621
  }
1622
+ function fixerAttempts(attempts = []) {
1623
+ return attempts.filter((attempt) => attempt.kind !== "ci");
1624
+ }
1625
+ function ciAttempts(attempts = []) {
1626
+ return attempts.filter((attempt) => attempt.kind === "ci");
1627
+ }
1612
1628
 
1613
1629
  // ../core/dist/types/ids.js
1614
1630
  var id = {
@@ -2345,6 +2361,18 @@ function firstLine(text3) {
2345
2361
  const line = text3.split("\n").find((item) => item.trim()) ?? "";
2346
2362
  return line.length > 160 ? `${line.slice(0, 157)}...` : line;
2347
2363
  }
2364
+ function tell(messages, text3) {
2365
+ const last = messages.at(-1);
2366
+ if (last?.role === "user" && typeof last.content === "string") {
2367
+ last.content = `${last.content}
2368
+
2369
+ ${text3}`;
2370
+ } else if (last?.role === "user" && Array.isArray(last.content)) {
2371
+ last.content.push({ type: "text", text: text3 });
2372
+ } else {
2373
+ messages.push({ role: "user", content: text3 });
2374
+ }
2375
+ }
2348
2376
  function textOf(result3) {
2349
2377
  return result3.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
2350
2378
  }
@@ -2373,14 +2401,17 @@ function prune(messages) {
2373
2401
  }
2374
2402
  }
2375
2403
  }
2404
+ var REQUEST_TIMEOUT_MS = 4 * 60 * 1e3;
2376
2405
  var ModelRuntime = class {
2377
2406
  use;
2378
2407
  label;
2379
2408
  request;
2409
+ requestTimeoutMs;
2380
2410
  constructor(use, options = {}) {
2381
2411
  this.use = use;
2382
2412
  this.label = `model:${use.via}/${use.model}`;
2383
2413
  this.request = options.fetch ?? fetch;
2414
+ this.requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
2384
2415
  }
2385
2416
  async run(task, emit) {
2386
2417
  const keyName = MODEL_KEYS[this.use.via];
@@ -2403,6 +2434,7 @@ var ModelRuntime = class {
2403
2434
  let costUsd = 0;
2404
2435
  let lastText = "";
2405
2436
  let textOnly = 0;
2437
+ let handBack;
2406
2438
  const outcome = (stop, summary, error) => ({
2407
2439
  stop,
2408
2440
  steps,
@@ -2410,12 +2442,21 @@ var ModelRuntime = class {
2410
2442
  summary,
2411
2443
  error
2412
2444
  });
2413
- while (steps < task.maxSteps) {
2445
+ for (; ; ) {
2446
+ if (steps >= task.maxSteps) {
2447
+ const finish = task.tools.filter((tool) => tool.name.startsWith("finish"));
2448
+ if (handBack || !finish.length) {
2449
+ return outcome("max-steps", lastText);
2450
+ }
2451
+ handBack = { ...task, tools: finish };
2452
+ tell(messages, "Your steps are used. Call finish now with what you did and what you did not reach.");
2453
+ }
2454
+ const current = handBack ?? task;
2414
2455
  if (Date.now() - started >= task.timeoutMs) {
2415
2456
  return outcome("timeout", lastText);
2416
2457
  }
2417
2458
  prune(messages);
2418
- const body2 = requestBody(this.use, task, messages, anthropic);
2459
+ const body2 = requestBody(this.use, current, messages, anthropic);
2419
2460
  let payload;
2420
2461
  try {
2421
2462
  payload = await this.call(endpoint, apiKey, anthropic, body2, deadline);
@@ -2442,22 +2483,21 @@ var ModelRuntime = class {
2442
2483
  if (textOnly >= 3) {
2443
2484
  return outcome("done", lastText);
2444
2485
  }
2445
- messages.push({ role: "user", content: "Call one of the tools. Call finish when you are done." });
2486
+ tell(messages, "Call one of the tools. Call finish when you are done.");
2446
2487
  } else {
2447
2488
  textOnly = 0;
2448
- const calls = await this.runCalls(task, parsed.calls, emit, messages, anthropic, deadline, stepCost, step);
2489
+ const calls = await this.runCalls(current, parsed.calls, emit, messages, anthropic, deadline, stepCost, step);
2449
2490
  if (calls.timeout) {
2450
2491
  return outcome("timeout", lastText);
2451
2492
  }
2452
2493
  if (calls.done) {
2453
- return outcome("done", calls.output);
2494
+ return { ...outcome(handBack ? "max-steps" : "done", calls.output), finished: true };
2454
2495
  }
2455
2496
  }
2456
2497
  if (task.budgetUsd !== void 0 && costUsd >= task.budgetUsd) {
2457
2498
  return outcome("budget", lastText);
2458
2499
  }
2459
2500
  }
2460
- return outcome("max-steps", lastText);
2461
2501
  }
2462
2502
  async runCalls(task, calls, emit, messages, anthropic, deadline, stepCost, step) {
2463
2503
  const anthroResults = [];
@@ -2541,7 +2581,12 @@ var ModelRuntime = class {
2541
2581
  async call(endpoint, apiKey, anthropic, body2, deadline) {
2542
2582
  for (let attempt = 0; attempt < 3; attempt++) {
2543
2583
  const controller = new AbortController();
2544
- const timer = setTimeout(() => controller.abort(), Math.max(1, deadline - Date.now()));
2584
+ const left = Math.max(1, deadline - Date.now());
2585
+ let stalled = false;
2586
+ const timer = setTimeout(() => {
2587
+ stalled = this.requestTimeoutMs < left;
2588
+ controller.abort();
2589
+ }, Math.min(left, this.requestTimeoutMs));
2545
2590
  try {
2546
2591
  const response7 = await this.request(endpoint, {
2547
2592
  method: "POST",
@@ -2559,7 +2604,7 @@ var ModelRuntime = class {
2559
2604
  throw new Error(`Model returned ${response7.status}`);
2560
2605
  }
2561
2606
  } catch (error) {
2562
- if (attempt === 2 || error instanceof Error && (/^Model returned 4(?!29)/.test(error.message) || error.name === "AbortError")) {
2607
+ if (attempt === 2 || error instanceof Error && (/^Model returned 4(?!29)/.test(error.message) || error.name === "AbortError" && !stalled)) {
2563
2608
  throw error;
2564
2609
  }
2565
2610
  } finally {
@@ -2634,6 +2679,7 @@ var CliRuntime = class {
2634
2679
  async run(task, emit) {
2635
2680
  const temp = await mkdtemp(join3(tmpdir(), "bugpatrol-agent-"));
2636
2681
  let summary = "";
2682
+ let finished = false;
2637
2683
  let steps = 0;
2638
2684
  let stderr = "";
2639
2685
  let stdout = "";
@@ -2677,6 +2723,7 @@ var CliRuntime = class {
2677
2723
  });
2678
2724
  if (result3.done) {
2679
2725
  summary = output2;
2726
+ finished = true;
2680
2727
  }
2681
2728
  }
2682
2729
  });
@@ -2761,7 +2808,8 @@ ${task.prompt}`;
2761
2808
  stop: "timeout",
2762
2809
  steps,
2763
2810
  costUsd: 0,
2764
- summary: summary || text3
2811
+ summary: summary || text3,
2812
+ finished
2765
2813
  };
2766
2814
  }
2767
2815
  if (code !== 0 || inputError) {
@@ -2776,7 +2824,8 @@ ${task.prompt}`;
2776
2824
  stop: "done",
2777
2825
  steps,
2778
2826
  costUsd: 0,
2779
- summary: summary || text3
2827
+ summary: summary || text3,
2828
+ finished
2780
2829
  };
2781
2830
  } finally {
2782
2831
  await mcp.close();
@@ -2922,17 +2971,17 @@ function boundingRegions(pixels, gridPx = 32) {
2922
2971
  const cells = /* @__PURE__ */ new Map();
2923
2972
  for (const p of pixels) {
2924
2973
  const key = `${Math.floor(p.x / gridPx)},${Math.floor(p.y / gridPx)}`;
2925
- const cell = cells.get(key);
2926
- if (!cell) {
2974
+ const cell2 = cells.get(key);
2975
+ if (!cell2) {
2927
2976
  cells.set(key, { x: p.x, y: p.y, width: 1, height: 1 });
2928
2977
  continue;
2929
2978
  }
2930
- const right = Math.max(cell.x + cell.width, p.x + 1);
2931
- const bottom = Math.max(cell.y + cell.height, p.y + 1);
2932
- cell.x = Math.min(cell.x, p.x);
2933
- cell.y = Math.min(cell.y, p.y);
2934
- cell.width = right - cell.x;
2935
- cell.height = bottom - cell.y;
2979
+ const right = Math.max(cell2.x + cell2.width, p.x + 1);
2980
+ const bottom = Math.max(cell2.y + cell2.height, p.y + 1);
2981
+ cell2.x = Math.min(cell2.x, p.x);
2982
+ cell2.y = Math.min(cell2.y, p.y);
2983
+ cell2.width = right - cell2.x;
2984
+ cell2.height = bottom - cell2.y;
2936
2985
  }
2937
2986
  return [...cells.values()].sort((a, b) => a.y - b.y || a.x - b.x);
2938
2987
  }
@@ -3826,14 +3875,14 @@ function severityRank(s) {
3826
3875
  // ../agents/dist/github.js
3827
3876
  import { execFile as execFile3 } from "node:child_process";
3828
3877
  import { createHash as createHash2 } from "node:crypto";
3829
- import { mkdtemp as mkdtemp2, readFile as readFile3, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
3878
+ import { mkdtemp as mkdtemp2, readFile as readFile3, rm as rm3, writeFile as writeFile4 } from "node:fs/promises";
3830
3879
  import { tmpdir as tmpdir2 } from "node:os";
3831
3880
  import { basename as basename3, join as join7, resolve as resolve3 } from "node:path";
3832
3881
  import { promisify as promisify3 } from "node:util";
3833
3882
 
3834
3883
  // ../agents/dist/roles/fixer.js
3835
3884
  import { execFile as execFile2 } from "node:child_process";
3836
- import { lstat, mkdir as mkdir2, readdir as readdir2, readFile as readFile2, realpath, symlink, writeFile as writeFile3 } from "node:fs/promises";
3885
+ import { lstat, mkdir as mkdir2, readdir as readdir2, readFile as readFile2, realpath, rm as rm2, symlink, writeFile as writeFile3 } from "node:fs/promises";
3837
3886
  import { basename as basename2, dirname as dirname2, join as join6, relative as relative3, resolve as resolve2, sep } from "node:path";
3838
3887
  import { promisify as promisify2 } from "node:util";
3839
3888
 
@@ -3961,7 +4010,9 @@ Find the problems that this pull request causes, before it merges.
3961
4010
 
3962
4011
  YOUR JOB, IN ORDER
3963
4012
  1. Read the pull request and its diff in the prompt. Work out which screens and flows the change can affect:
3964
- the screens that the changed files render, and the flows that use the changed logic.
4013
+ the screens that the changed files render, and the flows that use the changed logic. If the app does not
4014
+ render or run any changed file (a change to tests, CI, docs, or code outside the app), call finish at once
4015
+ and say so in untested. Do not look for a screen that the change cannot reach.
3965
4016
  2. Enter the app. If the routine "enter-app" is known, use run_routine for it. To reach a known screen, use
3966
4017
  run_routine with its routine. A flow that starts from a routine is a flow that Bugpatrol can repeat.
3967
4018
  3. Test each affected screen and flow like a careful user. Use each control that the change touches. Try an
@@ -3970,7 +4021,9 @@ YOUR JOB, IN ORDER
3970
4021
  after a changed flow.
3971
4022
  5. Report each problem with report_bug as soon as you see it, with the problem on screen. Then continue.
3972
4023
  6. Do not test the rest of the app. Call finish when you tested what the change can affect, or when your steps
3973
- are almost gone. The summary names the screens and flows that you tested, and the ones that you did not reach.
4024
+ are almost gone. In tested, name each screen or flow that you tested. In untested, name each one that the
4025
+ change can affect and that you did not reach, with why. A developer scans these lines: one short line each,
4026
+ such as "Rename a project from settings", not a paragraph.
3974
4027
 
3975
4028
  Report each problem that you see, also when it looks older than this pull request. Bugpatrol repeats the flow of
3976
4029
  each report on the base build, and the QA lead compares the two builds.
@@ -4340,7 +4393,7 @@ function lessonTools(session, role) {
4340
4393
  return [
4341
4394
  {
4342
4395
  name: "save_lesson",
4343
- description: 'Save one lasting fact about this app or its code for later sessions: one short, specific sentence. Use "for" to save it for another role (explorer, judge, or fixer). When a similar lesson exists, the tool shows it and saves nothing.',
4396
+ description: `Save one lasting fact about this app or its code for later sessions: one short, specific sentence of at most ${LESSON_MAX_LENGTH} characters. Use "for" to save it for another role (explorer, judge, or fixer). When a similar lesson exists, the tool shows it and saves nothing.`,
4344
4397
  inputSchema: {
4345
4398
  type: "object",
4346
4399
  properties: {
@@ -4360,7 +4413,7 @@ function lessonTools(session, role) {
4360
4413
  if (saved >= memory.maxPerSession) {
4361
4414
  return { content: [{ type: "text", text: "The lesson limit for this session is reached." }] };
4362
4415
  }
4363
- const text3 = session.vars.redact(String(input.text ?? "")).trim().slice(0, 240);
4416
+ const text3 = session.vars.redact(String(input.text ?? "")).trim();
4364
4417
  if (!text3)
4365
4418
  return { content: [{ type: "text", text: "The lesson needs a text." }], isError: true };
4366
4419
  const target = roles.includes(input.for) ? input.for : role;
@@ -4372,6 +4425,9 @@ function lessonTools(session, role) {
4372
4425
  ]);
4373
4426
  return { content: [{ type: "text", text: `Confirmed ${confirmed.id}. It has one more hit now.` }] };
4374
4427
  }
4428
+ const tooLong = lessonTooLong(text3);
4429
+ if (tooLong)
4430
+ return { content: [{ type: "text", text: tooLong }], isError: true };
4375
4431
  const similar = input.different === true ? void 0 : similarLesson(lessons, target, text3);
4376
4432
  if (similar && similar.text.toLowerCase() !== text3.toLowerCase()) {
4377
4433
  return {
@@ -4448,7 +4504,7 @@ var Workspace = class {
4448
4504
  const now = (/* @__PURE__ */ new Date()).toISOString();
4449
4505
  const saved = [];
4450
4506
  for (const lesson of lessons) {
4451
- const text3 = lesson.text.trim().slice(0, 200);
4507
+ const text3 = lesson.text.trim();
4452
4508
  if (!text3)
4453
4509
  continue;
4454
4510
  const id2 = `les_${shortHash(`${lesson.role}:${text3.toLowerCase().replace(/\s+/g, " ")}`)}`;
@@ -4583,6 +4639,20 @@ var Workspace = class {
4583
4639
  saveFix(value) {
4584
4640
  return atomic(paths.fix(this.root, value.id), value);
4585
4641
  }
4642
+ async readFixAttemptDiff(id2, n) {
4643
+ try {
4644
+ return await readFile(paths.fixAttemptDiff(this.root, id2, n), "utf8");
4645
+ } catch (error) {
4646
+ if (error.code === "ENOENT")
4647
+ return void 0;
4648
+ throw error;
4649
+ }
4650
+ }
4651
+ async saveFixAttemptDiff(id2, n, diff2) {
4652
+ const file = paths.fixAttemptDiff(this.root, id2, n);
4653
+ await mkdir(join5(file, ".."), { recursive: true });
4654
+ await writeFile2(file, diff2);
4655
+ }
4586
4656
  readReview(pr) {
4587
4657
  return readJson(paths.review(this.root, pr));
4588
4658
  }
@@ -4947,6 +5017,27 @@ async function linkEnvFiles(source, worktree) {
4947
5017
  }
4948
5018
  return linked;
4949
5019
  }
5020
+ async function change(worktree, source) {
5021
+ await git(worktree, "add", "-A");
5022
+ const base = await git(worktree, "merge-base", "HEAD", await git(source, "rev-parse", "HEAD"));
5023
+ return {
5024
+ diffStat: await git(worktree, "diff", "--cached", "--stat", base),
5025
+ diff: await git(worktree, "diff", "--cached", base)
5026
+ };
5027
+ }
5028
+ async function closeAttempt(workspace, fixId, attempt, worktree, source) {
5029
+ attempt.endedAt = (/* @__PURE__ */ new Date()).toISOString();
5030
+ try {
5031
+ await rm2(join6(worktree, LAST_DIFF), { force: true });
5032
+ const { diffStat, diff: diff2 } = await change(worktree, source);
5033
+ attempt.diffStat = diffStat || void 0;
5034
+ if (diff2)
5035
+ await workspace.saveFixAttemptDiff(fixId, attempt.n, `${diff2}
5036
+ `);
5037
+ } catch (error) {
5038
+ attempt.reason = `${attempt.reason ? `${attempt.reason} ` : ""}(The change could not be read: ${String(error).slice(0, 200)})`;
5039
+ }
5040
+ }
4950
5041
  async function updateIssue(session, id2, patch) {
4951
5042
  const current = await session.workspace.readIssue(id2);
4952
5043
  if (!current || current.status === "dismissed")
@@ -4968,6 +5059,8 @@ async function runFixer(session, runtime, opts = {}) {
4968
5059
  return false;
4969
5060
  if (!opts.issueIds && (issue.fixRejected || fixes.some((fix) => fix.issueId === issue.id && fix.status === "rejected")))
4970
5061
  return false;
5062
+ if (!opts.issueIds && fixerAttempts(fixes.find((fix) => fix.issueId === issue.id)?.attempts).length >= config.attempts)
5063
+ return false;
4971
5064
  const pending = ["new", "filed"].includes(issue.status) || failed2.has(issue.id) || retry.has(issue.id);
4972
5065
  return pending && (!issue.fixId || failed2.has(issue.id) || retry.has(issue.id) || fixes.some((fix) => fix.issueId === issue.id && fix.worktreeRemovedAt) || opts.issueIds && fixes.some((fix) => fix.issueId === issue.id && fix.status === "rejected")) && ranks[issue.severity] >= ranks[config.minSeverity];
4973
5066
  });
@@ -4978,6 +5071,7 @@ async function runFixer(session, runtime, opts = {}) {
4978
5071
  if (session.cancelled)
4979
5072
  break;
4980
5073
  const oldFix = fixes.find((fix) => fix.issueId === issue.id);
5074
+ const fixId = `fix_${issue.id}`;
4981
5075
  const baseBranch = `bugpatrol/fix-${issue.id}`;
4982
5076
  let branch = baseBranch;
4983
5077
  if (oldFix?.worktreeRemovedAt && (await branchExists(source, oldFix.branch) || await branchExists(source, baseBranch))) {
@@ -4995,7 +5089,18 @@ async function runFixer(session, runtime, opts = {}) {
4995
5089
  } catch {
4996
5090
  await git(source, "worktree", "add", "-b", branch, worktree, "HEAD");
4997
5091
  }
4998
- if (existing && failed2.has(issue.id)) {
5092
+ const earlier = (oldFix?.attempts ?? []).map((item) => ({ ...item }));
5093
+ const unfinished2 = earlier.at(-1);
5094
+ if (unfinished2 && !unfinished2.outcome) {
5095
+ unfinished2.outcome = "abandoned";
5096
+ unfinished2.reason = "The fixer was stopped before it finished.";
5097
+ if (existing)
5098
+ await closeAttempt(session.workspace, fixId, unfinished2, worktree, source);
5099
+ else
5100
+ unfinished2.endedAt = (/* @__PURE__ */ new Date()).toISOString();
5101
+ }
5102
+ const start = !existing ? "clean" : !failed2.has(issue.id) ? "continue" : earlier.at(-1)?.outcome === "verify-failed" ? "repair" : "clean";
5103
+ if (existing && start === "clean") {
4999
5104
  const base = await git(worktree, "merge-base", "HEAD", await git(source, "rev-parse", "HEAD"));
5000
5105
  await git(worktree, "reset", "--hard", base);
5001
5106
  await git(worktree, "clean", "-fd");
@@ -5017,10 +5122,16 @@ async function runFixer(session, runtime, opts = {}) {
5017
5122
  }
5018
5123
  }
5019
5124
  const previous = retry.get(issue.id);
5125
+ const attempt = {
5126
+ n: (earlier.at(-1)?.n ?? 0) + 1,
5127
+ kind: previous ? "refix" : earlier.length || failed2.has(issue.id) ? "rerun" : "first",
5128
+ sessionId: session.sessionId,
5129
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
5130
+ };
5020
5131
  const proposal = {
5021
5132
  ...previous,
5022
5133
  version: 1,
5023
- id: `fix_${issue.id}`,
5134
+ id: fixId,
5024
5135
  issueId: issue.id,
5025
5136
  status: "running",
5026
5137
  runtime: runtime.label,
@@ -5030,96 +5141,102 @@ async function runFixer(session, runtime, opts = {}) {
5030
5141
  worktreeRemovedAt: void 0,
5031
5142
  ...oldFix?.worktreeRemovedAt ? { pr: void 0, commit: void 0 } : {},
5032
5143
  startedAt: previous?.startedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
5033
- error: void 0
5144
+ error: void 0,
5145
+ attempts: [...earlier, attempt]
5034
5146
  };
5035
5147
  await session.workspace.saveFix(proposal);
5036
5148
  await session.workspace.saveIssue({ ...issue, status: "fixing" });
5037
5149
  session.emit({ kind: "fix", summary: `Fixing ${issue.title}` });
5038
5150
  try {
5039
- const outcome = await runOne(session, runtime, issue, worktree, previous?.retests?.at(-1));
5151
+ const outcome = await runOne(session, runtime, issue, worktree, previous?.retests?.at(-1), { earlier, start });
5152
+ attempt.costUsd = outcome.costUsd;
5040
5153
  if (outcome.stop !== "done") {
5154
+ attempt.outcome = outcome.stop === "timeout" ? "timeout" : "error";
5041
5155
  throw new Error(outcome.error ?? `Fixer stopped: ${outcome.stop}`);
5042
5156
  }
5043
5157
  proposal.costUsd = (previous?.costUsd ?? 0) + outcome.costUsd;
5044
5158
  proposal.summary = outcome.summary;
5159
+ attempt.reason = outcome.summary;
5045
5160
  await git(worktree, "add", "-A");
5046
5161
  const newDiff = await git(worktree, "diff", "--cached");
5047
- const sourceHead = await git(source, "rev-parse", "HEAD");
5048
- const base = await git(worktree, "merge-base", "HEAD", sourceHead);
5049
- proposal.diffStat = await git(worktree, "diff", "--cached", "--stat", base);
5050
- proposal.diff = Buffer.from(await git(worktree, "diff", "--cached", base)).subarray(0, 2e5).toString("utf8");
5162
+ Object.assign(proposal, await change(worktree, source));
5163
+ proposal.diff = Buffer.from(proposal.diff ?? "").subarray(0, 2e5).toString("utf8");
5051
5164
  if (!newDiff && previous) {
5165
+ attempt.outcome = "no-change";
5052
5166
  proposal.status = "proposed";
5053
- proposal.endedAt = (/* @__PURE__ */ new Date()).toISOString();
5054
- await session.workspace.saveFix(proposal);
5055
5167
  await updateIssue(session, issue.id, { status: "fix-proposed", fixId: proposal.id });
5056
- proposals.push(proposal);
5057
- continue;
5058
- }
5059
- if (!newDiff) {
5168
+ } else if (!newDiff) {
5060
5169
  proposal.status = outcome.summary ? "declined" : "failed";
5170
+ attempt.outcome = outcome.summary ? "declined" : "no-change";
5061
5171
  if (proposal.status === "declined")
5062
5172
  await session.workspace.upsertLessons([
5063
5173
  {
5064
5174
  role: "judge",
5065
5175
  source: "fixer-decline",
5066
5176
  scope: issue.screenId,
5067
- text: `Likely by design: ${issue.title} \u2014 ${outcome.summary?.split(/(?<=[.!?])\s/)[0] ?? ""}`.slice(0, 200)
5177
+ text: fitLesson(`Likely by design: ${issue.title} \u2014 ${outcome.summary?.split(/(?<=[.!?])\s/)[0] ?? ""}`)
5068
5178
  }
5069
5179
  ]);
5070
5180
  if (!outcome.summary)
5071
5181
  proposal.error = "The fixer made no change and gave no reason.";
5072
5182
  await updateIssue(session, issue.id, { status: issue.status });
5073
- proposal.endedAt = (/* @__PURE__ */ new Date()).toISOString();
5074
- await session.workspace.saveFix(proposal);
5075
- proposals.push(proposal);
5076
- continue;
5077
- }
5078
- if (config.verify) {
5079
- try {
5080
- await exec2("/bin/sh", ["-c", config.verify], {
5081
- cwd: worktree,
5082
- timeout: 3e5,
5083
- maxBuffer: 4 * 1024 * 1024
5084
- });
5085
- } catch (error) {
5086
- const output2 = error;
5087
- const last = (output2.stderr || output2.stdout || output2.message).trim().split("\n").at(-1) ?? "unknown error";
5183
+ } else {
5184
+ if (config.verify) {
5185
+ try {
5186
+ await exec2("/bin/sh", ["-c", config.verify], {
5187
+ cwd: worktree,
5188
+ timeout: 3e5,
5189
+ maxBuffer: 4 * 1024 * 1024
5190
+ });
5191
+ } catch (error) {
5192
+ const output2 = error;
5193
+ const text3 = `${output2.stdout ?? ""}${output2.stderr ?? ""}`.trim() || output2.message;
5194
+ const last = text3.split("\n").at(-1) ?? "unknown error";
5195
+ await session.workspace.upsertLessons([
5196
+ {
5197
+ role: "fixer",
5198
+ source: "verify",
5199
+ text: fitLesson(`The verify command failed with: ${last}. Run it before you finish.`)
5200
+ }
5201
+ ]);
5202
+ attempt.outcome = "verify-failed";
5203
+ attempt.reason = `The verify command failed: ${last}`.slice(0, 500);
5204
+ attempt.verifyOutput = text3.slice(-4e3);
5205
+ throw new Error(`Verification failed: ${attempt.verifyOutput}`, {
5206
+ cause: error
5207
+ });
5208
+ }
5209
+ }
5210
+ attempt.outcome = "proposed";
5211
+ proposal.checks = config.verify;
5212
+ proposal.status = "proposed";
5213
+ const committed = await commitFix(worktree, issue.title, config.commitMessage);
5214
+ if (!committed.ok) {
5088
5215
  await session.workspace.upsertLessons([
5089
5216
  {
5090
5217
  role: "fixer",
5091
- source: "verify",
5092
- text: `The verify command failed with: ${last}. Run it before you finish.`.slice(0, 200)
5218
+ source: "commit-hook",
5219
+ text: fitLesson(`The commit hook rejected a commit: ${committed.reason}. Make the change pass it.`)
5093
5220
  }
5094
5221
  ]);
5095
- throw new Error(`Verification failed: ${(output2.stderr || output2.stdout || output2.message).slice(-4e3)}`, {
5096
- cause: error
5097
- });
5222
+ proposal.error = `Left uncommitted in the worktree: ${committed.reason}`;
5223
+ } else {
5224
+ proposal.commit = await git(worktree, "rev-parse", "HEAD");
5098
5225
  }
5226
+ if (committed.ok && config.retest.enabled && session.config.agents.explorer.enabled && session.config.agents.judge.enabled && (issue.evidence.routineId || issue.evidence.steps?.length || issue.candidateIds.length))
5227
+ proposal.status = "retesting";
5228
+ await updateIssue(session, issue.id, { status: "fix-proposed", fixId: proposal.id });
5099
5229
  }
5100
- proposal.checks = config.verify;
5101
- proposal.status = "proposed";
5102
- const committed = await commitFix(worktree, issue.title, config.commitMessage);
5103
- if (!committed.ok) {
5104
- await session.workspace.upsertLessons([
5105
- {
5106
- role: "fixer",
5107
- source: "commit-hook",
5108
- text: `The commit hook rejected a commit: ${committed.reason}. Make the change pass it.`.slice(0, 200)
5109
- }
5110
- ]);
5111
- proposal.error = `Left uncommitted in the worktree: ${committed.reason}`;
5112
- } else {
5113
- proposal.commit = await git(worktree, "rev-parse", "HEAD");
5114
- }
5115
- if (committed.ok && config.retest.enabled && session.config.agents.explorer.enabled && session.config.agents.judge.enabled && (issue.evidence.routineId || issue.evidence.steps?.length || issue.candidateIds.length))
5116
- proposal.status = "retesting";
5117
- await updateIssue(session, issue.id, { status: "fix-proposed", fixId: proposal.id });
5118
5230
  } catch (error) {
5119
5231
  proposal.status = "failed";
5120
5232
  proposal.error = String(error);
5233
+ attempt.outcome ??= "error";
5234
+ attempt.reason ??= String(error).slice(0, 500);
5121
5235
  await updateIssue(session, issue.id, { status: issue.status });
5122
5236
  }
5237
+ await closeAttempt(session.workspace, proposal.id, attempt, worktree, source);
5238
+ if (proposal.status === "failed" && fixerAttempts(proposal.attempts).length >= config.attempts)
5239
+ proposal.error = `Gave up after ${config.attempts} fix attempts. The last one: ${proposal.error}`;
5123
5240
  proposal.endedAt = (/* @__PURE__ */ new Date()).toISOString();
5124
5241
  await session.workspace.saveFix(proposal);
5125
5242
  proposals.push(proposal);
@@ -5131,7 +5248,7 @@ async function runFixer(session, runtime, opts = {}) {
5131
5248
  await session.idle(proposals.reduce((sum, item) => sum + (item.costUsd ?? 0), 0));
5132
5249
  return proposals;
5133
5250
  }
5134
- async function runOne(session, runtime, issue, worktree, last) {
5251
+ async function runOne(session, runtime, issue, worktree, last, history) {
5135
5252
  const config = session.config.agents.fixer;
5136
5253
  const evidencePaths = Object.entries(issue.evidence).filter(([key]) => ["screenshot", "baseline", "diff"].includes(key)).map(([key, value]) => `${key}: ${resolve2(session.root, String(value))}`);
5137
5254
  const evidence = evidencePaths.join("\n");
@@ -5139,29 +5256,67 @@ async function runOne(session, runtime, issue, worktree, last) {
5139
5256
  const repro = `Run the routine ${issue.evidence.routineId ?? "(none)"}, then:
5140
5257
  ${steps.join("\n")}`;
5141
5258
  const after = last?.shots?.length ? last.shots.map((shot) => `${shot.screenId ?? "(unknown screen)"}: ${shot.after ? resolve2(session.root, shot.after) : "(unavailable)"}`).join("\n") : last?.after ? resolve2(session.root, last.after) : "(unavailable)";
5142
- return runtime.run({
5143
- role: "fixer",
5144
- sessionId: session.sessionId,
5145
- workdir: worktree,
5146
- system: fixerSystem(lessonsFor(await session.workspace.readMemory(), "fixer")),
5147
- prompt: `Issue: ${issue.title}
5259
+ const earlierAttempts = await attemptsPrompt(session, `fix_${issue.id}`, worktree, history);
5260
+ try {
5261
+ return await runtime.run({
5262
+ role: "fixer",
5263
+ sessionId: session.sessionId,
5264
+ workdir: worktree,
5265
+ system: fixerSystem(lessonsFor(await session.workspace.readMemory(), "fixer")),
5266
+ prompt: `Issue: ${issue.title}
5148
5267
  Severity: ${issue.severity}
5149
5268
  ${issue.body}
5150
5269
  Evidence:
5151
5270
  ${evidence}
5152
- Reproduction: ${repro}` + (last ? `
5271
+ Reproduction: ${repro}` + earlierAttempts + (last ? `
5153
5272
  Your last change did not fix the issue in the running app. The QA lead said: ${last.reason}. After screenshots:
5154
5273
  ${after}
5155
5274
  First find why your last change had no effect in the running app, for example a different file that the app loads on this platform. Do not trust a guess from the QA lead: check the code. When you find the cause, call save_lesson with it, so that later fixes avoid it. Then fix the issue.` : ""),
5156
- tools: [
5157
- ...runtime.label.startsWith("cli:") ? [] : modelTools(worktree),
5158
- ...lessonTools(session, "fixer"),
5159
- finishTool()
5160
- ],
5161
- maxSteps: config.maxSteps,
5162
- budgetUsd: config.budgetUsd,
5163
- timeoutMs: config.timeoutMs
5164
- }, session.emit);
5275
+ tools: [
5276
+ ...runtime.label.startsWith("cli:") ? [] : modelTools(worktree),
5277
+ ...lessonTools(session, "fixer"),
5278
+ finishTool()
5279
+ ],
5280
+ maxSteps: config.maxSteps,
5281
+ budgetUsd: config.budgetUsd,
5282
+ timeoutMs: config.timeoutMs
5283
+ }, session.emit);
5284
+ } finally {
5285
+ await rm2(join6(worktree, LAST_DIFF), { force: true });
5286
+ }
5287
+ }
5288
+ var LAST_DIFF = ".bugpatrol-last-attempt.diff";
5289
+ async function attemptsPrompt(session, fixId, worktree, { earlier, start }) {
5290
+ const last = earlier.at(-1);
5291
+ if (!last)
5292
+ return "";
5293
+ const lines = earlier.map((attempt) => {
5294
+ const reason2 = attempt.reason?.split("\n")[0]?.slice(0, 200);
5295
+ const files2 = attempt.diffStat?.split("\n").slice(0, -1).map((line) => line.trim()).join(", ").slice(0, 300);
5296
+ return `${attempt.n}. ${attempt.kind}, ${attempt.outcome ?? "unfinished"}${reason2 ? `: ${reason2}` : ""}${files2 ? ` (${files2})` : ""}`;
5297
+ });
5298
+ const parts = [`
5299
+
5300
+ Earlier fix attempts on this issue:
5301
+ ${lines.join("\n")}`];
5302
+ if (last.verifyOutput)
5303
+ parts.push(`The last attempt failed the verify command \`${session.config.agents.fixer.verify ?? ""}\` with:
5304
+ ${last.verifyOutput}`);
5305
+ if (start === "repair")
5306
+ parts.push("Your last change is still in the worktree. Repair that change. Do not start over.");
5307
+ else if (start === "clean") {
5308
+ const diff2 = await session.workspace.readFixAttemptDiff(fixId, last.n);
5309
+ if (diff2 && diff2.length <= 2e4)
5310
+ parts.push(`The worktree starts clean. The last attempt's change was:
5311
+ \`\`\`diff
5312
+ ${diff2}\`\`\``);
5313
+ else if (diff2) {
5314
+ await writeFile3(join6(worktree, LAST_DIFF), diff2);
5315
+ parts.push(`The worktree starts clean. The last attempt's change is in ${LAST_DIFF}; that file goes away after you finish.`);
5316
+ }
5317
+ }
5318
+ return `${parts.join("\n\n")}
5319
+ `;
5165
5320
  }
5166
5321
  function stepWords(step) {
5167
5322
  if (step.kind === "tap") {
@@ -5304,7 +5459,7 @@ async function syncGitHub(root2, config, deps = {}) {
5304
5459
  role,
5305
5460
  source: "rejected-pr",
5306
5461
  scope: issue.screenId,
5307
- text: lesson
5462
+ text: fitLesson(lesson)
5308
5463
  })));
5309
5464
  const record = await workspace.startSession("judge");
5310
5465
  await workspace.appendEvent(record.id, {
@@ -5351,7 +5506,7 @@ async function syncGitHub(root2, config, deps = {}) {
5351
5506
  role: "judge",
5352
5507
  source: "human",
5353
5508
  scope: issue.screenId,
5354
- text: `Not a bug: ${issue.title} \u2014 ${reason2}`.slice(0, 200)
5509
+ text: fitLesson(`Not a bug: ${issue.title} \u2014 ${reason2}`)
5355
5510
  }
5356
5511
  ]);
5357
5512
  } else if (state === "closed" && stateReason === "completed") {
@@ -5423,7 +5578,7 @@ async function withBody(body2, run3) {
5423
5578
  await writeFile4(file, limitBody(body2));
5424
5579
  return await run3(file);
5425
5580
  } finally {
5426
- await rm2(dir2, { recursive: true, force: true });
5581
+ await rm3(dir2, { recursive: true, force: true });
5427
5582
  }
5428
5583
  }
5429
5584
  var fromUrl = (url) => ({ url: url.trim(), number: Number(url.trim().split("/").at(-1)) });
@@ -5438,7 +5593,7 @@ async function createPr(gh, input) {
5438
5593
  {
5439
5594
  role: "fixer",
5440
5595
  source: "commit-hook",
5441
- text: `The commit hook rejected a commit: ${committed.reason}. Make the change pass it.`.slice(0, 200)
5596
+ text: fitLesson(`The commit hook rejected a commit: ${committed.reason}. Make the change pass it.`)
5442
5597
  }
5443
5598
  ]);
5444
5599
  throw new Error(committed.reason);
@@ -6278,7 +6433,7 @@ async function captureSessionFlow(options) {
6278
6433
 
6279
6434
  // ../agents/dist/patrol.js
6280
6435
  import { execFile as execFile9 } from "node:child_process";
6281
- import { relative as relative5, resolve as resolve13 } from "node:path";
6436
+ import { relative as relative6, resolve as resolve13 } from "node:path";
6282
6437
  import { promisify as promisify9 } from "node:util";
6283
6438
 
6284
6439
  // ../drivers/dist/api.js
@@ -10362,7 +10517,7 @@ import { PNG as PNG3 } from "pngjs";
10362
10517
  // ../drivers/dist/video.js
10363
10518
  import { execFile as execFile4 } from "node:child_process";
10364
10519
  import { accessSync as accessSync2, constants as constants2 } from "node:fs";
10365
- import { mkdtemp as mkdtemp3, rm as rm3, stat as stat2, writeFile as writeFile6 } from "node:fs/promises";
10520
+ import { mkdtemp as mkdtemp3, rm as rm4, stat as stat2, writeFile as writeFile6 } from "node:fs/promises";
10366
10521
  import { tmpdir as tmpdir3 } from "node:os";
10367
10522
  import { delimiter as delimiter2, join as join9 } from "node:path";
10368
10523
  import { promisify as promisify4 } from "node:util";
@@ -10405,7 +10560,7 @@ async function encodeFrames(frames, file, endAt) {
10405
10560
  ...["-c:v", "libx264", "-movflags", "+faststart", "-an", file]
10406
10561
  ]);
10407
10562
  } finally {
10408
- await rm3(dir2, { recursive: true, force: true });
10563
+ await rm4(dir2, { recursive: true, force: true });
10409
10564
  }
10410
10565
  }
10411
10566
  var GIF_TRIES = [
@@ -10428,7 +10583,7 @@ async function inlineGif(video, gif) {
10428
10583
  if ((await stat2(gif)).size <= GIF_MAX_BYTES)
10429
10584
  return true;
10430
10585
  }
10431
- await rm3(gif, { force: true });
10586
+ await rm4(gif, { force: true });
10432
10587
  return false;
10433
10588
  }
10434
10589
 
@@ -10532,7 +10687,7 @@ var Terminal = class {
10532
10687
  }
10533
10688
  /** The lines that scrolled off, then the screen, with no trailing spaces or empty lines. */
10534
10689
  text() {
10535
- const lines = [...this.history, ...this.grid.map((row) => row.map((cell) => cell.char).join(""))].map((line) => line.trimEnd());
10690
+ const lines = [...this.history, ...this.grid.map((row) => row.map((cell2) => cell2.char).join(""))].map((line) => line.trimEnd());
10536
10691
  while (lines.length && !lines.at(-1))
10537
10692
  lines.pop();
10538
10693
  return lines.join("\n");
@@ -10543,9 +10698,9 @@ var Terminal = class {
10543
10698
  const out = new Uint8Array(width * this.rows * GLYPH.height);
10544
10699
  for (let row = 0; row < this.rows; row++) {
10545
10700
  for (let col = 0; col < this.cols; col++) {
10546
- const cell = this.grid[row][col];
10547
- const [fg, bg] = cell.inverse ? [cell.bg, cell.fg] : [cell.fg, cell.bg];
10548
- const bits = glyph(cell.char, cell.bold);
10701
+ const cell2 = this.grid[row][col];
10702
+ const [fg, bg] = cell2.inverse ? [cell2.bg, cell2.fg] : [cell2.fg, cell2.bg];
10703
+ const bits = glyph(cell2.char, cell2.bold);
10549
10704
  for (let line = 0; line < GLYPH.height; line++) {
10550
10705
  const start = (row * GLYPH.height + line) * width + col * GLYPH.width;
10551
10706
  for (let bit = 0; bit < GLYPH.width; bit++)
@@ -10573,7 +10728,7 @@ var Terminal = class {
10573
10728
  this.y++;
10574
10729
  return;
10575
10730
  }
10576
- this.history.push(this.grid.shift().map((cell) => cell.char).join(""));
10731
+ this.history.push(this.grid.shift().map((cell2) => cell2.char).join(""));
10577
10732
  if (this.history.length > 1e4)
10578
10733
  this.history.shift();
10579
10734
  this.grid.push(this.blank());
@@ -11637,7 +11792,7 @@ import { spawn as spawn5 } from "node:child_process";
11637
11792
  import { execFile as execFile5, spawn as spawn4 } from "node:child_process";
11638
11793
  import { randomUUID as randomUUID2 } from "node:crypto";
11639
11794
  import { once } from "node:events";
11640
- import { copyFile as copyFile2, mkdtemp as mkdtemp4, readFile as readFile6, rm as rm4 } from "node:fs/promises";
11795
+ import { copyFile as copyFile2, mkdtemp as mkdtemp4, readFile as readFile6, rm as rm5 } from "node:fs/promises";
11641
11796
  import { tmpdir as tmpdir4 } from "node:os";
11642
11797
  import { dirname as dirname3, join as join10 } from "node:path";
11643
11798
  import { promisify as promisify5 } from "node:util";
@@ -11704,13 +11859,35 @@ function nativeRole(leaf, hint, val, name, a11y, rid, txt) {
11704
11859
  }
11705
11860
  return "group";
11706
11861
  }
11862
+ function rootBounds(roots, read) {
11863
+ const first = bounds(read(roots[0] ?? {}, "bounds"));
11864
+ if (first) {
11865
+ return first;
11866
+ }
11867
+ let level = roots;
11868
+ while (level.length > 0) {
11869
+ const boxes = level.map((node) => bounds(read(node, "bounds"))).filter((box) => box !== void 0);
11870
+ if (boxes.length > 0) {
11871
+ const x1 = Math.min(...boxes.map((box) => box.x));
11872
+ const y1 = Math.min(...boxes.map((box) => box.y));
11873
+ const x2 = Math.max(...boxes.map((box) => box.x + box.width));
11874
+ const y2 = Math.max(...boxes.map((box) => box.y + box.height));
11875
+ return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
11876
+ }
11877
+ level = level.flatMap((node) => {
11878
+ const children = read(node, "children");
11879
+ return Array.isArray(children) ? children : [];
11880
+ });
11881
+ }
11882
+ return void 0;
11883
+ }
11707
11884
  function flattenHierarchy(schema10) {
11708
11885
  const aliases = schema10.ui_schema?.abbreviations ?? {};
11709
11886
  const defaults = schema10.ui_schema?.defaults ?? {};
11710
11887
  const key = (name) => Object.entries(aliases).find(([, expanded]) => expanded === name)?.[0] ?? name;
11711
11888
  const read = (node, name) => node[key(name)] ?? node[name] ?? defaults[key(name)] ?? defaults[name];
11712
11889
  const roots = schema10.elements;
11713
- const root2 = bounds(read(roots[0] ?? {}, "bounds"));
11890
+ const root2 = rootBounds(roots, read);
11714
11891
  if (!root2) {
11715
11892
  throw new Error("Maestro hierarchy has no root bounds");
11716
11893
  }
@@ -11948,7 +12125,7 @@ var MaestroDriver = class {
11948
12125
  await exec3("xcrun", ["simctl", "io", this.deviceId, "screenshot", file]);
11949
12126
  return await readFile6(file);
11950
12127
  } finally {
11951
- await rm4(dir2, { recursive: true, force: true });
12128
+ await rm5(dir2, { recursive: true, force: true });
11952
12129
  }
11953
12130
  }
11954
12131
  async observe() {
@@ -12149,7 +12326,7 @@ var MaestroDriver = class {
12149
12326
  }
12150
12327
  } catch (error) {
12151
12328
  child.kill("SIGKILL");
12152
- await rm4(dir2, { recursive: true, force: true });
12329
+ await rm5(dir2, { recursive: true, force: true });
12153
12330
  throw error;
12154
12331
  }
12155
12332
  return { child, exited, file };
@@ -12201,7 +12378,7 @@ var MaestroDriver = class {
12201
12378
  await within(recording.exited, 3e4, "Stopping the recording");
12202
12379
  await copyFile2(recording.file, file);
12203
12380
  } finally {
12204
- await rm4(dirname3(recording.file), { recursive: true, force: true });
12381
+ await rm5(dirname3(recording.file), { recursive: true, force: true });
12205
12382
  }
12206
12383
  return file;
12207
12384
  }
@@ -12224,7 +12401,7 @@ var MaestroDriver = class {
12224
12401
  });
12225
12402
  } else if (recording) {
12226
12403
  recording.child.kill("SIGKILL");
12227
- await rm4(dirname3(recording.file), { recursive: true, force: true });
12404
+ await rm5(dirname3(recording.file), { recursive: true, force: true });
12228
12405
  }
12229
12406
  await this.client?.close();
12230
12407
  await this.transport?.close();
@@ -12475,7 +12652,7 @@ var Control = class {
12475
12652
 
12476
12653
  // ../drivers/dist/cua/isolation.js
12477
12654
  import { randomBytes as randomBytes2 } from "node:crypto";
12478
- import { access, mkdir as mkdir4, mkdtemp as mkdtemp5, rm as rm5 } from "node:fs/promises";
12655
+ import { access, mkdir as mkdir4, mkdtemp as mkdtemp5, rm as rm6 } from "node:fs/promises";
12479
12656
  import { tmpdir as tmpdir5 } from "node:os";
12480
12657
  import { join as join11 } from "node:path";
12481
12658
  import { getDefaultEnvironment, StdioClientTransport as StdioClientTransport2 } from "@modelcontextprotocol/sdk/client/stdio.js";
@@ -12541,9 +12718,9 @@ async function isolatedTransport(command, windowManager, width, height) {
12541
12718
  env,
12542
12719
  stderr: "pipe"
12543
12720
  });
12544
- return { transport, directory, remove: () => rm5(directory, { recursive: true, force: true }) };
12721
+ return { transport, directory, remove: () => rm6(directory, { recursive: true, force: true }) };
12545
12722
  } catch (error) {
12546
- await rm5(directory, { recursive: true, force: true });
12723
+ await rm6(directory, { recursive: true, force: true });
12547
12724
  throw error;
12548
12725
  }
12549
12726
  }
@@ -13433,7 +13610,6 @@ async function watchCi(root2, config, deps = {}) {
13433
13610
  state,
13434
13611
  head: fix.commit,
13435
13612
  failing: failed2.map((check) => check.name),
13436
- attempts: fix.ci?.attempts ?? 0,
13437
13613
  checkedAt: (/* @__PURE__ */ new Date()).toISOString()
13438
13614
  };
13439
13615
  await workspace.saveFix(fix);
@@ -13456,7 +13632,7 @@ async function watchCi(root2, config, deps = {}) {
13456
13632
  deps.onLog?.(`CI: all ${checks.length} check(s) passed on PR #${pr}.`);
13457
13633
  break;
13458
13634
  }
13459
- if ((fix.ci?.attempts ?? 0) >= settings.attempts) {
13635
+ if (ciAttempts(fix.attempts).length >= settings.attempts) {
13460
13636
  await save("gave-up");
13461
13637
  const problem = `CI: PR #${pr} still fails ${failed2.map((check) => check.name).join(", ")} after ${settings.attempts} fixer attempt(s). A person must look at it.`;
13462
13638
  problems.push(problem);
@@ -13468,12 +13644,21 @@ async function watchCi(root2, config, deps = {}) {
13468
13644
  deps.onLog?.(`CI: PR #${pr} failed ${failed2.map((check) => check.name).join(", ")}. The fixer is off, so a person must fix it.`);
13469
13645
  break;
13470
13646
  }
13647
+ try {
13648
+ await access2(fix.worktree);
13649
+ } catch {
13650
+ fix.ci = { ...fix.ci, state: "gave-up" };
13651
+ await workspace.saveFix(fix);
13652
+ const problem = `CI: the worktree of ${fix.issueId} is gone, so the fixer cannot change PR #${pr}. A person must look at it.`;
13653
+ problems.push(problem);
13654
+ deps.onLog?.(problem);
13655
+ break;
13656
+ }
13471
13657
  deps.onLog?.(`CI: PR #${pr} failed ${failed2.map((check) => check.name).join(", ")}. The fixer tries to fix it.`);
13472
13658
  const pushed = await fixCi(root2, config, fix, failed2, await failureLog(gh, repo, failed2), deps);
13473
- fix.ci = { ...fix.ci, attempts: (fix.ci?.attempts ?? 0) + 1 };
13474
13659
  await workspace.saveFix(fix);
13475
13660
  if (!pushed) {
13476
- if (fix.ci.attempts >= settings.attempts) {
13661
+ if (ciAttempts(fix.attempts).length >= settings.attempts) {
13477
13662
  const problem = `CI: the fixer could not fix PR #${pr}. A person must look at it.`;
13478
13663
  fix.ci = { ...fix.ci, state: "gave-up" };
13479
13664
  await workspace.saveFix(fix);
@@ -13494,12 +13679,6 @@ async function watchCi(root2, config, deps = {}) {
13494
13679
  return { problems };
13495
13680
  }
13496
13681
  async function fixCi(root2, config, fix, failed2, log, deps) {
13497
- try {
13498
- await access2(fix.worktree);
13499
- } catch {
13500
- deps.onLog?.(`CI: the worktree of ${fix.issueId} is gone, so the fixer cannot change PR #${fix.pr.number}.`);
13501
- return false;
13502
- }
13503
13682
  const workspace = new Workspace(root2);
13504
13683
  const record = await workspace.startSession("fixer");
13505
13684
  const session = new AgentSession(root2, config, new Vars(config.app.secrets), record.id, "fixer", void 0, deps.onLog);
@@ -13511,6 +13690,20 @@ async function fixCi(root2, config, fix, failed2, log, deps) {
13511
13690
  });
13512
13691
  let summary = "";
13513
13692
  let status = "failed";
13693
+ const unfinished2 = fix.attempts?.at(-1);
13694
+ if (unfinished2 && !unfinished2.outcome) {
13695
+ unfinished2.outcome = "abandoned";
13696
+ unfinished2.reason = "The fixer was stopped before it finished.";
13697
+ unfinished2.endedAt = (/* @__PURE__ */ new Date()).toISOString();
13698
+ }
13699
+ const attempt = {
13700
+ n: (fix.attempts?.at(-1)?.n ?? 0) + 1,
13701
+ kind: "ci",
13702
+ sessionId: session.sessionId,
13703
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
13704
+ };
13705
+ fix.attempts = [...fix.attempts ?? [], attempt];
13706
+ await workspace.saveFix(fix);
13514
13707
  try {
13515
13708
  const outcome = await runtime.run({
13516
13709
  role: "fixer",
@@ -13528,20 +13721,31 @@ async function fixCi(root2, config, fix, failed2, log, deps) {
13528
13721
  timeoutMs: fixer.timeoutMs
13529
13722
  }, session.emit);
13530
13723
  summary = outcome.summary ?? "";
13531
- if (outcome.stop !== "done")
13724
+ attempt.costUsd = outcome.costUsd;
13725
+ if (outcome.stop !== "done") {
13726
+ attempt.outcome = outcome.stop === "timeout" ? "timeout" : "error";
13532
13727
  throw new Error(outcome.error ?? `Fixer stopped: ${outcome.stop}`);
13728
+ }
13533
13729
  if (!await git3(fix.worktree, "status", "--porcelain")) {
13534
13730
  summary = summary || "The fixer made no change.";
13731
+ attempt.outcome = "no-change";
13535
13732
  session.emit({ kind: "fix", summary: `No change for CI on PR #${fix.pr.number}: ${summary.split("\n")[0]}` });
13536
13733
  status = "finished";
13537
13734
  return false;
13538
13735
  }
13539
13736
  if (fixer.verify) {
13540
- await promisify6(execFile6)("/bin/sh", ["-c", fixer.verify], {
13541
- cwd: fix.worktree,
13542
- timeout: 3e5,
13543
- maxBuffer: 4 * 1024 * 1024
13544
- });
13737
+ try {
13738
+ await promisify6(execFile6)("/bin/sh", ["-c", fixer.verify], {
13739
+ cwd: fix.worktree,
13740
+ timeout: 3e5,
13741
+ maxBuffer: 4 * 1024 * 1024
13742
+ });
13743
+ } catch (error) {
13744
+ const output2 = error;
13745
+ attempt.outcome = "verify-failed";
13746
+ attempt.verifyOutput = (`${output2.stdout ?? ""}${output2.stderr ?? ""}`.trim() || output2.message).slice(-4e3);
13747
+ throw error;
13748
+ }
13545
13749
  }
13546
13750
  await git3(fix.worktree, "add", "-A");
13547
13751
  const committed = await commitFix(fix.worktree, "pass the CI checks", fixer.commitMessage);
@@ -13550,20 +13754,31 @@ async function fixCi(root2, config, fix, failed2, log, deps) {
13550
13754
  fix.commit = await git3(fix.worktree, "rev-parse", "HEAD");
13551
13755
  await git3(fix.worktree, "push", "origin", fix.branch);
13552
13756
  session.emit({ kind: "fix", summary: `Pushed a CI fix to PR #${fix.pr.number} (${fix.commit.slice(0, 7)})` });
13757
+ attempt.outcome = "proposed";
13553
13758
  status = "finished";
13554
13759
  return true;
13555
13760
  } catch (error) {
13556
13761
  summary = String(error).slice(0, 500);
13762
+ attempt.outcome ??= "error";
13557
13763
  session.emit({ kind: "error", summary: `CI fix failed on PR #${fix.pr.number}: ${summary}` });
13558
13764
  return false;
13559
13765
  } finally {
13766
+ attempt.reason = summary || void 0;
13767
+ await closeAttempt(workspace, fix.id, attempt, fix.worktree, fix.repo);
13560
13768
  await workspace.endSession(session.sessionId, { status, summary });
13561
13769
  await session.idle();
13562
13770
  }
13563
13771
  }
13564
13772
 
13565
- // ../agents/dist/roles/explorer.js
13566
- import { readFile as readFile8 } from "node:fs/promises";
13773
+ // ../agents/dist/guide.js
13774
+ import { existsSync as existsSync5 } from "node:fs";
13775
+ import { readFile as readFile7 } from "node:fs/promises";
13776
+ import { relative as relative4 } from "node:path";
13777
+ async function readGuide(root2, config) {
13778
+ const { path, required } = instructionsPath(root2, config.app.instructions);
13779
+ return required || existsSync5(path) ? { path, text: await readFile7(path, "utf8") } : { path };
13780
+ }
13781
+ var noGuide = (root2, guide) => `No app guide at ${relative4(root2, guide.path)}, so the explorer runs without one.`;
13567
13782
 
13568
13783
  // ../agents/dist/tools/explorer.js
13569
13784
  import { randomUUID as randomUUID3 } from "node:crypto";
@@ -14442,7 +14657,6 @@ Expected: ${arg(input, "expected")}`),
14442
14657
  }
14443
14658
 
14444
14659
  // ../agents/dist/roles/reflect.js
14445
- import { readFile as readFile7 } from "node:fs/promises";
14446
14660
  import { stripVTControlCharacters } from "node:util";
14447
14661
  var schema2 = (properties = {}, required = []) => ({
14448
14662
  type: "object",
@@ -14505,8 +14719,7 @@ async function reflectOnSession(root2, config, sessionId, deps = {}) {
14505
14719
  const existing = lessonsFor(memory, "explorer");
14506
14720
  const ids = new Set(existing.map((item) => item.id));
14507
14721
  const vars = deps.vars ?? new Vars(config.app.secrets);
14508
- const file = instructionsPath(root2, config.app.instructions);
14509
- const guide = file ? await readFile7(file, "utf8") : "";
14722
+ const guide = (await readGuide(root2, config)).text ?? "";
14510
14723
  const record = await workspace.startSession("explorer");
14511
14724
  const emit = workspace.recordEvent(record.id, "explorer", vars);
14512
14725
  let added = 0;
@@ -14515,14 +14728,17 @@ async function reflectOnSession(root2, config, sessionId, deps = {}) {
14515
14728
  const tools = [
14516
14729
  {
14517
14730
  name: "add_lesson",
14518
- description: "Save one specific imperative lesson.",
14731
+ description: `Save one specific imperative lesson of at most ${LESSON_MAX_LENGTH} characters.`,
14519
14732
  inputSchema: schema2({ text: string2, scope: string2 }, ["text"]),
14520
14733
  async run(input) {
14521
14734
  if (added >= config.agents.memory.maxPerSession)
14522
14735
  return reply("Lesson limit reached.");
14523
- const text3 = vars.redact(String(input.text ?? "")).trim().slice(0, 200);
14736
+ const text3 = vars.redact(String(input.text ?? "")).trim();
14524
14737
  if (!text3)
14525
14738
  return reply("Lesson text is required.");
14739
+ const tooLong = lessonTooLong(text3);
14740
+ if (tooLong)
14741
+ return { ...reply(tooLong), isError: true };
14526
14742
  const [lesson] = await workspace.upsertLessons([
14527
14743
  { role: "explorer", text: text3, scope: input.scope ? String(input.scope) : void 0, source: "reflection" }
14528
14744
  ]);
@@ -14568,7 +14784,7 @@ async function reflectOnSession(root2, config, sessionId, deps = {}) {
14568
14784
  outcome = await (deps.createRuntime ?? createRuntime)(config.agents.explorer.use).run({
14569
14785
  role: "explorer",
14570
14786
  sessionId: record.id,
14571
- system: `You review a QA explorer's run of this app. Write lessons that would have saved steps or avoided a mistake. Each lesson is ONE imperative sentence a future explorer can follow: what to do and where. Be specific to this app: name the screen, field, or control. Never include a secret value; placeholders such as {{E2E_RUN_ID}} are fine. Do not restate the app guide. Add at most ${config.agents.memory.maxPerSession} lessons. If an existing lesson already says it, call keep_lesson with its id. If one was followed and still failed, or is wrong, call retire_lesson with a reason. Then call finish.`,
14787
+ system: `You review a QA explorer's run of this app. Write lessons that would have saved steps or avoided a mistake. Each lesson is ONE imperative sentence of at most ${LESSON_MAX_LENGTH} characters that a future explorer can follow: what to do and where. Be specific to this app: name the screen, field, or control. Never include a secret value; placeholders such as {{E2E_RUN_ID}} are fine. Do not restate the app guide. Add at most ${config.agents.memory.maxPerSession} lessons. If an existing lesson already says it, call keep_lesson with its id. If one was followed and still failed, or is wrong, call retire_lesson with a reason. Then call finish.`,
14572
14788
  prompt: `TROUBLE LOG
14573
14789
  ${lines.join("\n")}
14574
14790
 
@@ -14623,12 +14839,13 @@ async function runExplorer(session, runtime, opts = {}) {
14623
14839
  const config = session.config.agents.explorer;
14624
14840
  const map = await session.workspace.readAppMap();
14625
14841
  const routines = await session.workspace.listRoutines();
14626
- const guide = instructionsPath(session.root, session.config.app.instructions);
14627
- const instructions = guide ? await readFile8(guide, "utf8") : "";
14842
+ const guide = await readGuide(session.root, session.config);
14843
+ if (guide.text === void 0)
14844
+ session.emit({ kind: "setup", summary: noGuide(session.root, guide) });
14628
14845
  const task = {
14629
14846
  role: "explorer",
14630
14847
  sessionId: session.sessionId,
14631
- system: explorerSystem(session.config.app.platform, instructions, lessonsFor(await session.workspace.readMemory(), "explorer"), session.config.agents.checks),
14848
+ system: explorerSystem(session.config.app.platform, guide.text ?? "", lessonsFor(await session.workspace.readMemory(), "explorer"), session.config.agents.checks),
14632
14849
  prompt: explorerPrompt({
14633
14850
  goal: opts.goal,
14634
14851
  screens: map?.screens ?? [],
@@ -14673,7 +14890,7 @@ async function runExplorer(session, runtime, opts = {}) {
14673
14890
  }
14674
14891
 
14675
14892
  // ../agents/dist/tools/judge.js
14676
- import { appendFile as appendFile2, copyFile as copyFile3, readFile as readFile9 } from "node:fs/promises";
14893
+ import { appendFile as appendFile2, copyFile as copyFile3, readFile as readFile8 } from "node:fs/promises";
14677
14894
  import { join as join12, resolve as resolve8 } from "node:path";
14678
14895
  var response = (value) => ({ content: [{ type: "text", text: value }] });
14679
14896
  var schema3 = (properties, required = []) => ({
@@ -14698,7 +14915,7 @@ function issueTitle(value) {
14698
14915
  }
14699
14916
  async function decisions(session, sessionId) {
14700
14917
  try {
14701
- const lines = await readFile9(join12(paths.session(session.root, sessionId), "decisions.jsonl"), "utf8");
14918
+ const lines = await readFile8(join12(paths.session(session.root, sessionId), "decisions.jsonl"), "utf8");
14702
14919
  return new Set(lines.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line).candidateId));
14703
14920
  } catch (error) {
14704
14921
  if (error.code === "ENOENT")
@@ -14743,7 +14960,7 @@ async function image(session, file) {
14743
14960
  if (!file)
14744
14961
  return [];
14745
14962
  try {
14746
- return [{ type: "image", png: await readFile9(resolve8(session.root, file)) }];
14963
+ return [{ type: "image", png: await readFile8(resolve8(session.root, file)) }];
14747
14964
  } catch {
14748
14965
  return [];
14749
14966
  }
@@ -14929,7 +15146,7 @@ function judgeTools(session, sessionIds, runtimeLabel) {
14929
15146
  role: "explorer",
14930
15147
  source: "dismissal",
14931
15148
  scope: candidate.screenId,
14932
- text: `Do not report: ${candidate.summary} \u2014 ${String(input.reason)}`.slice(0, 200)
15149
+ text: fitLesson(`Do not report: ${candidate.summary} \u2014 ${String(input.reason)}`)
14933
15150
  }
14934
15151
  ]);
14935
15152
  }
@@ -14993,7 +15210,7 @@ async function runJudge(session, runtime, opts) {
14993
15210
  }
14994
15211
 
14995
15212
  // ../agents/dist/roles/publish.js
14996
- import { mkdir as mkdir5, readFile as readFile10, writeFile as writeFile8 } from "node:fs/promises";
15213
+ import { mkdir as mkdir5, readFile as readFile9, writeFile as writeFile8 } from "node:fs/promises";
14997
15214
  import { join as join13, resolve as resolve9 } from "node:path";
14998
15215
 
14999
15216
  // ../agents/dist/pr-title.js
@@ -15127,7 +15344,7 @@ async function image2(root2, path) {
15127
15344
  if (!path)
15128
15345
  return [];
15129
15346
  try {
15130
- return [{ type: "image", png: await readFile10(resolve9(root2, path)) }];
15347
+ return [{ type: "image", png: await readFile9(resolve9(root2, path)) }];
15131
15348
  } catch {
15132
15349
  return [];
15133
15350
  }
@@ -15461,12 +15678,11 @@ ${items.map((item) => `${item.issue.id}: ${item.issue.title}`).join("\n")}`),
15461
15678
 
15462
15679
  // ../agents/dist/roles/retest.js
15463
15680
  import { execFile as execFile7 } from "node:child_process";
15464
- import { readFile as readFile13 } from "node:fs/promises";
15465
15681
  import { resolve as resolve11 } from "node:path";
15466
15682
  import { promisify as promisify7 } from "node:util";
15467
15683
 
15468
15684
  // ../agents/dist/roles/capture.js
15469
- import { readFile as readFile11 } from "node:fs/promises";
15685
+ import { readFile as readFile10 } from "node:fs/promises";
15470
15686
  import { resolve as resolve10 } from "node:path";
15471
15687
  var schema5 = (properties = {}, required = []) => ({
15472
15688
  type: "object",
@@ -15480,7 +15696,7 @@ async function image3(root2, file) {
15480
15696
  if (!file)
15481
15697
  return [];
15482
15698
  try {
15483
- return [{ type: "image", png: await readFile11(resolve10(root2, file)) }];
15699
+ return [{ type: "image", png: await readFile10(resolve10(root2, file)) }];
15484
15700
  } catch {
15485
15701
  return [];
15486
15702
  }
@@ -15619,8 +15835,8 @@ async function captureTargets(session, runtime, targets, task) {
15619
15835
  }
15620
15836
 
15621
15837
  // ../agents/dist/roles/overlay.js
15622
- import { mkdir as mkdir6, readdir as readdir3, readFile as readFile12, rm as rm6, writeFile as writeFile9 } from "node:fs/promises";
15623
- import { dirname as dirname4, join as join14, relative as relative4, sep as sep2 } from "node:path";
15838
+ import { mkdir as mkdir6, readdir as readdir3, readFile as readFile11, rm as rm7, writeFile as writeFile9 } from "node:fs/promises";
15839
+ import { dirname as dirname4, join as join14, relative as relative5, sep as sep2 } from "node:path";
15624
15840
  async function files(dir2, skip) {
15625
15841
  const out = [];
15626
15842
  let entries;
@@ -15641,7 +15857,7 @@ async function files(dir2, skip) {
15641
15857
  return out;
15642
15858
  }
15643
15859
  async function overlayBugpatrol(root2, source, worktree) {
15644
- const where = relative4(source, root2);
15860
+ const where = relative5(source, root2);
15645
15861
  if (where.startsWith("..") || where.split(sep2)[0] === "..")
15646
15862
  return async () => {
15647
15863
  };
@@ -15649,11 +15865,11 @@ async function overlayBugpatrol(root2, source, worktree) {
15649
15865
  const saved = [];
15650
15866
  const skip = [join14(from, DATA_DIR), paths.routines(root2), paths.appMap(root2)];
15651
15867
  for (const file of await files(from, skip)) {
15652
- const target = join14(worktree, where, relative4(root2, file));
15653
- const content = await readFile12(file);
15868
+ const target = join14(worktree, where, relative5(root2, file));
15869
+ const content = await readFile11(file);
15654
15870
  let before;
15655
15871
  try {
15656
- before = await readFile12(target);
15872
+ before = await readFile11(target);
15657
15873
  } catch {
15658
15874
  before = void 0;
15659
15875
  }
@@ -15668,7 +15884,7 @@ async function overlayBugpatrol(root2, source, worktree) {
15668
15884
  if (item.content)
15669
15885
  await writeFile9(item.path, item.content);
15670
15886
  else
15671
- await rm6(item.path, { force: true });
15887
+ await rm7(item.path, { force: true });
15672
15888
  }
15673
15889
  };
15674
15890
  }
@@ -15726,6 +15942,7 @@ async function retestFix(root2, config, issue, fix, attempt, deps, options = {})
15726
15942
  const targets = await loadRetestTargets(workspace, issue);
15727
15943
  const retest = {
15728
15944
  attempt,
15945
+ fixAttempt: [...fix.attempts ?? []].reverse().find((item) => item.outcome === "proposed")?.n,
15729
15946
  outcome: "unclear",
15730
15947
  reason: "",
15731
15948
  shots: targets.map((target) => target.shot),
@@ -15775,8 +15992,7 @@ async function retestFix(root2, config, issue, fix, attempt, deps, options = {})
15775
15992
  session2.emit({ kind: "session-start", summary: `Retesting the fix for: ${issue.title}` });
15776
15993
  let outcome;
15777
15994
  try {
15778
- const guide = config.app.instructions;
15779
- const instructions = guide ? await readFile13(resolve11(root2, guide), "utf8") : "";
15995
+ const instructions = (await readGuide(root2, config)).text ?? "";
15780
15996
  outcome = await captureTargets(session2, explorerRuntime, targets, {
15781
15997
  replay: { save: false, onFixBuild: !options.build },
15782
15998
  system: explorerRetestSystem(config.app.platform, instructions, lessonsFor(await workspace.readMemory(), "explorer")).replace("a build with a proposed fix", options.build === "main" ? "the main build after a merged fix" : "a build with a proposed fix"),
@@ -15911,12 +16127,15 @@ function needsRetest(fix) {
15911
16127
  }
15912
16128
  function applyRetest(config, fix, result3) {
15913
16129
  fix.retests = [...fix.retests ?? [], result3];
15914
- const attemptsLeft = judgedRetests(fix.retests).length < config.agents.fixer.retest.attempts;
16130
+ const retestsLeft = judgedRetests(fix.retests).length < config.agents.fixer.retest.attempts;
16131
+ const refixLeft = fixerAttempts(fix.attempts).length < config.agents.fixer.attempts;
15915
16132
  if (result3.outcome === "fixed")
15916
16133
  fix.status = "verified";
15917
16134
  else if (result3.outcome === "error")
15918
16135
  fix.status = "retesting";
15919
- else if ((result3.outcome === "not-fixed" || result3.outcome === "unclear") && attemptsLeft)
16136
+ else if (result3.outcome === "not-fixed" && retestsLeft && refixLeft)
16137
+ fix.status = "retesting";
16138
+ else if (result3.outcome === "unclear" && retestsLeft)
15920
16139
  fix.status = "retesting";
15921
16140
  else
15922
16141
  fix.status = "proposed";
@@ -16165,7 +16384,7 @@ async function git5(cwd, ...args) {
16165
16384
  return (await exec6("git", args, { cwd })).stdout.trim();
16166
16385
  }
16167
16386
  async function dirty(root2, source) {
16168
- const own = [paths.routines(root2), paths.appMap(root2)].map((path) => relative5(source, path)).filter((path) => !path.startsWith(".."));
16387
+ const own = [paths.routines(root2), paths.appMap(root2)].map((path) => relative6(source, path)).filter((path) => !path.startsWith(".."));
16169
16388
  const exclude = own.map((path) => `:(exclude)${path}`);
16170
16389
  return Boolean(await git5(source, "status", "--porcelain", "--untracked-files=no", "--", ".", ...exclude));
16171
16390
  }
@@ -16390,22 +16609,26 @@ async function runPatrol(options) {
16390
16609
  }
16391
16610
 
16392
16611
  // ../agents/dist/roles/promote.js
16393
- import { readFile as readFile14 } from "node:fs/promises";
16612
+ import { readFile as readFile12 } from "node:fs/promises";
16394
16613
  import { resolve as resolve14 } from "node:path";
16395
16614
 
16396
16615
  // ../agents/dist/review-comment.js
16397
- var REVIEW_MARKER = "<!-- bugpatrol:review -->";
16616
+ function reviewMarker(name) {
16617
+ return name ? `<!-- bugpatrol:review:${name} -->` : "<!-- bugpatrol:review -->";
16618
+ }
16619
+ var REVIEW_MARKER = reviewMarker();
16398
16620
  var SUPERSEDED_MARKER = "<!-- bugpatrol:superseded -->";
16399
16621
  var rank2 = { cosmetic: 0, minor: 1, major: 2, critical: 3 };
16400
16622
  var short = (commit) => commit.slice(0, 7);
16401
16623
  var stoppedAt = (replay) => `stopped at step ${(replay?.failedStep ?? 0) + 1}`;
16402
- function details(summary, lines) {
16403
- return lines.length ? [`<details><summary>${summary}</summary>
16624
+ function details(summary, lines, open3 = false) {
16625
+ return lines.length ? [`<details${open3 ? " open" : ""}><summary>${summary}</summary>
16404
16626
 
16405
16627
  ${lines.join("\n")}
16406
16628
 
16407
16629
  </details>`] : [];
16408
16630
  }
16631
+ var cell = (text3) => text3.replaceAll("|", "\\|").replace(/\s*\n\s*/g, " ");
16409
16632
  function numberDiff(diff2) {
16410
16633
  const lines = /* @__PURE__ */ new Map();
16411
16634
  const text3 = [];
@@ -16484,9 +16707,7 @@ function claimLines(review, image4, url) {
16484
16707
  return [];
16485
16708
  const head = `\`${short(review.head)}\``;
16486
16709
  const base = `\`${short(review.base)}\``;
16487
- const tested = claims.filter((finding) => finding.verdict !== "untested");
16488
- const untested = claims.filter((finding) => finding.verdict === "untested");
16489
- const cell = (path, fallback) => image4(path) ?? `_${fallback}_`;
16710
+ const shot = (path, fallback) => image4(path) ?? `_${fallback}_`;
16490
16711
  const stopped = (finding, build) => {
16491
16712
  const replay = finding[build];
16492
16713
  return replay && !replay.ok ? `The replay ${stoppedAt(replay)}.` : "No screenshot.";
@@ -16495,18 +16716,18 @@ function claimLines(review, image4, url) {
16495
16716
  const recording = finding[build]?.recording;
16496
16717
  const video = recording && url(recording.file);
16497
16718
  const label = recording?.file.endsWith(".cast") ? "Terminal recording" : "Full video";
16498
- return cell(recording?.gif ?? finding[build]?.shots.at(-1), stopped(finding, build)) + (video ? `<br><a href="${video}">${label}</a>` : recording ? `<br>${label}: \`${recording.file}\` in the run, and in the dashboard` : "");
16719
+ return shot(recording?.gif ?? finding[build]?.shots.at(-1), stopped(finding, build)) + (video ? `<br><a href="${video}">${label}</a>` : recording ? `<br>${label}: \`${recording.file}\` in the run, and in the dashboard` : "");
16499
16720
  };
16500
16721
  const checks = (finding) => {
16501
16722
  const results = finding.head?.assertions;
16502
16723
  if (!results?.length)
16503
16724
  return [];
16504
16725
  const cellText = (text3) => text3.replaceAll("|", "\\|");
16505
- const result3 = (item2) => !item2 ? "-" : item2.ok ? "Passed" : item2.actual !== void 0 ? `Failed, ${assertionWords(item2, true)}` : "Failed";
16726
+ const result3 = (item) => !item ? "-" : item.ok ? "Passed" : item.actual !== void 0 ? `Failed, ${assertionWords(item, true)}` : "Failed";
16506
16727
  return [
16507
16728
  `| Check | Base ${base} | This pull request ${head} |
16508
16729
  | --- | --- | --- |
16509
- ` + results.map((item2, index) => `| ${cellText(assertionWords(item2))} | ${cellText(result3(finding.base?.assertions?.[index]))} | ${cellText(result3(item2))} |`).join("\n")
16730
+ ` + results.map((item, index) => `| ${cellText(assertionWords(item))} | ${cellText(result3(finding.base?.assertions?.[index]))} | ${cellText(result3(item))} |`).join("\n")
16510
16731
  ];
16511
16732
  };
16512
16733
  const compared = (finding) => {
@@ -16529,8 +16750,7 @@ ${fence}`;
16529
16750
  const replayed2 = Boolean(finding.head);
16530
16751
  const steps = finding.steps ?? [];
16531
16752
  return [
16532
- `#### ${finding.claim.text}`,
16533
- `\`${finding.verdict}\` \xB7 From ${sourceWords(finding.claim.source)}.` + (finding.evidence ? ` Evidence: ${finding.evidence === "explored" && replayed2 ? judgedReplay : evidenceWords[finding.evidence]}.` : ""),
16753
+ `From ${sourceWords(finding.claim.source)}.` + (finding.evidence ? ` Evidence: ${finding.evidence === "explored" && replayed2 ? judgedReplay : evidenceWords[finding.evidence]}.` : ""),
16534
16754
  finding.reason,
16535
16755
  // With a replay, the screenshots show the rest.
16536
16756
  ...finding.saw && (finding.verdict === "not-proven" || !replayed2) ? [`Bugpatrol saw: ${finding.saw}`] : [],
@@ -16547,21 +16767,36 @@ ${fence}`;
16547
16767
  ...details("Each step", recorded(finding) ? [] : [
16548
16768
  `| Step | Base ${base} | This pull request ${head} |
16549
16769
  | --- | --- | --- |`,
16550
- ...["Start", ...steps].map((step, index) => `| ${index ? `${index}. ${step}` : step} | ${cell(finding.base?.shots[index], "-")} | ${cell(finding.head?.shots[index], "-")} |`)
16770
+ ...["Start", ...steps].map((step, index) => `| ${index ? `${index}. ${step}` : step} | ${shot(finding.base?.shots[index], "-")} | ${shot(finding.head?.shots[index], "-")} |`)
16551
16771
  ])
16552
16772
  ] : []
16553
16773
  ].join("\n\n");
16554
16774
  };
16555
- const item = (finding) => `- **${finding.claim.text}**<br><sub>From ${sourceWords(finding.claim.source)}. ${finding.reason}</sub>`;
16775
+ if (!claims.length)
16776
+ return ["#### Claims (0)", "Bugpatrol found no claim in the pull request."];
16777
+ const order = ["not-proven", "partly-proven", "proven", "untested"];
16778
+ const sorted = [...claims].sort((a, b) => order.indexOf(a.verdict) - order.indexOf(b.verdict));
16779
+ const counts = ["proven", "partly-proven", "not-proven", "untested"].map((verdict) => [verdict, claims.filter((finding) => finding.verdict === verdict).length]).filter(([, count]) => count).map(([verdict, count]) => `${count} ${verdictWords[verdict].toLowerCase()}`);
16780
+ const evidence = (finding) => finding.evidence === "explored" && finding.head ? "replay, judged" : finding.evidence ? evidenceLabel[finding.evidence] : "";
16556
16781
  return [
16557
- `#### What this pull request says it does (${claims.length})`,
16558
- claims.length ? `Bugpatrol compared this pull request (${head}) with its base (${base} on \`${review.baseRef}\`).` : "Bugpatrol found no claim in the pull request.",
16559
- ...tested.map(section),
16560
- ...untested.length ? [`#### Claims that Bugpatrol could not test (${untested.length})`, untested.map(item).join("\n")] : [],
16561
- "#### Problems that this pull request introduces"
16782
+ `#### Claims (${claims.length}): ${counts.join(" \xB7 ")}`,
16783
+ "| Claim | Verdict | Evidence |\n| --- | --- | --- |\n" + sorted.map((finding) => finding.verdict === "untested" ? `| ${cell(finding.claim.text)} | Untested: ${cell(finding.reason)} | |` : `| ${cell(finding.claim.text)} | ${verdictWords[finding.verdict]} | ${evidence(finding)} |`).join("\n"),
16784
+ ...sorted.filter((finding) => finding.verdict !== "untested").flatMap((finding) => details(`${verdictWords[finding.verdict]}: ${finding.claim.text}`, [section(finding)], finding.verdict !== "proven"))
16562
16785
  ];
16563
16786
  }
16564
- function renderReview(review, imageUrl, inDiff) {
16787
+ var verdictWords = {
16788
+ proven: "Proven",
16789
+ "partly-proven": "Partly proven",
16790
+ "not-proven": "Not proven",
16791
+ untested: "Untested"
16792
+ };
16793
+ var evidenceLabel = {
16794
+ replay: "replay",
16795
+ assertion: "exact check",
16796
+ explored: "explored",
16797
+ bench: "benchmark"
16798
+ };
16799
+ function renderReview(review, imageUrl, inDiff, name) {
16565
16800
  const of = (verdict) => review.findings.filter((finding) => finding.verdict === verdict).sort((a, b) => rank2[b.severity] - rank2[a.severity]);
16566
16801
  const introduced = of("introduced");
16567
16802
  const onLine = (finding) => Boolean(finding.file && finding.line && inDiff(finding.file, finding.line));
@@ -16576,32 +16811,59 @@ function renderReview(review, imageUrl, inDiff) {
16576
16811
  heading,
16577
16812
  `**${finding.severity}**${finding.screenId ? ` \xB7 screen \`${finding.screenId}\`` : ""}`,
16578
16813
  finding.reason,
16814
+ ...shots(finding)
16815
+ ].join("\n\n");
16816
+ const shots = (finding) => [
16579
16817
  `| Base ${base} | This pull request ${head} |
16580
16818
  | --- | --- |
16581
16819
  | ${image4(finding.base) ?? `_${finding.baseNote ?? "No screenshot."}_`} | ${image4(finding.head) ?? "_No screenshot._"} |`,
16582
16820
  ...details("Steps", finding.steps.map((step, number) => `${number + 1}. ${step}`))
16583
- ].join("\n\n");
16584
- const line = (finding) => `- **${finding.title}** (${finding.severity}): ${finding.reason}`;
16585
- const where = located.length === 0 ? [] : located.length === introduced.length ? ["Each one is a comment on the changed line that causes it."] : [
16586
- `${located.length} of them ${located.length === 1 ? "is a comment on the changed line that causes it" : "are comments on the changed lines that cause them"}. The others are below.`
16587
16821
  ];
16822
+ const problem = (finding, index) => {
16823
+ const where = onLine(finding) ? `on \`${finding.file}:${finding.line}\` (line comment)` : finding.file && finding.line ? `\`${finding.file}:${finding.line}\`` : finding.screenId ? `screen \`${finding.screenId}\`` : "";
16824
+ const headline = [`**${index + 1}. ${finding.title}**`, finding.severity, where].filter(Boolean).join(" \xB7 ");
16825
+ if (onLine(finding))
16826
+ return headline;
16827
+ return [
16828
+ headline,
16829
+ finding.reason,
16830
+ ...details("Screenshots and steps", [
16831
+ shots(finding)[0],
16832
+ ...finding.steps.map((step, number) => `${number + 1}. ${step}`)
16833
+ ])
16834
+ ].join("\n\n");
16835
+ };
16836
+ const others = [
16837
+ ...of("pre-existing").map((finding) => [finding, `Already on \`${review.baseRef}\``]),
16838
+ ...of("unclear").map((finding) => [finding, "Could not compare"]),
16839
+ ...of("not-a-bug").map((finding) => [finding, "Not a bug"])
16840
+ ];
16841
+ const otherCounts = [
16842
+ [of("pre-existing").length, `already on \`${review.baseRef}\``],
16843
+ [of("unclear").length, "could not compare"],
16844
+ [of("not-a-bug").length, "not a bug"]
16845
+ ].filter(([count]) => count).map(([count, words2]) => `${count} ${words2}`);
16846
+ const coverage = review.coverage;
16847
+ const hint = nextRunHint(review);
16588
16848
  const body2 = [
16589
- REVIEW_MARKER,
16590
- "### Bugpatrol review",
16849
+ reviewMarker(name),
16850
+ name ? `### Bugpatrol review: ${name}` : "### Bugpatrol review",
16851
+ `**${introduced.length ? `${introduced.length} ${introduced.length === 1 ? "problem" : "problems"}` : "No problem"} introduced** \xB7 tested ${head} against \`${review.baseRef}\` ${base}`,
16852
+ ...hint ? [`> **Next run:** ${hint}`] : [],
16853
+ ...introduced.length ? [`#### Problems this pull request introduces (${introduced.length})`, ...introduced.map(problem)] : [],
16591
16854
  ...claimLines(review, image4, imageUrl),
16592
- introduced.length ? `**${introduced.length} ${introduced.length === 1 ? "problem" : "problems"} that this pull request introduces.**` : "**No problem found that this pull request introduces.**",
16593
- ...where,
16594
- review.sessions.base ? `Bugpatrol ran the app from this pull request (${head}) and from its base (${base} on \`${review.baseRef}\`), and repeated the same flows on both.` : `Bugpatrol ran the app from this pull request (${head}) and tested what the diff can affect.`,
16595
- ...introduced.filter((finding) => !onLine(finding)).map((finding, index) => section(finding, `#### ${index + 1}. ${finding.title}`)),
16596
- ...details(`Already on \`${review.baseRef}\`, not from this pull request (${of("pre-existing").length})`, [
16597
- ...of("pre-existing").map(line)
16598
- ]),
16599
- ...details(`Could not compare (${of("unclear").length})`, [
16600
- ...of("unclear").map((finding) => `${line(finding)}${finding.baseNote ? ` Base build: ${finding.baseNote}` : ""}`)
16601
- ]),
16602
- ...details(`Reported, then judged not a bug (${of("not-a-bug").length})`, [...of("not-a-bug").map(line)]),
16603
- ...details("What Bugpatrol tested", review.tested ? [review.tested] : []),
16604
- `<sub>Bugpatrol tested only what the diff can affect, not the whole app. This review does not block the merge.</sub>`
16855
+ ...coverage ? [
16856
+ ...coverage.tested.length ? ["#### Tested", coverage.tested.map((line) => `- ${line}`).join("\n")] : [],
16857
+ ...coverage.untested.length ? [
16858
+ "#### Not tested",
16859
+ coverage.untested.map((item) => `- ${item.what}${item.why ? `: ${item.why}` : ""}`).join("\n")
16860
+ ] : []
16861
+ ] : review.tested ? ["#### Tested", review.tested] : [],
16862
+ ...others.length ? details(`Other findings (${others.length}): ${otherCounts.join(" \xB7 ")}`, [
16863
+ "| Finding | Severity | Verdict | Why |\n| --- | --- | --- | --- |",
16864
+ ...others.map(([finding, verdict]) => `| ${cell(finding.title)} | ${finding.severity} | ${verdict} | ${cell(`${finding.reason}${finding.verdict === "unclear" && finding.baseNote ? ` Base build: ${finding.baseNote}` : ""}`)} |`)
16865
+ ]) : [],
16866
+ `<sub>Updated on each push. Tests only what the diff can affect.${review.check ? "" : " Does not block the merge."}</sub>`
16605
16867
  ].join("\n\n");
16606
16868
  return {
16607
16869
  body: body2,
@@ -16612,8 +16874,25 @@ function renderReview(review, imageUrl, inDiff) {
16612
16874
  }))
16613
16875
  };
16614
16876
  }
16615
- function supersededBody(head) {
16616
- return `${REVIEW_MARKER}
16877
+ function lineReviewBody(count, comment, name) {
16878
+ return [
16879
+ reviewMarker(name),
16880
+ `**Bugpatrol review${name ? `: ${name}` : ""}**: ${count} ${count === 1 ? "problem" : "problems"} on the changed lines. [The full review](${comment}) is updated on each push.`
16881
+ ].join("\n\n");
16882
+ }
16883
+ function nextRunHint(review) {
16884
+ if (review.cutShort?.by === "max-steps")
16885
+ return `The explorer used all ${review.cutShort.limit} steps. Raise \`agents.explorer.maxSteps\`, or the Action's \`steps\` input.`;
16886
+ if (review.cutShort?.by === "timeout")
16887
+ return `The explorer used its ${Math.round(review.cutShort.limit / 6e4)} minutes. Raise \`agents.explorer.timeoutMs\`.`;
16888
+ if (review.cutShort?.by === "budget")
16889
+ return `The explorer spent its $${review.cutShort.limit.toFixed(2)} budget. Raise \`agents.explorer.budgetUsd\`.`;
16890
+ if (review.files && !review.files.guide)
16891
+ return "Add `.bugpatrol/instructions.md`, an app guide that tells the explorer how to use the app.";
16892
+ return void 0;
16893
+ }
16894
+ function supersededBody(head, name) {
16895
+ return `${reviewMarker(name)}
16617
16896
  ${SUPERSEDED_MARKER}
16618
16897
  _A Bugpatrol review of \`${short(head)}\` replaced this one._`;
16619
16898
  }
@@ -16647,7 +16926,7 @@ async function promoteClaims(root2, config, number, claimIds, opts = {}) {
16647
16926
  for (const finding of chosen) {
16648
16927
  let saved;
16649
16928
  try {
16650
- saved = JSON.parse(await readFile14(resolve14(root2, finding.routine), "utf8"));
16929
+ saved = JSON.parse(await readFile12(resolve14(root2, finding.routine), "utf8"));
16651
16930
  } catch {
16652
16931
  throw new ConfigError(`The routine of ${finding.claim.id} is gone from ${finding.routine}. Run \`bugpatrol review ${number} --claims --force\` again.`);
16653
16932
  }
@@ -16672,8 +16951,8 @@ async function promoteClaims(root2, config, number, claimIds, opts = {}) {
16672
16951
 
16673
16952
  // ../agents/dist/roles/review.js
16674
16953
  import { execFile as execFile11 } from "node:child_process";
16675
- import { appendFile as appendFile3, lstat as lstat3, mkdir as mkdir8, readFile as readFile15, rm as rm7, stat as stat3, writeFile as writeFile11 } from "node:fs/promises";
16676
- import { dirname as dirname5, join as join16, resolve as resolve15 } from "node:path";
16954
+ import { appendFile as appendFile3, lstat as lstat3, mkdir as mkdir8, rm as rm8, stat as stat3, writeFile as writeFile11 } from "node:fs/promises";
16955
+ import { dirname as dirname5, join as join16, relative as relative8, resolve as resolve15 } from "node:path";
16677
16956
  import { promisify as promisify11 } from "node:util";
16678
16957
 
16679
16958
  // ../agents/dist/roles/benches.js
@@ -16909,7 +17188,7 @@ function benchWords(bench) {
16909
17188
 
16910
17189
  // ../agents/dist/roles/claims.js
16911
17190
  import { mkdir as mkdir7, writeFile as writeFile10 } from "node:fs/promises";
16912
- import { join as join15, relative as relative6 } from "node:path";
17191
+ import { join as join15, relative as relative7 } from "node:path";
16913
17192
 
16914
17193
  // ../agents/dist/outputs.js
16915
17194
  var RULES2 = [
@@ -17066,7 +17345,7 @@ async function reproClaims(ctx, first) {
17066
17345
  did: `Replayed the repro routine of #${issue.number}, and checked whether the last screen ${checkWords(routine.bug)}.`,
17067
17346
  routine: { ...routine, steps, requires: void 0 },
17068
17347
  check: routine.bug,
17069
- path: relative6(ctx.root, paths.routine(ctx.root, id2))
17348
+ path: relative7(ctx.root, paths.routine(ctx.root, id2))
17070
17349
  });
17071
17350
  }
17072
17351
  return { claims, flows };
@@ -17197,7 +17476,7 @@ function claimTools(session, ctx, claims, flows) {
17197
17476
  did: routine.description,
17198
17477
  saw: redact(input.saw),
17199
17478
  routine,
17200
- path: relative6(ctx.root, file)
17479
+ path: relative7(ctx.root, file)
17201
17480
  });
17202
17481
  end();
17203
17482
  if (same)
@@ -17293,13 +17572,13 @@ async function saveRecording(ctx, driver, name, what) {
17293
17572
  ctx.log(`Could not record ${what}: ${String(error).split("\n")[0]}`);
17294
17573
  return void 0;
17295
17574
  }
17296
- const recording = { file: relative6(ctx.root, file) };
17575
+ const recording = { file: relative7(ctx.root, file) };
17297
17576
  const gif = file.endsWith(".cast") ? castGif : /\.(mp4|mov|webm)$/.test(file) ? inlineGif : void 0;
17298
17577
  if (!gif)
17299
17578
  return recording;
17300
17579
  try {
17301
17580
  if (await gif(file, `${name}.gif`))
17302
- return { ...recording, gif: relative6(ctx.root, `${name}.gif`) };
17581
+ return { ...recording, gif: relative7(ctx.root, `${name}.gif`) };
17303
17582
  ctx.log(`The GIF of ${what} is over the size budget. The review shows its screenshots.`);
17304
17583
  } catch (error) {
17305
17584
  ctx.log(`Could not make a GIF of ${what}: ${String(error).split("\n")[0]}`);
@@ -17334,7 +17613,7 @@ async function replayClaims(ctx, review, build, routines, driver, vars, fresh) {
17334
17613
  last = await current.observe();
17335
17614
  errors.push(...last.consoleErrors, ...last.networkErrors ?? []);
17336
17615
  await writeFile10(file, last.screenshot);
17337
- shots.push(relative6(root2, file));
17616
+ shots.push(relative7(root2, file));
17338
17617
  };
17339
17618
  const what = `${claim} on ${where}`;
17340
17619
  let recording = false;
@@ -17740,7 +18019,7 @@ var schema9 = (properties = {}, required = []) => ({
17740
18019
  });
17741
18020
  var string9 = { type: "string" };
17742
18021
  var response6 = (value) => ({ content: [{ type: "text", text: value }] });
17743
- var CHECK_NAME = "Bugpatrol claim check";
18022
+ var checkName = (name) => name ? `Bugpatrol claim check (${name})` : "Bugpatrol claim check";
17744
18023
  var DIFF_PATHS = [".", ":(exclude,glob)**/*.lock", ":(exclude,glob)**/*-lock.*", ":(exclude,glob)**/*.lockb"];
17745
18024
  var DIFF_LIMIT = 4e4;
17746
18025
  var ISSUE_LIMIT = 4e3;
@@ -17762,9 +18041,44 @@ var REVIEW_TOOLS = /* @__PURE__ */ new Set([
17762
18041
  "run_routine",
17763
18042
  "switch_window",
17764
18043
  "report_bug",
17765
- "list_screens",
17766
- "finish"
18044
+ "list_screens"
17767
18045
  ]);
18046
+ var COVERAGE_LINE = 140;
18047
+ function coverageFinish(keep) {
18048
+ const refuse = (message2) => ({ ...response6(message2), isError: true });
18049
+ return {
18050
+ name: "finish",
18051
+ description: `Finish with each screen or flow that you tested, and each one the change can affect that you did not reach, with why. One short line each, under ${COVERAGE_LINE} characters.`,
18052
+ inputSchema: schema9({
18053
+ tested: { type: "array", items: string9 },
18054
+ untested: { type: "array", items: schema9({ what: string9, why: string9 }, ["what", "why"]) }
18055
+ }, ["tested", "untested"]),
18056
+ async run(input) {
18057
+ const tested = (Array.isArray(input.tested) ? input.tested : []).map((line) => String(line).trim());
18058
+ const untested = (Array.isArray(input.untested) ? input.untested : []).map((item) => {
18059
+ if (typeof item === "string") {
18060
+ const [what2 = "", ...why2] = item.split(": ");
18061
+ return { what: what2.trim(), why: why2.join(": ").trim() };
18062
+ }
18063
+ const { what, why } = item ?? {};
18064
+ return { what: String(what ?? "").trim(), why: String(why ?? "").trim() };
18065
+ });
18066
+ if (tested.some((line) => !line) || untested.some((item) => !item.what))
18067
+ return refuse("Each line needs text: a screen or flow in tested, and what (with why) in untested.");
18068
+ if (!tested.length && !untested.length)
18069
+ return refuse("Name what you tested in tested, and what you did not reach in untested.");
18070
+ const long = [...tested, ...untested.flatMap((item) => [item.what, item.why])].find((line) => line.length > COVERAGE_LINE);
18071
+ if (long)
18072
+ return refuse(`Keep each line under ${COVERAGE_LINE} characters. Too long: ${long}`);
18073
+ keep({ tested, untested });
18074
+ const text3 = [
18075
+ tested.length ? `Tested: ${tested.join("; ")}.` : "",
18076
+ untested.length ? `Not reached: ${untested.map((item) => item.why ? `${item.what} (${item.why})` : item.what).join("; ")}.` : ""
18077
+ ];
18078
+ return { ...response6(text3.filter(Boolean).join(" ")), done: true };
18079
+ }
18080
+ };
18081
+ }
17768
18082
  var claimCheck = (ctx) => Boolean(ctx.opts.claims ?? ctx.config.agents.review.claims);
17769
18083
  var blocking = (ctx) => ctx.config.agents.review.block;
17770
18084
  var headRefOf = (number) => `refs/bugpatrol/pr-${number}`;
@@ -17864,7 +18178,7 @@ async function withBuild(ctx, name, commit, run3) {
17864
18178
  }
17865
18179
  });
17866
18180
  }
17867
- async function exploreHead(ctx, review, claims, flows) {
18181
+ async function exploreHead(ctx, review, guide, claims, flows) {
17868
18182
  const { root: root2, config, workspace, pr, opts } = ctx;
17869
18183
  return withBuild(ctx, "head", pr.head, async (driver, vars) => {
17870
18184
  const record = await workspace.startSession("explorer");
@@ -17874,14 +18188,13 @@ async function exploreHead(ctx, review, claims, flows) {
17874
18188
  const runtime = (opts.createRuntime ?? createRuntime)(config.agents.explorer.use);
17875
18189
  const claimWork = claims.length ? claimTools(session, ctx, claims, flows) : void 0;
17876
18190
  const maxSteps = opts.maxSteps ?? config.agents.explorer.maxSteps;
17877
- const guide = instructionsPath(root2, config.app.instructions);
17878
18191
  await session.activity(`Reviewing PR #${pr.number}`, 0, runtime.label);
17879
18192
  session.emit({ kind: "session-start", summary: `Reviewing PR #${pr.number}: ${pr.title}` });
17880
18193
  try {
17881
18194
  const outcome = await withSessionLogs({ config, root: root2, vars, onLog: ctx.log, workspace, session: record }, async () => runtime.run({
17882
18195
  role: "explorer",
17883
18196
  sessionId: record.id,
17884
- system: explorerReviewSystem(config.app.platform, guide ? await readFile15(guide, "utf8") : "", lessonsFor(await workspace.readMemory(), "explorer")),
18197
+ system: explorerReviewSystem(config.app.platform, guide.text ?? "", lessonsFor(await workspace.readMemory(), "explorer")),
17885
18198
  prompt: [
17886
18199
  explorerReviewPrompt({
17887
18200
  pr,
@@ -17894,18 +18207,31 @@ async function exploreHead(ctx, review, claims, flows) {
17894
18207
  ].join("\n\n"),
17895
18208
  tools: [
17896
18209
  ...explorerTools(session, { replay: { save: false } }).filter((tool) => REVIEW_TOOLS.has(tool.name)),
18210
+ coverageFinish((coverage) => {
18211
+ review.coverage = vars.redact(coverage);
18212
+ }),
17897
18213
  ...claimWork?.tools ?? []
17898
18214
  ].map((tool) => stopOnCancellation(session, claimWork ? claimWork.meter(tool) : tool)),
17899
18215
  maxSteps,
17900
18216
  budgetUsd: config.agents.explorer.budgetUsd,
17901
18217
  timeoutMs: config.agents.explorer.timeoutMs
17902
18218
  }, session.emit));
17903
- if (outcome.stop === "error" || outcome.stop === "timeout")
18219
+ if (outcome.stop === "error" || outcome.stop === "timeout" && !outcome.steps)
17904
18220
  throw new InfrastructureError(`The explorer did not finish (${outcome.stop}): ${outcome.error ?? outcome.summary ?? "no result"}`);
17905
18221
  claimWork?.charge(outcome);
17906
18222
  const candidates = await workspace.readCandidates(record.id);
17907
18223
  review.costUsd += outcome.costUsd;
17908
- review.tested = outcome.stop === "done" && outcome.summary ? vars.redact(outcome.summary.trim()) : `The explorer stopped before it finished (${outcome.stop}), so it tested a part of the change only.`;
18224
+ const summary = outcome.summary?.trim();
18225
+ const cutShort = `The explorer stopped before it finished (${outcome.stop}), so it tested a part of the change only.`;
18226
+ if (outcome.stop === "max-steps")
18227
+ review.cutShort = { by: "max-steps", limit: maxSteps };
18228
+ if (outcome.stop === "timeout")
18229
+ review.cutShort = { by: "timeout", limit: config.agents.explorer.timeoutMs };
18230
+ if (outcome.stop === "budget" && config.agents.explorer.budgetUsd !== void 0)
18231
+ review.cutShort = { by: "budget", limit: config.agents.explorer.budgetUsd };
18232
+ review.tested = summary && (outcome.stop === "done" || outcome.finished) ? vars.redact(outcome.stop === "done" ? summary : `${cutShort}
18233
+
18234
+ ${summary}`) : cutShort;
17909
18235
  await workspace.endSession(record.id, {
17910
18236
  steps: outcome.steps,
17911
18237
  costUsd: outcome.costUsd,
@@ -17924,7 +18250,7 @@ async function exploreHead(ctx, review, claims, flows) {
17924
18250
  }
17925
18251
  });
17926
18252
  }
17927
- async function captureBase(ctx, review, candidates) {
18253
+ async function captureBase(ctx, review, guide, candidates) {
17928
18254
  const { root: root2, config, workspace, pr, opts } = ctx;
17929
18255
  const targets = candidates.map((candidate) => ({
17930
18256
  shot: {
@@ -17941,7 +18267,6 @@ async function captureBase(ctx, review, candidates) {
17941
18267
  const session = new AgentSession(root2, config, vars, record.id, "explorer", driver, ctx.log);
17942
18268
  opts.onSession?.(session);
17943
18269
  const runtime = (opts.createRuntime ?? createRuntime)(config.agents.explorer.use);
17944
- const guide = instructionsPath(root2, config.app.instructions);
17945
18270
  const summary = `Repeating ${targets.length} flow(s) on the base of PR #${pr.number}`;
17946
18271
  await session.activity(summary, 0, runtime.label);
17947
18272
  session.emit({ kind: "session-start", summary });
@@ -17950,7 +18275,7 @@ async function captureBase(ctx, review, candidates) {
17950
18275
  try {
17951
18276
  const outcome = await captureTargets(session, runtime, targets, {
17952
18277
  replay: { save: false },
17953
- system: explorerBaseSystem(config.app.platform, guide ? await readFile15(guide, "utf8") : "", lessonsFor(await workspace.readMemory(), "explorer")),
18278
+ system: explorerBaseSystem(config.app.platform, guide.text ?? "", lessonsFor(await workspace.readMemory(), "explorer")),
17954
18279
  prompt: `Pull request #${pr.number}: ${pr.title}
17955
18280
  Findings:
17956
18281
  ${candidates.map((candidate, index) => `${index + 1}. ${candidate.summary}`).join("\n")}
@@ -18307,7 +18632,11 @@ ${pr.diff}`
18307
18632
  return claims;
18308
18633
  }
18309
18634
  async function runReview(ctx) {
18310
- const { workspace, pr } = ctx;
18635
+ const { root: root2, workspace, pr } = ctx;
18636
+ const guide = await readGuide(root2, ctx.config);
18637
+ const configFile = relative8(root2, paths.config(root2, ctx.opts.configFile));
18638
+ const guideFile = guide.text === void 0 ? void 0 : relative8(root2, guide.path);
18639
+ ctx.log(`Config: ${configFile}. ${guideFile ? `App guide: ${guideFile}.` : noGuide(root2, guide)}`);
18311
18640
  const review = {
18312
18641
  version: 1,
18313
18642
  pr: { number: pr.number, url: pr.url, title: pr.title },
@@ -18317,6 +18646,7 @@ async function runReview(ctx) {
18317
18646
  status: "running",
18318
18647
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
18319
18648
  sessions: {},
18649
+ files: { config: configFile, ...guideFile && { guide: guideFile } },
18320
18650
  findings: [],
18321
18651
  costUsd: 0
18322
18652
  };
@@ -18333,7 +18663,7 @@ async function runReview(ctx) {
18333
18663
  });
18334
18664
  if (claims) {
18335
18665
  review.claims = claims.map((claim) => notYet(claim, "The review stopped before it tested this claim."));
18336
- await rm7(paths.reviewDir(ctx.root, pr.number), { recursive: true, force: true });
18666
+ await rm8(paths.reviewDir(ctx.root, pr.number), { recursive: true, force: true });
18337
18667
  }
18338
18668
  const platform = ctx.config.app.platform;
18339
18669
  const testable = (claims ?? []).filter((claim) => claim.testable);
@@ -18353,7 +18683,7 @@ async function runReview(ctx) {
18353
18683
  const flows = new Map(repros.flows);
18354
18684
  const picks = testable.some(explorable) && ctx.config.agents.review.benches.length ? await pickBenches(ctx, review, testable.filter(explorable)) : /* @__PURE__ */ new Map();
18355
18685
  const toExplore = toTest.filter((claim) => explorable(claim) && !picks.has(claim.id));
18356
- const candidates = await exploreHead(ctx, review, toExplore, flows);
18686
+ const candidates = await exploreHead(ctx, review, guide, toExplore, flows);
18357
18687
  if (claims) {
18358
18688
  const measured = await ctx.budget.timed(() => runBenches(ctx, picks, (run3) => withWorktree(ctx, "base", pr.base, (base) => withWorktree(ctx, "head", pr.head, (head) => run3({ head, base })))));
18359
18689
  const build = (name, commit, run3) => withBuild(ctx, name, commit, run3);
@@ -18367,7 +18697,7 @@ async function runReview(ctx) {
18367
18697
  review.claims = claims.map((claim) => tested.find((finding) => finding.claim.id === claim.id) ?? untestedHere(claim));
18368
18698
  }
18369
18699
  if (candidates.length) {
18370
- const targets = await captureBase(ctx, review, candidates);
18700
+ const targets = await captureBase(ctx, review, guide, candidates);
18371
18701
  review.findings = await judgeFindings(ctx, review, candidates, targets);
18372
18702
  await settleCandidates(ctx, review.sessions.explorer, review.findings);
18373
18703
  }
@@ -18385,19 +18715,30 @@ async function runReview(ctx) {
18385
18715
  }
18386
18716
  return review;
18387
18717
  }
18388
- async function openReviews(gh, reviews) {
18389
- const select = `.[] | select(.body | contains("${REVIEW_MARKER}")) | select(.body | contains("${SUPERSEDED_MARKER}") | not)`;
18390
- return (await gh(["api", "--paginate", reviews, "--jq", `${select} | "\\(.id) \\(.commit_id)"`])).split("\n").filter(Boolean).map((row) => {
18391
- const [id2, commit] = row.split(" ");
18392
- return { id: id2, commit };
18718
+ async function openReviews(gh, reviews, name) {
18719
+ const select = `.[] | select(.body | contains("${reviewMarker(name)}")) | select(.body | contains("${SUPERSEDED_MARKER}") | not)`;
18720
+ return (await gh(["api", "--paginate", reviews, "--jq", `${select} | "\\(.id) \\(.commit_id) \\(.node_id)"`])).split("\n").filter(Boolean).map((row) => {
18721
+ const [id2, commit, node] = row.split(" ");
18722
+ return { id: id2, commit, node };
18393
18723
  });
18394
18724
  }
18725
+ async function saveSticky(gh, repo, pr, marker, body2) {
18726
+ const comments = `repos/${repo}/issues/${pr}/comments`;
18727
+ const [id2] = (await gh(["api", "--paginate", comments, "--jq", `.[] | select(.body | contains("${marker}")) | .id`])).split("\n").filter(Boolean);
18728
+ const input = JSON.stringify({ body: body2 });
18729
+ if (id2) {
18730
+ const edited = JSON.parse(await gh(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${id2}`, "--input", "-"], { input }));
18731
+ return { html_url: edited.html_url, created: false };
18732
+ }
18733
+ const made = JSON.parse(await gh(["api", "-X", "POST", comments, "--input", "-"], { input }));
18734
+ return { html_url: made.html_url, created: true };
18735
+ }
18395
18736
  async function supersede(ctx, gh, old, head) {
18396
18737
  const pulls = `repos/${ctx.repo}/pulls`;
18397
18738
  const ids = new Set(old.map((review) => review.id));
18398
18739
  for (const id2 of ids)
18399
18740
  await gh(["api", "-X", "PUT", `${pulls}/${ctx.pr.number}/reviews/${id2}`, "--input", "-"], {
18400
- input: JSON.stringify({ body: supersededBody(head) })
18741
+ input: JSON.stringify({ body: supersededBody(head, ctx.config.agents.review.name) })
18401
18742
  });
18402
18743
  const rows = (await gh([
18403
18744
  "api",
@@ -18410,6 +18751,15 @@ async function supersede(ctx, gh, old, head) {
18410
18751
  for (const [id2, review, parent] of rows)
18411
18752
  if (ids.has(review) && parent === "null" && !answered.has(id2))
18412
18753
  await gh(["api", "-X", "DELETE", `${pulls}/comments/${id2}`]);
18754
+ for (const { node } of old)
18755
+ await gh([
18756
+ "api",
18757
+ "graphql",
18758
+ "-f",
18759
+ "query=mutation($id: ID!) { minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { clientMutationId } }",
18760
+ "-f",
18761
+ `id=${node}`
18762
+ ]);
18413
18763
  }
18414
18764
  async function publishReview(ctx, gh, review, tested) {
18415
18765
  const { root: root2, config, repo, pr } = ctx;
@@ -18417,7 +18767,7 @@ async function publishReview(ctx, gh, review, tested) {
18417
18767
  const inDiff = (file, line) => pr.lines.get(file)?.has(line) ?? false;
18418
18768
  if (ctx.opts.dryRun) {
18419
18769
  const file = paths.review(root2, pr.number).replace(/\.json$/, ".md");
18420
- const rendered2 = renderReview(review, (path) => resolve15(root2, path), inDiff);
18770
+ const rendered2 = renderReview(review, (path) => resolve15(root2, path), inDiff, config.agents.review.name);
18421
18771
  await mkdir8(dirname5(file), { recursive: true });
18422
18772
  await writeFile11(file, redact([
18423
18773
  rendered2.body,
@@ -18429,7 +18779,7 @@ ${comment.body}`),
18429
18779
  ...review.check ? [
18430
18780
  `---
18431
18781
 
18432
- Check run ${CHECK_NAME}: \`${review.check.conclusion}\`, ${review.check.title}
18782
+ Check run ${checkName(config.agents.review.name)}: \`${review.check.conclusion}\`, ${review.check.title}
18433
18783
 
18434
18784
  ${review.check.summary}`
18435
18785
  ] : []
@@ -18457,37 +18807,36 @@ ${review.check.summary}`
18457
18807
  ctx.log(`Could not upload ${path}: ${String(error).split("\n")[0]}`);
18458
18808
  }
18459
18809
  }
18460
- const rendered = renderReview(review, (path) => urls.get(path), inDiff);
18810
+ const rendered = renderReview(review, (path) => urls.get(path), inDiff, config.agents.review.name);
18461
18811
  const body2 = limitBody(redact(rendered.body));
18462
- const reviews = `repos/${repo}/pulls/${pr.number}/reviews`;
18463
- const open3 = await openReviews(gh, reviews);
18464
- const current = open3.find((item) => item.commit === review.head);
18465
- let posted;
18466
- if (current && !tested) {
18467
- posted = JSON.parse(await gh(["api", "-X", "PUT", `${reviews}/${current.id}`, "--input", "-"], { input: JSON.stringify({ body: body2 }) }));
18468
- } else {
18469
- posted = JSON.parse(await gh(["api", "-X", "POST", reviews, "--input", "-"], {
18470
- input: JSON.stringify({
18471
- commit_id: review.head,
18472
- event: "COMMENT",
18473
- body: body2,
18474
- comments: rendered.comments.map((comment) => ({
18475
- path: comment.path,
18476
- line: comment.line,
18477
- side: "RIGHT",
18478
- body: limitBody(redact(comment.body))
18479
- }))
18480
- })
18481
- }));
18812
+ const name = config.agents.review.name;
18813
+ const posted = await saveSticky(gh, repo, pr.number, reviewMarker(name), body2);
18814
+ review.posted = { url: posted.html_url, at: (/* @__PURE__ */ new Date()).toISOString() };
18815
+ if (tested) {
18816
+ const reviews = `repos/${repo}/pulls/${pr.number}/reviews`;
18817
+ const open3 = await openReviews(gh, reviews, name);
18818
+ if (rendered.comments.length)
18819
+ await gh(["api", "-X", "POST", reviews, "--input", "-"], {
18820
+ input: JSON.stringify({
18821
+ commit_id: review.head,
18822
+ event: "COMMENT",
18823
+ body: lineReviewBody(rendered.comments.length, posted.html_url, name),
18824
+ comments: rendered.comments.map((comment) => ({
18825
+ path: comment.path,
18826
+ line: comment.line,
18827
+ side: "RIGHT",
18828
+ body: limitBody(redact(comment.body))
18829
+ }))
18830
+ })
18831
+ });
18482
18832
  try {
18483
18833
  await supersede(ctx, gh, open3, review.head);
18484
18834
  } catch (error) {
18485
18835
  ctx.log(`Could not replace the older review(s) of PR #${pr.number}: ${String(error).split("\n")[0]}`);
18486
18836
  }
18487
18837
  }
18488
- review.posted = { url: posted.html_url, at: (/* @__PURE__ */ new Date()).toISOString() };
18489
18838
  await ctx.workspace.saveReview(review);
18490
- ctx.log(`${current && !tested ? "Updated" : "Posted"} the review of PR #${pr.number}: ${posted.html_url}`);
18839
+ ctx.log(`${posted.created ? "Posted" : "Updated"} the review of PR #${pr.number}: ${posted.html_url}`);
18491
18840
  if (review.check)
18492
18841
  await setCheckRun(ctx, gh, review, review.check);
18493
18842
  }
@@ -18496,7 +18845,7 @@ async function setCheckRun(ctx, gh, review, check) {
18496
18845
  try {
18497
18846
  const run3 = JSON.parse(await gh(["api", "-X", "POST", `repos/${ctx.repo}/check-runs`, "--input", "-"], {
18498
18847
  input: JSON.stringify({
18499
- name: CHECK_NAME,
18848
+ name: checkName(ctx.config.agents.review.name),
18500
18849
  head_sha: review.head,
18501
18850
  status: "completed",
18502
18851
  conclusion: check.conclusion,
@@ -18809,7 +19158,7 @@ function hostOf(href) {
18809
19158
  }
18810
19159
 
18811
19160
  // ../dashboard/dist/project.js
18812
- import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync4, statSync } from "node:fs";
19161
+ import { existsSync as existsSync6, readdirSync as readdirSync2, readFileSync as readFileSync4, statSync } from "node:fs";
18813
19162
  import { join as join17 } from "node:path";
18814
19163
  var ProjectReader = class {
18815
19164
  root;
@@ -18822,24 +19171,24 @@ var ProjectReader = class {
18822
19171
  return paths.dir(this.root);
18823
19172
  }
18824
19173
  hasProject() {
18825
- return existsSync5(paths.config(this.root)) || existsSync5(this.bugpatrolDir);
19174
+ return existsSync6(paths.config(this.root)) || existsSync6(this.bugpatrolDir);
18826
19175
  }
18827
19176
  /** Newest first. `latest/` holds loose capture artifacts, not a run. */
18828
19177
  listRunIds() {
18829
19178
  const dir2 = paths.runs(this.root);
18830
- if (!existsSync5(dir2))
19179
+ if (!existsSync6(dir2))
18831
19180
  return [];
18832
19181
  return readdirSync2(dir2, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== "latest").map((e) => ({ name: e.name, mtime: safeMtime(join17(dir2, e.name)) })).sort((a, b) => b.mtime - a.mtime).map((e) => e.name);
18833
19182
  }
18834
19183
  readRun(runId) {
18835
19184
  const dir2 = join17(paths.runs(this.root), runId);
18836
19185
  const runFile = join17(dir2, "run.json");
18837
- if (!existsSync5(runFile))
19186
+ if (!existsSync6(runFile))
18838
19187
  return void 0;
18839
19188
  try {
18840
19189
  const parsed = JSON.parse(readFileSync4(runFile, "utf8"));
18841
19190
  const traceFile = join17(dir2, "trace.json");
18842
- const trace = existsSync5(traceFile) ? JSON.parse(readFileSync4(traceFile, "utf8")) : void 0;
19191
+ const trace = existsSync6(traceFile) ? JSON.parse(readFileSync4(traceFile, "utf8")) : void 0;
18843
19192
  return { id: runId, dir: dir2, run: parsed.run, findings: parsed.findings, trace, mtimeMs: safeMtime(dir2) };
18844
19193
  } catch {
18845
19194
  return void 0;
@@ -18858,7 +19207,7 @@ var ProjectReader = class {
18858
19207
  }
18859
19208
  readAppModel() {
18860
19209
  const file = paths.appModel(this.root);
18861
- if (!existsSync5(file))
19210
+ if (!existsSync6(file))
18862
19211
  return void 0;
18863
19212
  try {
18864
19213
  return JSON.parse(readFileSync4(file, "utf8"));
@@ -18868,7 +19217,7 @@ var ProjectReader = class {
18868
19217
  }
18869
19218
  readIntents() {
18870
19219
  const file = paths.intents(this.root);
18871
- if (!existsSync5(file))
19220
+ if (!existsSync6(file))
18872
19221
  return [];
18873
19222
  try {
18874
19223
  return JSON.parse(readFileSync4(file, "utf8")).intents ?? [];
@@ -18879,7 +19228,7 @@ var ProjectReader = class {
18879
19228
  /** Progress for a run currently in flight, if one is. */
18880
19229
  readLive() {
18881
19230
  const file = paths.live(this.root);
18882
- if (!existsSync5(file))
19231
+ if (!existsSync6(file))
18883
19232
  return void 0;
18884
19233
  try {
18885
19234
  return JSON.parse(readFileSync4(file, "utf8"));
@@ -18889,7 +19238,7 @@ var ProjectReader = class {
18889
19238
  }
18890
19239
  readConfigRaw() {
18891
19240
  const file = paths.config(this.root, this.configFile);
18892
- return existsSync5(file) ? readFileSync4(file, "utf8") : void 0;
19241
+ return existsSync6(file) ? readFileSync4(file, "utf8") : void 0;
18893
19242
  }
18894
19243
  };
18895
19244
  function summarize(record) {
@@ -18923,14 +19272,14 @@ function safeMtime(path) {
18923
19272
  }
18924
19273
 
18925
19274
  // ../dashboard/dist/server.js
18926
- import { createReadStream, existsSync as existsSync6, realpathSync, statSync as statSync3, watch } from "node:fs";
19275
+ import { createReadStream, existsSync as existsSync8, realpathSync, statSync as statSync3, watch } from "node:fs";
18927
19276
  import { createServer as createServer4 } from "node:http";
18928
- import { extname as extname2, join as join19, relative as relative8, resolve as resolve16, sep as sep5 } from "node:path";
19277
+ import { extname as extname2, join as join19, relative as relative10, resolve as resolve16, sep as sep5 } from "node:path";
18929
19278
  import { fileURLToPath } from "node:url";
18930
19279
 
18931
19280
  // ../dashboard/dist/agents.js
18932
- import { readdirSync as readdirSync3, readFileSync as readFileSync5, statSync as statSync2 } from "node:fs";
18933
- import { basename as basename4, dirname as dirname6, extname, join as join18, relative as relative7, sep as sep4 } from "node:path";
19281
+ import { existsSync as existsSync7, readdirSync as readdirSync3, readFileSync as readFileSync5, statSync as statSync2 } from "node:fs";
19282
+ import { basename as basename4, dirname as dirname6, extname, join as join18, relative as relative9, sep as sep4 } from "node:path";
18934
19283
  var roles2 = ["explorer", "judge", "fixer"];
18935
19284
  var severity = { critical: 0, major: 1, minor: 2, cosmetic: 3 };
18936
19285
  var open2 = /* @__PURE__ */ new Set(["new", "filed", "fixing", "fix-proposed"]);
@@ -19064,7 +19413,26 @@ var AgentReader = class {
19064
19413
  const routine = this.routines().find((entry) => entry.id === issue.evidence?.routineId);
19065
19414
  const ids = new Set(issue.candidateIds);
19066
19415
  const candidates = this.sessions(Infinity).flatMap((session) => jsonLines(join18(paths.session(this.root, session.id), "candidates.jsonl"))).filter((candidate) => ids.has(candidate.id));
19067
- return { issue, fix, candidates, routine };
19416
+ return {
19417
+ issue,
19418
+ fix,
19419
+ gaveUpAfter: fix && this.gaveUpAfter(fix),
19420
+ attemptDiffs: fix && this.attemptDiffs(fix),
19421
+ candidates,
19422
+ routine
19423
+ };
19424
+ }
19425
+ /** Each attempt's diff file that exists, by attempt number, as an artifact path. */
19426
+ attemptDiffs(fix) {
19427
+ return Object.fromEntries((fix.attempts ?? []).map((attempt) => [attempt.n, paths.fixAttemptDiff(this.root, fix.id, attempt.n)]).filter(([, file]) => existsSync7(file)).map(([n, file]) => [n, relative9(this.root, file).split(sep4).join("/")]));
19428
+ }
19429
+ /** The fixer's attempt count, when this fix failed and reached the limit. CI fix attempts have their own limit. */
19430
+ gaveUpAfter(fix) {
19431
+ const limit = this.config()?.agents.fixer.attempts;
19432
+ const count = fixerAttempts(fix.attempts).length;
19433
+ if (fix.status !== "failed" || limit === void 0)
19434
+ return void 0;
19435
+ return count >= limit ? count : void 0;
19068
19436
  }
19069
19437
  screens() {
19070
19438
  const map = this.appmap();
@@ -19144,8 +19512,8 @@ var AgentReader = class {
19144
19512
  if (!review)
19145
19513
  return void 0;
19146
19514
  const recordings = walk(paths.reviewDir(this.root, pr)).map((file) => ({ file, kind: recordingKinds[extname(file).toLowerCase()] })).filter((entry) => Boolean(entry.kind)).map(({ file, kind }) => {
19147
- const path = relative7(this.root, file).split(sep4).join("/");
19148
- const inside2 = relative7(paths.reviewDir(this.root, pr), file);
19515
+ const path = relative9(this.root, file).split(sep4).join("/");
19516
+ const inside2 = relative9(paths.reviewDir(this.root, pr), file);
19149
19517
  const claimId = inside2.match(/\bclaim-\d+(?!\d)/)?.[0];
19150
19518
  const build = inside2.match(/\b(head|base)\b/)?.[1];
19151
19519
  return { path, kind, ...claimId ? { claimId } : {}, ...build ? { build } : {} };
@@ -19158,13 +19526,19 @@ var AgentReader = class {
19158
19526
  }
19159
19527
  /** Each role as the config sets it, in the same form as the runtime labels. */
19160
19528
  configuredAgents() {
19529
+ const config = this.config();
19530
+ if (!config)
19531
+ return void 0;
19532
+ return Object.fromEntries(roles2.map((role) => {
19533
+ const { enabled, use } = config.agents[role];
19534
+ const runtime = use.runtime === "cli" ? `cli:${use.command.split(/\s+/)[0]}` : `model:${use.via}/${use.model}`;
19535
+ return [role, { enabled, runtime }];
19536
+ }));
19537
+ }
19538
+ /** Undefined when the project has no config, unless the dashboard was given one. */
19539
+ config() {
19161
19540
  try {
19162
- const config = loadConfig(this.root, {}, this.configFile);
19163
- return Object.fromEntries(roles2.map((role) => {
19164
- const { enabled, use } = config.agents[role];
19165
- const runtime = use.runtime === "cli" ? `cli:${use.command.split(/\s+/)[0]}` : `model:${use.via}/${use.model}`;
19166
- return [role, { enabled, runtime }];
19167
- }));
19541
+ return loadConfig(this.root, {}, this.configFile);
19168
19542
  } catch (error) {
19169
19543
  if (this.configFile)
19170
19544
  throw error;
@@ -19270,9 +19644,10 @@ var MIME = {
19270
19644
  ".webm": "video/webm",
19271
19645
  ".cast": "application/x-asciicast",
19272
19646
  ".svg": "image/svg+xml",
19273
- ".json": "application/json; charset=utf-8"
19647
+ ".json": "application/json; charset=utf-8",
19648
+ ".diff": "text/x-diff; charset=utf-8"
19274
19649
  };
19275
- var ARTIFACTS = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm", ".cast", ".json"];
19650
+ var ARTIFACTS = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm", ".cast", ".json", ".diff"];
19276
19651
  async function startDashboard(options) {
19277
19652
  const root2 = resolve16(options.root);
19278
19653
  const host = options.host ?? "127.0.0.1";
@@ -19383,7 +19758,7 @@ async function handle2(req, res, ctx) {
19383
19758
  }
19384
19759
  function resolveArtifactPath(root2, requested) {
19385
19760
  const candidate = resolve16(root2, requested);
19386
- if (!existsSync6(candidate))
19761
+ if (!existsSync8(candidate))
19387
19762
  return void 0;
19388
19763
  let real;
19389
19764
  let allowed;
@@ -19393,7 +19768,7 @@ function resolveArtifactPath(root2, requested) {
19393
19768
  } catch {
19394
19769
  return void 0;
19395
19770
  }
19396
- const rel = relative8(allowed, real);
19771
+ const rel = relative10(allowed, real);
19397
19772
  if (rel === "" || rel.startsWith("..") || rel.startsWith(`..${sep5}`))
19398
19773
  return void 0;
19399
19774
  const stat4 = statSync3(real);
@@ -19444,7 +19819,7 @@ function byteRange(header, size) {
19444
19819
  function serveUi(res, path) {
19445
19820
  const name = path === "/" ? "index.html" : path.replace(/^\//, "");
19446
19821
  const file = resolve16(UI_DIR, name);
19447
- if (!file.startsWith(UI_DIR) || !existsSync6(file) || !statSync3(file).isFile()) {
19822
+ if (!file.startsWith(UI_DIR) || !existsSync8(file) || !statSync3(file).isFile()) {
19448
19823
  serveUi(res, "/");
19449
19824
  return;
19450
19825
  }
@@ -19485,7 +19860,7 @@ function watchProject(root2, onChange) {
19485
19860
  timer = setTimeout(onChange, 250);
19486
19861
  };
19487
19862
  const attach = () => {
19488
- if (!existsSync6(dir2))
19863
+ if (!existsSync8(dir2))
19489
19864
  return;
19490
19865
  try {
19491
19866
  const next = watch(dir2, { recursive: true }, changed);
@@ -19502,17 +19877,17 @@ function watchProject(root2, onChange) {
19502
19877
  }
19503
19878
  };
19504
19879
  try {
19505
- watcher = existsSync6(dir2) ? watch(dir2, { recursive: true }, changed) : watch(root2, { recursive: false }, () => {
19506
- if (existsSync6(dir2))
19880
+ watcher = existsSync8(dir2) ? watch(dir2, { recursive: true }, changed) : watch(root2, { recursive: false }, () => {
19881
+ if (existsSync8(dir2))
19507
19882
  attach();
19508
19883
  });
19509
19884
  watcher.on("error", () => {
19510
19885
  watcher.close();
19511
19886
  fallback = setInterval(changed, 5e3);
19512
19887
  });
19513
- if (!existsSync6(dir2))
19888
+ if (!existsSync8(dir2))
19514
19889
  probe = setInterval(() => {
19515
- if (existsSync6(dir2))
19890
+ if (existsSync8(dir2))
19516
19891
  attach();
19517
19892
  }, 100);
19518
19893
  return {
@@ -19590,7 +19965,7 @@ import { chmod, mkdir as mkdir9, writeFile as writeFile12 } from "node:fs/promis
19590
19965
  import { join as join22, resolve as resolve17 } from "node:path";
19591
19966
 
19592
19967
  // ../cli/dist/commands/run.js
19593
- import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "node:fs";
19968
+ import { existsSync as existsSync10, mkdirSync as mkdirSync5, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "node:fs";
19594
19969
  import { join as join21 } from "node:path";
19595
19970
 
19596
19971
  // ../capture/dist/app-server.js
@@ -20279,7 +20654,7 @@ function severityRank2(f) {
20279
20654
  }
20280
20655
 
20281
20656
  // ../triage/dist/ledger.js
20282
- import { existsSync as existsSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "node:fs";
20657
+ import { existsSync as existsSync9, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "node:fs";
20283
20658
  var IntentLedger = class _IntentLedger {
20284
20659
  intents;
20285
20660
  constructor(intents = []) {
@@ -20287,7 +20662,7 @@ var IntentLedger = class _IntentLedger {
20287
20662
  }
20288
20663
  static load(root2) {
20289
20664
  const file = paths.intents(root2);
20290
- if (!existsSync7(file))
20665
+ if (!existsSync9(file))
20291
20666
  return new _IntentLedger([]);
20292
20667
  const parsed = JSON.parse(readFileSync6(file, "utf8"));
20293
20668
  return new _IntentLedger(parsed.intents.map((i) => ({
@@ -20881,7 +21256,7 @@ async function captureOne(args) {
20881
21256
  });
20882
21257
  const key = baselineKeyFor(target.id, viewport.name);
20883
21258
  const baselinePath = store.pathFor(key);
20884
- if (!baselinePath || !existsSync8(baselinePath)) {
21259
+ if (!baselinePath || !existsSync10(baselinePath)) {
20885
21260
  store.put(key, viewport.name, readFileSync7(actualPath));
20886
21261
  saveBaselineSnapshot(args.root, key, output2.snapshot);
20887
21262
  return {
@@ -20941,7 +21316,7 @@ function resolveTargets(config, model, options) {
20941
21316
  }
20942
21317
  function loadAppModel(root2) {
20943
21318
  const file = paths.appModel(root2);
20944
- if (!existsSync8(file))
21319
+ if (!existsSync10(file))
20945
21320
  return void 0;
20946
21321
  try {
20947
21322
  return JSON.parse(readFileSync7(file, "utf8"));
@@ -20951,7 +21326,7 @@ function loadAppModel(root2) {
20951
21326
  }
20952
21327
  function loadBaselineSnapshot(root2, key) {
20953
21328
  const file = snapshotFile(root2, key);
20954
- if (!existsSync8(file))
21329
+ if (!existsSync10(file))
20955
21330
  return void 0;
20956
21331
  try {
20957
21332
  return JSON.parse(readFileSync7(file, "utf8"));
@@ -21090,7 +21465,7 @@ function preflight(command, config, env = process.env) {
21090
21465
  if (problems.length)
21091
21466
  throw new ConfigError(problems.join("\n"));
21092
21467
  }
21093
- async function runAgentCommand(command, args, root2, config, log) {
21468
+ async function runAgentCommand(command, args, root2, config, log, configFile) {
21094
21469
  const flags = parseAgentFlags(command, args);
21095
21470
  preflight(command, config);
21096
21471
  const workspace = new Workspace(root2);
@@ -21170,7 +21545,8 @@ ${problems.map((problem) => `- ${problem.split("\n")[0]}`).join("\n")}`);
21170
21545
  allowFork: Boolean(flags.allowFork),
21171
21546
  maxSteps: flags.steps,
21172
21547
  // Without the flag, agents.review.claims decides.
21173
- claims: flags.claims ? true : void 0
21548
+ claims: flags.claims ? true : void 0,
21549
+ configFile
21174
21550
  });
21175
21551
  const count = (verdict) => review.findings.filter((finding) => finding.verdict === verdict).length;
21176
21552
  const claims = review.claims ? `${review.claims.length} claim(s), ` : "";
@@ -21223,7 +21599,7 @@ ${problems.map((problem) => `- ${problem.split("\n")[0]}`).join("\n")}`);
21223
21599
  const result3 = await retestFix(root2, config, issue, fix, (fix.retests?.length ?? 0) + 1, { onLog: log });
21224
21600
  applyRetest(config, fix, result3);
21225
21601
  await workspace.saveFix(fix);
21226
- log(`Retest: ${result3.outcome} \u2014 ${result3.reason}`);
21602
+ log(`Retest: ${result3.outcome}: ${result3.reason}`);
21227
21603
  return;
21228
21604
  }
21229
21605
  let app;
@@ -21289,13 +21665,13 @@ ${problems.map((problem) => `- ${problem.split("\n")[0]}`).join("\n")}`);
21289
21665
 
21290
21666
  // ../cli/dist/commands/doctor.js
21291
21667
  import { spawnSync } from "node:child_process";
21292
- import { existsSync as existsSync9 } from "node:fs";
21668
+ import { existsSync as existsSync11 } from "node:fs";
21293
21669
  function runChecks(root2, config) {
21294
21670
  const checks = [];
21295
21671
  checks.push({
21296
21672
  name: "config",
21297
- ok: existsSync9(paths.config(root2)),
21298
- detail: existsSync9(paths.config(root2)) ? `${paths.config(root2)} found` : legacyLayout(root2) ?? "No .bugpatrol/bugpatrol.yml. Run `bugpatrol init`.",
21673
+ ok: existsSync11(paths.config(root2)),
21674
+ detail: existsSync11(paths.config(root2)) ? `${paths.config(root2)} found` : legacyLayout(root2) ?? "No .bugpatrol/bugpatrol.yml. Run `bugpatrol init`.",
21299
21675
  fatal: true
21300
21676
  });
21301
21677
  const nodeMajor = Number(process.versions.node.split(".")[0]);
@@ -21345,14 +21721,14 @@ function runChecks(root2, config) {
21345
21721
  });
21346
21722
  checks.push({
21347
21723
  name: "app-model",
21348
- ok: existsSync9(paths.appModel(root2)),
21349
- detail: existsSync9(paths.appModel(root2)) ? "AppModel present" : "No AppModel. Run `bugpatrol recon`.",
21724
+ ok: existsSync11(paths.appModel(root2)),
21725
+ detail: existsSync11(paths.appModel(root2)) ? "AppModel present" : "No AppModel. Run `bugpatrol recon`.",
21350
21726
  fatal: false
21351
21727
  });
21352
21728
  checks.push({
21353
21729
  name: "intent-ledger",
21354
21730
  ok: true,
21355
- detail: existsSync9(paths.intents(root2)) ? "Intent Ledger present" : "No Intent Ledger yet (created on first accept)",
21731
+ detail: existsSync11(paths.intents(root2)) ? "Intent Ledger present" : "No Intent Ledger yet (created on first accept)",
21356
21732
  fatal: false
21357
21733
  });
21358
21734
  return checks;
@@ -21384,12 +21760,12 @@ function doctorExitCode(checks) {
21384
21760
 
21385
21761
  // ../cli/dist/commands/init.js
21386
21762
  import { spawnSync as spawnSync2 } from "node:child_process";
21387
- import { appendFileSync, existsSync as existsSync11, mkdirSync as mkdirSync6, readdirSync as readdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "node:fs";
21388
- import { join as join24, relative as relative9 } from "node:path";
21763
+ import { appendFileSync, existsSync as existsSync13, mkdirSync as mkdirSync6, readdirSync as readdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "node:fs";
21764
+ import { join as join24, relative as relative11 } from "node:path";
21389
21765
  import { createInterface } from "node:readline/promises";
21390
21766
 
21391
21767
  // ../recon/dist/bringup.js
21392
- import { existsSync as existsSync10, readdirSync as readdirSync4, readFileSync as readFileSync8 } from "node:fs";
21768
+ import { existsSync as existsSync12, readdirSync as readdirSync4, readFileSync as readFileSync8 } from "node:fs";
21393
21769
  import { join as join23 } from "node:path";
21394
21770
  function detectBringUp(root2) {
21395
21771
  const candidates = [];
@@ -21406,7 +21782,7 @@ function detectBringUp(root2) {
21406
21782
  }
21407
21783
  }
21408
21784
  const pkgPath = join23(root2, "package.json");
21409
- if (existsSync10(pkgPath)) {
21785
+ if (existsSync12(pkgPath)) {
21410
21786
  const pkg = JSON.parse(readFileSync8(pkgPath, "utf8"));
21411
21787
  const manager = detectPackageManager(root2);
21412
21788
  for (const script of ["dev", "start", "preview", "serve"]) {
@@ -21420,7 +21796,7 @@ function detectBringUp(root2) {
21420
21796
  }
21421
21797
  }
21422
21798
  }
21423
- if (existsSync10(join23(root2, "docker-compose.yml")) || existsSync10(join23(root2, "compose.yaml"))) {
21799
+ if (existsSync12(join23(root2, "docker-compose.yml")) || existsSync12(join23(root2, "compose.yaml"))) {
21424
21800
  candidates.push({
21425
21801
  command: "docker compose up",
21426
21802
  rung: "convention",
@@ -21428,17 +21804,17 @@ function detectBringUp(root2) {
21428
21804
  confidence: 0.5
21429
21805
  });
21430
21806
  }
21431
- if (existsSync10(join23(root2, "Procfile"))) {
21807
+ if (existsSync12(join23(root2, "Procfile"))) {
21432
21808
  candidates.push({ command: "foreman start", rung: "convention", source: "Procfile", confidence: 0.3 });
21433
21809
  }
21434
21810
  return candidates.sort((a, b) => b.confidence - a.confidence);
21435
21811
  }
21436
21812
  function detectPackageManager(root2) {
21437
- if (existsSync10(join23(root2, "pnpm-lock.yaml")))
21813
+ if (existsSync12(join23(root2, "pnpm-lock.yaml")))
21438
21814
  return "pnpm";
21439
- if (existsSync10(join23(root2, "bun.lockb")))
21815
+ if (existsSync12(join23(root2, "bun.lockb")))
21440
21816
  return "bun";
21441
- if (existsSync10(join23(root2, "yarn.lock")))
21817
+ if (existsSync12(join23(root2, "yarn.lock")))
21442
21818
  return "yarn";
21443
21819
  return "npm";
21444
21820
  }
@@ -21459,7 +21835,7 @@ function detectStack(root2) {
21459
21835
  ["app.json", "expo"]
21460
21836
  ];
21461
21837
  for (const [file, name] of signatures) {
21462
- if (!existsSync10(join23(root2, file)))
21838
+ if (!existsSync12(join23(root2, file)))
21463
21839
  continue;
21464
21840
  framework ??= name;
21465
21841
  detectedFrom.push(file);
@@ -21475,7 +21851,7 @@ function detectStack(root2) {
21475
21851
  }
21476
21852
  function listWorkflows(root2) {
21477
21853
  const dir2 = join23(root2, ".github", "workflows");
21478
- if (!existsSync10(dir2))
21854
+ if (!existsSync12(dir2))
21479
21855
  return [];
21480
21856
  try {
21481
21857
  return readdirSync4(dir2).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml")).map((f) => join23(dir2, f));
@@ -21553,7 +21929,7 @@ function xcodeProjects(root2) {
21553
21929
  return [".", "ios"].flatMap((dir2) => list2(join24(root2, dir2)).filter((name) => name.endsWith(".xcodeproj")).map((name) => join24(root2, dir2, name, "project.pbxproj")));
21554
21930
  }
21555
21931
  function gradleFiles(root2) {
21556
- return ["android/app", "app"].flatMap((dir2) => ["build.gradle", "build.gradle.kts"].map((file) => join24(root2, dir2, file))).filter((file) => existsSync11(file));
21932
+ return ["android/app", "app"].flatMap((dir2) => ["build.gradle", "build.gradle.kts"].map((file) => join24(root2, dir2, file))).filter((file) => existsSync13(file));
21557
21933
  }
21558
21934
  function detectAppId(root2, platform) {
21559
21935
  const expo = readJson3(join24(root2, "app.json"))?.expo;
@@ -21600,7 +21976,7 @@ function detectPort(root2, pkg, framework) {
21600
21976
  };
21601
21977
  }
21602
21978
  function mobilePlatform(root2) {
21603
- const hasIos = xcodeProjects(root2).length > 0 || existsSync11(join24(root2, "app.json")) || existsSync11(join24(root2, "ios"));
21979
+ const hasIos = xcodeProjects(root2).length > 0 || existsSync13(join24(root2, "app.json")) || existsSync13(join24(root2, "ios"));
21604
21980
  return process.platform === "darwin" && hasIos ? "ios" : "android";
21605
21981
  }
21606
21982
  function detectApp(root2) {
@@ -21612,8 +21988,8 @@ function detectApp(root2) {
21612
21988
  return { platform: "electron", start: "npx electron . --remote-debugging-port=9222", cdpPort: 9222, notes };
21613
21989
  }
21614
21990
  const reactNative = deps.expo || deps["react-native"];
21615
- const nativeIos = !existsSync11(join24(root2, "package.json")) && xcodeProjects(root2).length > 0;
21616
- const nativeAndroid = !existsSync11(join24(root2, "package.json")) && gradleFiles(root2).length > 0;
21991
+ const nativeIos = !existsSync13(join24(root2, "package.json")) && xcodeProjects(root2).length > 0;
21992
+ const nativeAndroid = !existsSync13(join24(root2, "package.json")) && gradleFiles(root2).length > 0;
21617
21993
  if (reactNative || nativeIos || nativeAndroid) {
21618
21994
  const platform = nativeIos ? "ios" : nativeAndroid ? "android" : mobilePlatform(root2);
21619
21995
  notes.push(`Platform ${platform}: detected from ${reactNative ? "package.json" : platform === "ios" ? "the Xcode project" : "the Gradle files"}.`);
@@ -21717,7 +22093,7 @@ Example: Sign in with the email {{TEST_EMAIL}} and the password {{TEST_PASSWORD}
21717
22093
  `;
21718
22094
  function ignoreData(root2) {
21719
22095
  const gitignore = join24(root2, ".gitignore");
21720
- const ignored = existsSync11(gitignore) ? readFileSync9(gitignore, "utf8") : "";
22096
+ const ignored = existsSync13(gitignore) ? readFileSync9(gitignore, "utf8") : "";
21721
22097
  const lines = ignored.split("\n");
21722
22098
  const { dir: dir2 } = layout(root2);
21723
22099
  const line = `${dir2}/${DATA_DIR}/`;
@@ -21735,7 +22111,7 @@ ${line}
21735
22111
  return true;
21736
22112
  }
21737
22113
  function relativeConfig(root2) {
21738
- const shown = relative9(process.cwd(), paths.config(root2));
22114
+ const shown = relative11(process.cwd(), paths.config(root2));
21739
22115
  return shown && !shown.startsWith("..") ? shown : paths.config(root2);
21740
22116
  }
21741
22117
  function writeInitialConfig(root2, answers) {
@@ -21743,14 +22119,14 @@ function writeInitialConfig(root2, answers) {
21743
22119
  mkdirSync6(paths.dir(root2), { recursive: true });
21744
22120
  const configPath = paths.config(root2);
21745
22121
  const configName = `${BUGPATROL_DIR}/${CONFIG_FILENAME}`;
21746
- if (existsSync11(configPath))
22122
+ if (existsSync13(configPath))
21747
22123
  result3.skipped.push(configName);
21748
22124
  else {
21749
22125
  writeFileSync6(configPath, renderConfig(answers));
21750
22126
  result3.written.push(configName);
21751
22127
  }
21752
22128
  const guidePath = join24(paths.dir(root2), "instructions.md");
21753
- if (existsSync11(guidePath))
22129
+ if (existsSync13(guidePath))
21754
22130
  result3.skipped.push(`${BUGPATROL_DIR}/instructions.md`);
21755
22131
  else {
21756
22132
  writeFileSync6(guidePath, INSTRUCTIONS);
@@ -21922,7 +22298,7 @@ async function runInit(root2, args, log) {
21922
22298
  log("\nNext: review the TODO markers, then run `bugpatrol doctor`.");
21923
22299
  return;
21924
22300
  }
21925
- if (existsSync11(paths.config(root2))) {
22301
+ if (existsSync13(paths.config(root2))) {
21926
22302
  throw new ConfigError(`${relativeConfig(root2)} already exists. Edit it, or delete it and run init again.`);
21927
22303
  }
21928
22304
  const legacy = legacyLayout(root2);
@@ -22004,9 +22380,6 @@ run:
22004
22380
  timeoutMs: 60000
22005
22381
  seeds: []
22006
22382
 
22007
- auth:
22008
- kind: none # none | form | storageState | seededUser | ssoBypass | manual
22009
-
22010
22383
  viewports:
22011
22384
  - { name: desktop, width: 1440, height: 900 }
22012
22385
  - { name: mobile, width: 390, height: 844 }
@@ -22062,7 +22435,7 @@ production:
22062
22435
  `;
22063
22436
  const configPath = paths.config(root2);
22064
22437
  mkdirSync6(paths.dir(root2), { recursive: true });
22065
- if (!existsSync11(configPath))
22438
+ if (!existsSync13(configPath))
22066
22439
  writeFileSync6(configPath, config);
22067
22440
  else
22068
22441
  notes.push(`${relativeConfig(root2)} already exists and was left untouched.`);
@@ -22110,7 +22483,7 @@ ${issues.length} issue(s). Status: new = filed by the judge, not on GitHub; file
22110
22483
  role: "judge",
22111
22484
  source: "human",
22112
22485
  scope: issue.screenId,
22113
- text: `Not a bug: ${issue.title} \u2014 ${reason2}`.slice(0, 200)
22486
+ text: fitLesson(`Not a bug: ${issue.title} \u2014 ${reason2}`)
22114
22487
  }
22115
22488
  ]);
22116
22489
  log(`Dismissed ${issue.id}: ${issue.title}`);
@@ -22165,6 +22538,9 @@ async function runMemoryCommand(args, root2, log) {
22165
22538
  const text3 = args.slice(1).find((part, index, all) => !part.startsWith("--") && all[index - 1] !== "--role" && all[index - 1] !== "--scope");
22166
22539
  if (!role || !roles3.has(role) || !text3?.trim())
22167
22540
  throw new ConfigError('Usage: bugpatrol memory add --role explorer|judge|fixer "text" [--scope s]');
22541
+ const tooLong = lessonTooLong(text3);
22542
+ if (tooLong)
22543
+ throw new ConfigError(tooLong);
22168
22544
  const [lesson] = await workspace.upsertLessons([
22169
22545
  { role, source: "human", scope: flag(args.slice(1), "--scope"), text: text3 }
22170
22546
  ]);
@@ -22361,7 +22737,7 @@ var COMMAND_HELP = {
22361
22737
  init: `bugpatrol init [flags]
22362
22738
  Find the app and the LLMs, and write .bugpatrol/bugpatrol.yml and .bugpatrol/instructions.md.
22363
22739
  --yes, -y use the detected values, with no questions
22364
- --platform <p> web | electron | ios | android
22740
+ --platform <p> web | electron | ios | android | api | desktop | cli
22365
22741
  --start "<command>" the command that starts the app
22366
22742
  --url <url> web: the app URL
22367
22743
  --app-id <id> ios, android: the bundle ID or the package name
@@ -22461,7 +22837,7 @@ legacyEnv(process.env);
22461
22837
  var root = findProjectRoot(process.cwd());
22462
22838
  function version() {
22463
22839
  if (true)
22464
- return "0.2.1";
22840
+ return "0.3.0";
22465
22841
  try {
22466
22842
  return JSON.parse(readFileSync10(new URL("../package.json", import.meta.url), "utf8")).version;
22467
22843
  } catch {
@@ -22539,7 +22915,7 @@ try {
22539
22915
  case "replay": {
22540
22916
  const config = readConfig();
22541
22917
  await runAgentCommand(command, args, root, config, (message2) => process.stdout.write(`${message2}
22542
- `));
22918
+ `), selected.configFile);
22543
22919
  break;
22544
22920
  }
22545
22921
  case "promote": {
@@ -22606,7 +22982,7 @@ try {
22606
22982
  onReady: (url) => {
22607
22983
  process.stdout.write(`Bugpatrol dashboard on ${url}
22608
22984
  `);
22609
- process.stdout.write("Watching .bugpatrol/runs/ \u2014 runs appear as they finish. Ctrl-C to stop.\n");
22985
+ process.stdout.write("Watching .bugpatrol/runs/: runs appear as they finish. Ctrl-C to stop.\n");
22610
22986
  }
22611
22987
  });
22612
22988
  const stop = () => {