@wkronmiller/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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +407 -0
  3. package/bin/lisa-runtime.js +8797 -0
  4. package/bin/lisa.js +21 -0
  5. package/completion.ts +58 -0
  6. package/install.ps1 +51 -0
  7. package/install.sh +93 -0
  8. package/lisa.ts +6 -0
  9. package/package.json +66 -0
  10. package/skills/README.md +28 -0
  11. package/skills/claude-code/CLAUDE.md +151 -0
  12. package/skills/codex/AGENTS.md +151 -0
  13. package/skills/gemini/GEMINI.md +151 -0
  14. package/skills/opencode/AGENTS.md +152 -0
  15. package/src/cli.ts +85 -0
  16. package/src/harness/base-adapter.ts +47 -0
  17. package/src/harness/claude-code.ts +106 -0
  18. package/src/harness/codex.ts +80 -0
  19. package/src/harness/command.ts +173 -0
  20. package/src/harness/gemini.ts +74 -0
  21. package/src/harness/opencode.ts +84 -0
  22. package/src/harness/registry.ts +29 -0
  23. package/src/harness/runner.ts +19 -0
  24. package/src/harness/types.ts +73 -0
  25. package/src/output-mode.ts +32 -0
  26. package/src/skill/artifacts.ts +174 -0
  27. package/src/skill/cli.ts +29 -0
  28. package/src/skill/install.ts +317 -0
  29. package/src/spec/agent-guidance.ts +466 -0
  30. package/src/spec/cli.ts +151 -0
  31. package/src/spec/commands/check.ts +1 -0
  32. package/src/spec/commands/config.ts +146 -0
  33. package/src/spec/commands/diff.ts +1 -0
  34. package/src/spec/commands/generate.ts +1 -0
  35. package/src/spec/commands/guide.ts +1 -0
  36. package/src/spec/commands/harness-list.ts +36 -0
  37. package/src/spec/commands/implement.ts +1 -0
  38. package/src/spec/commands/import.ts +1 -0
  39. package/src/spec/commands/init.ts +1 -0
  40. package/src/spec/commands/status.ts +87 -0
  41. package/src/spec/config.ts +63 -0
  42. package/src/spec/diff.ts +791 -0
  43. package/src/spec/extensions/benchmark.ts +347 -0
  44. package/src/spec/extensions/registry.ts +59 -0
  45. package/src/spec/extensions/types.ts +56 -0
  46. package/src/spec/grammar/index.ts +14 -0
  47. package/src/spec/grammar/parser.ts +443 -0
  48. package/src/spec/grammar/types.ts +70 -0
  49. package/src/spec/grammar/validator.ts +104 -0
  50. package/src/spec/loader.ts +174 -0
  51. package/src/spec/local-config.ts +59 -0
  52. package/src/spec/parser.ts +226 -0
  53. package/src/spec/path-utils.ts +73 -0
  54. package/src/spec/planner.ts +299 -0
  55. package/src/spec/prompt-renderer.ts +318 -0
  56. package/src/spec/skill-content.ts +119 -0
  57. package/src/spec/types.ts +239 -0
  58. package/src/spec/validator.ts +443 -0
  59. package/src/spec/workflows/check.ts +1534 -0
  60. package/src/spec/workflows/diff.ts +209 -0
  61. package/src/spec/workflows/generate.ts +1270 -0
  62. package/src/spec/workflows/guide.ts +190 -0
  63. package/src/spec/workflows/implement.ts +797 -0
  64. package/src/spec/workflows/import.ts +986 -0
  65. package/src/spec/workflows/init.ts +548 -0
  66. package/src/spec/workflows/status.ts +22 -0
  67. package/src/spec/workspace.ts +541 -0
  68. package/uninstall.ps1 +21 -0
  69. package/uninstall.sh +22 -0
