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.
- package/.opencode/command/lisa.md +7 -0
- package/.opencode/plugin/lisa.ts +1228 -0
- package/.opencode/skill/lisa/SKILL.md +841 -0
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/package.json +32 -0
|
@@ -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
|
+
}
|