opencode-lisa 0.1.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,1228 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import { tool } from "@opencode-ai/plugin"
3
+ import { readdir, readFile, writeFile } from "fs/promises"
4
+ import { existsSync } from "fs"
5
+ import { join } from "path"
6
+
7
+ /**
8
+ * Lisa - Intelligent Epic Workflow Plugin for OpenCode
9
+ *
10
+ * Like the Ralph Wiggum pattern, but smarter. Lisa plans before she acts.
11
+ *
12
+ * Provides:
13
+ * 1. `build_task_context` tool - Builds context for a task (to be used with Task tool)
14
+ * 2. Yolo mode auto-continue - Keeps the session running until all tasks are done
15
+ *
16
+ * Works with the lisa skill (.opencode/skill/lisa/SKILL.md) which manages the epic state.
17
+ */
18
+
19
+ // ============================================================================
20
+ // Types
21
+ // ============================================================================
22
+
23
+ interface YoloState {
24
+ active: boolean
25
+ iteration: number
26
+ maxIterations: number
27
+ startedAt: string
28
+ }
29
+
30
+ interface EpicState {
31
+ name: string
32
+ currentPhase: string
33
+ specComplete: boolean
34
+ researchComplete: boolean
35
+ planComplete: boolean
36
+ executeComplete: boolean
37
+ lastUpdated: string
38
+ yolo?: YoloState
39
+ }
40
+
41
+ // ----------------------------------------------------------------------------
42
+ // Lisa Configuration Types
43
+ // ----------------------------------------------------------------------------
44
+
45
+ type GitCompletionMode = "pr" | "commit" | "none"
46
+
47
+ interface LisaConfigExecution {
48
+ maxRetries: number
49
+ }
50
+
51
+ interface LisaConfigGit {
52
+ completionMode: GitCompletionMode
53
+ branchPrefix: string
54
+ autoPush: boolean
55
+ }
56
+
57
+ interface LisaConfigYolo {
58
+ defaultMaxIterations: number
59
+ }
60
+
61
+ interface LisaConfig {
62
+ execution: LisaConfigExecution
63
+ git: LisaConfigGit
64
+ yolo: LisaConfigYolo
65
+ }
66
+
67
+ // Default configuration (most cautious)
68
+ const DEFAULT_CONFIG: LisaConfig = {
69
+ execution: {
70
+ maxRetries: 3,
71
+ },
72
+ git: {
73
+ completionMode: "none",
74
+ branchPrefix: "epic/",
75
+ autoPush: true,
76
+ },
77
+ yolo: {
78
+ defaultMaxIterations: 100,
79
+ },
80
+ }
81
+
82
+ // Default config file content with comments
83
+ const DEFAULT_CONFIG_CONTENT = `{
84
+ // Lisa Configuration
85
+ //
86
+ // Merge order: ~/.config/lisa/config.jsonc -> .lisa/config.jsonc -> .lisa/config.local.jsonc
87
+ // Override locally (gitignored) with: .lisa/config.local.jsonc
88
+
89
+ "execution": {
90
+ // Number of retries for failed tasks before stopping
91
+ "maxRetries": 3
92
+ },
93
+
94
+ "git": {
95
+ // How the epic completes when all tasks are done:
96
+ // "pr" - Create branch, commit, push, and open PR (requires \`gh\` CLI)
97
+ // "commit" - Create commits only, you handle push/PR manually
98
+ // "none" - No git operations, you manage everything
99
+ "completionMode": "none",
100
+
101
+ // Branch naming prefix (e.g., "epic/my-feature")
102
+ "branchPrefix": "epic/",
103
+
104
+ // When completionMode is "pr": automatically push and create PR
105
+ // Set false to review commits before pushing
106
+ "autoPush": true
107
+ },
108
+
109
+ "yolo": {
110
+ // Maximum iterations in yolo mode before pausing (0 = unlimited)
111
+ "defaultMaxIterations": 100
112
+ }
113
+ }
114
+ `
115
+
116
+ // .gitignore content for .lisa directory
117
+ const LISA_GITIGNORE_CONTENT = `# Local config overrides (not committed)
118
+ config.local.jsonc
119
+ `
120
+
121
+ // ============================================================================
122
+ // Helper Functions
123
+ // ============================================================================
124
+
125
+ /**
126
+ * Read a file if it exists, return empty string otherwise
127
+ */
128
+ async function readFileIfExists(path: string): Promise<string> {
129
+ if (!existsSync(path)) return ""
130
+ try {
131
+ return await readFile(path, "utf-8")
132
+ } catch {
133
+ return ""
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Strip JSON comments (single-line // and multi-line block comments) from a string
139
+ * Simple state-machine approach - handles most common cases
140
+ */
141
+ function stripJsonComments(jsonc: string): string {
142
+ // Remove single-line comments (// ...)
143
+ // Be careful not to match // inside strings
144
+ let result = ""
145
+ let inString = false
146
+ let inSingleLineComment = false
147
+ let inMultiLineComment = false
148
+ let i = 0
149
+
150
+ while (i < jsonc.length) {
151
+ const char = jsonc[i]
152
+ const nextChar = jsonc[i + 1]
153
+
154
+ // Handle string boundaries
155
+ if (!inSingleLineComment && !inMultiLineComment && char === '"' && jsonc[i - 1] !== "\\") {
156
+ inString = !inString
157
+ result += char
158
+ i++
159
+ continue
160
+ }
161
+
162
+ // Skip content inside strings
163
+ if (inString) {
164
+ result += char
165
+ i++
166
+ continue
167
+ }
168
+
169
+ // Check for comment start
170
+ if (!inSingleLineComment && !inMultiLineComment && char === "/" && nextChar === "/") {
171
+ inSingleLineComment = true
172
+ i += 2
173
+ continue
174
+ }
175
+
176
+ if (!inSingleLineComment && !inMultiLineComment && char === "/" && nextChar === "*") {
177
+ inMultiLineComment = true
178
+ i += 2
179
+ continue
180
+ }
181
+
182
+ // Check for comment end
183
+ if (inSingleLineComment && (char === "\n" || char === "\r")) {
184
+ inSingleLineComment = false
185
+ result += char
186
+ i++
187
+ continue
188
+ }
189
+
190
+ if (inMultiLineComment && char === "*" && nextChar === "/") {
191
+ inMultiLineComment = false
192
+ i += 2
193
+ continue
194
+ }
195
+
196
+ // Skip comment content
197
+ if (inSingleLineComment || inMultiLineComment) {
198
+ i++
199
+ continue
200
+ }
201
+
202
+ result += char
203
+ i++
204
+ }
205
+
206
+ return result
207
+ }
208
+
209
+ /**
210
+ * Deep merge two objects, with source overwriting target for matching keys
211
+ */
212
+ function deepMerge<T extends Record<string, any>>(target: T, source: Partial<T>): T {
213
+ const result = { ...target }
214
+
215
+ for (const key of Object.keys(source) as Array<keyof T>) {
216
+ const sourceValue = source[key]
217
+ const targetValue = target[key]
218
+
219
+ if (
220
+ sourceValue !== undefined &&
221
+ typeof sourceValue === "object" &&
222
+ sourceValue !== null &&
223
+ !Array.isArray(sourceValue) &&
224
+ typeof targetValue === "object" &&
225
+ targetValue !== null &&
226
+ !Array.isArray(targetValue)
227
+ ) {
228
+ result[key] = deepMerge(targetValue, sourceValue as any)
229
+ } else if (sourceValue !== undefined) {
230
+ result[key] = sourceValue as T[keyof T]
231
+ }
232
+ }
233
+
234
+ return result
235
+ }
236
+
237
+ /**
238
+ * Validate and sanitize config, logging warnings for invalid values
239
+ */
240
+ function validateConfig(config: Partial<LisaConfig>, logWarning: (msg: string) => void): LisaConfig {
241
+ const result = deepMerge(DEFAULT_CONFIG, config)
242
+
243
+ // Validate execution.maxRetries
244
+ if (typeof result.execution.maxRetries !== "number" || result.execution.maxRetries < 0) {
245
+ logWarning(`Invalid execution.maxRetries: ${result.execution.maxRetries}. Using default: ${DEFAULT_CONFIG.execution.maxRetries}`)
246
+ result.execution.maxRetries = DEFAULT_CONFIG.execution.maxRetries
247
+ }
248
+
249
+ // Validate git.completionMode
250
+ const validModes: GitCompletionMode[] = ["pr", "commit", "none"]
251
+ if (!validModes.includes(result.git.completionMode)) {
252
+ logWarning(`Invalid git.completionMode: "${result.git.completionMode}". Using default: "${DEFAULT_CONFIG.git.completionMode}"`)
253
+ result.git.completionMode = DEFAULT_CONFIG.git.completionMode
254
+ }
255
+
256
+ // Validate git.branchPrefix
257
+ if (typeof result.git.branchPrefix !== "string" || result.git.branchPrefix.length === 0) {
258
+ logWarning(`Invalid git.branchPrefix: "${result.git.branchPrefix}". Using default: "${DEFAULT_CONFIG.git.branchPrefix}"`)
259
+ result.git.branchPrefix = DEFAULT_CONFIG.git.branchPrefix
260
+ }
261
+
262
+ // Validate git.autoPush
263
+ if (typeof result.git.autoPush !== "boolean") {
264
+ logWarning(`Invalid git.autoPush: ${result.git.autoPush}. Using default: ${DEFAULT_CONFIG.git.autoPush}`)
265
+ result.git.autoPush = DEFAULT_CONFIG.git.autoPush
266
+ }
267
+
268
+ // Validate yolo.defaultMaxIterations
269
+ if (typeof result.yolo.defaultMaxIterations !== "number" || result.yolo.defaultMaxIterations < 0) {
270
+ logWarning(`Invalid yolo.defaultMaxIterations: ${result.yolo.defaultMaxIterations}. Using default: ${DEFAULT_CONFIG.yolo.defaultMaxIterations}`)
271
+ result.yolo.defaultMaxIterations = DEFAULT_CONFIG.yolo.defaultMaxIterations
272
+ }
273
+
274
+ return result
275
+ }
276
+
277
+ /**
278
+ * Load config from a JSONC file
279
+ */
280
+ async function loadConfigFile(path: string): Promise<Partial<LisaConfig> | null> {
281
+ if (!existsSync(path)) return null
282
+
283
+ try {
284
+ const content = await readFile(path, "utf-8")
285
+ const stripped = stripJsonComments(content)
286
+ return JSON.parse(stripped) as Partial<LisaConfig>
287
+ } catch {
288
+ return null
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Load and merge config from all sources
294
+ * Order: global -> project -> project-local
295
+ */
296
+ async function loadConfig(directory: string, logWarning: (msg: string) => void): Promise<LisaConfig> {
297
+ const homeDir = process.env.HOME || process.env.USERPROFILE || ""
298
+
299
+ // Config file paths
300
+ const globalConfigPath = join(homeDir, ".config", "lisa", "config.jsonc")
301
+ const projectConfigPath = join(directory, ".lisa", "config.jsonc")
302
+ const localConfigPath = join(directory, ".lisa", "config.local.jsonc")
303
+
304
+ // Load configs in order
305
+ const globalConfig = await loadConfigFile(globalConfigPath)
306
+ const projectConfig = await loadConfigFile(projectConfigPath)
307
+ const localConfig = await loadConfigFile(localConfigPath)
308
+
309
+ // Merge configs
310
+ let merged: Partial<LisaConfig> = {}
311
+
312
+ if (globalConfig) {
313
+ merged = deepMerge(merged as LisaConfig, globalConfig)
314
+ }
315
+ if (projectConfig) {
316
+ merged = deepMerge(merged as LisaConfig, projectConfig)
317
+ }
318
+ if (localConfig) {
319
+ merged = deepMerge(merged as LisaConfig, localConfig)
320
+ }
321
+
322
+ // Validate and return
323
+ return validateConfig(merged, logWarning)
324
+ }
325
+
326
+ /**
327
+ * Ensure .lisa directory exists with config files
328
+ */
329
+ async function ensureLisaDirectory(directory: string): Promise<{ created: boolean; configCreated: boolean }> {
330
+ const lisaDir = join(directory, ".lisa")
331
+ const configPath = join(lisaDir, "config.jsonc")
332
+ const gitignorePath = join(lisaDir, ".gitignore")
333
+
334
+ let created = false
335
+ let configCreated = false
336
+
337
+ // Create .lisa directory if needed
338
+ if (!existsSync(lisaDir)) {
339
+ const { mkdir } = await import("fs/promises")
340
+ await mkdir(lisaDir, { recursive: true })
341
+ created = true
342
+ }
343
+
344
+ // Create config.jsonc if it doesn't exist
345
+ if (!existsSync(configPath)) {
346
+ await writeFile(configPath, DEFAULT_CONFIG_CONTENT, "utf-8")
347
+ configCreated = true
348
+ }
349
+
350
+ // Create .gitignore if it doesn't exist
351
+ if (!existsSync(gitignorePath)) {
352
+ await writeFile(gitignorePath, LISA_GITIGNORE_CONTENT, "utf-8")
353
+ }
354
+
355
+ return { created, configCreated }
356
+ }
357
+
358
+ /**
359
+ * Get all task files for an epic, sorted by task number
360
+ */
361
+ async function getTaskFiles(directory: string, epicName: string): Promise<string[]> {
362
+ const tasksDir = join(directory, ".lisa", "epics", epicName, "tasks")
363
+
364
+ if (!existsSync(tasksDir)) return []
365
+
366
+ try {
367
+ const files = await readdir(tasksDir)
368
+ return files
369
+ .filter((f) => f.endsWith(".md"))
370
+ .sort((a, b) => {
371
+ const numA = parseInt(a.match(/^(\d+)/)?.[1] || "0", 10)
372
+ const numB = parseInt(b.match(/^(\d+)/)?.[1] || "0", 10)
373
+ return numA - numB
374
+ })
375
+ } catch {
376
+ return []
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Find the active epic with yolo mode enabled
382
+ */
383
+ async function findActiveYoloEpic(
384
+ directory: string
385
+ ): Promise<{ name: string; state: EpicState } | null> {
386
+ const epicsDir = join(directory, ".lisa", "epics")
387
+
388
+ if (!existsSync(epicsDir)) return null
389
+
390
+ try {
391
+ const entries = await readdir(epicsDir, { withFileTypes: true })
392
+
393
+ for (const entry of entries) {
394
+ if (!entry.isDirectory()) continue
395
+
396
+ const statePath = join(epicsDir, entry.name, ".state")
397
+ if (!existsSync(statePath)) continue
398
+
399
+ try {
400
+ const content = await readFile(statePath, "utf-8")
401
+ const state = JSON.parse(content) as EpicState
402
+
403
+ if (state.yolo?.active) {
404
+ return { name: entry.name, state }
405
+ }
406
+ } catch {
407
+ continue
408
+ }
409
+ }
410
+ } catch {
411
+ return null
412
+ }
413
+
414
+ return null
415
+ }
416
+
417
+ /**
418
+ * Count remaining tasks for an epic (pending or in-progress)
419
+ */
420
+ async function countRemainingTasks(directory: string, epicName: string): Promise<number> {
421
+ const tasksDir = join(directory, ".lisa", "epics", epicName, "tasks")
422
+
423
+ if (!existsSync(tasksDir)) return 0
424
+
425
+ try {
426
+ const files = await readdir(tasksDir)
427
+ const mdFiles = files.filter((f) => f.endsWith(".md"))
428
+
429
+ let remaining = 0
430
+ for (const file of mdFiles) {
431
+ const content = await readFile(join(tasksDir, file), "utf-8")
432
+ if (!content.includes("## Status: done") && !content.includes("## Status: blocked")) {
433
+ remaining++
434
+ }
435
+ }
436
+ return remaining
437
+ } catch {
438
+ return 0
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Update the epic's .state file
444
+ */
445
+ async function updateEpicState(
446
+ directory: string,
447
+ epicName: string,
448
+ updates: Partial<EpicState>
449
+ ): Promise<void> {
450
+ const statePath = join(directory, ".lisa", "epics", epicName, ".state")
451
+
452
+ try {
453
+ const content = await readFile(statePath, "utf-8")
454
+ const state = JSON.parse(content) as EpicState
455
+
456
+ const newState = { ...state, ...updates, lastUpdated: new Date().toISOString() }
457
+
458
+ // Handle nested yolo updates
459
+ if (updates.yolo && state.yolo) {
460
+ newState.yolo = { ...state.yolo, ...updates.yolo }
461
+ }
462
+
463
+ await writeFile(statePath, JSON.stringify(newState, null, 2), "utf-8")
464
+ } catch {
465
+ // Ignore errors
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Send a desktop notification (cross-platform)
471
+ * Fails silently if notifications aren't available
472
+ */
473
+ async function notify($: any, title: string, message: string): Promise<void> {
474
+ try {
475
+ // macOS
476
+ await $`osascript -e 'display notification "${message}" with title "${title}"'`.quiet()
477
+ } catch {
478
+ try {
479
+ // Linux
480
+ await $`notify-send "${title}" "${message}"`.quiet()
481
+ } catch {
482
+ // Silently fail - don't pollute the UI with console.log
483
+ }
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Get task statistics for an epic
489
+ */
490
+ async function getTaskStats(
491
+ directory: string,
492
+ epicName: string
493
+ ): Promise<{ total: number; done: number; inProgress: number; pending: number; blocked: number }> {
494
+ const tasksDir = join(directory, ".lisa", "epics", epicName, "tasks")
495
+
496
+ if (!existsSync(tasksDir)) {
497
+ return { total: 0, done: 0, inProgress: 0, pending: 0, blocked: 0 }
498
+ }
499
+
500
+ try {
501
+ const files = await readdir(tasksDir)
502
+ const mdFiles = files.filter((f) => f.endsWith(".md"))
503
+
504
+ let done = 0
505
+ let inProgress = 0
506
+ let pending = 0
507
+ let blocked = 0
508
+
509
+ for (const file of mdFiles) {
510
+ const content = await readFile(join(tasksDir, file), "utf-8")
511
+ if (content.includes("## Status: done")) {
512
+ done++
513
+ } else if (content.includes("## Status: in-progress")) {
514
+ inProgress++
515
+ } else if (content.includes("## Status: blocked")) {
516
+ blocked++
517
+ } else {
518
+ pending++
519
+ }
520
+ }
521
+
522
+ return { total: mdFiles.length, done, inProgress, pending, blocked }
523
+ } catch {
524
+ return { total: 0, done: 0, inProgress: 0, pending: 0, blocked: 0 }
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Parse dependencies from plan.md
530
+ */
531
+ async function parseDependencies(
532
+ directory: string,
533
+ epicName: string
534
+ ): Promise<Map<string, string[]>> {
535
+ const planPath = join(directory, ".lisa", "epics", epicName, "plan.md")
536
+ const deps = new Map<string, string[]>()
537
+
538
+ if (!existsSync(planPath)) return deps
539
+
540
+ try {
541
+ const content = await readFile(planPath, "utf-8")
542
+ const depsMatch = content.match(/## Dependencies\n([\s\S]*?)(?=\n##|$)/)
543
+ if (!depsMatch) return deps
544
+
545
+ const lines = depsMatch[1].trim().split("\n")
546
+ for (const line of lines) {
547
+ const match = line.match(/^-\s*(\d+):\s*\[(.*)\]/)
548
+ if (match) {
549
+ const taskId = match[1]
550
+ const depList = match[2]
551
+ .split(",")
552
+ .map((d) => d.trim())
553
+ .filter((d) => d.length > 0)
554
+ deps.set(taskId, depList)
555
+ }
556
+ }
557
+ } catch {
558
+ // Ignore errors
559
+ }
560
+
561
+ return deps
562
+ }
563
+
564
+ // ============================================================================
565
+ // Plugin
566
+ // ============================================================================
567
+
568
+ export const LisaPlugin: Plugin = async ({ directory, client, $ }) => {
569
+ return {
570
+ // ========================================================================
571
+ // Custom Tools
572
+ // ========================================================================
573
+ tool: {
574
+ // ----------------------------------------------------------------------
575
+ // list_epics - Fast listing of all epics
576
+ // ----------------------------------------------------------------------
577
+ list_epics: tool({
578
+ description: `List all epics and their current status.
579
+
580
+ Returns a list of all epics in .lisa/epics/ with their phase and task progress.
581
+ Much faster than manually reading files.`,
582
+ args: {},
583
+ async execute() {
584
+ const epicsDir = join(directory, ".lisa", "epics")
585
+
586
+ if (!existsSync(epicsDir)) {
587
+ return JSON.stringify({
588
+ epics: [],
589
+ message: "No epics found. Start one with `/lisa <name>`",
590
+ }, null, 2)
591
+ }
592
+
593
+ try {
594
+ const entries = await readdir(epicsDir, { withFileTypes: true })
595
+ const epics: Array<{
596
+ name: string
597
+ phase: string
598
+ tasks: { done: number; total: number } | null
599
+ yoloActive: boolean
600
+ }> = []
601
+
602
+ for (const entry of entries) {
603
+ if (!entry.isDirectory()) continue
604
+
605
+ const statePath = join(epicsDir, entry.name, ".state")
606
+ let phase = "unknown"
607
+ let yoloActive = false
608
+
609
+ if (existsSync(statePath)) {
610
+ try {
611
+ const content = await readFile(statePath, "utf-8")
612
+ const state = JSON.parse(content) as EpicState
613
+ phase = state.currentPhase || "unknown"
614
+ yoloActive = state.yolo?.active || false
615
+ } catch {
616
+ phase = "unknown"
617
+ }
618
+ } else {
619
+ // No state file - check what exists
620
+ const hasSpec = existsSync(join(epicsDir, entry.name, "spec.md"))
621
+ const hasResearch = existsSync(join(epicsDir, entry.name, "research.md"))
622
+ const hasPlan = existsSync(join(epicsDir, entry.name, "plan.md"))
623
+ const hasTasks = existsSync(join(epicsDir, entry.name, "tasks"))
624
+
625
+ if (hasTasks) phase = "execute"
626
+ else if (hasPlan) phase = "plan"
627
+ else if (hasResearch) phase = "research"
628
+ else if (hasSpec) phase = "spec"
629
+ else phase = "new"
630
+ }
631
+
632
+ // Get task stats if in execute phase
633
+ let tasks: { done: number; total: number } | null = null
634
+ if (phase === "execute") {
635
+ const stats = await getTaskStats(directory, entry.name)
636
+ tasks = { done: stats.done, total: stats.total }
637
+ }
638
+
639
+ epics.push({ name: entry.name, phase, tasks, yoloActive })
640
+ }
641
+
642
+ return JSON.stringify({ epics }, null, 2)
643
+ } catch (error) {
644
+ return JSON.stringify({ epics: [], error: String(error) }, null, 2)
645
+ }
646
+ },
647
+ }),
648
+
649
+ // ----------------------------------------------------------------------
650
+ // get_epic_status - Detailed status for one epic
651
+ // ----------------------------------------------------------------------
652
+ get_epic_status: tool({
653
+ description: `Get detailed status for a specific epic.
654
+
655
+ Returns phase, artifacts, task breakdown, and available actions.
656
+ Much faster than manually reading multiple files.`,
657
+ args: {
658
+ epicName: tool.schema.string().describe("Name of the epic"),
659
+ },
660
+ async execute(args) {
661
+ const { epicName } = args
662
+ const epicDir = join(directory, ".lisa", "epics", epicName)
663
+
664
+ if (!existsSync(epicDir)) {
665
+ return JSON.stringify({
666
+ found: false,
667
+ error: `Epic "${epicName}" not found. Start it with \`/lisa ${epicName}\``,
668
+ }, null, 2)
669
+ }
670
+
671
+ // Check which artifacts exist
672
+ const artifacts = {
673
+ spec: existsSync(join(epicDir, "spec.md")),
674
+ research: existsSync(join(epicDir, "research.md")),
675
+ plan: existsSync(join(epicDir, "plan.md")),
676
+ tasks: existsSync(join(epicDir, "tasks")),
677
+ state: existsSync(join(epicDir, ".state")),
678
+ }
679
+
680
+ // Read state
681
+ let state: EpicState | null = null
682
+ if (artifacts.state) {
683
+ try {
684
+ const content = await readFile(join(epicDir, ".state"), "utf-8")
685
+ state = JSON.parse(content)
686
+ } catch {
687
+ state = null
688
+ }
689
+ }
690
+
691
+ // Get task stats
692
+ const taskStats = await getTaskStats(directory, epicName)
693
+
694
+ // Determine current phase
695
+ let currentPhase = state?.currentPhase || "unknown"
696
+ if (currentPhase === "unknown") {
697
+ if (artifacts.tasks) currentPhase = "execute"
698
+ else if (artifacts.plan) currentPhase = "plan"
699
+ else if (artifacts.research) currentPhase = "research"
700
+ else if (artifacts.spec) currentPhase = "spec"
701
+ else currentPhase = "new"
702
+ }
703
+
704
+ // Determine next action
705
+ let nextAction = ""
706
+ if (!artifacts.spec) {
707
+ nextAction = `Create spec with \`/lisa ${epicName} spec\``
708
+ } else if (!artifacts.research) {
709
+ nextAction = `Run \`/lisa ${epicName}\` to start research`
710
+ } else if (!artifacts.plan) {
711
+ nextAction = `Run \`/lisa ${epicName}\` to create plan`
712
+ } else if (taskStats.pending > 0 || taskStats.inProgress > 0) {
713
+ nextAction = `Run \`/lisa ${epicName}\` to continue execution or \`/lisa ${epicName} yolo\` for auto mode`
714
+ } else if (taskStats.blocked > 0) {
715
+ nextAction = `${taskStats.blocked} task(s) blocked - review and unblock`
716
+ } else {
717
+ nextAction = "Epic complete!"
718
+ }
719
+
720
+ return JSON.stringify({
721
+ found: true,
722
+ name: epicName,
723
+ currentPhase,
724
+ artifacts,
725
+ tasks: taskStats,
726
+ yolo: state?.yolo || null,
727
+ lastUpdated: state?.lastUpdated || null,
728
+ nextAction,
729
+ }, null, 2)
730
+ },
731
+ }),
732
+
733
+ // ----------------------------------------------------------------------
734
+ // get_available_tasks - Tasks ready to execute
735
+ // ----------------------------------------------------------------------
736
+ get_available_tasks: tool({
737
+ description: `Get tasks that are available to execute (dependencies satisfied).
738
+
739
+ Returns tasks that are pending/in-progress and have all dependencies completed.`,
740
+ args: {
741
+ epicName: tool.schema.string().describe("Name of the epic"),
742
+ },
743
+ async execute(args) {
744
+ const { epicName } = args
745
+ const epicDir = join(directory, ".lisa", "epics", epicName)
746
+ const tasksDir = join(epicDir, "tasks")
747
+
748
+ if (!existsSync(tasksDir)) {
749
+ return JSON.stringify({
750
+ available: [],
751
+ blocked: [],
752
+ message: "No tasks directory found",
753
+ }, null, 2)
754
+ }
755
+
756
+ // Get all task files
757
+ const taskFiles = await getTaskFiles(directory, epicName)
758
+ if (taskFiles.length === 0) {
759
+ return JSON.stringify({
760
+ available: [],
761
+ blocked: [],
762
+ message: "No task files found",
763
+ }, null, 2)
764
+ }
765
+
766
+ // Parse dependencies
767
+ const dependencies = await parseDependencies(directory, epicName)
768
+
769
+ // Read task statuses
770
+ const taskStatuses = new Map<string, string>()
771
+ for (const file of taskFiles) {
772
+ const taskId = file.match(/^(\d+)/)?.[1] || ""
773
+ const content = await readFile(join(tasksDir, file), "utf-8")
774
+
775
+ if (content.includes("## Status: done")) {
776
+ taskStatuses.set(taskId, "done")
777
+ } else if (content.includes("## Status: in-progress")) {
778
+ taskStatuses.set(taskId, "in-progress")
779
+ } else if (content.includes("## Status: blocked")) {
780
+ taskStatuses.set(taskId, "blocked")
781
+ } else {
782
+ taskStatuses.set(taskId, "pending")
783
+ }
784
+ }
785
+
786
+ // Determine which tasks are available
787
+ const available: Array<{ taskId: string; file: string; status: string }> = []
788
+ const blocked: Array<{ taskId: string; file: string; blockedBy: string[] }> = []
789
+
790
+ for (const file of taskFiles) {
791
+ const taskId = file.match(/^(\d+)/)?.[1] || ""
792
+ const status = taskStatuses.get(taskId) || "pending"
793
+
794
+ // Skip done or blocked tasks
795
+ if (status === "done" || status === "blocked") continue
796
+
797
+ // Check dependencies
798
+ const deps = dependencies.get(taskId) || []
799
+ const unmetDeps = deps.filter((depId) => taskStatuses.get(depId) !== "done")
800
+
801
+ if (unmetDeps.length === 0) {
802
+ available.push({ taskId, file, status })
803
+ } else {
804
+ blocked.push({ taskId, file, blockedBy: unmetDeps })
805
+ }
806
+ }
807
+
808
+ return JSON.stringify({ available, blocked }, null, 2)
809
+ },
810
+ }),
811
+
812
+ // ----------------------------------------------------------------------
813
+ // build_task_context - Build context for task execution
814
+ // ----------------------------------------------------------------------
815
+ build_task_context: tool({
816
+ description: `Build the full context for executing an epic task.
817
+
818
+ This tool reads the epic's spec, research, plan, and all previous completed tasks,
819
+ then returns a complete prompt that should be passed to the Task tool to execute
820
+ the task with a fresh sub-agent.
821
+
822
+ Use this before calling the Task tool for each task execution.`,
823
+ args: {
824
+ epicName: tool.schema.string().describe("Name of the epic (the folder name under .lisa/epics/)"),
825
+ taskId: tool.schema
826
+ .string()
827
+ .describe("Task ID - the number prefix like '01', '02', etc."),
828
+ },
829
+ async execute(args) {
830
+ const { epicName, taskId } = args
831
+ const epicDir = join(directory, ".lisa", "epics", epicName)
832
+ const tasksDir = join(epicDir, "tasks")
833
+
834
+ // Verify epic exists
835
+ if (!existsSync(epicDir)) {
836
+ return JSON.stringify({
837
+ success: false,
838
+ error: `Epic "${epicName}" not found at ${epicDir}`,
839
+ }, null, 2)
840
+ }
841
+
842
+ // Read context files
843
+ const spec = await readFileIfExists(join(epicDir, "spec.md"))
844
+ const research = await readFileIfExists(join(epicDir, "research.md"))
845
+ const plan = await readFileIfExists(join(epicDir, "plan.md"))
846
+
847
+ if (!spec) {
848
+ return JSON.stringify({
849
+ success: false,
850
+ error: `No spec.md found for epic "${epicName}"`,
851
+ }, null, 2)
852
+ }
853
+
854
+ // Find the task file
855
+ const taskFiles = await getTaskFiles(directory, epicName)
856
+ const taskFile = taskFiles.find((f) => f.startsWith(taskId))
857
+
858
+ if (!taskFile) {
859
+ return JSON.stringify({
860
+ success: false,
861
+ error: `Task "${taskId}" not found in ${tasksDir}`,
862
+ }, null, 2)
863
+ }
864
+
865
+ const taskPath = join(tasksDir, taskFile)
866
+ const taskContent = await readFile(taskPath, "utf-8")
867
+
868
+ // Check if task is already done
869
+ if (taskContent.includes("## Status: done")) {
870
+ return JSON.stringify({
871
+ success: true,
872
+ alreadyDone: true,
873
+ message: `Task ${taskId} is already complete`,
874
+ }, null, 2)
875
+ }
876
+
877
+ // Read all previous task files (for context)
878
+ const previousTasks: string[] = []
879
+ for (const file of taskFiles) {
880
+ const fileTaskId = file.match(/^(\d+)/)?.[1] || ""
881
+ if (fileTaskId >= taskId) break // Stop at current task
882
+
883
+ const content = await readFile(join(tasksDir, file), "utf-8")
884
+ previousTasks.push(`### ${file}\n\n${content}`)
885
+ }
886
+
887
+ // Build the sub-agent prompt
888
+ const prompt = `# Execute Epic Task
889
+
890
+ You are executing task ${taskId} of epic "${epicName}".
891
+
892
+ ## Your Mission
893
+
894
+ Execute the task described below. When complete:
895
+ 1. Update the task file's status from "pending" or "in-progress" to "done"
896
+ 2. Add a "## Report" section at the end of the task file with:
897
+ - **What Was Done**: List the changes you made
898
+ - **Decisions Made**: Any choices you made and why
899
+ - **Issues / Notes for Next Task**: Anything the next task should know
900
+ - **Files Changed**: List of files created/modified
901
+
902
+ If you discover the task approach is wrong or future tasks need changes, you may update them.
903
+ The plan is a living document.
904
+
905
+ ---
906
+
907
+ ## Epic Spec
908
+
909
+ ${spec}
910
+
911
+ ---
912
+
913
+ ## Research
914
+
915
+ ${research || "(No research conducted yet)"}
916
+
917
+ ---
918
+
919
+ ## Plan
920
+
921
+ ${plan || "(No plan created yet)"}
922
+
923
+ ---
924
+
925
+ ## Previous Completed Tasks
926
+
927
+ ${previousTasks.length > 0 ? previousTasks.join("\n\n---\n\n") : "(This is the first task)"}
928
+
929
+ ---
930
+
931
+ ## Current Task to Execute
932
+
933
+ **File: .lisa/epics/${epicName}/tasks/${taskFile}**
934
+
935
+ ${taskContent}
936
+
937
+ ---
938
+
939
+ ## Instructions
940
+
941
+ 1. Read and understand the task
942
+ 2. Execute the steps described
943
+ 3. Verify the "Done When" criteria are met
944
+ 4. Update the task file:
945
+ - Change \`## Status: pending\` or \`## Status: in-progress\` to \`## Status: done\`
946
+ - Add a \`## Report\` section at the end
947
+ 5. If you need to modify future tasks or the plan, do so
948
+ 6. When complete, confirm what was done
949
+ `
950
+
951
+ await client.app.log({
952
+ service: "lisa-plugin",
953
+ level: "info",
954
+ message: `Built context for task ${taskId} of epic "${epicName}" (${previousTasks.length} previous tasks)`,
955
+ })
956
+
957
+ return JSON.stringify({
958
+ success: true,
959
+ taskFile,
960
+ taskPath,
961
+ prompt,
962
+ message: `Context built for task ${taskId}. Pass the 'prompt' field to the Task tool to execute with a sub-agent.`,
963
+ }, null, 2)
964
+ },
965
+ }),
966
+
967
+ // ----------------------------------------------------------------------
968
+ // lisa_config - View and manage Lisa configuration
969
+ // ----------------------------------------------------------------------
970
+ lisa_config: tool({
971
+ description: `View or reset Lisa configuration.
972
+
973
+ Actions:
974
+ - "view": Show current merged configuration and where values come from
975
+ - "reset": Reset project config to defaults (creates .lisa/config.jsonc)
976
+ - "init": Initialize config if it doesn't exist (non-destructive)`,
977
+ args: {
978
+ action: tool.schema.enum(["view", "reset", "init"]).describe("Action to perform"),
979
+ },
980
+ async execute(args) {
981
+ const { action } = args
982
+ const lisaDir = join(directory, ".lisa")
983
+ const configPath = join(lisaDir, "config.jsonc")
984
+ const localConfigPath = join(lisaDir, "config.local.jsonc")
985
+ const homeDir = process.env.HOME || process.env.USERPROFILE || ""
986
+ const globalConfigPath = join(homeDir, ".config", "lisa", "config.jsonc")
987
+
988
+ const logWarning = (msg: string) => {
989
+ client.app.log({
990
+ service: "lisa-plugin",
991
+ level: "warn",
992
+ message: msg,
993
+ })
994
+ }
995
+
996
+ if (action === "view") {
997
+ // Load config and show sources
998
+ const config = await loadConfig(directory, logWarning)
999
+
1000
+ const sources: string[] = []
1001
+ if (existsSync(globalConfigPath)) sources.push(`Global: ${globalConfigPath}`)
1002
+ if (existsSync(configPath)) sources.push(`Project: ${configPath}`)
1003
+ if (existsSync(localConfigPath)) sources.push(`Local: ${localConfigPath}`)
1004
+ if (sources.length === 0) sources.push("(Using defaults - no config files found)")
1005
+
1006
+ return JSON.stringify({
1007
+ config,
1008
+ sources,
1009
+ paths: {
1010
+ global: globalConfigPath,
1011
+ project: configPath,
1012
+ local: localConfigPath,
1013
+ },
1014
+ }, null, 2)
1015
+ }
1016
+
1017
+ if (action === "reset") {
1018
+ // Ensure directory exists and reset config
1019
+ const { mkdir } = await import("fs/promises")
1020
+ if (!existsSync(lisaDir)) {
1021
+ await mkdir(lisaDir, { recursive: true })
1022
+ }
1023
+
1024
+ await writeFile(configPath, DEFAULT_CONFIG_CONTENT, "utf-8")
1025
+
1026
+ // Also ensure .gitignore exists
1027
+ const gitignorePath = join(lisaDir, ".gitignore")
1028
+ if (!existsSync(gitignorePath)) {
1029
+ await writeFile(gitignorePath, LISA_GITIGNORE_CONTENT, "utf-8")
1030
+ }
1031
+
1032
+ return JSON.stringify({
1033
+ success: true,
1034
+ message: "Config reset to defaults",
1035
+ path: configPath,
1036
+ tip: "Edit .lisa/config.jsonc to customize settings. Create .lisa/config.local.jsonc for personal overrides (gitignored).",
1037
+ }, null, 2)
1038
+ }
1039
+
1040
+ if (action === "init") {
1041
+ const result = await ensureLisaDirectory(directory)
1042
+
1043
+ if (result.configCreated) {
1044
+ return JSON.stringify({
1045
+ success: true,
1046
+ message: "Config initialized with defaults",
1047
+ path: configPath,
1048
+ tip: "Edit .lisa/config.jsonc to customize settings. Create .lisa/config.local.jsonc for personal overrides (gitignored).",
1049
+ }, null, 2)
1050
+ } else {
1051
+ return JSON.stringify({
1052
+ success: true,
1053
+ message: "Config already exists",
1054
+ path: configPath,
1055
+ tip: "Use action 'reset' to overwrite with defaults, or 'view' to see current config.",
1056
+ }, null, 2)
1057
+ }
1058
+ }
1059
+
1060
+ return JSON.stringify({ success: false, error: `Unknown action: ${action}` }, null, 2)
1061
+ },
1062
+ }),
1063
+
1064
+ // ----------------------------------------------------------------------
1065
+ // get_lisa_config - Get current config for use by other tools/skills
1066
+ // ----------------------------------------------------------------------
1067
+ get_lisa_config: tool({
1068
+ description: `Get the current Lisa configuration.
1069
+
1070
+ Returns the merged configuration from all sources (global, project, local).
1071
+ Use this to check settings like git.completionMode before performing actions.`,
1072
+ args: {},
1073
+ async execute() {
1074
+ const logWarning = (msg: string) => {
1075
+ client.app.log({
1076
+ service: "lisa-plugin",
1077
+ level: "warn",
1078
+ message: msg,
1079
+ })
1080
+ }
1081
+
1082
+ const config = await loadConfig(directory, logWarning)
1083
+ return JSON.stringify({ config }, null, 2)
1084
+ },
1085
+ }),
1086
+ },
1087
+
1088
+ // ========================================================================
1089
+ // Event Handler: Yolo Mode Auto-Continue
1090
+ // ========================================================================
1091
+ event: async ({ event }) => {
1092
+ if (event.type !== "session.idle") return
1093
+
1094
+ const sessionId = (event as any).properties?.sessionID
1095
+
1096
+ // Debug: log the event
1097
+ await client.app.log({
1098
+ service: "lisa-plugin",
1099
+ level: "info",
1100
+ message: `session.idle event received. sessionId: ${sessionId || "UNDEFINED"}`,
1101
+ })
1102
+
1103
+ // Find active yolo epic
1104
+ const activeEpic = await findActiveYoloEpic(directory)
1105
+ if (!activeEpic) {
1106
+ await client.app.log({
1107
+ service: "lisa-plugin",
1108
+ level: "info",
1109
+ message: "No active yolo epic found",
1110
+ })
1111
+ return
1112
+ }
1113
+
1114
+ const { name: epicName, state } = activeEpic
1115
+ const yolo = state.yolo!
1116
+
1117
+ // Check remaining tasks
1118
+ const remaining = await countRemainingTasks(directory, epicName)
1119
+
1120
+ // Log progress
1121
+ await client.app.log({
1122
+ service: "lisa-plugin",
1123
+ level: "info",
1124
+ message: `Epic "${epicName}" yolo check: ${remaining} tasks remaining, iteration ${yolo.iteration}/${yolo.maxIterations || "unlimited"}`,
1125
+ })
1126
+
1127
+ // Check if complete
1128
+ if (remaining === 0) {
1129
+ await updateEpicState(directory, epicName, {
1130
+ executeComplete: true,
1131
+ yolo: { ...yolo, active: false },
1132
+ })
1133
+
1134
+ await notify($, "Lisa Complete", `Epic "${epicName}" finished successfully!`)
1135
+
1136
+ await client.app.log({
1137
+ service: "lisa-plugin",
1138
+ level: "info",
1139
+ message: `Epic "${epicName}" completed! All tasks done.`,
1140
+ })
1141
+
1142
+ return
1143
+ }
1144
+
1145
+ // Check max iterations
1146
+ if (yolo.maxIterations > 0 && yolo.iteration >= yolo.maxIterations) {
1147
+ await updateEpicState(directory, epicName, {
1148
+ yolo: { ...yolo, active: false },
1149
+ })
1150
+
1151
+ await notify(
1152
+ $,
1153
+ "Lisa Stopped",
1154
+ `Epic "${epicName}" hit max iterations (${yolo.maxIterations})`
1155
+ )
1156
+
1157
+ await client.app.log({
1158
+ service: "lisa-plugin",
1159
+ level: "warn",
1160
+ message: `Epic "${epicName}" stopped: max iterations (${yolo.maxIterations}) reached with ${remaining} tasks remaining`,
1161
+ })
1162
+
1163
+ return
1164
+ }
1165
+
1166
+ // Continue the epic
1167
+ const nextIteration = yolo.iteration + 1
1168
+ await updateEpicState(directory, epicName, {
1169
+ yolo: { ...yolo, iteration: nextIteration },
1170
+ })
1171
+
1172
+ // Send continuation prompt
1173
+ if (sessionId) {
1174
+ await client.app.log({
1175
+ service: "lisa-plugin",
1176
+ level: "info",
1177
+ message: `Sending continuation prompt for "${epicName}" to session ${sessionId}`,
1178
+ })
1179
+
1180
+ // Build a forceful continuation prompt that leaves no ambiguity
1181
+ const continuationPrompt = `[LISA YOLO MODE - AUTO-CONTINUE]
1182
+
1183
+ Epic: ${epicName}
1184
+ Tasks remaining: ${remaining}
1185
+ Iteration: ${nextIteration}${yolo.maxIterations > 0 ? ` of ${yolo.maxIterations}` : ""}
1186
+
1187
+ MANDATORY ACTIONS:
1188
+ 1. Load the lisa skill
1189
+ 2. Call get_available_tasks("${epicName}") to see ready tasks
1190
+ 3. For each available task, call build_task_context then Task tool
1191
+ 4. Continue until ALL tasks are done
1192
+
1193
+ RULES:
1194
+ - Do NOT stop to summarize
1195
+ - Do NOT ask for confirmation
1196
+ - Do NOT explain what you're about to do
1197
+ - Just execute the next task immediately
1198
+
1199
+ This is automated execution. Keep working.`
1200
+
1201
+ try {
1202
+ await client.session.send({
1203
+ id: sessionId,
1204
+ text: continuationPrompt,
1205
+ })
1206
+
1207
+ await client.app.log({
1208
+ service: "lisa-plugin",
1209
+ level: "info",
1210
+ message: `Epic "${epicName}" continuing: iteration ${nextIteration}, ${remaining} tasks remaining`,
1211
+ })
1212
+ } catch (err) {
1213
+ await client.app.log({
1214
+ service: "lisa-plugin",
1215
+ level: "error",
1216
+ message: `Failed to send continuation: ${err}`,
1217
+ })
1218
+ }
1219
+ } else {
1220
+ await client.app.log({
1221
+ service: "lisa-plugin",
1222
+ level: "warn",
1223
+ message: `No sessionId available - cannot continue epic "${epicName}"`,
1224
+ })
1225
+ }
1226
+ },
1227
+ }
1228
+ }