aztrx-cli 0.4.2 → 0.4.4

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.
@@ -19,7 +19,7 @@ import * as fs from "fs";
19
19
  import * as path from "path";
20
20
  import { redact, unredact } from "./redact.js";
21
21
  import { auditPatch } from "./gates.js";
22
- import { generatePatch, generateRulePatch, modelTiers, RULE_TIER } from "./llm.js";
22
+ import { generatePatch, generateRulePatch, modelTiers, RULE_TIER, BudgetExhaustedError } from "./llm.js";
23
23
  import { hasLlmKey } from "../llm.js";
24
24
  import { applyHunks, createWorktree, diffWorktree, runTests, typecheckWorktree, writeWorktreeFile } from "./sandbox.js";
25
25
  import { bootServer, detectStartCommand } from "./boot.js";
@@ -154,12 +154,14 @@ export async function heal(finding, opts) {
154
154
  // 2. Generate (this tier).
155
155
  let patch;
156
156
  try {
157
- patch = await generatePatch(ctx, { model: tier.model, patchFn: opts.patchFn });
157
+ patch = await generatePatch(ctx, { model: tier.model, patchFn: opts.patchFn, budget: opts.budget });
158
158
  }
159
159
  catch (e) {
160
- // A transport/config failure isn't a model-quality failure a pricier
161
- // tier won't fix a dead endpoint or a missing key, so stop here.
162
- last = { ...base, status: "no-llm", error: e.message, model: tier.model };
160
+ // A spent session budget stops everything paid; a transport/config failure
161
+ // won't be fixed by a pricier tier either, so both break out here.
162
+ last = e instanceof BudgetExhaustedError
163
+ ? { ...base, status: "budget-exhausted", error: e.message, model: tier.model }
164
+ : { ...base, status: "no-llm", error: e.message, model: tier.model };
163
165
  break;
164
166
  }
165
167
  if (patch.hunks.length === 0) {
@@ -45,6 +45,14 @@ Hard rules:
45
45
  - Do NOT add any new import/require/import(). Do NOT use eval or new Function. Do NOT write an empty catch block (catch {}). Do NOT touch child_process, exec, spawn, fork, process.exit.
46
46
  - If you see __AZTRX_REDACTED_N__ placeholders, treat them as opaque tokens and carry them through unchanged — do not invent values for them.
47
47
  - If you cannot fix the bug, return { "explanation": "cannot fix", "edits": [] }.`;
48
+ /** Thrown when the shared session budget has no paid generations left. Heal
49
+ * maps this to a `budget-exhausted` status rather than a transport error. */
50
+ export class BudgetExhaustedError extends Error {
51
+ constructor() {
52
+ super("spend budget exhausted");
53
+ this.name = "BudgetExhaustedError";
54
+ }
55
+ }
48
56
  function buildPrompt(ctx) {
49
57
  const loc = ctx.finding.mappedLocation;
50
58
  const msg = redact(ctx.finding.rawMessage).text;
@@ -120,6 +128,12 @@ export async function generatePatch(ctx, opts = {}) {
120
128
  return rulePatch;
121
129
  throw new Error("no rule-based fix applicable");
122
130
  }
131
+ // Paid path — enforce the shared session budget before spending, and charge it
132
+ // on success. The free rule tier above never reaches this, so a spent budget
133
+ // still lets free fixes through.
134
+ if (opts.budget && opts.budget.remaining <= 0) {
135
+ throw new BudgetExhaustedError();
136
+ }
123
137
  const text = await complete({
124
138
  system: SYSTEM,
125
139
  prompt: buildPrompt(ctx),
@@ -127,5 +141,7 @@ export async function generatePatch(ctx, opts = {}) {
127
141
  maxTokens: 2048,
128
142
  temperature: 0,
129
143
  });
144
+ if (opts.budget)
145
+ opts.budget.remaining -= 1;
130
146
  return parsePatch(text);
131
147
  }
@@ -42,7 +42,11 @@ const VALUE_MATCH = [
42
42
  label: "secret_value",
43
43
  },
44
44
  {
45
- re: /((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|amqps):\/\/[^:/\s]+:)[^@\s]+(@)/gi,
45
+ // Captures the password as group 2 and stops at `@` with a lookahead so the
46
+ // `@` is never consumed. (The old form captured `(@)` instead — group 2 was
47
+ // the literal `@` — so the password was dropped and `unredact` restored `@`
48
+ // in its place, corrupting the URL.)
49
+ re: /((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|amqps):\/\/[^:/\s]+:)([^@\s]+)(?=@)/gi,
46
50
  label: "url_password",
47
51
  },
48
52
  ];
@@ -43,7 +43,11 @@ export function applyHunks(content, hunks) {
43
43
  return { ok: false, patched: content, applied: 0, errors };
44
44
  let patched = content;
45
45
  for (const h of hunks) {
46
- patched = patched.replace(h.search, h.replace);
46
+ // Function replacer: a plain `replace(search, replace)` treats `$&`, `$1`,
47
+ // `` $` ``, `$'`, `$$` in `replace` as substitution tokens, so a fix that
48
+ // introduces a literal `$5` (a price, a template fragment, a regex capture)
49
+ // would be silently mangled. The function form inserts `replace` verbatim.
50
+ patched = patched.replace(h.search, () => h.replace);
47
51
  }
48
52
  return { ok: true, patched, applied: hunks.length, errors: [] };
49
53
  }
