@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,407 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ buildContextMdBlock,
4
+ readContext
5
+ } from "./chunk-UXVSQQID.js";
6
+ import {
7
+ normalizeLabels,
8
+ ok,
9
+ warn
10
+ } from "./chunk-5N4BWHIT.js";
11
+
12
+ // src/plan/create.ts
13
+ async function createPlanIssues(source, config, plan) {
14
+ if (!source.createIssue) {
15
+ throw new Error(`Source "${source.name}" does not support createIssue`);
16
+ }
17
+ const labels = normalizeLabels(config);
18
+ const primaryLabel = labels[0] ?? "";
19
+ const sorted = [...plan.issues].sort((a, b) => a.order - b.order);
20
+ const createdIds = [];
21
+ const orderToId = /* @__PURE__ */ new Map();
22
+ for (const issue of sorted) {
23
+ let description = issue.description;
24
+ if (issue.dependsOn.length > 0 && !source.linkDependency) {
25
+ const depRefs = issue.dependsOn.map((depOrder) => {
26
+ const depId = orderToId.get(depOrder);
27
+ return depId ? `#${depId}` : `step ${depOrder}`;
28
+ }).join(", ");
29
+ description += `
30
+
31
+ ---
32
+ _Depends on: ${depRefs}_`;
33
+ }
34
+ const id = await source.createIssue(
35
+ {
36
+ title: issue.title,
37
+ description,
38
+ status: config.pick_from,
39
+ label: primaryLabel,
40
+ order: issue.order,
41
+ parentId: plan.sourceIssueId
42
+ },
43
+ config
44
+ );
45
+ createdIds.push(id);
46
+ orderToId.set(issue.order, id);
47
+ ok(`${id}: ${issue.title}`);
48
+ if (source.linkDependency && issue.dependsOn.length > 0) {
49
+ for (const depOrder of issue.dependsOn) {
50
+ const depId = orderToId.get(depOrder);
51
+ if (depId) {
52
+ try {
53
+ await source.linkDependency(id, depId);
54
+ } catch (err) {
55
+ warn(
56
+ `Could not link dependency ${id} \u2192 ${depId}: ${err instanceof Error ? err.message : String(err)}`
57
+ );
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ return createdIds;
64
+ }
65
+
66
+ // src/plan/parser.ts
67
+ function parsePlanResponse(raw) {
68
+ let cleaned = raw.trim();
69
+ const fenceMatch = cleaned.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
70
+ if (fenceMatch) {
71
+ cleaned = fenceMatch[1].trim();
72
+ }
73
+ const jsonStart = cleaned.indexOf("{");
74
+ const jsonEnd = cleaned.lastIndexOf("}");
75
+ if (jsonStart === -1 || jsonEnd === -1) {
76
+ throw new PlanParseError("No JSON object found in AI response");
77
+ }
78
+ cleaned = cleaned.slice(jsonStart, jsonEnd + 1);
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(cleaned);
82
+ } catch (err) {
83
+ throw new PlanParseError(`Invalid JSON: ${err instanceof Error ? err.message : String(err)}`);
84
+ }
85
+ if (!Array.isArray(parsed.issues) || parsed.issues.length === 0) {
86
+ throw new PlanParseError("Response must contain a non-empty 'issues' array");
87
+ }
88
+ if (parsed.issues.length > 12) {
89
+ throw new PlanParseError(
90
+ `Too many issues (${parsed.issues.length}). Decompose into 2-8 atomic issues.`
91
+ );
92
+ }
93
+ const issues = parsed.issues.map((issue, idx) => {
94
+ if (!issue.title || typeof issue.title !== "string") {
95
+ throw new PlanParseError(`Issue ${idx + 1}: missing or invalid 'title'`);
96
+ }
97
+ if (!issue.description || typeof issue.description !== "string") {
98
+ throw new PlanParseError(`Issue ${idx + 1}: missing or invalid 'description'`);
99
+ }
100
+ return {
101
+ title: issue.title,
102
+ description: issue.description,
103
+ acceptanceCriteria: Array.isArray(issue.acceptanceCriteria) ? issue.acceptanceCriteria.filter((c) => typeof c === "string") : [],
104
+ relevantFiles: Array.isArray(issue.relevantFiles) ? issue.relevantFiles.filter((f) => typeof f === "string") : [],
105
+ order: typeof issue.order === "number" ? issue.order : idx + 1,
106
+ dependsOn: Array.isArray(issue.dependsOn) ? issue.dependsOn.filter((d) => typeof d === "number") : [],
107
+ repo: typeof issue.repo === "string" ? issue.repo : void 0
108
+ };
109
+ });
110
+ return issues;
111
+ }
112
+ var PlanParseError = class extends Error {
113
+ constructor(message) {
114
+ super(message);
115
+ this.name = "PlanParseError";
116
+ }
117
+ };
118
+
119
+ // src/plan/persistence.ts
120
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
121
+ import { join } from "path";
122
+ function plansDir(workspace) {
123
+ return join(workspace, ".lisa", "plans");
124
+ }
125
+ function savePlan(workspace, plan) {
126
+ const dir = plansDir(workspace);
127
+ mkdirSync(dir, { recursive: true });
128
+ const filename = `${plan.createdAt.replace(/[:.]/g, "-")}.json`;
129
+ const filePath = join(dir, filename);
130
+ writeFileSync(filePath, JSON.stringify(plan, null, 2));
131
+ return filePath;
132
+ }
133
+ function loadPlan(filePath) {
134
+ if (!existsSync(filePath)) return null;
135
+ try {
136
+ return JSON.parse(readFileSync(filePath, "utf-8"));
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+ function loadLatestPlan(workspace) {
142
+ const dir = plansDir(workspace);
143
+ if (!existsSync(dir)) return null;
144
+ const files = readdirSync(dir).filter((f) => f.endsWith(".json")).sort().reverse();
145
+ for (const file of files) {
146
+ const filePath = join(dir, file);
147
+ const plan = loadPlan(filePath);
148
+ if (plan && plan.status !== "created") return [plan, filePath];
149
+ }
150
+ return null;
151
+ }
152
+
153
+ // src/plan/prompt.ts
154
+ import { resolve } from "path";
155
+ function buildPlanningPrompt(goal, config, parentIssueDescription) {
156
+ const workspace = resolve(config.workspace);
157
+ const contextMd = readContext(workspace);
158
+ const contextBlock = buildContextMdBlock(contextMd);
159
+ const repoBlock = config.repos.length > 0 ? config.repos.map(
160
+ (r) => `- **${r.name}**: \`${resolve(workspace, r.path)}\` (base: \`${r.base_branch}\`)`
161
+ ).join("\n") : `- **${workspace}** (single-repo, base: \`${config.base_branch}\`)`;
162
+ const parentBlock = parentIssueDescription ? `
163
+ ## Parent Issue Description
164
+
165
+ ${parentIssueDescription}
166
+ ` : "";
167
+ return `You are a project planning agent. Your job is to decompose a high-level goal into atomic, implementable issues.
168
+
169
+ ## Goal
170
+
171
+ ${goal}
172
+ ${parentBlock}
173
+ ## Available Repositories
174
+
175
+ ${repoBlock}
176
+ ${contextBlock}
177
+ ## Instructions
178
+
179
+ Analyze the goal and the codebase context above. Decompose the goal into **2-8 atomic issues** that can each be completed in a single AI coding session (under 1 hour of agent work).
180
+
181
+ For each issue, provide:
182
+ - **title**: Short, descriptive title (imperative: "Add X", "Fix Y", "Create Z")
183
+ - **description**: Full markdown description with context, approach, and acceptance criteria as a \`- [ ]\` checklist
184
+ - **acceptanceCriteria**: Array of the checklist items as plain strings
185
+ - **relevantFiles**: Array of file paths in the codebase that will be modified or created
186
+ - **order**: Integer (1-based) \u2014 execution order based on dependencies
187
+ - **dependsOn**: Array of order numbers this issue depends on (empty if independent)
188
+ ${config.repos.length > 1 ? "- **repo**: Name of the target repository from the list above (required for multi-repo)\n" : ""}
189
+ ## Rules
190
+
191
+ 1. Each issue MUST be self-contained and completable in a single session
192
+ 2. Each issue MUST have at least 2 acceptance criteria
193
+ 3. Each issue MUST reference specific file paths (existing or to be created)
194
+ 4. Issues MUST include test expectations in their acceptance criteria
195
+ 5. Order issues so dependencies come first (lower order = executes first)
196
+ 6. Use clear, specific titles \u2014 not vague ("Improve X" is bad, "Add rate limit middleware to /api/users" is good)
197
+ 7. Output ONLY valid JSON \u2014 no markdown code fences, no explanation text
198
+
199
+ ## Output Format
200
+
201
+ Respond with ONLY this JSON structure (no wrapping, no markdown):
202
+
203
+ {"issues":[{"title":"...","description":"...","acceptanceCriteria":["..."],"relevantFiles":["..."],"order":1,"dependsOn":[]${config.repos.length > 1 ? ',"repo":"..."' : ""}}]}`;
204
+ }
205
+
206
+ // src/plan/wizard.ts
207
+ import { execSync } from "child_process";
208
+ import { mkdtempSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
209
+ import { tmpdir } from "os";
210
+ import { join as join2 } from "path";
211
+ import * as clack from "@clack/prompts";
212
+ import pc from "picocolors";
213
+ async function runPlanWizard(plan, planPath, opts) {
214
+ const workspace = opts.config.workspace;
215
+ while (true) {
216
+ displayPlan(plan);
217
+ const action = await clack.select({
218
+ message: "What would you like to do?",
219
+ options: [
220
+ { value: "approve", label: `${pc.green("Approve all")} \u2014 create issues in source` },
221
+ { value: "edit", label: `${pc.yellow("Edit")} \u2014 edit an issue in $EDITOR` },
222
+ { value: "delete", label: `${pc.red("Delete")} \u2014 remove an issue` },
223
+ { value: "reorder", label: `${pc.cyan("Reorder")} \u2014 change execution order` },
224
+ { value: "cancel", label: `${pc.gray("Cancel")} \u2014 save and exit` }
225
+ ]
226
+ });
227
+ if (clack.isCancel(action) || action === "cancel") {
228
+ plan.status = "draft";
229
+ savePlan(workspace, plan);
230
+ return false;
231
+ }
232
+ if (action === "approve") {
233
+ plan.status = "approved";
234
+ savePlan(workspace, plan);
235
+ return true;
236
+ }
237
+ if (action === "edit") {
238
+ await editIssue(plan, workspace);
239
+ savePlan(workspace, plan);
240
+ }
241
+ if (action === "delete") {
242
+ await deleteIssue(plan, workspace);
243
+ savePlan(workspace, plan);
244
+ }
245
+ if (action === "reorder") {
246
+ await reorderIssues(plan, workspace);
247
+ savePlan(workspace, plan);
248
+ }
249
+ }
250
+ }
251
+ function displayPlan(plan) {
252
+ clack.log.info(`${pc.bold("Goal:")} ${plan.goal}`);
253
+ clack.log.info("");
254
+ const sorted = [...plan.issues].sort((a, b) => a.order - b.order);
255
+ for (const issue of sorted) {
256
+ const deps = issue.dependsOn.length > 0 ? pc.gray(` \u2192 depends on: ${issue.dependsOn.map((d) => `#${d}`).join(", ")}`) : "";
257
+ const repo = issue.repo ? pc.cyan(` [${issue.repo}]`) : "";
258
+ const files = issue.relevantFiles.length > 0 ? pc.gray(
259
+ `
260
+ Files: ${issue.relevantFiles.slice(0, 3).join(", ")}${issue.relevantFiles.length > 3 ? ` +${issue.relevantFiles.length - 3}` : ""}`
261
+ ) : "";
262
+ clack.log.info(
263
+ ` ${pc.yellow(String(issue.order))}. ${pc.bold(issue.title)}${repo}${deps}${files}`
264
+ );
265
+ }
266
+ clack.log.info("");
267
+ }
268
+ async function editIssue(plan, workspace) {
269
+ if (plan.issues.length === 0) {
270
+ clack.log.warning("No issues to edit.");
271
+ return;
272
+ }
273
+ const choice = await clack.select({
274
+ message: "Which issue to edit?",
275
+ options: plan.issues.map((issue2) => ({
276
+ value: issue2.order,
277
+ label: `${issue2.order}. ${issue2.title}`
278
+ }))
279
+ });
280
+ if (clack.isCancel(choice)) return;
281
+ const issue = plan.issues.find((i) => i.order === choice);
282
+ if (!issue) return;
283
+ const tmpDir = mkdtempSync(join2(tmpdir(), "lisa-edit-"));
284
+ const tmpFile = join2(tmpDir, "issue.md");
285
+ writeFileSync2(tmpFile, issueToMarkdown(issue));
286
+ const editor = process.env.EDITOR || process.env.VISUAL || "vi";
287
+ try {
288
+ execSync(`${editor} "${tmpFile}"`, { stdio: "inherit" });
289
+ } catch {
290
+ clack.log.error("Editor failed. Issue unchanged.");
291
+ return;
292
+ }
293
+ const content = readFileSync2(tmpFile, "utf-8");
294
+ const updated = markdownToIssue(content, issue);
295
+ Object.assign(issue, updated);
296
+ clack.log.success(`Updated: ${issue.title}`);
297
+ }
298
+ async function deleteIssue(plan, _workspace) {
299
+ if (plan.issues.length === 0) {
300
+ clack.log.warning("No issues to delete.");
301
+ return;
302
+ }
303
+ const choice = await clack.select({
304
+ message: "Which issue to delete?",
305
+ options: plan.issues.map((issue) => ({
306
+ value: issue.order,
307
+ label: `${issue.order}. ${issue.title}`
308
+ }))
309
+ });
310
+ if (clack.isCancel(choice)) return;
311
+ const idx = plan.issues.findIndex((i) => i.order === choice);
312
+ if (idx === -1) return;
313
+ const removed = plan.issues.splice(idx, 1)[0];
314
+ for (const issue of plan.issues) {
315
+ issue.dependsOn = issue.dependsOn.filter((d) => d !== removed.order);
316
+ }
317
+ clack.log.success(`Deleted: ${removed.title}`);
318
+ }
319
+ async function reorderIssues(plan, _workspace) {
320
+ if (plan.issues.length <= 1) {
321
+ clack.log.warning("Need at least 2 issues to reorder.");
322
+ return;
323
+ }
324
+ clack.log.info("Enter new order (comma-separated issue numbers):");
325
+ clack.log.info(
326
+ `Current: ${plan.issues.sort((a, b) => a.order - b.order).map((i) => i.order).join(", ")}`
327
+ );
328
+ const answer = await clack.text({
329
+ message: "New order (e.g., 3,1,2,4):",
330
+ validate: (input = "") => {
331
+ const nums = input.split(",").map((n) => Number(n.trim()));
332
+ if (nums.some(Number.isNaN)) return "Enter comma-separated numbers";
333
+ if (nums.length !== plan.issues.length) return `Expected ${plan.issues.length} numbers`;
334
+ return void 0;
335
+ }
336
+ });
337
+ if (clack.isCancel(answer)) return;
338
+ const newOrder = answer.split(",").map((n) => Number(n.trim()));
339
+ const oldOrderMap = /* @__PURE__ */ new Map();
340
+ for (const issue of plan.issues) {
341
+ oldOrderMap.set(issue.order, issue);
342
+ }
343
+ for (let i = 0; i < newOrder.length; i++) {
344
+ const issue = oldOrderMap.get(newOrder[i]);
345
+ if (issue) {
346
+ issue.order = i + 1;
347
+ }
348
+ }
349
+ clack.log.success("Issues reordered.");
350
+ }
351
+ function issueToMarkdown(issue) {
352
+ let md = `# ${issue.title}
353
+
354
+ `;
355
+ md += `${issue.description}
356
+ `;
357
+ if (issue.relevantFiles.length > 0) {
358
+ md += `
359
+ ## Relevant Files
360
+
361
+ `;
362
+ for (const f of issue.relevantFiles) {
363
+ md += `- ${f}
364
+ `;
365
+ }
366
+ }
367
+ return md;
368
+ }
369
+ function markdownToIssue(content, original) {
370
+ const lines = content.split("\n");
371
+ const titleLine = lines.find((l) => l.startsWith("# "));
372
+ const title = titleLine ? titleLine.replace(/^#\s+/, "").trim() : original.title;
373
+ const titleIdx = lines.indexOf(titleLine ?? "");
374
+ const filesIdx = lines.findIndex((l) => l.startsWith("## Relevant Files"));
375
+ const descLines = filesIdx > 0 ? lines.slice(titleIdx + 1, filesIdx) : lines.slice(titleIdx + 1);
376
+ const description = descLines.join("\n").trim();
377
+ const acceptanceCriteria = descLines.filter((l) => l.trim().startsWith("- [ ]") || l.trim().startsWith("- [x]")).map((l) => l.trim().replace(/^- \[[ x]\]\s*/, ""));
378
+ const relevantFiles = [];
379
+ if (filesIdx > 0) {
380
+ for (let i = filesIdx + 1; i < lines.length; i++) {
381
+ const line = lines[i].trim();
382
+ if (line.startsWith("- ")) {
383
+ relevantFiles.push(line.replace(/^- /, ""));
384
+ } else if (line.startsWith("#")) {
385
+ break;
386
+ }
387
+ }
388
+ }
389
+ return {
390
+ title,
391
+ description: description || original.description,
392
+ acceptanceCriteria: acceptanceCriteria.length > 0 ? acceptanceCriteria : original.acceptanceCriteria,
393
+ relevantFiles: relevantFiles.length > 0 ? relevantFiles : original.relevantFiles
394
+ };
395
+ }
396
+
397
+ export {
398
+ createPlanIssues,
399
+ parsePlanResponse,
400
+ PlanParseError,
401
+ savePlan,
402
+ loadLatestPlan,
403
+ buildPlanningPrompt,
404
+ runPlanWizard,
405
+ issueToMarkdown,
406
+ markdownToIssue
407
+ };