@tarcisiopgs/lisa 1.26.2 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3059 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ WATCH_POLL_INTERVAL_MS,
4
+ analyzeProject,
5
+ appendPlatformAttribution,
6
+ appendPlatformProofOfWork,
7
+ buildContinuationPrompt,
8
+ buildImplementPrompt,
9
+ buildNativeWorktreePrompt,
10
+ buildPlanningPrompt,
11
+ buildScopedImplementPrompt,
12
+ buildValidationRecoveryPrompt,
13
+ contextExists,
14
+ createProvider,
15
+ createSource,
16
+ detectPackageManager,
17
+ detectTestRunner,
18
+ getContextPath,
19
+ isCompleteProviderExhaustion,
20
+ isProofOfWorkEnabled,
21
+ readContext,
22
+ resolveModels,
23
+ runValidationCommands,
24
+ runWithFallback
25
+ } from "./chunk-UXVSQQID.js";
26
+ import {
27
+ divider,
28
+ error,
29
+ initLogFile,
30
+ kanbanEmitter,
31
+ log,
32
+ ok,
33
+ warn
34
+ } from "./chunk-5N4BWHIT.js";
35
+ import {
36
+ appendRawEntry,
37
+ migrateGuardrails
38
+ } from "./chunk-3EOEDL3T.js";
39
+ import {
40
+ ensureCacheDir,
41
+ getLogsDir,
42
+ getManifestPath,
43
+ getPlanPath,
44
+ getPrCachePath,
45
+ rotateLogFiles
46
+ } from "./chunk-7OCDGYDM.js";
47
+ import {
48
+ notify,
49
+ resetTitle,
50
+ setTitle,
51
+ startSpinner,
52
+ stopSpinner
53
+ } from "./chunk-72CYGBT4.js";
54
+ import {
55
+ formatError
56
+ } from "./chunk-2TW2MJXF.js";
57
+ import {
58
+ fetchPrFeedback,
59
+ formatPrFeedbackEntry
60
+ } from "./chunk-DGL33SDZ.js";
61
+
62
+ // src/loop/index.ts
63
+ import { join as join3, resolve as resolve11 } from "path";
64
+
65
+ // src/config.ts
66
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
67
+ import { resolve } from "path";
68
+ import { parse, stringify } from "yaml";
69
+ import { literal, object, string, union } from "zod";
70
+ var VALID_PROVIDERS = [
71
+ "claude",
72
+ "gemini",
73
+ "opencode",
74
+ "copilot",
75
+ "cursor",
76
+ "goose",
77
+ "aider",
78
+ "codex"
79
+ ];
80
+ var VALID_SOURCES = [
81
+ "linear",
82
+ "trello",
83
+ "plane",
84
+ "shortcut",
85
+ "gitlab-issues",
86
+ "github-issues",
87
+ "jira"
88
+ ];
89
+ var VALID_PLATFORMS = ["cli", "token", "gitlab", "bitbucket"];
90
+ var VALID_WORKFLOWS = ["worktree", "branch"];
91
+ function enumOrEmpty(values) {
92
+ return union([literal(""), ...values.map((v) => literal(v))]);
93
+ }
94
+ var configSchema = object({
95
+ provider: enumOrEmpty(VALID_PROVIDERS),
96
+ source: enumOrEmpty(VALID_SOURCES),
97
+ platform: enumOrEmpty(VALID_PLATFORMS),
98
+ workflow: union(
99
+ VALID_WORKFLOWS.map((w) => literal(w))
100
+ ),
101
+ base_branch: string().optional(),
102
+ workspace: string().optional()
103
+ }).passthrough();
104
+ var ConfigValidationError = class extends Error {
105
+ constructor(message) {
106
+ super(message);
107
+ this.name = "ConfigValidationError";
108
+ }
109
+ };
110
+ function validateConfig(config) {
111
+ if (!config.provider && !config.source) return;
112
+ const result = configSchema.safeParse(config);
113
+ if (!result.success) {
114
+ const issues = result.error.issues.map((issue) => {
115
+ const path = issue.path.join(".");
116
+ if (issue.code === "invalid_union") {
117
+ if (path === "provider")
118
+ return ` provider: "${config.provider}" is not valid. Must be one of: ${VALID_PROVIDERS.join(", ")}`;
119
+ if (path === "source")
120
+ return ` source: "${config.source}" is not valid. Must be one of: ${VALID_SOURCES.join(", ")}`;
121
+ if (path === "platform")
122
+ return ` platform: "${config.platform}" is not valid. Must be one of: ${VALID_PLATFORMS.join(", ")}`;
123
+ if (path === "workflow")
124
+ return ` workflow: "${config.workflow}" is not valid. Must be one of: ${VALID_WORKFLOWS.join(", ")}`;
125
+ }
126
+ return ` ${path}: ${issue.message}`;
127
+ });
128
+ throw new ConfigValidationError(`Invalid configuration:
129
+ ${issues.join("\n")}`);
130
+ }
131
+ if (config.provider && config.provider_options?.[config.provider]?.models) {
132
+ const models = config.provider_options[config.provider]?.models ?? [];
133
+ for (const model of models) {
134
+ if (typeof model !== "string" || !model.trim()) {
135
+ throw new ConfigValidationError(
136
+ `Invalid configuration:
137
+ provider_options.${config.provider}.models contains an empty or non-string value`
138
+ );
139
+ }
140
+ }
141
+ }
142
+ }
143
+ var DEFAULT_OVERSEER_CONFIG = {
144
+ enabled: true,
145
+ check_interval: 30,
146
+ stuck_threshold: 300
147
+ };
148
+ var CONFIG_DIR = ".lisa";
149
+ var CONFIG_FILE = "config.yaml";
150
+ var DEFAULT_CONFIG = {
151
+ provider: "",
152
+ provider_options: {},
153
+ source: "",
154
+ source_config: {
155
+ scope: "",
156
+ project: "",
157
+ label: "",
158
+ pick_from: "",
159
+ in_progress: "",
160
+ done: ""
161
+ },
162
+ platform: "cli",
163
+ workflow: "branch",
164
+ workspace: "",
165
+ base_branch: "main",
166
+ repos: [],
167
+ loop: {
168
+ cooldown: 0,
169
+ max_sessions: 0
170
+ },
171
+ overseer: { ...DEFAULT_OVERSEER_CONFIG }
172
+ };
173
+ function getConfigPath(cwd = process.cwd()) {
174
+ return resolve(cwd, CONFIG_DIR, CONFIG_FILE);
175
+ }
176
+ function configExists(cwd = process.cwd()) {
177
+ return existsSync(getConfigPath(cwd));
178
+ }
179
+ function findConfigDir(startDir = process.cwd()) {
180
+ let dir = startDir;
181
+ while (true) {
182
+ if (existsSync(getConfigPath(dir))) return dir;
183
+ const parent = resolve(dir, "..");
184
+ if (parent === dir) return null;
185
+ dir = parent;
186
+ }
187
+ }
188
+ function loadConfig(cwd = process.cwd()) {
189
+ const configPath = getConfigPath(cwd);
190
+ if (!existsSync(configPath)) {
191
+ return { ...DEFAULT_CONFIG };
192
+ }
193
+ const raw = readFileSync(configPath, "utf-8");
194
+ const parsed = parse(raw);
195
+ const rawSource = parsed.source_config ?? {};
196
+ const rawLabel = rawSource.label;
197
+ const label = Array.isArray(rawLabel) ? rawLabel : typeof rawLabel === "string" ? rawLabel : "";
198
+ const sourceConfig = {
199
+ scope: (rawSource.scope ?? rawSource.team ?? rawSource.board) || "",
200
+ project: (rawSource.project ?? rawSource.list ?? rawSource.pick_from) || "",
201
+ label,
202
+ remove_label: rawSource.remove_label || void 0,
203
+ pick_from: (rawSource.pick_from ?? rawSource.initial_status) || "",
204
+ in_progress: (rawSource.in_progress ?? rawSource.active_status) || "",
205
+ done: (rawSource.done ?? rawSource.done_status) || ""
206
+ };
207
+ if (parsed.source === "trello" && !sourceConfig.pick_from) {
208
+ sourceConfig.pick_from = sourceConfig.project;
209
+ }
210
+ if (parsed.source === "plane" && !sourceConfig.scope && process.env.PLANE_WORKSPACE) {
211
+ sourceConfig.scope = process.env.PLANE_WORKSPACE;
212
+ }
213
+ if (parsed.source === "gitlab-issues" && !sourceConfig.scope && rawSource.project) {
214
+ sourceConfig.scope = rawSource.project;
215
+ }
216
+ if (parsed.source === "github-issues" && !sourceConfig.scope && rawSource.project) {
217
+ sourceConfig.scope = rawSource.project;
218
+ }
219
+ if (parsed.source === "jira" && !sourceConfig.scope && rawSource.project) {
220
+ sourceConfig.scope = rawSource.project;
221
+ }
222
+ const { logs: _ignoredLogs, ...parsedWithoutLogs } = parsed;
223
+ const rawLifecycle = parsed.lifecycle ?? void 0;
224
+ const rawHooks = parsed.hooks;
225
+ const rawProofOfWork = parsed.proof_of_work;
226
+ const rawReconciliation = parsed.reconciliation;
227
+ const config = {
228
+ ...DEFAULT_CONFIG,
229
+ ...parsedWithoutLogs,
230
+ platform: parsed.platform ?? parsed.github ?? "cli",
231
+ source_config: sourceConfig,
232
+ loop: { ...DEFAULT_CONFIG.loop, ...parsed.loop ?? {} },
233
+ overseer: {
234
+ ...DEFAULT_OVERSEER_CONFIG,
235
+ ...parsed.overseer ?? {}
236
+ },
237
+ lifecycle: rawLifecycle ? {
238
+ mode: rawLifecycle.mode,
239
+ timeout: rawLifecycle.timeout
240
+ } : void 0,
241
+ hooks: rawHooks ? {
242
+ before_run: rawHooks.before_run,
243
+ after_run: rawHooks.after_run,
244
+ after_create: rawHooks.after_create,
245
+ before_remove: rawHooks.before_remove,
246
+ timeout: rawHooks.timeout
247
+ } : void 0,
248
+ proof_of_work: rawProofOfWork ? {
249
+ enabled: rawProofOfWork.enabled ?? false,
250
+ commands: Array.isArray(rawProofOfWork.commands) ? rawProofOfWork.commands : [],
251
+ max_retries: rawProofOfWork.max_retries,
252
+ timeout: rawProofOfWork.timeout
253
+ } : void 0,
254
+ reconciliation: rawReconciliation ? {
255
+ enabled: rawReconciliation.enabled ?? false,
256
+ check_interval: rawReconciliation.check_interval
257
+ } : void 0,
258
+ provider_options: {
259
+ ...DEFAULT_CONFIG.provider_options || {},
260
+ ...parsed.provider_options ?? {}
261
+ }
262
+ };
263
+ if (!config.base_branch) config.base_branch = "main";
264
+ for (const repo of config.repos) {
265
+ if (!repo.base_branch) repo.base_branch = config.base_branch;
266
+ }
267
+ if (!config.provider_options) {
268
+ config.provider_options = {};
269
+ }
270
+ if (!config.provider_options[config.provider]) {
271
+ config.provider_options[config.provider] = {};
272
+ }
273
+ if (parsed.models && Array.isArray(parsed.models)) {
274
+ config.provider_options[config.provider] = {
275
+ ...config.provider_options[config.provider],
276
+ models: parsed.models
277
+ };
278
+ }
279
+ if (!config.provider_options[config.provider]?.models?.length && config.provider) {
280
+ config.provider_options[config.provider] = {
281
+ ...config.provider_options[config.provider],
282
+ models: [config.provider]
283
+ };
284
+ }
285
+ validateConfig(config);
286
+ return config;
287
+ }
288
+ function saveConfig(config, cwd = process.cwd()) {
289
+ const configPath = getConfigPath(cwd);
290
+ const dir = resolve(cwd, CONFIG_DIR);
291
+ if (!existsSync(dir)) {
292
+ mkdirSync(dir, { recursive: true });
293
+ }
294
+ const sc = config.source_config;
295
+ const removeLabelEntry = sc.remove_label ? { remove_label: sc.remove_label } : {};
296
+ const sourceYaml = config.source === "trello" ? {
297
+ board: sc.scope,
298
+ pick_from: sc.pick_from || sc.project,
299
+ label: sc.label,
300
+ ...removeLabelEntry,
301
+ in_progress: sc.in_progress,
302
+ done: sc.done
303
+ } : config.source === "gitlab-issues" ? {
304
+ scope: sc.scope,
305
+ label: sc.label,
306
+ ...removeLabelEntry,
307
+ in_progress: sc.in_progress,
308
+ done: sc.done
309
+ } : config.source === "github-issues" ? {
310
+ scope: sc.scope,
311
+ label: sc.label,
312
+ ...removeLabelEntry,
313
+ in_progress: sc.in_progress,
314
+ done: sc.done
315
+ } : config.source === "jira" ? {
316
+ scope: sc.scope,
317
+ label: sc.label,
318
+ ...removeLabelEntry,
319
+ pick_from: sc.pick_from,
320
+ in_progress: sc.in_progress,
321
+ done: sc.done
322
+ } : {
323
+ scope: sc.scope,
324
+ project: sc.project,
325
+ label: sc.label,
326
+ ...removeLabelEntry,
327
+ pick_from: sc.pick_from,
328
+ in_progress: sc.in_progress,
329
+ done: sc.done
330
+ };
331
+ const output = { ...config, source_config: sourceYaml };
332
+ writeFileSync(configPath, stringify(output), "utf-8");
333
+ }
334
+ function mergeWithFlags(config, flags) {
335
+ const merged = { ...config };
336
+ if (flags.provider) merged.provider = flags.provider;
337
+ if (flags.source) merged.source = flags.source;
338
+ if (flags.platform) merged.platform = flags.platform;
339
+ if (flags.bell !== void 0) merged.bell = flags.bell;
340
+ if (flags.lifecycle) {
341
+ merged.lifecycle = { ...merged.lifecycle, ...flags.lifecycle };
342
+ }
343
+ if (flags.label) {
344
+ const parts = flags.label.split(",").map((s) => s.trim()).filter(Boolean);
345
+ const label = parts.length === 1 ? parts[0] : parts;
346
+ merged.source_config = { ...merged.source_config, label };
347
+ }
348
+ return merged;
349
+ }
350
+ function getRemoveLabel(sc) {
351
+ if (sc.remove_label) return sc.remove_label;
352
+ if (typeof sc.label === "string" && sc.label) return sc.label;
353
+ return void 0;
354
+ }
355
+ function getLabelsArray(sc) {
356
+ if (Array.isArray(sc.label)) return sc.label;
357
+ return sc.label ? [sc.label] : [];
358
+ }
359
+ function formatLabels(sc) {
360
+ const labels = getLabelsArray(sc);
361
+ return labels.length === 0 ? "(none)" : labels.join(", ");
362
+ }
363
+
364
+ // src/loop/concurrent.ts
365
+ import { resolve as resolve7 } from "path";
366
+
367
+ // src/session/pr-cache.ts
368
+ import {
369
+ existsSync as existsSync2,
370
+ mkdirSync as mkdirSync2,
371
+ readFileSync as readFileSync2,
372
+ renameSync,
373
+ unlinkSync,
374
+ writeFileSync as writeFileSync2
375
+ } from "fs";
376
+ import { dirname } from "path";
377
+ function readCache(cwd) {
378
+ const path = getPrCachePath(cwd);
379
+ if (!existsSync2(path)) return {};
380
+ try {
381
+ return JSON.parse(readFileSync2(path, "utf-8"));
382
+ } catch {
383
+ return {};
384
+ }
385
+ }
386
+ function writeCacheSafe(cwd, cache) {
387
+ const path = getPrCachePath(cwd);
388
+ const dir = dirname(path);
389
+ if (!existsSync2(dir)) {
390
+ mkdirSync2(dir, { recursive: true });
391
+ }
392
+ const tmpPath = `${path}.tmp.${process.pid}`;
393
+ const data = JSON.stringify(cache, null, 2);
394
+ writeFileSync2(tmpPath, data, "utf-8");
395
+ try {
396
+ renameSync(tmpPath, path);
397
+ } catch {
398
+ writeFileSync2(path, data, "utf-8");
399
+ try {
400
+ unlinkSync(tmpPath);
401
+ } catch {
402
+ }
403
+ }
404
+ }
405
+ function storePrUrls(cwd, issueId, prUrls) {
406
+ const cache = readCache(cwd);
407
+ cache[issueId] = prUrls;
408
+ writeCacheSafe(cwd, cache);
409
+ }
410
+ function loadPrUrls(cwd, issueId) {
411
+ const entry = readCache(cwd)[issueId];
412
+ if (!entry) return [];
413
+ return Array.isArray(entry) ? entry : [entry];
414
+ }
415
+ function clearPrUrl(cwd, issueId) {
416
+ const cache = readCache(cwd);
417
+ delete cache[issueId];
418
+ writeCacheSafe(cwd, cache);
419
+ }
420
+
421
+ // src/loop/helpers.ts
422
+ import { appendFileSync } from "fs";
423
+ import { resolve as resolve3 } from "path";
424
+ import { execa } from "execa";
425
+
426
+ // src/session/discovery.ts
427
+ import { copyFileSync, existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
428
+ import { join } from "path";
429
+ import { parse as parse2 } from "yaml";
430
+ var DOCKER_COMPOSE_FILES = [
431
+ "docker-compose.yml",
432
+ "docker-compose.yaml",
433
+ "compose.yml",
434
+ "compose.yaml"
435
+ ];
436
+ function parseHostPort(port) {
437
+ const raw = String(port).split("/")[0] ?? "";
438
+ const parts = raw.split(":");
439
+ if (parts.length === 1) {
440
+ const n = Number.parseInt(parts[0] ?? "", 10);
441
+ return Number.isNaN(n) ? null : n;
442
+ }
443
+ if (parts.length === 2) {
444
+ const n = Number.parseInt(parts[0] ?? "", 10);
445
+ return Number.isNaN(n) ? null : n;
446
+ }
447
+ if (parts.length === 3) {
448
+ const n = Number.parseInt(parts[1] ?? "", 10);
449
+ return Number.isNaN(n) ? null : n;
450
+ }
451
+ return null;
452
+ }
453
+ function discoverDockerCompose(cwd) {
454
+ let composeFile = null;
455
+ for (const name of DOCKER_COMPOSE_FILES) {
456
+ const candidate = join(cwd, name);
457
+ if (existsSync3(candidate)) {
458
+ composeFile = candidate;
459
+ break;
460
+ }
461
+ }
462
+ if (!composeFile) return [];
463
+ let content;
464
+ try {
465
+ content = readFileSync3(composeFile, "utf-8");
466
+ } catch {
467
+ return [];
468
+ }
469
+ let parsed;
470
+ try {
471
+ parsed = parse2(content);
472
+ } catch {
473
+ warn("Failed to parse docker-compose file. Skipping auto-discovery for Docker.");
474
+ return [];
475
+ }
476
+ if (!parsed?.services) return [];
477
+ const resources = [];
478
+ for (const [serviceName, service] of Object.entries(parsed.services)) {
479
+ if (!/^[a-zA-Z0-9._-]+$/.test(serviceName)) {
480
+ warn(`Skipping Docker service with unsafe name: ${serviceName}`);
481
+ continue;
482
+ }
483
+ const firstPort = service.ports?.[0];
484
+ if (firstPort === void 0) continue;
485
+ const hostPort = parseHostPort(firstPort);
486
+ if (hostPort === null) continue;
487
+ resources.push({
488
+ name: serviceName,
489
+ check_port: hostPort,
490
+ up: `docker compose up -d ${serviceName}`,
491
+ down: `docker compose stop ${serviceName}`,
492
+ startup_timeout: 30
493
+ });
494
+ }
495
+ return resources;
496
+ }
497
+ function discoverEnvFile(cwd) {
498
+ const envExample = join(cwd, ".env.example");
499
+ const envFile = join(cwd, ".env");
500
+ if (existsSync3(envExample) && !existsSync3(envFile)) {
501
+ try {
502
+ copyFileSync(envExample, envFile);
503
+ ok("Copied .env.example \u2192 .env");
504
+ } catch (err) {
505
+ warn(`Failed to copy .env.example: ${formatError(err)}`);
506
+ }
507
+ }
508
+ }
509
+ function discoverInfra(cwd) {
510
+ discoverEnvFile(cwd);
511
+ const resources = discoverDockerCompose(cwd);
512
+ if (resources.length === 0) return null;
513
+ log(`Auto-discovered infrastructure: ${resources.length} resource(s)`);
514
+ return { resources, setup: [] };
515
+ }
516
+
517
+ // src/session/lifecycle.ts
518
+ import { spawn } from "child_process";
519
+ import { createConnection } from "net";
520
+ import { resolve as resolve2 } from "path";
521
+ var managedResources = [];
522
+ var cleanupRegistered = false;
523
+ function isPortInUse(port) {
524
+ return new Promise((resolve12) => {
525
+ const socket = createConnection({ port }, () => {
526
+ socket.destroy();
527
+ resolve12(true);
528
+ });
529
+ socket.on("error", () => {
530
+ socket.destroy();
531
+ resolve12(false);
532
+ });
533
+ });
534
+ }
535
+ function waitForPort(port, timeoutMs) {
536
+ return new Promise((resolve12) => {
537
+ const deadline = Date.now() + timeoutMs;
538
+ const check = () => {
539
+ if (Date.now() > deadline) {
540
+ resolve12(false);
541
+ return;
542
+ }
543
+ isPortInUse(port).then((inUse) => {
544
+ if (inUse) {
545
+ resolve12(true);
546
+ } else {
547
+ setTimeout(check, 500);
548
+ }
549
+ });
550
+ };
551
+ check();
552
+ });
553
+ }
554
+ async function allocatePort(basePort, range) {
555
+ for (let offset = 0; offset < range; offset++) {
556
+ const port = basePort + offset;
557
+ const inUse = await isPortInUse(port);
558
+ if (!inUse) {
559
+ return port;
560
+ }
561
+ }
562
+ return null;
563
+ }
564
+ function spawnResource(config, baseCwd, allocatedPort) {
565
+ const cwd = config.cwd ? resolve2(baseCwd, config.cwd) : baseCwd;
566
+ const env = { ...process.env };
567
+ if (config.port_env_var) {
568
+ env[config.port_env_var] = String(allocatedPort);
569
+ }
570
+ const child = spawn("sh", ["-c", config.up], {
571
+ cwd,
572
+ env,
573
+ stdio: "ignore",
574
+ detached: true
575
+ });
576
+ return child;
577
+ }
578
+ function runSetupCommand(command, cwd, env) {
579
+ return new Promise((resolve12, reject) => {
580
+ const child = spawn("sh", ["-c", command], {
581
+ cwd,
582
+ env: { ...process.env, ...env },
583
+ stdio: "inherit"
584
+ });
585
+ child.on("close", (code) => {
586
+ if (code === 0) {
587
+ resolve12();
588
+ } else {
589
+ reject(new Error(`Setup command failed with exit code ${code}: ${command}`));
590
+ }
591
+ });
592
+ child.on("error", (err) => {
593
+ reject(new Error(`Setup command error: ${err.message}`));
594
+ });
595
+ });
596
+ }
597
+ async function startResources(infra, baseCwd) {
598
+ registerCleanup();
599
+ const allocatedEnv = {};
600
+ for (const resource of infra.resources) {
601
+ let allocatedPort;
602
+ if (resource.port_range && resource.port_range > 0) {
603
+ const found = await allocatePort(resource.check_port, resource.port_range);
604
+ if (found === null) {
605
+ error(
606
+ `No free port found for "${resource.name}" in range ${resource.check_port}\u2013${resource.check_port + resource.port_range - 1}`
607
+ );
608
+ await stopResources();
609
+ return { success: false, env: allocatedEnv };
610
+ }
611
+ allocatedPort = found;
612
+ log(`Allocated port ${allocatedPort} for "${resource.name}"`);
613
+ } else {
614
+ allocatedPort = resource.check_port;
615
+ }
616
+ if (resource.port_env_var) {
617
+ allocatedEnv[resource.port_env_var] = String(allocatedPort);
618
+ }
619
+ const alreadyRunning = await isPortInUse(allocatedPort);
620
+ if (alreadyRunning) {
621
+ ok(`Resource "${resource.name}" already running on port ${allocatedPort}`);
622
+ continue;
623
+ }
624
+ log(`Starting resource "${resource.name}" on port ${allocatedPort}...`);
625
+ const child = spawnResource(resource, baseCwd, allocatedPort);
626
+ managedResources.push({
627
+ name: resource.name,
628
+ config: resource,
629
+ process: child
630
+ });
631
+ const timeoutMs = (resource.startup_timeout || 30) * 1e3;
632
+ const ready = await waitForPort(allocatedPort, timeoutMs);
633
+ if (!ready) {
634
+ error(
635
+ `Resource "${resource.name}" failed to start within ${resource.startup_timeout}s`
636
+ );
637
+ await stopResources();
638
+ return { success: false, env: allocatedEnv };
639
+ }
640
+ ok(`Resource "${resource.name}" is ready on port ${allocatedPort}`);
641
+ }
642
+ for (const command of infra.setup) {
643
+ log(`Running setup: ${command}`);
644
+ try {
645
+ await runSetupCommand(command, baseCwd, allocatedEnv);
646
+ ok(`Setup complete: ${command}`);
647
+ } catch (err) {
648
+ error(`Setup failed: ${formatError(err)}`);
649
+ await stopResources();
650
+ return { success: false, env: allocatedEnv };
651
+ }
652
+ }
653
+ return { success: true, env: allocatedEnv };
654
+ }
655
+ async function runLifecycle(infra, lifecycle, baseCwd) {
656
+ const mode = lifecycle?.mode ?? "skip";
657
+ if (mode === "skip") {
658
+ return { success: true, env: {} };
659
+ }
660
+ if (mode === "validate-only") {
661
+ for (const resource of infra.resources) {
662
+ const inUse = await isPortInUse(resource.check_port);
663
+ if (!inUse) {
664
+ error(
665
+ `Resource "${resource.name}" is not running on port ${resource.check_port}. Start it manually or use lifecycle: auto.`
666
+ );
667
+ return { success: false, env: {} };
668
+ }
669
+ }
670
+ return { success: true, env: {} };
671
+ }
672
+ if (lifecycle?.timeout !== void 0) {
673
+ const patchedInfra = {
674
+ ...infra,
675
+ resources: infra.resources.map((r) => ({
676
+ ...r,
677
+ startup_timeout: lifecycle.timeout
678
+ }))
679
+ };
680
+ return startResources(patchedInfra, baseCwd);
681
+ }
682
+ return startResources(infra, baseCwd);
683
+ }
684
+ async function stopResources() {
685
+ for (const managed of managedResources) {
686
+ const { name, config, process: child } = managed;
687
+ log(`Stopping resource "${name}"...`);
688
+ try {
689
+ if (config.down === "auto") {
690
+ if (child?.pid) {
691
+ try {
692
+ process.kill(-child.pid, "SIGTERM");
693
+ } catch {
694
+ }
695
+ }
696
+ } else {
697
+ await new Promise((resolve12) => {
698
+ const down = spawn("sh", ["-c", config.down], {
699
+ stdio: "ignore"
700
+ });
701
+ down.on("close", () => resolve12());
702
+ down.on("error", () => resolve12());
703
+ });
704
+ }
705
+ ok(`Resource "${name}" stopped`);
706
+ } catch (err) {
707
+ warn(`Failed to stop resource "${name}": ${formatError(err)}`);
708
+ }
709
+ }
710
+ managedResources.length = 0;
711
+ }
712
+ function registerCleanup() {
713
+ if (cleanupRegistered) return;
714
+ cleanupRegistered = true;
715
+ const cleanup = () => {
716
+ for (const managed of managedResources) {
717
+ const { config, process: child } = managed;
718
+ try {
719
+ if (config.down === "auto") {
720
+ if (child?.pid) {
721
+ process.kill(-child.pid, "SIGTERM");
722
+ }
723
+ }
724
+ } catch {
725
+ }
726
+ }
727
+ };
728
+ process.on("exit", cleanup);
729
+ process.on("SIGINT", () => {
730
+ cleanup();
731
+ process.exit(130);
732
+ });
733
+ process.on("SIGTERM", () => {
734
+ cleanup();
735
+ process.exit(143);
736
+ });
737
+ }
738
+
739
+ // src/loop/state.ts
740
+ var activeCleanups = /* @__PURE__ */ new Map();
741
+ var activeProviderPids = /* @__PURE__ */ new Map();
742
+ var providerPausedSet = /* @__PURE__ */ new Set();
743
+ var userKilledSet = /* @__PURE__ */ new Set();
744
+ var userSkippedSet = /* @__PURE__ */ new Set();
745
+ var reconciliationSet = /* @__PURE__ */ new Set();
746
+ var _shuttingDown = false;
747
+ var _loopPaused = false;
748
+ var _userQuitFromWatchPrompt = false;
749
+ var _loopIdle = false;
750
+ var _idleResolve = null;
751
+ function isShuttingDown() {
752
+ return _shuttingDown;
753
+ }
754
+ function setShuttingDown(value) {
755
+ _shuttingDown = value;
756
+ }
757
+ function isLoopPaused() {
758
+ return _loopPaused;
759
+ }
760
+ function hasUserQuitFromWatchPrompt() {
761
+ return _userQuitFromWatchPrompt;
762
+ }
763
+ function waitForResume() {
764
+ if (_shuttingDown || _userQuitFromWatchPrompt) return Promise.resolve();
765
+ _loopIdle = true;
766
+ return new Promise((resolve12) => {
767
+ _idleResolve = resolve12;
768
+ });
769
+ }
770
+ function resolveIdle() {
771
+ _loopIdle = false;
772
+ if (_idleResolve) {
773
+ _idleResolve();
774
+ _idleResolve = null;
775
+ }
776
+ }
777
+ function killProviderForIssue(issueId) {
778
+ const pid = activeProviderPids.get(issueId);
779
+ if (!pid) return;
780
+ if (providerPausedSet.has(issueId)) {
781
+ try {
782
+ process.kill(pid, "SIGCONT");
783
+ } catch {
784
+ }
785
+ providerPausedSet.delete(issueId);
786
+ }
787
+ try {
788
+ process.kill(pid, "SIGTERM");
789
+ } catch {
790
+ }
791
+ setTimeout(() => {
792
+ try {
793
+ process.kill(pid, "SIGKILL");
794
+ } catch {
795
+ }
796
+ }, 5e3);
797
+ }
798
+ function setupEventListeners() {
799
+ kanbanEmitter.on("loop:pause", () => {
800
+ _loopPaused = true;
801
+ });
802
+ kanbanEmitter.on("loop:resume", () => {
803
+ _loopPaused = false;
804
+ });
805
+ kanbanEmitter.on("loop:pause-provider", (issueId) => {
806
+ if (issueId) {
807
+ const pid = activeProviderPids.get(issueId);
808
+ if (pid) {
809
+ try {
810
+ process.kill(pid, "SIGSTOP");
811
+ } catch {
812
+ }
813
+ providerPausedSet.add(issueId);
814
+ }
815
+ } else {
816
+ for (const [id, pid] of activeProviderPids) {
817
+ try {
818
+ process.kill(pid, "SIGSTOP");
819
+ } catch {
820
+ }
821
+ providerPausedSet.add(id);
822
+ }
823
+ }
824
+ kanbanEmitter.emit("provider:paused", issueId);
825
+ });
826
+ kanbanEmitter.on("loop:resume-provider", (issueId) => {
827
+ if (issueId) {
828
+ const pid = activeProviderPids.get(issueId);
829
+ if (pid && providerPausedSet.has(issueId)) {
830
+ try {
831
+ process.kill(pid, "SIGCONT");
832
+ } catch {
833
+ }
834
+ providerPausedSet.delete(issueId);
835
+ }
836
+ } else {
837
+ for (const id of providerPausedSet) {
838
+ const pid = activeProviderPids.get(id);
839
+ if (pid) {
840
+ try {
841
+ process.kill(pid, "SIGCONT");
842
+ } catch {
843
+ }
844
+ }
845
+ }
846
+ providerPausedSet.clear();
847
+ }
848
+ kanbanEmitter.emit("provider:resumed", issueId);
849
+ });
850
+ kanbanEmitter.on("loop:kill", (issueId) => {
851
+ if (issueId) {
852
+ userKilledSet.add(issueId);
853
+ killProviderForIssue(issueId);
854
+ } else {
855
+ const firstId = activeProviderPids.keys().next().value;
856
+ if (firstId) {
857
+ userKilledSet.add(firstId);
858
+ killProviderForIssue(firstId);
859
+ }
860
+ }
861
+ });
862
+ kanbanEmitter.on("loop:skip", (issueId) => {
863
+ if (issueId) {
864
+ userSkippedSet.add(issueId);
865
+ killProviderForIssue(issueId);
866
+ } else {
867
+ const firstId = activeProviderPids.keys().next().value;
868
+ if (firstId) {
869
+ userSkippedSet.add(firstId);
870
+ killProviderForIssue(firstId);
871
+ }
872
+ }
873
+ });
874
+ kanbanEmitter.on("loop:run", () => {
875
+ resolveIdle();
876
+ });
877
+ kanbanEmitter.on("loop:quit", () => {
878
+ _userQuitFromWatchPrompt = true;
879
+ setShuttingDown(true);
880
+ resolveIdle();
881
+ });
882
+ }
883
+
884
+ // src/session/reconciliation.ts
885
+ var DEFAULT_CHECK_INTERVAL = 30;
886
+ function startReconciliation(source, issueId, config, sourceConfig) {
887
+ if (!config.enabled) {
888
+ return { stop() {
889
+ }, wasReconciled: () => false };
890
+ }
891
+ let reconciled = false;
892
+ let paused = false;
893
+ const intervalMs = (config.check_interval ?? DEFAULT_CHECK_INTERVAL) * 1e3;
894
+ const onPause = () => {
895
+ paused = true;
896
+ };
897
+ const onResume = () => {
898
+ paused = false;
899
+ };
900
+ kanbanEmitter.on("loop:pause-provider", onPause);
901
+ kanbanEmitter.on("loop:resume-provider", onResume);
902
+ const check = async () => {
903
+ if (reconciled || paused) return;
904
+ try {
905
+ const issue = await source.fetchIssueById(issueId);
906
+ if (!issue) {
907
+ reconciled = true;
908
+ reconciliationSet.add(issueId);
909
+ warn(`Issue ${issueId} no longer found in tracker. Killing provider.`);
910
+ killProviderForIssue(issueId);
911
+ cleanup();
912
+ return;
913
+ }
914
+ if (issue.status) {
915
+ const currentStatus = issue.status.toLowerCase();
916
+ const inProgress = sourceConfig.in_progress.toLowerCase();
917
+ const done = sourceConfig.done.toLowerCase();
918
+ if (currentStatus === done || inProgress && currentStatus !== inProgress) {
919
+ reconciled = true;
920
+ reconciliationSet.add(issueId);
921
+ warn(
922
+ `Issue ${issueId} status changed to "${issue.status}" externally. Killing provider.`
923
+ );
924
+ killProviderForIssue(issueId);
925
+ cleanup();
926
+ }
927
+ }
928
+ } catch {
929
+ }
930
+ };
931
+ let timer = setInterval(check, intervalMs);
932
+ if (timer && typeof timer === "object" && "unref" in timer) {
933
+ timer.unref();
934
+ }
935
+ function cleanup() {
936
+ if (timer) {
937
+ clearInterval(timer);
938
+ timer = null;
939
+ }
940
+ kanbanEmitter.off("loop:pause-provider", onPause);
941
+ kanbanEmitter.off("loop:resume-provider", onResume);
942
+ }
943
+ return {
944
+ stop() {
945
+ cleanup();
946
+ },
947
+ wasReconciled() {
948
+ return reconciled;
949
+ }
950
+ };
951
+ }
952
+
953
+ // src/validation.ts
954
+ var ACCEPTANCE_CRITERIA_PATTERNS = [
955
+ /- \[ \]/,
956
+ // Markdown checklist item: - [ ]
957
+ /critérios/i,
958
+ // Portuguese: "critérios de aceite"
959
+ /acceptance criteria/i,
960
+ // English
961
+ /expected behavior/i,
962
+ // English alternative
963
+ /\bexpected\b/i,
964
+ // English: "expected output", etc.
965
+ /\bdeve\b/i,
966
+ // Portuguese: "deve fazer X"
967
+ /\bshould\b/i
968
+ // English: "should do X"
969
+ ];
970
+ function validateIssueSpec(issue, config) {
971
+ if (config?.require_acceptance_criteria === false) {
972
+ return { valid: true };
973
+ }
974
+ const description = issue.description?.trim() ?? "";
975
+ if (!description) {
976
+ return {
977
+ valid: false,
978
+ reason: "issue has no description"
979
+ };
980
+ }
981
+ const hasAcceptanceCriteria = ACCEPTANCE_CRITERIA_PATTERNS.some(
982
+ (pattern) => pattern.test(description)
983
+ );
984
+ if (!hasAcceptanceCriteria) {
985
+ return {
986
+ valid: false,
987
+ reason: "issue description has no detectable acceptance criteria (missing checklist `- [ ]`, 'acceptance criteria', 'expected', 'deve', 'should', or 'crit\xE9rios')"
988
+ };
989
+ }
990
+ return { valid: true };
991
+ }
992
+
993
+ // src/loop/helpers.ts
994
+ async function checkIssueSpec(issue, config, source) {
995
+ const specResult = validateIssueSpec(issue, config.validation);
996
+ if (!specResult.valid) {
997
+ warn(`Issue ${issue.id}: ${specResult.reason} \u2014 proceeding with incomplete spec`);
998
+ try {
999
+ await source.addLabel?.(issue.id, "needs-spec");
1000
+ ok(`Added label "needs-spec" to ${issue.id}`);
1001
+ } catch (err) {
1002
+ warn(`Failed to add label "needs-spec": ${formatError(err)}`);
1003
+ }
1004
+ issue.specWarning = specResult.reason;
1005
+ return false;
1006
+ }
1007
+ return true;
1008
+ }
1009
+ async function moveToInProgress(issue, source, config) {
1010
+ try {
1011
+ await source.updateStatus(issue.id, config.source_config.in_progress, config.source_config);
1012
+ ok(`Moved ${issue.id} to "${config.source_config.in_progress}"`);
1013
+ } catch (err) {
1014
+ warn(`Failed to update status: ${formatError(err)}`);
1015
+ }
1016
+ }
1017
+ async function revertIssueStatus(issue, source, config) {
1018
+ const previousStatus = config.source_config.pick_from;
1019
+ try {
1020
+ await source.updateStatus(issue.id, previousStatus, config.source_config);
1021
+ ok(`Reverted ${issue.id} to "${previousStatus}"`);
1022
+ } catch (revertErr) {
1023
+ error(`Failed to revert status: ${formatError(revertErr)}`);
1024
+ }
1025
+ }
1026
+ function resolveProviderOptions(config) {
1027
+ const opts = config.provider_options?.[config.provider];
1028
+ if (!opts?.effort) return void 0;
1029
+ return { effort: opts.effort };
1030
+ }
1031
+ function resolveBaseBranch(config, repoPath) {
1032
+ const workspace = resolve3(config.workspace);
1033
+ const repo = config.repos.find((r) => resolve3(workspace, r.path) === repoPath);
1034
+ return repo?.base_branch ?? config.base_branch;
1035
+ }
1036
+ async function checkoutBaseBranches(config, workspace) {
1037
+ const targets = [
1038
+ { cwd: workspace, branch: config.base_branch },
1039
+ ...config.repos.map((r) => ({
1040
+ cwd: resolve3(workspace, r.path),
1041
+ branch: r.base_branch
1042
+ }))
1043
+ ];
1044
+ for (const { cwd, branch } of targets) {
1045
+ try {
1046
+ await execa("git", ["checkout", branch], { cwd });
1047
+ ok(`Checked out ${branch} in ${cwd}`);
1048
+ } catch (err) {
1049
+ warn(`Could not checkout ${branch} in ${cwd}: ${formatError(err)}`);
1050
+ }
1051
+ }
1052
+ }
1053
+ function sleep(ms) {
1054
+ return new Promise((resolve12) => setTimeout(resolve12, ms));
1055
+ }
1056
+ async function waitIfPaused() {
1057
+ while (isLoopPaused()) {
1058
+ await sleep(500);
1059
+ }
1060
+ }
1061
+ async function refreshKanban(source, config) {
1062
+ if (kanbanEmitter.listenerCount("issue:queued") === 0) return;
1063
+ try {
1064
+ const allIssues = await source.listIssues(config.source_config);
1065
+ for (const issue of allIssues) {
1066
+ kanbanEmitter.emit("issue:queued", issue);
1067
+ }
1068
+ } catch {
1069
+ }
1070
+ }
1071
+ function defaultProvider(models) {
1072
+ return models[0]?.provider ?? "claude";
1073
+ }
1074
+ function failureResult(providerUsed, fallback) {
1075
+ return { success: false, providerUsed, prUrls: [], fallback };
1076
+ }
1077
+ function hookFailure(providerUsed, message) {
1078
+ return failureResult(providerUsed, {
1079
+ success: false,
1080
+ output: message,
1081
+ duration: 0,
1082
+ providerUsed,
1083
+ attempts: []
1084
+ });
1085
+ }
1086
+ function emptyCommitFailure(result) {
1087
+ error("Provider reported success but no code changes detected. Treating as failure.");
1088
+ return failureResult(result.providerUsed, {
1089
+ success: false,
1090
+ output: "Provider reported success but no code changes detected",
1091
+ duration: result.duration,
1092
+ providerUsed: result.providerUsed,
1093
+ attempts: [
1094
+ {
1095
+ provider: result.providerUsed,
1096
+ model: "",
1097
+ success: false,
1098
+ error: "Eligible error (empty commit)",
1099
+ duration: result.duration
1100
+ }
1101
+ ]
1102
+ });
1103
+ }
1104
+ function appendSessionLog(logFile, result) {
1105
+ try {
1106
+ appendFileSync(
1107
+ logFile,
1108
+ `
1109
+ ${"=".repeat(80)}
1110
+ Provider used: ${result.providerUsed}
1111
+ Full output:
1112
+ ${result.output}
1113
+ `
1114
+ );
1115
+ } catch {
1116
+ }
1117
+ }
1118
+ function checkReconciliation(issueId, result) {
1119
+ if (reconciliationSet.has(issueId)) {
1120
+ reconciliationSet.delete(issueId);
1121
+ warn(`Issue ${issueId} was closed/cancelled externally. Skipping.`);
1122
+ return failureResult(result.providerUsed, result);
1123
+ }
1124
+ return null;
1125
+ }
1126
+ function buildRunOptions(config, issue, cwd, logFile, workspace, lifecycleEnv, extra) {
1127
+ return {
1128
+ logFile,
1129
+ cwd,
1130
+ guardrailsDir: workspace,
1131
+ issueId: issue.id,
1132
+ overseer: config.overseer,
1133
+ sessionTimeout: config.loop.session_timeout,
1134
+ outputStallTimeout: config.loop.output_stall_timeout,
1135
+ providerOptions: resolveProviderOptions(config),
1136
+ env: Object.keys(lifecycleEnv).length > 0 ? lifecycleEnv : void 0,
1137
+ onProcess: (pid) => {
1138
+ activeProviderPids.set(issue.id, pid);
1139
+ },
1140
+ shouldAbort: () => userKilledSet.has(issue.id) || userSkippedSet.has(issue.id),
1141
+ ...extra
1142
+ };
1143
+ }
1144
+ async function startInfra(issueId, cwd, config) {
1145
+ const infra = discoverInfra(cwd);
1146
+ if (!infra) return {};
1147
+ startSpinner(`${issueId} \u2014 starting resources...`);
1148
+ const started = await runLifecycle(infra, config.lifecycle, cwd);
1149
+ stopSpinner();
1150
+ if (!started.success) {
1151
+ warn(
1152
+ `Lifecycle startup failed for ${issueId}. Continuing with manual resource instructions.`
1153
+ );
1154
+ }
1155
+ return started.env;
1156
+ }
1157
+ function startReconciliationMonitor(source, issueId, config) {
1158
+ return source && config.reconciliation?.enabled ? startReconciliation(source, issueId, config.reconciliation, config.source_config) : null;
1159
+ }
1160
+ async function runProofOfWork(config, issue, models, cwd, logFile, workspace, lifecycleEnv, result) {
1161
+ if (!isProofOfWorkEnabled(config.proof_of_work)) return {};
1162
+ const pow = config.proof_of_work;
1163
+ let retriesLeft = pow?.max_retries ?? 2;
1164
+ while (true) {
1165
+ if (reconciliationSet.has(issue.id)) {
1166
+ reconciliationSet.delete(issue.id);
1167
+ warn(`Issue ${issue.id} was closed/cancelled during validation. Skipping.`);
1168
+ return { reconciled: true };
1169
+ }
1170
+ startSpinner(`${issue.id} \u2014 validating...`);
1171
+ const results = await runValidationCommands(pow?.commands ?? [], cwd, pow?.timeout);
1172
+ stopSpinner();
1173
+ const failures = results.filter((r) => !r.success);
1174
+ if (failures.length === 0) {
1175
+ ok(`All validation checks passed for ${issue.id}`);
1176
+ return { results };
1177
+ }
1178
+ if (retriesLeft <= 0) {
1179
+ error(
1180
+ `Validation failed after max retries for ${issue.id}. Creating PR with failures noted.`
1181
+ );
1182
+ return { results };
1183
+ }
1184
+ retriesLeft--;
1185
+ warn(
1186
+ `Validation failed for ${issue.id} (${retriesLeft} retries left). Re-invoking agent...`
1187
+ );
1188
+ const recoveryPrompt = buildValidationRecoveryPrompt(issue, failures);
1189
+ startSpinner(`${issue.id} \u2014 fixing validation failures...`);
1190
+ const recoveryResult = await runWithFallback(
1191
+ models,
1192
+ recoveryPrompt,
1193
+ buildRunOptions(config, issue, cwd, logFile, workspace, lifecycleEnv)
1194
+ );
1195
+ stopSpinner();
1196
+ if (!recoveryResult.success) {
1197
+ error(`Validation recovery failed for ${issue.id}. Creating PR with failures noted.`);
1198
+ return { results };
1199
+ }
1200
+ }
1201
+ }
1202
+
1203
+ // src/loop/recovery.ts
1204
+ async function injectRejectedPrFeedback(workspace, issueId, prUrls) {
1205
+ for (const prUrl of prUrls) {
1206
+ try {
1207
+ const feedback = await fetchPrFeedback(prUrl);
1208
+ if (feedback.state !== "closed") continue;
1209
+ const hasAnyFeedback = feedback.reviews.length > 0 || feedback.comments.length > 0;
1210
+ if (!hasAnyFeedback) continue;
1211
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1212
+ const entryText = formatPrFeedbackEntry(feedback, issueId, date);
1213
+ appendRawEntry(workspace, entryText);
1214
+ ok(`Injected PR review feedback for ${issueId} into guardrails`);
1215
+ } catch (err) {
1216
+ warn(`Could not check PR feedback for ${issueId}: ${formatError(err)}`);
1217
+ }
1218
+ }
1219
+ clearPrUrl(workspace, issueId);
1220
+ }
1221
+ async function recoverOrphanIssues(source, config) {
1222
+ if (!config.source_config.in_progress) return;
1223
+ if (config.source_config.in_progress === config.source_config.pick_from) return;
1224
+ const orphanConfig = {
1225
+ ...config.source_config,
1226
+ pick_from: config.source_config.in_progress
1227
+ };
1228
+ while (true) {
1229
+ let orphan;
1230
+ try {
1231
+ orphan = await source.fetchNextIssue(orphanConfig);
1232
+ } catch (err) {
1233
+ warn(`Failed to check for orphan issues: ${formatError(err)}`);
1234
+ break;
1235
+ }
1236
+ if (!orphan) break;
1237
+ warn(
1238
+ `Found orphan issue ${orphan.id} stuck in "${config.source_config.in_progress}". Reverting to "${config.source_config.pick_from}".`
1239
+ );
1240
+ try {
1241
+ await source.updateStatus(orphan.id, config.source_config.pick_from, config.source_config);
1242
+ ok(`Recovered orphan ${orphan.id}`);
1243
+ } catch (err) {
1244
+ error(`Failed to recover orphan ${orphan.id}: ${formatError(err)}`);
1245
+ break;
1246
+ }
1247
+ }
1248
+ }
1249
+
1250
+ // src/loop/result.ts
1251
+ import { resolve as resolve4 } from "path";
1252
+ async function handleSessionResult(sessionResult, issue, previousStatus, source, config, opts) {
1253
+ if (!sessionResult.success) {
1254
+ if (userKilledSet.has(issue.id)) {
1255
+ providerPausedSet.delete(issue.id);
1256
+ warn(`Issue ${issue.id} killed by user.`);
1257
+ try {
1258
+ await source.updateStatus(issue.id, previousStatus, config.source_config);
1259
+ ok(`Reverted ${issue.id} to "${previousStatus}"`);
1260
+ } catch (err) {
1261
+ error(`Failed to revert status: ${formatError(err)}`);
1262
+ }
1263
+ kanbanEmitter.emit("issue:killed", issue.id);
1264
+ activeCleanups.delete(issue.id);
1265
+ if (config.bell !== false) {
1266
+ const { notify: notify2 } = await import("./terminal-X7O55P6E.js");
1267
+ notify2();
1268
+ }
1269
+ return false;
1270
+ }
1271
+ if (userSkippedSet.has(issue.id)) {
1272
+ providerPausedSet.delete(issue.id);
1273
+ warn(`Issue ${issue.id} skipped by user.`);
1274
+ try {
1275
+ await source.updateStatus(issue.id, previousStatus, config.source_config);
1276
+ ok(`Reverted ${issue.id} to "${previousStatus}"`);
1277
+ } catch (err) {
1278
+ error(`Failed to revert status: ${formatError(err)}`);
1279
+ }
1280
+ kanbanEmitter.emit("issue:skipped", issue.id);
1281
+ activeCleanups.delete(issue.id);
1282
+ if (config.bell !== false) {
1283
+ const { notify: notify2 } = await import("./terminal-X7O55P6E.js");
1284
+ notify2();
1285
+ }
1286
+ return false;
1287
+ }
1288
+ if (reconciliationSet.has(issue.id)) {
1289
+ reconciliationSet.delete(issue.id);
1290
+ warn(`Issue ${issue.id} was reconciled (status changed externally). Not reverting.`);
1291
+ kanbanEmitter.emit("issue:skipped", issue.id);
1292
+ activeCleanups.delete(issue.id);
1293
+ return false;
1294
+ }
1295
+ error(`All models failed for ${issue.id}. Reverting to "${previousStatus}".`);
1296
+ logAttemptHistory(sessionResult);
1297
+ try {
1298
+ await source.updateStatus(issue.id, previousStatus, config.source_config);
1299
+ ok(`Reverted ${issue.id} to "${previousStatus}"`);
1300
+ kanbanEmitter.emit("issue:reverted", issue.id);
1301
+ } catch (err) {
1302
+ error(`Failed to revert status: ${formatError(err)}`);
1303
+ }
1304
+ if (!opts.issueId) {
1305
+ const labelToRemove = getRemoveLabel(config.source_config);
1306
+ if (labelToRemove) {
1307
+ try {
1308
+ await source.removeLabel(issue.id, labelToRemove);
1309
+ ok(`Removed label "${labelToRemove}" from ${issue.id} to prevent retry`);
1310
+ } catch (err) {
1311
+ warn(`Failed to remove label: ${formatError(err)}`);
1312
+ }
1313
+ }
1314
+ }
1315
+ activeCleanups.delete(issue.id);
1316
+ return false;
1317
+ }
1318
+ ok(`Completed with provider: ${sessionResult.providerUsed}`);
1319
+ if (sessionResult.prUrls.length === 0) {
1320
+ warn(
1321
+ `Session succeeded but no PRs created for ${issue.id}. Reverting to "${previousStatus}".`
1322
+ );
1323
+ try {
1324
+ await source.updateStatus(issue.id, previousStatus, config.source_config);
1325
+ ok(`Reverted ${issue.id} to "${previousStatus}"`);
1326
+ kanbanEmitter.emit("issue:reverted", issue.id);
1327
+ } catch (err) {
1328
+ error(`Failed to revert status: ${formatError(err)}`);
1329
+ }
1330
+ activeCleanups.delete(issue.id);
1331
+ return false;
1332
+ }
1333
+ const workspace = resolve4(config.workspace);
1334
+ for (const prUrl of sessionResult.prUrls) {
1335
+ try {
1336
+ await source.attachPullRequest(issue.id, prUrl);
1337
+ ok(`Attached PR to ${issue.id}`);
1338
+ } catch (err) {
1339
+ warn(`Failed to attach PR: ${formatError(err)}`);
1340
+ }
1341
+ }
1342
+ try {
1343
+ storePrUrls(workspace, issue.id, sessionResult.prUrls);
1344
+ } catch {
1345
+ }
1346
+ kanbanEmitter.emit("issue:done", issue.id, sessionResult.prUrls);
1347
+ try {
1348
+ const doneStatus = config.source_config.done;
1349
+ const labelToRemove = opts.issueId ? void 0 : getRemoveLabel(config.source_config);
1350
+ await source.completeIssue(issue.id, doneStatus, labelToRemove, config.source_config);
1351
+ ok(`Updated ${issue.id} status to "${doneStatus}"`);
1352
+ if (labelToRemove) {
1353
+ ok(`Removed label "${labelToRemove}" from ${issue.id}`);
1354
+ }
1355
+ } catch (err) {
1356
+ error(`Failed to complete issue: ${formatError(err)}`);
1357
+ }
1358
+ activeCleanups.delete(issue.id);
1359
+ stopSpinner(`\u2713 Lisa \u2014 ${issue.id} \u2014 PR created`);
1360
+ return true;
1361
+ }
1362
+ function logAttemptHistory(result) {
1363
+ for (const [i, attempt] of result.fallback.attempts.entries()) {
1364
+ const status = attempt.success ? "OK" : "FAILED";
1365
+ const error2 = attempt.error ? ` \u2014 ${attempt.error}` : "";
1366
+ const duration = attempt.duration > 0 ? ` (${Math.round(attempt.duration / 1e3)}s)` : "";
1367
+ warn(` Attempt ${i + 1}: ${attempt.provider} ${status}${error2}${duration}`);
1368
+ }
1369
+ }
1370
+
1371
+ // src/loop/worktree-session.ts
1372
+ import { resolve as resolve6 } from "path";
1373
+ import { execa as execa3 } from "execa";
1374
+
1375
+ // src/git/worktree.ts
1376
+ import { appendFileSync as appendFileSync2, existsSync as existsSync4, readFileSync as readFileSync4, rmSync } from "fs";
1377
+ import { join as join2 } from "path";
1378
+ import { execa as execa2 } from "execa";
1379
+ var WORKTREES_DIR = ".worktrees";
1380
+ function generateBranchName(issueId, title) {
1381
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").substring(0, 40).replace(/^-|-$/g, "");
1382
+ const safeId = issueId.toLowerCase().replace(/[^a-z0-9-]/g, "-");
1383
+ return `feat/${safeId}-${slug}`;
1384
+ }
1385
+ async function cleanupOrphanedWorktree(repoRoot, branchName) {
1386
+ const { stdout: branchList } = await execa2("git", ["branch", "--list", branchName], {
1387
+ cwd: repoRoot,
1388
+ reject: false
1389
+ });
1390
+ if (!branchList.trim()) {
1391
+ return false;
1392
+ }
1393
+ const worktreePath = join2(repoRoot, WORKTREES_DIR, branchName);
1394
+ const { stdout: worktreeList } = await execa2("git", ["worktree", "list", "--porcelain"], {
1395
+ cwd: repoRoot,
1396
+ reject: false
1397
+ });
1398
+ if (worktreeList.includes(worktreePath)) {
1399
+ await execa2("git", ["worktree", "remove", worktreePath, "--force"], { cwd: repoRoot });
1400
+ await execa2("git", ["worktree", "prune"], { cwd: repoRoot });
1401
+ }
1402
+ await execa2("git", ["branch", "-D", branchName], { cwd: repoRoot });
1403
+ return true;
1404
+ }
1405
+ async function createWorktree(repoRoot, branchName, baseBranch) {
1406
+ const worktreePath = join2(repoRoot, WORKTREES_DIR, branchName);
1407
+ await cleanupOrphanedWorktree(repoRoot, branchName);
1408
+ if (existsSync4(worktreePath)) {
1409
+ await execa2("git", ["worktree", "remove", worktreePath, "--force"], {
1410
+ cwd: repoRoot,
1411
+ reject: false
1412
+ });
1413
+ await execa2("git", ["worktree", "prune"], { cwd: repoRoot, reject: false });
1414
+ if (existsSync4(worktreePath)) {
1415
+ rmSync(worktreePath, { recursive: true, force: true });
1416
+ }
1417
+ }
1418
+ await execa2("git", ["fetch", "origin", baseBranch], { cwd: repoRoot });
1419
+ await execa2("git", ["worktree", "add", "-b", branchName, worktreePath, `origin/${baseBranch}`], {
1420
+ cwd: repoRoot
1421
+ });
1422
+ return worktreePath;
1423
+ }
1424
+ async function removeWorktree(repoRoot, worktreePath) {
1425
+ await execa2("git", ["worktree", "remove", worktreePath, "--force"], {
1426
+ cwd: repoRoot
1427
+ });
1428
+ await execa2("git", ["worktree", "prune"], { cwd: repoRoot });
1429
+ }
1430
+ function ensureWorktreeGitignore(repoRoot) {
1431
+ const gitignorePath = join2(repoRoot, ".gitignore");
1432
+ if (!existsSync4(gitignorePath)) {
1433
+ appendFileSync2(gitignorePath, `${WORKTREES_DIR}
1434
+ `);
1435
+ return;
1436
+ }
1437
+ const content = readFileSync4(gitignorePath, "utf-8");
1438
+ if (!content.split("\n").some((line) => line.trim() === WORKTREES_DIR)) {
1439
+ const separator = content.endsWith("\n") ? "" : "\n";
1440
+ appendFileSync2(gitignorePath, `${separator}${WORKTREES_DIR}
1441
+ `);
1442
+ }
1443
+ }
1444
+ async function findBranchByIssueId(repoRoot, issueId) {
1445
+ const needle = issueId.toLowerCase();
1446
+ const { stdout: local } = await execa2(
1447
+ "git",
1448
+ ["for-each-ref", "--sort=-committerdate", "--format=%(refname:short)", "refs/heads/"],
1449
+ { cwd: repoRoot }
1450
+ );
1451
+ const localMatch = local.split("\n").map((b) => b.trim()).filter(Boolean).find((b) => b.toLowerCase().includes(needle));
1452
+ if (localMatch) return localMatch;
1453
+ const { stdout: remote } = await execa2(
1454
+ "git",
1455
+ ["for-each-ref", "--sort=-committerdate", "--format=%(refname:short)", "refs/remotes/origin/"],
1456
+ { cwd: repoRoot }
1457
+ );
1458
+ const remoteMatch = remote.split("\n").map((b) => b.trim()).filter(Boolean).find((b) => b.toLowerCase().includes(needle));
1459
+ if (remoteMatch) return remoteMatch.replace("origin/", "");
1460
+ const { stdout: lsRemote } = await execa2("git", ["ls-remote", "--heads", "origin"], {
1461
+ cwd: repoRoot
1462
+ });
1463
+ 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));
1464
+ if (lsMatch) return lsMatch;
1465
+ return void 0;
1466
+ }
1467
+ function determineRepoPath(repos, issue, workspace) {
1468
+ if (repos.length === 0) return void 0;
1469
+ if (issue.repo) {
1470
+ const match = repos.find((r) => r.name === issue.repo);
1471
+ if (match) return join2(workspace, match.path);
1472
+ }
1473
+ for (const r of repos) {
1474
+ if (r.match && issue.title.startsWith(r.match)) {
1475
+ return join2(workspace, r.path);
1476
+ }
1477
+ }
1478
+ const first = repos[0];
1479
+ return first ? join2(workspace, first.path) : void 0;
1480
+ }
1481
+ async function hasCodeChanges(repoPath, baseBranch) {
1482
+ try {
1483
+ const { stdout } = await execa2("git", ["diff", "--stat", `${baseBranch}..HEAD`], {
1484
+ cwd: repoPath,
1485
+ reject: false
1486
+ });
1487
+ const trimmed = stdout.trim();
1488
+ return trimmed.length > 0;
1489
+ } catch {
1490
+ return false;
1491
+ }
1492
+ }
1493
+ async function getDiffStat(repoPath, baseBranch) {
1494
+ try {
1495
+ const { stdout } = await execa2("git", ["diff", "--stat", `${baseBranch}..HEAD`], {
1496
+ cwd: repoPath,
1497
+ reject: false
1498
+ });
1499
+ return stdout.trim();
1500
+ } catch {
1501
+ return "";
1502
+ }
1503
+ }
1504
+
1505
+ // src/session/hooks.ts
1506
+ import { spawn as spawn2 } from "child_process";
1507
+ var DEFAULT_HOOK_TIMEOUT = 6e4;
1508
+ function runHook(hookName, command, cwd, env, timeoutMs) {
1509
+ const timeout = timeoutMs ?? DEFAULT_HOOK_TIMEOUT;
1510
+ return new Promise((resolve12) => {
1511
+ const proc = spawn2("sh", ["-c", command], {
1512
+ cwd,
1513
+ stdio: ["ignore", "pipe", "pipe"],
1514
+ env: { ...process.env, ...env }
1515
+ });
1516
+ let output = "";
1517
+ let killed = false;
1518
+ const timer = setTimeout(() => {
1519
+ killed = true;
1520
+ proc.kill("SIGTERM");
1521
+ setTimeout(() => {
1522
+ try {
1523
+ proc.kill("SIGKILL");
1524
+ } catch {
1525
+ }
1526
+ }, 1e3);
1527
+ }, timeout);
1528
+ proc.stdout?.on("data", (data) => {
1529
+ output += data.toString();
1530
+ });
1531
+ proc.stderr?.on("data", (data) => {
1532
+ output += data.toString();
1533
+ });
1534
+ proc.on("close", (code) => {
1535
+ clearTimeout(timer);
1536
+ if (killed) {
1537
+ resolve12({
1538
+ success: false,
1539
+ output: `${output}
1540
+ [lisa-hooks] Hook "${hookName}" timed out after ${timeout}ms`
1541
+ });
1542
+ } else {
1543
+ resolve12({ success: code === 0, output });
1544
+ }
1545
+ });
1546
+ proc.on("error", (err) => {
1547
+ clearTimeout(timer);
1548
+ resolve12({ success: false, output: `Hook spawn error: ${err.message}` });
1549
+ });
1550
+ });
1551
+ }
1552
+ async function executeHook(hookName, hooks, cwd, issueEnv) {
1553
+ if (!hooks) return true;
1554
+ const command = hooks[hookName];
1555
+ if (!command) return true;
1556
+ const critical = hookName === "before_run" || hookName === "after_create";
1557
+ log(`Running hook "${hookName}": ${command}`);
1558
+ const result = await runHook(hookName, command, cwd, issueEnv, hooks.timeout);
1559
+ if (!result.success) {
1560
+ const trimmed = result.output.trim().slice(-500);
1561
+ if (critical) {
1562
+ error(`Hook "${hookName}" failed:
1563
+ ${trimmed}`);
1564
+ return false;
1565
+ }
1566
+ warn(`Hook "${hookName}" failed (non-critical):
1567
+ ${trimmed}`);
1568
+ }
1569
+ return true;
1570
+ }
1571
+ function buildHookEnv(issueId, issueTitle, branch, workspace) {
1572
+ return {
1573
+ LISA_ISSUE_ID: issueId,
1574
+ LISA_ISSUE_TITLE: issueTitle,
1575
+ LISA_BRANCH: branch,
1576
+ LISA_WORKSPACE: workspace
1577
+ };
1578
+ }
1579
+
1580
+ // src/loop/manifest.ts
1581
+ import { existsSync as existsSync5, readdirSync, readFileSync as readFileSync5, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
1582
+ import { dirname as dirname2 } from "path";
1583
+ function readLisaManifest(cwd, issueId) {
1584
+ const manifestPath = getManifestPath(cwd, issueId);
1585
+ if (!existsSync5(manifestPath)) return null;
1586
+ try {
1587
+ return JSON.parse(readFileSync5(manifestPath, "utf-8").trim());
1588
+ } catch {
1589
+ warn(`Failed to parse manifest at ${manifestPath} \u2014 agent may not have written it correctly`);
1590
+ return null;
1591
+ }
1592
+ }
1593
+ function cleanupManifest(cwd, issueId) {
1594
+ try {
1595
+ unlinkSync2(getManifestPath(cwd, issueId));
1596
+ } catch {
1597
+ }
1598
+ }
1599
+ function readManifestFile(filePath) {
1600
+ if (!existsSync5(filePath)) return null;
1601
+ try {
1602
+ return JSON.parse(readFileSync5(filePath, "utf-8").trim());
1603
+ } catch {
1604
+ return null;
1605
+ }
1606
+ }
1607
+ function extractPrUrlFromOutput(output) {
1608
+ const patterns = [
1609
+ /https?:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/,
1610
+ /https?:\/\/[^/]*gitlab[^/]*\/[^/].+?\/-\/merge_requests\/\d+/,
1611
+ /https?:\/\/bitbucket\.org\/[^/]+\/[^/]+\/pull-requests\/\d+/
1612
+ ];
1613
+ for (const pattern of patterns) {
1614
+ const match = output.match(pattern);
1615
+ if (match) return match[0];
1616
+ }
1617
+ return null;
1618
+ }
1619
+ function readPlanFile(filePath) {
1620
+ if (!existsSync5(filePath)) return null;
1621
+ try {
1622
+ return JSON.parse(readFileSync5(filePath, "utf-8").trim());
1623
+ } catch {
1624
+ return null;
1625
+ }
1626
+ }
1627
+ function cleanupPlanFile(filePath) {
1628
+ try {
1629
+ unlinkSync2(filePath);
1630
+ } catch {
1631
+ }
1632
+ try {
1633
+ const dir = dirname2(filePath);
1634
+ if (existsSync5(dir) && readdirSync(dir).length === 0) {
1635
+ rmdirSync(dir);
1636
+ }
1637
+ } catch {
1638
+ }
1639
+ }
1640
+
1641
+ // src/loop/multi-repo-session.ts
1642
+ import { appendFileSync as appendFileSync3 } from "fs";
1643
+ import { resolve as resolve5 } from "path";
1644
+ async function runWorktreeMultiRepoSession(config, issue, logFile, session, models, source) {
1645
+ const workspace = resolve5(config.workspace);
1646
+ const planPath = getPlanPath(workspace, issue.id);
1647
+ initLogFile(logFile);
1648
+ kanbanEmitter.emit("issue:log-file", issue.id, logFile);
1649
+ const cachedPlan = readPlanFile(planPath);
1650
+ let planProviderUsed = defaultProvider(models);
1651
+ let planFallback = null;
1652
+ let sortedSteps;
1653
+ if (cachedPlan?.steps && cachedPlan.steps.length > 0) {
1654
+ sortedSteps = [...cachedPlan.steps].sort((a, b) => a.order - b.order);
1655
+ ok(
1656
+ `Reusing cached plan for ${issue.id}: ${sortedSteps.length} step(s) \u2014 ${sortedSteps.map((s) => s.repoPath).join(" \u2192 ")}`
1657
+ );
1658
+ } else {
1659
+ startSpinner(`${issue.id} \u2014 analyzing issue...`);
1660
+ log(`Multi-repo planning phase for ${issue.id}`);
1661
+ const globalContextMd = readContext(workspace);
1662
+ const planPrompt = buildPlanningPrompt(issue, config, planPath, globalContextMd);
1663
+ const planResult = await runWithFallback(
1664
+ models,
1665
+ planPrompt,
1666
+ buildRunOptions(
1667
+ config,
1668
+ issue,
1669
+ workspace,
1670
+ logFile,
1671
+ workspace,
1672
+ {},
1673
+ {
1674
+ earlySuccess: () => {
1675
+ const p = readPlanFile(planPath);
1676
+ return !!(p?.steps && p.steps.length > 0);
1677
+ }
1678
+ }
1679
+ )
1680
+ );
1681
+ stopSpinner();
1682
+ planProviderUsed = planResult.providerUsed;
1683
+ planFallback = planResult;
1684
+ try {
1685
+ appendFileSync3(
1686
+ logFile,
1687
+ `
1688
+ ${"=".repeat(80)}
1689
+ Planning phase \u2014 provider: ${planResult.providerUsed}
1690
+ ${planResult.output}
1691
+ `
1692
+ );
1693
+ } catch {
1694
+ }
1695
+ const plan = readPlanFile(planPath);
1696
+ if (!plan?.steps || plan.steps.length === 0) {
1697
+ if (!planResult.success) {
1698
+ error(`Planning phase failed for ${issue.id}. Check ${logFile}`);
1699
+ } else {
1700
+ error(`Agent did not produce a valid execution plan for ${issue.id}. Aborting.`);
1701
+ }
1702
+ cleanupPlanFile(planPath);
1703
+ activeProviderPids.delete(issue.id);
1704
+ return {
1705
+ success: false,
1706
+ providerUsed: planResult.providerUsed,
1707
+ prUrls: [],
1708
+ fallback: planResult
1709
+ };
1710
+ }
1711
+ if (!planResult.success) {
1712
+ warn(
1713
+ `Planning provider exited with error but plan file was written for ${issue.id}. Proceeding.`
1714
+ );
1715
+ }
1716
+ sortedSteps = [...plan.steps].sort((a, b) => a.order - b.order);
1717
+ ok(
1718
+ `Plan produced ${sortedSteps.length} step(s): ${sortedSteps.map((s) => s.repoPath).join(" \u2192 ")}`
1719
+ );
1720
+ }
1721
+ const defaultFallback = planFallback ?? {
1722
+ success: true,
1723
+ output: "",
1724
+ duration: 0,
1725
+ providerUsed: planProviderUsed,
1726
+ attempts: []
1727
+ };
1728
+ const reconciliation = source && config.reconciliation?.enabled ? startReconciliation(source, issue.id, config.reconciliation, config.source_config) : null;
1729
+ const prUrls = [];
1730
+ const previousResults = [];
1731
+ let lastFallback = defaultFallback;
1732
+ let lastProvider = planProviderUsed;
1733
+ for (const [i, step] of sortedSteps.entries()) {
1734
+ if (reconciliationSet.has(issue.id)) {
1735
+ reconciliation?.stop();
1736
+ reconciliationSet.delete(issue.id);
1737
+ warn(`Issue ${issue.id} was closed/cancelled externally. Skipping remaining steps.`);
1738
+ activeProviderPids.delete(issue.id);
1739
+ return { success: false, providerUsed: lastProvider, prUrls, fallback: lastFallback };
1740
+ }
1741
+ const stepNum = i + 1;
1742
+ const isLastStep = i === sortedSteps.length - 1;
1743
+ divider(stepNum);
1744
+ log(`Step ${stepNum}/${sortedSteps.length}: ${step.repoPath} \u2014 ${step.scope}`);
1745
+ const stepResult = await runMultiRepoStep(
1746
+ config,
1747
+ issue,
1748
+ step,
1749
+ previousResults,
1750
+ logFile,
1751
+ models,
1752
+ stepNum,
1753
+ isLastStep
1754
+ );
1755
+ lastFallback = stepResult.fallback;
1756
+ lastProvider = stepResult.providerUsed;
1757
+ if (!stepResult.success) {
1758
+ error(`Step ${stepNum} failed for ${step.repoPath}. Aborting remaining steps.`);
1759
+ activeProviderPids.delete(issue.id);
1760
+ return {
1761
+ success: false,
1762
+ providerUsed: lastProvider,
1763
+ prUrls,
1764
+ fallback: lastFallback
1765
+ };
1766
+ }
1767
+ if (stepResult.prUrl) {
1768
+ prUrls.push(stepResult.prUrl);
1769
+ }
1770
+ previousResults.push({
1771
+ repoPath: step.repoPath,
1772
+ branch: stepResult.branch,
1773
+ prUrl: stepResult.prUrl
1774
+ });
1775
+ }
1776
+ reconciliation?.stop();
1777
+ activeProviderPids.delete(issue.id);
1778
+ cleanupPlanFile(planPath);
1779
+ ok(`Session ${session} complete for ${issue.id} \u2014 ${prUrls.length} PR(s) created`);
1780
+ return { success: true, providerUsed: lastProvider, prUrls, fallback: lastFallback };
1781
+ }
1782
+ async function runMultiRepoStep(config, issue, step, previousResults, logFile, models, stepNum, isLastStep) {
1783
+ const repoPath = step.repoPath;
1784
+ const defaultBranch = resolveBaseBranch(config, repoPath);
1785
+ const branchName = generateBranchName(issue.id, issue.title);
1786
+ const baseBranch = issue.dependency?.branch ?? defaultBranch;
1787
+ const failResult = (providerUsed, fallback) => ({
1788
+ success: false,
1789
+ providerUsed,
1790
+ branch: branchName,
1791
+ fallback: fallback ?? { success: false, output: "", duration: 0, providerUsed, attempts: [] }
1792
+ });
1793
+ startSpinner(`${issue.id} step ${stepNum} \u2014 creating worktree...`);
1794
+ let worktreePath;
1795
+ try {
1796
+ worktreePath = await createWorktree(repoPath, branchName, baseBranch);
1797
+ } catch (err) {
1798
+ stopSpinner();
1799
+ error(`Failed to create worktree: ${formatError(err)}`);
1800
+ return failResult(defaultProvider(models));
1801
+ }
1802
+ stopSpinner();
1803
+ ok(`Worktree created at ${worktreePath}`);
1804
+ const hookEnv = buildHookEnv(issue.id, issue.title, branchName, worktreePath);
1805
+ if (!await executeHook("after_create", config.hooks, worktreePath, hookEnv)) {
1806
+ await cleanupWorktree(repoPath, worktreePath);
1807
+ return failResult(defaultProvider(models));
1808
+ }
1809
+ const testRunner = detectTestRunner(worktreePath);
1810
+ if (testRunner) log(`Detected test runner: ${testRunner}`);
1811
+ const pm = detectPackageManager(worktreePath);
1812
+ const projectContext = analyzeProject(worktreePath);
1813
+ const repoContextMd = readContext(repoPath);
1814
+ const infra = discoverInfra(worktreePath);
1815
+ let lifecycleEnv = {};
1816
+ if (infra) {
1817
+ startSpinner(`${issue.id} step ${stepNum} \u2014 starting resources...`);
1818
+ const started = await runLifecycle(infra, config.lifecycle, worktreePath);
1819
+ stopSpinner();
1820
+ if (!started.success) {
1821
+ warn(
1822
+ `Lifecycle startup failed for step ${stepNum}. Continuing with manual resource instructions.`
1823
+ );
1824
+ }
1825
+ lifecycleEnv = started.env;
1826
+ }
1827
+ if (!await executeHook("before_run", config.hooks, worktreePath, hookEnv)) {
1828
+ await cleanupWorktree(repoPath, worktreePath);
1829
+ return failResult(defaultProvider(models));
1830
+ }
1831
+ const workspace = resolve5(config.workspace);
1832
+ const manifestPath = getManifestPath(worktreePath, issue.id);
1833
+ const prompt = buildScopedImplementPrompt(
1834
+ issue,
1835
+ step,
1836
+ previousResults,
1837
+ testRunner,
1838
+ pm,
1839
+ isLastStep,
1840
+ defaultBranch,
1841
+ projectContext,
1842
+ manifestPath,
1843
+ worktreePath,
1844
+ config.platform,
1845
+ repoContextMd
1846
+ );
1847
+ startSpinner(`${issue.id} step ${stepNum} \u2014 implementing...`);
1848
+ const result = await runWithFallback(
1849
+ models,
1850
+ prompt,
1851
+ buildRunOptions(config, issue, worktreePath, logFile, workspace, lifecycleEnv)
1852
+ );
1853
+ stopSpinner();
1854
+ if (infra) await stopResources();
1855
+ try {
1856
+ appendFileSync3(
1857
+ logFile,
1858
+ `
1859
+ ${"=".repeat(80)}
1860
+ Step ${stepNum} \u2014 provider: ${result.providerUsed}
1861
+ ${result.output}
1862
+ `
1863
+ );
1864
+ } catch {
1865
+ }
1866
+ await executeHook("after_run", config.hooks, worktreePath, hookEnv);
1867
+ if (!result.success) {
1868
+ error(`Step ${stepNum} implementation failed. Check ${logFile}`);
1869
+ await executeHook("before_remove", config.hooks, worktreePath, hookEnv);
1870
+ await cleanupWorktree(repoPath, worktreePath);
1871
+ return { ...failResult(result.providerUsed, result), branch: branchName };
1872
+ }
1873
+ const manifest = readManifestFile(manifestPath);
1874
+ let prUrl = manifest?.prUrl;
1875
+ if (!prUrl) {
1876
+ const extractedUrl = extractPrUrlFromOutput(result.output);
1877
+ if (extractedUrl) {
1878
+ warn(
1879
+ `Manifest missing prUrl for step ${stepNum}, extracted from output: ${extractedUrl}`
1880
+ );
1881
+ prUrl = extractedUrl;
1882
+ } else {
1883
+ warn(
1884
+ `Agent completed with code changes but no PR for step ${stepNum}. Attempting continuation...`
1885
+ );
1886
+ const diffStat = await getDiffStat(worktreePath, baseBranch);
1887
+ const continuationPrompt = buildContinuationPrompt({
1888
+ issue: { id: issue.id, title: issue.title },
1889
+ diffStat,
1890
+ previousOutput: result.output,
1891
+ platform: config.platform,
1892
+ baseBranch,
1893
+ manifestPath
1894
+ });
1895
+ startSpinner(`${issue.id} step ${stepNum} \u2014 continuation...`);
1896
+ const contResult = await runWithFallback(
1897
+ models,
1898
+ continuationPrompt,
1899
+ buildRunOptions(config, issue, worktreePath, logFile, workspace, lifecycleEnv)
1900
+ );
1901
+ stopSpinner();
1902
+ try {
1903
+ appendFileSync3(
1904
+ logFile,
1905
+ `
1906
+ ${"=".repeat(80)}
1907
+ Step ${stepNum} continuation \u2014 provider: ${contResult.providerUsed}
1908
+ ${contResult.output}
1909
+ `
1910
+ );
1911
+ } catch {
1912
+ }
1913
+ if (contResult.success) {
1914
+ const contManifest = readManifestFile(manifestPath);
1915
+ prUrl = contManifest?.prUrl;
1916
+ if (!prUrl) {
1917
+ prUrl = extractPrUrlFromOutput(contResult.output) ?? void 0;
1918
+ }
1919
+ }
1920
+ if (!prUrl) {
1921
+ error(`Continuation also failed to produce PR for step ${stepNum}. Aborting.`);
1922
+ await cleanupWorktree(repoPath, worktreePath);
1923
+ return { ...failResult(result.providerUsed, result), branch: branchName };
1924
+ }
1925
+ }
1926
+ }
1927
+ await executeHook("before_remove", config.hooks, worktreePath, hookEnv);
1928
+ await cleanupWorktree(repoPath, worktreePath);
1929
+ await appendPlatformAttribution(prUrl, result.providerUsed, config.platform);
1930
+ ok(`Step ${stepNum} complete: ${repoPath} \u2014 PR: ${prUrl}`);
1931
+ return {
1932
+ success: true,
1933
+ providerUsed: result.providerUsed,
1934
+ branch: manifest?.branch ?? branchName,
1935
+ prUrl,
1936
+ fallback: result
1937
+ };
1938
+ }
1939
+
1940
+ // src/loop/worktree-session.ts
1941
+ async function findWorktree(repoRoot, predicate) {
1942
+ try {
1943
+ const { stdout } = await execa3("git", ["worktree", "list", "--porcelain"], { cwd: repoRoot });
1944
+ const lines = stdout.split("\n");
1945
+ let currentPath = null;
1946
+ for (const line of lines) {
1947
+ if (line.startsWith("worktree ")) {
1948
+ currentPath = line.slice("worktree ".length);
1949
+ }
1950
+ if (line.startsWith("branch ") && predicate(line)) {
1951
+ return currentPath;
1952
+ }
1953
+ }
1954
+ return null;
1955
+ } catch {
1956
+ return null;
1957
+ }
1958
+ }
1959
+ async function findWorktreeForBranch(repoRoot, branch) {
1960
+ return findWorktree(repoRoot, (line) => line.endsWith(`/${branch}`));
1961
+ }
1962
+ async function findWorktreeByIssueId(repoRoot, issueId) {
1963
+ if (!issueId) return null;
1964
+ const needle = issueId.toLowerCase().replace(/[^a-z0-9-]/g, "-");
1965
+ return findWorktree(repoRoot, (line) => line.toLowerCase().includes(needle));
1966
+ }
1967
+ async function cleanupWorktree(repoRoot, worktreePath) {
1968
+ try {
1969
+ await removeWorktree(repoRoot, worktreePath);
1970
+ log("Worktree cleaned up.");
1971
+ } catch (err) {
1972
+ warn(`Failed to clean up worktree: ${formatError(err)}`);
1973
+ }
1974
+ }
1975
+ async function runWorktreeSession(config, issue, logFile, session, models, source) {
1976
+ if (config.repos.length > 1) {
1977
+ return runWorktreeMultiRepoSession(config, issue, logFile, session, models, source);
1978
+ }
1979
+ const workspace = resolve6(config.workspace);
1980
+ const repoPath = determineRepoPath(config.repos, issue, workspace) ?? workspace;
1981
+ const defaultBranch = resolveBaseBranch(config, repoPath);
1982
+ const primaryProvider = createProvider(models[0]?.provider ?? "claude");
1983
+ const useNativeWorktree = primaryProvider.supportsNativeWorktree === true;
1984
+ if (useNativeWorktree) {
1985
+ return runNativeWorktreeSession(
1986
+ config,
1987
+ issue,
1988
+ logFile,
1989
+ session,
1990
+ models,
1991
+ repoPath,
1992
+ defaultBranch,
1993
+ source
1994
+ );
1995
+ }
1996
+ return runManualWorktreeSession(
1997
+ config,
1998
+ issue,
1999
+ logFile,
2000
+ session,
2001
+ models,
2002
+ repoPath,
2003
+ defaultBranch,
2004
+ source
2005
+ );
2006
+ }
2007
+ async function runNativeWorktreeSession(config, issue, logFile, session, models, repoPath, _defaultBranch, source) {
2008
+ const testRunner = detectTestRunner(repoPath);
2009
+ if (testRunner) log(`Detected test runner: ${testRunner}`);
2010
+ const pm = detectPackageManager(repoPath);
2011
+ const projectContext = analyzeProject(repoPath);
2012
+ const repoContextMd = readContext(repoPath);
2013
+ const workspace = resolve6(config.workspace);
2014
+ const hookEnv = buildHookEnv(issue.id, issue.title, "", repoPath);
2015
+ const lifecycleEnv = await startInfra(issue.id, repoPath, config);
2016
+ cleanupManifest(workspace, issue.id);
2017
+ if (!await executeHook("before_run", config.hooks, repoPath, hookEnv)) {
2018
+ return hookFailure(defaultProvider(models), "before_run hook failed");
2019
+ }
2020
+ const prompt = buildNativeWorktreePrompt(
2021
+ issue,
2022
+ repoPath,
2023
+ testRunner,
2024
+ pm,
2025
+ _defaultBranch,
2026
+ projectContext,
2027
+ getManifestPath(workspace, issue.id),
2028
+ config.platform,
2029
+ repoContextMd
2030
+ );
2031
+ initLogFile(logFile);
2032
+ kanbanEmitter.emit("issue:log-file", issue.id, logFile);
2033
+ startSpinner(`${issue.id} \u2014 implementing (native worktree)...`);
2034
+ log(`Implementing with native worktree... (log: ${logFile})`);
2035
+ const reconciliation = startReconciliationMonitor(source, issue.id, config);
2036
+ const result = await runWithFallback(
2037
+ models,
2038
+ prompt,
2039
+ buildRunOptions(config, issue, repoPath, logFile, workspace, lifecycleEnv, {
2040
+ useNativeWorktree: true
2041
+ })
2042
+ );
2043
+ stopSpinner();
2044
+ reconciliation?.stop();
2045
+ if (Object.keys(lifecycleEnv).length > 0) await stopResources();
2046
+ await executeHook("after_run", config.hooks, repoPath, hookEnv);
2047
+ const reconciled = checkReconciliation(issue.id, result);
2048
+ if (reconciled) {
2049
+ cleanupManifest(workspace, issue.id);
2050
+ return reconciled;
2051
+ }
2052
+ appendSessionLog(logFile, result);
2053
+ if (!result.success) {
2054
+ error(`Session ${session} failed for ${issue.id}. Check ${logFile}`);
2055
+ cleanupManifest(workspace, issue.id);
2056
+ return failureResult(result.providerUsed, result);
2057
+ }
2058
+ const hasChanges = await hasCodeChanges(repoPath, _defaultBranch);
2059
+ if (!hasChanges) {
2060
+ cleanupManifest(workspace, issue.id);
2061
+ return emptyCommitFailure(result);
2062
+ }
2063
+ const manifest = readLisaManifest(workspace, issue.id);
2064
+ cleanupManifest(workspace, issue.id);
2065
+ let prUrl = manifest?.prUrl;
2066
+ if (!prUrl) {
2067
+ const extractedUrl = extractPrUrlFromOutput(result.output);
2068
+ if (extractedUrl) {
2069
+ warn(`Manifest missing prUrl for ${issue.id}, extracted from output: ${extractedUrl}`);
2070
+ prUrl = extractedUrl;
2071
+ } else {
2072
+ error(`Agent did not produce a manifest with prUrl for ${issue.id}. Aborting.`);
2073
+ const worktreePath2 = manifest?.branch ? await findWorktreeForBranch(repoPath, manifest.branch) : null;
2074
+ if (worktreePath2) await cleanupWorktree(repoPath, worktreePath2);
2075
+ return failureResult(result.providerUsed, result);
2076
+ }
2077
+ }
2078
+ let worktreePath = manifest?.branch ? await findWorktreeForBranch(repoPath, manifest.branch) : null;
2079
+ if (!worktreePath) {
2080
+ worktreePath = await findWorktreeByIssueId(repoPath, issue.id);
2081
+ }
2082
+ ok(`PR created by provider: ${prUrl}`);
2083
+ await appendPlatformAttribution(prUrl, result.providerUsed, config.platform);
2084
+ if (worktreePath) await cleanupWorktree(repoPath, worktreePath);
2085
+ ok(`Session ${session} complete for ${issue.id}`);
2086
+ return {
2087
+ success: true,
2088
+ providerUsed: result.providerUsed,
2089
+ prUrls: [prUrl],
2090
+ fallback: result
2091
+ };
2092
+ }
2093
+ async function runManualWorktreeSession(config, issue, logFile, session, models, repoPath, defaultBranch, source) {
2094
+ const branchName = generateBranchName(issue.id, issue.title);
2095
+ const baseBranch = issue.dependency?.branch ?? defaultBranch;
2096
+ startSpinner(`${issue.id} \u2014 creating worktree...`);
2097
+ log(`Creating worktree for ${branchName} (base: ${baseBranch})...`);
2098
+ let worktreePath;
2099
+ try {
2100
+ worktreePath = await createWorktree(repoPath, branchName, baseBranch);
2101
+ } catch (err) {
2102
+ stopSpinner();
2103
+ error(`Failed to create worktree: ${formatError(err)}`);
2104
+ return hookFailure(defaultProvider(models), "");
2105
+ }
2106
+ stopSpinner();
2107
+ ok(`Worktree created at ${worktreePath}`);
2108
+ const hookEnv = buildHookEnv(issue.id, issue.title, branchName, worktreePath);
2109
+ if (!await executeHook("after_create", config.hooks, worktreePath, hookEnv)) {
2110
+ await cleanupWorktree(repoPath, worktreePath);
2111
+ return hookFailure(defaultProvider(models), "after_create hook failed");
2112
+ }
2113
+ const testRunner = detectTestRunner(worktreePath);
2114
+ if (testRunner) {
2115
+ log(`Detected test runner: ${testRunner}`);
2116
+ }
2117
+ const pm = detectPackageManager(worktreePath);
2118
+ const projectContext = analyzeProject(worktreePath);
2119
+ const repoContextMd = readContext(repoPath);
2120
+ const lifecycleEnv = await startInfra(issue.id, worktreePath, config);
2121
+ const workspace = resolve6(config.workspace);
2122
+ const manifestPath = getManifestPath(worktreePath, issue.id);
2123
+ if (!await executeHook("before_run", config.hooks, worktreePath, hookEnv)) {
2124
+ await cleanupWorktree(repoPath, worktreePath);
2125
+ return hookFailure(defaultProvider(models), "before_run hook failed");
2126
+ }
2127
+ const prompt = buildImplementPrompt(
2128
+ issue,
2129
+ config,
2130
+ testRunner,
2131
+ pm,
2132
+ projectContext,
2133
+ worktreePath,
2134
+ manifestPath,
2135
+ repoContextMd
2136
+ );
2137
+ initLogFile(logFile);
2138
+ kanbanEmitter.emit("issue:log-file", issue.id, logFile);
2139
+ startSpinner(`${issue.id} \u2014 implementing...`);
2140
+ log(`Implementing in worktree... (log: ${logFile})`);
2141
+ const reconciliation = startReconciliationMonitor(source, issue.id, config);
2142
+ const result = await runWithFallback(
2143
+ models,
2144
+ prompt,
2145
+ buildRunOptions(config, issue, worktreePath, logFile, workspace, lifecycleEnv)
2146
+ );
2147
+ stopSpinner();
2148
+ reconciliation?.stop();
2149
+ if (Object.keys(lifecycleEnv).length > 0) await stopResources();
2150
+ await executeHook("after_run", config.hooks, worktreePath, hookEnv);
2151
+ const reconciled = checkReconciliation(issue.id, result);
2152
+ if (reconciled) {
2153
+ await cleanupWorktree(repoPath, worktreePath);
2154
+ return reconciled;
2155
+ }
2156
+ appendSessionLog(logFile, result);
2157
+ if (!result.success) {
2158
+ error(`Session ${session} failed for ${issue.id}. Check ${logFile}`);
2159
+ await executeHook("before_remove", config.hooks, worktreePath, hookEnv);
2160
+ await cleanupWorktree(repoPath, worktreePath);
2161
+ return failureResult(result.providerUsed, result);
2162
+ }
2163
+ const hasChanges = await hasCodeChanges(worktreePath, baseBranch);
2164
+ if (!hasChanges) {
2165
+ await executeHook("before_remove", config.hooks, worktreePath, hookEnv);
2166
+ await cleanupWorktree(repoPath, worktreePath);
2167
+ return emptyCommitFailure(result);
2168
+ }
2169
+ const powResult = await runProofOfWork(
2170
+ config,
2171
+ issue,
2172
+ models,
2173
+ worktreePath,
2174
+ logFile,
2175
+ workspace,
2176
+ lifecycleEnv,
2177
+ result
2178
+ );
2179
+ if (powResult.reconciled) {
2180
+ await cleanupWorktree(repoPath, worktreePath);
2181
+ return failureResult(result.providerUsed, result);
2182
+ }
2183
+ const validationResults = powResult.results;
2184
+ const manifest = readManifestFile(manifestPath);
2185
+ let prUrl = manifest?.prUrl;
2186
+ if (!prUrl) {
2187
+ const extractedUrl = extractPrUrlFromOutput(result.output);
2188
+ if (extractedUrl) {
2189
+ warn(`Manifest missing prUrl for ${issue.id}, extracted from output: ${extractedUrl}`);
2190
+ prUrl = extractedUrl;
2191
+ } else {
2192
+ warn(
2193
+ `Agent completed with code changes but no PR for ${issue.id}. Attempting continuation...`
2194
+ );
2195
+ const diffStat = await getDiffStat(worktreePath, baseBranch);
2196
+ const continuationPrompt = buildContinuationPrompt({
2197
+ issue: { id: issue.id, title: issue.title },
2198
+ diffStat,
2199
+ previousOutput: result.output,
2200
+ platform: config.platform,
2201
+ baseBranch,
2202
+ manifestPath
2203
+ });
2204
+ startSpinner(`${issue.id} \u2014 continuation...`);
2205
+ const contResult = await runWithFallback(
2206
+ models,
2207
+ continuationPrompt,
2208
+ buildRunOptions(config, issue, worktreePath, logFile, workspace, lifecycleEnv)
2209
+ );
2210
+ stopSpinner();
2211
+ appendSessionLog(logFile, contResult);
2212
+ if (contResult.success) {
2213
+ const contManifest = readManifestFile(manifestPath);
2214
+ prUrl = contManifest?.prUrl;
2215
+ if (!prUrl) {
2216
+ prUrl = extractPrUrlFromOutput(contResult.output) ?? void 0;
2217
+ }
2218
+ }
2219
+ if (!prUrl) {
2220
+ error(`Continuation also failed to produce PR for ${issue.id}. Aborting.`);
2221
+ await executeHook("before_remove", config.hooks, worktreePath, hookEnv);
2222
+ await cleanupWorktree(repoPath, worktreePath);
2223
+ return {
2224
+ success: false,
2225
+ providerUsed: result.providerUsed,
2226
+ prUrls: [],
2227
+ fallback: contResult
2228
+ };
2229
+ }
2230
+ }
2231
+ }
2232
+ ok(`PR created by provider: ${prUrl}`);
2233
+ await appendPlatformAttribution(prUrl, result.providerUsed, config.platform);
2234
+ if (validationResults) {
2235
+ await appendPlatformProofOfWork(prUrl, validationResults, config.platform);
2236
+ }
2237
+ await executeHook("before_remove", config.hooks, worktreePath, hookEnv);
2238
+ await cleanupWorktree(repoPath, worktreePath);
2239
+ ok(`Session ${session} complete for ${issue.id}`);
2240
+ return {
2241
+ success: true,
2242
+ providerUsed: result.providerUsed,
2243
+ prUrls: [prUrl],
2244
+ fallback: result
2245
+ };
2246
+ }
2247
+
2248
+ // src/loop/concurrent.ts
2249
+ async function runConcurrentLoop(config, source, models, workspace, opts) {
2250
+ const concurrency = opts.concurrency;
2251
+ const loopStart = Date.now();
2252
+ let completedCount = 0;
2253
+ let sessionCounter = 0;
2254
+ let noMoreIssues = false;
2255
+ let exhausted = false;
2256
+ let consecutiveFetchErrors = 0;
2257
+ const MAX_CONSECUTIVE_FETCH_ERRORS = 3;
2258
+ const activeWorkers = /* @__PURE__ */ new Map();
2259
+ const claimedIssueIds = /* @__PURE__ */ new Set();
2260
+ let consecutiveExhaustions = 0;
2261
+ const MAX_CONSECUTIVE_EXHAUSTIONS = 3;
2262
+ const processIssue = async (issue, session) => {
2263
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").substring(0, 19);
2264
+ const logFile = resolve7(getLogsDir(workspace), `session_${session}_${timestamp}.log`);
2265
+ ok(`Picked up: ${issue.id} \u2014 ${issue.title}`);
2266
+ const cachedPrUrls = loadPrUrls(workspace, issue.id);
2267
+ if (cachedPrUrls.length > 0) {
2268
+ await injectRejectedPrFeedback(workspace, issue.id, cachedPrUrls);
2269
+ }
2270
+ await checkIssueSpec(issue, config, source);
2271
+ kanbanEmitter.emit("issue:queued", issue);
2272
+ const previousStatus = config.source_config.pick_from;
2273
+ kanbanEmitter.emit("issue:started", issue.id);
2274
+ await moveToInProgress(issue, source, config);
2275
+ activeCleanups.set(issue.id, { previousStatus, source, sourceConfig: config.source_config });
2276
+ let sessionResult;
2277
+ try {
2278
+ sessionResult = await runWorktreeSession(config, issue, logFile, session, models, source);
2279
+ } catch (err) {
2280
+ error(`Unhandled error in session for ${issue.id}: ${formatError(err)}`);
2281
+ await revertIssueStatus(issue, source, config);
2282
+ activeCleanups.delete(issue.id);
2283
+ activeProviderPids.delete(issue.id);
2284
+ if (config.bell !== false) notify(2);
2285
+ return;
2286
+ }
2287
+ const completed = await handleSessionResult(
2288
+ sessionResult,
2289
+ issue,
2290
+ previousStatus,
2291
+ source,
2292
+ config,
2293
+ opts
2294
+ );
2295
+ if (completed) completedCount++;
2296
+ if (!sessionResult.success && !userKilledSet.has(issue.id) && !userSkippedSet.has(issue.id) && isCompleteProviderExhaustion(sessionResult.fallback.attempts)) {
2297
+ consecutiveExhaustions++;
2298
+ if (consecutiveExhaustions >= MAX_CONSECUTIVE_EXHAUSTIONS) {
2299
+ exhausted = true;
2300
+ error(
2301
+ "All providers exhausted due to infrastructure issues. Fix your provider configuration and restart lisa."
2302
+ );
2303
+ } else {
2304
+ warn(
2305
+ `Provider exhausted for ${issue.id} (${consecutiveExhaustions}/${MAX_CONSECUTIVE_EXHAUSTIONS}). Continuing with next issue.`
2306
+ );
2307
+ }
2308
+ } else if (sessionResult.success) {
2309
+ consecutiveExhaustions = 0;
2310
+ }
2311
+ userKilledSet.delete(issue.id);
2312
+ userSkippedSet.delete(issue.id);
2313
+ providerPausedSet.delete(issue.id);
2314
+ activeProviderPids.delete(issue.id);
2315
+ activeCleanups.delete(issue.id);
2316
+ claimedIssueIds.delete(issue.id);
2317
+ };
2318
+ while (!noMoreIssues && !exhausted) {
2319
+ await waitIfPaused();
2320
+ while (activeWorkers.size < concurrency && !noMoreIssues && !exhausted) {
2321
+ const tentativeSession = sessionCounter + 1;
2322
+ if (opts.limit > 0 && tentativeSession > opts.limit) {
2323
+ ok(`Reached limit of ${opts.limit} issues. Stopping.`);
2324
+ noMoreIssues = true;
2325
+ break;
2326
+ }
2327
+ let issue;
2328
+ try {
2329
+ issue = await source.fetchNextIssue(config.source_config);
2330
+ consecutiveFetchErrors = 0;
2331
+ } catch (err) {
2332
+ consecutiveFetchErrors++;
2333
+ error(`Failed to fetch issues: ${formatError(err)}`);
2334
+ if (consecutiveFetchErrors >= MAX_CONSECUTIVE_FETCH_ERRORS) {
2335
+ error(
2336
+ `Stopping after ${MAX_CONSECUTIVE_FETCH_ERRORS} consecutive fetch failures.`
2337
+ );
2338
+ noMoreIssues = true;
2339
+ } else {
2340
+ await sleep(config.loop.cooldown * 1e3);
2341
+ }
2342
+ break;
2343
+ }
2344
+ if (issue && claimedIssueIds.has(issue.id)) {
2345
+ log(`Issue ${issue.id} already claimed by another worker. Skipping.`);
2346
+ break;
2347
+ }
2348
+ if (!issue) {
2349
+ if (opts.watch) {
2350
+ if (activeWorkers.size === 0) {
2351
+ if (completedCount > 0) {
2352
+ ok(`All issues resolved. Prompting user to continue watching...`);
2353
+ kanbanEmitter.emit("work:watch-prompt");
2354
+ setTitle("Lisa \u2014 all resolved");
2355
+ await waitIfPaused();
2356
+ if (hasUserQuitFromWatchPrompt() || isShuttingDown()) {
2357
+ noMoreIssues = true;
2358
+ break;
2359
+ }
2360
+ kanbanEmitter.emit("work:watch-prompt-resumed");
2361
+ ok(`Resuming watch mode (polling every ${WATCH_POLL_INTERVAL_MS / 1e3}s)...`);
2362
+ }
2363
+ ok(
2364
+ `No issues ready. Watching for new issues (polling every ${WATCH_POLL_INTERVAL_MS / 1e3}s)...`
2365
+ );
2366
+ kanbanEmitter.emit("work:watching");
2367
+ setTitle("Lisa \u2014 watching...");
2368
+ await sleep(WATCH_POLL_INTERVAL_MS);
2369
+ kanbanEmitter.emit("work:watch-resume");
2370
+ }
2371
+ break;
2372
+ }
2373
+ if (activeWorkers.size === 0) {
2374
+ ok(`No more issues with label '${formatLabels(config.source_config)}'.`);
2375
+ await refreshKanban(source, config);
2376
+ kanbanEmitter.emit("work:empty");
2377
+ setTitle("Lisa \u2014 idle");
2378
+ await waitForResume();
2379
+ if (isShuttingDown() || hasUserQuitFromWatchPrompt()) {
2380
+ noMoreIssues = true;
2381
+ break;
2382
+ }
2383
+ kanbanEmitter.emit("work:resumed");
2384
+ }
2385
+ break;
2386
+ }
2387
+ sessionCounter = tentativeSession;
2388
+ const session = sessionCounter;
2389
+ claimedIssueIds.add(issue.id);
2390
+ const promise = processIssue(issue, session).finally(() => {
2391
+ activeWorkers.delete(issue.id);
2392
+ });
2393
+ activeWorkers.set(issue.id, promise);
2394
+ }
2395
+ if (activeWorkers.size === 0 && !opts.watch) break;
2396
+ if (activeWorkers.size === 0) continue;
2397
+ await Promise.race([...activeWorkers.values()]);
2398
+ if (!noMoreIssues && !exhausted && activeWorkers.size < concurrency) {
2399
+ await sleep(1e3);
2400
+ }
2401
+ }
2402
+ if (activeWorkers.size > 0) {
2403
+ log(`Waiting for ${activeWorkers.size} active worker(s) to finish...`);
2404
+ await Promise.allSettled([...activeWorkers.values()]);
2405
+ }
2406
+ if (completedCount > 0) {
2407
+ await checkoutBaseBranches(config, workspace);
2408
+ kanbanEmitter.emit("work:complete", {
2409
+ total: completedCount,
2410
+ duration: Date.now() - loopStart
2411
+ });
2412
+ }
2413
+ resetTitle();
2414
+ ok(`lisa finished. ${sessionCounter} session(s) run, ${completedCount} completed.`);
2415
+ }
2416
+
2417
+ // src/loop/context-generation.ts
2418
+ import { resolve as resolve8 } from "path";
2419
+ function buildContextGenerationPrompt(repoPath, outputPath) {
2420
+ return `You are a project analyst. Your job is to document this repository so that future autonomous agents can work in it correctly.
2421
+
2422
+ Repository path: \`${repoPath}\`
2423
+
2424
+ Read the project files and write \`${outputPath}\` with a concise markdown document covering:
2425
+
2426
+ 1. **Stack & Tools**: tools detected in this project and the EXACT commands to use (e.g., if package.json has a \`generate\` script, say "run \`pnpm run generate\` \u2014 not \`npx orval\` directly")
2427
+ 2. **File conventions**: where generated files live, migration naming patterns, any naming conventions observed from existing files
2428
+ 3. **Constraints**: anything an agent should NOT do (e.g., "do not run migrations manually \u2014 always use the \`db:push\` script")
2429
+
2430
+ Rules:
2431
+ - Be concise \u2014 max 300 words. This is injected into every agent prompt.
2432
+ - Write only what is non-obvious. Do NOT explain what tools are \u2014 only how they are used in THIS project.
2433
+ - If nothing non-obvious exists (e.g., a plain Node.js project with no special tooling), write a single line: "No special tooling conventions."
2434
+ - Do NOT create branches, commit, or make any changes other than writing \`${outputPath}\`.`;
2435
+ }
2436
+ function buildGlobalContextGenerationPrompt(repos, outputPath) {
2437
+ const repoList = repos.map((r) => `- **${r.name}**: \`${r.path}\``).join("\n");
2438
+ return `You are a project analyst for a multi-repo workspace. Your job is to document the relationships between repositories so that future autonomous agents can coordinate work across them correctly.
2439
+
2440
+ Repositories:
2441
+ ${repoList}
2442
+
2443
+ Read the relevant config files in each repository and write \`${outputPath}\` with a concise markdown document covering:
2444
+
2445
+ 1. **Repo relationships**: how repos depend on each other (e.g., "frontend reads backend OpenAPI spec from http://localhost:3000/openapi.json")
2446
+ 2. **Execution ordering rules**: which repo must be implemented first when multiple repos are affected (e.g., "backend changes must precede frontend API client regeneration")
2447
+ 3. **Shared conventions**: anything that applies across all repos
2448
+
2449
+ Rules:
2450
+ - Be concise \u2014 max 200 words.
2451
+ - Document only the relationship and ordering information. Repo-specific tooling goes in each repo's own context.md.
2452
+ - If repos are independent (no API sharing, no codegen), write: "Repos are independent \u2014 no cross-repo execution ordering constraints."
2453
+ - Do NOT create branches, commit, or make any changes other than writing \`${outputPath}\`.`;
2454
+ }
2455
+ async function generateRepoContext(repoPath, models, logFile, guardrailsDir) {
2456
+ if (contextExists(repoPath)) return;
2457
+ log(`Generating context for ${repoPath}...`);
2458
+ const outputPath = getContextPath(repoPath);
2459
+ const prompt = buildContextGenerationPrompt(repoPath, outputPath);
2460
+ try {
2461
+ await runWithFallback(models, prompt, {
2462
+ logFile,
2463
+ cwd: repoPath,
2464
+ guardrailsDir,
2465
+ issueId: "__context_gen__"
2466
+ });
2467
+ ok(`Context generated: ${outputPath}`);
2468
+ } catch (err) {
2469
+ warn(
2470
+ `Context generation failed for ${repoPath}: ${formatError(err)}. Proceeding without context.md.`
2471
+ );
2472
+ }
2473
+ }
2474
+ async function generateGlobalContext(workspacePath, repos, models, logFile) {
2475
+ if (contextExists(workspacePath)) return;
2476
+ log("Generating global workspace context...");
2477
+ const outputPath = getContextPath(workspacePath);
2478
+ const prompt = buildGlobalContextGenerationPrompt(repos, outputPath);
2479
+ try {
2480
+ await runWithFallback(models, prompt, {
2481
+ logFile,
2482
+ cwd: workspacePath,
2483
+ guardrailsDir: workspacePath,
2484
+ issueId: "__context_gen_global__"
2485
+ });
2486
+ ok(`Global context generated: ${outputPath}`);
2487
+ } catch (err) {
2488
+ warn(
2489
+ `Global context generation failed: ${formatError(err)}. Proceeding without global context.md.`
2490
+ );
2491
+ }
2492
+ }
2493
+ async function ensureWorkspaceContext(config, models, workspace, logFile) {
2494
+ const isMultiRepo = config.repos.length > 1;
2495
+ if (isMultiRepo) {
2496
+ const repoList = config.repos.map((r) => ({
2497
+ name: r.name,
2498
+ path: resolve8(workspace, r.path)
2499
+ }));
2500
+ await generateGlobalContext(workspace, repoList, models, logFile);
2501
+ for (const repo of config.repos) {
2502
+ const absPath = resolve8(workspace, repo.path);
2503
+ await generateRepoContext(absPath, models, logFile, workspace);
2504
+ }
2505
+ } else {
2506
+ await generateRepoContext(workspace, models, logFile, workspace);
2507
+ }
2508
+ }
2509
+
2510
+ // src/loop/sequential.ts
2511
+ import { resolve as resolve10 } from "path";
2512
+
2513
+ // src/git/dependency.ts
2514
+ import { execa as execa4 } from "execa";
2515
+ async function resolveDependency(repoPath, blockerIssueId, baseBranch) {
2516
+ const branch = await findBranchByIssueId(repoPath, blockerIssueId);
2517
+ if (!branch) return null;
2518
+ const prInfo = await findOpenPr(branch);
2519
+ if (!prInfo) return null;
2520
+ const changedFiles = await getChangedFiles(repoPath, baseBranch, branch);
2521
+ return {
2522
+ issueId: blockerIssueId,
2523
+ branch,
2524
+ prUrl: prInfo.url,
2525
+ changedFiles
2526
+ };
2527
+ }
2528
+ async function resolveFirstDependency(repoPath, blockerIds, baseBranch) {
2529
+ for (const blockerId of blockerIds) {
2530
+ const dep = await resolveDependency(repoPath, blockerId, baseBranch);
2531
+ if (dep) return dep;
2532
+ }
2533
+ return null;
2534
+ }
2535
+ async function findOpenPr(branch) {
2536
+ try {
2537
+ const { stdout } = await execa4("gh", [
2538
+ "pr",
2539
+ "list",
2540
+ "--head",
2541
+ branch,
2542
+ "--state",
2543
+ "open",
2544
+ "--json",
2545
+ "url,state",
2546
+ "--limit",
2547
+ "1"
2548
+ ]);
2549
+ const prs = JSON.parse(stdout);
2550
+ if (prs.length === 0) return null;
2551
+ return prs[0] ?? null;
2552
+ } catch {
2553
+ return null;
2554
+ }
2555
+ }
2556
+ async function getChangedFiles(repoPath, baseBranch, dependencyBranch) {
2557
+ try {
2558
+ await execa4("git", ["fetch", "origin", dependencyBranch], {
2559
+ cwd: repoPath,
2560
+ reject: false
2561
+ });
2562
+ const { stdout } = await execa4(
2563
+ "git",
2564
+ ["diff", "--name-only", `origin/${baseBranch}...origin/${dependencyBranch}`],
2565
+ { cwd: repoPath }
2566
+ );
2567
+ return stdout.split("\n").map((f) => f.trim()).filter(Boolean);
2568
+ } catch {
2569
+ return [];
2570
+ }
2571
+ }
2572
+
2573
+ // src/loop/branch-session.ts
2574
+ import { unlinkSync as unlinkSync3 } from "fs";
2575
+ import { resolve as resolve9 } from "path";
2576
+ async function runBranchSession(config, issue, logFile, session, models, source) {
2577
+ const workspace = resolve9(config.workspace);
2578
+ const manifestPath = getManifestPath(workspace, issue.id);
2579
+ const hookEnv = buildHookEnv(issue.id, issue.title, "", workspace);
2580
+ try {
2581
+ unlinkSync3(manifestPath);
2582
+ } catch {
2583
+ }
2584
+ const testRunner = detectTestRunner(workspace);
2585
+ if (testRunner) {
2586
+ log(`Detected test runner: ${testRunner}`);
2587
+ }
2588
+ const pm = detectPackageManager(workspace);
2589
+ const projectContext = analyzeProject(workspace);
2590
+ const repoContextMd = readContext(workspace);
2591
+ const lifecycleEnv = await startInfra(issue.id, workspace, config);
2592
+ if (!await executeHook("before_run", config.hooks, workspace, hookEnv)) {
2593
+ return hookFailure(defaultProvider(models), "before_run hook failed");
2594
+ }
2595
+ const prompt = buildImplementPrompt(
2596
+ issue,
2597
+ config,
2598
+ testRunner,
2599
+ pm,
2600
+ projectContext,
2601
+ workspace,
2602
+ manifestPath,
2603
+ repoContextMd
2604
+ );
2605
+ initLogFile(logFile);
2606
+ kanbanEmitter.emit("issue:log-file", issue.id, logFile);
2607
+ startSpinner(`${issue.id} \u2014 implementing...`);
2608
+ log(`Implementing... (log: ${logFile})`);
2609
+ const reconciliation = startReconciliationMonitor(source, issue.id, config);
2610
+ const result = await runWithFallback(
2611
+ models,
2612
+ prompt,
2613
+ buildRunOptions(config, issue, workspace, logFile, workspace, lifecycleEnv)
2614
+ );
2615
+ stopSpinner();
2616
+ reconciliation?.stop();
2617
+ await stopResources();
2618
+ await executeHook("after_run", config.hooks, workspace, hookEnv);
2619
+ const reconciled = checkReconciliation(issue.id, result);
2620
+ if (reconciled) return reconciled;
2621
+ appendSessionLog(logFile, result);
2622
+ if (!result.success) {
2623
+ error(`Session ${session} failed for ${issue.id}. Check ${logFile}`);
2624
+ return failureResult(result.providerUsed, result);
2625
+ }
2626
+ const hasChanges = await hasCodeChanges(workspace, config.base_branch);
2627
+ if (!hasChanges) {
2628
+ return emptyCommitFailure(result);
2629
+ }
2630
+ const pow = await runProofOfWork(
2631
+ config,
2632
+ issue,
2633
+ models,
2634
+ workspace,
2635
+ logFile,
2636
+ workspace,
2637
+ lifecycleEnv,
2638
+ result
2639
+ );
2640
+ if (pow.reconciled) {
2641
+ return failureResult(result.providerUsed, result);
2642
+ }
2643
+ const validationResults = pow.results;
2644
+ const manifest = readManifestFile(manifestPath);
2645
+ try {
2646
+ unlinkSync3(manifestPath);
2647
+ } catch {
2648
+ }
2649
+ let prUrl = manifest?.prUrl;
2650
+ if (!prUrl) {
2651
+ const extractedUrl = extractPrUrlFromOutput(result.output);
2652
+ if (extractedUrl) {
2653
+ warn(`Manifest missing prUrl for ${issue.id}, extracted from output: ${extractedUrl}`);
2654
+ prUrl = extractedUrl;
2655
+ } else {
2656
+ error(`Agent did not produce a manifest with prUrl for ${issue.id}.`);
2657
+ return failureResult(result.providerUsed, result);
2658
+ }
2659
+ }
2660
+ ok(`PR created by provider: ${prUrl}`);
2661
+ await appendPlatformAttribution(prUrl, result.providerUsed, config.platform);
2662
+ if (validationResults) {
2663
+ await appendPlatformProofOfWork(prUrl, validationResults, config.platform);
2664
+ }
2665
+ ok(`Session ${session} complete for ${issue.id}`);
2666
+ return {
2667
+ success: true,
2668
+ providerUsed: result.providerUsed,
2669
+ prUrls: [prUrl],
2670
+ fallback: result
2671
+ };
2672
+ }
2673
+
2674
+ // src/loop/sequential.ts
2675
+ async function runSequentialLoop(config, source, models, workspace, opts) {
2676
+ let session = 0;
2677
+ const loopStart = Date.now();
2678
+ let completedCount = 0;
2679
+ let consecutiveFetchErrors = 0;
2680
+ const MAX_CONSECUTIVE_FETCH_ERRORS = 3;
2681
+ let consecutiveExhaustions = 0;
2682
+ const MAX_CONSECUTIVE_EXHAUSTIONS = 3;
2683
+ while (true) {
2684
+ session++;
2685
+ if (opts.limit > 0 && session > opts.limit) {
2686
+ ok(`Reached limit of ${opts.limit} issues. Stopping.`);
2687
+ break;
2688
+ }
2689
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").substring(0, 19);
2690
+ const logFile = resolve10(getLogsDir(workspace), `session_${session}_${timestamp}.log`);
2691
+ divider(session);
2692
+ await waitIfPaused();
2693
+ startSpinner("fetching issue...");
2694
+ if (opts.issueId) {
2695
+ log(`Fetching issue '${opts.issueId}' from ${config.source}...`);
2696
+ } else {
2697
+ log(
2698
+ `Fetching next '${formatLabels(config.source_config)}' issue from ${config.source}...`
2699
+ );
2700
+ }
2701
+ if (opts.dryRun) {
2702
+ stopSpinner();
2703
+ if (opts.issueId) {
2704
+ log(`[dry-run] Would fetch issue '${opts.issueId}' from ${config.source}`);
2705
+ } else {
2706
+ log(
2707
+ `[dry-run] Would fetch issue from ${config.source} (${config.source_config.scope}/${config.source_config.project})`
2708
+ );
2709
+ }
2710
+ log(`[dry-run] Workflow mode: ${config.workflow}`);
2711
+ log(
2712
+ `[dry-run] Models priority: ${models.map((m) => m.model ? `${m.provider}/${m.model}` : m.provider).join(" \u2192 ")}`
2713
+ );
2714
+ log("[dry-run] Then implement, push, create PR, and update issue status");
2715
+ break;
2716
+ }
2717
+ let issue;
2718
+ try {
2719
+ issue = opts.issueId ? await source.fetchIssueById(opts.issueId) : await source.fetchNextIssue(config.source_config);
2720
+ consecutiveFetchErrors = 0;
2721
+ } catch (err) {
2722
+ stopSpinner();
2723
+ consecutiveFetchErrors++;
2724
+ error(`Failed to fetch issues: ${formatError(err)}`);
2725
+ if (opts.once || consecutiveFetchErrors >= MAX_CONSECUTIVE_FETCH_ERRORS) {
2726
+ if (consecutiveFetchErrors >= MAX_CONSECUTIVE_FETCH_ERRORS) {
2727
+ error(
2728
+ `Stopping after ${MAX_CONSECUTIVE_FETCH_ERRORS} consecutive fetch failures.`
2729
+ );
2730
+ }
2731
+ break;
2732
+ }
2733
+ setTitle("Lisa \u2014 cooling down...");
2734
+ await sleep(config.loop.cooldown * 1e3);
2735
+ continue;
2736
+ }
2737
+ stopSpinner();
2738
+ if (!issue) {
2739
+ if (opts.issueId) {
2740
+ error(`Issue '${opts.issueId}' not found.`);
2741
+ break;
2742
+ }
2743
+ if (opts.watch) {
2744
+ if (completedCount > 0) {
2745
+ ok(`All issues resolved. Prompting user to continue watching...`);
2746
+ kanbanEmitter.emit("work:watch-prompt");
2747
+ setTitle("Lisa \u2014 all resolved");
2748
+ await waitIfPaused();
2749
+ if (hasUserQuitFromWatchPrompt() || isShuttingDown()) {
2750
+ break;
2751
+ }
2752
+ kanbanEmitter.emit("work:watch-prompt-resumed");
2753
+ ok(`Resuming watch mode (polling every ${WATCH_POLL_INTERVAL_MS / 1e3}s)...`);
2754
+ kanbanEmitter.emit("work:watching");
2755
+ setTitle("Lisa \u2014 watching...");
2756
+ await sleep(WATCH_POLL_INTERVAL_MS);
2757
+ kanbanEmitter.emit("work:watch-resume");
2758
+ session--;
2759
+ continue;
2760
+ }
2761
+ ok(
2762
+ `No issues ready. Watching for new issues (polling every ${WATCH_POLL_INTERVAL_MS / 1e3}s)...`
2763
+ );
2764
+ kanbanEmitter.emit("work:watching");
2765
+ setTitle("Lisa \u2014 watching...");
2766
+ await sleep(WATCH_POLL_INTERVAL_MS);
2767
+ kanbanEmitter.emit("work:watch-resume");
2768
+ session--;
2769
+ continue;
2770
+ }
2771
+ ok(`No more issues with label '${formatLabels(config.source_config)}'.`);
2772
+ await refreshKanban(source, config);
2773
+ kanbanEmitter.emit("work:empty");
2774
+ setTitle("Lisa \u2014 idle");
2775
+ await waitForResume();
2776
+ if (isShuttingDown() || hasUserQuitFromWatchPrompt()) {
2777
+ break;
2778
+ }
2779
+ kanbanEmitter.emit("work:resumed");
2780
+ session--;
2781
+ continue;
2782
+ }
2783
+ ok(`Picked up: ${issue.id} \u2014 ${issue.title}`);
2784
+ setTitle(`Lisa \u2014 ${issue.id}`);
2785
+ const cachedPrUrls = loadPrUrls(workspace, issue.id);
2786
+ if (cachedPrUrls.length > 0) {
2787
+ await injectRejectedPrFeedback(workspace, issue.id, cachedPrUrls);
2788
+ }
2789
+ await checkIssueSpec(issue, config, source);
2790
+ if (issue.completedBlockerIds && issue.completedBlockerIds.length > 0) {
2791
+ const repoPath = determineRepoPath(config.repos, issue, workspace) ?? workspace;
2792
+ const baseBranch = resolveBaseBranch(config, repoPath);
2793
+ startSpinner(`${issue.id} \u2014 resolving dependency...`);
2794
+ const dep = await resolveFirstDependency(repoPath, issue.completedBlockerIds, baseBranch);
2795
+ stopSpinner();
2796
+ if (dep) {
2797
+ issue.dependency = dep;
2798
+ ok(
2799
+ `Dependency resolved: ${dep.issueId} \u2192 branch ${dep.branch} (${dep.changedFiles.length} changed files)`
2800
+ );
2801
+ }
2802
+ }
2803
+ kanbanEmitter.emit("issue:queued", issue);
2804
+ const previousStatus = config.source_config.pick_from;
2805
+ kanbanEmitter.emit("issue:started", issue.id);
2806
+ await moveToInProgress(issue, source, config);
2807
+ activeCleanups.set(issue.id, { previousStatus, source, sourceConfig: config.source_config });
2808
+ let sessionResult;
2809
+ try {
2810
+ sessionResult = config.workflow === "worktree" ? await runWorktreeSession(config, issue, logFile, session, models, source) : await runBranchSession(config, issue, logFile, session, models, source);
2811
+ } catch (err) {
2812
+ stopSpinner();
2813
+ error(`Unhandled error in session for ${issue.id}: ${formatError(err)}`);
2814
+ await revertIssueStatus(issue, source, config);
2815
+ activeCleanups.delete(issue.id);
2816
+ if (config.bell !== false) notify(2);
2817
+ if (opts.once) break;
2818
+ log(`Cooling down ${config.loop.cooldown}s before next issue...`);
2819
+ setTitle("Lisa \u2014 cooling down...");
2820
+ await sleep(config.loop.cooldown * 1e3);
2821
+ continue;
2822
+ }
2823
+ const completed = await handleSessionResult(
2824
+ sessionResult,
2825
+ issue,
2826
+ previousStatus,
2827
+ source,
2828
+ config,
2829
+ opts
2830
+ );
2831
+ if (completed) completedCount++;
2832
+ if (opts.once) {
2833
+ log("Single iteration mode. Exiting.");
2834
+ break;
2835
+ }
2836
+ if (!sessionResult.success && !userKilledSet.has(issue.id) && !userSkippedSet.has(issue.id) && isCompleteProviderExhaustion(sessionResult.fallback.attempts)) {
2837
+ consecutiveExhaustions++;
2838
+ if (consecutiveExhaustions >= MAX_CONSECUTIVE_EXHAUSTIONS) {
2839
+ error(
2840
+ "All providers exhausted due to infrastructure issues (quota, plan limits, or not installed). Fix your provider configuration and restart lisa."
2841
+ );
2842
+ break;
2843
+ }
2844
+ warn(
2845
+ `Provider exhausted for ${issue.id} (${consecutiveExhaustions}/${MAX_CONSECUTIVE_EXHAUSTIONS}). Continuing with next issue.`
2846
+ );
2847
+ } else if (sessionResult.success) {
2848
+ consecutiveExhaustions = 0;
2849
+ }
2850
+ userKilledSet.delete(issue.id);
2851
+ userSkippedSet.delete(issue.id);
2852
+ providerPausedSet.delete(issue.id);
2853
+ log(`Cooling down ${config.loop.cooldown}s before next issue...`);
2854
+ setTitle("Lisa \u2014 cooling down...");
2855
+ await sleep(config.loop.cooldown * 1e3);
2856
+ }
2857
+ if (completedCount > 0) {
2858
+ await checkoutBaseBranches(config, workspace);
2859
+ kanbanEmitter.emit("work:complete", {
2860
+ total: completedCount,
2861
+ duration: Date.now() - loopStart
2862
+ });
2863
+ }
2864
+ resetTitle();
2865
+ ok(`lisa finished. ${session} session(s) run.`);
2866
+ }
2867
+
2868
+ // src/loop/signals.ts
2869
+ function installSignalHandlers(onBeforeExit) {
2870
+ const cleanup = async (signal) => {
2871
+ if (isShuttingDown()) {
2872
+ warn("Force exiting...");
2873
+ process.exit(1);
2874
+ }
2875
+ setShuttingDown(true);
2876
+ stopSpinner();
2877
+ resetTitle();
2878
+ warn(`Received ${signal}. Reverting active issues...`);
2879
+ for (const [, pid] of activeProviderPids) {
2880
+ try {
2881
+ process.kill(pid, "SIGTERM");
2882
+ } catch {
2883
+ }
2884
+ }
2885
+ const revertPromises = [...activeCleanups.entries()].map(
2886
+ async ([issueId, { previousStatus, source, sourceConfig }]) => {
2887
+ try {
2888
+ await Promise.race([
2889
+ source.updateStatus(issueId, previousStatus, sourceConfig),
2890
+ new Promise(
2891
+ (_, reject) => setTimeout(() => reject(new Error("Revert timed out")), 5e3)
2892
+ )
2893
+ ]);
2894
+ ok(`Reverted ${issueId} to "${previousStatus}"`);
2895
+ } catch (err) {
2896
+ error(`Failed to revert ${issueId}: ${formatError(err)}`);
2897
+ }
2898
+ kanbanEmitter.emit("issue:reverted", issueId);
2899
+ }
2900
+ );
2901
+ await Promise.allSettled(revertPromises);
2902
+ const hasTUI = kanbanEmitter.listenerCount("tui:exit") > 0;
2903
+ kanbanEmitter.emit("tui:exit");
2904
+ if (hasTUI) {
2905
+ await new Promise((resolve12) => setTimeout(resolve12, 250));
2906
+ }
2907
+ onBeforeExit?.();
2908
+ process.exit(0);
2909
+ };
2910
+ process.on("SIGINT", () => {
2911
+ cleanup("SIGINT");
2912
+ });
2913
+ process.on("SIGTERM", () => {
2914
+ cleanup("SIGTERM");
2915
+ });
2916
+ }
2917
+
2918
+ // src/loop/demo.ts
2919
+ async function runDemoLoop() {
2920
+ const demoIssues = [
2921
+ { id: "INT-514", title: "Dark mode UI" },
2922
+ { id: "INT-513", title: "WebSocket leak fix" },
2923
+ { id: "INT-512", title: "Rate limiter middleware" },
2924
+ { id: "INT-511", title: "Sidebar navigation icons" },
2925
+ { id: "INT-510", title: "Blog post CRUD" },
2926
+ { id: "INT-509", title: "Admin FAQ management" },
2927
+ { id: "INT-508", title: "Changelog CRUD" }
2928
+ ];
2929
+ await sleep(3e3);
2930
+ kanbanEmitter.emit("provider:model-changed", "claude-sonnet-4-6");
2931
+ await sleep(400);
2932
+ for (const issue of demoIssues) {
2933
+ kanbanEmitter.emit("issue:queued", {
2934
+ id: issue.id,
2935
+ title: issue.title,
2936
+ description: "",
2937
+ url: ""
2938
+ });
2939
+ await sleep(200);
2940
+ }
2941
+ await sleep(1e3);
2942
+ const issue1 = demoIssues[0];
2943
+ kanbanEmitter.emit("issue:started", issue1.id);
2944
+ const outputs1 = [
2945
+ "Reading issue description...\n",
2946
+ "Analyzing codebase structure...\n",
2947
+ "Creating src/theme/dark-mode.ts...\n",
2948
+ "Updating CSS variables for dark palette...\n",
2949
+ "Adding toggle component...\n",
2950
+ "Running tests... all passing \u2713\n",
2951
+ "Pushing branch int-514-dark-mode-ui...\n"
2952
+ ];
2953
+ for (const line of outputs1) {
2954
+ kanbanEmitter.emit("issue:output", issue1.id, line);
2955
+ await sleep(500);
2956
+ }
2957
+ kanbanEmitter.emit("issue:done", issue1.id, ["https://github.com/acme/webapp/pull/91"]);
2958
+ await sleep(1e3);
2959
+ const issue2 = demoIssues[1];
2960
+ kanbanEmitter.emit("issue:started", issue2.id);
2961
+ const outputs2 = [
2962
+ "Reading issue description...\n",
2963
+ "Locating WebSocket connection handler...\n",
2964
+ "Patching connection lifecycle in src/ws/handler.ts...\n",
2965
+ "Adding cleanup in disconnect callback...\n",
2966
+ "Running tests... all passing \u2713\n",
2967
+ "Pushing branch int-513-fix-ws-leak...\n"
2968
+ ];
2969
+ for (const line of outputs2) {
2970
+ kanbanEmitter.emit("issue:output", issue2.id, line);
2971
+ await sleep(500);
2972
+ }
2973
+ kanbanEmitter.emit("issue:done", issue2.id, ["https://github.com/acme/webapp/pull/92"]);
2974
+ await sleep(1e3);
2975
+ const issue3 = demoIssues[2];
2976
+ kanbanEmitter.emit("issue:started", issue3.id);
2977
+ const outputs3 = [
2978
+ "Reading issue description...\n",
2979
+ "Creating src/middleware/rateLimiter.ts...\n",
2980
+ "Writing sliding window rate limiter...\n",
2981
+ "Adding tests in rateLimiter.test.ts...\n",
2982
+ "Running tests... all passing \u2713\n",
2983
+ "Pushing branch int-512-rate-limiting...\n"
2984
+ ];
2985
+ for (const line of outputs3) {
2986
+ kanbanEmitter.emit("issue:output", issue3.id, line);
2987
+ await sleep(500);
2988
+ }
2989
+ kanbanEmitter.emit("issue:done", issue3.id, ["https://github.com/acme/webapp/pull/93"]);
2990
+ await sleep(1e3);
2991
+ kanbanEmitter.emit("work:complete", { total: 3, duration: 127e3 });
2992
+ await sleep(4e3);
2993
+ kanbanEmitter.emit("tui:exit");
2994
+ }
2995
+
2996
+ // src/loop/index.ts
2997
+ setupEventListeners();
2998
+ async function runLoop(config, opts) {
2999
+ const source = createSource(config.source);
3000
+ const models = resolveModels(config);
3001
+ const workspace = resolve11(config.workspace);
3002
+ const concurrency = opts.concurrency;
3003
+ installSignalHandlers(opts.onBeforeExit);
3004
+ ensureCacheDir(workspace);
3005
+ migrateGuardrails(workspace);
3006
+ rotateLogFiles(workspace);
3007
+ if (!opts.dryRun) {
3008
+ const contextLogFile = join3(workspace, ".lisa", "context-generation.log");
3009
+ ensureWorkspaceContext(config, models, workspace, contextLogFile).catch((err) => {
3010
+ warn(`Background context generation failed: ${formatError(err)}`);
3011
+ });
3012
+ }
3013
+ log(
3014
+ `Starting loop (models: ${models.map((m) => m.model ? `${m.provider}/${m.model}` : m.provider).join(" \u2192 ")}, source: ${config.source}, label: ${formatLabels(config.source_config)}, workflow: ${config.workflow}${concurrency > 1 ? `, concurrency: ${concurrency}` : ""})`
3015
+ );
3016
+ if (!opts.dryRun) {
3017
+ await recoverOrphanIssues(source, config);
3018
+ }
3019
+ if (kanbanEmitter.listenerCount("issue:queued") > 0) {
3020
+ try {
3021
+ const allIssues = await source.listIssues(config.source_config);
3022
+ const activeIssueIds = new Set(allIssues.map((i) => i.id));
3023
+ if (opts.initialCards) {
3024
+ for (const card of opts.initialCards) {
3025
+ if (card.column === "backlog" && !activeIssueIds.has(card.id)) {
3026
+ kanbanEmitter.emit("issue:reconcile-remove", card.id);
3027
+ log(`Reconciled ${card.id}: no longer in source queue \u2014 removed from kanban`);
3028
+ }
3029
+ }
3030
+ }
3031
+ for (const issue of allIssues) {
3032
+ kanbanEmitter.emit("issue:queued", issue);
3033
+ }
3034
+ } catch {
3035
+ }
3036
+ }
3037
+ if (concurrency <= 1) {
3038
+ await runSequentialLoop(config, source, models, workspace, opts);
3039
+ } else {
3040
+ await runConcurrentLoop(config, source, models, workspace, opts);
3041
+ }
3042
+ }
3043
+
3044
+ export {
3045
+ validateConfig,
3046
+ configExists,
3047
+ findConfigDir,
3048
+ loadConfig,
3049
+ saveConfig,
3050
+ mergeWithFlags,
3051
+ getRemoveLabel,
3052
+ formatLabels,
3053
+ ensureWorktreeGitignore,
3054
+ buildContextGenerationPrompt,
3055
+ buildGlobalContextGenerationPrompt,
3056
+ checkoutBaseBranches,
3057
+ runDemoLoop,
3058
+ runLoop
3059
+ };