@@ -90,7 +90,7 @@ function hostAllowed(url, allowHosts) {
90
90
  return false;
91
91
  }
92
92
  /** Harvest candidate endpoints the app actually uses — not blind probing. */
93
- async function collectEndpoints(page, origin) {
93
+ async function collectEndpoints(page, origin, allowDestructive, seedUrls = []) {
94
94
  const seen = new Map();
95
95
  const push = (raw) => {
96
96
  let u;
@@ -104,11 +104,15 @@ async function collectEndpoints(page, origin) {
104
104
  return;
105
105
  if (STATIC_EXT.test(u.pathname))
106
106
  return;
107
- if (DESTRUCTIVE_PATH.test(u.pathname))
107
+ if (!allowDestructive && DESTRUCTIVE_PATH.test(u.pathname))
108
108
  return;
109
109
  if (!seen.has(u.pathname))
110
110
  seen.set(u.pathname, u);
111
111
  };
112
+ // Endpoints observed live by the caller — e.g. a `fetch()` fired from a click
113
+ // handler that never appears in `performance` resources or the DOM.
114
+ for (const s of seedUrls)
115
+ push(s);
112
116
  // URLs the page already fetched (API calls, RSC/data endpoints).
113
117
  const resources = await page
114
118
  .evaluate(() => performance.getEntriesByType("resource").map((e) => e.name))
@@ -191,11 +195,11 @@ export async function httpFuzz(page, targetUrl, bus, opts = {}) {
191
195
  const allowHosts = opts.allowHosts ?? new Set();
192
196
  if (!hostAllowed(targetUrl, allowHosts))
193
197
  return 0;
194
- if (!opts.dryRun) {
198
+ if (!opts.dryRun && opts.navigate !== false) {
195
199
  await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => { });
196
200
  await page.waitForTimeout(500);
197
201
  }
198
- const endpoints = await collectEndpoints(page, new URL(targetUrl).origin);
202
+ const endpoints = await collectEndpoints(page, new URL(targetUrl).origin, Boolean(opts.allowDestructive), opts.seedUrls ?? []);
199
203
  endpoints.sort((a, b) => a.pathname.localeCompare(b.pathname));
200
204
  let sent = 0;
201
205
  outer: for (const endpoint of endpoints) {
@@ -3,6 +3,7 @@ import pc from "picocolors";
3
3
  import { VERSION } from "./version.js";
4
4
  import { EventBus } from "./eventBus.js";
5
5
  import { loadBaseline } from "./classifier.js";
6
+ import { diagnoseFinding } from "./diagnose.js";
6
7
  import { attachNetworkGuard, allowHostsFrom } from "./networkGuard.js";
7
8
  import { swarmDetect } from "./swarm.js";
8
9
  import { ReplayEngine } from "./replay.js";
@@ -14,18 +15,23 @@ import { RunLog } from "./events.js";
14
15
  import { heal } from "./heal/index.js";
15
16
  import { submitTelemetry } from "./telemetry/index.js";
16
17
  import { submitRun } from "./cloud/index.js";
18
+ import { formatDiff } from "./diff.js";
17
19
  const SEVERITY_MARK = {
18
20
  crash: pc.red("● crash "),
19
21
  error: pc.red("● error "),
20
22
  warning: pc.yellow("○ warning "),
21
23
  noise: pc.dim("○ noise "),
22
24
  };
23
- function printFinding(f, write) {
25
+ function printFinding(f, write, lang) {
24
26
  write(SEVERITY_MARK[f.severity] + pc.bold(f.rawMessage));
25
27
  if (f.mappedLocation) {
26
28
  write(pc.dim(` ${f.mappedLocation.filePath}:${f.mappedLocation.line}:${f.mappedLocation.column}`));
27
29
  write(pc.dim(f.mappedLocation.codeContext));
28
30
  }
31
+ // F14 — the one-line "why + fix" diagnosis, inline with every crash/error.
32
+ const dx = diagnoseFinding(f, lang);
33
+ if (dx)
34
+ write(pc.cyan(` ↳ ${dx}`));
29
35
  if (f.serverError) {
30
36
  write(pc.dim(` server: ${f.serverError.message}`));
31
37
  }
@@ -78,6 +84,10 @@ export async function run(options) {
78
84
  say(pc.dim(`Net: deny-by-default → allow ${[...allowHosts].join(", ") || "origin"}`));
79
85
  if (options.storageState)
80
86
  say(pc.dim(`Auth: ${options.storageState}`));
87
+ if (options.allowDestructive) {
88
+ say(pc.yellow(pc.bold("⚠ DESTRUCTIVE MODE — delete/pay/logout/checkout controls are ENABLED.")));
89
+ say(pc.yellow(" This can mutate real data. Run only against a disposable/dev instance you own."));
90
+ }
81
91
  say("");
82
92
  emitPhase("launch", url);
83
93
  const runLog = new RunLog(repoRoot);
@@ -85,10 +95,11 @@ export async function run(options) {
85
95
  runLog.append({ type: "run_start", url, ts: Date.now() });
86
96
  const baseline = await loadBaseline(repoRoot);
87
97
  const workers = options.workers ?? 1;
88
- // F-swarm — parallel detection. One worker is the legacy pass; `--http-fuzz`
89
- // or `workers > 1` fan out into a swarm. Findings come back merged by
98
+ // F-swarm — parallel detection. One worker is the legacy pass; `workers > 1`
99
+ // fans out into a swarm. `--http-fuzz` folds into the walk/fuzz pass as a
100
+ // post-pass on the same page (no extra worker). Findings come back merged by
90
101
  // fingerprint, with per-worker action history already attached.
91
- if (workers > 1 || options.httpFuzz) {
102
+ if (workers > 1) {
92
103
  emitPhase("swarm", `${workers} worker(s)`);
93
104
  }
94
105
  else if (options.fuzz) {
@@ -105,6 +116,7 @@ export async function run(options) {
105
116
  fuzz: options.fuzz,
106
117
  httpFuzz: options.httpFuzz,
107
118
  httpFuzzMutations: options.httpFuzzMutations,
119
+ allowDestructive: options.allowDestructive,
108
120
  seed: options.seed ?? 42,
109
121
  workers,
110
122
  allowHosts,
@@ -125,7 +137,7 @@ export async function run(options) {
125
137
  for (const f of findings) {
126
138
  bus.emit("finding", f);
127
139
  runLog.append({ type: "finding", finding: f });
128
- printFinding(f, say);
140
+ printFinding(f, say, options.lang);
129
141
  }
130
142
  if (workerCount > 1) {
131
143
  say(pc.dim(`\nSwarm: ${totalActions} action(s) across ${workerCount} worker(s) — ${roles.join(", ")}.\n`));
@@ -235,7 +247,9 @@ export async function run(options) {
235
247
  f.type !== "network_timeout" &&
236
248
  f.repro &&
237
249
  f.repro.verdict !== "unreliable");
238
- const healTargets = reproducible.filter((f) => f.mappedLocation?.isOwnCode);
250
+ const skip = new Set(options.skipHealFingerprints ?? []);
251
+ const candidates = reproducible.filter((f) => !skip.has(f.fingerprint));
252
+ const healTargets = candidates.filter((f) => f.mappedLocation?.isOwnCode);
239
253
  if (healTargets.length) {
240
254
  say(pc.cyan("— Closed-loop healing (redact → generate → gate → sandbox → test → verify) —"));
241
255
  emitPhase("heal");
@@ -254,6 +268,7 @@ export async function run(options) {
254
268
  testTimeoutMs: options.testTimeoutMs,
255
269
  skipTest: options.skipTest,
256
270
  startCommand: options.startCommand,
271
+ budget: options.budget,
257
272
  });
258
273
  f.heal = result;
259
274
  bus.emit("heal", {
@@ -273,8 +288,14 @@ export async function run(options) {
273
288
  ? pc.green(" ✓ healed")
274
289
  : result.status === "unfixed"
275
290
  ? pc.yellow(" ◐ unfixed")
276
- : pc.red(` ✗ ${result.status}`);
291
+ : result.status === "budget-exhausted"
292
+ ? pc.dim(" ⏹ budget exhausted")
293
+ : pc.red(` ✗ ${result.status}`);
277
294
  say(`${mark} ${pc.bold(f.rawMessage.split("\n")[0].slice(0, 60))}`);
295
+ if (result.status === "healed" && result.hunks.length > 0) {
296
+ say(pc.dim(` ${result.filePath}`));
297
+ say(formatDiff(result.hunks));
298
+ }
278
299
  if (result.patchPath)
279
300
  say(pc.dim(` patch: ${path.relative(repoRoot, result.patchPath)}`));
280
301
  if (result.error)
@@ -295,13 +316,14 @@ export async function run(options) {
295
316
  }
296
317
  }
297
318
  }
298
- else if (reproducible.length > 0) {
319
+ else if (candidates.length > 0) {
299
320
  // Reproducible crashes, but none mapped to source (wrong --repo?) — tell the
300
- // user rather than silently doing nothing.
301
- say(pc.yellow(`Found ${reproducible.length} reproducible crash(es) but couldn't map them to source files. Run from your project root (or pass --repo <dir>) so --fix can read the code.`));
321
+ // user rather than silently doing nothing. Already-handled fingerprints are
322
+ // excluded above, so this only fires for genuinely new findings.
323
+ say(pc.yellow(`Found ${candidates.length} reproducible crash(es) but couldn't map them to source files. Run from your project root (or pass --repo <dir>) so --fix can read the code.`));
302
324
  }
303
325
  }
304
- const reportPath = writeReport(repoRoot, url, findings);
326
+ const reportPath = writeReport(repoRoot, url, findings, options.lang);
305
327
  say(pc.dim(`Report: ${path.relative(repoRoot, reportPath)}`));
306
328
  const counts = {};
307
329
  for (const f of findings)
@@ -0,0 +1,218 @@
1
+ /**
2
+ * `aztrx patrol` — the autonomous bug-patrol loop. It wraps the existing
3
+ * `run()` detect → repro → heal pipeline in a supervisor loop with memory, so
4
+ * instead of "run it and read the output" the tool re-scans on an interval,
5
+ * fixes anything new, and opens a PR per bug — no human in the middle.
6
+ *
7
+ * Guardrails that keep an autonomous run from going off the rails:
8
+ * - memory (PatrolState) — a handled fingerprint is never re-fixed/re-PR'd;
9
+ * - per-session fix cap (`maxFixes`) — bounds LLM spend + PR spam;
10
+ * - scoped staging — PRs carry only the files the patch touched;
11
+ * - a liveness check — a dead target skips the cycle instead of burning a crawl.
12
+ */
13
+ import pc from "picocolors";
14
+ import { run } from "../orchestrator.js";
15
+ import { applyVerifiedPatches } from "../heal/apply.js";
16
+ import { PatrolState } from "./state.js";
17
+ import { openPatrolPr, openPatrolBatchPr } from "./pr.js";
18
+ import { recordFindingGif } from "./record.js";
19
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
+ /** Any HTTP response means the server is up; only a network failure is "down". */
21
+ async function isAlive(url) {
22
+ try {
23
+ await fetch(url, { signal: AbortSignal.timeout(3000) });
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ function head(f) {
31
+ return f.rawMessage.split("\n")[0].slice(0, 50);
32
+ }
33
+ /** Best-effort GIF recording — a failed capture must never block a PR. */
34
+ async function recordGif(repoRoot, url, f) {
35
+ try {
36
+ return await recordFindingGif(repoRoot, url, f);
37
+ }
38
+ catch (e) {
39
+ console.log(pc.dim(` · recorded repro skipped: ${e.message}`));
40
+ return null;
41
+ }
42
+ }
43
+ export async function patrol(opts) {
44
+ const retryAfterMs = opts.retryAfterMs ?? 30 * 60 * 1000;
45
+ const state = new PatrolState(opts.repoRoot, opts.url, retryAfterMs);
46
+ const maxFixes = opts.maxFixes ?? 5;
47
+ let sessionFixes = 0;
48
+ let sessionPrs = 0;
49
+ const seenFp = new Set();
50
+ // One budget object is shared across every cycle so the cap spans the session,
51
+ // not just a single run.
52
+ const budget = opts.maxSpend && opts.maxSpend > 0 ? { remaining: opts.maxSpend } : undefined;
53
+ console.log(pc.cyan("Aztrx AI — patrol"));
54
+ console.log(pc.dim(`Target: ${opts.url} interval: ${Math.round(opts.intervalMs / 1000)}s max fixes/session: ${maxFixes}` +
55
+ ` retry unfixed after: ${Math.round(retryAfterMs / 1000)}s` +
56
+ (budget ? ` spend cap: ${budget.remaining} generations` : "")));
57
+ console.log("");
58
+ for (let cycle = 1;; cycle++) {
59
+ if (!(await isAlive(opts.url))) {
60
+ console.log(pc.dim(`[cycle ${cycle}] target not responding — skipping this pass.`));
61
+ if (opts.once)
62
+ break;
63
+ await sleep(opts.intervalMs);
64
+ continue;
65
+ }
66
+ console.log(pc.dim(`[cycle ${cycle}] scanning…`));
67
+ let findings;
68
+ try {
69
+ // `ui: true` silences the per-run console so patrol prints its own concise
70
+ // summary instead of the full pipeline log on every cycle.
71
+ findings = await run({
72
+ url: opts.url,
73
+ repoRoot: opts.repoRoot,
74
+ maxActions: opts.maxActions,
75
+ fuzz: opts.fuzz,
76
+ workers: opts.workers,
77
+ allowHosts: opts.allowHosts,
78
+ storageState: opts.storageState,
79
+ login: opts.login,
80
+ loginEmail: opts.loginEmail,
81
+ loginPassword: opts.loginPassword,
82
+ loginUrl: opts.loginUrl,
83
+ lang: opts.lang,
84
+ seed: opts.seed,
85
+ allowDestructive: opts.allowDestructive,
86
+ httpFuzz: opts.httpFuzz,
87
+ httpFuzzMutations: opts.httpFuzzMutations,
88
+ repro: true,
89
+ heal: true,
90
+ healModel: opts.healModel,
91
+ healFastModel: opts.healFastModel,
92
+ testCommand: opts.testCommand,
93
+ testTimeoutMs: opts.testTimeoutMs,
94
+ skipTest: opts.skipTest,
95
+ startCommand: opts.startCommand,
96
+ // Already-handled fingerprints from a prior cycle get skipped before heal,
97
+ // so a re-scan re-detects (to confirm they're still gone) without re-paying
98
+ // the LLM to re-fix them.
99
+ skipHealFingerprints: state.handled(),
100
+ budget,
101
+ ui: true,
102
+ });
103
+ }
104
+ catch (e) {
105
+ console.log(pc.red(`[cycle ${cycle}] run failed: ${e.message}`));
106
+ if (opts.once)
107
+ break;
108
+ await sleep(opts.intervalMs);
109
+ continue;
110
+ }
111
+ // "found" = distinct crash/error fingerprints ever seen this session.
112
+ for (const f of findings) {
113
+ if (f.severity === "crash" || f.severity === "error")
114
+ seenFp.add(f.fingerprint);
115
+ }
116
+ // Reproducible but not healed → mark unfixable so we don't re-burn the LLM on
117
+ // it every cycle; `PatrolState` backs off and retries it once the cooldown
118
+ // lapses. `budget-exhausted` / `no-llm` are NOT unfixable — they mean we never
119
+ // got a real attempt, so they must not be written into memory as "won't fix".
120
+ for (const f of findings) {
121
+ if ((f.severity === "crash" || f.severity === "error") &&
122
+ f.repro &&
123
+ f.heal &&
124
+ f.heal.status !== "healed" &&
125
+ f.heal.status !== "budget-exhausted" &&
126
+ f.heal.status !== "no-llm" &&
127
+ !state.isHandled(f.fingerprint)) {
128
+ state.markUnfixed(f.fingerprint);
129
+ }
130
+ }
131
+ const newHealed = findings.filter((f) => f.heal?.status === "healed" && !state.isHandled(f.fingerprint));
132
+ const toFix = newHealed.slice(0, Math.max(0, maxFixes - sessionFixes));
133
+ if (toFix.length === 0 && sessionFixes >= maxFixes && newHealed.length > 0) {
134
+ console.log(pc.yellow(`[cycle ${cycle}] reached max fixes (${maxFixes}) — no more this session.`));
135
+ }
136
+ if (opts.batch) {
137
+ // Batch: apply each patch, then open one PR carrying every fix that landed.
138
+ const landed = [];
139
+ const files = [];
140
+ for (const f of toFix) {
141
+ const applied = applyVerifiedPatches(opts.repoRoot, [f]);
142
+ if (applied.applied.length === 0) {
143
+ state.markUnfixed(f.fingerprint);
144
+ console.log(pc.yellow(` ◐ ${head(f)} — apply conflict, marked unfixable.`));
145
+ continue;
146
+ }
147
+ landed.push(f);
148
+ for (const a of applied.applied)
149
+ files.push(a.filePath);
150
+ }
151
+ if (landed.length > 0) {
152
+ const mediaPaths = [];
153
+ for (const f of landed)
154
+ mediaPaths.push(await recordGif(opts.repoRoot, opts.url, f));
155
+ const pr = await openPatrolBatchPr(opts.repoRoot, landed, opts.url, [...new Set(files)], mediaPaths);
156
+ if (pr.ok && pr.url) {
157
+ sessionPrs++;
158
+ sessionFixes += landed.length;
159
+ for (const f of landed)
160
+ state.markPr(f.fingerprint, pr.url, pr.branch ?? "");
161
+ console.log(pc.green(` ✓ batch PR opened ${pr.url} — ${landed.length} fix(es)`));
162
+ }
163
+ else if (pr.skipped) {
164
+ for (const f of landed)
165
+ state.markPr(f.fingerprint, "existing", pr.branch ?? "");
166
+ console.log(pc.dim(` — batch PR already exists (${pr.branch})`));
167
+ }
168
+ else {
169
+ for (const f of landed)
170
+ state.markUnfixed(f.fingerprint);
171
+ console.log(pc.red(` ✗ batch PR failed: ${pr.error}`));
172
+ }
173
+ }
174
+ }
175
+ else {
176
+ for (const f of toFix) {
177
+ const applied = applyVerifiedPatches(opts.repoRoot, [f]);
178
+ if (applied.applied.length === 0) {
179
+ state.markUnfixed(f.fingerprint);
180
+ console.log(pc.yellow(` ◐ ${head(f)} — apply conflict, marked unfixable.`));
181
+ continue;
182
+ }
183
+ const files = applied.applied.map((a) => a.filePath);
184
+ const mediaPath = await recordGif(opts.repoRoot, opts.url, f);
185
+ const pr = await openPatrolPr(opts.repoRoot, f, opts.url, files, mediaPath);
186
+ if (pr.ok && pr.url) {
187
+ sessionPrs++;
188
+ sessionFixes++;
189
+ state.markPr(f.fingerprint, pr.url, pr.branch ?? "");
190
+ console.log(pc.green(` ✓ PR opened ${pr.url} — ${head(f)}`));
191
+ }
192
+ else if (pr.skipped) {
193
+ state.markPr(f.fingerprint, "existing", pr.branch ?? "");
194
+ console.log(pc.dim(` — already has a PR (${pr.branch})`));
195
+ }
196
+ else {
197
+ state.markUnfixed(f.fingerprint);
198
+ console.log(pc.red(` ✗ PR failed: ${pr.error}`));
199
+ }
200
+ }
201
+ }
202
+ // Rolling status — the "found / fixed / PRs" tally that makes the loop read
203
+ // as alive rather than a stream of isolated lines.
204
+ console.log(pc.dim(`[cycle ${cycle}] found ${seenFp.size} · fixed ${sessionFixes} · PRs ${sessionPrs}` +
205
+ (newHealed.length === 0 ? " · nothing new to fix" : "")));
206
+ // Once the session's paid budget is spent there's nothing left to fix — stop
207
+ // rather than silently re-detecting without healing.
208
+ if (budget && budget.remaining <= 0) {
209
+ console.log(pc.yellow(`\nSpend budget exhausted — ${sessionPrs} PR(s) opened this session.`));
210
+ break;
211
+ }
212
+ state.save();
213
+ if (opts.once)
214
+ break;
215
+ await sleep(opts.intervalMs);
216
+ }
217
+ console.log(pc.cyan(`patrol session done — found ${seenFp.size} bug(s) · fixed ${sessionFixes} · PRs ${sessionPrs}`));
218
+ }