bughunters 0.1.1 → 0.1.2

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
@@ -197,8 +197,6 @@ var toleranceSchema = z.object({
197
197
  })).default([])
198
198
  }).default({});
199
199
  var decisionsSchema = z.object({
200
- decider: z.enum(["jev", "model"]).default("jev"),
201
- jev: z.object({ via: z.enum(["auto", "typesafe", "openrouter", "vercel"]).default("auto") }).default({}),
202
200
  model: z.object({
203
201
  via: z.enum(["auto", "openrouter", "vercel", "openai", "anthropic", "custom"]).default("auto"),
204
202
  name: z.string().default("")
@@ -310,17 +308,39 @@ function runtimeFor(role, fallback) {
310
308
  var roleBase = {
311
309
  enabled: z.boolean().default(true),
312
310
  maxSteps: z.number().int().positive().default(60),
313
- budgetUsd: z.number().nonnegative().default(0.5),
311
+ /** A cost limit for one session, in USD. No default: the team decides. */
312
+ budgetUsd: z.number().nonnegative().optional(),
314
313
  timeoutMs: z.number().int().positive().default(20 * 60 * 1e3)
315
314
  };
315
+ var AGENT_CHECKS = [
316
+ "usability/contrast",
317
+ "usability/tap-target",
318
+ "layout/overlap",
319
+ "layout/overflow",
320
+ "layout/occlusion",
321
+ "layout/zero-size-interactive",
322
+ "layout/off-viewport",
323
+ "layout/horizontal-scroll",
324
+ "layout/shift-versus-baseline",
325
+ "rendering/broken-imagery",
326
+ "rendering/unstyled-content",
327
+ "pixel-diff"
328
+ ];
316
329
  var agentsSchema = z.object({
330
+ /** Automatic checks on each recorded screen, for example [usability/contrast, usability/tap-target]. */
331
+ checks: z.array(z.enum(AGENT_CHECKS)).default([]),
317
332
  memory: z.object({
318
333
  enabled: z.boolean().default(true),
319
334
  maxPerSession: z.number().int().positive().default(5),
320
335
  reflectMaxSteps: z.number().int().positive().default(10),
321
- reflectBudgetUsd: z.number().nonnegative().default(0.05)
336
+ reflectBudgetUsd: z.number().nonnegative().optional()
337
+ }).default({}),
338
+ explorer: z.object({
339
+ ...roleBase,
340
+ use: runtimeFor("explorer", { runtime: "model" }),
341
+ /** Full coverage needs many actions: one step is one tool call. */
342
+ maxSteps: z.number().int().positive().default(150)
322
343
  }).default({}),
323
- explorer: z.object({ ...roleBase, use: runtimeFor("explorer", { runtime: "model" }) }).default({}),
324
344
  judge: z.object({
325
345
  ...roleBase,
326
346
  use: runtimeFor("judge", { runtime: "model" })
@@ -340,7 +360,7 @@ var agentsSchema = z.object({
340
360
  /** Fix attempts in total; each attempt after the first gets the last verdict as feedback. */
341
361
  attempts: z.number().int().positive().default(2),
342
362
  maxSteps: z.number().int().positive().default(30),
343
- budgetUsd: z.number().nonnegative().default(0.2)
363
+ budgetUsd: z.number().nonnegative().optional()
344
364
  }).default({}),
345
365
  minSeverity: z.enum(["cosmetic", "minor", "major", "critical"]).default("minor"),
346
366
  /**
@@ -359,7 +379,15 @@ var agentsSchema = z.object({
359
379
  assetsBranch: z.string().default("bughunters-assets"),
360
380
  labels: z.array(z.string()).default(["bughunters"]),
361
381
  /** The scope in PR titles, e.g. 'app'. Default: the scope in fixer.commitMessage. */
362
- prScope: z.string().optional()
382
+ prScope: z.string().optional(),
383
+ /** After a PR opens, wait for its CI checks, and let the fixer fix a failed check. */
384
+ ci: z.object({
385
+ enabled: z.boolean().default(true),
386
+ /** Fixer attempts for each PR before Bughunters gives up and tells the team. */
387
+ attempts: z.number().int().nonnegative().default(2),
388
+ /** How long one cycle waits for pending checks. */
389
+ waitMinutes: z.number().nonnegative().default(20)
390
+ }).default({})
363
391
  }).default({}),
364
392
  patrol: z.object({
365
393
  intervalMinutes: z.number().positive().default(30),
@@ -700,6 +728,7 @@ Agents
700
728
  bughunters fix [--issue <id>...]
701
729
  bughunters retest --issue <id>
702
730
  bughunters publish [--issue <id>] [--dry-run]
731
+ bughunters ci [--issue <id>] [--wait]
703
732
  bughunters github sync
704
733
  bughunters worktrees clean
705
734
  bughunters patrol [--once] [--force]
@@ -748,7 +777,6 @@ var COMMAND_HELP = {
748
777
  --app-id <id> ios, android: the bundle ID or the package name
749
778
  --agent <a> the LLM for all agents: claude | codex | kimi | pi | openrouter | vercel | openai | anthropic
750
779
  --explorer, --judge, --fixer <a> the LLM for one agent
751
- --jev <route> auto | typesafe | openrouter | vercel
752
780
  --gate write a starter config for the deterministic web gate
753
781
  `,
754
782
  explore: `bughunters explore [--goal "..."] [--steps N]
@@ -776,13 +804,21 @@ var COMMAND_HELP = {
776
804
  Needs agents.github.enabled: true and a logged-in gh CLI.
777
805
  --dry-run write the reports to .bughunters/runs/publish/, and open nothing
778
806
  --issue <id> publish this issue only
807
+ `,
808
+ ci: `bughunters ci [--issue <id>]... [--wait]
809
+ Read the CI checks of each open Bughunters PR. When a check fails, the fixer gets its log,
810
+ and Bughunters pushes the new commit to the PR branch. After agents.github.ci.attempts tries, it stops.
811
+ The patrol runs this step after publish.
812
+ --wait wait for the running checks, up to agents.github.ci.waitMinutes
813
+ --issue <id> only the PR of this issue
779
814
  `,
780
815
  patrol: `bughunters patrol [--once] [--force]
781
816
  Run the full cycle again and again: setup, explore, judge, teardown, fix, retest, publish.
782
817
  The fixer and GitHub steps run only when you turn them on.
783
- A cycle runs only when the commit changed since the last full cycle. Otherwise it only syncs GitHub.
818
+ Explore and judge run only when the commit changed since the last full cycle. The fixes, retests,
819
+ publish, and CI checks run in each cycle.
784
820
  --once run one cycle, then stop
785
- --force run the first cycle also when the commit did not change
821
+ --force explore and judge in the first cycle also when the commit did not change
786
822
  `,
787
823
  github: `bughunters github sync
788
824
  Read the state of each PR and issue from GitHub. A closed PR becomes a lesson. An issue closed as not planned becomes a dismissal.
@@ -1044,8 +1080,8 @@ function serialized(file, update) {
1044
1080
  }
1045
1081
  async function listJson(dir) {
1046
1082
  try {
1047
- const files = (await readdir(dir)).filter((file) => file.endsWith(".json"));
1048
- return Promise.all(files.map(async (file) => await readJson(join3(dir, file))));
1083
+ const files2 = (await readdir(dir)).filter((file) => file.endsWith(".json"));
1084
+ return Promise.all(files2.map(async (file) => await readJson(join3(dir, file))));
1049
1085
  } catch (error) {
1050
1086
  if (error.code === "ENOENT") {
1051
1087
  return [];
@@ -1229,8 +1265,8 @@ var Workspace = class {
1229
1265
  async saveScreenshot(sessionId, png, label) {
1230
1266
  const dir = paths.session(this.root, sessionId);
1231
1267
  await mkdir(dir, { recursive: true });
1232
- const files = await readdir(dir);
1233
- const number = files.filter((file2) => /^\d{3}-.*\.png$/.test(file2)).length + 1;
1268
+ const files2 = await readdir(dir);
1269
+ const number = files2.filter((file2) => /^\d{3}-.*\.png$/.test(file2)).length + 1;
1234
1270
  const file = join3(dir, `${String(number).padStart(3, "0")}-${basename(label).replace(/[^a-zA-Z0-9_-]/g, "-")}.png`);
1235
1271
  await writeFile(file, png);
1236
1272
  return relative(this.root, file);
@@ -1463,64 +1499,7 @@ function toMcp(part) {
1463
1499
  import { accessSync, constants } from "node:fs";
1464
1500
  import { delimiter, join as join5 } from "node:path";
1465
1501
 
1466
- // ../decide/dist/providers/jev.js
1467
- var JevDecider = class {
1468
- options;
1469
- name = "jev";
1470
- endpoint;
1471
- model;
1472
- lastUsage;
1473
- timeoutMs;
1474
- constructor(options) {
1475
- this.options = options;
1476
- this.endpoint = options.endpoint ?? "https://api.typesafe.ai/v1/systemone";
1477
- this.model = options.model ?? "jev-latest";
1478
- this.timeoutMs = options.timeoutMs ?? 1e4;
1479
- }
1480
- async ask(state, questions) {
1481
- const controller = new AbortController();
1482
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
1483
- try {
1484
- const response3 = await fetch(this.endpoint, {
1485
- method: "POST",
1486
- headers: {
1487
- "content-type": "application/json",
1488
- authorization: `Bearer ${this.options.apiKey}`
1489
- },
1490
- // Every question is evaluated against the same state in one call, so
1491
- // the entire per-screen analysis is a single round trip.
1492
- body: JSON.stringify({
1493
- model: this.model,
1494
- state,
1495
- questions: Object.fromEntries(Object.entries(questions).map(([id2, question]) => [
1496
- id2,
1497
- question.type === "score" ? { type: "score", instructions: question.instructions, criteria: question.legend } : question
1498
- ]))
1499
- }),
1500
- signal: controller.signal
1501
- });
1502
- if (!response3.ok) {
1503
- throw new InfrastructureError(`Jev returned ${response3.status}: ${await safeText(response3)}`);
1504
- }
1505
- const body = await response3.json();
1506
- this.lastUsage = usageFrom(body.usage) ?? { input: Math.ceil(state.length / 4), output: 0, estimated: true };
1507
- return normalizeAnswers(body.answers ?? {}, questions);
1508
- } catch (cause) {
1509
- if (cause instanceof InfrastructureError)
1510
- throw cause;
1511
- throw new InfrastructureError("Jev request failed", { cause });
1512
- } finally {
1513
- clearTimeout(timer);
1514
- }
1515
- }
1516
- };
1517
- async function safeText(response3) {
1518
- try {
1519
- return (await response3.text()).slice(0, 400);
1520
- } catch {
1521
- return "<no body>";
1522
- }
1523
- }
1502
+ // ../decide/dist/answers.js
1524
1503
  function normalizeAnswers(raw, questions) {
1525
1504
  const out = {};
1526
1505
  for (const [key, question] of Object.entries(questions)) {
@@ -1947,19 +1926,11 @@ var Budget = class {
1947
1926
  return { spentUsd: this.spent, capUsd: this.capUsd };
1948
1927
  }
1949
1928
  };
1950
- var JEV_USD_PER_INPUT_TOKEN = 0.042 / 1e6;
1951
- function estimateDecisionCost(stateChars, decider = "jev") {
1952
- if (decider === "jev")
1953
- return stateChars / 4 * JEV_USD_PER_INPUT_TOKEN;
1929
+ function estimateDecisionCost(stateChars) {
1954
1930
  return ((stateChars / 4 + 1500) * 3 + 900 * 15) / 1e6;
1955
1931
  }
1956
1932
 
1957
1933
  // ../decide/dist/index.js
1958
- var JEV_ROUTES = {
1959
- typesafe: { key: "TYPESAFE_API_KEY", endpoint: "https://api.typesafe.ai/v1/systemone", model: "jev-latest" },
1960
- openrouter: { key: "OPENROUTER_API_KEY", endpoint: "https://openrouter.ai/api/v1/systemone", model: "jev-latest" },
1961
- vercel: { key: "AI_GATEWAY_API_KEY", endpoint: "https://ai-gateway.vercel.sh/typesafe/v1/systemone", model: "typesafe-ai/jev" }
1962
- };
1963
1934
  var MODEL_KEYS = {
1964
1935
  openrouter: "OPENROUTER_API_KEY",
1965
1936
  vercel: "AI_GATEWAY_API_KEY",
@@ -1967,24 +1938,8 @@ var MODEL_KEYS = {
1967
1938
  anthropic: "ANTHROPIC_API_KEY",
1968
1939
  custom: "BUGHUNTERS_MODEL_API_KEY"
1969
1940
  };
1970
- var jevOrder = ["typesafe", "openrouter", "vercel"];
1971
1941
  var modelOrder = ["openrouter", "vercel", "openai", "anthropic", "custom"];
1972
1942
  function resolveDecider(config, env) {
1973
- let jevMissing = "";
1974
- if (config.decider === "jev") {
1975
- const routes2 = config.jev.via === "auto" ? jevOrder : [config.jev.via];
1976
- for (const via of routes2) {
1977
- const route2 = JEV_ROUTES[via];
1978
- const key = env[route2.key];
1979
- if (key)
1980
- return {
1981
- decider: new JevDecider({ apiKey: key, endpoint: route2.endpoint, model: route2.model }),
1982
- via: `jev:${via}`,
1983
- reason: `Decider: jev via ${via} (${route2.model}).`
1984
- };
1985
- }
1986
- jevMissing = `decider is jev, but no ${routes2.map((via) => JEV_ROUTES[via].key).join(", ").replace(/, ([^,]*)$/, " or $1")} is set`;
1987
- }
1988
1943
  const routes = config.model.via === "auto" ? modelOrder : [config.model.via];
1989
1944
  for (const via of routes) {
1990
1945
  const key = env[MODEL_KEYS[via]];
@@ -1995,14 +1950,11 @@ function resolveDecider(config, env) {
1995
1950
  return {
1996
1951
  decider: new ModelDecider({ via, apiKey: key, endpoint, model: name }),
1997
1952
  via: `model:${via}`,
1998
- reason: jevMissing ? `Decider: model via ${via} (${name}) because ${jevMissing}.` : `Decider: model via ${via} (${name}).`
1953
+ reason: `Decider: model via ${via} (${name}).`
1999
1954
  };
2000
1955
  }
2001
- const modelMissing = routes.map((via) => via === "custom" ? "BUGHUNTERS_MODEL_ENDPOINT and BUGHUNTERS_MODEL_API_KEY" : MODEL_KEYS[via]).join(", ").replace(/, ([^,]*)$/, " or $1");
2002
- return {
2003
- via: "none",
2004
- reason: `Decider: none, so the judge decides each finding, because ${jevMissing ? `${jevMissing}, and ` : ""}no ${modelMissing} is set. Set a Jev key to cut cost.`
2005
- };
1956
+ const missing2 = routes.map((via) => via === "custom" ? "BUGHUNTERS_MODEL_ENDPOINT and BUGHUNTERS_MODEL_API_KEY" : MODEL_KEYS[via]).join(", ").replace(/, ([^,]*)$/, " or $1");
1957
+ return { via: "none", reason: `Decider: none, because no ${missing2} is set.` };
2006
1958
  }
2007
1959
 
2008
1960
  // ../agents/dist/runtime/cli.js
@@ -2194,7 +2146,7 @@ var ModelRuntime = class {
2194
2146
  return outcome("done", calls.output);
2195
2147
  }
2196
2148
  }
2197
- if (costUsd >= task.budgetUsd) {
2149
+ if (task.budgetUsd !== void 0 && costUsd >= task.budgetUsd) {
2198
2150
  return outcome("budget", lastText);
2199
2151
  }
2200
2152
  }
@@ -2572,7 +2524,6 @@ var AgentSession = class {
2572
2524
  lastScreenTrailIndex = 0;
2573
2525
  /** Where the app was when lastScreenId was recorded. */
2574
2526
  lastScreenLocation;
2575
- decisionSpentUsd = 0;
2576
2527
  /** Issues already counted as seen again in this session. */
2577
2528
  seenIssues = /* @__PURE__ */ new Set();
2578
2529
  cancelled = false;
@@ -2671,6 +2622,26 @@ ${lessons.map((lesson) => `- [${lesson.scope ?? "app"}] ${lesson.text}`).join("\
2671
2622
 
2672
2623
  ` : "";
2673
2624
  }
2625
+ var MEMORY = {
2626
+ explorer: `- How to reach a screen that is hard to find, or a control that needs a special action.
2627
+ - How the app behaves in a way that looks wrong but is correct, for example a slow screen or a test account limit.`,
2628
+ judge: `- A behavior that the team or the code shows is intended, so the explorer does not report it again.
2629
+ - For the fixer: why a fix did not work, when the screenshots or the logs show the cause.`,
2630
+ fixer: `- Where the code for a feature lives, or a file that must change together with another file.
2631
+ - How to build, test, or run this codebase, for example a platform file (.web.tsx, .ios.tsx) that the app loads.
2632
+ - Why an earlier change did not work, when you find the cause.`
2633
+ };
2634
+ function memoryPart(role) {
2635
+ return `MEMORY
2636
+ If you have the save_lesson tool, use it when you learn a lasting fact about this app or its code that a
2637
+ later session needs. Write one short, specific sentence. Use "for" to save it for another role. Save facts
2638
+ such as these:
2639
+ ${MEMORY[role]}
2640
+ Do not save the details of one bug, or a fact that the app guide or a lesson already says.
2641
+ When save_lesson shows a similar lesson, confirm it with "same" if it has the same meaning.
2642
+
2643
+ `;
2644
+ }
2674
2645
  function explorerCommon(platform, instructions, lessons = []) {
2675
2646
  return `HOW THE TOOLS WORK
2676
2647
  - Each action tool returns a screenshot and a list of elements. Refs such as [e12] are valid only for
@@ -2686,24 +2657,38 @@ RULES
2686
2657
  - Do not use the same failed action more than two times. Try another way, or report the problem.
2687
2658
  - Keep your notes short. Spend your steps on actions, not on long thoughts.
2688
2659
 
2689
- ${lessonPart(lessons)}APP GUIDE
2660
+ ${memoryPart("explorer")}${lessonPart(lessons)}APP GUIDE
2690
2661
  ${instructions.trim() || "(No guide was given. Explore carefully and do not change any data.)"}`;
2691
2662
  }
2692
- function explorerSystem(platform, instructions, lessons = []) {
2663
+ function explorerSystem(platform, instructions, lessons = [], checks = []) {
2664
+ const automatic = checks.length ? `record_screen also runs automatic checks (${checks.join(", ")}) and sends what they find to the QA lead.
2665
+ Do not report those findings again with report_bug. Report what the checks cannot see.
2666
+ ` : "";
2693
2667
  return `You are the explorer on an automated QA team. You use the app through tools, like a careful
2694
2668
  human tester, and you look for problems that a real user would notice.
2695
2669
 
2696
- YOUR JOB, IN ORDER
2670
+ YOUR JOB IS FULL COVERAGE
2671
+ Your goal is to reach every screen of the app and to use every control on each screen. A bug hides in
2672
+ the screen or the button that nobody opened. Partial coverage is a failed session.
2673
+
2697
2674
  1. Enter the app. Follow the app guide below. When you are in the app, call save_routine with the id
2698
2675
  "enter-app" at once.
2699
2676
  2. Record each screen that you see for the first time, before you do anything on it. Call record_screen
2700
2677
  with a short kebab-case id, a name, and one sentence about what the screen is for. A dialog, a sheet,
2701
- or a menu with its own content is a screen too.
2678
+ a menu, a tab, or a modal with its own content is a screen too.
2702
2679
  3. Explore breadth first. Visit each item of the main navigation before you go deep into one area.
2703
- 4. Report every problem with report_bug as soon as you see it, with the screenshot on screen. Then
2680
+ 4. On each screen, use every control that is safe: each button, link, tab, toggle, menu, list row,
2681
+ icon button, and field. Open each menu and each dialog, look at it, then close it. Scroll to the end
2682
+ of each list and each page, and use what appears. Try an empty and a wrong value in each form.
2683
+ 5. Keep a list in your notes of the controls and the screens that you did not try yet. Go back to them.
2684
+ When a screen is done, go to the next screen that you did not visit.
2685
+ 6. Report every problem with report_bug as soon as you see it, with the screenshot on screen. Then
2704
2686
  continue. Do not stop at the first problem.
2705
- 5. Before your steps run out, call finish with a summary: the screens you covered, the problems you
2706
- reported, and the areas you did not reach.
2687
+ 7. Do not spend steps on waits and repeats. Wait for a slow screen at most two times. Use run_routine to
2688
+ go back to a known screen quickly.
2689
+ 8. Call finish only when you tried every screen and every control that you found, or when your steps are
2690
+ almost gone. The summary names the screens you covered, the problems you reported, and the screens and
2691
+ controls that you did not reach, so the next session starts there.
2707
2692
 
2708
2693
  WHAT TO REPORT
2709
2694
  - A control that does nothing, or does the wrong thing.
@@ -2711,15 +2696,16 @@ WHAT TO REPORT
2711
2696
  - Layout that is broken: content off screen, elements on top of each other, empty gaps, wrong alignment.
2712
2697
  - Wrong, missing, or contradictory data. Placeholder text such as "undefined", "NaN", "null", or "{{".
2713
2698
  - Error messages, crash screens, blank screens.
2699
+ - Console errors and failed requests. The tools list them under "Console errors" and "Failed requests".
2700
+ Report one when it breaks what the user sees or does, for example data that does not load or an action
2701
+ that fails. Name the error in what_is_wrong. Do not report noise that has no effect on the user.
2714
2702
  - Use the id from record_screen for the screen you are on when you call report_bug. Use 'unrecorded' if needed.
2715
2703
  - A loading state that does not end. If a screen still shows a spinner or skeleton rows after two waits
2716
2704
  of 10 seconds, report it as a bug and continue somewhere else. Do not wait again and again.
2717
2705
  - A dead end: a screen with no way back.
2718
2706
  - Inconsistent UI: the same thing named or styled in two different ways.
2719
2707
  Do not report the things that the app guide tells you to ignore.
2720
- record_screen runs automatic checks (contrast, overlap, tap size, visual change) and sends what they find
2721
- to the QA lead. Do not report those findings again with report_bug. Report what the checks cannot see.
2722
-
2708
+ ${automatic}
2723
2709
  ${explorerCommon(platform, instructions, lessons)}`;
2724
2710
  }
2725
2711
  function explorerRetestSystem(platform, instructions, lessons = []) {
@@ -2743,7 +2729,9 @@ Call view_retest to inspect the before and after screenshots. Then call verdict.
2743
2729
  fixed: the problem is gone on EVERY affected screen and nothing new is broken.
2744
2730
  not-fixed: the problem is still visible on at least one screen. Name it.
2745
2731
  unclear: the explorer did not reach at least one screen and none shows the problem still present. Name the missing screens.
2746
- ${lessonPart(lessons)}`;
2732
+ Give a cause only when the screenshots or the logs show it. Do not guess one.
2733
+
2734
+ ${memoryPart("judge")}${lessonPart(lessons)}`;
2747
2735
  }
2748
2736
  function explorerPrompt(input) {
2749
2737
  const screens = input.screens.length ? input.screens.map((screen) => `- ${screen.id}: ${screen.name}. ${screen.description}`).join("\n") : "(none yet)";
@@ -2751,7 +2739,7 @@ function explorerPrompt(input) {
2751
2739
  const health = routine.lastReplay ? routine.lastReplay.ok ? "works" : routine.lastReplay.onFixBuild ? "BROKEN (seen on a fix build), repair it" : "BROKEN, repair it" : "not replayed";
2752
2740
  return `- ${routine.id} (${routine.steps.length} steps, ${health}): ${routine.description}`;
2753
2741
  }).join("\n") : "(none yet)";
2754
- const goal = input.goal ?? (input.screens.length ? 'Enter the app. If "enter-app" works, use run_routine for it. Then look for screens that are NOT in the known list below, and record and test each one. Revisit a known screen only to test something new on it.' : "This is the first visit. Enter the app, then map as many screens as you can and report every problem.");
2742
+ const goal = input.goal ?? (input.screens.length ? 'Enter the app. If "enter-app" works, use run_routine for it. Then find the screens that are NOT in the known list below, and record and test each one. On a known screen, use each control that an earlier session did not try. The goal is every screen and every control of the app.' : "This is the first visit. Enter the app, then map every screen that you can reach, use every safe control on each one, and report every problem.");
2755
2743
  return `GOAL
2756
2744
  ${goal}
2757
2745
 
@@ -2805,11 +2793,12 @@ HOW TO WRITE AN ISSUE
2805
2793
 
2806
2794
  Be strict. A report that nobody can act on costs the team time. When you have decided every candidate,
2807
2795
  call finish with one sentence per decision.
2808
- ${lessonPart(lessons)}`;
2796
+
2797
+ ${memoryPart("judge")}${lessonPart(lessons)}`;
2809
2798
  }
2810
2799
  function judgePrompt(sessionIds, candidates, issues) {
2811
2800
  const list3 = candidates.map((candidate) => {
2812
- const route2 = candidate.route ? ` Decider: ${candidate.route.reason}` : "";
2801
+ const route2 = candidate.route ? ` Route: ${candidate.route.reason}` : "";
2813
2802
  return `- ${candidate.id} [${candidate.source}, ${candidate.severity}] on ${candidate.screenId ?? "unknown screen"}: ${candidate.summary}.${route2}`;
2814
2803
  }).join("\n");
2815
2804
  const open2 = issues.filter((issue) => issue.status !== "dismissed" && issue.status !== "fixed");
@@ -2862,7 +2851,111 @@ a QA lead confirmed it. Fix the cause, not the symptom.
2862
2851
  your summary, with the file and line. Bughunters shows your explanation to the team.
2863
2852
  - Do not commit and do not push. Bughunters commits the change on its own branch.
2864
2853
  - End with a short summary: the cause, the change, and what you ran to verify it.
2865
- ${lessonPart(lessons)}`;
2854
+
2855
+ ${memoryPart("fixer")}${lessonPart(lessons)}`;
2856
+ }
2857
+
2858
+ // ../agents/dist/tools/memory.js
2859
+ var roles = ["explorer", "judge", "fixer"];
2860
+ var STOP = /* @__PURE__ */ new Set([
2861
+ "the",
2862
+ "and",
2863
+ "for",
2864
+ "with",
2865
+ "that",
2866
+ "this",
2867
+ "from",
2868
+ "into",
2869
+ "not",
2870
+ "are",
2871
+ "was",
2872
+ "has",
2873
+ "have",
2874
+ "its",
2875
+ "use",
2876
+ "when",
2877
+ "then",
2878
+ "than",
2879
+ "there",
2880
+ "can",
2881
+ "does",
2882
+ "will",
2883
+ "must",
2884
+ "only",
2885
+ "also",
2886
+ "you"
2887
+ ]);
2888
+ function words(text3) {
2889
+ return new Set(text3.toLowerCase().match(/[a-z0-9_.:/-]+/g)?.map((word) => word.replace(/[.:]+$/, "").replace(/s$/, "")).filter((word) => word.length > 2 && !STOP.has(word)) ?? []);
2890
+ }
2891
+ function similarLesson(lessons, role, text3) {
2892
+ const mine = words(text3);
2893
+ let best;
2894
+ for (const lesson of lessons) {
2895
+ if (lesson.role !== role || lesson.retired)
2896
+ continue;
2897
+ const theirs = words(lesson.text);
2898
+ const shared = [...mine].filter((word) => theirs.has(word)).length;
2899
+ const score = shared / Math.max(1, Math.min(mine.size, theirs.size));
2900
+ if (shared >= 4 && score >= 0.6 && (!best || score > best.score))
2901
+ best = { lesson, score };
2902
+ }
2903
+ return best?.lesson;
2904
+ }
2905
+ function lessonTools(session, role) {
2906
+ const memory = session.config.agents.memory;
2907
+ if (!memory.enabled)
2908
+ return [];
2909
+ let saved = 0;
2910
+ return [{
2911
+ name: "save_lesson",
2912
+ 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.',
2913
+ inputSchema: {
2914
+ type: "object",
2915
+ properties: {
2916
+ text: { type: "string" },
2917
+ for: { type: "string", enum: roles },
2918
+ scope: { type: "string", description: "A screen id, or leave it out for the whole app." },
2919
+ same: { type: "string", description: "The id of a saved lesson that has the same meaning. It confirms that lesson, and saves no new one." },
2920
+ different: { type: "boolean", description: "true saves the lesson also when a similar lesson exists." }
2921
+ },
2922
+ required: ["text"],
2923
+ additionalProperties: false
2924
+ },
2925
+ async run(input) {
2926
+ if (saved >= memory.maxPerSession) {
2927
+ return { content: [{ type: "text", text: "The lesson limit for this session is reached." }] };
2928
+ }
2929
+ const text3 = session.vars.redact(String(input.text ?? "")).trim().slice(0, 240);
2930
+ if (!text3)
2931
+ return { content: [{ type: "text", text: "The lesson needs a text." }], isError: true };
2932
+ const target = roles.includes(input.for) ? input.for : role;
2933
+ const { lessons } = await session.workspace.readMemory();
2934
+ const confirmed = input.same ? lessons.find((lesson2) => lesson2.id === input.same && !lesson2.retired) : void 0;
2935
+ if (confirmed) {
2936
+ await session.workspace.upsertLessons([{
2937
+ role: confirmed.role,
2938
+ text: confirmed.text,
2939
+ source: confirmed.source,
2940
+ scope: confirmed.scope
2941
+ }]);
2942
+ return { content: [{ type: "text", text: `Confirmed ${confirmed.id}. It has one more hit now.` }] };
2943
+ }
2944
+ const similar = input.different === true ? void 0 : similarLesson(lessons, target, text3);
2945
+ if (similar && similar.text.toLowerCase() !== text3.toLowerCase()) {
2946
+ return { content: [{ type: "text", text: `The ${target} already has a similar lesson: ${similar.id}: "${similar.text}" If it has the same meaning, call save_lesson with same: "${similar.id}". If your lesson tells something new, call it again with different: true.` }] };
2947
+ }
2948
+ const [lesson] = await session.workspace.upsertLessons([{
2949
+ role: target,
2950
+ text: text3,
2951
+ source: "agent",
2952
+ scope: input.scope ? String(input.scope) : void 0
2953
+ }]);
2954
+ saved++;
2955
+ session.emit({ kind: "lesson", summary: `Learned (${target}): ${lesson.text}` });
2956
+ return { content: [{ type: "text", text: `Saved ${lesson.id} for the ${target}.` }] };
2957
+ }
2958
+ }];
2866
2959
  }
2867
2960
 
2868
2961
  // ../agents/dist/roles/fixer.js
@@ -3204,10 +3297,10 @@ ${issue.body}
3204
3297
  Evidence:
3205
3298
  ${evidence}
3206
3299
  Reproduction: ${repro}` + (last ? `
3207
- Your last change did not fix the issue. The QA lead said: ${last.reason}. After screenshots:
3300
+ Your last change did not fix the issue in the running app. The QA lead said: ${last.reason}. After screenshots:
3208
3301
  ${after}
3209
- Fix it now.` : ""),
3210
- tools: runtime.label.startsWith("cli:") ? [finishTool()] : [...modelTools(worktree), finishTool()],
3302
+ 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.` : ""),
3303
+ tools: [...runtime.label.startsWith("cli:") ? [] : modelTools(worktree), ...lessonTools(session, "fixer"), finishTool()],
3211
3304
  maxSteps: config.maxSteps,
3212
3305
  budgetUsd: config.budgetUsd,
3213
3306
  timeoutMs: config.timeoutMs
@@ -3234,12 +3327,12 @@ function stepWords(step) {
3234
3327
  }
3235
3328
 
3236
3329
  // ../agents/dist/github.js
3237
- var defaultGh = (args2, opts) => new Promise((resolve11, reject) => {
3330
+ var defaultGh = (args2, opts) => new Promise((resolve12, reject) => {
3238
3331
  const child = execFile2("gh", args2, { cwd: opts?.cwd, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
3239
3332
  if (error)
3240
3333
  reject(Object.assign(error, { stderr, stdout }));
3241
3334
  else
3242
- resolve11(stdout.trim());
3335
+ resolve12(stdout.trim());
3243
3336
  });
3244
3337
  child.stdin?.end(opts?.input);
3245
3338
  });
@@ -4479,14 +4572,15 @@ async function evaluateScreen(session, input) {
4479
4572
  if (firstSight)
4480
4573
  await saveBaseline(session, baseline, screenshot, snapshot);
4481
4574
  const native = observation.platform === "ios" || observation.platform === "android";
4575
+ const enabled = new Set(session.config.agents.checks);
4482
4576
  const violations = evaluateAll(snapshot, {
4483
4577
  baseline: baseline.snapshot,
4484
- disabled: native ? NATIVE_DISABLED : []
4578
+ disabled: RULES.map((rule) => rule.id).filter((id2) => !enabled.has(id2) || native && NATIVE_DISABLED.includes(id2))
4485
4579
  });
4486
4580
  const entries = groupViolations(violations);
4487
4581
  let diffPath;
4488
4582
  let visual;
4489
- if (!firstSight) {
4583
+ if (!firstSight && enabled.has("pixel-diff")) {
4490
4584
  diffPath = join8(paths.session(session.root, session.sessionId), `${screenId}-diff.png`);
4491
4585
  const comparison = await diff({
4492
4586
  baselinePath: baseline.png,
@@ -4506,7 +4600,7 @@ async function evaluateScreen(session, input) {
4506
4600
  await closeAbsentChecks(session, screenId, new Set(entries.map((entry) => entryFingerprint(screenId, entry))));
4507
4601
  if (fresh.length === 0)
4508
4602
  return [];
4509
- const routeResult = await decide(session, screenId, snapshot, violations, visual);
4603
+ const routeResult = { to: "judge", reason: "Automatic check" };
4510
4604
  const candidates = [];
4511
4605
  for (const entry of fresh) {
4512
4606
  const candidate = toCandidate(session, screenId, entry, {
@@ -4615,6 +4709,7 @@ async function addOccurrence(session, issueId) {
4615
4709
  }
4616
4710
  async function closeAbsentChecks(session, screenId, fired) {
4617
4711
  const closed = [];
4712
+ const enabled = new Set(session.config.agents.checks);
4618
4713
  const ids = new Set((await session.workspace.listIssues()).filter((issue) => issue.screenId === screenId && ["new", "filed", "fixing", "fix-proposed"].includes(issue.status)).map((issue) => issue.id));
4619
4714
  const sources = /* @__PURE__ */ new Map();
4620
4715
  for (const record of await session.workspace.listSessions(Infinity)) {
@@ -4628,6 +4723,8 @@ async function closeAbsentChecks(session, screenId, fired) {
4628
4723
  const candidates = issue.candidateIds.map((candidateId) => sources.get(candidateId));
4629
4724
  if (candidates.some((candidate) => !candidate || candidate.source === "explorer" || candidate.screenId !== screenId))
4630
4725
  continue;
4726
+ if (candidates.some((candidate) => !enabled.has(candidate.ruleId ?? candidate.source)))
4727
+ continue;
4631
4728
  if (candidates.some((candidate) => fired.has(candidate.fingerprint))) {
4632
4729
  if (issue.notSeen)
4633
4730
  await session.workspace.saveIssue({ ...issue, notSeen: 0 });
@@ -4677,57 +4774,6 @@ function toCandidate(session, screenId, entry, extra) {
4677
4774
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
4678
4775
  };
4679
4776
  }
4680
- async function decide(session, screenId, snapshot, violations, visual) {
4681
- const config = session.config.decisions;
4682
- const { decider } = resolveDecider(config, process.env);
4683
- if (!decider)
4684
- return { to: "judge", reason: "no decider key is set, so the judge decides" };
4685
- const { text: text3 } = buildState({
4686
- screen: { id: screenId, description: snapshot.title ?? snapshot.url },
4687
- product: { summary: "", audience: "", domainVocabulary: [] },
4688
- assertions: violationsToAssertions(violations),
4689
- diff: visual ? {
4690
- changedPixels: visual.changedPixels,
4691
- changedPercent: visual.changedFraction * 100,
4692
- maskedPercent: visual.maskedFraction * 100,
4693
- regions: visual.regions.length
4694
- } : void 0,
4695
- console: snapshot.consoleErrors
4696
- });
4697
- const budget = new Budget(config.budget.perRunUsd);
4698
- budget.record(session.decisionSpentUsd);
4699
- const estimate = estimateDecisionCost(text3.length, decider.name);
4700
- if (!budget.canSpend(estimate)) {
4701
- return { to: "judge", reason: "decider budget exhausted" };
4702
- }
4703
- try {
4704
- const answers = await decider.ask(text3, SCREEN_QUESTIONS);
4705
- session.decisionSpentUsd += estimate;
4706
- const result3 = route({
4707
- answers,
4708
- thresholds: config.confidence,
4709
- hasDeterministicRegression: false,
4710
- matchedLedger: false
4711
- });
4712
- session.emit({
4713
- kind: "tool-result",
4714
- tool: "decider",
4715
- summary: `Decider routed ${screenId} to ${result3.route}`,
4716
- costUsd: estimate,
4717
- tokens: decider.lastUsage,
4718
- model: decider.model ?? decider.name
4719
- });
4720
- const choice = answers["route"];
4721
- const confidence = choice?.confidence;
4722
- return {
4723
- to: result3.route === "ignore" || result3.route === "intent" ? "ignore" : "judge",
4724
- reason: result3.reason,
4725
- confidence
4726
- };
4727
- } catch {
4728
- return { to: "judge", reason: "decider unavailable" };
4729
- }
4730
- }
4731
4777
 
4732
4778
  // ../agents/dist/replay.js
4733
4779
  async function replaySteps(session, steps, opts = {}) {
@@ -4858,7 +4904,7 @@ async function actWhenReady(driver, action, session, windowMs) {
4858
4904
  if (result3.ok || !retry || Date.now() >= deadline || session.cancelled) {
4859
4905
  return { ...result3, degraded };
4860
4906
  }
4861
- await new Promise((resolve11) => setTimeout(resolve11, 500));
4907
+ await new Promise((resolve12) => setTimeout(resolve12, 500));
4862
4908
  }
4863
4909
  }
4864
4910
  function toAction(step, session) {
@@ -4904,14 +4950,20 @@ function elementLine(element) {
4904
4950
  ].filter(Boolean).join(" ");
4905
4951
  return `[${element.ref}] ${element.role} ${JSON.stringify(element.name)} (${box.x},${box.y} ${box.width}x${box.height})${flags ? ` ${flags}` : ""}`;
4906
4952
  }
4953
+ function errorLines(observation) {
4954
+ const unique = (items = []) => [...new Set(items)].map((item) => item.slice(0, 200));
4955
+ const lines = (label, items) => items.length ? [`${label}:`, ...items.slice(0, 5).map((item) => `- ${item}`), ...items.length > 5 ? [`- (${items.length - 5} more)`] : []] : [];
4956
+ return [...lines("Console errors", unique(observation.consoleErrors)), ...lines("Failed requests", unique(observation.networkErrors))];
4957
+ }
4907
4958
  function observationText(observation) {
4908
4959
  const windows = observation.windows?.map((window) => `${window.active ? "*" : " "} ${window.title} (${window.location})`).join("; ") ?? "(none)";
4909
4960
  return [
4910
4961
  `Location: ${observation.location}`,
4911
4962
  `Windows: ${windows}`,
4963
+ ...errorLines(observation),
4912
4964
  "Elements:",
4913
4965
  ...observation.elements.slice(0, 117).map(elementLine)
4914
- ].slice(0, 120).join("\n");
4966
+ ].slice(0, 130).join("\n");
4915
4967
  }
4916
4968
  async function observe(session, label, summary) {
4917
4969
  const observation = await session.driver.observe();
@@ -5239,7 +5291,7 @@ function explorerTools(session) {
5239
5291
  },
5240
5292
  {
5241
5293
  name: "record_screen",
5242
- description: "Record this screen, its routine, and automatic findings.",
5294
+ description: "Record this screen and its routine in the app map.",
5243
5295
  inputSchema: schema({ id: string, name: string, description: string }, ["id", "name", "description"]),
5244
5296
  async run(input) {
5245
5297
  const id2 = slug(arg(input, "id"));
@@ -5249,8 +5301,7 @@ function explorerTools(session) {
5249
5301
  await session.driver.settle();
5250
5302
  const observation = await session.driver.observe();
5251
5303
  const screenshot = await session.capture(observation, id2);
5252
- const snapshot = session.driver.snapshot(observation, id2);
5253
- const findings = await evaluateScreen(session, { screenId: id2, observation, snapshot });
5304
+ const findings = session.config.agents.checks.length ? await evaluateScreen(session, { screenId: id2, observation, snapshot: session.driver.snapshot(observation, id2) }) : [];
5254
5305
  const previous = session.lastScreenId;
5255
5306
  const edge = transition(session, id2);
5256
5307
  const routineId = `screen-${id2}`;
@@ -5278,15 +5329,17 @@ function explorerTools(session) {
5278
5329
  session.lastScreenTrailIndex = session.trail.length;
5279
5330
  const screens = (await session.workspace.readAppMap())?.screens ?? [];
5280
5331
  const names = screens.map((screen) => screen.id).join(", ") || id2;
5281
- const found = findings.length ? `${findings.length} automatic finding(s): ${findings.map((item) => item.summary).join("; ")}` : "no automatic findings";
5332
+ const found = !session.config.agents.checks.length ? "" : findings.length ? ` ${findings.length} automatic finding(s): ${findings.map((item) => item.summary).join("; ")}.` : " No automatic findings.";
5333
+ const errors = errorLines(observation);
5282
5334
  session.emit({
5283
5335
  kind: "screen",
5284
- summary: `Recorded ${name} (${found.split(":")[0]})`,
5336
+ summary: `Recorded ${name}${findings.length ? ` (${findings.length} automatic finding(s))` : ""}`,
5285
5337
  screenId: id2,
5286
5338
  screenshot
5287
5339
  });
5288
5340
  return {
5289
- ...text(`Recorded ${name}. ${found}. Known screens: ${names}`),
5341
+ ...text(session.vars.redact(`Recorded ${name}.${found} Known screens: ${names}${errors.length ? `
5342
+ ${errors.join("\n")}` : ""}`)),
5290
5343
  meta: { summary: "", screenshot, screenId: id2 }
5291
5344
  };
5292
5345
  }
@@ -5402,6 +5455,7 @@ Expected: ${arg(input, "expected")}`),
5402
5455
  run: (input) => act(session, { kind: "window", match: arg(input, "match") })
5403
5456
  });
5404
5457
  }
5458
+ tools.push(...lessonTools(session, "explorer"));
5405
5459
  return tools;
5406
5460
  }
5407
5461
 
@@ -5419,9 +5473,9 @@ var string2 = { type: "string" };
5419
5473
  function issueTitle(value) {
5420
5474
  if (value.length <= 80)
5421
5475
  return value;
5422
- const words = value.trim().split(/\s+/);
5476
+ const words2 = value.trim().split(/\s+/);
5423
5477
  let title = "";
5424
- for (const word of words) {
5478
+ for (const word of words2) {
5425
5479
  const next = title ? `${title} ${word}` : word;
5426
5480
  if (next.length > 79)
5427
5481
  break;
@@ -5474,6 +5528,7 @@ function triageEntries(candidates, decision, reason, issueId) {
5474
5528
  }
5475
5529
  function judgeTools(session, sessionIds, runtimeLabel) {
5476
5530
  return [
5531
+ ...lessonTools(session, "judge"),
5477
5532
  {
5478
5533
  name: "list_candidates",
5479
5534
  description: "List undecided candidates and existing issues.",
@@ -5830,7 +5885,7 @@ async function runExplorer(session, runtime, opts = {}) {
5830
5885
  const task = {
5831
5886
  role: "explorer",
5832
5887
  sessionId: session.sessionId,
5833
- system: explorerSystem(session.config.app.platform, instructions, lessonsFor(await session.workspace.readMemory(), "explorer")),
5888
+ system: explorerSystem(session.config.app.platform, instructions, lessonsFor(await session.workspace.readMemory(), "explorer"), session.config.agents.checks),
5834
5889
  prompt: explorerPrompt({
5835
5890
  goal: opts.goal,
5836
5891
  screens: map?.screens ?? [],
@@ -5853,13 +5908,13 @@ async function runExplorer(session, runtime, opts = {}) {
5853
5908
  await session.workspace.endSession(session.sessionId, {
5854
5909
  status: outcome.stop === "error" ? "failed" : "finished",
5855
5910
  steps: outcome.steps,
5856
- costUsd: outcome.costUsd + session.decisionSpentUsd,
5911
+ costUsd: outcome.costUsd,
5857
5912
  summary: outcome.summary,
5858
5913
  candidates: candidates.length,
5859
5914
  screensFound: screens.map((item) => item.id)
5860
5915
  });
5861
5916
  session.emit({ kind: "session-end", summary: outcome.summary ?? `Explorer stopped: ${outcome.stop}` });
5862
- await session.idle(outcome.costUsd + session.decisionSpentUsd);
5917
+ await session.idle(outcome.costUsd);
5863
5918
  const learned = await reflectOnSession(session.root, session.config, session.sessionId, { vars: session.vars, onLog: (message2) => session.emit({ kind: "error", summary: message2 }) });
5864
5919
  if (learned)
5865
5920
  session.emit({ kind: "lesson", summary: `Saved ${learned} lesson(s) for the next sessions` });
@@ -6033,7 +6088,7 @@ async function deleteBranch(fix, onLog) {
6033
6088
 
6034
6089
  // ../agents/dist/roles/retest.js
6035
6090
  import { execFile as execFile5 } from "node:child_process";
6036
- import { readFile as readFile9 } from "node:fs/promises";
6091
+ import { readFile as readFile10 } from "node:fs/promises";
6037
6092
  import { resolve as resolve7 } from "node:path";
6038
6093
  import { promisify as promisify5 } from "node:util";
6039
6094
 
@@ -6093,7 +6148,7 @@ async function observeDom(page) {
6093
6148
  };
6094
6149
  const trim = (value) => value?.trim().replace(/\s+/g, " ").slice(0, 80) ?? "";
6095
6150
  const implicitRole = (tag, type) => {
6096
- const roles3 = {
6151
+ const roles4 = {
6097
6152
  a: "link",
6098
6153
  button: "button",
6099
6154
  select: "combobox",
@@ -6116,7 +6171,7 @@ async function observeDom(page) {
6116
6171
  if (/^h[1-6]$/.test(tag)) {
6117
6172
  return "heading";
6118
6173
  }
6119
- return roles3[tag] ?? "button";
6174
+ return roles4[tag] ?? "button";
6120
6175
  };
6121
6176
  for (const element of Array.from(document.querySelectorAll(candidates))) {
6122
6177
  if (result3.length >= 150) {
@@ -6269,6 +6324,7 @@ var WebDriver = class {
6269
6324
  page;
6270
6325
  lastObservation;
6271
6326
  errors = /* @__PURE__ */ new Map();
6327
+ failures = /* @__PURE__ */ new Map();
6272
6328
  watched = /* @__PURE__ */ new WeakSet();
6273
6329
  constructor(options) {
6274
6330
  this.options = options;
@@ -6298,11 +6354,30 @@ var WebDriver = class {
6298
6354
  const errors = [];
6299
6355
  this.errors.set(page, errors);
6300
6356
  page.on("console", (message2) => {
6301
- if (message2.type() === "error") {
6357
+ if (message2.type() === "error" && !message2.text().startsWith("Failed to load resource:")) {
6302
6358
  errors.push(message2.text());
6303
6359
  }
6304
6360
  });
6305
6361
  page.on("pageerror", (error) => errors.push(`${error.name}: ${error.message}`));
6362
+ const failures = [];
6363
+ this.failures.set(page, failures);
6364
+ const add = (line) => {
6365
+ if (failures.length < 50)
6366
+ failures.push(line);
6367
+ };
6368
+ page.on("response", (response3) => {
6369
+ const type = response3.request().resourceType();
6370
+ if (response3.status() >= 400 && ["fetch", "xhr", "document"].includes(type)) {
6371
+ add(`${response3.request().method()} ${response3.url()} \u2192 ${response3.status()}`);
6372
+ }
6373
+ });
6374
+ page.on("requestfailed", (request) => {
6375
+ const reason = request.failure()?.errorText ?? "failed";
6376
+ if (/ERR_ABORTED|NS_BINDING_ABORTED|cancelled/i.test(reason))
6377
+ return;
6378
+ if (["fetch", "xhr", "document"].includes(request.resourceType()))
6379
+ add(`${request.method()} ${request.url()} \u2192 ${reason}`);
6380
+ });
6306
6381
  }
6307
6382
  async observe() {
6308
6383
  const page = this.activePage();
@@ -6334,6 +6409,7 @@ var WebDriver = class {
6334
6409
  windows,
6335
6410
  volatileRegions: [],
6336
6411
  consoleErrors: errors.splice(0),
6412
+ networkErrors: (this.failures.get(page) ?? []).splice(0),
6337
6413
  at: (/* @__PURE__ */ new Date()).toISOString()
6338
6414
  };
6339
6415
  this.lastObservation = observation;
@@ -6959,7 +7035,7 @@ var MaestroDriver = class {
6959
7035
  await this.run([`openLink: ${yamlString(action.url)}`]);
6960
7036
  }
6961
7037
  async wait(action) {
6962
- await new Promise((resolve11) => setTimeout(resolve11, action.ms));
7038
+ await new Promise((resolve12) => setTimeout(resolve12, action.ms));
6963
7039
  }
6964
7040
  async switchWindow() {
6965
7041
  throw new Error("Window switching is unsupported on mobile");
@@ -7020,7 +7096,7 @@ var MaestroDriver = class {
7020
7096
  return { frames, stable: true };
7021
7097
  }
7022
7098
  previous = current;
7023
- await new Promise((resolve11) => setTimeout(resolve11, interval));
7099
+ await new Promise((resolve12) => setTimeout(resolve12, interval));
7024
7100
  }
7025
7101
  return { frames, stable: false };
7026
7102
  }
@@ -7062,6 +7138,60 @@ function createDriver(config, vars) {
7062
7138
  });
7063
7139
  }
7064
7140
 
7141
+ // ../agents/dist/roles/overlay.js
7142
+ import { mkdir as mkdir4, readFile as readFile9, readdir as readdir3, rm as rm4, writeFile as writeFile6 } from "node:fs/promises";
7143
+ import { dirname as dirname3, join as join11, relative as relative3, sep as sep3 } from "node:path";
7144
+ async function files(dir, skip) {
7145
+ const out = [];
7146
+ let entries;
7147
+ try {
7148
+ entries = await readdir3(dir, { withFileTypes: true });
7149
+ } catch {
7150
+ return out;
7151
+ }
7152
+ for (const entry of entries) {
7153
+ const path = join11(dir, entry.name);
7154
+ if (path === skip)
7155
+ continue;
7156
+ if (entry.isDirectory())
7157
+ out.push(...await files(path, skip));
7158
+ else if (entry.isFile())
7159
+ out.push(path);
7160
+ }
7161
+ return out;
7162
+ }
7163
+ async function overlayBughunters(root2, source, worktree) {
7164
+ const where = relative3(source, root2);
7165
+ if (where.startsWith("..") || where.split(sep3)[0] === "..")
7166
+ return async () => {
7167
+ };
7168
+ const from = join11(root2, BUGHUNTERS_DIR);
7169
+ const saved = [];
7170
+ for (const file of await files(from, join11(from, DATA_DIR))) {
7171
+ const target = join11(worktree, where, relative3(root2, file));
7172
+ const content = await readFile9(file);
7173
+ let before;
7174
+ try {
7175
+ before = await readFile9(target);
7176
+ } catch {
7177
+ before = void 0;
7178
+ }
7179
+ if (before?.equals(content))
7180
+ continue;
7181
+ saved.push({ path: target, content: before });
7182
+ await mkdir4(dirname3(target), { recursive: true });
7183
+ await writeFile6(target, content);
7184
+ }
7185
+ return async () => {
7186
+ for (const item of saved.reverse()) {
7187
+ if (item.content)
7188
+ await writeFile6(item.path, item.content);
7189
+ else
7190
+ await rm4(item.path, { force: true });
7191
+ }
7192
+ };
7193
+ }
7194
+
7065
7195
  // ../agents/dist/roles/retest.js
7066
7196
  var exec4 = promisify5(execFile5);
7067
7197
  var schema4 = (properties = {}, required = []) => ({ type: "object", properties, required, additionalProperties: false });
@@ -7071,7 +7201,7 @@ async function image2(root2, file) {
7071
7201
  if (!file)
7072
7202
  return [];
7073
7203
  try {
7074
- return [{ type: "image", png: await readFile9(resolve7(root2, file)) }];
7204
+ return [{ type: "image", png: await readFile10(resolve7(root2, file)) }];
7075
7205
  } catch {
7076
7206
  return [];
7077
7207
  }
@@ -7137,9 +7267,13 @@ async function retestFix(root2, config, issue, fix, attempt, deps, options = {})
7137
7267
  const vars = new Vars(config.app.secrets);
7138
7268
  let app;
7139
7269
  let driver;
7270
+ let restore = async () => {
7271
+ };
7140
7272
  try {
7141
7273
  if (!options.build)
7142
7274
  await linkEnvFiles(fix.repo, fix.worktree);
7275
+ if (!options.build)
7276
+ restore = await overlayBughunters(root2, fix.repo, fix.worktree);
7143
7277
  if (!options.build && config.agents.fixer.retest.prepare) {
7144
7278
  await exec4("/bin/sh", ["-c", config.agents.fixer.retest.prepare], { cwd: fix.worktree, timeout: 6e5, maxBuffer: 4 * 1024 * 1024 });
7145
7279
  }
@@ -7176,7 +7310,8 @@ async function retestFix(root2, config, issue, fix, attempt, deps, options = {})
7176
7310
  "open",
7177
7311
  "wait",
7178
7312
  "run_routine",
7179
- "switch_window"
7313
+ "switch_window",
7314
+ "save_lesson"
7180
7315
  ]);
7181
7316
  const tools = explorerTools(session2).filter((tool) => allowed.has(tool.name)).map((tool) => tool.name !== "run_routine" ? tool : { ...tool, async run(input) {
7182
7317
  const id2 = String(input.id ?? "");
@@ -7266,7 +7401,7 @@ async function retestFix(root2, config, issue, fix, attempt, deps, options = {})
7266
7401
  }
7267
7402
  });
7268
7403
  const guide = config.app.instructions;
7269
- const instructions = guide ? await readFile9(resolve7(root2, guide), "utf8") : "";
7404
+ const instructions = guide ? await readFile10(resolve7(root2, guide), "utf8") : "";
7270
7405
  const result3 = await explorerRuntime.run({
7271
7406
  role: "explorer",
7272
7407
  sessionId: record2.id,
@@ -7320,12 +7455,17 @@ ${targets.map((target, index) => `${index + 1}. screen ${target.shot.screenId ??
7320
7455
  } catch (error) {
7321
7456
  retest.outcome = "error";
7322
7457
  retest.reason = `Retest preparation or exploration failed: ${String(error)}`;
7458
+ deps.onLog?.(retest.reason);
7323
7459
  return retest;
7324
7460
  } finally {
7325
7461
  try {
7326
7462
  await driver?.close();
7327
7463
  } finally {
7328
- await app?.stop();
7464
+ try {
7465
+ await app?.stop();
7466
+ } finally {
7467
+ await restore();
7468
+ }
7329
7469
  }
7330
7470
  }
7331
7471
  if (!targets.some((target) => target.shot.after)) {
@@ -7369,7 +7509,7 @@ Fixer: ${fix.summary ?? ""}` }
7369
7509
  judged = { outcome: input.outcome, reason: String(input.reason ?? "") };
7370
7510
  return { ...response2(`Retest verdict: ${judged.outcome} \u2014 ${judged.reason}`), done: true };
7371
7511
  }
7372
- }];
7512
+ }, ...lessonTools(session, "judge")];
7373
7513
  const result3 = await judgeRuntime.run({
7374
7514
  role: "judge",
7375
7515
  sessionId: record.id,
@@ -7538,8 +7678,8 @@ async function runFixCycle(root2, config, deps = {}) {
7538
7678
  }
7539
7679
 
7540
7680
  // ../agents/dist/roles/publish.js
7541
- import { mkdir as mkdir4, readFile as readFile10, writeFile as writeFile6 } from "node:fs/promises";
7542
- import { join as join11, resolve as resolve8 } from "node:path";
7681
+ import { mkdir as mkdir5, readFile as readFile11, writeFile as writeFile7 } from "node:fs/promises";
7682
+ import { join as join12, resolve as resolve8 } from "node:path";
7543
7683
 
7544
7684
  // ../agents/dist/report.js
7545
7685
  function buildReport(input) {
@@ -7657,7 +7797,7 @@ async function image3(root2, path) {
7657
7797
  if (!path)
7658
7798
  return [];
7659
7799
  try {
7660
- return [{ type: "image", png: await readFile10(resolve8(root2, path)) }];
7800
+ return [{ type: "image", png: await readFile11(resolve8(root2, path)) }];
7661
7801
  } catch {
7662
7802
  return [];
7663
7803
  }
@@ -7849,9 +7989,9 @@ Retest: ${last?.outcome ?? "none"} \u2014 ${last?.reason ?? ""}`;
7849
7989
  let url;
7850
7990
  if (deps.dryRun) {
7851
7991
  const dir = paths.publish(root2);
7852
- await mkdir4(dir, { recursive: true });
7853
- url = join11(dir, `${item.issue.id}.md`);
7854
- await writeFile6(url, body);
7992
+ await mkdir5(dir, { recursive: true });
7993
+ url = join12(dir, `${item.issue.id}.md`);
7994
+ await writeFile7(url, body);
7855
7995
  } else if (item.kind === "pr" && item.fix) {
7856
7996
  const opened = await createPr(gh, {
7857
7997
  repo: resolved.repo,
@@ -7958,12 +8098,211 @@ ${items.map((item) => `${item.issue.id}: ${item.issue.title}`).join("\n")}`),
7958
8098
  return outcomes;
7959
8099
  }
7960
8100
 
7961
- // ../agents/dist/patrol.js
8101
+ // ../agents/dist/roles/ci.js
7962
8102
  import { execFile as execFile6 } from "node:child_process";
8103
+ import { access } from "node:fs/promises";
7963
8104
  import { resolve as resolve9 } from "node:path";
7964
8105
  import { promisify as promisify6 } from "node:util";
7965
- var exec5 = promisify6(execFile6);
7966
- async function git4(cwd, ...args2) {
8106
+ var git4 = async (cwd, ...args2) => (await promisify6(execFile6)("git", args2, { cwd, maxBuffer: 16 * 1024 * 1024 })).stdout.trim();
8107
+ async function readChecks(gh, repo, pr) {
8108
+ let out;
8109
+ try {
8110
+ out = await gh(["pr", "checks", String(pr), "--repo", repo, "--json", "name,bucket,link,description,workflow"]);
8111
+ } catch (error) {
8112
+ out = String(error.stdout ?? "");
8113
+ if (!out.trim().startsWith("[")) {
8114
+ if (/no checks/i.test(String(error.stderr ?? "")))
8115
+ return [];
8116
+ throw error;
8117
+ }
8118
+ }
8119
+ return out.trim() ? JSON.parse(out) : [];
8120
+ }
8121
+ async function failureLog(gh, repo, failed2) {
8122
+ const parts = [];
8123
+ const runs = /* @__PURE__ */ new Set();
8124
+ for (const check of failed2) {
8125
+ const run2 = check.link?.match(/\/actions\/runs\/(\d+)/)?.[1];
8126
+ if (run2 && !runs.has(run2)) {
8127
+ runs.add(run2);
8128
+ try {
8129
+ const log = await gh(["run", "view", run2, "--repo", repo, "--log-failed"]);
8130
+ parts.push(`### ${check.workflow || check.name} (run ${run2})
8131
+ ${log.split("\n").slice(-150).join("\n")}`);
8132
+ continue;
8133
+ } catch {
8134
+ }
8135
+ }
8136
+ if (!run2)
8137
+ parts.push(`### ${check.name}
8138
+ ${check.description ?? ""}
8139
+ ${check.link ?? ""}`);
8140
+ }
8141
+ return parts.join("\n\n").slice(-12e3);
8142
+ }
8143
+ function ciPrompt(pr, failed2, log) {
8144
+ return `The CI checks failed on PR #${pr}, which has your earlier fix. Make every check pass.
8145
+
8146
+ FAILED CHECKS
8147
+ ${failed2.map((check) => `- ${check.name}${check.link ? ` (${check.link})` : ""}`).join("\n")}
8148
+
8149
+ THE END OF THE FAILED LOGS
8150
+ ${log || "(no log is available)"}
8151
+
8152
+ - Find the cause in the log. Fix the code or the test, not the CI config.
8153
+ - Run the same command as the failed check in this worktree, and make it pass before you finish.
8154
+ - Keep the original fix. Do not revert it to make CI pass.
8155
+ - If the failure has nothing to do with this change (a flaky test, an outage), change nothing, and say so.`;
8156
+ }
8157
+ async function watchCi(root2, config, deps = {}) {
8158
+ const problems = [];
8159
+ const settings = config.agents.github.ci;
8160
+ if (!config.agents.github.enabled || !settings.enabled)
8161
+ return { problems };
8162
+ const gh = deps.gh ?? defaultGh;
8163
+ const ready = await ghReady(gh);
8164
+ if (!ready.ok) {
8165
+ deps.onLog?.(ready.reason);
8166
+ return { problems };
8167
+ }
8168
+ const workspace = new Workspace(root2);
8169
+ const fixes = (await workspace.listFixes()).filter((fix) => fix.pr && (fix.pr.state ?? "open") === "open" && (!deps.issueIds || deps.issueIds.includes(fix.issueId)));
8170
+ if (!fixes.length)
8171
+ return { problems };
8172
+ const { repo } = await resolveRepo(gh, config, resolve9(root2, config.app.source));
8173
+ const sleep = deps.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
8174
+ for (const fix of fixes) {
8175
+ const pr = fix.pr.number;
8176
+ const deadline = Date.now() + settings.waitMinutes * 6e4;
8177
+ for (; ; ) {
8178
+ const checks = await readChecks(gh, repo, pr);
8179
+ const pending = checks.filter((check) => check.bucket === "pending");
8180
+ const failed2 = checks.filter((check) => check.bucket === "fail" || check.bucket === "cancel");
8181
+ const save = async (state) => {
8182
+ fix.ci = {
8183
+ state,
8184
+ head: fix.commit,
8185
+ failing: failed2.map((check) => check.name),
8186
+ attempts: fix.ci?.attempts ?? 0,
8187
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
8188
+ };
8189
+ await workspace.saveFix(fix);
8190
+ };
8191
+ if (!checks.length) {
8192
+ await save("none");
8193
+ break;
8194
+ }
8195
+ if (pending.length && !failed2.length) {
8196
+ if (deps.wait && Date.now() < deadline) {
8197
+ await sleep(3e4);
8198
+ continue;
8199
+ }
8200
+ await save("pending");
8201
+ deps.onLog?.(`CI: PR #${pr} has ${pending.length} check(s) still running. The next cycle looks again.`);
8202
+ break;
8203
+ }
8204
+ if (!failed2.length) {
8205
+ await save("passed");
8206
+ deps.onLog?.(`CI: all ${checks.length} check(s) passed on PR #${pr}.`);
8207
+ break;
8208
+ }
8209
+ if ((fix.ci?.attempts ?? 0) >= settings.attempts) {
8210
+ await save("gave-up");
8211
+ 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.`;
8212
+ problems.push(problem);
8213
+ deps.onLog?.(problem);
8214
+ break;
8215
+ }
8216
+ await save("failed");
8217
+ deps.onLog?.(`CI: PR #${pr} failed ${failed2.map((check) => check.name).join(", ")}. The fixer tries to fix it.`);
8218
+ const pushed = await fixCi(root2, config, fix, failed2, await failureLog(gh, repo, failed2), deps);
8219
+ fix.ci = { ...fix.ci, attempts: (fix.ci?.attempts ?? 0) + 1 };
8220
+ await workspace.saveFix(fix);
8221
+ if (!pushed) {
8222
+ if (fix.ci.attempts >= settings.attempts) {
8223
+ const problem = `CI: the fixer could not fix PR #${pr}. A person must look at it.`;
8224
+ fix.ci = { ...fix.ci, state: "gave-up" };
8225
+ await workspace.saveFix(fix);
8226
+ problems.push(problem);
8227
+ deps.onLog?.(problem);
8228
+ break;
8229
+ }
8230
+ continue;
8231
+ }
8232
+ if (!deps.wait) {
8233
+ fix.ci = { ...fix.ci, state: "pending" };
8234
+ await workspace.saveFix(fix);
8235
+ break;
8236
+ }
8237
+ await sleep(3e4);
8238
+ }
8239
+ }
8240
+ return { problems };
8241
+ }
8242
+ async function fixCi(root2, config, fix, failed2, log, deps) {
8243
+ try {
8244
+ await access(fix.worktree);
8245
+ } catch {
8246
+ deps.onLog?.(`CI: the worktree of ${fix.issueId} is gone, so the fixer cannot change PR #${fix.pr.number}.`);
8247
+ return false;
8248
+ }
8249
+ const workspace = new Workspace(root2);
8250
+ const record = await workspace.startSession("fixer");
8251
+ const session = new AgentSession(root2, config, new Vars(config.app.secrets), record.id, "fixer", void 0, deps.onLog);
8252
+ const fixer = config.agents.fixer;
8253
+ const runtime = (deps.createRuntime ?? createRuntime)(fixer.use);
8254
+ session.emit({ kind: "fix", summary: `Fixing CI on PR #${fix.pr.number}: ${failed2.map((check) => check.name).join(", ")}` });
8255
+ let summary = "";
8256
+ let status = "failed";
8257
+ try {
8258
+ const outcome = await runtime.run({
8259
+ role: "fixer",
8260
+ sessionId: session.sessionId,
8261
+ workdir: fix.worktree,
8262
+ system: fixerSystem(lessonsFor(await workspace.readMemory(), "fixer")),
8263
+ prompt: ciPrompt(fix.pr.number, failed2, log),
8264
+ tools: [...runtime.label.startsWith("cli:") ? [] : modelTools(fix.worktree), ...lessonTools(session, "fixer"), finishTool()],
8265
+ maxSteps: fixer.maxSteps,
8266
+ budgetUsd: fixer.budgetUsd,
8267
+ timeoutMs: fixer.timeoutMs
8268
+ }, session.emit);
8269
+ summary = outcome.summary ?? "";
8270
+ if (outcome.stop !== "done")
8271
+ throw new Error(outcome.error ?? `Fixer stopped: ${outcome.stop}`);
8272
+ if (!await git4(fix.worktree, "status", "--porcelain")) {
8273
+ summary = summary || "The fixer made no change.";
8274
+ session.emit({ kind: "fix", summary: `No change for CI on PR #${fix.pr.number}: ${summary.split("\n")[0]}` });
8275
+ status = "finished";
8276
+ return false;
8277
+ }
8278
+ if (fixer.verify) {
8279
+ await promisify6(execFile6)("/bin/sh", ["-c", fixer.verify], { cwd: fix.worktree, timeout: 3e5, maxBuffer: 4 * 1024 * 1024 });
8280
+ }
8281
+ await git4(fix.worktree, "add", "-A");
8282
+ const committed = await commitFix(fix.worktree, "pass the CI checks", fixer.commitMessage);
8283
+ if (!committed.ok)
8284
+ throw new Error(`The commit hook rejected the commit: ${committed.reason}`);
8285
+ fix.commit = await git4(fix.worktree, "rev-parse", "HEAD");
8286
+ await git4(fix.worktree, "push", "origin", fix.branch);
8287
+ session.emit({ kind: "fix", summary: `Pushed a CI fix to PR #${fix.pr.number} (${fix.commit.slice(0, 7)})` });
8288
+ status = "finished";
8289
+ return true;
8290
+ } catch (error) {
8291
+ summary = String(error).slice(0, 500);
8292
+ session.emit({ kind: "error", summary: `CI fix failed on PR #${fix.pr.number}: ${summary}` });
8293
+ return false;
8294
+ } finally {
8295
+ await workspace.endSession(session.sessionId, { status, summary });
8296
+ await session.idle();
8297
+ }
8298
+ }
8299
+
8300
+ // ../agents/dist/patrol.js
8301
+ import { execFile as execFile7 } from "node:child_process";
8302
+ import { resolve as resolve10 } from "node:path";
8303
+ import { promisify as promisify7 } from "node:util";
8304
+ var exec5 = promisify7(execFile7);
8305
+ async function git5(cwd, ...args2) {
7967
8306
  return (await exec5("git", args2, { cwd })).stdout.trim();
7968
8307
  }
7969
8308
  async function pullSource(root2, config, onLog) {
@@ -7973,15 +8312,15 @@ async function pullSource(root2, config, onLog) {
7973
8312
  const slash = target.indexOf("/");
7974
8313
  const remote = target.slice(0, slash);
7975
8314
  const branch = target.slice(slash + 1);
7976
- const source = resolve9(root2, config.app.source);
8315
+ const source = resolve10(root2, config.app.source);
7977
8316
  try {
7978
- if (await git4(source, "status", "--porcelain", "--untracked-files=no")) {
8317
+ if (await git5(source, "status", "--porcelain", "--untracked-files=no")) {
7979
8318
  onLog?.(`Did not pull ${target}: the source repository has uncommitted changes.`);
7980
8319
  return false;
7981
8320
  }
7982
- await git4(source, "fetch", "-q", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`);
7983
- await git4(source, "checkout", "-q", "--detach", `${remote}/${branch}`);
7984
- onLog?.(`Pulled ${target} at ${await git4(source, "rev-parse", "--short", "HEAD")}.`);
8321
+ await git5(source, "fetch", "-q", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`);
8322
+ await git5(source, "checkout", "-q", "--detach", `${remote}/${branch}`);
8323
+ onLog?.(`Pulled ${target} at ${await git5(source, "rev-parse", "--short", "HEAD")}.`);
7985
8324
  return true;
7986
8325
  } catch (error) {
7987
8326
  const reason = error.stderr?.trim() || error.message;
@@ -7990,11 +8329,11 @@ async function pullSource(root2, config, onLog) {
7990
8329
  }
7991
8330
  }
7992
8331
  async function sourceCommit(root2, config) {
7993
- const source = resolve9(root2, config.app.source);
8332
+ const source = resolve10(root2, config.app.source);
7994
8333
  try {
7995
- if (await git4(source, "status", "--porcelain", "--untracked-files=no"))
8334
+ if (await git5(source, "status", "--porcelain", "--untracked-files=no"))
7996
8335
  return void 0;
7997
- return await git4(source, "rev-parse", "HEAD");
8336
+ return await git5(source, "rev-parse", "HEAD");
7998
8337
  } catch {
7999
8338
  return void 0;
8000
8339
  }
@@ -8021,12 +8360,13 @@ function wait(minutes) {
8021
8360
  });
8022
8361
  }
8023
8362
  async function runPatrol(options) {
8363
+ const problems = [];
8024
8364
  const { root: root2, config } = options;
8025
8365
  const workspace = new Workspace(root2);
8026
8366
  const previous = (await workspace.readAgents()).patrol;
8027
8367
  if (previous?.state === "running" && previous.pid && previous.pid !== process.pid && alive(previous.pid)) {
8028
8368
  options.onLog?.(`A patrol already runs (pid ${previous.pid}): did not start another.`);
8029
- return;
8369
+ return { problems };
8030
8370
  }
8031
8371
  const runtime = options.createRuntime ?? createRuntime;
8032
8372
  let interrupted = false;
@@ -8060,49 +8400,54 @@ async function runPatrol(options) {
8060
8400
  await syncGitHub(root2, config, { onLog: options.onLog });
8061
8401
  await cleanWorktrees(root2, config, { onLog: options.onLog });
8062
8402
  const commit = await sourceCommit(root2, config);
8063
- if (commit && commit === tested) {
8064
- options.onLog?.(`No new commit since the last cycle (${commit.slice(0, 7)}): skipped the cycle.`);
8065
- await idle();
8066
- if (options.once)
8067
- break;
8068
- continue;
8069
- }
8070
- for (const role of ["explorer", "judge", "fixer"]) {
8071
- if (!config.agents[role].enabled) {
8072
- await workspace.setAgentStatus(role, { state: "off" });
8073
- }
8074
- }
8075
- const vars = new Vars(config.app.secrets);
8076
- let app;
8077
- let driver;
8078
- try {
8079
- app = await startApp(config.app, { root: root2, vars, emit: options.onLog });
8080
- driver = (options.createDriver ?? createDriver)(config, vars.resolve.bind(vars));
8081
- await driver.connect();
8082
- let explorerId;
8083
- if (config.agents.explorer.enabled && !interrupted) {
8084
- const record = await workspace.startSession("explorer");
8085
- explorerId = record.id;
8086
- const session = new AgentSession(root2, config, vars, record.id, "explorer", driver, options.onLog);
8087
- activeSession = session;
8088
- if (!interrupted) {
8089
- await runExplorer(session, runtime(config.agents.explorer.use));
8090
- } else {
8091
- await workspace.endSession(record.id, { summary: "Interrupted" });
8403
+ const fresh = !commit || commit !== tested;
8404
+ if (!fresh) {
8405
+ options.onLog?.(`No new commit since the last cycle (${commit.slice(0, 7)}): skipped explore and judge. The pending fixes, retests, publish, and CI checks still run.`);
8406
+ }
8407
+ const before = problems.length;
8408
+ if (fresh) {
8409
+ for (const role of ["explorer", "judge", "fixer"]) {
8410
+ if (!config.agents[role].enabled) {
8411
+ await workspace.setAgentStatus(role, { state: "off" });
8092
8412
  }
8093
- activeSession = void 0;
8094
- }
8095
- if (config.agents.judge.enabled && explorerId && !interrupted) {
8096
- const record = await workspace.startSession("judge");
8097
- const session = new AgentSession(root2, config, vars, record.id, "judge", void 0, options.onLog);
8098
- activeSession = session;
8099
- await runJudge(session, runtime(config.agents.judge.use), { sessionIds: [explorerId] });
8100
8413
  }
8101
- } finally {
8414
+ const vars = new Vars(config.app.secrets);
8415
+ let app;
8416
+ let driver;
8102
8417
  try {
8103
- await driver?.close();
8418
+ app = await startApp(config.app, { root: root2, vars, emit: options.onLog });
8419
+ driver = (options.createDriver ?? createDriver)(config, vars.resolve.bind(vars));
8420
+ await driver.connect();
8421
+ let explorerId;
8422
+ if (config.agents.explorer.enabled && !interrupted) {
8423
+ const record = await workspace.startSession("explorer");
8424
+ explorerId = record.id;
8425
+ const session = new AgentSession(root2, config, vars, record.id, "explorer", driver, options.onLog);
8426
+ activeSession = session;
8427
+ if (!interrupted) {
8428
+ await runExplorer(session, runtime(config.agents.explorer.use));
8429
+ } else {
8430
+ await workspace.endSession(record.id, { summary: "Interrupted" });
8431
+ }
8432
+ activeSession = void 0;
8433
+ }
8434
+ if (config.agents.judge.enabled && explorerId && !interrupted) {
8435
+ const record = await workspace.startSession("judge");
8436
+ const session = new AgentSession(root2, config, vars, record.id, "judge", void 0, options.onLog);
8437
+ activeSession = session;
8438
+ await runJudge(session, runtime(config.agents.judge.use), { sessionIds: [explorerId] });
8439
+ }
8440
+ } catch (error) {
8441
+ activeSession = void 0;
8442
+ const problem = `Cycle ${cycle}: ${String(error)}`;
8443
+ problems.push(problem);
8444
+ options.onLog?.(problem);
8104
8445
  } finally {
8105
- await app?.stop();
8446
+ try {
8447
+ await driver?.close();
8448
+ } finally {
8449
+ await app?.stop();
8450
+ }
8106
8451
  }
8107
8452
  }
8108
8453
  if (!interrupted && config.agents.explorer.enabled && config.agents.judge.enabled) {
@@ -8116,16 +8461,21 @@ async function runPatrol(options) {
8116
8461
  isInterrupted: () => interrupted
8117
8462
  });
8118
8463
  }
8119
- if (!interrupted)
8120
- await runFixCycle(root2, config, {
8121
- createDriver: options.createDriver,
8122
- createRuntime: options.createRuntime,
8123
- onLog: options.onLog,
8124
- onSession: (session) => {
8125
- activeSession = session;
8126
- },
8127
- isInterrupted: () => interrupted
8128
- });
8464
+ const fixes = interrupted ? [] : await runFixCycle(root2, config, {
8465
+ createDriver: options.createDriver,
8466
+ createRuntime: options.createRuntime,
8467
+ onLog: options.onLog,
8468
+ onSession: (session) => {
8469
+ activeSession = session;
8470
+ },
8471
+ isInterrupted: () => interrupted
8472
+ });
8473
+ for (const fix of fixes) {
8474
+ const last = fix.retests?.at(-1);
8475
+ if (last?.outcome === "error")
8476
+ problems.push(`Cycle ${cycle}: the retest of ${fix.issueId} failed: ${last.reason}`);
8477
+ }
8478
+ const cycleFailed = problems.length > before;
8129
8479
  if (!interrupted && config.agents.github.enabled)
8130
8480
  await runPublisher(root2, config, {
8131
8481
  createRuntime: options.createRuntime,
@@ -8134,9 +8484,13 @@ async function runPatrol(options) {
8134
8484
  activeSession = session;
8135
8485
  }
8136
8486
  });
8487
+ if (!interrupted && config.agents.github.enabled) {
8488
+ const ci = await watchCi(root2, config, { createRuntime: options.createRuntime, onLog: options.onLog, wait: true });
8489
+ problems.push(...ci.problems.map((problem) => `Cycle ${cycle}: ${problem}`));
8490
+ }
8137
8491
  if (!interrupted && config.agents.github.enabled)
8138
8492
  await syncGitHub(root2, config, { onLog: options.onLog });
8139
- if (!interrupted && commit) {
8493
+ if (!interrupted && fresh && commit && !cycleFailed) {
8140
8494
  tested = commit;
8141
8495
  await workspace.setPatrol({ commit });
8142
8496
  }
@@ -8150,6 +8504,7 @@ async function runPatrol(options) {
8150
8504
  process.off("SIGINT", onSignal);
8151
8505
  process.off("SIGTERM", onSignal);
8152
8506
  }
8507
+ return { problems };
8153
8508
  }
8154
8509
 
8155
8510
  // ../cli/dist/commands/doctor.js
@@ -8198,15 +8553,13 @@ function runChecks(root2, config) {
8198
8553
  }
8199
8554
  function agentChecks(config, env = process.env) {
8200
8555
  const checks = [];
8201
- const roles3 = ["explorer", "judge", "fixer"].filter((role) => role !== "fixer" || config.agents.fixer.enabled);
8202
- for (const role of roles3) {
8556
+ const roles4 = ["explorer", "judge", "fixer"].filter((role) => role !== "fixer" || config.agents.fixer.enabled);
8557
+ for (const role of roles4) {
8203
8558
  const use = config.agents[role].use;
8204
8559
  const problem = runtimeProblem(role, use, env);
8205
8560
  const label = use.runtime === "cli" ? `cli: ${use.agent ?? use.command.split(" ")[0]}` : `${use.via}: ${use.model ?? "default model"}`;
8206
8561
  checks.push({ name: role, ok: !problem, detail: problem ?? `The ${role} uses ${label}`, fatal: true });
8207
8562
  }
8208
- const decider = resolveDecider(config.decisions, env);
8209
- checks.push({ name: "decider", ok: Boolean(decider.decider), detail: decider.reason, fatal: false });
8210
8563
  if (config.agents.github.enabled) {
8211
8564
  const installed = spawnSync("gh", ["--version"], { stdio: "ignore" }).status === 0;
8212
8565
  const loggedIn = installed && spawnSync("gh", ["auth", "status"], { stdio: "ignore" }).status === 0;
@@ -8221,12 +8574,12 @@ function doctorExitCode(checks) {
8221
8574
  // ../cli/dist/commands/init.js
8222
8575
  import { spawnSync as spawnSync2 } from "node:child_process";
8223
8576
  import { appendFileSync, existsSync as existsSync6, readFileSync as readFileSync5, readdirSync, writeFileSync as writeFileSync3, mkdirSync as mkdirSync2 } from "node:fs";
8224
- import { join as join13, relative as relative3 } from "node:path";
8577
+ import { join as join14, relative as relative4 } from "node:path";
8225
8578
  import { createInterface } from "node:readline/promises";
8226
8579
 
8227
8580
  // ../recon/dist/bringup.js
8228
8581
  import { existsSync as existsSync5, readFileSync as readFileSync4 } from "node:fs";
8229
- import { join as join12 } from "node:path";
8582
+ import { join as join13 } from "node:path";
8230
8583
  function detectBringUp(root2) {
8231
8584
  const candidates = [];
8232
8585
  for (const workflow of listWorkflows(root2)) {
@@ -8236,7 +8589,7 @@ function detectBringUp(root2) {
8236
8589
  candidates.push({ command: match[0].replace(/^run:\s*/, ""), rung: "precedent", source: workflow, confidence: 0.8 });
8237
8590
  }
8238
8591
  }
8239
- const pkgPath = join12(root2, "package.json");
8592
+ const pkgPath = join13(root2, "package.json");
8240
8593
  if (existsSync5(pkgPath)) {
8241
8594
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
8242
8595
  const manager = detectPackageManager(root2);
@@ -8251,20 +8604,20 @@ function detectBringUp(root2) {
8251
8604
  }
8252
8605
  }
8253
8606
  }
8254
- if (existsSync5(join12(root2, "docker-compose.yml")) || existsSync5(join12(root2, "compose.yaml"))) {
8607
+ if (existsSync5(join13(root2, "docker-compose.yml")) || existsSync5(join13(root2, "compose.yaml"))) {
8255
8608
  candidates.push({ command: "docker compose up", rung: "convention", source: "docker-compose.yml", confidence: 0.5 });
8256
8609
  }
8257
- if (existsSync5(join12(root2, "Procfile"))) {
8610
+ if (existsSync5(join13(root2, "Procfile"))) {
8258
8611
  candidates.push({ command: "foreman start", rung: "convention", source: "Procfile", confidence: 0.3 });
8259
8612
  }
8260
8613
  return candidates.sort((a, b) => b.confidence - a.confidence);
8261
8614
  }
8262
8615
  function detectPackageManager(root2) {
8263
- if (existsSync5(join12(root2, "pnpm-lock.yaml")))
8616
+ if (existsSync5(join13(root2, "pnpm-lock.yaml")))
8264
8617
  return "pnpm";
8265
- if (existsSync5(join12(root2, "bun.lockb")))
8618
+ if (existsSync5(join13(root2, "bun.lockb")))
8266
8619
  return "bun";
8267
- if (existsSync5(join12(root2, "yarn.lock")))
8620
+ if (existsSync5(join13(root2, "yarn.lock")))
8268
8621
  return "yarn";
8269
8622
  return "npm";
8270
8623
  }
@@ -8285,7 +8638,7 @@ function detectStack(root2) {
8285
8638
  ["app.json", "expo"]
8286
8639
  ];
8287
8640
  for (const [file, name] of signatures) {
8288
- if (!existsSync5(join12(root2, file)))
8641
+ if (!existsSync5(join13(root2, file)))
8289
8642
  continue;
8290
8643
  framework ??= name;
8291
8644
  detectedFrom.push(file);
@@ -8300,12 +8653,12 @@ function detectStack(root2) {
8300
8653
  };
8301
8654
  }
8302
8655
  function listWorkflows(root2) {
8303
- const dir = join12(root2, ".github", "workflows");
8656
+ const dir = join13(root2, ".github", "workflows");
8304
8657
  if (!existsSync5(dir))
8305
8658
  return [];
8306
8659
  try {
8307
8660
  const { readdirSync: readdirSync4 } = __require("node:fs");
8308
- return readdirSync4(dir).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml")).map((f) => join12(dir, f));
8661
+ return readdirSync4(dir).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml")).map((f) => join13(dir, f));
8309
8662
  } catch {
8310
8663
  return [];
8311
8664
  }
@@ -8319,11 +8672,6 @@ var KEY_PROVIDERS = {
8319
8672
  openai: { env: "OPENAI_API_KEY", label: "OpenAI", url: "https://platform.openai.com/api-keys", model: "gpt-5-mini" },
8320
8673
  anthropic: { env: "ANTHROPIC_API_KEY", label: "Anthropic", url: "https://console.anthropic.com/settings/keys", model: "claude-haiku-4-5" }
8321
8674
  };
8322
- var JEV_ROUTES2 = {
8323
- typesafe: { env: "TYPESAFE_API_KEY", label: "TypeSafe", url: "https://typesafe.ai" },
8324
- openrouter: { env: "OPENROUTER_API_KEY", label: "OpenRouter", url: "https://openrouter.ai/keys" },
8325
- vercel: { env: "AI_GATEWAY_API_KEY", label: "Vercel AI Gateway", url: "https://vercel.com/ai-gateway" }
8326
- };
8327
8675
  var CLI_LABELS = { claude: "Claude Code", codex: "Codex", kimi: "Kimi CLI", pi: "pi" };
8328
8676
  var run = (command2, args2) => spawnSync2(command2, args2, { encoding: "utf8", timeout: 1e4 }).stdout ?? "";
8329
8677
  function detectProviders(env = process.env, exec6 = run) {
@@ -8333,7 +8681,6 @@ function detectProviders(env = process.env, exec6 = run) {
8333
8681
  return {
8334
8682
  clis: found.filter((agent) => agent !== "pi" || !piWithoutMcp),
8335
8683
  keys: Object.keys(KEY_PROVIDERS).filter((key) => env[KEY_PROVIDERS[key].env]),
8336
- jev: Object.keys(JEV_ROUTES2).filter((route2) => env[JEV_ROUTES2[route2].env]),
8337
8684
  piWithoutMcp,
8338
8685
  piPermissionModes: piList.includes("pi-permission-modes")
8339
8686
  };
@@ -8368,18 +8715,18 @@ function list(dir) {
8368
8715
  }
8369
8716
  }
8370
8717
  function xcodeProjects(root2) {
8371
- return [".", "ios"].flatMap((dir) => list(join13(root2, dir)).filter((name) => name.endsWith(".xcodeproj")).map((name) => join13(root2, dir, name, "project.pbxproj")));
8718
+ return [".", "ios"].flatMap((dir) => list(join14(root2, dir)).filter((name) => name.endsWith(".xcodeproj")).map((name) => join14(root2, dir, name, "project.pbxproj")));
8372
8719
  }
8373
8720
  function gradleFiles(root2) {
8374
- return ["android/app", "app"].flatMap((dir) => ["build.gradle", "build.gradle.kts"].map((file) => join13(root2, dir, file))).filter((file) => existsSync6(file));
8721
+ return ["android/app", "app"].flatMap((dir) => ["build.gradle", "build.gradle.kts"].map((file) => join14(root2, dir, file))).filter((file) => existsSync6(file));
8375
8722
  }
8376
8723
  function detectAppId(root2, platform) {
8377
- const expo = readJson2(join13(root2, "app.json"))?.expo;
8724
+ const expo = readJson2(join14(root2, "app.json"))?.expo;
8378
8725
  const fromJson = platform === "ios" ? expo?.ios?.bundleIdentifier : expo?.android?.package;
8379
8726
  if (fromJson)
8380
8727
  return { appId: fromJson, source: "app.json" };
8381
8728
  for (const file of ["app.config.ts", "app.config.js"]) {
8382
- const match = readText(join13(root2, file)).match(platform === "ios" ? /bundleIdentifier:\s*['"`]([\w.-]+)['"`]/ : /package:\s*['"`]([\w.-]+)['"`]/);
8729
+ const match = readText(join14(root2, file)).match(platform === "ios" ? /bundleIdentifier:\s*['"`]([\w.-]+)['"`]/ : /package:\s*['"`]([\w.-]+)['"`]/);
8383
8730
  if (match)
8384
8731
  return { appId: match[1], source: file };
8385
8732
  }
@@ -8405,21 +8752,21 @@ function detectPort(root2, pkg, framework) {
8405
8752
  if (fromScript)
8406
8753
  return { port: Number(fromScript), source: "package.json" };
8407
8754
  for (const file of ["vite.config.ts", "vite.config.js", "vite.config.mjs"]) {
8408
- const match = readText(join13(root2, file)).match(/\bport:\s*(\d{2,5})\b/);
8755
+ const match = readText(join14(root2, file)).match(/\bport:\s*(\d{2,5})\b/);
8409
8756
  if (match)
8410
8757
  return { port: Number(match[1]), source: file };
8411
8758
  }
8412
- const fromEnv = readText(join13(root2, ".env")).match(/^PORT=(\d{2,5})\s*$/m)?.[1];
8759
+ const fromEnv = readText(join14(root2, ".env")).match(/^PORT=(\d{2,5})\s*$/m)?.[1];
8413
8760
  if (fromEnv)
8414
8761
  return { port: Number(fromEnv), source: ".env" };
8415
8762
  return { port: framework && FRAMEWORK_PORTS[framework] || 3e3, source: framework ? `the ${framework} default` : "the usual default" };
8416
8763
  }
8417
8764
  function mobilePlatform(root2) {
8418
- const hasIos = xcodeProjects(root2).length > 0 || existsSync6(join13(root2, "app.json")) || existsSync6(join13(root2, "ios"));
8765
+ const hasIos = xcodeProjects(root2).length > 0 || existsSync6(join14(root2, "app.json")) || existsSync6(join14(root2, "ios"));
8419
8766
  return process.platform === "darwin" && hasIos ? "ios" : "android";
8420
8767
  }
8421
8768
  function detectApp(root2) {
8422
- const pkg = readJson2(join13(root2, "package.json")) ?? {};
8769
+ const pkg = readJson2(join14(root2, "package.json")) ?? {};
8423
8770
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
8424
8771
  const notes = [];
8425
8772
  if (deps.electron) {
@@ -8427,8 +8774,8 @@ function detectApp(root2) {
8427
8774
  return { platform: "electron", start: "npx electron . --remote-debugging-port=9222", cdpPort: 9222, notes };
8428
8775
  }
8429
8776
  const reactNative = deps.expo || deps["react-native"];
8430
- const nativeIos = !existsSync6(join13(root2, "package.json")) && xcodeProjects(root2).length > 0;
8431
- const nativeAndroid = !existsSync6(join13(root2, "package.json")) && gradleFiles(root2).length > 0;
8777
+ const nativeIos = !existsSync6(join14(root2, "package.json")) && xcodeProjects(root2).length > 0;
8778
+ const nativeAndroid = !existsSync6(join14(root2, "package.json")) && gradleFiles(root2).length > 0;
8432
8779
  if (reactNative || nativeIos || nativeAndroid) {
8433
8780
  const platform = nativeIos ? "ios" : nativeAndroid ? "android" : mobilePlatform(root2);
8434
8781
  notes.push(`Platform ${platform}: detected from ${reactNative ? "package.json" : platform === "ios" ? "the Xcode project" : "the Gradle files"}.`);
@@ -8498,7 +8845,7 @@ function renderConfig(answers) {
8498
8845
  lines.push(` appId: ${answers.appId ?? "com.example.app"}${answers.appId ? "" : " # TODO: your bundle ID or package name"}`);
8499
8846
  lines.push(" # device: <simulator UDID or emulator serial> # default: the booted one");
8500
8847
  }
8501
- lines.push(" instructions: .bughunters/instructions.md # plain English for the explorer: sign in, main flows, never-do list", " secrets: [] # env var names the explorer may use as {{NAME}}, e.g. [TEST_PASSWORD]", "", "# Each agent runs on an LLM: a local agent CLI (claude, codex, kimi, pi) or an API key.", "agents:", " explorer: # uses the app and reports what looks wrong", ` use: ${useLine("explorer", answers.providers.explorer, answers.piPermissionModes)}`, " judge: # decides which reports are real bugs, and writes the issues", ` use: ${useLine("judge", answers.providers.judge, answers.piPermissionModes)}`, " fixer: # writes a fix in its own git worktree", " enabled: false # turn on when you want Bughunters to fix bugs", ` use: ${useLine("fixer", answers.providers.fixer, answers.piPermissionModes)}`, " github:", " enabled: false # turn on to open PRs and issues (needs the gh CLI)", "", "# Jev triages each finding in one fast, low-cost call, so the LLM judge sees only the real ones.", "# With no Jev key, a general model or the judge does this work, at a higher cost.", "decisions:", " decider: jev", ` jev: { via: ${answers.jev} }${answers.jev === "auto" ? " # tries TYPESAFE_API_KEY, OPENROUTER_API_KEY, AI_GATEWAY_API_KEY" : ""}`, "");
8848
+ lines.push(" instructions: .bughunters/instructions.md # plain English for the explorer: sign in, main flows, never-do list", " secrets: [] # env var names the explorer may use as {{NAME}}, e.g. [TEST_PASSWORD]", "", "# Each agent runs on an LLM: a local agent CLI (claude, codex, kimi, pi) or an API key.", "agents:", " explorer: # uses the app and reports what looks wrong", ` use: ${useLine("explorer", answers.providers.explorer, answers.piPermissionModes)}`, " judge: # decides which reports are real bugs, and writes the issues", ` use: ${useLine("judge", answers.providers.judge, answers.piPermissionModes)}`, " fixer: # writes a fix in its own git worktree", " enabled: false # turn on when you want Bughunters to fix bugs", ` use: ${useLine("fixer", answers.providers.fixer, answers.piPermissionModes)}`, " github:", " enabled: false # turn on to open PRs and issues (needs the gh CLI)", "");
8502
8849
  return lines.join("\n");
8503
8850
  }
8504
8851
  var INSTRUCTIONS = `# About the app
@@ -8521,7 +8868,7 @@ Example: Sign in with the email {{TEST_EMAIL}} and the password {{TEST_PASSWORD}
8521
8868
  - Do not make payments.
8522
8869
  `;
8523
8870
  function ignoreData(root2) {
8524
- const gitignore = join13(root2, ".gitignore");
8871
+ const gitignore = join14(root2, ".gitignore");
8525
8872
  const ignored = existsSync6(gitignore) ? readFileSync5(gitignore, "utf8") : "";
8526
8873
  const lines = ignored.split("\n");
8527
8874
  if (lines.some((line) => /^\/?\.bughunters\/runs\/?$/.test(line.trim())))
@@ -8538,7 +8885,7 @@ ${IGNORE_LINE}
8538
8885
  return true;
8539
8886
  }
8540
8887
  function relativeConfig(root2) {
8541
- const shown = relative3(process.cwd(), paths.config(root2));
8888
+ const shown = relative4(process.cwd(), paths.config(root2));
8542
8889
  return shown && !shown.startsWith("..") ? shown : paths.config(root2);
8543
8890
  }
8544
8891
  var IGNORE_LINE = `${BUGHUNTERS_DIR}/${DATA_DIR}/`;
@@ -8553,7 +8900,7 @@ function writeInitialConfig(root2, answers) {
8553
8900
  writeFileSync3(configPath, renderConfig(answers));
8554
8901
  result3.written.push(configName);
8555
8902
  }
8556
- const guidePath = join13(paths.dir(root2), "instructions.md");
8903
+ const guidePath = join14(paths.dir(root2), "instructions.md");
8557
8904
  if (existsSync6(guidePath))
8558
8905
  result3.skipped.push(`${BUGHUNTERS_DIR}/instructions.md`);
8559
8906
  else {
@@ -8568,12 +8915,6 @@ function writeInitialConfig(root2, answers) {
8568
8915
  result3.warnings.push(`Set ${route2.env} before you run Bughunters. Get a key at ${route2.url}`);
8569
8916
  }
8570
8917
  }
8571
- if (answers.jev !== "auto" && !process.env[JEV_ROUTES2[answers.jev].env]) {
8572
- result3.warnings.push(`Set ${JEV_ROUTES2[answers.jev].env} to use Jev. Get a key at ${JEV_ROUTES2[answers.jev].url}`);
8573
- }
8574
- if (answers.jev === "auto" && !Object.values(JEV_ROUTES2).some((route2) => process.env[route2.env])) {
8575
- result3.warnings.push("No Jev key is set, so Bughunters costs more. Get a key at https://typesafe.ai");
8576
- }
8577
8918
  return result3;
8578
8919
  }
8579
8920
  var PROVIDERS = [...CLI_AGENTS, ...Object.keys(KEY_PROVIDERS)];
@@ -8618,9 +8959,6 @@ function parseInitFlags(args2) {
8618
8959
  case "--fixer":
8619
8960
  flags[flag2.slice(2)] = oneOf(flag2, args2[++index], PROVIDERS);
8620
8961
  break;
8621
- case "--jev":
8622
- flags.jev = oneOf(flag2, args2[++index], ["typesafe", "openrouter", "vercel", "auto"]);
8623
- break;
8624
8962
  default:
8625
8963
  throw new ConfigError(`Unknown flag for init: ${flag2}`);
8626
8964
  }
@@ -8667,7 +9005,6 @@ function defaultAnswers(guess, detected, flags) {
8667
9005
  cdpPort: guess.cdpPort,
8668
9006
  ready: platform === guess.platform ? guess.ready : void 0,
8669
9007
  providers,
8670
- jev: flags.jev ?? detected.jev[0] ?? "auto",
8671
9008
  piPermissionModes: detected.piPermissionModes
8672
9009
  };
8673
9010
  }
@@ -8720,15 +9057,6 @@ async function interview(ask, root2, guess, detected, answers) {
8720
9057
  next.providers.explorer = await choose(ask, "Explorer: it uses the app and reports what looks wrong.", options, next.providers.explorer);
8721
9058
  next.providers.judge = await choose(ask, "Judge: it decides which reports are real bugs.", options, next.providers.explorer);
8722
9059
  next.providers.fixer = await choose(ask, "Fixer: it writes the fixes (off until you turn it on).", options, next.providers.fixer);
8723
- process.stdout.write("\nJev triages each finding in one fast, low-cost call. The LLM judge then sees only the real findings.\n");
8724
- const jevOptions = [
8725
- ...Object.keys(JEV_ROUTES2).map((route2) => ({
8726
- value: route2,
8727
- label: `${JEV_ROUTES2[route2].label} (${JEV_ROUTES2[route2].env}${detected.jev.includes(route2) ? " is set" : `, get a key at ${JEV_ROUTES2[route2].url}`})`
8728
- })),
8729
- { value: "auto", label: "Try each key in order (with no key, the LLM does this work at a higher cost)" }
8730
- ];
8731
- next.jev = await choose(ask, "How does Bughunters reach Jev?", jevOptions, next.jev === "auto" ? detected.jev[0] ?? "typesafe" : next.jev);
8732
9060
  return next;
8733
9061
  }
8734
9062
  async function runInit(root2, args2, log) {
@@ -8861,9 +9189,6 @@ determinism:
8861
9189
  blockThirdPartyRequests: true
8862
9190
 
8863
9191
  decisions:
8864
- decider: jev # jev | model
8865
- jev:
8866
- via: auto # auto | typesafe | openrouter | vercel
8867
9192
  model:
8868
9193
  via: auto # auto | openrouter | vercel | openai | anthropic | custom
8869
9194
  name: "" # optional model id override
@@ -8895,7 +9220,7 @@ production:
8895
9220
 
8896
9221
  // ../cli/dist/commands/run.js
8897
9222
  import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "node:fs";
8898
- import { join as join15 } from "node:path";
9223
+ import { join as join16 } from "node:path";
8899
9224
 
8900
9225
  // ../capture/dist/determinism.js
8901
9226
  var DETERMINISTIC_CHROMIUM_ARGS = [
@@ -9087,7 +9412,7 @@ async function assertNoFontFallback(page, failOnFallback) {
9087
9412
 
9088
9413
  // ../capture/dist/capture.js
9089
9414
  import { mkdirSync as mkdirSync3 } from "node:fs";
9090
- import { dirname as dirname3 } from "node:path";
9415
+ import { dirname as dirname4 } from "node:path";
9091
9416
 
9092
9417
  // ../capture/dist/pixel-contrast.js
9093
9418
  import { PNG as PNG3 } from "pngjs";
@@ -9160,7 +9485,7 @@ async function captureScreen(page, config, request) {
9160
9485
  const stability = await waitForStableFrame(page, config.determinism.stabilityGate);
9161
9486
  const masks = await resolveMasks(page, config.mask, request.url);
9162
9487
  const consoleErrors2 = collectedErrors.get(page) ?? [];
9163
- mkdirSync3(dirname3(request.outPath), { recursive: true });
9488
+ mkdirSync3(dirname4(request.outPath), { recursive: true });
9164
9489
  const buffer = await page.screenshot({
9165
9490
  path: request.outPath,
9166
9491
  fullPage: request.fullPage ?? true,
@@ -9298,7 +9623,7 @@ function bodyMentions(body, selector) {
9298
9623
  async function stopProcess(child) {
9299
9624
  if (child.exitCode !== null || child.pid === void 0)
9300
9625
  return;
9301
- const done = new Promise((resolve11) => child.once("exit", () => resolve11()));
9626
+ const done = new Promise((resolve12) => child.once("exit", () => resolve12()));
9302
9627
  try {
9303
9628
  process.kill(-child.pid, "SIGTERM");
9304
9629
  } catch {
@@ -9316,7 +9641,7 @@ async function stopProcess(child) {
9316
9641
 
9317
9642
  // ../cli/dist/commands/run-pipeline.js
9318
9643
  import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync5 } from "node:fs";
9319
- import { join as join14 } from "node:path";
9644
+ import { join as join15 } from "node:path";
9320
9645
 
9321
9646
  // ../triage/dist/ledger.js
9322
9647
  import { readFileSync as readFileSync6, writeFileSync as writeFileSync4, existsSync as existsSync7 } from "node:fs";
@@ -9418,8 +9743,8 @@ async function cluster(findings, options = {}) {
9418
9743
  function deterministicPass(findings, changedFilesByFinding) {
9419
9744
  const buckets = /* @__PURE__ */ new Map();
9420
9745
  for (const finding of findings) {
9421
- const files = changedFilesByFinding?.get(finding.id) ?? finding.suspectedFiles;
9422
- const key = [finding.ruleId, [...files].sort().join(",")].join("::");
9746
+ const files2 = changedFilesByFinding?.get(finding.id) ?? finding.suspectedFiles;
9747
+ const key = [finding.ruleId, [...files2].sort().join(",")].join("::");
9423
9748
  const bucket = buckets.get(key);
9424
9749
  if (bucket)
9425
9750
  bucket.push(finding);
@@ -9747,7 +10072,7 @@ async function executeRun(options) {
9747
10072
  console: screen.snapshot.consoleErrors,
9748
10073
  knownIntents: ledger.summaries()
9749
10074
  });
9750
- const estimate = decider ? estimateDecisionCost(text3.length, decider.name) : 0;
10075
+ const estimate = decider ? estimateDecisionCost(text3.length) : 0;
9751
10076
  let answers = {};
9752
10077
  if (!decider) {
9753
10078
  trace2.decision = {
@@ -9996,13 +10321,13 @@ function worstOf(a, b) {
9996
10321
  function writeArtifacts(root2, run2, findings, meta) {
9997
10322
  const dir = paths.run(root2, run2.id);
9998
10323
  mkdirSync4(dir, { recursive: true });
9999
- const reportPath = join14(dir, "report.html");
10324
+ const reportPath = join15(dir, "report.html");
10000
10325
  writeFileSync5(reportPath, renderHtml({ run: run2, findings, suppressed: meta.suppressed, quarantined: 0, notes: meta.notes }));
10001
- writeFileSync5(join14(dir, "junit.xml"), toJUnit(run2, findings));
10002
- writeFileSync5(join14(dir, "results.sarif"), toSarif(findings));
10003
- writeFileSync5(join14(dir, "run.json"), `${JSON.stringify({ run: run2, findings }, null, 2)}
10326
+ writeFileSync5(join15(dir, "junit.xml"), toJUnit(run2, findings));
10327
+ writeFileSync5(join15(dir, "results.sarif"), toSarif(findings));
10328
+ writeFileSync5(join15(dir, "run.json"), `${JSON.stringify({ run: run2, findings }, null, 2)}
10004
10329
  `);
10005
- writeFileSync5(join14(dir, "trace.json"), `${JSON.stringify(meta.trace, null, 2)}
10330
+ writeFileSync5(join15(dir, "trace.json"), `${JSON.stringify(meta.trace, null, 2)}
10006
10331
  `);
10007
10332
  return { reportPath, runDir: dir };
10008
10333
  }
@@ -10031,7 +10356,7 @@ async function runCommand(options) {
10031
10356
  if (server.external)
10032
10357
  log(`Using the app already serving at ${requireRun(config).url}`);
10033
10358
  const captured = [];
10034
- const runDir = join15(paths.runs(root2), "latest");
10359
+ const runDir = join16(paths.runs(root2), "latest");
10035
10360
  mkdirSync5(runDir, { recursive: true });
10036
10361
  const live = new LiveProgressWriter(root2, targets.length * config.viewports.length);
10037
10362
  live.start();
@@ -10135,7 +10460,7 @@ var LiveProgressWriter = class {
10135
10460
  async function captureOne(args2) {
10136
10461
  const { target, viewport, session, config, store, runDir } = args2;
10137
10462
  const slug2 = `${slugify(target.id)}--${viewport.name}`;
10138
- const actualPath = join15(runDir, `${slug2}.actual.png`);
10463
+ const actualPath = join16(runDir, `${slug2}.actual.png`);
10139
10464
  const output = await captureScreen(session.page, config, {
10140
10465
  screenId: target.id,
10141
10466
  url: target.url,
@@ -10160,7 +10485,7 @@ async function captureOne(args2) {
10160
10485
  missingFonts: output.missingFonts
10161
10486
  };
10162
10487
  }
10163
- const diffPath = join15(runDir, `${slug2}.diff.png`);
10488
+ const diffPath = join16(runDir, `${slug2}.diff.png`);
10164
10489
  const comparison = await diff({
10165
10490
  baselinePath,
10166
10491
  actualPath,
@@ -10223,7 +10548,7 @@ function loadBaselineSnapshot(root2, key) {
10223
10548
  }
10224
10549
  }
10225
10550
  function snapshotFile(root2, key) {
10226
- return join15(paths.baselines(root2), `${slugify(key)}-${shortHash(key, 8)}.snapshot.json`);
10551
+ return join16(paths.baselines(root2), `${slugify(key)}-${shortHash(key, 8)}.snapshot.json`);
10227
10552
  }
10228
10553
  function saveBaselineSnapshot(root2, key, snapshot) {
10229
10554
  mkdirSync5(paths.baselines(root2), { recursive: true });
@@ -10324,12 +10649,12 @@ function formatRunSummary(result3) {
10324
10649
  // ../dashboard/dist/server.js
10325
10650
  import { createServer as createServer2 } from "node:http";
10326
10651
  import { createReadStream, existsSync as existsSync10, realpathSync, statSync as statSync2, watch } from "node:fs";
10327
- import { extname, join as join18, relative as relative4, resolve as resolve10, sep as sep3 } from "node:path";
10652
+ import { extname, join as join19, relative as relative5, resolve as resolve11, sep as sep4 } from "node:path";
10328
10653
  import { fileURLToPath } from "node:url";
10329
10654
 
10330
10655
  // ../dashboard/dist/project.js
10331
10656
  import { existsSync as existsSync9, readFileSync as readFileSync8, readdirSync as readdirSync2, statSync } from "node:fs";
10332
- import { join as join16 } from "node:path";
10657
+ import { join as join17 } from "node:path";
10333
10658
  var ProjectReader = class {
10334
10659
  root;
10335
10660
  constructor(root2) {
@@ -10346,16 +10671,16 @@ var ProjectReader = class {
10346
10671
  const dir = paths.runs(this.root);
10347
10672
  if (!existsSync9(dir))
10348
10673
  return [];
10349
- return readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== "latest").map((e) => ({ name: e.name, mtime: safeMtime(join16(dir, e.name)) })).sort((a, b) => b.mtime - a.mtime).map((e) => e.name);
10674
+ return readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== "latest").map((e) => ({ name: e.name, mtime: safeMtime(join17(dir, e.name)) })).sort((a, b) => b.mtime - a.mtime).map((e) => e.name);
10350
10675
  }
10351
10676
  readRun(runId) {
10352
- const dir = join16(paths.runs(this.root), runId);
10353
- const runFile = join16(dir, "run.json");
10677
+ const dir = join17(paths.runs(this.root), runId);
10678
+ const runFile = join17(dir, "run.json");
10354
10679
  if (!existsSync9(runFile))
10355
10680
  return void 0;
10356
10681
  try {
10357
10682
  const parsed = JSON.parse(readFileSync8(runFile, "utf8"));
10358
- const traceFile = join16(dir, "trace.json");
10683
+ const traceFile = join17(dir, "trace.json");
10359
10684
  const trace = existsSync9(traceFile) ? JSON.parse(readFileSync8(traceFile, "utf8")) : void 0;
10360
10685
  return { id: runId, dir, run: parsed.run, findings: parsed.findings, trace, mtimeMs: safeMtime(dir) };
10361
10686
  } catch {
@@ -10441,8 +10766,8 @@ function safeMtime(path) {
10441
10766
 
10442
10767
  // ../dashboard/dist/agents.js
10443
10768
  import { readFileSync as readFileSync9, readdirSync as readdirSync3 } from "node:fs";
10444
- import { basename as basename4, join as join17 } from "node:path";
10445
- var roles = ["explorer", "judge", "fixer"];
10769
+ import { basename as basename4, join as join18 } from "node:path";
10770
+ var roles2 = ["explorer", "judge", "fixer"];
10446
10771
  var severity = { critical: 0, major: 1, minor: 2, cosmetic: 3 };
10447
10772
  var open = /* @__PURE__ */ new Set(["new", "filed", "fixing", "fix-proposed"]);
10448
10773
  function readJson3(file) {
@@ -10498,7 +10823,7 @@ var AgentReader = class {
10498
10823
  const value = readJson3(paths.agents(this.root));
10499
10824
  if (!Array.isArray(value?.agents))
10500
10825
  return void 0;
10501
- const agents = value.agents.filter((agent) => agent && roles.includes(agent.role) && typeof agent.state === "string").map((agent) => {
10826
+ const agents = value.agents.filter((agent) => agent && roles2.includes(agent.role) && typeof agent.state === "string").map((agent) => {
10502
10827
  const busy = agent.state === "working" || agent.state === "waiting";
10503
10828
  return busy && !processAlive(agent.pid) ? { ...agent, state: "idle", activity: STOPPED } : agent;
10504
10829
  });
@@ -10513,28 +10838,31 @@ var AgentReader = class {
10513
10838
  }
10514
10839
  issues() {
10515
10840
  const fixes = this.fixes();
10516
- return list2(paths.issues(this.root)).filter((name) => name.endsWith(".json")).map((name) => readJson3(join17(paths.issues(this.root), name))).filter((issue) => Boolean(issue?.id && issue.title && issue.lastSeenAt && issue.evidence && issue.judgement && Array.isArray(issue.candidateIds))).map((issue) => ({ ...issue, pr: fixes.find((fix) => fix.id === issue.fixId || fix.issueId === issue.id)?.pr })).sort((a, b) => (severity[a.severity] ?? 9) - (severity[b.severity] ?? 9) || b.lastSeenAt.localeCompare(a.lastSeenAt));
10841
+ return list2(paths.issues(this.root)).filter((name) => name.endsWith(".json")).map((name) => readJson3(join18(paths.issues(this.root), name))).filter((issue) => Boolean(issue?.id && issue.title && issue.lastSeenAt && issue.evidence && issue.judgement && Array.isArray(issue.candidateIds))).map((issue) => {
10842
+ const fix = fixes.find((entry) => entry.id === issue.fixId || entry.issueId === issue.id);
10843
+ return { ...issue, pr: fix?.pr ? { ...fix.pr, ci: fix.ci?.state } : void 0 };
10844
+ }).sort((a, b) => (severity[a.severity] ?? 9) - (severity[b.severity] ?? 9) || b.lastSeenAt.localeCompare(a.lastSeenAt));
10517
10845
  }
10518
10846
  fixes() {
10519
- return list2(paths.fixes(this.root)).filter((name) => name.endsWith(".json")).map((name) => readJson3(join17(paths.fixes(this.root), name))).filter((fix) => Boolean(fix?.id && fix.issueId));
10847
+ return list2(paths.fixes(this.root)).filter((name) => name.endsWith(".json")).map((name) => readJson3(join18(paths.fixes(this.root), name))).filter((fix) => Boolean(fix?.id && fix.issueId));
10520
10848
  }
10521
10849
  routines() {
10522
- return list2(paths.routines(this.root)).filter((name) => name.endsWith(".json")).map((name) => readJson3(join17(paths.routines(this.root), name))).filter((routine) => Boolean(routine?.id && Array.isArray(routine.steps)));
10850
+ return list2(paths.routines(this.root)).filter((name) => name.endsWith(".json")).map((name) => readJson3(join18(paths.routines(this.root), name))).filter((routine) => Boolean(routine?.id && Array.isArray(routine.steps)));
10523
10851
  }
10524
10852
  sessions(limit = 50) {
10525
- return list2(paths.sessions(this.root)).filter((id2) => /^[\w-]+$/.test(id2)).map((id2) => ({ id: id2, value: readJson3(join17(paths.session(this.root, id2), "session.json")) })).filter(({ id: id2, value }) => value?.id === id2).map(({ value }) => value).filter((session) => Boolean(session?.id && session.startedAt && Array.isArray(session.screensFound) && Array.isArray(session.issues))).map((session) => session.status === "running" && !processAlive(session.pid) ? { ...session, status: "failed", summary: session.summary ?? `Interrupted. ${STOPPED}` } : session).sort((a, b) => b.startedAt.localeCompare(a.startedAt)).slice(0, limit);
10853
+ return list2(paths.sessions(this.root)).filter((id2) => /^[\w-]+$/.test(id2)).map((id2) => ({ id: id2, value: readJson3(join18(paths.session(this.root, id2), "session.json")) })).filter(({ id: id2, value }) => value?.id === id2).map(({ value }) => value).filter((session) => Boolean(session?.id && session.startedAt && Array.isArray(session.screensFound) && Array.isArray(session.issues))).map((session) => session.status === "running" && !processAlive(session.pid) ? { ...session, status: "failed", summary: session.summary ?? `Interrupted. ${STOPPED}` } : session).sort((a, b) => b.startedAt.localeCompare(a.startedAt)).slice(0, limit);
10526
10854
  }
10527
10855
  session(id2) {
10528
10856
  const session = this.sessions(Infinity).find((entry) => entry.id === id2);
10529
10857
  if (!session)
10530
10858
  return void 0;
10531
10859
  const dir = paths.session(this.root, id2);
10532
- const events = jsonLines(join17(dir, "events.jsonl"));
10860
+ const events = jsonLines(join18(dir, "events.jsonl"));
10533
10861
  return {
10534
10862
  // A running session has no totals yet: add up its events so far.
10535
10863
  session: session.status === "running" ? { ...session, ...usageOf(events) } : session,
10536
10864
  events,
10537
- candidates: jsonLines(join17(dir, "candidates.jsonl"))
10865
+ candidates: jsonLines(join18(dir, "candidates.jsonl"))
10538
10866
  };
10539
10867
  }
10540
10868
  issue(id2) {
@@ -10544,7 +10872,7 @@ var AgentReader = class {
10544
10872
  const fix = this.fixes().find((entry) => entry.id === issue.fixId || entry.issueId === id2);
10545
10873
  const routine = this.routines().find((entry) => entry.id === issue.evidence?.routineId);
10546
10874
  const ids = new Set(issue.candidateIds);
10547
- const candidates = this.sessions(Infinity).flatMap((session) => jsonLines(join17(paths.session(this.root, session.id), "candidates.jsonl"))).filter((candidate) => ids.has(candidate.id));
10875
+ const candidates = this.sessions(Infinity).flatMap((session) => jsonLines(join18(paths.session(this.root, session.id), "candidates.jsonl"))).filter((candidate) => ids.has(candidate.id));
10548
10876
  return { issue, fix, candidates, routine };
10549
10877
  }
10550
10878
  screens() {
@@ -10593,6 +10921,10 @@ var AgentReader = class {
10593
10921
  edges.push({ from: "__start", to: screen.id, kind: "route", via: "route", count: 1, steps: 0 });
10594
10922
  }
10595
10923
  }
10924
+ if (map.screens.length && !edges.some((edge) => edge.from === "__start")) {
10925
+ const entry = byRoutine.get("enter-app")?.requires?.map((id2) => screenByRoutine.get(id2)).find(Boolean) ?? map.screens[0].id;
10926
+ edges.push({ from: "__start", to: entry, kind: "route", via: "route", count: 1, steps: 0 });
10927
+ }
10596
10928
  const screens = map.screens.map((screen) => ({
10597
10929
  ...screen,
10598
10930
  openIssues: issues.filter((issue) => issue.screenId === screen.id).length
@@ -10601,16 +10933,30 @@ var AgentReader = class {
10601
10933
  screens.push({ id: "__start", name: "App start", virtual: true, openIssues: 0 });
10602
10934
  return { ...map, screens, edges, entryId: screens.length ? "__start" : void 0 };
10603
10935
  }
10936
+ /** Each role as the config sets it, in the same form as the runtime labels. */
10937
+ configuredAgents() {
10938
+ try {
10939
+ const config = loadConfig(this.root);
10940
+ return Object.fromEntries(roles2.map((role) => {
10941
+ const { enabled, use } = config.agents[role];
10942
+ const runtime = use.runtime === "cli" ? `cli:${use.command.split(/\s+/)[0]}` : `model:${use.via}/${use.model}`;
10943
+ return [role, { enabled, runtime }];
10944
+ }));
10945
+ } catch {
10946
+ return void 0;
10947
+ }
10948
+ }
10604
10949
  overview(now = /* @__PURE__ */ new Date()) {
10605
10950
  const agentFile = this.agents();
10606
10951
  const appmap = this.appmap();
10607
10952
  const issues = this.issues();
10608
10953
  const sessions = this.sessions(Infinity);
10609
10954
  const today = now.toLocaleDateString("en-CA");
10610
- const agents = roles.map((role) => agentFile?.agents.find((agent) => agent.role === role) ?? {
10955
+ const configured = this.configuredAgents();
10956
+ const agents = roles2.map((role) => agentFile?.agents.find((agent) => agent.role === role) ?? {
10611
10957
  role,
10612
- state: "off",
10613
- runtime: "",
10958
+ state: configured?.[role].enabled === false ? "off" : configured ? "idle" : "off",
10959
+ runtime: configured?.[role].runtime ?? "",
10614
10960
  updatedAt: "",
10615
10961
  spentUsd: 0
10616
10962
  });
@@ -10620,9 +10966,9 @@ var AgentReader = class {
10620
10966
  const screenshot = [...detail?.events ?? []].reverse().find((event) => event.screenshot)?.screenshot;
10621
10967
  const openIssues = issues.filter((issue) => open.has(issue.status));
10622
10968
  const fixes = this.fixes();
10623
- const attention = openIssues.filter((issue) => ["new", "filed", "fix-proposed"].includes(issue.status)).slice(0, 8).map((issue) => {
10969
+ const attention = openIssues.slice(0, 8).map((issue) => {
10624
10970
  const fix = fixes.find((entry) => entry.id === issue.fixId || entry.issueId === issue.id);
10625
- return { ...issue, pr: fix?.pr, fix: fix ? { status: fix.status } : void 0 };
10971
+ return { ...issue, pr: fix?.pr ? { ...fix.pr, ci: fix.ci?.state } : void 0, fix: fix ? { status: fix.status } : void 0 };
10626
10972
  });
10627
10973
  const prStates = fixes.map((fix) => fix.pr?.state).filter(Boolean);
10628
10974
  const issueStates = issues.map((issue) => issue.github?.state).filter(Boolean);
@@ -10674,7 +11020,7 @@ function usageReport(sessions, now = /* @__PURE__ */ new Date()) {
10674
11020
  rows.set(key, row);
10675
11021
  }
10676
11022
  }
10677
- const week = [...rows.values()].sort((a, b) => roles.indexOf(a.role) - roles.indexOf(b.role) || b.tokens.input + b.tokens.output - (a.tokens.input + a.tokens.output));
11023
+ const week = [...rows.values()].sort((a, b) => roles2.indexOf(a.role) - roles2.indexOf(b.role) || b.tokens.input + b.tokens.output - (a.tokens.input + a.tokens.output));
10678
11024
  return { todayByRole, week, ...weekTotal ? { weekTotal } : {} };
10679
11025
  }
10680
11026
  function isFeedEvent(event) {
@@ -10942,7 +11288,7 @@ function hostOf(href) {
10942
11288
  }
10943
11289
 
10944
11290
  // ../dashboard/dist/server.js
10945
- var UI_DIR = resolve10(fileURLToPath(new URL(".", import.meta.url)), "..", "src", "ui");
11291
+ var UI_DIR = resolve11(fileURLToPath(new URL(".", import.meta.url)), "..", "src", "ui");
10946
11292
  var MIME = {
10947
11293
  ".html": "text/html; charset=utf-8",
10948
11294
  ".js": "text/javascript; charset=utf-8",
@@ -10953,7 +11299,7 @@ var MIME = {
10953
11299
  ".json": "application/json; charset=utf-8"
10954
11300
  };
10955
11301
  async function startDashboard(options) {
10956
- const root2 = resolve10(options.root);
11302
+ const root2 = resolve11(options.root);
10957
11303
  const host = options.host ?? "127.0.0.1";
10958
11304
  const reader = new ProjectReader(root2);
10959
11305
  const agents = new AgentReader(root2);
@@ -11048,7 +11394,7 @@ async function handle2(req, res, ctx) {
11048
11394
  return serveUi(res, path);
11049
11395
  }
11050
11396
  function resolveArtifactPath(root2, requested) {
11051
- const candidate = resolve10(root2, requested);
11397
+ const candidate = resolve11(root2, requested);
11052
11398
  if (!existsSync10(candidate))
11053
11399
  return void 0;
11054
11400
  let real;
@@ -11059,8 +11405,8 @@ function resolveArtifactPath(root2, requested) {
11059
11405
  } catch {
11060
11406
  return void 0;
11061
11407
  }
11062
- const rel = relative4(allowed, real);
11063
- if (rel === "" || rel.startsWith("..") || rel.startsWith(`..${sep3}`))
11408
+ const rel = relative5(allowed, real);
11409
+ if (rel === "" || rel.startsWith("..") || rel.startsWith(`..${sep4}`))
11064
11410
  return void 0;
11065
11411
  const stat = statSync2(real);
11066
11412
  if (!stat.isFile())
@@ -11081,7 +11427,7 @@ function serveArtifact(res, root2, requested) {
11081
11427
  }
11082
11428
  function serveUi(res, path) {
11083
11429
  const name = path === "/" ? "index.html" : path.replace(/^\//, "");
11084
- const file = resolve10(UI_DIR, name);
11430
+ const file = resolve11(UI_DIR, name);
11085
11431
  if (!file.startsWith(UI_DIR) || !existsSync10(file) || !statSync2(file).isFile()) {
11086
11432
  return serveUi(res, "/");
11087
11433
  }
@@ -11214,6 +11560,7 @@ function parseAgentFlags(command2, args2) {
11214
11560
  fix: ["--issue"],
11215
11561
  retest: ["--issue"],
11216
11562
  publish: ["--issue"],
11563
+ ci: ["--issue"],
11217
11564
  patrol: []
11218
11565
  };
11219
11566
  if (!values[command2])
@@ -11224,6 +11571,10 @@ function parseAgentFlags(command2, args2) {
11224
11571
  flags[flag2.slice(2)] = true;
11225
11572
  continue;
11226
11573
  }
11574
+ if (command2 === "ci" && flag2 === "--wait") {
11575
+ flags.wait = true;
11576
+ continue;
11577
+ }
11227
11578
  if (command2 === "publish" && flag2 === "--dry-run") {
11228
11579
  flags.dryRun = true;
11229
11580
  continue;
@@ -11261,6 +11612,8 @@ function rolesFor(command2, config) {
11261
11612
  case "judge":
11262
11613
  case "publish":
11263
11614
  return ["judge"];
11615
+ case "ci":
11616
+ return ["fixer"];
11264
11617
  case "retest":
11265
11618
  return ["explorer", "judge"];
11266
11619
  case "fix":
@@ -11275,15 +11628,36 @@ function preflight(command2, config, env = process.env) {
11275
11628
  const problems = rolesFor(command2, config).map((role) => runtimeProblem(role, config.agents[role].use, env)).filter((problem) => Boolean(problem));
11276
11629
  if (problems.length)
11277
11630
  throw new ConfigError(problems.join("\n"));
11278
- return ["explore", "patrol"].includes(command2) ? [resolveDecider(config.decisions, env).reason] : [];
11279
11631
  }
11280
11632
  async function runAgentCommand(command2, args2, root2, config, log) {
11281
11633
  const flags = parseAgentFlags(command2, args2);
11282
- for (const line of preflight(command2, config))
11283
- log(line);
11634
+ preflight(command2, config);
11284
11635
  const workspace = new Workspace(root2);
11285
11636
  if (command2 === "patrol") {
11286
- await runPatrol({ root: root2, config, once: Boolean(flags.once), force: Boolean(flags.force), onLog: log });
11637
+ const { problems } = await runPatrol({ root: root2, config, once: Boolean(flags.once), force: Boolean(flags.force), onLog: log });
11638
+ if (problems.length) {
11639
+ log(`The patrol had ${problems.length} problem(s):
11640
+ ${problems.map((problem) => `- ${problem.split("\n")[0]}`).join("\n")}`);
11641
+ process.exitCode = 1;
11642
+ }
11643
+ return;
11644
+ }
11645
+ if (command2 === "ci") {
11646
+ if (!config.agents.github.enabled) {
11647
+ throw new ConfigError("GitHub is off. Set agents.github.enabled: true in .bughunters/bughunters.yml.");
11648
+ }
11649
+ const { problems } = await watchCi(root2, config, {
11650
+ onLog: log,
11651
+ wait: Boolean(flags.wait),
11652
+ issueIds: flags.issue
11653
+ });
11654
+ const fixes = (await workspace.listFixes()).filter((fix) => fix.pr && fix.ci && (!flags.issue || flags.issue.includes(fix.issueId)));
11655
+ if (!fixes.length)
11656
+ log("No open Bughunters PR has CI checks yet.");
11657
+ for (const fix of fixes)
11658
+ log(` PR #${fix.pr.number} ${fix.ci.state.padEnd(8)} ${fix.issueId} ${fix.pr.url}`);
11659
+ if (problems.length)
11660
+ process.exitCode = 1;
11287
11661
  return;
11288
11662
  }
11289
11663
  if (command2 === "publish") {
@@ -11475,7 +11849,7 @@ function bySeverity(a, b) {
11475
11849
  }
11476
11850
 
11477
11851
  // ../cli/dist/commands/memory.js
11478
- var roles2 = /* @__PURE__ */ new Set(["explorer", "judge", "fixer"]);
11852
+ var roles3 = /* @__PURE__ */ new Set(["explorer", "judge", "fixer"]);
11479
11853
  var flag = (args2, name) => {
11480
11854
  const at = args2.indexOf(name);
11481
11855
  return at < 0 ? void 0 : args2[at + 1];
@@ -11485,7 +11859,7 @@ async function runMemoryCommand(args2, root2, log) {
11485
11859
  const workspace = new Workspace(root2);
11486
11860
  if (action === "list") {
11487
11861
  const role = flag(args2.slice(1), "--role");
11488
- if (role && !roles2.has(role))
11862
+ if (role && !roles3.has(role))
11489
11863
  throw new ConfigError(`Unknown memory role: ${role}`);
11490
11864
  const lessons = (await workspace.readMemory()).lessons.filter((lesson) => !role || lesson.role === role);
11491
11865
  log("id role hits source scope text");
@@ -11498,7 +11872,7 @@ async function runMemoryCommand(args2, root2, log) {
11498
11872
  if (action === "add") {
11499
11873
  const role = flag(args2.slice(1), "--role");
11500
11874
  const text3 = args2.slice(1).find((part, index, all) => !part.startsWith("--") && all[index - 1] !== "--role" && all[index - 1] !== "--scope");
11501
- if (!role || !roles2.has(role) || !text3?.trim())
11875
+ if (!role || !roles3.has(role) || !text3?.trim())
11502
11876
  throw new ConfigError('Usage: bughunters memory add --role explorer|judge|fixer "text" [--scope s]');
11503
11877
  const [lesson] = await workspace.upsertLessons([{
11504
11878
  role,
@@ -11532,7 +11906,7 @@ var [command, ...args] = process.argv.slice(2);
11532
11906
  var root = findProjectRoot(process.cwd());
11533
11907
  function version() {
11534
11908
  if (true)
11535
- return "0.1.1";
11909
+ return "0.1.2";
11536
11910
  try {
11537
11911
  return JSON.parse(readFileSync10(new URL("../package.json", import.meta.url), "utf8")).version;
11538
11912
  } catch {
@@ -11596,6 +11970,7 @@ try {
11596
11970
  case "fix":
11597
11971
  case "retest":
11598
11972
  case "publish":
11973
+ case "ci":
11599
11974
  case "patrol":
11600
11975
  case "replay": {
11601
11976
  const config = loadConfig(root);