@@ -0,0 +1,986 @@
1
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "fs";
2
+ import { tmpdir } from "os";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "path";
4
+ import { createInterface } from "readline/promises";
5
+ import { stdin as input, stdout as output } from "process";
6
+
7
+ import { resolveHarnessCommandOverride } from "../../harness/command";
8
+ import { getHarnessAdapterById } from "../../harness/registry";
9
+ import { runHarnessStage } from "../../harness/runner";
10
+ import type { HarnessInspection, HarnessRequest, HarnessResult } from "../../harness/types";
11
+ import { updateAgentGuidanceSafely } from "../agent-guidance";
12
+ import { resolveStageProfile } from "../config";
13
+ import { resolveLocalOverrides } from "../local-config";
14
+ import { buildImportPromptGuidance, validateImportBundleWithExtensions } from "../extensions/registry";
15
+ import { loadSpecWorkspace } from "../loader";
16
+ import { parseSpecEnvironmentConfig, parseSpecDocument } from "../parser";
17
+ import { renderImportPrompt } from "../prompt-renderer";
18
+ import type {
19
+ LoadedSpecWorkspace,
20
+ ParsedEnvironmentConfig,
21
+ ParsedSpecDocument,
22
+ ResolvedStageProfile,
23
+ SpecImportReport,
24
+ SpecImportUncertainty,
25
+ ValidationIssue,
26
+ } from "../types";
27
+ import { SPEC_IMPORT_UNCERTAINTY_LEVELS } from "../types";
28
+ import { validateEnvironmentConfig, validateLoadedSpecWorkspace, validateSpecDocument } from "../validator";
29
+ import { assertSafeLisaStorageWritePath, resolveEffectiveProjectSpecPath, resolveSpecWriteRoot, resolveWorkspaceLayout, toLogicalPath, type SpecWriteScope } from "../workspace";
30
+ import type { ParsedImportBundle } from "../extensions/types";
31
+
32
+ interface ImportCommandOptions {
33
+ paths: string[];
34
+ profile?: string;
35
+ harness?: string;
36
+ model?: string;
37
+ scope?: SpecWriteScope;
38
+ force: boolean;
39
+ dryRun: boolean;
40
+ help: boolean;
41
+ }
42
+
43
+ interface ImportDraftFile {
44
+ path: string;
45
+ content: string;
46
+ }
47
+
48
+ interface ImportParsedDrafts {
49
+ documents: ParsedSpecDocument[];
50
+ environments: ParsedEnvironmentConfig[];
51
+ }
52
+
53
+ export interface ImportCommandIO {
54
+ ask(question: string): Promise<string>;
55
+ print(message: string): void;
56
+ error(message: string): void;
57
+ close?(): void;
58
+ }
59
+
60
+ export interface ImportCommandDeps {
61
+ io?: ImportCommandIO;
62
+ runHarness?: (harnessId: string, request: HarnessRequest, cwd: string) => Promise<HarnessResult>;
63
+ inspectHarness?: (harnessId: string, cwd: string, commandOverride?: string) => Promise<HarnessInspection>;
64
+ }
65
+
66
+ function createConsoleIO(): ImportCommandIO {
67
+ if (!input.isTTY) {
68
+ const rl = createInterface({ input, terminal: false });
69
+ const answers: string[] = [];
70
+ const waiters: Array<(answer: string) => void> = [];
71
+ let closed = false;
72
+
73
+ rl.on("line", (line) => {
74
+ const waiter = waiters.shift();
75
+ if (waiter) {
76
+ waiter(line);
77
+ return;
78
+ }
79
+ answers.push(line);
80
+ });
81
+
82
+ rl.on("close", () => {
83
+ closed = true;
84
+ while (waiters.length > 0) {
85
+ const waiter = waiters.shift();
86
+ waiter?.("");
87
+ }
88
+ });
89
+
90
+ return {
91
+ async ask(question: string): Promise<string> {
92
+ output.write(question);
93
+ if (answers.length > 0) {
94
+ return answers.shift() ?? "";
95
+ }
96
+
97
+ if (closed) {
98
+ return "";
99
+ }
100
+
101
+ return new Promise((resolve) => {
102
+ waiters.push(resolve);
103
+ });
104
+ },
105
+ print(message: string): void {
106
+ console.log(message);
107
+ },
108
+ error(message: string): void {
109
+ console.error(message);
110
+ },
111
+ close(): void {
112
+ rl.close();
113
+ },
114
+ };
115
+ }
116
+
117
+ const rl = createInterface({ input, output });
118
+
119
+ return {
120
+ async ask(question: string): Promise<string> {
121
+ return rl.question(question);
122
+ },
123
+ print(message: string): void {
124
+ console.log(message);
125
+ },
126
+ error(message: string): void {
127
+ console.error(message);
128
+ },
129
+ close(): void {
130
+ rl.close();
131
+ },
132
+ };
133
+ }
134
+
135
+ function printImportHelp(io: ImportCommandIO): void {
136
+ io.print(`Lisa spec import
137
+
138
+ Usage:
139
+ lisa spec import <path...> [options]
140
+
141
+ Options:
142
+ --profile <name> Override the import stage profile
143
+ --harness <id> Override the configured harness adapter
144
+ --model <name> Override the configured model
145
+ --scope <name> Write to the shared repo layer or worktree override layer
146
+ --force Skip approval prompts and write immediately
147
+ --dry-run Show the proposed patch without writing files
148
+ --help, -h Show this help
149
+ `);
150
+ }
151
+
152
+ function parseImportArgs(args: string[]): ImportCommandOptions {
153
+ const options: ImportCommandOptions = {
154
+ paths: [],
155
+ force: false,
156
+ dryRun: false,
157
+ help: false,
158
+ };
159
+
160
+ for (let index = 0; index < args.length; index += 1) {
161
+ const arg = args[index];
162
+ if (!arg) {
163
+ continue;
164
+ }
165
+
166
+ if (arg === "--help" || arg === "-h") {
167
+ options.help = true;
168
+ continue;
169
+ }
170
+
171
+ if (arg === "--force") {
172
+ options.force = true;
173
+ continue;
174
+ }
175
+
176
+ if (arg === "--dry-run") {
177
+ options.dryRun = true;
178
+ continue;
179
+ }
180
+
181
+ if (arg === "--profile" || arg === "--harness" || arg === "--model" || arg === "--scope") {
182
+ const value = args[index + 1];
183
+ if (!value) {
184
+ throw new Error(`${arg} requires a value.`);
185
+ }
186
+
187
+ index += 1;
188
+ if (arg === "--profile") {
189
+ options.profile = value;
190
+ } else if (arg === "--harness") {
191
+ options.harness = value;
192
+ } else if (arg === "--scope") {
193
+ if (value !== "repo" && value !== "worktree") {
194
+ throw new Error("--scope requires `repo` or `worktree`.");
195
+ }
196
+ options.scope = value;
197
+ } else {
198
+ options.model = value;
199
+ }
200
+ continue;
201
+ }
202
+
203
+ if (arg.startsWith("--")) {
204
+ throw new Error(`Unknown lisa spec import option: ${arg}`);
205
+ }
206
+
207
+ options.paths.push(arg);
208
+ }
209
+
210
+ if (!options.help && options.paths.length === 0) {
211
+ throw new Error("lisa spec import requires at least one path to analyze.");
212
+ }
213
+
214
+ return options;
215
+ }
216
+
217
+ function normalizeBooleanAnswer(answer: string, fallback = false): boolean {
218
+ const normalized = answer.trim().toLowerCase();
219
+ if (!normalized) {
220
+ return fallback;
221
+ }
222
+
223
+ if (["y", "yes"].includes(normalized)) {
224
+ return true;
225
+ }
226
+
227
+ if (["n", "no"].includes(normalized)) {
228
+ return false;
229
+ }
230
+
231
+ return fallback;
232
+ }
233
+
234
+ async function promptYesNo(io: ImportCommandIO, question: string, fallback = false): Promise<boolean> {
235
+ const label = fallback ? "Y/n" : "y/N";
236
+ const answer = await io.ask(`${question} [${label}]: `);
237
+ return normalizeBooleanAnswer(answer, fallback);
238
+ }
239
+
240
+ function assertSafeImportSourcePath(workspaceRoot: string, targetPath: string): void {
241
+ const workspaceRealPath = realpathSync(workspaceRoot);
242
+ let cursor = targetPath;
243
+
244
+ while (cursor.startsWith(workspaceRoot)) {
245
+ if (existsSync(cursor) && lstatSync(cursor).isSymbolicLink()) {
246
+ throw new Error(`Import paths must not traverse symlinked segments: ${targetPath}`);
247
+ }
248
+ if (cursor === workspaceRoot) {
249
+ break;
250
+ }
251
+ cursor = dirname(cursor);
252
+ }
253
+
254
+ const realTargetPath = realpathSync(targetPath);
255
+ if (realTargetPath !== workspaceRealPath && !realTargetPath.startsWith(`${workspaceRealPath}/`)) {
256
+ throw new Error(`Import paths must stay inside the workspace: ${targetPath}`);
257
+ }
258
+ }
259
+
260
+ function shouldTraverseImportDirectory(name: string): boolean {
261
+ return ![".git", "node_modules", ".lisa"].includes(name);
262
+ }
263
+
264
+ function shouldIncludeImportFile(path: string): boolean {
265
+ const name = basename(path);
266
+ if (/^\.env(?:\..*)?$/i.test(name) || /(?:secret|token|credential|private)/i.test(name)) {
267
+ return false;
268
+ }
269
+
270
+ if ([".pem", ".key", ".p12", ".pfx", ".crt", ".cer", ".der", ".jks", ".keystore"].some((suffix) => name.toLowerCase().endsWith(suffix))) {
271
+ return false;
272
+ }
273
+
274
+ const extension = name.includes(".") ? name.slice(name.lastIndexOf(".")).toLowerCase() : "";
275
+ if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip", ".gz", ".tar", ".mp4", ".mov", ".mp3", ".wav"].includes(extension)) {
276
+ return false;
277
+ }
278
+
279
+ if (["Dockerfile", "Makefile", "README", "README.md", "README.txt"].includes(name)) {
280
+ return true;
281
+ }
282
+
283
+ return [
284
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".md", ".mdx", ".yaml", ".yml", ".toml", ".txt", ".py", ".rb",
285
+ ".go", ".rs", ".java", ".kt", ".swift", ".php", ".c", ".cc", ".cpp", ".h", ".hpp", ".cs", ".sh", ".sql", ".graphql",
286
+ ".proto", ".html", ".css", ".scss", ".sass", ".less", ".xml",
287
+ ].includes(extension);
288
+ }
289
+
290
+ function resolveProfile(workspace: LoadedSpecWorkspace, options: ImportCommandOptions): ResolvedStageProfile {
291
+ const local = resolveLocalOverrides(workspace.localConfigRoot);
292
+ if (workspace.config) {
293
+ const resolved = resolveStageProfile(workspace.config, "import", {
294
+ profile: options.profile,
295
+ harness: options.harness,
296
+ model: options.model,
297
+ }, local);
298
+ if (!resolved) {
299
+ throw new Error("Unable to resolve an import stage profile from `.specs/config.yaml`.");
300
+ }
301
+ return resolved;
302
+ }
303
+
304
+ return {
305
+ stage: "import",
306
+ profileName: options.profile ?? "default-import",
307
+ harness: options.harness ?? local.harness ?? "opencode",
308
+ model: options.model,
309
+ allowEdits: false,
310
+ args: [],
311
+ };
312
+ }
313
+
314
+ function formatCapability(value: boolean): string {
315
+ return value ? "yes" : "no";
316
+ }
317
+
318
+ async function defaultInspectHarness(harnessId: string, cwd: string, commandOverride?: string): Promise<HarnessInspection> {
319
+ const adapter = getHarnessAdapterById(harnessId, cwd);
320
+ if (!adapter) {
321
+ throw new Error(`No harness adapter is registered for \`${harnessId}\`.`);
322
+ }
323
+
324
+ return {
325
+ adapter,
326
+ availability: commandOverride
327
+ ? {
328
+ available: true,
329
+ command: commandOverride,
330
+ reason: "Configured command override supplied by the selected stage profile.",
331
+ }
332
+ : await adapter.detect(),
333
+ capabilities: await adapter.capabilities(),
334
+ };
335
+ }
336
+
337
+ function ensureHarnessSupportsImport(inspection: HarnessInspection, profile: ResolvedStageProfile, options: ImportCommandOptions): void {
338
+ if (!inspection.adapter.supportedStages.includes("import")) {
339
+ throw new Error(`Harness \`${inspection.adapter.id}\` does not support the import stage.`);
340
+ }
341
+
342
+ if (profile.allowEdits) {
343
+ throw new Error(`Import stage profile \`${profile.profileName}\` must declare \`allow_edits: false\`.`);
344
+ }
345
+
346
+ if (options.model && !inspection.capabilities.supportsModelSelection) {
347
+ throw new Error(`Harness \`${inspection.adapter.id}\` does not support model overrides.`);
348
+ }
349
+
350
+ if (!inspection.availability.available) {
351
+ throw new Error(`Harness \`${inspection.adapter.id}\` is unavailable: ${inspection.availability.reason}`);
352
+ }
353
+ }
354
+
355
+ function printHarnessReport(io: ImportCommandIO, inspection: HarnessInspection, profile: ResolvedStageProfile): void {
356
+ io.print("Harness");
357
+ io.print(`- adapter: ${inspection.adapter.id} (${inspection.adapter.displayName})`);
358
+ io.print(`- profile: ${profile.profileName}`);
359
+ io.print(`- command: ${inspection.availability.command ?? "(auto-detect)"}`);
360
+ io.print(`- availability: ${inspection.availability.reason}`);
361
+ io.print(`- supports model selection: ${formatCapability(inspection.capabilities.supportsModelSelection)}`);
362
+ io.print(`- supports read-only mode: ${formatCapability(inspection.capabilities.supportsReadOnlyMode)}`);
363
+ io.print(`- workspace isolation: yes (snapshot import context)`);
364
+ io.print("");
365
+ }
366
+
367
+ function extractJsonPayload(text: string): string | undefined {
368
+ const markerMatch = text.match(/LISA_IMPORT_JSON_START\s*([\s\S]*?)\s*LISA_IMPORT_JSON_END/);
369
+ if (markerMatch?.[1]) {
370
+ return markerMatch[1].trim();
371
+ }
372
+
373
+ const fencedMatch = text.match(/```json\s*([\s\S]*?)```/i);
374
+ if (fencedMatch?.[1]) {
375
+ return fencedMatch[1].trim();
376
+ }
377
+
378
+ const firstBrace = text.indexOf("{");
379
+ const lastBrace = text.lastIndexOf("}");
380
+ if (firstBrace >= 0 && lastBrace > firstBrace) {
381
+ return text.slice(firstBrace, lastBrace + 1).trim();
382
+ }
383
+
384
+ return undefined;
385
+ }
386
+
387
+ function parseJsonPayloadWithRecovery(payload: string): unknown {
388
+ const attempts = [payload];
389
+ if (payload.includes('\\"')) {
390
+ attempts.push(payload.replace(/\\"/g, '"'));
391
+ }
392
+
393
+ for (const attempt of attempts) {
394
+ try {
395
+ return JSON.parse(attempt);
396
+ } catch {
397
+ // Try the next recovery strategy.
398
+ }
399
+ }
400
+
401
+ return undefined;
402
+ }
403
+
404
+ function parseImportBundle(result: HarnessResult): ParsedImportBundle {
405
+ const raw = typeof result.structuredOutput === "object" && result.structuredOutput !== null
406
+ ? result.structuredOutput
407
+ : result.finalText
408
+ ? parseJsonPayloadWithRecovery(extractJsonPayload(result.finalText) ?? "null")
409
+ : null;
410
+
411
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
412
+ throw new Error("Harness did not return a valid JSON import payload.");
413
+ }
414
+
415
+ const files = Array.isArray((raw as { files?: unknown[] }).files)
416
+ ? (raw as { files: unknown[] }).files
417
+ .filter(
418
+ (entry): entry is { path: string; content: string } =>
419
+ typeof entry === "object"
420
+ && entry !== null
421
+ && typeof (entry as { path?: unknown }).path === "string"
422
+ && typeof (entry as { content?: unknown }).content === "string",
423
+ )
424
+ .map((entry) => ({ path: entry.path.trim(), content: entry.content.trim() }))
425
+ : [];
426
+
427
+ if (files.length === 0) {
428
+ throw new Error("Harness import payload did not include any draft files.");
429
+ }
430
+
431
+ const notes = Array.isArray((raw as { notes?: unknown[] }).notes)
432
+ ? (raw as { notes: unknown[] }).notes.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
433
+ : [];
434
+
435
+ const uncertainties = Array.isArray((raw as { uncertainties?: unknown[] }).uncertainties)
436
+ ? (raw as { uncertainties: unknown[] }).uncertainties
437
+ .filter(
438
+ (entry): entry is SpecImportUncertainty =>
439
+ typeof entry === "object"
440
+ && entry !== null
441
+ && typeof (entry as { detail?: unknown }).detail === "string"
442
+ && typeof (entry as { level?: unknown }).level === "string"
443
+ && SPEC_IMPORT_UNCERTAINTY_LEVELS.includes((entry as { level: SpecImportUncertainty["level"] }).level),
444
+ )
445
+ .map((entry) => ({
446
+ path: typeof entry.path === "string" && entry.path.trim().length > 0 ? entry.path.trim() : undefined,
447
+ specId: typeof entry.specId === "string" && entry.specId.trim().length > 0 ? entry.specId.trim() : undefined,
448
+ level: entry.level,
449
+ detail: entry.detail.trim(),
450
+ question: typeof entry.question === "string" && entry.question.trim().length > 0 ? entry.question.trim() : undefined,
451
+ }))
452
+ : [];
453
+
454
+ return { files, notes, uncertainties };
455
+ }
456
+
457
+ function normalizeDraftPaths(workspaceRoot: string, drafts: ImportDraftFile[], scope: SpecWriteScope = "repo"): ImportDraftFile[] {
458
+ const layout = resolveWorkspaceLayout(workspaceRoot);
459
+ const writeRoot = resolveSpecWriteRoot(workspaceRoot, scope);
460
+ return drafts.map((draft) => ({
461
+ path: (() => {
462
+ if (draft.path.startsWith(".specs/")) {
463
+ return resolve(writeRoot, draft.path.replace(/^\.specs\//, ""));
464
+ }
465
+
466
+ if (isAbsolute(draft.path)) {
467
+ const logicalPath = toLogicalPath(layout, draft.path);
468
+ if (logicalPath.startsWith(".specs/")) {
469
+ return resolve(writeRoot, logicalPath.replace(/^\.specs\//, ""));
470
+ }
471
+ }
472
+
473
+ return resolve(workspaceRoot, draft.path);
474
+ })(),
475
+ content: draft.content,
476
+ }));
477
+ }
478
+
479
+ function isAllowedImportPath(workspaceRoot: string, targetPath: string): boolean {
480
+ const relativePath = toLogicalPath(resolveWorkspaceLayout(workspaceRoot), targetPath);
481
+ if ((relativePath.startsWith(".specs/backend/") || relativePath.startsWith(".specs/frontend/")) && relativePath.endsWith(".md")) {
482
+ return true;
483
+ }
484
+
485
+ if (relativePath.startsWith(".specs/environments/") && (relativePath.endsWith(".yaml") || relativePath.endsWith(".yml"))) {
486
+ return true;
487
+ }
488
+
489
+ return false;
490
+ }
491
+
492
+ function enforceAllowedImportPaths(workspaceRoot: string, drafts: ImportDraftFile[]): void {
493
+ for (const draft of drafts) {
494
+ if (!isAllowedImportPath(workspaceRoot, draft.path)) {
495
+ throw new Error(`Harness returned an unexpected import file path: ${draft.path}`);
496
+ }
497
+ }
498
+ }
499
+
500
+ function normalizeHeading(title: string): string {
501
+ return title.toLowerCase().replace(/\s+/g, " ").trim();
502
+ }
503
+
504
+ function hasSection(document: ParsedSpecDocument, expectedTitle: string): boolean {
505
+ const expected = normalizeHeading(expectedTitle);
506
+
507
+ for (const [index, section] of document.sections.entries()) {
508
+ if (normalizeHeading(section.title) !== expected) {
509
+ continue;
510
+ }
511
+
512
+ if (section.content.length > 0) {
513
+ return true;
514
+ }
515
+
516
+ for (let childIndex = index + 1; childIndex < document.sections.length; childIndex += 1) {
517
+ const childSection = document.sections[childIndex];
518
+ if (childSection.level <= section.level) {
519
+ break;
520
+ }
521
+
522
+ if (childSection.content.length > 0) {
523
+ return true;
524
+ }
525
+ }
526
+ }
527
+
528
+ return false;
529
+ }
530
+
531
+ function parseImportedDrafts(drafts: ImportDraftFile[]): ImportParsedDrafts {
532
+ const documents: ParsedSpecDocument[] = [];
533
+ const environments: ParsedEnvironmentConfig[] = [];
534
+
535
+ for (const draft of drafts) {
536
+ if (draft.path.endsWith(".md")) {
537
+ documents.push(parseSpecDocument(draft.path, draft.content));
538
+ continue;
539
+ }
540
+
541
+ if (draft.path.endsWith(".yaml") || draft.path.endsWith(".yml")) {
542
+ environments.push(parseSpecEnvironmentConfig(draft.path, draft.content));
543
+ }
544
+ }
545
+
546
+ return { documents, environments };
547
+ }
548
+
549
+ function validateImportedDrafts(
550
+ workspace: LoadedSpecWorkspace,
551
+ bundle: ParsedImportBundle,
552
+ drafts: ImportDraftFile[],
553
+ ): ValidationIssue[] {
554
+ const layout = resolveWorkspaceLayout(workspace.workspacePath);
555
+ const issues: ValidationIssue[] = [];
556
+ const parsed = parseImportedDrafts(drafts);
557
+
558
+ for (const document of parsed.documents) {
559
+ issues.push(...validateSpecDocument(document));
560
+
561
+ const logicalDocumentPath = toLogicalPath(layout, document.path);
562
+ const existingDocument = workspace.documents.find((entry) => toLogicalPath(layout, entry.path) === logicalDocumentPath);
563
+ if (existingDocument && existingDocument.status !== "draft") {
564
+ issues.push({
565
+ severity: "error",
566
+ path: document.path,
567
+ message: `Import cannot overwrite existing non-draft spec \`${existingDocument.id ?? document.path}\` without an explicit migration step.`,
568
+ });
569
+ }
570
+
571
+ if (document.status !== "draft") {
572
+ issues.push({
573
+ severity: "error",
574
+ path: document.path,
575
+ message: "Imported specs must declare `status: draft`.",
576
+ });
577
+ }
578
+
579
+ if (document.kind === "base") {
580
+ for (const title of ["Summary", "Use Cases", "Invariants", "Acceptance Criteria", "Out of Scope", "Open Questions", "Uncertainty"]) {
581
+ if (!hasSection(document, title)) {
582
+ issues.push({
583
+ severity: "error",
584
+ path: document.path,
585
+ message: `Imported base specs must include a non-empty \`${title}\` section.`,
586
+ });
587
+ }
588
+ }
589
+ }
590
+ }
591
+
592
+ for (const environment of parsed.environments) {
593
+ issues.push(...validateEnvironmentConfig(environment));
594
+
595
+ const logicalEnvironmentPath = toLogicalPath(layout, environment.path);
596
+ const existingEnvironment = workspace.environments.find((entry) => toLogicalPath(layout, entry.path) === logicalEnvironmentPath);
597
+ if (existingEnvironment) {
598
+ issues.push({
599
+ severity: "error",
600
+ path: environment.path,
601
+ message: `Import cannot overwrite existing environment config \`${existingEnvironment.name ?? environment.path}\` without an explicit migration step.`,
602
+ });
603
+ }
604
+ }
605
+
606
+ if (bundle.uncertainties.length === 0) {
607
+ issues.push({
608
+ severity: "error",
609
+ path: workspace.specRoot,
610
+ message: "Imported bundles must capture at least one uncertainty entry.",
611
+ });
612
+ }
613
+
614
+ const replacedPaths = new Set(drafts.map((draft) => toLogicalPath(layout, draft.path)));
615
+ const mergedDocuments = [
616
+ ...workspace.documents.filter((document) => !replacedPaths.has(toLogicalPath(layout, document.path))),
617
+ ...parsed.documents,
618
+ ];
619
+ const mergedEnvironments = [
620
+ ...workspace.environments.filter((environment) => !replacedPaths.has(toLogicalPath(layout, environment.path))),
621
+ ...parsed.environments,
622
+ ];
623
+
624
+ issues.push(...validateLoadedSpecWorkspace({
625
+ workspacePath: workspace.workspacePath,
626
+ config: workspace.config,
627
+ documents: mergedDocuments,
628
+ environments: mergedEnvironments,
629
+ }));
630
+ issues.push(...validateImportBundleWithExtensions(bundle, workspace));
631
+ return issues;
632
+ }
633
+
634
+ function buildPatchPreview(workspaceRoot: string, targetPath: string, nextContent: string): string {
635
+ const layout = resolveWorkspaceLayout(workspaceRoot);
636
+ const currentContent = existsSync(targetPath) ? readFileSync(targetPath, "utf8") : "";
637
+ const tempDir = mkdtempSync(join(tmpdir(), "lisa-import-preview-"));
638
+ const beforePath = join(tempDir, "before.txt");
639
+ const afterPath = join(tempDir, "after.txt");
640
+ writeFileSync(beforePath, currentContent);
641
+ writeFileSync(afterPath, nextContent);
642
+
643
+ try {
644
+ const proc = Bun.spawnSync({
645
+ cmd: ["git", "diff", "--no-index", "--no-color", "--", beforePath, afterPath],
646
+ stdout: "pipe",
647
+ stderr: "pipe",
648
+ });
649
+ const relativePath = toLogicalPath(layout, targetPath) || targetPath;
650
+ const beforeLabel = `a/${relativePath}`;
651
+ const afterLabel = `b/${relativePath}`;
652
+ const stdout = new TextDecoder().decode(proc.stdout);
653
+
654
+ if (!stdout.trim()) {
655
+ return `diff --git ${beforeLabel} ${afterLabel}\n(no content changes)`;
656
+ }
657
+
658
+ return stdout
659
+ .split(beforePath).join(relativePath)
660
+ .split(afterPath).join(relativePath)
661
+ .split(`a/${beforePath}`).join(beforeLabel)
662
+ .split(`b/${afterPath}`).join(afterLabel);
663
+ } finally {
664
+ rmSync(tempDir, { recursive: true, force: true });
665
+ }
666
+ }
667
+
668
+ function assertSafeWritePath(workspaceRoot: string, targetPath: string): void {
669
+ const layout = resolveWorkspaceLayout(workspaceRoot);
670
+ if (layout.storageMode === "external") {
671
+ const normalizedTargetPath = resolve(targetPath);
672
+ const allowedRoots = [layout.repoSpecRoot, layout.worktreeSpecRoot].filter((root): root is string => Boolean(root));
673
+ const writeRoot = allowedRoots.find((root) => normalizedTargetPath === resolve(root) || normalizedTargetPath.startsWith(`${resolve(root)}/`));
674
+ if (!writeRoot) {
675
+ throw new Error(`Refusing to write imported drafts outside the Lisa workspace: ${targetPath}`);
676
+ }
677
+ assertSafeLisaStorageWritePath(writeRoot, targetPath, "Lisa spec import");
678
+ return;
679
+ }
680
+
681
+ const allowedRoots = [
682
+ workspaceRoot,
683
+ layout.repoSpecRoot,
684
+ layout.worktreeSpecRoot,
685
+ layout.runtimeRoot,
686
+ ]
687
+ .filter((root): root is string => Boolean(root))
688
+ .map((root) => existsSync(root) ? realpathSync(root) : resolve(root));
689
+ let existingPath = targetPath;
690
+
691
+ while (!existsSync(existingPath)) {
692
+ const parentPath = dirname(existingPath);
693
+ if (parentPath === existingPath) {
694
+ throw new Error(`Could not resolve a safe parent directory for ${targetPath}`);
695
+ }
696
+ existingPath = parentPath;
697
+ }
698
+
699
+ let cursor = existingPath;
700
+ while (cursor.startsWith(workspaceRoot)) {
701
+ if (existsSync(cursor) && lstatSync(cursor).isSymbolicLink()) {
702
+ throw new Error(`Refusing to write through symlinked import path: ${cursor}`);
703
+ }
704
+ if (cursor === workspaceRoot) {
705
+ break;
706
+ }
707
+ cursor = dirname(cursor);
708
+ }
709
+
710
+ const realExistingPath = realpathSync(existingPath);
711
+ if (!allowedRoots.some((root) => realExistingPath === root || realExistingPath.startsWith(`${root}/`))) {
712
+ throw new Error(`Refusing to write imported drafts outside the workspace: ${targetPath}`);
713
+ }
714
+
715
+ if (existsSync(targetPath)) {
716
+ const realTargetPath = realpathSync(targetPath);
717
+ if (!allowedRoots.some((root) => realTargetPath === root || realTargetPath.startsWith(`${root}/`))) {
718
+ throw new Error(`Refusing to overwrite imported draft outside the workspace: ${targetPath}`);
719
+ }
720
+ }
721
+ }
722
+
723
+ function writeDrafts(workspaceRoot: string, drafts: ImportDraftFile[]): void {
724
+ for (const draft of drafts) {
725
+ assertSafeWritePath(workspaceRoot, draft.path);
726
+ mkdirSync(dirname(draft.path), { recursive: true });
727
+ writeFileSync(draft.path, `${draft.content.trim()}\n`);
728
+ }
729
+ }
730
+
731
+ interface ImportContextFile {
732
+ sourcePath: string;
733
+ snapshotRelativePath: string;
734
+ }
735
+
736
+ function collectImportContextFiles(workspaceRoot: string, selectedPaths: string[]): ImportContextFile[] {
737
+ const candidates = new Map<string, ImportContextFile>();
738
+ const layout = resolveWorkspaceLayout(workspaceRoot);
739
+
740
+ const addCandidate = (sourcePath: string, snapshotRelativePath: string): void => {
741
+ candidates.set(snapshotRelativePath, { sourcePath, snapshotRelativePath });
742
+ };
743
+
744
+ const visit = (targetPath: string): void => {
745
+ assertSafeImportSourcePath(workspaceRoot, targetPath);
746
+ const stats = lstatSync(targetPath);
747
+
748
+ if (stats.isDirectory()) {
749
+ for (const entry of readdirSync(targetPath)) {
750
+ if (!shouldTraverseImportDirectory(entry)) {
751
+ continue;
752
+ }
753
+ visit(join(targetPath, entry));
754
+ }
755
+ return;
756
+ }
757
+
758
+ if (!stats.isFile() || !shouldIncludeImportFile(targetPath)) {
759
+ return;
760
+ }
761
+
762
+ addCandidate(targetPath, relative(workspaceRoot, targetPath).split("\\").join("/"));
763
+ };
764
+
765
+ for (const selectedPath of selectedPaths) {
766
+ visit(selectedPath);
767
+ }
768
+
769
+ for (const common of ["README.md", ".specs/config.yaml", "package.json"]) {
770
+ const fullPath = join(workspaceRoot, common);
771
+ if (existsSync(fullPath)) {
772
+ visit(fullPath);
773
+ }
774
+ }
775
+
776
+ const projectSpecPath = resolveEffectiveProjectSpecPath(layout);
777
+ if (projectSpecPath && existsSync(projectSpecPath)) {
778
+ const logicalPath = toLogicalPath(layout, projectSpecPath);
779
+ if (logicalPath.startsWith(".specs/")) {
780
+ addCandidate(projectSpecPath, logicalPath);
781
+ }
782
+ }
783
+
784
+ return Array.from(candidates.values()).sort((left, right) => left.snapshotRelativePath.localeCompare(right.snapshotRelativePath));
785
+ }
786
+
787
+ function buildImportSnapshot(_workspaceRoot: string, files: ImportContextFile[]): { snapshotRoot: string; contextFiles: string[] } {
788
+ const snapshotRoot = mkdtempSync(join(tmpdir(), "lisa-import-context-"));
789
+
790
+ for (const file of files) {
791
+ const destinationPath = join(snapshotRoot, file.snapshotRelativePath);
792
+ mkdirSync(dirname(destinationPath), { recursive: true });
793
+ writeFileSync(destinationPath, readFileSync(file.sourcePath));
794
+ }
795
+
796
+ return {
797
+ snapshotRoot,
798
+ contextFiles: files.map((file) => join(snapshotRoot, file.snapshotRelativePath)),
799
+ };
800
+ }
801
+
802
+ function writeImportArtifact(workspaceRoot: string, report: SpecImportReport): string {
803
+ const runtimeDir = resolveWorkspaceLayout(workspaceRoot).runtimeRoot;
804
+ mkdirSync(runtimeDir, { recursive: true });
805
+ const artifactPath = join(runtimeDir, "spec-import-report.json");
806
+ writeFileSync(artifactPath, JSON.stringify(report, null, 2) + "\n");
807
+ return artifactPath;
808
+ }
809
+
810
+ function printUncertainties(io: ImportCommandIO, uncertainties: SpecImportUncertainty[]): void {
811
+ if (uncertainties.length === 0) {
812
+ return;
813
+ }
814
+
815
+ io.print("Captured Uncertainties");
816
+ for (const uncertainty of uncertainties) {
817
+ io.print(`- ${uncertainty.level}: ${uncertainty.specId ?? uncertainty.path ?? "(unspecified target)"} - ${uncertainty.detail}`);
818
+ if (uncertainty.question) {
819
+ io.print(` question: ${uncertainty.question}`);
820
+ }
821
+ }
822
+ io.print("");
823
+ }
824
+
825
+ export async function runSpecImportCommand(
826
+ args: string[],
827
+ cwd = process.cwd(),
828
+ deps: ImportCommandDeps = {},
829
+ ): Promise<number> {
830
+ const io = deps.io ?? createConsoleIO();
831
+
832
+ try {
833
+ const options = parseImportArgs(args);
834
+ if (options.help) {
835
+ printImportHelp(io);
836
+ return 0;
837
+ }
838
+
839
+ const layout = resolveWorkspaceLayout(cwd);
840
+ const workspaceRoot = layout.workspacePath;
841
+ const workspace = loadSpecWorkspace(workspaceRoot);
842
+ const profile = resolveProfile(workspace, options);
843
+ const inspectHarness = deps.inspectHarness ?? defaultInspectHarness;
844
+ const inspection = await inspectHarness(profile.harness, workspaceRoot, profile.command);
845
+ ensureHarnessSupportsImport(inspection, profile, options);
846
+ printHarnessReport(io, inspection, profile);
847
+
848
+ const selectedPaths = options.paths.map((entry) => resolve(workspaceRoot, entry));
849
+ for (const path of selectedPaths) {
850
+ const relativePath = relative(workspaceRoot, path).split("\\").join("/");
851
+ if (relativePath === ".." || relativePath.startsWith("../")) {
852
+ throw new Error(`Import paths must stay inside the workspace: ${path}`);
853
+ }
854
+
855
+ if (!existsSync(path)) {
856
+ throw new Error(`Import path does not exist: ${relative(workspaceRoot, path) || path}`);
857
+ }
858
+
859
+ assertSafeImportSourcePath(workspaceRoot, path);
860
+ }
861
+
862
+ io.print("Selection");
863
+ for (const selectedPath of selectedPaths) {
864
+ io.print(`- ${relative(workspaceRoot, selectedPath) || selectedPath}`);
865
+ }
866
+ io.print("");
867
+
868
+ const prompt = renderImportPrompt({
869
+ workspacePath: workspaceRoot,
870
+ selectedPaths: selectedPaths.map((path) => relative(workspaceRoot, path) || path),
871
+ existingDocuments: workspace.documents,
872
+ extensionGuidance: buildImportPromptGuidance({
873
+ workspace,
874
+ selectedPaths: selectedPaths.map((path) => relative(workspaceRoot, path) || path),
875
+ }),
876
+ directContextOnly: inspection.adapter.id === "opencode",
877
+ });
878
+ const runHarness = deps.runHarness ?? runHarnessStage;
879
+ const importContextFiles = collectImportContextFiles(workspaceRoot, selectedPaths);
880
+ const snapshot = buildImportSnapshot(workspaceRoot, importContextFiles);
881
+ let result: HarnessResult;
882
+ try {
883
+ result = await runHarness(
884
+ profile.harness,
885
+ {
886
+ stage: "import",
887
+ prompt,
888
+ cwd: snapshot.snapshotRoot,
889
+ allowEdits: false,
890
+ contextFiles: snapshot.contextFiles,
891
+ env: profile.command ? { LISA_HARNESS_COMMAND: resolveHarnessCommandOverride(profile.command, dirname(workspace.configPath)) } : undefined,
892
+ model: profile.model,
893
+ profile: profile.profileName,
894
+ extraArgs: profile.args,
895
+ limits: { maxTurns: 4 },
896
+ },
897
+ workspaceRoot,
898
+ );
899
+ } finally {
900
+ rmSync(snapshot.snapshotRoot, { recursive: true, force: true });
901
+ }
902
+
903
+ if (result.status === "failed" || result.status === "blocked") {
904
+ io.error(result.abortReason || result.finalText || "Harness failed to draft imported specs.");
905
+ return 1;
906
+ }
907
+
908
+ const bundle = parseImportBundle(result);
909
+ const normalizedDrafts = normalizeDraftPaths(workspaceRoot, bundle.files, options.scope ?? "repo");
910
+ enforceAllowedImportPaths(workspaceRoot, normalizedDrafts);
911
+ const issues = validateImportedDrafts(workspace, bundle, normalizedDrafts);
912
+
913
+ io.print("Proposed patch");
914
+ io.print("");
915
+ for (const draft of normalizedDrafts) {
916
+ io.print(buildPatchPreview(workspaceRoot, draft.path, draft.content));
917
+ io.print("");
918
+ }
919
+
920
+ if (bundle.notes.length > 0) {
921
+ io.print("Import Notes");
922
+ for (const note of bundle.notes) {
923
+ io.print(`- ${note}`);
924
+ }
925
+ io.print("");
926
+ }
927
+
928
+ printUncertainties(io, bundle.uncertainties);
929
+
930
+ if (issues.length > 0) {
931
+ io.print("Validation issues");
932
+ for (const issue of issues) {
933
+ io.print(`- ${issue.severity}: ${toLogicalPath(layout, issue.path) || issue.path} - ${issue.message}`);
934
+ }
935
+ io.print("");
936
+ }
937
+
938
+ if (issues.some((issue) => issue.severity === "error")) {
939
+ io.error("Refusing to write imported specs with validation errors.");
940
+ return 1;
941
+ }
942
+
943
+ const report: SpecImportReport = {
944
+ workspacePath: workspaceRoot,
945
+ generatedAt: new Date().toISOString(),
946
+ selectedPaths: selectedPaths.map((path) => relative(workspaceRoot, path) || path),
947
+ profile: profile.profileName,
948
+ harness: profile.harness,
949
+ files: normalizedDrafts.map((draft) => toLogicalPath(layout, draft.path) || draft.path),
950
+ notes: bundle.notes,
951
+ uncertainties: bundle.uncertainties,
952
+ dryRun: options.dryRun,
953
+ };
954
+
955
+ if (options.dryRun) {
956
+ io.print("Dry run complete. No files were written.");
957
+ return 0;
958
+ }
959
+
960
+ if (!options.force) {
961
+ const approved = await promptYesNo(io, "Write the proposed imported spec patch now?", false);
962
+ if (!approved) {
963
+ io.print("Cancelled. No files were written.");
964
+ return 0;
965
+ }
966
+ }
967
+
968
+ writeDrafts(workspaceRoot, normalizedDrafts);
969
+ const artifactPath = writeImportArtifact(workspaceRoot, report);
970
+ const guidanceUpdate = updateAgentGuidanceSafely(workspaceRoot);
971
+ io.print(`Wrote ${normalizedDrafts.length} imported spec file${normalizedDrafts.length === 1 ? "" : "s"}.`);
972
+ io.print(`Import report: ${layout.storageMode === "external" ? artifactPath : relative(workspaceRoot, artifactPath) || artifactPath}`);
973
+ if (guidanceUpdate.path) {
974
+ io.print(`Updated agent guidance: ${guidanceUpdate.path}`);
975
+ }
976
+ if (guidanceUpdate.warning) {
977
+ io.print(`Agent guidance warning: ${guidanceUpdate.warning}`);
978
+ }
979
+ return 0;
980
+ } catch (error) {
981
+ io.error(error instanceof Error ? error.message : String(error));
982
+ return 1;
983
+ } finally {
984
+ io.close?.();
985
+ }
986
+ }