@tarcisiopgs/lisa 0.9.2 → 0.9.3

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.
Files changed (2) hide show
  1. package/dist/index.js +1274 -299
  2. package/package.json +23 -5
package/dist/index.js CHANGED
@@ -2,16 +2,21 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { execSync as execSync4 } from "child_process";
5
- import { existsSync as existsSync4, readFileSync as readFileSync3, readdirSync } from "fs";
6
- import { join as join5, resolve as resolvePath } from "path";
7
- import { defineCommand, runMain } from "citty";
5
+ import { existsSync as existsSync6, readdirSync, readFileSync as readFileSync6 } from "fs";
6
+ import { join as join8, resolve as resolvePath } from "path";
8
7
  import * as clack from "@clack/prompts";
8
+ import { defineCommand, runMain } from "citty";
9
9
  import pc2 from "picocolors";
10
10
 
11
11
  // src/config.ts
12
12
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
13
13
  import { resolve } from "path";
14
14
  import { parse, stringify } from "yaml";
15
+ var DEFAULT_OVERSEER_CONFIG = {
16
+ enabled: false,
17
+ check_interval: 30,
18
+ stuck_threshold: 300
19
+ };
15
20
  var CONFIG_DIR = ".lisa";
16
21
  var CONFIG_FILE = "config.yaml";
17
22
  var DEFAULT_CONFIG = {
@@ -37,7 +42,8 @@ var DEFAULT_CONFIG = {
37
42
  logs: {
38
43
  dir: "",
39
44
  format: ""
40
- }
45
+ },
46
+ overseer: { ...DEFAULT_OVERSEER_CONFIG }
41
47
  };
42
48
  function getConfigPath(cwd = process.cwd()) {
43
49
  return resolve(cwd, CONFIG_DIR, CONFIG_FILE);
@@ -69,12 +75,19 @@ function loadConfig(cwd = process.cwd()) {
69
75
  ...parsed,
70
76
  source_config: sourceConfig,
71
77
  loop: { ...DEFAULT_CONFIG.loop, ...parsed.loop ?? {} },
72
- logs: { ...DEFAULT_CONFIG.logs, ...parsed.logs ?? {} }
78
+ logs: { ...DEFAULT_CONFIG.logs, ...parsed.logs ?? {} },
79
+ overseer: {
80
+ ...DEFAULT_OVERSEER_CONFIG,
81
+ ...parsed.overseer ?? {}
82
+ }
73
83
  };
74
84
  if (!config2.base_branch) config2.base_branch = "main";
75
85
  for (const repo of config2.repos) {
76
86
  if (!repo.base_branch) repo.base_branch = config2.base_branch;
77
87
  }
88
+ if (!config2.models && config2.provider) {
89
+ config2.models = [config2.provider];
90
+ }
78
91
  return config2;
79
92
  }
80
93
  function saveConfig(config2, cwd = process.cwd()) {
@@ -84,7 +97,20 @@ function saveConfig(config2, cwd = process.cwd()) {
84
97
  mkdirSync(dir, { recursive: true });
85
98
  }
86
99
  const sc = config2.source_config;
87
- const sourceYaml = config2.source === "trello" ? { board: sc.team, pick_from: sc.pick_from || sc.project, label: sc.label, in_progress: sc.in_progress, done: sc.done } : { team: sc.team, project: sc.project, label: sc.label, pick_from: sc.pick_from, in_progress: sc.in_progress, done: sc.done };
100
+ const sourceYaml = config2.source === "trello" ? {
101
+ board: sc.team,
102
+ pick_from: sc.pick_from || sc.project,
103
+ label: sc.label,
104
+ in_progress: sc.in_progress,
105
+ done: sc.done
106
+ } : {
107
+ team: sc.team,
108
+ project: sc.project,
109
+ label: sc.label,
110
+ pick_from: sc.pick_from,
111
+ in_progress: sc.in_progress,
112
+ done: sc.done
113
+ };
88
114
  const output = { ...config2, source_config: sourceYaml };
89
115
  writeFileSync(configPath, stringify(output), "utf-8");
90
116
  }
@@ -97,6 +123,101 @@ function mergeWithFlags(config2, flags) {
97
123
  return merged;
98
124
  }
99
125
 
