bughunters 0.1.0 → 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,12 +379,25 @@ 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),
366
394
  /** Stop after this many cycles; 0 means run until stopped. */
367
- cycles: z.number().int().nonnegative().default(0)
395
+ cycles: z.number().int().nonnegative().default(0),
396
+ /**
397
+ * At the start of each cycle, fetch this `remote/branch` and check out its
398
+ * latest commit (detached) in the source repository. false keeps the checkout.
399
+ */
400
+ pull: z.union([z.string().regex(/^[^/]+\/.+$/, "Use remote/branch, e.g. origin/main"), z.literal(false)]).default("origin/main")
368
401
  }).default({})
369
402
  }).default({});
370
403
  var bughuntersConfigSchema = z.object({
@@ -695,9 +728,10 @@ Agents
695
728
  bughunters fix [--issue <id>...]
696
729
  bughunters retest --issue <id>
697
730
  bughunters publish [--issue <id>] [--dry-run]
731
+ bughunters ci [--issue <id>] [--wait]
698
732
  bughunters github sync
699
733
  bughunters worktrees clean
700
- bughunters patrol [--once]
734
+ bughunters patrol [--once] [--force]
701
735
  bughunters replay <routine-id>
702
736
  bughunters issue list | dismiss <id> --reason "..." [--by name] | reopen <id>
703
737
  bughunters memory list [--role r] | add --role r "text" [--scope s]
@@ -743,7 +777,6 @@ var COMMAND_HELP = {
743
777
  --app-id <id> ios, android: the bundle ID or the package name
744
778
  --agent <a> the LLM for all agents: claude | codex | kimi | pi | openrouter | vercel | openai | anthropic
745
779
  --explorer, --judge, --fixer <a> the LLM for one agent
746
- --jev <route> auto | typesafe | openrouter | vercel
747
780
  --gate write a starter config for the deterministic web gate
748
781
  `,
749
782
  explore: `bughunters explore [--goal "..."] [--steps N]
@@ -772,10 +805,20 @@ var COMMAND_HELP = {
772
805
  --dry-run write the reports to .bughunters/runs/publish/, and open nothing
773
806
  --issue <id> publish this issue only
774
807
  `,
775
- patrol: `bughunters patrol [--once]
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
814
+ `,
815
+ patrol: `bughunters patrol [--once] [--force]
776
816
  Run the full cycle again and again: setup, explore, judge, teardown, fix, retest, publish.
777
817
  The fixer and GitHub steps run only when you turn them on.
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.
778
820
  --once run one cycle, then stop
821
+ --force explore and judge in the first cycle also when the commit did not change
779
822
  `,
780
823
  github: `bughunters github sync
781
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.
@@ -910,9 +953,9 @@ async function startApp(app, opts) {
910
953
  if (command2.readyWhen) {
911
954
  const ready = new RegExp(command2.readyWhen);
912
955
  await new Promise((done, reject) => {
913
- const wait = setTimeout(() => finish(new Error("timed out waiting for ready output")), command2.timeoutMs);
956
+ const wait2 = setTimeout(() => finish(new Error("timed out waiting for ready output")), command2.timeoutMs);
914
957
  const finish = (error) => {
915
- clearTimeout(wait);
958
+ clearTimeout(wait2);
916
959
  child.stdout?.off("data", check);
917
960
  child.stderr?.off("data", check);
918
961
  child.off("exit", onExit);
@@ -1037,8 +1080,8 @@ function serialized(file, update) {
1037
1080
  }
1038
1081
  async function listJson(dir) {
1039
1082
  try {
1040
- const files = (await readdir(dir)).filter((file) => file.endsWith(".json"));
1041
- 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))));
1042
1085
  } catch (error) {
1043
1086
  if (error.code === "ENOENT") {
1044
1087
  return [];
@@ -1222,8 +1265,8 @@ var Workspace = class {
1222
1265
  async saveScreenshot(sessionId, png, label) {
1223
1266
  const dir = paths.session(this.root, sessionId);
1224
1267
  await mkdir(dir, { recursive: true });
1225
- const files = await readdir(dir);
1226
- 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;
1227
1270
  const file = join3(dir, `${String(number).padStart(3, "0")}-${basename(label).replace(/[^a-zA-Z0-9_-]/g, "-")}.png`);
1228
1271
  await writeFile(file, png);
1229
1272
  return relative(this.root, file);
@@ -1456,64 +1499,7 @@ function toMcp(part) {
1456
1499
  import { accessSync, constants } from "node:fs";
1457
1500
  import { delimiter, join as join5 } from "node:path";
1458
1501
 
1459
- // ../decide/dist/providers/jev.js
1460
- var JevDecider = class {
1461
- options;
1462
- name = "jev";
1463
- endpoint;
1464
- model;
1465
- lastUsage;
1466
- timeoutMs;
1467
- constructor(options) {
1468
- this.options = options;
1469
- this.endpoint = options.endpoint ?? "https://api.typesafe.ai/v1/systemone";
1470
- this.model = options.model ?? "jev-latest";
1471
- this.timeoutMs = options.timeoutMs ?? 1e4;
1472
- }
1473
- async ask(state, questions) {
1474
- const controller = new AbortController();
1475
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
1476
- try {
1477
- const response3 = await fetch(this.endpoint, {
1478
- method: "POST",
1479
- headers: {
1480
- "content-type": "application/json",
1481
- authorization: `Bearer ${this.options.apiKey}`
1482
- },
1483
- // Every question is evaluated against the same state in one call, so
1484
- // the entire per-screen analysis is a single round trip.
1485
- body: JSON.stringify({
1486
- model: this.model,
1487
- state,
1488
- questions: Object.fromEntries(Object.entries(questions).map(([id2, question]) => [
1489
- id2,
1490
- question.type === "score" ? { type: "score", instructions: question.instructions, criteria: question.legend } : question
1491
- ]))
1492
- }),
1493
- signal: controller.signal
1494
- });
1495
- if (!response3.ok) {
1496
- throw new InfrastructureError(`Jev returned ${response3.status}: ${await safeText(response3)}`);
1497
- }
1498
- const body = await response3.json();
1499
- this.lastUsage = usageFrom(body.usage) ?? { input: Math.ceil(state.length / 4), output: 0, estimated: true };
1500
- return normalizeAnswers(body.answers ?? {}, questions);
1501
- } catch (cause) {
1502
- if (cause instanceof InfrastructureError)
1503
- throw cause;
1504
- throw new InfrastructureError("Jev request failed", { cause });
1505
- } finally {
1506
- clearTimeout(timer);
1507
- }
1508
- }
1509
- };
1510
- async function safeText(response3) {
1511
- try {
1512
- return (await response3.text()).slice(0, 400);
1513
- } catch {
1514
- return "<no body>";
1515
- }
1516
- }
1502
+ // ../decide/dist/answers.js
1517
1503
  function normalizeAnswers(raw, questions) {
1518
1504
  const out = {};
1519
1505
  for (const [key, question] of Object.entries(questions)) {
@@ -1940,19 +1926,11 @@ var Budget = class {
1940
1926
  return { spentUsd: this.spent, capUsd: this.capUsd };
1941
1927
  }
1942
1928
  };
1943
- var JEV_USD_PER_INPUT_TOKEN = 0.042 / 1e6;
1944
- function estimateDecisionCost(stateChars, decider = "jev") {
1945
- if (decider === "jev")
1946
- return stateChars / 4 * JEV_USD_PER_INPUT_TOKEN;
1929
+ function estimateDecisionCost(stateChars) {
1947
1930
  return ((stateChars / 4 + 1500) * 3 + 900 * 15) / 1e6;
1948
1931
  }
1949
1932
 
1950
1933
  // ../decide/dist/index.js
1951
- var JEV_ROUTES = {
1952
- typesafe: { key: "TYPESAFE_API_KEY", endpoint: "https://api.typesafe.ai/v1/systemone", model: "jev-latest" },
1953
- openrouter: { key: "OPENROUTER_API_KEY", endpoint: "https://openrouter.ai/api/v1/systemone", model: "jev-latest" },
1954
- vercel: { key: "AI_GATEWAY_API_KEY", endpoint: "https://ai-gateway.vercel.sh/typesafe/v1/systemone", model: "typesafe-ai/jev" }
1955
- };
1956
1934
  var MODEL_KEYS = {
1957
1935
  openrouter: "OPENROUTER_API_KEY",
1958
1936
  vercel: "AI_GATEWAY_API_KEY",
@@ -1960,24 +1938,8 @@ var MODEL_KEYS = {
1960
1938
  anthropic: "ANTHROPIC_API_KEY",
1961
1939
  custom: "BUGHUNTERS_MODEL_API_KEY"
1962
1940
  };
1963
- var jevOrder = ["typesafe", "openrouter", "vercel"];
1964
1941
  var modelOrder = ["openrouter", "vercel", "openai", "anthropic", "custom"];
1965
1942
  function resolveDecider(config, env) {
1966
- let jevMissing = "";
1967
- if (config.decider === "jev") {
1968
- const routes2 = config.jev.via === "auto" ? jevOrder : [config.jev.via];
1969
- for (const via of routes2) {
1970
- const route2 = JEV_ROUTES[via];
1971
- const key = env[route2.key];
1972
- if (key)
1973
- return {
1974
- decider: new JevDecider({ apiKey: key, endpoint: route2.endpoint, model: route2.model }),
1975
- via: `jev:${via}`,
1976
- reason: `Decider: jev via ${via} (${route2.model}).`
1977
- };
1978
- }
1979
- jevMissing = `decider is jev, but no ${routes2.map((via) => JEV_ROUTES[via].key).join(", ").replace(/, ([^,]*)$/, " or $1")} is set`;
1980
- }
1981
1943
  const routes = config.model.via === "auto" ? modelOrder : [config.model.via];
1982
1944
  for (const via of routes) {
1983
1945
  const key = env[MODEL_KEYS[via]];
@@ -1988,14 +1950,11 @@ function resolveDecider(config, env) {
1988
1950
  return {
1989
1951
  decider: new ModelDecider({ via, apiKey: key, endpoint, model: name }),
1990
1952
  via: `model:${via}`,
1991
- reason: jevMissing ? `Decider: model via ${via} (${name}) because ${jevMissing}.` : `Decider: model via ${via} (${name}).`
1953
+ reason: `Decider: model via ${via} (${name}).`
1992
1954
  };
1993
1955
  }
1994
- const modelMissing = routes.map((via) => via === "custom" ? "BUGHUNTERS_MODEL_ENDPOINT and BUGHUNTERS_MODEL_API_KEY" : MODEL_KEYS[via]).join(", ").replace(/, ([^,]*)$/, " or $1");
1995
- return {
1996
- via: "none",
1997
- reason: `Decider: none, so the judge decides each finding, because ${jevMissing ? `${jevMissing}, and ` : ""}no ${modelMissing} is set. Set a Jev key to cut cost.`
1998
- };
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.` };
1999
1958
  }
2000
1959
 
2001
1960
  // ../agents/dist/runtime/cli.js
@@ -2187,7 +2146,7 @@ var ModelRuntime = class {
2187
2146
  return outcome("done", calls.output);
2188
2147
  }
2189
2148
  }
2190
- if (costUsd >= task.budgetUsd) {
2149
+ if (task.budgetUsd !== void 0 && costUsd >= task.budgetUsd) {
2191
2150
  return outcome("budget", lastText);
2192
2151
  }
2193
2152
  }
@@ -2565,7 +2524,6 @@ var AgentSession = class {
2565
2524
  lastScreenTrailIndex = 0;
2566
2525
  /** Where the app was when lastScreenId was recorded. */
2567
2526
  lastScreenLocation;
2568
- decisionSpentUsd = 0;
2569
2527
  /** Issues already counted as seen again in this session. */
2570
2528
  seenIssues = /* @__PURE__ */ new Set();
2571
2529
  cancelled = false;
@@ -2664,6 +2622,26 @@ ${lessons.map((lesson) => `- [${lesson.scope ?? "app"}] ${lesson.text}`).join("\
2664
2622
 
2665
2623
  ` : "";
2666
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
+ }
2667
2645
  function explorerCommon(platform, instructions, lessons = []) {
2668
2646
  return `HOW THE TOOLS WORK
2669
2647
  - Each action tool returns a screenshot and a list of elements. Refs such as [e12] are valid only for
@@ -2679,24 +2657,38 @@ RULES
2679
2657
  - Do not use the same failed action more than two times. Try another way, or report the problem.
2680
2658
  - Keep your notes short. Spend your steps on actions, not on long thoughts.
2681
2659
 
2682
- ${lessonPart(lessons)}APP GUIDE
2660
+ ${memoryPart("explorer")}${lessonPart(lessons)}APP GUIDE
2683
2661
  ${instructions.trim() || "(No guide was given. Explore carefully and do not change any data.)"}`;
2684
2662
  }
2685
- 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
+ ` : "";
2686
2667
  return `You are the explorer on an automated QA team. You use the app through tools, like a careful
2687
2668
  human tester, and you look for problems that a real user would notice.
2688
2669
 
2689
- 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
+
2690
2674
  1. Enter the app. Follow the app guide below. When you are in the app, call save_routine with the id
2691
2675
  "enter-app" at once.
2692
2676
  2. Record each screen that you see for the first time, before you do anything on it. Call record_screen
2693
2677
  with a short kebab-case id, a name, and one sentence about what the screen is for. A dialog, a sheet,
2694
- 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.
2695
2679
  3. Explore breadth first. Visit each item of the main navigation before you go deep into one area.
2696
- 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
2697
2686
  continue. Do not stop at the first problem.
2698
- 5. Before your steps run out, call finish with a summary: the screens you covered, the problems you
2699
- 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.
2700
2692
 
2701
2693
  WHAT TO REPORT
2702
2694
  - A control that does nothing, or does the wrong thing.
@@ -2704,15 +2696,16 @@ WHAT TO REPORT
2704
2696
  - Layout that is broken: content off screen, elements on top of each other, empty gaps, wrong alignment.
2705
2697
  - Wrong, missing, or contradictory data. Placeholder text such as "undefined", "NaN", "null", or "{{".
2706
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.
2707
2702
  - Use the id from record_screen for the screen you are on when you call report_bug. Use 'unrecorded' if needed.
2708
2703
  - A loading state that does not end. If a screen still shows a spinner or skeleton rows after two waits
2709
2704
  of 10 seconds, report it as a bug and continue somewhere else. Do not wait again and again.
2710
2705
  - A dead end: a screen with no way back.
2711
2706
  - Inconsistent UI: the same thing named or styled in two different ways.
2712
2707
  Do not report the things that the app guide tells you to ignore.
2713
- record_screen runs automatic checks (contrast, overlap, tap size, visual change) and sends what they find
2714
- to the QA lead. Do not report those findings again with report_bug. Report what the checks cannot see.
2715
-
2708
+ ${automatic}
2716
2709
  ${explorerCommon(platform, instructions, lessons)}`;
2717
2710
  }
2718
2711
  function explorerRetestSystem(platform, instructions, lessons = []) {
@@ -2736,7 +2729,9 @@ Call view_retest to inspect the before and after screenshots. Then call verdict.
2736
2729
  fixed: the problem is gone on EVERY affected screen and nothing new is broken.
2737
2730
  not-fixed: the problem is still visible on at least one screen. Name it.
2738
2731
  unclear: the explorer did not reach at least one screen and none shows the problem still present. Name the missing screens.
2739
- ${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)}`;
2740
2735
  }
2741
2736
  function explorerPrompt(input) {
2742
2737
  const screens = input.screens.length ? input.screens.map((screen) => `- ${screen.id}: ${screen.name}. ${screen.description}`).join("\n") : "(none yet)";
@@ -2744,7 +2739,7 @@ function explorerPrompt(input) {
2744
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";
2745
2740
  return `- ${routine.id} (${routine.steps.length} steps, ${health}): ${routine.description}`;
2746
2741
  }).join("\n") : "(none yet)";
2747
- 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.");
2748
2743
  return `GOAL
2749
2744
  ${goal}
2750
2745
 
@@ -2798,11 +2793,12 @@ HOW TO WRITE AN ISSUE
2798
2793
 
2799
2794
  Be strict. A report that nobody can act on costs the team time. When you have decided every candidate,
2800
2795
  call finish with one sentence per decision.
2801
- ${lessonPart(lessons)}`;
2796
+
2797
+ ${memoryPart("judge")}${lessonPart(lessons)}`;
2802
2798
  }
2803
2799
  function judgePrompt(sessionIds, candidates, issues) {
2804
2800
  const list3 = candidates.map((candidate) => {
2805
- const route2 = candidate.route ? ` Decider: ${candidate.route.reason}` : "";
2801
+ const route2 = candidate.route ? ` Route: ${candidate.route.reason}` : "";
2806
2802
  return `- ${candidate.id} [${candidate.source}, ${candidate.severity}] on ${candidate.screenId ?? "unknown screen"}: ${candidate.summary}.${route2}`;
2807
2803
  }).join("\n");
2808
2804
  const open2 = issues.filter((issue) => issue.status !== "dismissed" && issue.status !== "fixed");
@@ -2855,7 +2851,111 @@ a QA lead confirmed it. Fix the cause, not the symptom.
2855
2851
  your summary, with the file and line. Bughunters shows your explanation to the team.
2856
2852
  - Do not commit and do not push. Bughunters commits the change on its own branch.
2857
2853
  - End with a short summary: the cause, the change, and what you ran to verify it.
2858
- ${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
+ }];
2859
2959
  }
2860
2960
 
2861
2961
  // ../agents/dist/roles/fixer.js
@@ -3197,10 +3297,10 @@ ${issue.body}
3197
3297
  Evidence:
3198
3298
  ${evidence}
3199
3299
  Reproduction: ${repro}` + (last ? `
3200
- 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:
3201
3301
  ${after}
3202
- Fix it now.` : ""),
3203
- 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()],
3204
3304
  maxSteps: config.maxSteps,
3205
3305
  budgetUsd: config.budgetUsd,
3206
3306
  timeoutMs: config.timeoutMs
@@ -3227,12 +3327,12 @@ function stepWords(step) {
3227
3327
  }
3228
3328
 
3229
3329
  // ../agents/dist/github.js
3230
- var defaultGh = (args2, opts) => new Promise((resolve10, reject) => {
3330
+ var defaultGh = (args2, opts) => new Promise((resolve12, reject) => {
3231
3331
  const child = execFile2("gh", args2, { cwd: opts?.cwd, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
3232
3332
  if (error)
3233
3333
  reject(Object.assign(error, { stderr, stdout }));
3234
3334
  else
3235
- resolve10(stdout.trim());
3335
+ resolve12(stdout.trim());
3236
3336
  });
3237
3337
  child.stdin?.end(opts?.input);
3238
3338
  });
@@ -4472,14 +4572,15 @@ async function evaluateScreen(session, input) {
4472
4572
  if (firstSight)
4473
4573
  await saveBaseline(session, baseline, screenshot, snapshot);
4474
4574
  const native = observation.platform === "ios" || observation.platform === "android";
4575
+ const enabled = new Set(session.config.agents.checks);
4475
4576
  const violations = evaluateAll(snapshot, {
4476
4577
  baseline: baseline.snapshot,
4477
- disabled: native ? NATIVE_DISABLED : []
4578
+ disabled: RULES.map((rule) => rule.id).filter((id2) => !enabled.has(id2) || native && NATIVE_DISABLED.includes(id2))
4478
4579
  });
4479
4580
  const entries = groupViolations(violations);
4480
4581
  let diffPath;
4481
4582
  let visual;
4482
- if (!firstSight) {
4583
+ if (!firstSight && enabled.has("pixel-diff")) {
4483
4584
  diffPath = join8(paths.session(session.root, session.sessionId), `${screenId}-diff.png`);
4484
4585
  const comparison = await diff({
4485
4586
  baselinePath: baseline.png,
@@ -4499,7 +4600,7 @@ async function evaluateScreen(session, input) {
4499
4600
  await closeAbsentChecks(session, screenId, new Set(entries.map((entry) => entryFingerprint(screenId, entry))));
4500
4601
  if (fresh.length === 0)
4501
4602
  return [];
4502
- const routeResult = await decide(session, screenId, snapshot, violations, visual);
4603
+ const routeResult = { to: "judge", reason: "Automatic check" };
4503
4604
  const candidates = [];
4504
4605
  for (const entry of fresh) {
4505
4606
  const candidate = toCandidate(session, screenId, entry, {
@@ -4608,6 +4709,7 @@ async function addOccurrence(session, issueId) {
4608
4709
  }
4609
4710
  async function closeAbsentChecks(session, screenId, fired) {
4610
4711
  const closed = [];
4712
+ const enabled = new Set(session.config.agents.checks);
4611
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));
4612
4714
  const sources = /* @__PURE__ */ new Map();
4613
4715
  for (const record of await session.workspace.listSessions(Infinity)) {
@@ -4621,6 +4723,8 @@ async function closeAbsentChecks(session, screenId, fired) {
4621
4723
  const candidates = issue.candidateIds.map((candidateId) => sources.get(candidateId));
4622
4724
  if (candidates.some((candidate) => !candidate || candidate.source === "explorer" || candidate.screenId !== screenId))
4623
4725
  continue;
4726
+ if (candidates.some((candidate) => !enabled.has(candidate.ruleId ?? candidate.source)))
4727
+ continue;
4624
4728
  if (candidates.some((candidate) => fired.has(candidate.fingerprint))) {
4625
4729
  if (issue.notSeen)
4626
4730
  await session.workspace.saveIssue({ ...issue, notSeen: 0 });
@@ -4670,57 +4774,6 @@ function toCandidate(session, screenId, entry, extra) {
4670
4774
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
4671
4775
  };
4672
4776
  }
4673
- async function decide(session, screenId, snapshot, violations, visual) {
4674
- const config = session.config.decisions;
4675
- const { decider } = resolveDecider(config, process.env);
4676
- if (!decider)
4677
- return { to: "judge", reason: "no decider key is set, so the judge decides" };
4678
- const { text: text3 } = buildState({
4679
- screen: { id: screenId, description: snapshot.title ?? snapshot.url },
4680
- product: { summary: "", audience: "", domainVocabulary: [] },
4681
- assertions: violationsToAssertions(violations),
4682
- diff: visual ? {
4683
- changedPixels: visual.changedPixels,
4684
- changedPercent: visual.changedFraction * 100,
4685
- maskedPercent: visual.maskedFraction * 100,
4686
- regions: visual.regions.length
4687
- } : void 0,
4688
- console: snapshot.consoleErrors
4689
- });
4690
- const budget = new Budget(config.budget.perRunUsd);
4691
- budget.record(session.decisionSpentUsd);
4692
- const estimate = estimateDecisionCost(text3.length, decider.name);
4693
- if (!budget.canSpend(estimate)) {
4694
- return { to: "judge", reason: "decider budget exhausted" };
4695
- }
4696
- try {
4697
- const answers = await decider.ask(text3, SCREEN_QUESTIONS);
4698
- session.decisionSpentUsd += estimate;
4699
- const result3 = route({
4700
- answers,
4701
- thresholds: config.confidence,
4702
- hasDeterministicRegression: false,
4703
- matchedLedger: false
4704
- });
4705
- session.emit({
4706
- kind: "tool-result",
4707
- tool: "decider",
4708
- summary: `Decider routed ${screenId} to ${result3.route}`,
4709
- costUsd: estimate,
4710
- tokens: decider.lastUsage,
4711
- model: decider.model ?? decider.name
4712
- });
4713
- const choice = answers["route"];
4714
- const confidence = choice?.confidence;
4715
- return {
4716
- to: result3.route === "ignore" || result3.route === "intent" ? "ignore" : "judge",
4717
- reason: result3.reason,
4718
- confidence
4719
- };
4720
- } catch {
4721
- return { to: "judge", reason: "decider unavailable" };
4722
- }
4723
- }
4724
4777
 
4725
4778
  // ../agents/dist/replay.js
4726
4779
  async function replaySteps(session, steps, opts = {}) {
@@ -4851,7 +4904,7 @@ async function actWhenReady(driver, action, session, windowMs) {
4851
4904
  if (result3.ok || !retry || Date.now() >= deadline || session.cancelled) {
4852
4905
  return { ...result3, degraded };
4853
4906
  }
4854
- await new Promise((resolve10) => setTimeout(resolve10, 500));
4907
+ await new Promise((resolve12) => setTimeout(resolve12, 500));
4855
4908
  }
4856
4909
  }
4857
4910
  function toAction(step, session) {
@@ -4897,14 +4950,20 @@ function elementLine(element) {
4897
4950
  ].filter(Boolean).join(" ");
4898
4951
  return `[${element.ref}] ${element.role} ${JSON.stringify(element.name)} (${box.x},${box.y} ${box.width}x${box.height})${flags ? ` ${flags}` : ""}`;
4899
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
+ }
4900
4958
  function observationText(observation) {
4901
4959
  const windows = observation.windows?.map((window) => `${window.active ? "*" : " "} ${window.title} (${window.location})`).join("; ") ?? "(none)";
4902
4960
  return [
4903
4961
  `Location: ${observation.location}`,
4904
4962
  `Windows: ${windows}`,
4963
+ ...errorLines(observation),
4905
4964
  "Elements:",
4906
4965
  ...observation.elements.slice(0, 117).map(elementLine)
4907
- ].slice(0, 120).join("\n");
4966
+ ].slice(0, 130).join("\n");
4908
4967
  }
4909
4968
  async function observe(session, label, summary) {
4910
4969
  const observation = await session.driver.observe();
@@ -5232,7 +5291,7 @@ function explorerTools(session) {
5232
5291
  },
5233
5292
  {
5234
5293
  name: "record_screen",
5235
- description: "Record this screen, its routine, and automatic findings.",
5294
+ description: "Record this screen and its routine in the app map.",
5236
5295
  inputSchema: schema({ id: string, name: string, description: string }, ["id", "name", "description"]),
5237
5296
  async run(input) {
5238
5297
  const id2 = slug(arg(input, "id"));
@@ -5242,8 +5301,7 @@ function explorerTools(session) {
5242
5301
  await session.driver.settle();
5243
5302
  const observation = await session.driver.observe();
5244
5303
  const screenshot = await session.capture(observation, id2);
5245
- const snapshot = session.driver.snapshot(observation, id2);
5246
- 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) }) : [];
5247
5305
  const previous = session.lastScreenId;
5248
5306
  const edge = transition(session, id2);
5249
5307
  const routineId = `screen-${id2}`;
@@ -5271,15 +5329,17 @@ function explorerTools(session) {
5271
5329
  session.lastScreenTrailIndex = session.trail.length;
5272
5330
  const screens = (await session.workspace.readAppMap())?.screens ?? [];
5273
5331
  const names = screens.map((screen) => screen.id).join(", ") || id2;
5274
- 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);
5275
5334
  session.emit({
5276
5335
  kind: "screen",
5277
- summary: `Recorded ${name} (${found.split(":")[0]})`,
5336
+ summary: `Recorded ${name}${findings.length ? ` (${findings.length} automatic finding(s))` : ""}`,
5278
5337
  screenId: id2,
5279
5338
  screenshot
5280
5339
  });
5281
5340
  return {
5282
- ...text(`Recorded ${name}. ${found}. Known screens: ${names}`),
5341
+ ...text(session.vars.redact(`Recorded ${name}.${found} Known screens: ${names}${errors.length ? `
5342
+ ${errors.join("\n")}` : ""}`)),
5283
5343
  meta: { summary: "", screenshot, screenId: id2 }
5284
5344
  };
5285
5345
  }
@@ -5395,6 +5455,7 @@ Expected: ${arg(input, "expected")}`),
5395
5455
  run: (input) => act(session, { kind: "window", match: arg(input, "match") })
5396
5456
  });
5397
5457
  }
5458
+ tools.push(...lessonTools(session, "explorer"));
5398
5459
  return tools;
5399
5460
  }
5400
5461
 
@@ -5412,9 +5473,9 @@ var string2 = { type: "string" };
5412
5473
  function issueTitle(value) {
5413
5474
  if (value.length <= 80)
5414
5475
  return value;
5415
- const words = value.trim().split(/\s+/);
5476
+ const words2 = value.trim().split(/\s+/);
5416
5477
  let title = "";
5417
- for (const word of words) {
5478
+ for (const word of words2) {
5418
5479
  const next = title ? `${title} ${word}` : word;
5419
5480
  if (next.length > 79)
5420
5481
  break;
@@ -5467,6 +5528,7 @@ function triageEntries(candidates, decision, reason, issueId) {
5467
5528
  }
5468
5529
  function judgeTools(session, sessionIds, runtimeLabel) {
5469
5530
  return [
5531
+ ...lessonTools(session, "judge"),
5470
5532
  {
5471
5533
  name: "list_candidates",
5472
5534
  description: "List undecided candidates and existing issues.",
@@ -5823,7 +5885,7 @@ async function runExplorer(session, runtime, opts = {}) {
5823
5885
  const task = {
5824
5886
  role: "explorer",
5825
5887
  sessionId: session.sessionId,
5826
- 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),
5827
5889
  prompt: explorerPrompt({
5828
5890
  goal: opts.goal,
5829
5891
  screens: map?.screens ?? [],
@@ -5846,13 +5908,13 @@ async function runExplorer(session, runtime, opts = {}) {
5846
5908
  await session.workspace.endSession(session.sessionId, {
5847
5909
  status: outcome.stop === "error" ? "failed" : "finished",
5848
5910
  steps: outcome.steps,
5849
- costUsd: outcome.costUsd + session.decisionSpentUsd,
5911
+ costUsd: outcome.costUsd,
5850
5912
  summary: outcome.summary,
5851
5913
  candidates: candidates.length,
5852
5914
  screensFound: screens.map((item) => item.id)
5853
5915
  });
5854
5916
  session.emit({ kind: "session-end", summary: outcome.summary ?? `Explorer stopped: ${outcome.stop}` });
5855
- await session.idle(outcome.costUsd + session.decisionSpentUsd);
5917
+ await session.idle(outcome.costUsd);
5856
5918
  const learned = await reflectOnSession(session.root, session.config, session.sessionId, { vars: session.vars, onLog: (message2) => session.emit({ kind: "error", summary: message2 }) });
5857
5919
  if (learned)
5858
5920
  session.emit({ kind: "lesson", summary: `Saved ${learned} lesson(s) for the next sessions` });
@@ -6026,7 +6088,7 @@ async function deleteBranch(fix, onLog) {
6026
6088
 
6027
6089
  // ../agents/dist/roles/retest.js
6028
6090
  import { execFile as execFile5 } from "node:child_process";
6029
- import { readFile as readFile9 } from "node:fs/promises";
6091
+ import { readFile as readFile10 } from "node:fs/promises";
6030
6092
  import { resolve as resolve7 } from "node:path";
6031
6093
  import { promisify as promisify5 } from "node:util";
6032
6094
 
@@ -6086,7 +6148,7 @@ async function observeDom(page) {
6086
6148
  };
6087
6149
  const trim = (value) => value?.trim().replace(/\s+/g, " ").slice(0, 80) ?? "";
6088
6150
  const implicitRole = (tag, type) => {
6089
- const roles3 = {
6151
+ const roles4 = {
6090
6152
  a: "link",
6091
6153
  button: "button",
6092
6154
  select: "combobox",
@@ -6109,7 +6171,7 @@ async function observeDom(page) {
6109
6171
  if (/^h[1-6]$/.test(tag)) {
6110
6172
  return "heading";
6111
6173
  }
6112
- return roles3[tag] ?? "button";
6174
+ return roles4[tag] ?? "button";
6113
6175
  };
6114
6176
  for (const element of Array.from(document.querySelectorAll(candidates))) {
6115
6177
  if (result3.length >= 150) {
@@ -6262,6 +6324,7 @@ var WebDriver = class {
6262
6324
  page;
6263
6325
  lastObservation;
6264
6326
  errors = /* @__PURE__ */ new Map();
6327
+ failures = /* @__PURE__ */ new Map();
6265
6328
  watched = /* @__PURE__ */ new WeakSet();
6266
6329
  constructor(options) {
6267
6330
  this.options = options;
@@ -6291,11 +6354,30 @@ var WebDriver = class {
6291
6354
  const errors = [];
6292
6355
  this.errors.set(page, errors);
6293
6356
  page.on("console", (message2) => {
6294
- if (message2.type() === "error") {
6357
+ if (message2.type() === "error" && !message2.text().startsWith("Failed to load resource:")) {
6295
6358
  errors.push(message2.text());
6296
6359
  }
6297
6360
  });
6298
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
+ });
6299
6381
  }
6300
6382
  async observe() {
6301
6383
  const page = this.activePage();
@@ -6327,6 +6409,7 @@ var WebDriver = class {
6327
6409
  windows,
6328
6410
  volatileRegions: [],
6329
6411
  consoleErrors: errors.splice(0),
6412
+ networkErrors: (this.failures.get(page) ?? []).splice(0),
6330
6413
  at: (/* @__PURE__ */ new Date()).toISOString()
6331
6414
  };
6332
6415
  this.lastObservation = observation;
@@ -6952,7 +7035,7 @@ var MaestroDriver = class {
6952
7035
  await this.run([`openLink: ${yamlString(action.url)}`]);
6953
7036
  }
6954
7037
  async wait(action) {
6955
- await new Promise((resolve10) => setTimeout(resolve10, action.ms));
7038
+ await new Promise((resolve12) => setTimeout(resolve12, action.ms));
6956
7039
  }
6957
7040
  async switchWindow() {
6958
7041
  throw new Error("Window switching is unsupported on mobile");
@@ -7013,7 +7096,7 @@ var MaestroDriver = class {
7013
7096
  return { frames, stable: true };
7014
7097
  }
7015
7098
  previous = current;
7016
- await new Promise((resolve10) => setTimeout(resolve10, interval));
7099
+ await new Promise((resolve12) => setTimeout(resolve12, interval));
7017
7100
  }
7018
7101
  return { frames, stable: false };
7019
7102
  }
@@ -7055,6 +7138,60 @@ function createDriver(config, vars) {
7055
7138
  });
7056
7139
  }
7057
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
+
7058
7195
  // ../agents/dist/roles/retest.js
7059
7196
  var exec4 = promisify5(execFile5);
7060
7197
  var schema4 = (properties = {}, required = []) => ({ type: "object", properties, required, additionalProperties: false });
@@ -7064,7 +7201,7 @@ async function image2(root2, file) {
7064
7201
  if (!file)
7065
7202
  return [];
7066
7203
  try {
7067
- return [{ type: "image", png: await readFile9(resolve7(root2, file)) }];
7204
+ return [{ type: "image", png: await readFile10(resolve7(root2, file)) }];
7068
7205
  } catch {
7069
7206
  return [];
7070
7207
  }
@@ -7130,9 +7267,13 @@ async function retestFix(root2, config, issue, fix, attempt, deps, options = {})
7130
7267
  const vars = new Vars(config.app.secrets);
7131
7268
  let app;
7132
7269
  let driver;
7270
+ let restore = async () => {
7271
+ };
7133
7272
  try {
7134
7273
  if (!options.build)
7135
7274
  await linkEnvFiles(fix.repo, fix.worktree);
7275
+ if (!options.build)
7276
+ restore = await overlayBughunters(root2, fix.repo, fix.worktree);
7136
7277
  if (!options.build && config.agents.fixer.retest.prepare) {
7137
7278
  await exec4("/bin/sh", ["-c", config.agents.fixer.retest.prepare], { cwd: fix.worktree, timeout: 6e5, maxBuffer: 4 * 1024 * 1024 });
7138
7279
  }
@@ -7169,7 +7310,8 @@ async function retestFix(root2, config, issue, fix, attempt, deps, options = {})
7169
7310
  "open",
7170
7311
  "wait",
7171
7312
  "run_routine",
7172
- "switch_window"
7313
+ "switch_window",
7314
+ "save_lesson"
7173
7315
  ]);
7174
7316
  const tools = explorerTools(session2).filter((tool) => allowed.has(tool.name)).map((tool) => tool.name !== "run_routine" ? tool : { ...tool, async run(input) {
7175
7317
  const id2 = String(input.id ?? "");
@@ -7259,7 +7401,7 @@ async function retestFix(root2, config, issue, fix, attempt, deps, options = {})
7259
7401
  }
7260
7402
  });
7261
7403
  const guide = config.app.instructions;
7262
- const instructions = guide ? await readFile9(resolve7(root2, guide), "utf8") : "";
7404
+ const instructions = guide ? await readFile10(resolve7(root2, guide), "utf8") : "";
7263
7405
  const result3 = await explorerRuntime.run({
7264
7406
  role: "explorer",
7265
7407
  sessionId: record2.id,
@@ -7313,12 +7455,17 @@ ${targets.map((target, index) => `${index + 1}. screen ${target.shot.screenId ??
7313
7455
  } catch (error) {
7314
7456
  retest.outcome = "error";
7315
7457
  retest.reason = `Retest preparation or exploration failed: ${String(error)}`;
7458
+ deps.onLog?.(retest.reason);
7316
7459
  return retest;
7317
7460
  } finally {
7318
7461
  try {
7319
7462
  await driver?.close();
7320
7463
  } finally {
7321
- await app?.stop();
7464
+ try {
7465
+ await app?.stop();
7466
+ } finally {
7467
+ await restore();
7468
+ }
7322
7469
  }
7323
7470
  }
7324
7471
  if (!targets.some((target) => target.shot.after)) {
@@ -7362,7 +7509,7 @@ Fixer: ${fix.summary ?? ""}` }
7362
7509
  judged = { outcome: input.outcome, reason: String(input.reason ?? "") };
7363
7510
  return { ...response2(`Retest verdict: ${judged.outcome} \u2014 ${judged.reason}`), done: true };
7364
7511
  }
7365
- }];
7512
+ }, ...lessonTools(session, "judge")];
7366
7513
  const result3 = await judgeRuntime.run({
7367
7514
  role: "judge",
7368
7515
  sessionId: record.id,
@@ -7531,8 +7678,8 @@ async function runFixCycle(root2, config, deps = {}) {
7531
7678
  }
7532
7679
 
7533
7680
  // ../agents/dist/roles/publish.js
7534
- import { mkdir as mkdir4, readFile as readFile10, writeFile as writeFile6 } from "node:fs/promises";
7535
- 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";
7536
7683
 
7537
7684
  // ../agents/dist/report.js
7538
7685
  function buildReport(input) {
@@ -7650,7 +7797,7 @@ async function image3(root2, path) {
7650
7797
  if (!path)
7651
7798
  return [];
7652
7799
  try {
7653
- return [{ type: "image", png: await readFile10(resolve8(root2, path)) }];
7800
+ return [{ type: "image", png: await readFile11(resolve8(root2, path)) }];
7654
7801
  } catch {
7655
7802
  return [];
7656
7803
  }
@@ -7842,9 +7989,9 @@ Retest: ${last?.outcome ?? "none"} \u2014 ${last?.reason ?? ""}`;
7842
7989
  let url;
7843
7990
  if (deps.dryRun) {
7844
7991
  const dir = paths.publish(root2);
7845
- await mkdir4(dir, { recursive: true });
7846
- url = join11(dir, `${item.issue.id}.md`);
7847
- await writeFile6(url, body);
7992
+ await mkdir5(dir, { recursive: true });
7993
+ url = join12(dir, `${item.issue.id}.md`);
7994
+ await writeFile7(url, body);
7848
7995
  } else if (item.kind === "pr" && item.fix) {
7849
7996
  const opened = await createPr(gh, {
7850
7997
  repo: resolved.repo,
@@ -7951,10 +8098,276 @@ ${items.map((item) => `${item.issue.id}: ${item.issue.title}`).join("\n")}`),
7951
8098
  return outcomes;
7952
8099
  }
7953
8100
 
8101
+ // ../agents/dist/roles/ci.js
8102
+ import { execFile as execFile6 } from "node:child_process";
8103
+ import { access } from "node:fs/promises";
8104
+ import { resolve as resolve9 } from "node:path";
8105
+ import { promisify as promisify6 } from "node:util";
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
+
7954
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) {
8306
+ return (await exec5("git", args2, { cwd })).stdout.trim();
8307
+ }
8308
+ async function pullSource(root2, config, onLog) {
8309
+ const target = config.agents.patrol.pull;
8310
+ if (!target)
8311
+ return false;
8312
+ const slash = target.indexOf("/");
8313
+ const remote = target.slice(0, slash);
8314
+ const branch = target.slice(slash + 1);
8315
+ const source = resolve10(root2, config.app.source);
8316
+ try {
8317
+ if (await git5(source, "status", "--porcelain", "--untracked-files=no")) {
8318
+ onLog?.(`Did not pull ${target}: the source repository has uncommitted changes.`);
8319
+ return false;
8320
+ }
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")}.`);
8324
+ return true;
8325
+ } catch (error) {
8326
+ const reason = error.stderr?.trim() || error.message;
8327
+ onLog?.(`Did not pull ${target}: ${reason}. The cycle uses the current checkout.`);
8328
+ return false;
8329
+ }
8330
+ }
8331
+ async function sourceCommit(root2, config) {
8332
+ const source = resolve10(root2, config.app.source);
8333
+ try {
8334
+ if (await git5(source, "status", "--porcelain", "--untracked-files=no"))
8335
+ return void 0;
8336
+ return await git5(source, "rev-parse", "HEAD");
8337
+ } catch {
8338
+ return void 0;
8339
+ }
8340
+ }
8341
+ function alive(pid) {
8342
+ try {
8343
+ process.kill(pid, 0);
8344
+ return true;
8345
+ } catch {
8346
+ return false;
8347
+ }
8348
+ }
8349
+ function wait(minutes) {
8350
+ return new Promise((done) => {
8351
+ const finish = () => {
8352
+ clearTimeout(timer);
8353
+ process.off("SIGINT", finish);
8354
+ process.off("SIGTERM", finish);
8355
+ done();
8356
+ };
8357
+ const timer = setTimeout(finish, minutes * 6e4);
8358
+ process.once("SIGINT", finish);
8359
+ process.once("SIGTERM", finish);
8360
+ });
8361
+ }
7955
8362
  async function runPatrol(options) {
8363
+ const problems = [];
7956
8364
  const { root: root2, config } = options;
7957
8365
  const workspace = new Workspace(root2);
8366
+ const previous = (await workspace.readAgents()).patrol;
8367
+ if (previous?.state === "running" && previous.pid && previous.pid !== process.pid && alive(previous.pid)) {
8368
+ options.onLog?.(`A patrol already runs (pid ${previous.pid}): did not start another.`);
8369
+ return { problems };
8370
+ }
7958
8371
  const runtime = options.createRuntime ?? createRuntime;
7959
8372
  let interrupted = false;
7960
8373
  let activeSession;
@@ -7964,6 +8377,11 @@ async function runPatrol(options) {
7964
8377
  activeSession.cancelled = true;
7965
8378
  }
7966
8379
  };
8380
+ const idle = () => workspace.setPatrol({
8381
+ state: "stopped",
8382
+ nextAt: new Date(Date.now() + config.agents.patrol.intervalMinutes * 6e4).toISOString()
8383
+ });
8384
+ let tested = options.force ? void 0 : previous?.commit;
7967
8385
  process.on("SIGINT", onSignal);
7968
8386
  process.on("SIGTERM", onSignal);
7969
8387
  try {
@@ -7971,46 +8389,65 @@ async function runPatrol(options) {
7971
8389
  if (config.agents.patrol.cycles && cycle > config.agents.patrol.cycles) {
7972
8390
  break;
7973
8391
  }
8392
+ if (cycle > 1) {
8393
+ await wait(config.agents.patrol.intervalMinutes);
8394
+ if (interrupted)
8395
+ break;
8396
+ }
7974
8397
  await workspace.setPatrol({ state: "running", cycle, startedAt: (/* @__PURE__ */ new Date()).toISOString() });
8398
+ await pullSource(root2, config, options.onLog);
7975
8399
  if (config.agents.github.enabled)
7976
8400
  await syncGitHub(root2, config, { onLog: options.onLog });
7977
8401
  await cleanWorktrees(root2, config, { onLog: options.onLog });
7978
- for (const role of ["explorer", "judge", "fixer"]) {
7979
- if (!config.agents[role].enabled) {
7980
- await workspace.setAgentStatus(role, { state: "off" });
7981
- }
7982
- }
7983
- const vars = new Vars(config.app.secrets);
7984
- let app;
7985
- let driver;
7986
- try {
7987
- app = await startApp(config.app, { root: root2, vars, emit: options.onLog });
7988
- driver = (options.createDriver ?? createDriver)(config, vars.resolve.bind(vars));
7989
- await driver.connect();
7990
- let explorerId;
7991
- if (config.agents.explorer.enabled && !interrupted) {
7992
- const record = await workspace.startSession("explorer");
7993
- explorerId = record.id;
7994
- const session = new AgentSession(root2, config, vars, record.id, "explorer", driver, options.onLog);
7995
- activeSession = session;
7996
- if (!interrupted) {
7997
- await runExplorer(session, runtime(config.agents.explorer.use));
7998
- } else {
7999
- await workspace.endSession(record.id, { summary: "Interrupted" });
8402
+ const commit = await sourceCommit(root2, config);
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" });
8000
8412
  }
8001
- activeSession = void 0;
8002
8413
  }
8003
- if (config.agents.judge.enabled && explorerId && !interrupted) {
8004
- const record = await workspace.startSession("judge");
8005
- const session = new AgentSession(root2, config, vars, record.id, "judge", void 0, options.onLog);
8006
- activeSession = session;
8007
- await runJudge(session, runtime(config.agents.judge.use), { sessionIds: [explorerId] });
8008
- }
8009
- } finally {
8414
+ const vars = new Vars(config.app.secrets);
8415
+ let app;
8416
+ let driver;
8010
8417
  try {
8011
- 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);
8012
8445
  } finally {
8013
- await app?.stop();
8446
+ try {
8447
+ await driver?.close();
8448
+ } finally {
8449
+ await app?.stop();
8450
+ }
8014
8451
  }
8015
8452
  }
8016
8453
  if (!interrupted && config.agents.explorer.enabled && config.agents.judge.enabled) {
@@ -8024,16 +8461,21 @@ async function runPatrol(options) {
8024
8461
  isInterrupted: () => interrupted
8025
8462
  });
8026
8463
  }
8027
- if (!interrupted)
8028
- await runFixCycle(root2, config, {
8029
- createDriver: options.createDriver,
8030
- createRuntime: options.createRuntime,
8031
- onLog: options.onLog,
8032
- onSession: (session) => {
8033
- activeSession = session;
8034
- },
8035
- isInterrupted: () => interrupted
8036
- });
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;
8037
8479
  if (!interrupted && config.agents.github.enabled)
8038
8480
  await runPublisher(root2, config, {
8039
8481
  createRuntime: options.createRuntime,
@@ -8042,31 +8484,27 @@ async function runPatrol(options) {
8042
8484
  activeSession = session;
8043
8485
  }
8044
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
+ }
8045
8491
  if (!interrupted && config.agents.github.enabled)
8046
8492
  await syncGitHub(root2, config, { onLog: options.onLog });
8047
- const nextAt = new Date(Date.now() + config.agents.patrol.intervalMinutes * 6e4).toISOString();
8048
- await workspace.setPatrol({ state: "stopped", nextAt });
8493
+ if (!interrupted && fresh && commit && !cycleFailed) {
8494
+ tested = commit;
8495
+ await workspace.setPatrol({ commit });
8496
+ }
8497
+ await idle();
8049
8498
  if (options.once || interrupted) {
8050
8499
  break;
8051
8500
  }
8052
- await new Promise((done) => {
8053
- const finish = () => {
8054
- clearTimeout(timer);
8055
- process.off("SIGINT", stop);
8056
- process.off("SIGTERM", stop);
8057
- done();
8058
- };
8059
- const timer = setTimeout(finish, config.agents.patrol.intervalMinutes * 6e4);
8060
- const stop = () => finish();
8061
- process.once("SIGINT", stop);
8062
- process.once("SIGTERM", stop);
8063
- });
8064
8501
  }
8065
8502
  } finally {
8066
- await workspace.setPatrol({ state: "stopped" });
8503
+ await workspace.setPatrol({ state: "stopped", nextAt: void 0 });
8067
8504
  process.off("SIGINT", onSignal);
8068
8505
  process.off("SIGTERM", onSignal);
8069
8506
  }
8507
+ return { problems };
8070
8508
  }
8071
8509
 
8072
8510
  // ../cli/dist/commands/doctor.js
@@ -8115,15 +8553,13 @@ function runChecks(root2, config) {
8115
8553
  }
8116
8554
  function agentChecks(config, env = process.env) {
8117
8555
  const checks = [];
8118
- const roles3 = ["explorer", "judge", "fixer"].filter((role) => role !== "fixer" || config.agents.fixer.enabled);
8119
- 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) {
8120
8558
  const use = config.agents[role].use;
8121
8559
  const problem = runtimeProblem(role, use, env);
8122
8560
  const label = use.runtime === "cli" ? `cli: ${use.agent ?? use.command.split(" ")[0]}` : `${use.via}: ${use.model ?? "default model"}`;
8123
8561
  checks.push({ name: role, ok: !problem, detail: problem ?? `The ${role} uses ${label}`, fatal: true });
8124
8562
  }
8125
- const decider = resolveDecider(config.decisions, env);
8126
- checks.push({ name: "decider", ok: Boolean(decider.decider), detail: decider.reason, fatal: false });
8127
8563
  if (config.agents.github.enabled) {
8128
8564
  const installed = spawnSync("gh", ["--version"], { stdio: "ignore" }).status === 0;
8129
8565
  const loggedIn = installed && spawnSync("gh", ["auth", "status"], { stdio: "ignore" }).status === 0;
@@ -8138,12 +8574,12 @@ function doctorExitCode(checks) {
8138
8574
  // ../cli/dist/commands/init.js
8139
8575
  import { spawnSync as spawnSync2 } from "node:child_process";
8140
8576
  import { appendFileSync, existsSync as existsSync6, readFileSync as readFileSync5, readdirSync, writeFileSync as writeFileSync3, mkdirSync as mkdirSync2 } from "node:fs";
8141
- import { join as join13, relative as relative3 } from "node:path";
8577
+ import { join as join14, relative as relative4 } from "node:path";
8142
8578
  import { createInterface } from "node:readline/promises";
8143
8579
 
8144
8580
  // ../recon/dist/bringup.js
8145
8581
  import { existsSync as existsSync5, readFileSync as readFileSync4 } from "node:fs";
8146
- import { join as join12 } from "node:path";
8582
+ import { join as join13 } from "node:path";
8147
8583
  function detectBringUp(root2) {
8148
8584
  const candidates = [];
8149
8585
  for (const workflow of listWorkflows(root2)) {
@@ -8153,7 +8589,7 @@ function detectBringUp(root2) {
8153
8589
  candidates.push({ command: match[0].replace(/^run:\s*/, ""), rung: "precedent", source: workflow, confidence: 0.8 });
8154
8590
  }
8155
8591
  }
8156
- const pkgPath = join12(root2, "package.json");
8592
+ const pkgPath = join13(root2, "package.json");
8157
8593
  if (existsSync5(pkgPath)) {
8158
8594
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
8159
8595
  const manager = detectPackageManager(root2);
@@ -8168,20 +8604,20 @@ function detectBringUp(root2) {
8168
8604
  }
8169
8605
  }
8170
8606
  }
8171
- 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"))) {
8172
8608
  candidates.push({ command: "docker compose up", rung: "convention", source: "docker-compose.yml", confidence: 0.5 });
8173
8609
  }
8174
- if (existsSync5(join12(root2, "Procfile"))) {
8610
+ if (existsSync5(join13(root2, "Procfile"))) {
8175
8611
  candidates.push({ command: "foreman start", rung: "convention", source: "Procfile", confidence: 0.3 });
8176
8612
  }
8177
8613
  return candidates.sort((a, b) => b.confidence - a.confidence);
8178
8614
  }
8179
8615
  function detectPackageManager(root2) {
8180
- if (existsSync5(join12(root2, "pnpm-lock.yaml")))
8616
+ if (existsSync5(join13(root2, "pnpm-lock.yaml")))
8181
8617
  return "pnpm";
8182
- if (existsSync5(join12(root2, "bun.lockb")))
8618
+ if (existsSync5(join13(root2, "bun.lockb")))
8183
8619
  return "bun";
8184
- if (existsSync5(join12(root2, "yarn.lock")))
8620
+ if (existsSync5(join13(root2, "yarn.lock")))
8185
8621
  return "yarn";
8186
8622
  return "npm";
8187
8623
  }
@@ -8202,7 +8638,7 @@ function detectStack(root2) {
8202
8638
  ["app.json", "expo"]
8203
8639
  ];
8204
8640
  for (const [file, name] of signatures) {
8205
- if (!existsSync5(join12(root2, file)))
8641
+ if (!existsSync5(join13(root2, file)))
8206
8642
  continue;
8207
8643
  framework ??= name;
8208
8644
  detectedFrom.push(file);
@@ -8217,12 +8653,12 @@ function detectStack(root2) {
8217
8653
  };
8218
8654
  }
8219
8655
  function listWorkflows(root2) {
8220
- const dir = join12(root2, ".github", "workflows");
8656
+ const dir = join13(root2, ".github", "workflows");
8221
8657
  if (!existsSync5(dir))
8222
8658
  return [];
8223
8659
  try {
8224
8660
  const { readdirSync: readdirSync4 } = __require("node:fs");
8225
- 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));
8226
8662
  } catch {
8227
8663
  return [];
8228
8664
  }
@@ -8236,21 +8672,15 @@ var KEY_PROVIDERS = {
8236
8672
  openai: { env: "OPENAI_API_KEY", label: "OpenAI", url: "https://platform.openai.com/api-keys", model: "gpt-5-mini" },
8237
8673
  anthropic: { env: "ANTHROPIC_API_KEY", label: "Anthropic", url: "https://console.anthropic.com/settings/keys", model: "claude-haiku-4-5" }
8238
8674
  };
8239
- var JEV_ROUTES2 = {
8240
- typesafe: { env: "TYPESAFE_API_KEY", label: "TypeSafe", url: "https://typesafe.ai" },
8241
- openrouter: { env: "OPENROUTER_API_KEY", label: "OpenRouter", url: "https://openrouter.ai/keys" },
8242
- vercel: { env: "AI_GATEWAY_API_KEY", label: "Vercel AI Gateway", url: "https://vercel.com/ai-gateway" }
8243
- };
8244
8675
  var CLI_LABELS = { claude: "Claude Code", codex: "Codex", kimi: "Kimi CLI", pi: "pi" };
8245
8676
  var run = (command2, args2) => spawnSync2(command2, args2, { encoding: "utf8", timeout: 1e4 }).stdout ?? "";
8246
- function detectProviders(env = process.env, exec5 = run) {
8677
+ function detectProviders(env = process.env, exec6 = run) {
8247
8678
  const found = CLI_AGENTS.filter((agent) => onPath(agent, env));
8248
- const piList = found.includes("pi") ? exec5("pi", ["list"]) : "";
8679
+ const piList = found.includes("pi") ? exec6("pi", ["list"]) : "";
8249
8680
  const piWithoutMcp = found.includes("pi") && !piList.includes("pi-mcp-adapter");
8250
8681
  return {
8251
8682
  clis: found.filter((agent) => agent !== "pi" || !piWithoutMcp),
8252
8683
  keys: Object.keys(KEY_PROVIDERS).filter((key) => env[KEY_PROVIDERS[key].env]),
8253
- jev: Object.keys(JEV_ROUTES2).filter((route2) => env[JEV_ROUTES2[route2].env]),
8254
8684
  piWithoutMcp,
8255
8685
  piPermissionModes: piList.includes("pi-permission-modes")
8256
8686
  };
@@ -8285,18 +8715,18 @@ function list(dir) {
8285
8715
  }
8286
8716
  }
8287
8717
  function xcodeProjects(root2) {
8288
- 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")));
8289
8719
  }
8290
8720
  function gradleFiles(root2) {
8291
- 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));
8292
8722
  }
8293
8723
  function detectAppId(root2, platform) {
8294
- const expo = readJson2(join13(root2, "app.json"))?.expo;
8724
+ const expo = readJson2(join14(root2, "app.json"))?.expo;
8295
8725
  const fromJson = platform === "ios" ? expo?.ios?.bundleIdentifier : expo?.android?.package;
8296
8726
  if (fromJson)
8297
8727
  return { appId: fromJson, source: "app.json" };
8298
8728
  for (const file of ["app.config.ts", "app.config.js"]) {
8299
- 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.-]+)['"`]/);
8300
8730
  if (match)
8301
8731
  return { appId: match[1], source: file };
8302
8732
  }
@@ -8322,21 +8752,21 @@ function detectPort(root2, pkg, framework) {
8322
8752
  if (fromScript)
8323
8753
  return { port: Number(fromScript), source: "package.json" };
8324
8754
  for (const file of ["vite.config.ts", "vite.config.js", "vite.config.mjs"]) {
8325
- 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/);
8326
8756
  if (match)
8327
8757
  return { port: Number(match[1]), source: file };
8328
8758
  }
8329
- 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];
8330
8760
  if (fromEnv)
8331
8761
  return { port: Number(fromEnv), source: ".env" };
8332
8762
  return { port: framework && FRAMEWORK_PORTS[framework] || 3e3, source: framework ? `the ${framework} default` : "the usual default" };
8333
8763
  }
8334
8764
  function mobilePlatform(root2) {
8335
- 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"));
8336
8766
  return process.platform === "darwin" && hasIos ? "ios" : "android";
8337
8767
  }
8338
8768
  function detectApp(root2) {
8339
- const pkg = readJson2(join13(root2, "package.json")) ?? {};
8769
+ const pkg = readJson2(join14(root2, "package.json")) ?? {};
8340
8770
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
8341
8771
  const notes = [];
8342
8772
  if (deps.electron) {
@@ -8344,8 +8774,8 @@ function detectApp(root2) {
8344
8774
  return { platform: "electron", start: "npx electron . --remote-debugging-port=9222", cdpPort: 9222, notes };
8345
8775
  }
8346
8776
  const reactNative = deps.expo || deps["react-native"];
8347
- const nativeIos = !existsSync6(join13(root2, "package.json")) && xcodeProjects(root2).length > 0;
8348
- 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;
8349
8779
  if (reactNative || nativeIos || nativeAndroid) {
8350
8780
  const platform = nativeIos ? "ios" : nativeAndroid ? "android" : mobilePlatform(root2);
8351
8781
  notes.push(`Platform ${platform}: detected from ${reactNative ? "package.json" : platform === "ios" ? "the Xcode project" : "the Gradle files"}.`);
@@ -8415,7 +8845,7 @@ function renderConfig(answers) {
8415
8845
  lines.push(` appId: ${answers.appId ?? "com.example.app"}${answers.appId ? "" : " # TODO: your bundle ID or package name"}`);
8416
8846
  lines.push(" # device: <simulator UDID or emulator serial> # default: the booted one");
8417
8847
  }
8418
- 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)", "");
8419
8849
  return lines.join("\n");
8420
8850
  }
8421
8851
  var INSTRUCTIONS = `# About the app
@@ -8438,7 +8868,7 @@ Example: Sign in with the email {{TEST_EMAIL}} and the password {{TEST_PASSWORD}
8438
8868
  - Do not make payments.
8439
8869
  `;
8440
8870
  function ignoreData(root2) {
8441
- const gitignore = join13(root2, ".gitignore");
8871
+ const gitignore = join14(root2, ".gitignore");
8442
8872
  const ignored = existsSync6(gitignore) ? readFileSync5(gitignore, "utf8") : "";
8443
8873
  const lines = ignored.split("\n");
8444
8874
  if (lines.some((line) => /^\/?\.bughunters\/runs\/?$/.test(line.trim())))
@@ -8455,7 +8885,7 @@ ${IGNORE_LINE}
8455
8885
  return true;
8456
8886
  }
8457
8887
  function relativeConfig(root2) {
8458
- const shown = relative3(process.cwd(), paths.config(root2));
8888
+ const shown = relative4(process.cwd(), paths.config(root2));
8459
8889
  return shown && !shown.startsWith("..") ? shown : paths.config(root2);
8460
8890
  }
8461
8891
  var IGNORE_LINE = `${BUGHUNTERS_DIR}/${DATA_DIR}/`;
@@ -8470,7 +8900,7 @@ function writeInitialConfig(root2, answers) {
8470
8900
  writeFileSync3(configPath, renderConfig(answers));
8471
8901
  result3.written.push(configName);
8472
8902
  }
8473
- const guidePath = join13(paths.dir(root2), "instructions.md");
8903
+ const guidePath = join14(paths.dir(root2), "instructions.md");
8474
8904
  if (existsSync6(guidePath))
8475
8905
  result3.skipped.push(`${BUGHUNTERS_DIR}/instructions.md`);
8476
8906
  else {
@@ -8485,12 +8915,6 @@ function writeInitialConfig(root2, answers) {
8485
8915
  result3.warnings.push(`Set ${route2.env} before you run Bughunters. Get a key at ${route2.url}`);
8486
8916
  }
8487
8917
  }
8488
- if (answers.jev !== "auto" && !process.env[JEV_ROUTES2[answers.jev].env]) {
8489
- result3.warnings.push(`Set ${JEV_ROUTES2[answers.jev].env} to use Jev. Get a key at ${JEV_ROUTES2[answers.jev].url}`);
8490
- }
8491
- if (answers.jev === "auto" && !Object.values(JEV_ROUTES2).some((route2) => process.env[route2.env])) {
8492
- result3.warnings.push("No Jev key is set, so Bughunters costs more. Get a key at https://typesafe.ai");
8493
- }
8494
8918
  return result3;
8495
8919
  }
8496
8920
  var PROVIDERS = [...CLI_AGENTS, ...Object.keys(KEY_PROVIDERS)];
@@ -8535,9 +8959,6 @@ function parseInitFlags(args2) {
8535
8959
  case "--fixer":
8536
8960
  flags[flag2.slice(2)] = oneOf(flag2, args2[++index], PROVIDERS);
8537
8961
  break;
8538
- case "--jev":
8539
- flags.jev = oneOf(flag2, args2[++index], ["typesafe", "openrouter", "vercel", "auto"]);
8540
- break;
8541
8962
  default:
8542
8963
  throw new ConfigError(`Unknown flag for init: ${flag2}`);
8543
8964
  }
@@ -8584,7 +9005,6 @@ function defaultAnswers(guess, detected, flags) {
8584
9005
  cdpPort: guess.cdpPort,
8585
9006
  ready: platform === guess.platform ? guess.ready : void 0,
8586
9007
  providers,
8587
- jev: flags.jev ?? detected.jev[0] ?? "auto",
8588
9008
  piPermissionModes: detected.piPermissionModes
8589
9009
  };
8590
9010
  }
@@ -8637,15 +9057,6 @@ async function interview(ask, root2, guess, detected, answers) {
8637
9057
  next.providers.explorer = await choose(ask, "Explorer: it uses the app and reports what looks wrong.", options, next.providers.explorer);
8638
9058
  next.providers.judge = await choose(ask, "Judge: it decides which reports are real bugs.", options, next.providers.explorer);
8639
9059
  next.providers.fixer = await choose(ask, "Fixer: it writes the fixes (off until you turn it on).", options, next.providers.fixer);
8640
- process.stdout.write("\nJev triages each finding in one fast, low-cost call. The LLM judge then sees only the real findings.\n");
8641
- const jevOptions = [
8642
- ...Object.keys(JEV_ROUTES2).map((route2) => ({
8643
- value: route2,
8644
- label: `${JEV_ROUTES2[route2].label} (${JEV_ROUTES2[route2].env}${detected.jev.includes(route2) ? " is set" : `, get a key at ${JEV_ROUTES2[route2].url}`})`
8645
- })),
8646
- { value: "auto", label: "Try each key in order (with no key, the LLM does this work at a higher cost)" }
8647
- ];
8648
- next.jev = await choose(ask, "How does Bughunters reach Jev?", jevOptions, next.jev === "auto" ? detected.jev[0] ?? "typesafe" : next.jev);
8649
9060
  return next;
8650
9061
  }
8651
9062
  async function runInit(root2, args2, log) {
@@ -8778,9 +9189,6 @@ determinism:
8778
9189
  blockThirdPartyRequests: true
8779
9190
 
8780
9191
  decisions:
8781
- decider: jev # jev | model
8782
- jev:
8783
- via: auto # auto | typesafe | openrouter | vercel
8784
9192
  model:
8785
9193
  via: auto # auto | openrouter | vercel | openai | anthropic | custom
8786
9194
  name: "" # optional model id override
@@ -8812,7 +9220,7 @@ production:
8812
9220
 
8813
9221
  // ../cli/dist/commands/run.js
8814
9222
  import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "node:fs";
8815
- import { join as join15 } from "node:path";
9223
+ import { join as join16 } from "node:path";
8816
9224
 
8817
9225
  // ../capture/dist/determinism.js
8818
9226
  var DETERMINISTIC_CHROMIUM_ARGS = [
@@ -9004,7 +9412,7 @@ async function assertNoFontFallback(page, failOnFallback) {
9004
9412
 
9005
9413
  // ../capture/dist/capture.js
9006
9414
  import { mkdirSync as mkdirSync3 } from "node:fs";
9007
- import { dirname as dirname3 } from "node:path";
9415
+ import { dirname as dirname4 } from "node:path";
9008
9416
 
9009
9417
  // ../capture/dist/pixel-contrast.js
9010
9418
  import { PNG as PNG3 } from "pngjs";
@@ -9077,7 +9485,7 @@ async function captureScreen(page, config, request) {
9077
9485
  const stability = await waitForStableFrame(page, config.determinism.stabilityGate);
9078
9486
  const masks = await resolveMasks(page, config.mask, request.url);
9079
9487
  const consoleErrors2 = collectedErrors.get(page) ?? [];
9080
- mkdirSync3(dirname3(request.outPath), { recursive: true });
9488
+ mkdirSync3(dirname4(request.outPath), { recursive: true });
9081
9489
  const buffer = await page.screenshot({
9082
9490
  path: request.outPath,
9083
9491
  fullPage: request.fullPage ?? true,
@@ -9215,7 +9623,7 @@ function bodyMentions(body, selector) {
9215
9623
  async function stopProcess(child) {
9216
9624
  if (child.exitCode !== null || child.pid === void 0)
9217
9625
  return;
9218
- const done = new Promise((resolve10) => child.once("exit", () => resolve10()));
9626
+ const done = new Promise((resolve12) => child.once("exit", () => resolve12()));
9219
9627
  try {
9220
9628
  process.kill(-child.pid, "SIGTERM");
9221
9629
  } catch {
@@ -9233,7 +9641,7 @@ async function stopProcess(child) {
9233
9641
 
9234
9642
  // ../cli/dist/commands/run-pipeline.js
9235
9643
  import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync5 } from "node:fs";
9236
- import { join as join14 } from "node:path";
9644
+ import { join as join15 } from "node:path";
9237
9645
 
9238
9646
  // ../triage/dist/ledger.js
9239
9647
  import { readFileSync as readFileSync6, writeFileSync as writeFileSync4, existsSync as existsSync7 } from "node:fs";
@@ -9335,8 +9743,8 @@ async function cluster(findings, options = {}) {
9335
9743
  function deterministicPass(findings, changedFilesByFinding) {
9336
9744
  const buckets = /* @__PURE__ */ new Map();
9337
9745
  for (const finding of findings) {
9338
- const files = changedFilesByFinding?.get(finding.id) ?? finding.suspectedFiles;
9339
- 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("::");
9340
9748
  const bucket = buckets.get(key);
9341
9749
  if (bucket)
9342
9750
  bucket.push(finding);
@@ -9664,7 +10072,7 @@ async function executeRun(options) {
9664
10072
  console: screen.snapshot.consoleErrors,
9665
10073
  knownIntents: ledger.summaries()
9666
10074
  });
9667
- const estimate = decider ? estimateDecisionCost(text3.length, decider.name) : 0;
10075
+ const estimate = decider ? estimateDecisionCost(text3.length) : 0;
9668
10076
  let answers = {};
9669
10077
  if (!decider) {
9670
10078
  trace2.decision = {
@@ -9913,13 +10321,13 @@ function worstOf(a, b) {
9913
10321
  function writeArtifacts(root2, run2, findings, meta) {
9914
10322
  const dir = paths.run(root2, run2.id);
9915
10323
  mkdirSync4(dir, { recursive: true });
9916
- const reportPath = join14(dir, "report.html");
10324
+ const reportPath = join15(dir, "report.html");
9917
10325
  writeFileSync5(reportPath, renderHtml({ run: run2, findings, suppressed: meta.suppressed, quarantined: 0, notes: meta.notes }));
9918
- writeFileSync5(join14(dir, "junit.xml"), toJUnit(run2, findings));
9919
- writeFileSync5(join14(dir, "results.sarif"), toSarif(findings));
9920
- 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)}
9921
10329
  `);
9922
- writeFileSync5(join14(dir, "trace.json"), `${JSON.stringify(meta.trace, null, 2)}
10330
+ writeFileSync5(join15(dir, "trace.json"), `${JSON.stringify(meta.trace, null, 2)}
9923
10331
  `);
9924
10332
  return { reportPath, runDir: dir };
9925
10333
  }
@@ -9948,7 +10356,7 @@ async function runCommand(options) {
9948
10356
  if (server.external)
9949
10357
  log(`Using the app already serving at ${requireRun(config).url}`);
9950
10358
  const captured = [];
9951
- const runDir = join15(paths.runs(root2), "latest");
10359
+ const runDir = join16(paths.runs(root2), "latest");
9952
10360
  mkdirSync5(runDir, { recursive: true });
9953
10361
  const live = new LiveProgressWriter(root2, targets.length * config.viewports.length);
9954
10362
  live.start();
@@ -10052,7 +10460,7 @@ var LiveProgressWriter = class {
10052
10460
  async function captureOne(args2) {
10053
10461
  const { target, viewport, session, config, store, runDir } = args2;
10054
10462
  const slug2 = `${slugify(target.id)}--${viewport.name}`;
10055
- const actualPath = join15(runDir, `${slug2}.actual.png`);
10463
+ const actualPath = join16(runDir, `${slug2}.actual.png`);
10056
10464
  const output = await captureScreen(session.page, config, {
10057
10465
  screenId: target.id,
10058
10466
  url: target.url,
@@ -10077,7 +10485,7 @@ async function captureOne(args2) {
10077
10485
  missingFonts: output.missingFonts
10078
10486
  };
10079
10487
  }
10080
- const diffPath = join15(runDir, `${slug2}.diff.png`);
10488
+ const diffPath = join16(runDir, `${slug2}.diff.png`);
10081
10489
  const comparison = await diff({
10082
10490
  baselinePath,
10083
10491
  actualPath,
@@ -10140,7 +10548,7 @@ function loadBaselineSnapshot(root2, key) {
10140
10548
  }
10141
10549
  }
10142
10550
  function snapshotFile(root2, key) {
10143
- 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`);
10144
10552
  }
10145
10553
  function saveBaselineSnapshot(root2, key, snapshot) {
10146
10554
  mkdirSync5(paths.baselines(root2), { recursive: true });
@@ -10241,12 +10649,12 @@ function formatRunSummary(result3) {
10241
10649
  // ../dashboard/dist/server.js
10242
10650
  import { createServer as createServer2 } from "node:http";
10243
10651
  import { createReadStream, existsSync as existsSync10, realpathSync, statSync as statSync2, watch } from "node:fs";
10244
- import { extname, join as join18, relative as relative4, resolve as resolve9, sep as sep3 } from "node:path";
10652
+ import { extname, join as join19, relative as relative5, resolve as resolve11, sep as sep4 } from "node:path";
10245
10653
  import { fileURLToPath } from "node:url";
10246
10654
 
10247
10655
  // ../dashboard/dist/project.js
10248
10656
  import { existsSync as existsSync9, readFileSync as readFileSync8, readdirSync as readdirSync2, statSync } from "node:fs";
10249
- import { join as join16 } from "node:path";
10657
+ import { join as join17 } from "node:path";
10250
10658
  var ProjectReader = class {
10251
10659
  root;
10252
10660
  constructor(root2) {
@@ -10263,16 +10671,16 @@ var ProjectReader = class {
10263
10671
  const dir = paths.runs(this.root);
10264
10672
  if (!existsSync9(dir))
10265
10673
  return [];
10266
- 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);
10267
10675
  }
10268
10676
  readRun(runId) {
10269
- const dir = join16(paths.runs(this.root), runId);
10270
- const runFile = join16(dir, "run.json");
10677
+ const dir = join17(paths.runs(this.root), runId);
10678
+ const runFile = join17(dir, "run.json");
10271
10679
  if (!existsSync9(runFile))
10272
10680
  return void 0;
10273
10681
  try {
10274
10682
  const parsed = JSON.parse(readFileSync8(runFile, "utf8"));
10275
- const traceFile = join16(dir, "trace.json");
10683
+ const traceFile = join17(dir, "trace.json");
10276
10684
  const trace = existsSync9(traceFile) ? JSON.parse(readFileSync8(traceFile, "utf8")) : void 0;
10277
10685
  return { id: runId, dir, run: parsed.run, findings: parsed.findings, trace, mtimeMs: safeMtime(dir) };
10278
10686
  } catch {
@@ -10358,8 +10766,8 @@ function safeMtime(path) {
10358
10766
 
10359
10767
  // ../dashboard/dist/agents.js
10360
10768
  import { readFileSync as readFileSync9, readdirSync as readdirSync3 } from "node:fs";
10361
- import { basename as basename4, join as join17 } from "node:path";
10362
- var roles = ["explorer", "judge", "fixer"];
10769
+ import { basename as basename4, join as join18 } from "node:path";
10770
+ var roles2 = ["explorer", "judge", "fixer"];
10363
10771
  var severity = { critical: 0, major: 1, minor: 2, cosmetic: 3 };
10364
10772
  var open = /* @__PURE__ */ new Set(["new", "filed", "fixing", "fix-proposed"]);
10365
10773
  function readJson3(file) {
@@ -10415,7 +10823,7 @@ var AgentReader = class {
10415
10823
  const value = readJson3(paths.agents(this.root));
10416
10824
  if (!Array.isArray(value?.agents))
10417
10825
  return void 0;
10418
- 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) => {
10419
10827
  const busy = agent.state === "working" || agent.state === "waiting";
10420
10828
  return busy && !processAlive(agent.pid) ? { ...agent, state: "idle", activity: STOPPED } : agent;
10421
10829
  });
@@ -10430,28 +10838,31 @@ var AgentReader = class {
10430
10838
  }
10431
10839
  issues() {
10432
10840
  const fixes = this.fixes();
10433
- 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));
10434
10845
  }
10435
10846
  fixes() {
10436
- 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));
10437
10848
  }
10438
10849
  routines() {
10439
- 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)));
10440
10851
  }
10441
10852
  sessions(limit = 50) {
10442
- 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);
10443
10854
  }
10444
10855
  session(id2) {
10445
10856
  const session = this.sessions(Infinity).find((entry) => entry.id === id2);
10446
10857
  if (!session)
10447
10858
  return void 0;
10448
10859
  const dir = paths.session(this.root, id2);
10449
- const events = jsonLines(join17(dir, "events.jsonl"));
10860
+ const events = jsonLines(join18(dir, "events.jsonl"));
10450
10861
  return {
10451
10862
  // A running session has no totals yet: add up its events so far.
10452
10863
  session: session.status === "running" ? { ...session, ...usageOf(events) } : session,
10453
10864
  events,
10454
- candidates: jsonLines(join17(dir, "candidates.jsonl"))
10865
+ candidates: jsonLines(join18(dir, "candidates.jsonl"))
10455
10866
  };
10456
10867
  }
10457
10868
  issue(id2) {
@@ -10461,7 +10872,7 @@ var AgentReader = class {
10461
10872
  const fix = this.fixes().find((entry) => entry.id === issue.fixId || entry.issueId === id2);
10462
10873
  const routine = this.routines().find((entry) => entry.id === issue.evidence?.routineId);
10463
10874
  const ids = new Set(issue.candidateIds);
10464
- 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));
10465
10876
  return { issue, fix, candidates, routine };
10466
10877
  }
10467
10878
  screens() {
@@ -10510,6 +10921,10 @@ var AgentReader = class {
10510
10921
  edges.push({ from: "__start", to: screen.id, kind: "route", via: "route", count: 1, steps: 0 });
10511
10922
  }
10512
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
+ }
10513
10928
  const screens = map.screens.map((screen) => ({
10514
10929
  ...screen,
10515
10930
  openIssues: issues.filter((issue) => issue.screenId === screen.id).length
@@ -10518,16 +10933,30 @@ var AgentReader = class {
10518
10933
  screens.push({ id: "__start", name: "App start", virtual: true, openIssues: 0 });
10519
10934
  return { ...map, screens, edges, entryId: screens.length ? "__start" : void 0 };
10520
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
+ }
10521
10949
  overview(now = /* @__PURE__ */ new Date()) {
10522
10950
  const agentFile = this.agents();
10523
10951
  const appmap = this.appmap();
10524
10952
  const issues = this.issues();
10525
10953
  const sessions = this.sessions(Infinity);
10526
10954
  const today = now.toLocaleDateString("en-CA");
10527
- 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) ?? {
10528
10957
  role,
10529
- state: "off",
10530
- runtime: "",
10958
+ state: configured?.[role].enabled === false ? "off" : configured ? "idle" : "off",
10959
+ runtime: configured?.[role].runtime ?? "",
10531
10960
  updatedAt: "",
10532
10961
  spentUsd: 0
10533
10962
  });
@@ -10537,9 +10966,9 @@ var AgentReader = class {
10537
10966
  const screenshot = [...detail?.events ?? []].reverse().find((event) => event.screenshot)?.screenshot;
10538
10967
  const openIssues = issues.filter((issue) => open.has(issue.status));
10539
10968
  const fixes = this.fixes();
10540
- 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) => {
10541
10970
  const fix = fixes.find((entry) => entry.id === issue.fixId || entry.issueId === issue.id);
10542
- 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 };
10543
10972
  });
10544
10973
  const prStates = fixes.map((fix) => fix.pr?.state).filter(Boolean);
10545
10974
  const issueStates = issues.map((issue) => issue.github?.state).filter(Boolean);
@@ -10591,7 +11020,7 @@ function usageReport(sessions, now = /* @__PURE__ */ new Date()) {
10591
11020
  rows.set(key, row);
10592
11021
  }
10593
11022
  }
10594
- 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));
10595
11024
  return { todayByRole, week, ...weekTotal ? { weekTotal } : {} };
10596
11025
  }
10597
11026
  function isFeedEvent(event) {
@@ -10859,7 +11288,7 @@ function hostOf(href) {
10859
11288
  }
10860
11289
 
10861
11290
  // ../dashboard/dist/server.js
10862
- var UI_DIR = resolve9(fileURLToPath(new URL(".", import.meta.url)), "..", "src", "ui");
11291
+ var UI_DIR = resolve11(fileURLToPath(new URL(".", import.meta.url)), "..", "src", "ui");
10863
11292
  var MIME = {
10864
11293
  ".html": "text/html; charset=utf-8",
10865
11294
  ".js": "text/javascript; charset=utf-8",
@@ -10870,7 +11299,7 @@ var MIME = {
10870
11299
  ".json": "application/json; charset=utf-8"
10871
11300
  };
10872
11301
  async function startDashboard(options) {
10873
- const root2 = resolve9(options.root);
11302
+ const root2 = resolve11(options.root);
10874
11303
  const host = options.host ?? "127.0.0.1";
10875
11304
  const reader = new ProjectReader(root2);
10876
11305
  const agents = new AgentReader(root2);
@@ -10965,7 +11394,7 @@ async function handle2(req, res, ctx) {
10965
11394
  return serveUi(res, path);
10966
11395
  }
10967
11396
  function resolveArtifactPath(root2, requested) {
10968
- const candidate = resolve9(root2, requested);
11397
+ const candidate = resolve11(root2, requested);
10969
11398
  if (!existsSync10(candidate))
10970
11399
  return void 0;
10971
11400
  let real;
@@ -10976,8 +11405,8 @@ function resolveArtifactPath(root2, requested) {
10976
11405
  } catch {
10977
11406
  return void 0;
10978
11407
  }
10979
- const rel = relative4(allowed, real);
10980
- if (rel === "" || rel.startsWith("..") || rel.startsWith(`..${sep3}`))
11408
+ const rel = relative5(allowed, real);
11409
+ if (rel === "" || rel.startsWith("..") || rel.startsWith(`..${sep4}`))
10981
11410
  return void 0;
10982
11411
  const stat = statSync2(real);
10983
11412
  if (!stat.isFile())
@@ -10998,7 +11427,7 @@ function serveArtifact(res, root2, requested) {
10998
11427
  }
10999
11428
  function serveUi(res, path) {
11000
11429
  const name = path === "/" ? "index.html" : path.replace(/^\//, "");
11001
- const file = resolve9(UI_DIR, name);
11430
+ const file = resolve11(UI_DIR, name);
11002
11431
  if (!file.startsWith(UI_DIR) || !existsSync10(file) || !statSync2(file).isFile()) {
11003
11432
  return serveUi(res, "/");
11004
11433
  }
@@ -11131,14 +11560,19 @@ function parseAgentFlags(command2, args2) {
11131
11560
  fix: ["--issue"],
11132
11561
  retest: ["--issue"],
11133
11562
  publish: ["--issue"],
11563
+ ci: ["--issue"],
11134
11564
  patrol: []
11135
11565
  };
11136
11566
  if (!values[command2])
11137
11567
  throw new ConfigError(`Unknown agent command ${command2}`);
11138
11568
  for (let index = 0; index < args2.length; index++) {
11139
11569
  const flag2 = args2[index];
11140
- if (command2 === "patrol" && flag2 === "--once") {
11141
- flags.once = true;
11570
+ if (command2 === "patrol" && (flag2 === "--once" || flag2 === "--force")) {
11571
+ flags[flag2.slice(2)] = true;
11572
+ continue;
11573
+ }
11574
+ if (command2 === "ci" && flag2 === "--wait") {
11575
+ flags.wait = true;
11142
11576
  continue;
11143
11577
  }
11144
11578
  if (command2 === "publish" && flag2 === "--dry-run") {
@@ -11178,6 +11612,8 @@ function rolesFor(command2, config) {
11178
11612
  case "judge":
11179
11613
  case "publish":
11180
11614
  return ["judge"];
11615
+ case "ci":
11616
+ return ["fixer"];
11181
11617
  case "retest":
11182
11618
  return ["explorer", "judge"];
11183
11619
  case "fix":
@@ -11192,15 +11628,36 @@ function preflight(command2, config, env = process.env) {
11192
11628
  const problems = rolesFor(command2, config).map((role) => runtimeProblem(role, config.agents[role].use, env)).filter((problem) => Boolean(problem));
11193
11629
  if (problems.length)
11194
11630
  throw new ConfigError(problems.join("\n"));
11195
- return ["explore", "patrol"].includes(command2) ? [resolveDecider(config.decisions, env).reason] : [];
11196
11631
  }
11197
11632
  async function runAgentCommand(command2, args2, root2, config, log) {
11198
11633
  const flags = parseAgentFlags(command2, args2);
11199
- for (const line of preflight(command2, config))
11200
- log(line);
11634
+ preflight(command2, config);
11201
11635
  const workspace = new Workspace(root2);
11202
11636
  if (command2 === "patrol") {
11203
- await runPatrol({ root: root2, config, once: Boolean(flags.once), 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;
11204
11661
  return;
11205
11662
  }
11206
11663
  if (command2 === "publish") {
@@ -11392,7 +11849,7 @@ function bySeverity(a, b) {
11392
11849
  }
11393
11850
 
11394
11851
  // ../cli/dist/commands/memory.js
11395
- var roles2 = /* @__PURE__ */ new Set(["explorer", "judge", "fixer"]);
11852
+ var roles3 = /* @__PURE__ */ new Set(["explorer", "judge", "fixer"]);
11396
11853
  var flag = (args2, name) => {
11397
11854
  const at = args2.indexOf(name);
11398
11855
  return at < 0 ? void 0 : args2[at + 1];
@@ -11402,7 +11859,7 @@ async function runMemoryCommand(args2, root2, log) {
11402
11859
  const workspace = new Workspace(root2);
11403
11860
  if (action === "list") {
11404
11861
  const role = flag(args2.slice(1), "--role");
11405
- if (role && !roles2.has(role))
11862
+ if (role && !roles3.has(role))
11406
11863
  throw new ConfigError(`Unknown memory role: ${role}`);
11407
11864
  const lessons = (await workspace.readMemory()).lessons.filter((lesson) => !role || lesson.role === role);
11408
11865
  log("id role hits source scope text");
@@ -11415,7 +11872,7 @@ async function runMemoryCommand(args2, root2, log) {
11415
11872
  if (action === "add") {
11416
11873
  const role = flag(args2.slice(1), "--role");
11417
11874
  const text3 = args2.slice(1).find((part, index, all) => !part.startsWith("--") && all[index - 1] !== "--role" && all[index - 1] !== "--scope");
11418
- if (!role || !roles2.has(role) || !text3?.trim())
11875
+ if (!role || !roles3.has(role) || !text3?.trim())
11419
11876
  throw new ConfigError('Usage: bughunters memory add --role explorer|judge|fixer "text" [--scope s]');
11420
11877
  const [lesson] = await workspace.upsertLessons([{
11421
11878
  role,
@@ -11449,7 +11906,7 @@ var [command, ...args] = process.argv.slice(2);
11449
11906
  var root = findProjectRoot(process.cwd());
11450
11907
  function version() {
11451
11908
  if (true)
11452
- return "0.1.0";
11909
+ return "0.1.2";
11453
11910
  try {
11454
11911
  return JSON.parse(readFileSync10(new URL("../package.json", import.meta.url), "utf8")).version;
11455
11912
  } catch {
@@ -11513,6 +11970,7 @@ try {
11513
11970
  case "fix":
11514
11971
  case "retest":
11515
11972
  case "publish":
11973
+ case "ci":
11516
11974
  case "patrol":
11517
11975
  case "replay": {
11518
11976
  const config = loadConfig(root);