126
+ // src/github.ts
127
+ import { execa } from "execa";
128
+ var API_URL = "https://api.github.com";
129
+ var REQUEST_TIMEOUT_MS = 3e4;
130
+ async function isGhCliAvailable() {
131
+ try {
132
+ await execa("gh", ["auth", "status"]);
133
+ return true;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+ function getToken() {
139
+ const token = process.env.GITHUB_TOKEN;
140
+ if (!token) throw new Error("GITHUB_TOKEN is not set");
141
+ return token;
142
+ }
143
+ async function createPullRequest(opts, method = "cli") {
144
+ if (method === "cli" && await isGhCliAvailable()) {
145
+ return createPullRequestWithGhCli(opts);
146
+ }
147
+ const res = await fetch(`${API_URL}/repos/${opts.owner}/${opts.repo}/pulls`, {
148
+ method: "POST",
149
+ headers: {
150
+ Authorization: `Bearer ${getToken()}`,
151
+ Accept: "application/vnd.github+json",
152
+ "Content-Type": "application/json"
153
+ },
154
+ body: JSON.stringify({
155
+ title: opts.title,
156
+ body: opts.body,
157
+ head: opts.head,
158
+ base: opts.base
159
+ }),
160
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
161
+ });
162
+ if (!res.ok) {
163
+ const text2 = await res.text();
164
+ throw new Error(`GitHub API error (${res.status}): ${text2}`);
165
+ }
166
+ const data = await res.json();
167
+ return { number: data.number, html_url: data.html_url };
168
+ }
169
+ async function createPullRequestWithGhCli(opts) {
170
+ const result = await execa("gh", [
171
+ "pr",
172
+ "create",
173
+ "--repo",
174
+ `${opts.owner}/${opts.repo}`,
175
+ "--head",
176
+ opts.head,
177
+ "--base",
178
+ opts.base,
179
+ "--title",
180
+ opts.title,
181
+ "--body",
182
+ opts.body
183
+ ]);
184
+ const url = result.stdout.trim();
185
+ const prNumberMatch = url.match(/\/pull\/(\d+)/);
186
+ const number = prNumberMatch ? Number.parseInt(prNumberMatch[1] ?? "0", 10) : 0;
187
+ return { number, html_url: url };
188
+ }
189
+ async function getRepoInfo(cwd) {
190
+ const { stdout: remoteUrl } = await execa("git", ["remote", "get-url", "origin"], { cwd });
191
+ let owner;
192
+ let repo;
193
+ const sshMatch = remoteUrl.match(/git@github\.com:(.+?)\/(.+?)(?:\.git)?$/);
194
+ const httpsMatch = remoteUrl.match(/github\.com\/(.+?)\/(.+?)(?:\.git)?$/);
195
+ if (sshMatch) {
196
+ owner = sshMatch[1] ?? "";
197
+ repo = sshMatch[2] ?? "";
198
+ } else if (httpsMatch) {
199
+ owner = httpsMatch[1] ?? "";
200
+ repo = httpsMatch[2] ?? "";
201
+ } else {
202
+ throw new Error(`Cannot parse GitHub owner/repo from remote URL: ${remoteUrl}`);
203
+ }
204
+ const { stdout: branch } = await execa("git", ["branch", "--show-current"], { cwd });
205
+ const { stdout: defaultBranch } = await execa(
206
+ "git",
207
+ ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"],
208
+ { cwd, reject: false }
209
+ ).then(
210
+ (r) => r,
211
+ () => ({ stdout: "origin/main" })
212
+ );
213
+ return {
214
+ owner,
215
+ repo,
216
+ branch: branch.trim(),
217
+ defaultBranch: defaultBranch.replace("origin/", "").trim()
218
+ };
219
+ }
220
+
100
221
  // src/logger.ts
101
222
  import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
102
223
  import { dirname } from "path";
@@ -131,26 +252,38 @@ function emitJson(level, message) {
131
252
  console.log(JSON.stringify(event));
132
253
  }
133
254
  function log(message) {
134
- if (outputMode === "json") return emitJson("info", message);
255
+ if (outputMode === "json") {
256
+ emitJson("info", message);
257
+ return;
258
+ }
135
259
  if (outputMode !== "quiet") {
136
260
  console.log(`${pc.cyan("[lisa]")} ${pc.dim(timestamp())} ${message}`);
137
261
  }
138
262
  writeToFile("info", message);
139
263
  }
140
264
  function warn(message) {
141
- if (outputMode === "json") return emitJson("warn", message);
265
+ if (outputMode === "json") {
266
+ emitJson("warn", message);
267
+ return;
268
+ }
142
269
  if (outputMode !== "quiet") {
143
270
  console.error(`${pc.yellow("[lisa]")} ${pc.dim(timestamp())} ${message}`);
144
271
  }
145
272
  writeToFile("warn", message);
146
273
  }
147
274
  function error(message) {
148
- if (outputMode === "json") return emitJson("error", message);
275
+ if (outputMode === "json") {
276
+ emitJson("error", message);
277
+ return;
278
+ }
149
279
  console.error(`${pc.red("[lisa]")} ${pc.dim(timestamp())} ${message}`);
150
280
  writeToFile("error", message);
151
281
  }
152
282
  function ok(message) {
153
- if (outputMode === "json") return emitJson("ok", message);
283
+ if (outputMode === "json") {
284
+ emitJson("ok", message);
285
+ return;
286
+ }
154
287
  if (outputMode !== "quiet") {
155
288
  console.log(`${pc.green("[lisa]")} ${pc.dim(timestamp())} ${message}`);
156
289
  }
@@ -188,7 +321,7 @@ function banner() {
188
321
  @%@+=#@==@#:+@##@
189
322
  @@@@%%@##%
190
323
  `;
191
- const title = " lisa \u2014 autonomous issue resolver ";
324
+ const title = " Lisa \u2014 deterministic autonomous issue resolver ";
192
325
  const border = "\u2500".repeat(title.length);
193
326
  console.log(pc.yellow(art));
194
327
  console.log(pc.cyan(` \u250C${border}\u2510`));
@@ -198,18 +331,232 @@ function banner() {
198
331
  }
199
332
 
200
333
  // src/loop.ts
201
- import { resolve as resolve4 } from "path";
202
- import { appendFileSync as appendFileSync6 } from "fs";
334
+ import { appendFileSync as appendFileSync6, readFileSync as readFileSync5, unlinkSync as unlinkSync4 } from "fs";
335
+ import { join as join7, resolve as resolve5 } from "path";
336
+ import { execa as execa3 } from "execa";
203
337
 
204
- // src/prompt.ts
338
+ // src/lifecycle.ts
339
+ import { spawn } from "child_process";
340
+ import { createConnection } from "net";
205
341
  import { resolve as resolve2 } from "path";
206
- function buildImplementPrompt(issue, config2) {
342
+ var managedResources = [];
343
+ var cleanupRegistered = false;
344
+ function isPortInUse(port) {
345
+ return new Promise((resolve6) => {
346
+ const socket = createConnection({ port }, () => {
347
+ socket.destroy();
348
+ resolve6(true);
349
+ });
350
+ socket.on("error", () => {
351
+ socket.destroy();
352
+ resolve6(false);
353
+ });
354
+ });
355
+ }
356
+ function waitForPort(port, timeoutMs) {
357
+ return new Promise((resolve6) => {
358
+ const deadline = Date.now() + timeoutMs;
359
+ const check = () => {
360
+ if (Date.now() > deadline) {
361
+ resolve6(false);
362
+ return;
363
+ }
364
+ isPortInUse(port).then((inUse) => {
365
+ if (inUse) {
366
+ resolve6(true);
367
+ } else {
368
+ setTimeout(check, 500);
369
+ }
370
+ });
371
+ };
372
+ check();
373
+ });
374
+ }
375
+ function spawnResource(config2, baseCwd) {
376
+ const cwd = config2.cwd ? resolve2(baseCwd, config2.cwd) : baseCwd;
377
+ const child = spawn("sh", ["-c", config2.up], {
378
+ cwd,
379
+ stdio: "ignore",
380
+ detached: true
381
+ });
382
+ child.unref();
383
+ return child;
384
+ }
385
+ function runSetupCommand(command, cwd) {
386
+ return new Promise((resolve6, reject) => {
387
+ const child = spawn("sh", ["-c", command], {
388
+ cwd,
389
+ stdio: "inherit"
390
+ });
391
+ child.on("close", (code) => {
392
+ if (code === 0) {
393
+ resolve6();
394
+ } else {
395
+ reject(new Error(`Setup command failed with exit code ${code}: ${command}`));
396
+ }
397
+ });
398
+ child.on("error", (err) => {
399
+ reject(new Error(`Setup command error: ${err.message}`));
400
+ });
401
+ });
402
+ }
403
+ async function startResources(repo, baseCwd) {
404
+ const lifecycle = repo.lifecycle;
405
+ if (!lifecycle) return true;
406
+ registerCleanup();
407
+ for (const resource of lifecycle.resources) {
408
+ const alreadyRunning = await isPortInUse(resource.check_port);
409
+ if (alreadyRunning) {
410
+ ok(`Resource "${resource.name}" already running on port ${resource.check_port}`);
411
+ continue;
412
+ }
413
+ log(`Starting resource "${resource.name}" on port ${resource.check_port}...`);
414
+ const child = spawnResource(resource, baseCwd);
415
+ managedResources.push({
416
+ name: resource.name,
417
+ config: resource,
418
+ process: child
419
+ });
420
+ const timeoutMs = (resource.startup_timeout || 30) * 1e3;
421
+ const ready = await waitForPort(resource.check_port, timeoutMs);
422
+ if (!ready) {
423
+ error(
424
+ `Resource "${resource.name}" failed to start within ${resource.startup_timeout}s`
425
+ );
426
+ await stopResources();
427
+ return false;
428
+ }
429
+ ok(`Resource "${resource.name}" is ready on port ${resource.check_port}`);
430
+ }
431
+ for (const command of lifecycle.setup) {
432
+ log(`Running setup: ${command}`);
433
+ try {
434
+ await runSetupCommand(command, baseCwd);
435
+ ok(`Setup complete: ${command}`);
436
+ } catch (err) {
437
+ error(`Setup failed: ${err instanceof Error ? err.message : String(err)}`);
438
+ await stopResources();
439
+ return false;
440
+ }
441
+ }
442
+ return true;
443
+ }
444
+ async function stopResources() {
445
+ for (const managed of managedResources) {
446
+ const { name, config: config2, process: child } = managed;
447
+ log(`Stopping resource "${name}"...`);
448
+ try {
449
+ if (config2.down === "auto") {
450
+ if (child?.pid) {
451
+ try {
452
+ process.kill(-child.pid, "SIGTERM");
453
+ } catch {
454
+ }
455
+ }
456
+ } else {
457
+ await new Promise((resolve6) => {
458
+ const down = spawn("sh", ["-c", config2.down], {
459
+ stdio: "ignore"
460
+ });
461
+ down.on("close", () => resolve6());
462
+ down.on("error", () => resolve6());
463
+ });
464
+ }
465
+ ok(`Resource "${name}" stopped`);
466
+ } catch (err) {
467
+ warn(
468
+ `Failed to stop resource "${name}": ${err instanceof Error ? err.message : String(err)}`
469
+ );
470
+ }
471
+ }
472
+ managedResources.length = 0;
473
+ }
474
+ function registerCleanup() {
475
+ if (cleanupRegistered) return;
476
+ cleanupRegistered = true;
477
+ const cleanup = () => {
478
+ for (const managed of managedResources) {
479
+ const { config: config2, process: child } = managed;
480
+ try {
481
+ if (config2.down === "auto") {
482
+ if (child?.pid) {
483
+ process.kill(-child.pid, "SIGTERM");
484
+ }
485
+ }
486
+ } catch {
487
+ }
488
+ }
489
+ };
490
+ process.on("exit", cleanup);
491
+ process.on("SIGINT", () => {
492
+ cleanup();
493
+ process.exit(130);
494
+ });
495
+ process.on("SIGTERM", () => {
496
+ cleanup();
497
+ process.exit(143);
498
+ });
499
+ }
500
+
501
+ // src/prompt.ts
502
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
503
+ import { join, resolve as resolve3 } from "path";
504
+ function detectTestRunner(cwd) {
505
+ const packageJsonPath = join(cwd, "package.json");
506
+ if (!existsSync3(packageJsonPath)) return null;
507
+ try {
508
+ const content = JSON.parse(readFileSync2(packageJsonPath, "utf-8"));
509
+ const deps = { ...content.dependencies, ...content.devDependencies };
510
+ if ("vitest" in deps) return "vitest";
511
+ if ("jest" in deps) return "jest";
512
+ return null;
513
+ } catch {
514
+ return null;
515
+ }
516
+ }
517
+ function buildImplementPrompt(issue, config2, testRunner) {
207
518
  if (config2.workflow === "worktree") {
208
- return buildWorktreePrompt(issue);
519
+ return buildWorktreePrompt(issue, testRunner);
209
520
  }
210
- return buildBranchPrompt(issue, config2);
521
+ return buildBranchPrompt(issue, config2, testRunner);
522
+ }
523
+ function buildTestInstructions(testRunner) {
524
+ if (!testRunner) return "";
525
+ return `
526
+ **MANDATORY \u2014 Unit Tests:**
527
+ This project uses **${testRunner}** as its test runner.
528
+ - You MUST write unit tests (\`*.test.ts\`) for every new file or module you create.
529
+ - Tests should cover the main functionality, edge cases, and error scenarios.
530
+ - Run \`npm run test\` and ensure ALL tests pass before committing.
531
+ - Do NOT skip writing tests \u2014 the PR will be blocked if tests are missing or failing.
532
+ `;
533
+ }
534
+ function buildReadmeInstructions() {
535
+ return `
536
+ **README.md Evaluation:**
537
+ After implementing, review the diff of all changed files and check if README.md needs updating.
538
+
539
+ Update README.md if the changes include:
540
+ - New or removed CLI commands or flags
541
+ - New or removed providers or sources
542
+ - Configuration schema changes (new fields, renamed fields, removed fields)
543
+ - Pipeline or workflow stage changes
544
+ - New or removed environment variables
545
+ - Architectural changes
546
+
547
+ Do NOT update README.md for:
548
+ - Internal refactors that don't change documented behavior
549
+ - Bug fixes that don't change documented behavior
550
+ - Test-only changes
551
+ - Logging or formatting changes
552
+ - Dependency updates
553
+
554
+ If an update is needed, keep the existing README style and structure. Include the README change in the same commit as the implementation.
555
+ `;
211
556
  }
212
- function buildWorktreePrompt(issue) {
557
+ function buildWorktreePrompt(issue, testRunner) {
558
+ const testBlock = buildTestInstructions(testRunner ?? null);
559
+ const readmeBlock = buildReadmeInstructions();
213
560
  return `You are an autonomous implementation agent. Your job is to implement a single
214
561
  issue, validate it, commit, and push the branch.
215
562
 
@@ -233,7 +580,7 @@ ${issue.description}
233
580
  - Follow the implementation instructions exactly
234
581
  - Verify each acceptance criteria (if present)
235
582
  - Respect any stack or technical constraints (if present)
236
-
583
+ ${testBlock}${readmeBlock}
237
584
  2. **Validate**: Run the project's linter/typecheck/tests if available:
238
585
  - Check \`package.json\` (or equivalent) for lint, typecheck, check, or test scripts.
239
586
  - Run whichever validation scripts exist (e.g., \`npm run lint\`, \`npm run typecheck\`).
@@ -245,9 +592,14 @@ ${issue.description}
245
592
  - All commit messages MUST be in English.
246
593
  - Use conventional commits format: \`feat: ...\`, \`fix: ...\`, \`refactor: ...\`, \`chore: ...\`
247
594
 
595
+ 4. **PR Metadata**: Before finishing, create a file named \`.pr-title\` at the repository root
596
+ containing a single line with the PR title in **English** using conventional commit format
597
+ (e.g., \`feat: add user authentication\`, \`fix: resolve null pointer in login flow\`).
598
+ This file is used by the caller to create the pull request. Do NOT commit this file.
599
+
248
600
  ## Rules
249
601
 
250
- - **ALL git commits MUST be in English.**
602
+ - **ALL git commits, PR titles, and PR descriptions MUST be in English.**
251
603
  - The issue description may be in any language \u2014 read it for context but write all code artifacts in English.
252
604
  - Do NOT install new dependencies unless the issue explicitly requires it.
253
605
  - If you get stuck or the issue is unclear, STOP and explain why.
@@ -256,10 +608,14 @@ ${issue.description}
256
608
  - Do NOT create pull requests \u2014 the caller handles that.
257
609
  - Do NOT update the issue tracker \u2014 the caller handles that.`;
258
610
  }
259
- function buildBranchPrompt(issue, config2) {
260
- const workspace = resolve2(config2.workspace);
261
- const repoEntries = config2.repos.map((r) => ` - If it says "Repo: ${r.name}" or title starts with "${r.match}" \u2192 \`${resolve2(workspace, r.path)}\` (base branch: \`${r.base_branch}\`)`).join("\n");
611
+ function buildBranchPrompt(issue, config2, testRunner) {
612
+ const workspace = resolve3(config2.workspace);
613
+ const repoEntries = config2.repos.map(
614
+ (r) => ` - If it says "Repo: ${r.name}" or title starts with "${r.match}" \u2192 \`${resolve3(workspace, r.path)}\` (base branch: \`${r.base_branch}\`)`
615
+ ).join("\n");
262
616
  const baseBranchInstruction = config2.repos.length > 0 ? "From the repo's base branch (listed above)" : `From \`${config2.base_branch}\``;
617
+ const testBlock = buildTestInstructions(testRunner ?? null);
618
+ const readmeBlock = buildReadmeInstructions();
263
619
  return `You are an autonomous implementation agent. Your job is to implement a single
264
620
  issue, validate it, commit, and push the branch.
265
621
 
@@ -287,7 +643,7 @@ ${repoEntries}
287
643
  - Follow the implementation instructions exactly
288
644
  - Verify each acceptance criteria (if present)
289
645
  - Respect any stack or technical constraints (if present)
290
-
646
+ ${testBlock}${readmeBlock}
291
647
  4. **Validate**: Run the project's linter/typecheck/tests if available:
292
648
  - Check \`package.json\` (or equivalent) for lint, typecheck, check, or test scripts.
293
649
  - Run whichever validation scripts exist (e.g., \`npm run lint\`, \`npm run typecheck\`).
@@ -299,9 +655,14 @@ ${repoEntries}
299
655
  - All commit messages MUST be in English.
300
656
  - Use conventional commits format: \`feat: ...\`, \`fix: ...\`, \`refactor: ...\`, \`chore: ...\`
301
657
 
658
+ 6. **PR Metadata**: Before finishing, create a file named \`.pr-title\` at the repository root
659
+ containing a single line with the PR title in **English** using conventional commit format
660
+ (e.g., \`feat: add user authentication\`, \`fix: resolve null pointer in login flow\`).
661
+ This file is used by the caller to create the pull request. Do NOT commit this file.
662
+
302
663
  ## Rules
303
664
 
304
- - **ALL git commits, branch names MUST be in English.**
665
+ - **ALL git commits, branch names, PR titles, and PR descriptions MUST be in English.**
305
666
  - The issue description may be in any language \u2014 read it for context but write all code artifacts in English.
306
667
  - Do NOT modify files outside the target repo.
307
668
  - Do NOT install new dependencies unless the issue explicitly requires it.
@@ -312,11 +673,175 @@ ${repoEntries}
312
673
  - Do NOT update the issue tracker \u2014 the caller handles that.`;
313
674
  }
314
675
 
676
+ // src/guardrails.ts
677
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
678
+ import { dirname as dirname2, join as join2 } from "path";
679
+ var GUARDRAILS_FILE = ".lisa/guardrails.md";
680
+ var MAX_ENTRIES = 20;
681
+ var CONTEXT_LINES = 20;
682
+ function guardrailsPath(dir) {
683
+ return join2(dir, GUARDRAILS_FILE);
684
+ }
685
+ function readGuardrails(dir) {
686
+ const path = guardrailsPath(dir);
687
+ if (!existsSync4(path)) return "";
688
+ try {
689
+ return readFileSync3(path, "utf-8");
690
+ } catch {
691
+ return "";
692
+ }
693
+ }
694
+ function buildGuardrailsSection(dir) {
695
+ const content = readGuardrails(dir);
696
+ if (!content.trim()) return "";
697
+ return `
698
+ ## Guardrails \u2014 Avoid these known pitfalls
699
+
700
+ ${content}
701
+ `;
702
+ }
703
+ function extractContext(output) {
704
+ const lines = output.trim().split("\n");
705
+ return lines.slice(-CONTEXT_LINES).join("\n");
706
+ }
707
+ function extractErrorType(output) {
708
+ if (/429|rate.?limit|quota/i.test(output)) return "Rate limit / quota exceeded";
709
+ if (/ETIMEDOUT|ECONNREFUSED|ECONNRESET|ENOTFOUND/.test(output)) return "Network error";
710
+ if (/timeout|timed?\s*out/i.test(output)) return "Timeout";
711
+ const exitMatch = output.match(/exit code[:\s]+(\d+)/i);
712
+ if (exitMatch) return `Exit code ${exitMatch[1]}`;
713
+ if (/exit(?:ed)? with/i.test(output)) return "Non-zero exit code";
714
+ return "Unknown error";
715
+ }
716
+ function appendEntry(dir, entry) {
717
+ const path = guardrailsPath(dir);
718
+ const guardrailsDir = dirname2(path);
719
+ if (!existsSync4(guardrailsDir)) {
720
+ mkdirSync3(guardrailsDir, { recursive: true });
721
+ }
722
+ const existing = existsSync4(path) ? readFileSync3(path, "utf-8") : "";
723
+ const newEntryText = formatEntry(entry);
724
+ let content;
725
+ if (!existing.trim()) {
726
+ content = `# Guardrails \u2014 Li\xE7\xF5es aprendidas
727
+
728
+ ${newEntryText}`;
729
+ } else {
730
+ const header = extractHeader(existing);
731
+ const entries = splitEntries(existing);
732
+ entries.push(newEntryText);
733
+ const rotated = entries.length > MAX_ENTRIES ? entries.slice(-MAX_ENTRIES) : entries;
734
+ content = `${header}
735
+
736
+ ${rotated.join("\n\n")}`;
737
+ }
738
+ writeFileSync3(path, content, "utf-8");
739
+ }
740
+ function formatEntry(entry) {
741
+ return [
742
+ `## Issue ${entry.issueId} (${entry.date})`,
743
+ `- Provider: ${entry.provider}`,
744
+ `- Erro: ${entry.errorType}`,
745
+ `- Contexto:`,
746
+ "```",
747
+ entry.context,
748
+ "```"
749
+ ].join("\n");
750
+ }
751
+ function extractHeader(content) {
752
+ const firstEntry = content.search(/^## /m);
753
+ if (firstEntry === -1) return content.trim();
754
+ return content.slice(0, firstEntry).trim();
755
+ }
756
+ function splitEntries(content) {
757
+ const positions = [];
758
+ const regex = /^## /gm;
759
+ for (const match of content.matchAll(regex)) {
760
+ positions.push(match.index);
761
+ }
762
+ return positions.map((start, i) => {
763
+ const end = positions[i + 1] ?? content.length;
764
+ return content.slice(start, end).trim();
765
+ });
766
+ }
767
+
315
768
  // src/providers/claude.ts
316
- import { spawn, execSync } from "child_process";
317
- import { appendFileSync as appendFileSync2, writeFileSync as writeFileSync3, unlinkSync, mkdtempSync } from "fs";
318
- import { join } from "path";
769
+ import { execSync, spawn as spawn2 } from "child_process";
770
+ import { appendFileSync as appendFileSync2, mkdtempSync, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
319
771
  import { tmpdir } from "os";
772
+ import { join as join3 } from "path";
773
+
774
+ // src/overseer.ts
775
+ import { execFile } from "child_process";
776
+ import { promisify } from "util";
777
+ var execFileAsync = promisify(execFile);
778
+ var STUCK_MESSAGE = "\n[lisa-overseer] Provider killed: no git changes detected within the stuck threshold. Eligible for fallback.\n";
779
+ async function getGitSnapshot(cwd) {
780
+ try {
781
+ const { stdout } = await execFileAsync("git", ["status", "--porcelain"], {
782
+ cwd,
783
+ timeout: 1e4
784
+ });
785
+ return stdout;
786
+ } catch {
787
+ return "";
788
+ }
789
+ }
790
+ function startOverseer(proc, cwd, config2, getSnapshot = getGitSnapshot) {
791
+ if (!config2.enabled) {
792
+ return {
793
+ stop() {
794
+ },
795
+ wasKilled() {
796
+ return false;
797
+ }
798
+ };
799
+ }
800
+ let killed = false;
801
+ let lastSnapshot;
802
+ let lastChangeTime = Date.now();
803
+ let timer = null;
804
+ const check = async () => {
805
+ if (killed) return;
806
+ try {
807
+ const snapshot = await getSnapshot(cwd);
808
+ if (lastSnapshot === void 0) {
809
+ lastSnapshot = snapshot;
810
+ lastChangeTime = Date.now();
811
+ return;
812
+ }
813
+ if (snapshot !== lastSnapshot) {
814
+ lastSnapshot = snapshot;
815
+ lastChangeTime = Date.now();
816
+ return;
817
+ }
818
+ const idleMs = Date.now() - lastChangeTime;
819
+ if (idleMs >= config2.stuck_threshold * 1e3) {
820
+ killed = true;
821
+ if (timer) {
822
+ clearInterval(timer);
823
+ timer = null;
824
+ }
825
+ proc.kill("SIGTERM");
826
+ }
827
+ } catch {
828
+ }
829
+ };
830
+ timer = setInterval(check, config2.check_interval * 1e3);
831
+ return {
832
+ stop() {
833
+ if (timer) {
834
+ clearInterval(timer);
835
+ timer = null;
836
+ }
837
+ },
838
+ wasKilled() {
839
+ return killed;
840
+ }
841
+ };
842
+ }
843
+
844
+ // src/providers/claude.ts
320
845
  var ClaudeProvider = class {
321
846
  name = "claude";
322
847
  async isAvailable() {
@@ -329,11 +854,11 @@ var ClaudeProvider = class {
329
854
  }
330
855
  async run(prompt, opts) {
331
856
  const start = Date.now();
332
- const tmpDir = mkdtempSync(join(tmpdir(), "lisa-"));
333
- const promptFile = join(tmpDir, "prompt.md");
334
- writeFileSync3(promptFile, prompt, "utf-8");
857
+ const tmpDir = mkdtempSync(join3(tmpdir(), "lisa-"));
858
+ const promptFile = join3(tmpDir, "prompt.md");
859
+ writeFileSync4(promptFile, prompt, "utf-8");
335
860
  try {
336
- const proc = spawn(
861
+ const proc = spawn2(
337
862
  "sh",
338
863
  ["-c", `claude -p --dangerously-skip-permissions "$(cat '${promptFile}')"`],
339
864
  {
@@ -342,6 +867,7 @@ var ClaudeProvider = class {
342
867
  env: { ...process.env, CLAUDECODE: void 0 }
343
868
  }
344
869
  );
870
+ const overseer = opts.overseer?.enabled ? startOverseer(proc, opts.cwd, opts.overseer) : null;
345
871
  const chunks = [];
346
872
  proc.stdout.on("data", (chunk) => {
347
873
  const text2 = chunk.toString();
@@ -360,11 +886,17 @@ var ClaudeProvider = class {
360
886
  } catch {
361
887
  }
362
888
  });
363
- const exitCode = await new Promise((resolve5) => {
364
- proc.on("close", (code) => resolve5(code ?? 1));
889
+ const exitCode = await new Promise((resolve6) => {
890
+ proc.on("close", (code) => {
891
+ overseer?.stop();
892
+ resolve6(code ?? 1);
893
+ });
365
894
  });
895
+ if (overseer?.wasKilled()) {
896
+ chunks.push(STUCK_MESSAGE);
897
+ }
366
898
  return {
367
- success: exitCode === 0,
899
+ success: exitCode === 0 && !overseer?.wasKilled(),
368
900
  output: chunks.join(""),
369
901
  duration: Date.now() - start
370
902
  };
@@ -384,10 +916,10 @@ var ClaudeProvider = class {
384
916
  };
385
917
 
386
918
  // src/providers/gemini.ts
387
- import { spawn as spawn2, execSync as execSync2 } from "child_process";
388
- import { appendFileSync as appendFileSync3, writeFileSync as writeFileSync4, unlinkSync as unlinkSync2, mkdtempSync as mkdtempSync2 } from "fs";
389
- import { join as join2 } from "path";
919
+ import { execSync as execSync2, spawn as spawn3 } from "child_process";
920
+ import { appendFileSync as appendFileSync3, mkdtempSync as mkdtempSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync5 } from "fs";
390
921
  import { tmpdir as tmpdir2 } from "os";
922
+ import { join as join4 } from "path";
391
923
  var GeminiProvider = class {
392
924
  name = "gemini";
393
925
  async isAvailable() {
@@ -400,18 +932,15 @@ var GeminiProvider = class {
400
932
  }
401
933
  async run(prompt, opts) {
402
934
  const start = Date.now();
403
- const tmpDir = mkdtempSync2(join2(tmpdir2(), "lisa-"));
404
- const promptFile = join2(tmpDir, "prompt.md");
405
- writeFileSync4(promptFile, prompt, "utf-8");
935
+ const tmpDir = mkdtempSync2(join4(tmpdir2(), "lisa-"));
936
+ const promptFile = join4(tmpDir, "prompt.md");
937
+ writeFileSync5(promptFile, prompt, "utf-8");
406
938
  try {
407
- const proc = spawn2(
408
- "sh",
409
- ["-c", `gemini --yolo -p "$(cat '${promptFile}')"`],
410
- {
411
- cwd: opts.cwd,
412
- stdio: ["ignore", "pipe", "pipe"]
413
- }
414
- );
939
+ const proc = spawn3("sh", ["-c", `gemini --yolo -p "$(cat '${promptFile}')"`], {
940
+ cwd: opts.cwd,
941
+ stdio: ["ignore", "pipe", "pipe"]
942
+ });
943
+ const overseer = opts.overseer?.enabled ? startOverseer(proc, opts.cwd, opts.overseer) : null;
415
944
  const chunks = [];
416
945
  proc.stdout.on("data", (chunk) => {
417
946
  const text2 = chunk.toString();
@@ -430,11 +959,17 @@ var GeminiProvider = class {
430
959
  } catch {
431
960
  }
432
961
  });
433
- const exitCode = await new Promise((resolve5) => {
434
- proc.on("close", (code) => resolve5(code ?? 1));
962
+ const exitCode = await new Promise((resolve6) => {
963
+ proc.on("close", (code) => {
964
+ overseer?.stop();
965
+ resolve6(code ?? 1);
966
+ });
435
967
  });
968
+ if (overseer?.wasKilled()) {
969
+ chunks.push(STUCK_MESSAGE);
970
+ }
436
971
  return {
437
- success: exitCode === 0,
972
+ success: exitCode === 0 && !overseer?.wasKilled(),
438
973
  output: chunks.join(""),
439
974
  duration: Date.now() - start
440
975
  };
@@ -454,10 +989,10 @@ var GeminiProvider = class {
454
989
  };
455
990
 
456
991
  // src/providers/opencode.ts
457
- import { spawn as spawn3, execSync as execSync3 } from "child_process";
458
- import { appendFileSync as appendFileSync4, writeFileSync as writeFileSync5, unlinkSync as unlinkSync3, mkdtempSync as mkdtempSync3 } from "fs";
459
- import { join as join3 } from "path";
992
+ import { execSync as execSync3, spawn as spawn4 } from "child_process";
993
+ import { appendFileSync as appendFileSync4, mkdtempSync as mkdtempSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync6 } from "fs";
460
994
  import { tmpdir as tmpdir3 } from "os";
995
+ import { join as join5 } from "path";
461
996
  var OpenCodeProvider = class {
462
997
  name = "opencode";
463
998
  async isAvailable() {
@@ -470,18 +1005,15 @@ var OpenCodeProvider = class {
470
1005
  }
471
1006
  async run(prompt, opts) {
472
1007
  const start = Date.now();
473
- const tmpDir = mkdtempSync3(join3(tmpdir3(), "lisa-"));
474
- const promptFile = join3(tmpDir, "prompt.md");
475
- writeFileSync5(promptFile, prompt, "utf-8");
1008
+ const tmpDir = mkdtempSync3(join5(tmpdir3(), "lisa-"));
1009
+ const promptFile = join5(tmpDir, "prompt.md");
1010
+ writeFileSync6(promptFile, prompt, "utf-8");
476
1011
  try {
477
- const proc = spawn3(
478
- "sh",
479
- ["-c", `opencode run "$(cat '${promptFile}')"`],
480
- {
481
- cwd: opts.cwd,
482
- stdio: ["ignore", "pipe", "pipe"]
483
- }
484
- );
1012
+ const proc = spawn4("sh", ["-c", `opencode run "$(cat '${promptFile}')"`], {
1013
+ cwd: opts.cwd,
1014
+ stdio: ["ignore", "pipe", "pipe"]
1015
+ });
1016
+ const overseer = opts.overseer?.enabled ? startOverseer(proc, opts.cwd, opts.overseer) : null;
485
1017
  const chunks = [];
486
1018
  proc.stdout.on("data", (chunk) => {
487
1019
  const text2 = chunk.toString();
@@ -500,11 +1032,17 @@ var OpenCodeProvider = class {
500
1032
  } catch {
501
1033
  }
502
1034
  });
503
- const exitCode = await new Promise((resolve5) => {
504
- proc.on("close", (code) => resolve5(code ?? 1));
1035
+ const exitCode = await new Promise((resolve6) => {
1036
+ proc.on("close", (code) => {
1037
+ overseer?.stop();
1038
+ resolve6(code ?? 1);
1039
+ });
505
1040
  });
1041
+ if (overseer?.wasKilled()) {
1042
+ chunks.push(STUCK_MESSAGE);
1043
+ }
506
1044
  return {
507
- success: exitCode === 0,
1045
+ success: exitCode === 0 && !overseer?.wasKilled(),
508
1046
  output: chunks.join(""),
509
1047
  duration: Date.now() - start
510
1048
  };
@@ -543,24 +1081,126 @@ function createProvider(name) {
543
1081
  }
544
1082
  return factory();
545
1083
  }
546
-
547
- // src/sources/linear.ts
548
- var API_URL = "https://api.linear.app/graphql";
549
- var REQUEST_TIMEOUT_MS = 3e4;
550
- function getApiKey() {
551
- const key = process.env.LINEAR_API_KEY;
552
- if (!key) throw new Error("LINEAR_API_KEY is not set");
553
- return key;
1084
+ var ELIGIBLE_ERROR_PATTERNS = [
1085
+ /429/i,
1086
+ /quota/i,
1087
+ /rate.?limit/i,
1088
+ /too many requests/i,
1089
+ /resource.?exhausted/i,
1090
+ /overloaded/i,
1091
+ /unavailable/i,
1092
+ /not.?found.*model/i,
1093
+ /model.*not.?found/i,
1094
+ /does not exist/i,
1095
+ /ETIMEDOUT/,
1096
+ /ECONNREFUSED/,
1097
+ /ECONNRESET/,
1098
+ /ENOTFOUND/,
1099
+ /timeout/i,
1100
+ /timed?\s*out/i,
1101
+ /network.?error/i,
1102
+ /not installed/i,
1103
+ /not in PATH/i,
1104
+ /command not found/i,
1105
+ /lisa-overseer/i
1106
+ ];
1107
+ function isEligibleForFallback(output) {
1108
+ return ELIGIBLE_ERROR_PATTERNS.some((pattern) => pattern.test(output));
554
1109
  }
555
- async function gql(query, variables) {
556
- const res = await fetch(API_URL, {
557
- method: "POST",
558
- headers: {
1110
+ async function runWithFallback(models, prompt, opts) {
1111
+ const attempts = [];
1112
+ for (const model of models) {
1113
+ const provider = createProvider(model);
1114
+ const available = await provider.isAvailable();
1115
+ if (!available) {
1116
+ attempts.push({
1117
+ provider: model,
1118
+ success: false,
1119
+ error: `Provider "${model}" is not installed or not in PATH`,
1120
+ duration: 0
1121
+ });
1122
+ continue;
1123
+ }
1124
+ const guardrailsSection = opts.guardrailsDir ? buildGuardrailsSection(opts.guardrailsDir) : "";
1125
+ const fullPrompt = guardrailsSection ? `${prompt}${guardrailsSection}` : prompt;
1126
+ const result = await provider.run(fullPrompt, opts);
1127
+ if (result.success) {
1128
+ attempts.push({
1129
+ provider: model,
1130
+ success: true,
1131
+ duration: result.duration
1132
+ });
1133
+ return {
1134
+ success: true,
1135
+ output: result.output,
1136
+ duration: result.duration,
1137
+ providerUsed: model,
1138
+ attempts
1139
+ };
1140
+ }
1141
+ if (opts.guardrailsDir && opts.issueId) {
1142
+ appendEntry(opts.guardrailsDir, {
1143
+ issueId: opts.issueId,
1144
+ date: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
1145
+ provider: model,
1146
+ errorType: extractErrorType(result.output),
1147
+ context: extractContext(result.output)
1148
+ });
1149
+ }
1150
+ const eligible = isEligibleForFallback(result.output);
1151
+ attempts.push({
1152
+ provider: model,
1153
+ success: false,
1154
+ error: eligible ? "Eligible error (quota/unavailable/timeout)" : "Non-eligible error",
1155
+ duration: result.duration
1156
+ });
1157
+ if (!eligible) {
1158
+ return {
1159
+ success: false,
1160
+ output: result.output,
1161
+ duration: result.duration,
1162
+ providerUsed: model,
1163
+ attempts
1164
+ };
1165
+ }
1166
+ }
1167
+ const totalDuration = attempts.reduce((sum, a) => sum + a.duration, 0);
1168
+ return {
1169
+ success: false,
1170
+ output: formatAttemptsReport(attempts),
1171
+ duration: totalDuration,
1172
+ providerUsed: attempts[attempts.length - 1]?.provider ?? models[0] ?? "claude",
1173
+ attempts
1174
+ };
1175
+ }
1176
+ function formatAttemptsReport(attempts) {
1177
+ const lines = ["All models exhausted. Attempt history:"];
1178
+ for (const [i, a] of attempts.entries()) {
1179
+ const status2 = a.success ? "OK" : "FAILED";
1180
+ const error2 = a.error ? ` \u2014 ${a.error}` : "";
1181
+ const duration = a.duration > 0 ? ` (${Math.round(a.duration / 1e3)}s)` : "";
1182
+ lines.push(` ${i + 1}. ${a.provider}: ${status2}${error2}${duration}`);
1183
+ }
1184
+ return lines.join("\n");
1185
+ }
1186
+
1187
+ // src/sources/linear.ts
1188
+ var API_URL2 = "https://api.linear.app/graphql";
1189
+ var REQUEST_TIMEOUT_MS2 = 3e4;
1190
+ function getApiKey() {
1191
+ const key = process.env.LINEAR_API_KEY;
1192
+ if (!key) throw new Error("LINEAR_API_KEY is not set");
1193
+ return key;
1194
+ }
1195
+ async function gql(query, variables) {
1196
+ const res = await fetch(API_URL2, {
1197
+ method: "POST",
1198
+ headers: {
559
1199
  "Content-Type": "application/json",
560
1200
  Authorization: getApiKey()
561
1201
  },
562
1202
  body: JSON.stringify({ query, variables }),
563
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1203
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2)
564
1204
  });
565
1205
  if (!res.ok) {
566
1206
  const text2 = await res.text();
@@ -584,7 +1224,7 @@ var LinearSource = class {
584
1224
  labels: { name: { eq: $labelName } }
585
1225
  state: { name: { eq: $statusName } }
586
1226
  }
587
- first: 20
1227
+ first: 50
588
1228
  ) {
589
1229
  nodes {
590
1230
  id
@@ -593,6 +1233,15 @@ var LinearSource = class {
593
1233
  description
594
1234
  url
595
1235
  priority
1236
+ inverseRelations(first: 50) {
1237
+ nodes {
1238
+ type
1239
+ issue {
1240
+ identifier
1241
+ state { type }
1242
+ }
1243
+ }
1244
+ }
596
1245
  }
597
1246
  }
598
1247
  }`,
@@ -605,12 +1254,32 @@ var LinearSource = class {
605
1254
  );
606
1255
  const issues = data.issues.nodes;
607
1256
  if (issues.length === 0) return null;
608
- issues.sort((a, b) => {
1257
+ const unblocked = [];
1258
+ const blocked = [];
1259
+ for (const issue2 of issues) {
1260
+ const activeBlockers = issue2.inverseRelations.nodes.filter((r) => r.type === "blocks").filter((r) => r.issue.state.type !== "completed" && r.issue.state.type !== "canceled").map((r) => r.issue.identifier);
1261
+ if (activeBlockers.length === 0) {
1262
+ unblocked.push(issue2);
1263
+ } else {
1264
+ blocked.push({ identifier: issue2.identifier, blockers: activeBlockers });
1265
+ }
1266
+ }
1267
+ if (unblocked.length === 0) {
1268
+ if (blocked.length > 0) {
1269
+ warn("No unblocked issues found. Blocked issues:");
1270
+ for (const entry of blocked) {
1271
+ warn(` ${entry.identifier} \u2014 blocked by: ${entry.blockers.join(", ")}`);
1272
+ }
1273
+ }
1274
+ return null;
1275
+ }
1276
+ unblocked.sort((a, b) => {
609
1277
  const pa = a.priority === 0 ? 5 : a.priority;
610
1278
  const pb = b.priority === 0 ? 5 : b.priority;
611
1279
  return pa - pb;
612
1280
  });
613
- const issue = issues[0];
1281
+ const issue = unblocked[0];
1282
+ if (!issue) return null;
614
1283
  return {
615
1284
  id: issue.identifier,
616
1285
  title: issue.title,
@@ -618,6 +1287,28 @@ var LinearSource = class {
618
1287
  url: issue.url
619
1288
  };
620
1289
  }
1290
+ async fetchIssueById(id) {
1291
+ const identifier = parseLinearIdentifier(id);
1292
+ const data = await gql(
1293
+ `query($identifier: String!) {
1294
+ issue(id: $identifier) {
1295
+ id
1296
+ identifier
1297
+ title
1298
+ description
1299
+ url
1300
+ }
1301
+ }`,
1302
+ { identifier }
1303
+ );
1304
+ if (!data.issue) return null;
1305
+ return {
1306
+ id: data.issue.identifier,
1307
+ title: data.issue.title,
1308
+ description: data.issue.description || "",
1309
+ url: data.issue.url
1310
+ };
1311
+ }
621
1312
  async updateStatus(issueId, statusName) {
622
1313
  const issueData = await gql(
623
1314
  `query($identifier: String!) {
@@ -643,7 +1334,7 @@ var LinearSource = class {
643
1334
  const available = statesData.workflowStates.nodes.map((s) => s.name).join(", ");
644
1335
  throw new Error(`Status "${statusName}" not found. Available: ${available}`);
645
1336
  }
646
- await gql(
1337
+ const mutationResult = await gql(
647
1338
  `mutation($issueId: String!, $stateId: String!) {
648
1339
  issueUpdate(id: $issueId, input: { stateId: $stateId }) {
649
1340
  success
@@ -651,6 +1342,11 @@ var LinearSource = class {
651
1342
  }`,
652
1343
  { issueId: issueData.issue.id, stateId: state.id }
653
1344
  );
1345
+ if (!mutationResult.issueUpdate.success) {
1346
+ throw new Error(
1347
+ `issueUpdate returned success=false for ${issueId} (stateId: ${state.id}, stateName: ${state.name})`
1348
+ );
1349
+ }
654
1350
  }
655
1351
  async attachPullRequest(_issueId, _prUrl) {
656
1352
  }
@@ -665,11 +1361,9 @@ var LinearSource = class {
665
1361
  { identifier: issueId }
666
1362
  );
667
1363
  const currentLabels = issueData.issue.labels.nodes;
668
- const filtered = currentLabels.filter(
669
- (l) => l.name.toLowerCase() !== labelName.toLowerCase()
670
- );
1364
+ const filtered = currentLabels.filter((l) => l.name.toLowerCase() !== labelName.toLowerCase());
671
1365
  if (filtered.length === currentLabels.length) return;
672
- await gql(
1366
+ const mutationResult = await gql(
673
1367
  `mutation($issueId: String!, $labelIds: [String!]!) {
674
1368
  issueUpdate(id: $issueId, input: { labelIds: $labelIds }) {
675
1369
  success
@@ -680,12 +1374,22 @@ var LinearSource = class {
680
1374
  labelIds: filtered.map((l) => l.id)
681
1375
  }
682
1376
  );
1377
+ if (!mutationResult.issueUpdate.success) {
1378
+ throw new Error(
1379
+ `issueUpdate returned success=false when removing label "${labelName}" from ${issueId}`
1380
+ );
1381
+ }
683
1382
  }
684
1383
  };
1384
+ function parseLinearIdentifier(input) {
1385
+ const urlMatch = input.match(/\/issue\/([A-Z]+-\d+)/);
1386
+ if (urlMatch?.[1]) return urlMatch[1];
1387
+ return input;
1388
+ }
685
1389
 
686
1390
  // src/sources/trello.ts
687
- var API_URL2 = "https://api.trello.com/1";
688
- var REQUEST_TIMEOUT_MS2 = 3e4;
1391
+ var API_URL3 = "https://api.trello.com/1";
1392
+ var REQUEST_TIMEOUT_MS3 = 3e4;
689
1393
  function getAuthHeaders() {
690
1394
  const key = process.env.TRELLO_API_KEY;
691
1395
  const token = process.env.TRELLO_TOKEN;
@@ -696,11 +1400,11 @@ function getAuthHeaders() {
696
1400
  }
697
1401
  async function trelloFetch(method, path, params = "") {
698
1402
  const sep = params ? "?" : "";
699
- const url = `${API_URL2}${path}${sep}${params}`;
1403
+ const url = `${API_URL3}${path}${sep}${params}`;
700
1404
  const res = await fetch(url, {
701
1405
  method,
702
1406
  headers: getAuthHeaders(),
703
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2)
1407
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS3)
704
1408
  });
705
1409
  if (!res.ok) {
706
1410
  const text2 = await res.text();
@@ -755,6 +1459,7 @@ var TrelloSource = class {
755
1459
  const matching = cards.filter((c) => c.idLabels.includes(label.id));
756
1460
  if (matching.length === 0) return null;
757
1461
  const card = matching[0];
1462
+ if (!card) return null;
758
1463
  return {
759
1464
  id: card.id,
760
1465
  title: card.name,
@@ -762,6 +1467,23 @@ var TrelloSource = class {
762
1467
  url: card.url
763
1468
  };
764
1469
  }
1470
+ async fetchIssueById(id) {
1471
+ const shortLink = parseTrelloIdentifier(id);
1472
+ try {
1473
+ const card = await trelloGet(
1474
+ `/cards/${shortLink}`,
1475
+ "fields=name,desc,url,idLabels,idList"
1476
+ );
1477
+ return {
1478
+ id: card.id,
1479
+ title: card.name,
1480
+ description: card.desc || "",
1481
+ url: card.url
1482
+ };
1483
+ } catch {
1484
+ return null;
1485
+ }
1486
+ }
765
1487
  async updateStatus(cardId, listName) {
766
1488
  const card = await trelloGet(`/cards/${cardId}`, "fields=idBoard");
767
1489
  const list = await findListByName(card.idBoard, listName);
@@ -780,6 +1502,11 @@ var TrelloSource = class {
780
1502
  await trelloDelete(`/cards/${cardId}/idLabels/${label.id}`);
781
1503
  }
782
1504
  };
1505
+ function parseTrelloIdentifier(input) {
1506
+ const urlMatch = input.match(/\/c\/([a-zA-Z0-9]+)/);
1507
+ if (urlMatch?.[1]) return urlMatch[1];
1508
+ return input;
1509
+ }
783
1510
 
784
1511
  // src/sources/index.ts
785
1512
  var sources = {
@@ -794,118 +1521,42 @@ function createSource(name) {
794
1521
  return factory();
795
1522
  }
796
1523
 
797
- // src/github.ts
798
- import { execa } from "execa";
799
- var API_URL3 = "https://api.github.com";
800
- var REQUEST_TIMEOUT_MS3 = 3e4;
801
- async function isGhCliAvailable() {
802
- try {
803
- await execa("gh", ["auth", "status"]);
804
- return true;
805
- } catch {
806
- return false;
807
- }
808
- }
809
- function getToken() {
810
- const token = process.env.GITHUB_TOKEN;
811
- if (!token) throw new Error("GITHUB_TOKEN is not set");
812
- return token;
813
- }
814
- async function createPullRequest(opts, method = "cli") {
815
- if (method === "cli" && await isGhCliAvailable()) {
816
- return createPullRequestWithGhCli(opts);
817
- }
818
- const res = await fetch(`${API_URL3}/repos/${opts.owner}/${opts.repo}/pulls`, {
819
- method: "POST",
820
- headers: {
821
- Authorization: `Bearer ${getToken()}`,
822
- Accept: "application/vnd.github+json",
823
- "Content-Type": "application/json"
824
- },
825
- body: JSON.stringify({
826
- title: opts.title,
827
- body: opts.body,
828
- head: opts.head,
829
- base: opts.base
830
- }),
831
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS3)
832
- });
833
- if (!res.ok) {
834
- const text2 = await res.text();
835
- throw new Error(`GitHub API error (${res.status}): ${text2}`);
836
- }
837
- const data = await res.json();
838
- return { number: data.number, html_url: data.html_url };
839
- }
840
- async function createPullRequestWithGhCli(opts) {
841
- const result = await execa("gh", [
842
- "pr",
843
- "create",
844
- "--repo",
845
- `${opts.owner}/${opts.repo}`,
846
- "--head",
847
- opts.head,
848
- "--base",
849
- opts.base,
850
- "--title",
851
- opts.title,
852
- "--body",
853
- opts.body
854
- ]);
855
- const url = result.stdout.trim();
856
- const prNumberMatch = url.match(/\/pull\/(\d+)/);
857
- const number = prNumberMatch ? Number.parseInt(prNumberMatch[1], 10) : 0;
858
- return { number, html_url: url };
859
- }
860
- async function getRepoInfo(cwd) {
861
- const { stdout: remoteUrl } = await execa("git", ["remote", "get-url", "origin"], { cwd });
862
- let owner;
863
- let repo;
864
- const sshMatch = remoteUrl.match(/git@github\.com:(.+?)\/(.+?)(?:\.git)?$/);
865
- const httpsMatch = remoteUrl.match(/github\.com\/(.+?)\/(.+?)(?:\.git)?$/);
866
- if (sshMatch) {
867
- owner = sshMatch[1];
868
- repo = sshMatch[2];
869
- } else if (httpsMatch) {
870
- owner = httpsMatch[1];
871
- repo = httpsMatch[2];
872
- } else {
873
- throw new Error(`Cannot parse GitHub owner/repo from remote URL: ${remoteUrl}`);
874
- }
875
- const { stdout: branch } = await execa("git", ["branch", "--show-current"], { cwd });
876
- const { stdout: defaultBranch } = await execa(
877
- "git",
878
- ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"],
879
- { cwd, reject: false }
880
- ).then(
881
- (r) => r,
882
- () => ({ stdout: "origin/main" })
883
- );
884
- return {
885
- owner,
886
- repo,
887
- branch: branch.trim(),
888
- defaultBranch: defaultBranch.replace("origin/", "").trim()
889
- };
890
- }
891
-
892
1524
  // src/worktree.ts
893
- import { existsSync as existsSync3, readFileSync as readFileSync2, appendFileSync as appendFileSync5 } from "fs";
894
- import { join as join4, resolve as resolve3 } from "path";
1525
+ import { appendFileSync as appendFileSync5, existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
1526
+ import { join as join6, resolve as resolve4 } from "path";
895
1527
  import { execa as execa2 } from "execa";
896
1528
  var WORKTREES_DIR = ".worktrees";
897
1529
  function generateBranchName(issueId, title) {
898
1530
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").substring(0, 40);
899
1531
  return `feat/${issueId.toLowerCase()}-${slug}`;
900
1532
  }
1533
+ async function cleanupOrphanedWorktree(repoRoot, branchName) {
1534
+ const { stdout: branchList } = await execa2("git", ["branch", "--list", branchName], {
1535
+ cwd: repoRoot,
1536
+ reject: false
1537
+ });
1538
+ if (!branchList.trim()) {
1539
+ return false;
1540
+ }
1541
+ const worktreePath = join6(repoRoot, WORKTREES_DIR, branchName);
1542
+ const { stdout: worktreeList } = await execa2("git", ["worktree", "list", "--porcelain"], {
1543
+ cwd: repoRoot,
1544
+ reject: false
1545
+ });
1546
+ if (worktreeList.includes(worktreePath)) {
1547
+ await execa2("git", ["worktree", "remove", worktreePath, "--force"], { cwd: repoRoot });
1548
+ await execa2("git", ["worktree", "prune"], { cwd: repoRoot });
1549
+ }
1550
+ await execa2("git", ["branch", "-D", branchName], { cwd: repoRoot });
1551
+ return true;
1552
+ }
901
1553
  async function createWorktree(repoRoot, branchName, baseBranch) {
902
- const worktreePath = join4(repoRoot, WORKTREES_DIR, branchName);
1554
+ const worktreePath = join6(repoRoot, WORKTREES_DIR, branchName);
1555
+ await cleanupOrphanedWorktree(repoRoot, branchName);
903
1556
  await execa2("git", ["fetch", "origin", baseBranch], { cwd: repoRoot });
904
- await execa2(
905
- "git",
906
- ["worktree", "add", "-b", branchName, worktreePath, `origin/${baseBranch}`],
907
- { cwd: repoRoot }
908
- );
1557
+ await execa2("git", ["worktree", "add", "-b", branchName, worktreePath, `origin/${baseBranch}`], {
1558
+ cwd: repoRoot
1559
+ });
909
1560
  return worktreePath;
910
1561
  }
911
1562
  async function removeWorktree(repoRoot, worktreePath) {
@@ -915,13 +1566,13 @@ async function removeWorktree(repoRoot, worktreePath) {
915
1566
  await execa2("git", ["worktree", "prune"], { cwd: repoRoot });
916
1567
  }
917
1568
  function ensureWorktreeGitignore(repoRoot) {
918
- const gitignorePath = join4(repoRoot, ".gitignore");
919
- if (!existsSync3(gitignorePath)) {
1569
+ const gitignorePath = join6(repoRoot, ".gitignore");
1570
+ if (!existsSync5(gitignorePath)) {
920
1571
  appendFileSync5(gitignorePath, `${WORKTREES_DIR}
921
1572
  `);
922
1573
  return;
923
1574
  }
924
- const content = readFileSync2(gitignorePath, "utf-8");
1575
+ const content = readFileSync4(gitignorePath, "utf-8");
925
1576
  if (!content.split("\n").some((line) => line.trim() === WORKTREES_DIR)) {
926
1577
  const separator = content.endsWith("\n") ? "" : "\n";
927
1578
  appendFileSync5(gitignorePath, `${separator}${WORKTREES_DIR}
@@ -930,27 +1581,23 @@ function ensureWorktreeGitignore(repoRoot) {
930
1581
  }
931
1582
  async function findBranchByIssueId(repoRoot, issueId) {
932
1583
  const needle = issueId.toLowerCase();
933
- const { stdout: local } = await execa2("git", [
934
- "for-each-ref",
935
- "--sort=-committerdate",
936
- "--format=%(refname:short)",
937
- "refs/heads/"
938
- ], { cwd: repoRoot });
1584
+ const { stdout: local } = await execa2(
1585
+ "git",
1586
+ ["for-each-ref", "--sort=-committerdate", "--format=%(refname:short)", "refs/heads/"],
1587
+ { cwd: repoRoot }
1588
+ );
939
1589
  const localMatch = local.split("\n").map((b) => b.trim()).filter(Boolean).find((b) => b.toLowerCase().includes(needle));
940
1590
  if (localMatch) return localMatch;
941
- const { stdout: remote } = await execa2("git", [
942
- "for-each-ref",
943
- "--sort=-committerdate",
944
- "--format=%(refname:short)",
945
- "refs/remotes/origin/"
946
- ], { cwd: repoRoot });
1591
+ const { stdout: remote } = await execa2(
1592
+ "git",
1593
+ ["for-each-ref", "--sort=-committerdate", "--format=%(refname:short)", "refs/remotes/origin/"],
1594
+ { cwd: repoRoot }
1595
+ );
947
1596
  const remoteMatch = remote.split("\n").map((b) => b.trim()).filter(Boolean).find((b) => b.toLowerCase().includes(needle));
948
1597
  if (remoteMatch) return remoteMatch.replace("origin/", "");
949
- const { stdout: lsRemote } = await execa2("git", [
950
- "ls-remote",
951
- "--heads",
952
- "origin"
953
- ], { cwd: repoRoot });
1598
+ const { stdout: lsRemote } = await execa2("git", ["ls-remote", "--heads", "origin"], {
1599
+ cwd: repoRoot
1600
+ });
954
1601
  const lsMatch = lsRemote.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => l.split(" ")[1]?.replace("refs/heads/", "") ?? "").find((b) => b.toLowerCase().includes(needle));
955
1602
  if (lsMatch) return lsMatch;
956
1603
  return void 0;
@@ -959,17 +1606,18 @@ function determineRepoPath(repos, issue, workspace) {
959
1606
  if (repos.length === 0) return void 0;
960
1607
  if (issue.repo) {
961
1608
  const match = repos.find((r) => r.name === issue.repo);
962
- if (match) return join4(workspace, match.path);
1609
+ if (match) return join6(workspace, match.path);
963
1610
  }
964
1611
  for (const r of repos) {
965
1612
  if (r.match && issue.title.startsWith(r.match)) {
966
- return join4(workspace, r.path);
1613
+ return join6(workspace, r.path);
967
1614
  }
968
1615
  }
969
- return join4(workspace, repos[0].path);
1616
+ const first = repos[0];
1617
+ return first ? join6(workspace, first.path) : void 0;
970
1618
  }
971
1619
  async function detectFeatureBranches(repos, issueId, workspace, globalBaseBranch) {
972
- const entries = repos.length > 0 ? repos.map((r) => ({ path: resolve3(workspace, r.path), baseBranch: r.base_branch })) : [{ path: workspace, baseBranch: globalBaseBranch }];
1620
+ const entries = repos.length > 0 ? repos.map((r) => ({ path: resolve4(workspace, r.path), baseBranch: r.base_branch })) : [{ path: workspace, baseBranch: globalBaseBranch }];
973
1621
  const needle = issueId.toLowerCase();
974
1622
  const results = [];
975
1623
  const matched = /* @__PURE__ */ new Set();
@@ -979,7 +1627,7 @@ async function detectFeatureBranches(repos, issueId, workspace, globalBaseBranch
979
1627
  const { stdout } = await execa2("git", ["branch", "--show-current"], { cwd: entry.path });
980
1628
  const current = stdout.trim();
981
1629
  currentBranches.push({ ...entry, current });
982
- if (current && current.toLowerCase().includes(needle)) {
1630
+ if (current?.toLowerCase().includes(needle)) {
983
1631
  results.push({ repoPath: entry.path, branch: current });
984
1632
  matched.add(entry.path);
985
1633
  }
@@ -1003,17 +1651,105 @@ async function detectFeatureBranches(repos, issueId, workspace, globalBaseBranch
1003
1651
  }
1004
1652
 
1005
1653
  // src/loop.ts
1006
- async function runLoop(config2, opts) {
1007
- const provider = createProvider(config2.provider);
1008
- const source = createSource(config2.source);
1009
- const available = await provider.isAvailable();
1010
- if (!available) {
1011
- error(`Provider "${config2.provider}" is not installed or not in PATH.`);
1654
+ var activeCleanup = null;
1655
+ var shuttingDown = false;
1656
+ function resolveModels(config2) {
1657
+ if (config2.models && config2.models.length > 0) return config2.models;
1658
+ return [config2.provider];
1659
+ }
1660
+ function buildPrBody(issue, providerUsed) {
1661
+ return `Closes ${issue.url}
1662
+
1663
+ Implemented by [lisa](https://github.com/tarcisiopgs/lisa) using **${providerUsed}**.`;
1664
+ }
1665
+ var PR_TITLE_FILE = ".pr-title";
1666
+ function readPrTitle(cwd) {
1667
+ try {
1668
+ const title = readFileSync5(join7(cwd, PR_TITLE_FILE), "utf-8").trim().split("\n")[0]?.trim();
1669
+ return title || null;
1670
+ } catch {
1671
+ return null;
1672
+ }
1673
+ }
1674
+ function cleanupPrTitle(cwd) {
1675
+ try {
1676
+ unlinkSync4(join7(cwd, PR_TITLE_FILE));
1677
+ } catch {
1678
+ }
1679
+ }
1680
+ function installSignalHandlers() {
1681
+ const cleanup = async (signal) => {
1682
+ if (shuttingDown) {
1683
+ warn("Force exiting...");
1684
+ process.exit(1);
1685
+ }
1686
+ shuttingDown = true;
1687
+ warn(`Received ${signal}. Reverting active issue...`);
1688
+ if (activeCleanup) {
1689
+ const { issueId, previousStatus, source } = activeCleanup;
1690
+ try {
1691
+ await Promise.race([
1692
+ source.updateStatus(issueId, previousStatus),
1693
+ new Promise(
1694
+ (_, reject) => setTimeout(() => reject(new Error("Revert timed out")), 5e3)
1695
+ )
1696
+ ]);
1697
+ ok(`Reverted ${issueId} to "${previousStatus}"`);
1698
+ } catch (err) {
1699
+ error(
1700
+ `Failed to revert ${issueId}: ${err instanceof Error ? err.message : String(err)}`
1701
+ );
1702
+ }
1703
+ }
1012
1704
  process.exit(1);
1705
+ };
1706
+ process.on("SIGINT", () => {
1707
+ cleanup("SIGINT");
1708
+ });
1709
+ process.on("SIGTERM", () => {
1710
+ cleanup("SIGTERM");
1711
+ });
1712
+ }
1713
+ async function recoverOrphanIssues(source, config2) {
1714
+ const orphanConfig = {
1715
+ ...config2.source_config,
1716
+ pick_from: config2.source_config.in_progress
1717
+ };
1718
+ while (true) {
1719
+ let orphan;
1720
+ try {
1721
+ orphan = await source.fetchNextIssue(orphanConfig);
1722
+ } catch (err) {
1723
+ warn(
1724
+ `Failed to check for orphan issues: ${err instanceof Error ? err.message : String(err)}`
1725
+ );
1726
+ break;
1727
+ }
1728
+ if (!orphan) break;
1729
+ warn(
1730
+ `Found orphan issue ${orphan.id} stuck in "${config2.source_config.in_progress}". Reverting to "${config2.source_config.pick_from}".`
1731
+ );
1732
+ try {
1733
+ await source.updateStatus(orphan.id, config2.source_config.pick_from);
1734
+ ok(`Recovered orphan ${orphan.id}`);
1735
+ } catch (err) {
1736
+ error(
1737
+ `Failed to recover orphan ${orphan.id}: ${err instanceof Error ? err.message : String(err)}`
1738
+ );
1739
+ break;
1740
+ }
1013
1741
  }
1742
+ }
1743
+ async function runLoop(config2, opts) {
1744
+ const source = createSource(config2.source);
1745
+ const models = resolveModels(config2);
1746
+ installSignalHandlers();
1014
1747
  log(
1015
- `Starting loop (provider: ${config2.provider}, source: ${config2.source}, label: ${config2.source_config.label}, workflow: ${config2.workflow})`
1748
+ `Starting loop (models: ${models.join(" \u2192 ")}, source: ${config2.source}, label: ${config2.source_config.label}, workflow: ${config2.workflow})`
1016
1749
  );
1750
+ if (!opts.dryRun) {
1751
+ await recoverOrphanIssues(source, config2);
1752
+ }
1017
1753
  let session = 0;
1018
1754
  while (true) {
1019
1755
  session++;
@@ -1022,18 +1758,29 @@ async function runLoop(config2, opts) {
1022
1758
  break;
1023
1759
  }
1024
1760
  const timestamp2 = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").substring(0, 19);
1025
- const logFile = resolve4(config2.logs.dir, `session_${session}_${timestamp2}.log`);
1761
+ const logFile = resolve5(config2.logs.dir, `session_${session}_${timestamp2}.log`);
1026
1762
  divider(session);
1027
- log(`Fetching next '${config2.source_config.label}' issue from ${config2.source}...`);
1763
+ if (opts.issueId) {
1764
+ log(`Fetching issue '${opts.issueId}' from ${config2.source}...`);
1765
+ } else {
1766
+ log(`Fetching next '${config2.source_config.label}' issue from ${config2.source}...`);
1767
+ }
1028
1768
  if (opts.dryRun) {
1029
- log(`[dry-run] Would fetch issue from ${config2.source} (${config2.source_config.team}/${config2.source_config.project})`);
1769
+ if (opts.issueId) {
1770
+ log(`[dry-run] Would fetch issue '${opts.issueId}' from ${config2.source}`);
1771
+ } else {
1772
+ log(
1773
+ `[dry-run] Would fetch issue from ${config2.source} (${config2.source_config.team}/${config2.source_config.project})`
1774
+ );
1775
+ }
1030
1776
  log(`[dry-run] Workflow mode: ${config2.workflow}`);
1777
+ log(`[dry-run] Models priority: ${models.join(" \u2192 ")}`);
1031
1778
  log("[dry-run] Then implement, push, create PR, and update issue status");
1032
1779
  break;
1033
1780
  }
1034
1781
  let issue;
1035
1782
  try {
1036
- issue = await source.fetchNextIssue(config2.source_config);
1783
+ issue = opts.issueId ? await source.fetchIssueById(opts.issueId) : await source.fetchNextIssue(config2.source_config);
1037
1784
  } catch (err) {
1038
1785
  error(`Failed to fetch issues: ${err instanceof Error ? err.message : String(err)}`);
1039
1786
  if (opts.once) break;
@@ -1041,10 +1788,15 @@ async function runLoop(config2, opts) {
1041
1788
  continue;
1042
1789
  }
1043
1790
  if (!issue) {
1044
- ok(`No more issues with label '${config2.source_config.label}'. Done.`);
1791
+ if (opts.issueId) {
1792
+ error(`Issue '${opts.issueId}' not found.`);
1793
+ } else {
1794
+ ok(`No more issues with label '${config2.source_config.label}'. Done.`);
1795
+ }
1045
1796
  break;
1046
1797
  }
1047
1798
  ok(`Picked up: ${issue.id} \u2014 ${issue.title}`);
1799
+ const previousStatus = config2.source_config.pick_from;
1048
1800
  try {
1049
1801
  const inProgress = config2.source_config.in_progress;
1050
1802
  await source.updateStatus(issue.id, inProgress);
@@ -1052,8 +1804,71 @@ async function runLoop(config2, opts) {
1052
1804
  } catch (err) {
1053
1805
  warn(`Failed to update status: ${err instanceof Error ? err.message : String(err)}`);
1054
1806
  }
1055
- const prUrls = config2.workflow === "worktree" ? await runWorktreeSession(config2, issue, logFile, session) : await runBranchSession(config2, issue, logFile, session);
1056
- for (const prUrl of prUrls) {
1807
+ activeCleanup = { issueId: issue.id, previousStatus, source };
1808
+ let sessionResult;
1809
+ try {
1810
+ sessionResult = config2.workflow === "worktree" ? await runWorktreeSession(config2, issue, logFile, session, models) : await runBranchSession(config2, issue, logFile, session, models);
1811
+ } catch (err) {
1812
+ error(
1813
+ `Unhandled error in session for ${issue.id}: ${err instanceof Error ? err.message : String(err)}`
1814
+ );
1815
+ try {
1816
+ await source.updateStatus(issue.id, previousStatus);
1817
+ ok(`Reverted ${issue.id} to "${previousStatus}"`);
1818
+ } catch (revertErr) {
1819
+ error(
1820
+ `Failed to revert status: ${revertErr instanceof Error ? revertErr.message : String(revertErr)}`
1821
+ );
1822
+ }
1823
+ activeCleanup = null;
1824
+ if (opts.once) break;
1825
+ log(`Cooling down ${config2.loop.cooldown}s before next issue...`);
1826
+ await sleep(config2.loop.cooldown * 1e3);
1827
+ continue;
1828
+ }
1829
+ if (!sessionResult.success) {
1830
+ error(`All models failed for ${issue.id}. Reverting to "${previousStatus}".`);
1831
+ logAttemptHistory(sessionResult);
1832
+ try {
1833
+ await source.updateStatus(issue.id, previousStatus);
1834
+ ok(`Reverted ${issue.id} to "${previousStatus}"`);
1835
+ } catch (err) {
1836
+ error(
1837
+ `Failed to revert status: ${err instanceof Error ? err.message : String(err)}`
1838
+ );
1839
+ }
1840
+ activeCleanup = null;
1841
+ if (opts.once) {
1842
+ log("Single iteration mode. Exiting.");
1843
+ break;
1844
+ }
1845
+ log(`Cooling down ${config2.loop.cooldown}s before next issue...`);
1846
+ await sleep(config2.loop.cooldown * 1e3);
1847
+ continue;
1848
+ }
1849
+ ok(`Completed with provider: ${sessionResult.providerUsed}`);
1850
+ if (sessionResult.prUrls.length === 0) {
1851
+ warn(
1852
+ `Session succeeded but no PRs created for ${issue.id}. Reverting to "${previousStatus}".`
1853
+ );
1854
+ try {
1855
+ await source.updateStatus(issue.id, previousStatus);
1856
+ ok(`Reverted ${issue.id} to "${previousStatus}"`);
1857
+ } catch (err) {
1858
+ error(
1859
+ `Failed to revert status: ${err instanceof Error ? err.message : String(err)}`
1860
+ );
1861
+ }
1862
+ activeCleanup = null;
1863
+ if (opts.once) {
1864
+ log("Single iteration mode. Exiting.");
1865
+ break;
1866
+ }
1867
+ log(`Cooling down ${config2.loop.cooldown}s before next issue...`);
1868
+ await sleep(config2.loop.cooldown * 1e3);
1869
+ continue;
1870
+ }
1871
+ for (const prUrl of sessionResult.prUrls) {
1057
1872
  try {
1058
1873
  await source.attachPullRequest(issue.id, prUrl);
1059
1874
  ok(`Attached PR to ${issue.id}`);
@@ -1061,19 +1876,24 @@ async function runLoop(config2, opts) {
1061
1876
  warn(`Failed to attach PR: ${err instanceof Error ? err.message : String(err)}`);
1062
1877
  }
1063
1878
  }
1879
+ let statusUpdated = false;
1064
1880
  try {
1065
1881
  const doneStatus = config2.source_config.done;
1066
1882
  await source.updateStatus(issue.id, doneStatus);
1067
1883
  ok(`Updated ${issue.id} status to "${doneStatus}"`);
1884
+ statusUpdated = true;
1068
1885
  } catch (err) {
1069
1886
  error(`Failed to update status: ${err instanceof Error ? err.message : String(err)}`);
1070
1887
  }
1071
- try {
1072
- await source.removeLabel(issue.id, config2.source_config.label);
1073
- ok(`Removed label "${config2.source_config.label}" from ${issue.id}`);
1074
- } catch (err) {
1075
- error(`Failed to remove label: ${err instanceof Error ? err.message : String(err)}`);
1888
+ if (statusUpdated && !opts.issueId) {
1889
+ try {
1890
+ await source.removeLabel(issue.id, config2.source_config.label);
1891
+ ok(`Removed label "${config2.source_config.label}" from ${issue.id}`);
1892
+ } catch (err) {
1893
+ error(`Failed to remove label: ${err instanceof Error ? err.message : String(err)}`);
1894
+ }
1076
1895
  }
1896
+ activeCleanup = null;
1077
1897
  if (opts.once) {
1078
1898
  log("Single iteration mode. Exiting.");
1079
1899
  break;
@@ -1083,14 +1903,46 @@ async function runLoop(config2, opts) {
1083
1903
  }
1084
1904
  ok(`lisa finished. ${session} session(s) run.`);
1085
1905
  }
1906
+ function logAttemptHistory(result) {
1907
+ for (const [i, attempt] of result.fallback.attempts.entries()) {
1908
+ const status2 = attempt.success ? "OK" : "FAILED";
1909
+ const error2 = attempt.error ? ` \u2014 ${attempt.error}` : "";
1910
+ const duration = attempt.duration > 0 ? ` (${Math.round(attempt.duration / 1e3)}s)` : "";
1911
+ warn(` Attempt ${i + 1}: ${attempt.provider} ${status2}${error2}${duration}`);
1912
+ }
1913
+ }
1086
1914
  function resolveBaseBranch(config2, repoPath) {
1087
- const workspace = resolve4(config2.workspace);
1088
- const repo = config2.repos.find((r) => resolve4(workspace, r.path) === repoPath);
1915
+ const workspace = resolve5(config2.workspace);
1916
+ const repo = config2.repos.find((r) => resolve5(workspace, r.path) === repoPath);
1089
1917
  return repo?.base_branch ?? config2.base_branch;
1090
1918
  }
1091
- async function runWorktreeSession(config2, issue, logFile, session) {
1092
- const provider = createProvider(config2.provider);
1093
- const workspace = resolve4(config2.workspace);
1919
+ function findRepoConfig(config2, issue) {
1920
+ if (config2.repos.length === 0) return void 0;
1921
+ if (issue.repo) {
1922
+ const match = config2.repos.find((r) => r.name === issue.repo);
1923
+ if (match) return match;
1924
+ }
1925
+ for (const r of config2.repos) {
1926
+ if (r.match && issue.title.startsWith(r.match)) return r;
1927
+ }
1928
+ return config2.repos[0];
1929
+ }
1930
+ async function runTestValidation(cwd) {
1931
+ const testRunner = detectTestRunner(cwd);
1932
+ if (!testRunner) return true;
1933
+ log(`Running test validation (${testRunner} detected)...`);
1934
+ try {
1935
+ await execa3("npm", ["run", "test"], { cwd, stdio: "pipe" });
1936
+ ok("Tests passed.");
1937
+ return true;
1938
+ } catch (err) {
1939
+ const message = err instanceof Error ? err.message : String(err);
1940
+ error(`Tests failed: ${message}`);
1941
+ return false;
1942
+ }
1943
+ }
1944
+ async function runWorktreeSession(config2, issue, logFile, session, models) {
1945
+ const workspace = resolve5(config2.workspace);
1094
1946
  const repoPath = determineRepoPath(config2.repos, issue, workspace) ?? workspace;
1095
1947
  const defaultBranch = resolveBaseBranch(config2, repoPath);
1096
1948
  const branchName = generateBranchName(issue.id, issue.title);
@@ -1100,39 +1952,105 @@ async function runWorktreeSession(config2, issue, logFile, session) {
1100
1952
  worktreePath = await createWorktree(repoPath, branchName, defaultBranch);
1101
1953
  } catch (err) {
1102
1954
  error(`Failed to create worktree: ${err instanceof Error ? err.message : String(err)}`);
1103
- return [];
1955
+ return {
1956
+ success: false,
1957
+ providerUsed: models[0] ?? "claude",
1958
+ prUrls: [],
1959
+ fallback: {
1960
+ success: false,
1961
+ output: "",
1962
+ duration: 0,
1963
+ providerUsed: models[0] ?? "claude",
1964
+ attempts: []
1965
+ }
1966
+ };
1104
1967
  }
1105
1968
  ok(`Worktree created at ${worktreePath}`);
1106
- const prompt = buildImplementPrompt(issue, config2);
1969
+ const repo = findRepoConfig(config2, issue);
1970
+ if (repo?.lifecycle) {
1971
+ const started = await startResources(repo, worktreePath);
1972
+ if (!started) {
1973
+ error(`Lifecycle startup failed for ${issue.id}. Aborting session.`);
1974
+ await cleanupWorktree(repoPath, worktreePath);
1975
+ return {
1976
+ success: false,
1977
+ providerUsed: models[0] ?? "claude",
1978
+ prUrls: [],
1979
+ fallback: {
1980
+ success: false,
1981
+ output: "",
1982
+ duration: 0,
1983
+ providerUsed: models[0] ?? "claude",
1984
+ attempts: []
1985
+ }
1986
+ };
1987
+ }
1988
+ }
1989
+ const testRunner = detectTestRunner(worktreePath);
1990
+ if (testRunner) {
1991
+ log(`Detected test runner: ${testRunner}`);
1992
+ }
1993
+ const prompt = buildImplementPrompt(issue, config2, testRunner);
1107
1994
  log(`Implementing in worktree... (log: ${logFile})`);
1108
1995
  initLogFile(logFile);
1109
- const result = await provider.run(prompt, { logFile, cwd: worktreePath });
1996
+ const result = await runWithFallback(models, prompt, {
1997
+ logFile,
1998
+ cwd: worktreePath,
1999
+ guardrailsDir: repoPath,
2000
+ issueId: issue.id,
2001
+ overseer: config2.overseer
2002
+ });
1110
2003
  try {
1111
- appendFileSync6(logFile, `
2004
+ appendFileSync6(
2005
+ logFile,
2006
+ `
1112
2007
  ${"=".repeat(80)}
2008
+ Provider used: ${result.providerUsed}
1113
2009
  Full output:
1114
2010
  ${result.output}
1115
- `);
2011
+ `
2012
+ );
1116
2013
  } catch {
1117
2014
  }
2015
+ if (repo?.lifecycle) {
2016
+ await stopResources();
2017
+ }
1118
2018
  if (!result.success) {
1119
2019
  error(`Session ${session} failed for ${issue.id}. Check ${logFile}`);
1120
2020
  await cleanupWorktree(repoPath, worktreePath);
1121
- return [];
2021
+ return { success: false, providerUsed: result.providerUsed, prUrls: [], fallback: result };
2022
+ }
2023
+ const testsPassed = await runTestValidation(worktreePath);
2024
+ if (!testsPassed) {
2025
+ error(`Tests failed for ${issue.id}. Blocking PR creation.`);
2026
+ await cleanupWorktree(repoPath, worktreePath);
2027
+ return { success: false, providerUsed: result.providerUsed, prUrls: [], fallback: result };
1122
2028
  }
2029
+ try {
2030
+ await execa3("git", ["push", "-u", "origin", branchName], { cwd: worktreePath });
2031
+ } catch (err) {
2032
+ error(
2033
+ `Failed to push branch to remote: ${err instanceof Error ? err.message : String(err)}`
2034
+ );
2035
+ await cleanupWorktree(repoPath, worktreePath);
2036
+ return { success: false, providerUsed: result.providerUsed, prUrls: [], fallback: result };
2037
+ }
2038
+ const prTitle = readPrTitle(worktreePath) ?? issue.title;
2039
+ cleanupPrTitle(worktreePath);
1123
2040
  const prUrls = [];
1124
2041
  try {
1125
2042
  const repoInfo = await getRepoInfo(worktreePath);
1126
- const pr = await createPullRequest({
1127
- owner: repoInfo.owner,
1128
- repo: repoInfo.repo,
1129
- head: branchName,
1130
- base: defaultBranch,
1131
- title: issue.title,
1132
- body: `Closes ${issue.url}
1133
-
1134
- Implemented by [lisa](https://github.com/tarcisiopgs/lisa).`
1135
- }, config2.github);
2043
+ const pr = await createPullRequest(
2044
+ {
2045
+ owner: repoInfo.owner,
2046
+ repo: repoInfo.repo,
2047
+ head: branchName,
2048
+ base: defaultBranch,
2049
+ title: prTitle,
2050
+ body: buildPrBody(issue, result.providerUsed)
2051
+ },
2052
+ config2.github
2053
+ );
1136
2054
  ok(`PR created: ${pr.html_url}`);
1137
2055
  prUrls.push(pr.html_url);
1138
2056
  } catch (err) {
@@ -1140,49 +2058,98 @@ Implemented by [lisa](https://github.com/tarcisiopgs/lisa).`
1140
2058
  }
1141
2059
  await cleanupWorktree(repoPath, worktreePath);
1142
2060
  ok(`Session ${session} complete for ${issue.id}`);
1143
- return prUrls;
2061
+ return { success: true, providerUsed: result.providerUsed, prUrls, fallback: result };
1144
2062
  }
1145
- async function runBranchSession(config2, issue, logFile, session) {
1146
- const provider = createProvider(config2.provider);
1147
- const prompt = buildImplementPrompt(issue, config2);
1148
- const workspace = resolve4(config2.workspace);
2063
+ async function runBranchSession(config2, issue, logFile, session, models) {
2064
+ const workspace = resolve5(config2.workspace);
2065
+ const testRunner = detectTestRunner(workspace);
2066
+ if (testRunner) {
2067
+ log(`Detected test runner: ${testRunner}`);
2068
+ }
2069
+ const prompt = buildImplementPrompt(issue, config2, testRunner);
2070
+ const repo = findRepoConfig(config2, issue);
2071
+ if (repo?.lifecycle) {
2072
+ const cwd = resolve5(workspace, repo.path);
2073
+ const started = await startResources(repo, cwd);
2074
+ if (!started) {
2075
+ error(`Lifecycle startup failed for ${issue.id}. Aborting session.`);
2076
+ return {
2077
+ success: false,
2078
+ providerUsed: models[0] ?? "claude",
2079
+ prUrls: [],
2080
+ fallback: {
2081
+ success: false,
2082
+ output: "",
2083
+ duration: 0,
2084
+ providerUsed: models[0] ?? "claude",
2085
+ attempts: []
2086
+ }
2087
+ };
2088
+ }
2089
+ }
1149
2090
  log(`Implementing... (log: ${logFile})`);
1150
2091
  initLogFile(logFile);
1151
- const result = await provider.run(prompt, { logFile, cwd: workspace });
2092
+ const result = await runWithFallback(models, prompt, {
2093
+ logFile,
2094
+ cwd: workspace,
2095
+ guardrailsDir: workspace,
2096
+ issueId: issue.id,
2097
+ overseer: config2.overseer
2098
+ });
1152
2099
  try {
1153
- appendFileSync6(logFile, `
2100
+ appendFileSync6(
2101
+ logFile,
2102
+ `
1154
2103
  ${"=".repeat(80)}
2104
+ Provider used: ${result.providerUsed}
1155
2105
  Full output:
1156
2106
  ${result.output}
1157
- `);
2107
+ `
2108
+ );
1158
2109
  } catch {
1159
2110
  }
2111
+ if (repo?.lifecycle) {
2112
+ await stopResources();
2113
+ }
1160
2114
  if (!result.success) {
1161
2115
  error(`Session ${session} failed for ${issue.id}. Check ${logFile}`);
1162
- return [];
1163
- }
1164
- const detected = await detectFeatureBranches(config2.repos, issue.id, workspace, config2.base_branch);
2116
+ return { success: false, providerUsed: result.providerUsed, prUrls: [], fallback: result };
2117
+ }
2118
+ const testsPassed = await runTestValidation(workspace);
2119
+ if (!testsPassed) {
2120
+ error(`Tests failed for ${issue.id}. Blocking PR creation.`);
2121
+ return { success: false, providerUsed: result.providerUsed, prUrls: [], fallback: result };
2122
+ }
2123
+ const detected = await detectFeatureBranches(
2124
+ config2.repos,
2125
+ issue.id,
2126
+ workspace,
2127
+ config2.base_branch
2128
+ );
1165
2129
  if (detected.length === 0) {
1166
2130
  error(`Could not detect feature branch for ${issue.id} \u2014 skipping PR creation`);
1167
2131
  ok(`Session ${session} complete for ${issue.id}`);
1168
- return [];
2132
+ return { success: true, providerUsed: result.providerUsed, prUrls: [], fallback: result };
1169
2133
  }
2134
+ const prTitle = readPrTitle(workspace) ?? issue.title;
2135
+ cleanupPrTitle(workspace);
1170
2136
  const prUrls = [];
1171
2137
  for (const { repoPath, branch } of detected) {
1172
2138
  const baseBranch = resolveBaseBranch(config2, repoPath);
1173
2139
  if (branch === baseBranch) continue;
1174
2140
  try {
1175
2141
  const repoInfo = await getRepoInfo(repoPath);
1176
- const pr = await createPullRequest({
1177
- owner: repoInfo.owner,
1178
- repo: repoInfo.repo,
1179
- head: branch,
1180
- base: baseBranch,
1181
- title: issue.title,
1182
- body: `Closes ${issue.url}
1183
-
1184
- Implemented by [lisa](https://github.com/tarcisiopgs/lisa).`
1185
- }, config2.github);
2142
+ const pr = await createPullRequest(
2143
+ {
2144
+ owner: repoInfo.owner,
2145
+ repo: repoInfo.repo,
2146
+ head: branch,
2147
+ base: baseBranch,
2148
+ title: prTitle,
2149
+ body: buildPrBody(issue, result.providerUsed)
2150
+ },
2151
+ config2.github
2152
+ );
1186
2153
  ok(`PR created: ${pr.html_url}`);
1187
2154
  prUrls.push(pr.html_url);
1188
2155
  } catch (err) {
@@ -1190,7 +2157,7 @@ Implemented by [lisa](https://github.com/tarcisiopgs/lisa).`
1190
2157
  }
1191
2158
  }
1192
2159
  ok(`Session ${session} complete for ${issue.id}`);
1193
- return prUrls;
2160
+ return { success: true, providerUsed: result.providerUsed, prUrls, fallback: result };
1194
2161
  }
1195
2162
  async function cleanupWorktree(repoRoot, worktreePath) {
1196
2163
  try {
@@ -1201,7 +2168,7 @@ async function cleanupWorktree(repoRoot, worktreePath) {
1201
2168
  }
1202
2169
  }
1203
2170
  function sleep(ms) {
1204
- return new Promise((resolve5) => setTimeout(resolve5, ms));
2171
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
1205
2172
  }
1206
2173
 
1207
2174
  // src/cli.ts
@@ -1211,6 +2178,7 @@ var run = defineCommand({
1211
2178
  once: { type: "boolean", description: "Run a single iteration", default: false },
1212
2179
  limit: { type: "string", description: "Max number of issues to process", default: "0" },
1213
2180
  "dry-run": { type: "boolean", description: "Preview without executing", default: false },
2181
+ issue: { type: "string", description: "Run a specific issue by identifier or URL" },
1214
2182
  provider: { type: "string", description: "AI provider (claude, gemini, opencode)" },
1215
2183
  source: { type: "string", description: "Issue source (linear, trello)" },
1216
2184
  label: { type: "string", description: "Label to filter issues" },
@@ -1236,16 +2204,21 @@ var run = defineCommand({
1236
2204
  const missingVars = await getMissingEnvVars(merged.source);
1237
2205
  if (missingVars.length > 0) {
1238
2206
  const shell = process.env.SHELL?.includes("zsh") ? "~/.zshrc" : "~/.bashrc";
1239
- console.error(pc2.red(`Missing required environment variables:
1240
- ${missingVars.map((v) => ` ${v}`).join("\n")}`));
2207
+ console.error(
2208
+ pc2.red(
2209
+ `Missing required environment variables:
2210
+ ${missingVars.map((v) => ` ${v}`).join("\n")}`
2211
+ )
2212
+ );
1241
2213
  console.error(pc2.dim(`
1242
2214
  Add them to your ${shell} and run: source ${shell}`));
1243
2215
  process.exit(1);
1244
2216
  }
1245
2217
  await runLoop(merged, {
1246
- once: args.once,
2218
+ once: args.once || !!args.issue,
1247
2219
  limit: Number.parseInt(args.limit, 10),
1248
- dryRun: args["dry-run"]
2220
+ dryRun: args["dry-run"],
2221
+ issueId: args.issue
1249
2222
  });
1250
2223
  }
1251
2224
  });
@@ -1281,7 +2254,9 @@ var init = defineCommand({
1281
2254
  meta: { name: "init", description: "Initialize lisa configuration" },
1282
2255
  async run() {
1283
2256
  if (!process.stdin.isTTY) {
1284
- console.error(pc2.red("Interactive mode requires a TTY. Cannot run init in non-interactive environments."));
2257
+ console.error(
2258
+ pc2.red("Interactive mode requires a TTY. Cannot run init in non-interactive environments.")
2259
+ );
1285
2260
  process.exit(1);
1286
2261
  }
1287
2262
  if (configExists()) {
@@ -1315,8 +2290,8 @@ var status = defineCommand({
1315
2290
  console.log(` In progress: ${pc2.bold(config2.source_config.in_progress)}`);
1316
2291
  console.log(` Done: ${pc2.bold(config2.source_config.done)}`);
1317
2292
  console.log(` Logs: ${pc2.dim(config2.logs.dir)}`);
1318
- const { readdirSync: readdirSync2, existsSync: existsSync5 } = await import("fs");
1319
- if (existsSync5(config2.logs.dir)) {
2293
+ const { readdirSync: readdirSync2, existsSync: existsSync7 } = await import("fs");
2294
+ if (existsSync7(config2.logs.dir)) {
1320
2295
  const logs = readdirSync2(config2.logs.dir).filter((f) => f.endsWith(".log"));
1321
2296
  console.log(`
1322
2297
  ${pc2.cyan("Sessions:")} ${logs.length} log file(s) found`);
@@ -1329,7 +2304,7 @@ ${pc2.dim("No sessions yet.")}`);
1329
2304
  function getVersion() {
1330
2305
  try {
1331
2306
  const pkgPath = resolvePath(new URL(".", import.meta.url).pathname, "../package.json");
1332
- const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
2307
+ const pkg = JSON.parse(readFileSync6(pkgPath, "utf-8"));
1333
2308
  return pkg.version;
1334
2309
  } catch {
1335
2310
  return "0.0.0";
@@ -1339,7 +2314,7 @@ var main = defineCommand({
1339
2314
  meta: {
1340
2315
  name: "lisa",
1341
2316
  version: getVersion(),
1342
- description: "Autonomous issue resolver \u2014 AI agent loop for Linear/Trello"
2317
+ description: "Deterministic autonomous issue resolver \u2014 structured AI agent loop for Linear/Trello"
1343
2318
  },
1344
2319
  subCommands: { run, config, init, status }
1345
2320
  });
@@ -1365,7 +2340,7 @@ After installing, run ${pc2.cyan("lisa init")} again.`
1365
2340
  return process.exit(1);
1366
2341
  }
1367
2342
  let providerName;
1368
- if (available.length === 1) {
2343
+ if (available.length === 1 && available[0]) {
1369
2344
  providerName = available[0].name;
1370
2345
  clack.log.info(`Found provider: ${pc2.bold(providerLabels[providerName])}`);
1371
2346
  } else {
@@ -1551,12 +2526,12 @@ async function detectGitHubMethod() {
1551
2526
  }
1552
2527
  async function detectGitRepos() {
1553
2528
  const cwd = process.cwd();
1554
- if (existsSync4(join5(cwd, ".git"))) {
2529
+ if (existsSync6(join8(cwd, ".git"))) {
1555
2530
  clack.log.info(`Detected git repository in current directory.`);
1556
2531
  return [];
1557
2532
  }
1558
2533
  const entries = readdirSync(cwd, { withFileTypes: true });
1559
- const gitDirs = entries.filter((e) => e.isDirectory() && existsSync4(join5(cwd, e.name, ".git"))).map((e) => e.name);
2534
+ const gitDirs = entries.filter((e) => e.isDirectory() && existsSync6(join8(cwd, e.name, ".git"))).map((e) => e.name);
1560
2535
  if (gitDirs.length === 0) {
1561
2536
  return [];
1562
2537
  }
@@ -1566,7 +2541,7 @@ async function detectGitRepos() {
1566
2541
  });
1567
2542
  if (clack.isCancel(selected)) return process.exit(0);
1568
2543
  return selected.map((dir) => ({
1569
- name: getGitRepoName(join5(cwd, dir)) ?? dir,
2544
+ name: getGitRepoName(join8(cwd, dir)) ?? dir,
1570
2545
  path: `./${dir}`,
1571
2546
  match: "",
1572
2547
  base_branch: ""