@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,1270 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
2
+ import { tmpdir } from "os";
3
+ import { basename, dirname, 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 { runHarnessStage } from "../../harness/runner";
9
+ import type { HarnessRequest, HarnessResult } from "../../harness/types";
10
+ import { updateAgentGuidanceSafely } from "../agent-guidance";
11
+ import { resolveStageProfile } from "../config";
12
+ import { resolveLocalOverrides } from "../local-config";
13
+ import { loadSpecWorkspace } from "../loader";
14
+ import { parseSpecDocument } from "../parser";
15
+ import type {
16
+ LoadedSpecWorkspace,
17
+ ParsedSpecDocument,
18
+ ResolvedStageProfile,
19
+ SpecArea,
20
+ ValidationIssue,
21
+ } from "../types";
22
+ import { SPEC_AREAS } from "../types";
23
+ import { validateLoadedSpecWorkspace, validateSpecDocument } from "../validator";
24
+ import { assertSafeLisaStorageWritePath, resolveEffectiveProjectSpecPath, resolveSpecWriteRoot, resolveWorkspaceLayout, toLogicalPath, type SpecWriteScope } from "../workspace";
25
+
26
+ interface GenerateCommandOptions {
27
+ area?: SpecArea;
28
+ name?: string;
29
+ prompt?: string;
30
+ profile?: string;
31
+ harness?: string;
32
+ model?: string;
33
+ scope?: SpecWriteScope;
34
+ force: boolean;
35
+ dryRun: boolean;
36
+ help: boolean;
37
+ }
38
+
39
+ interface GenerateDefaults {
40
+ summary?: string;
41
+ useCases: string[];
42
+ invariants: string[];
43
+ failureModes: string[];
44
+ acceptanceCriteria: string[];
45
+ outOfScope: string[];
46
+ codePaths: string[];
47
+ testPaths: string[];
48
+ testCommands: string[];
49
+ owners: string[];
50
+ }
51
+
52
+ interface BenchmarkDefaults {
53
+ environment?: string;
54
+ command?: string;
55
+ required?: boolean;
56
+ metrics: Record<string, string>;
57
+ notes: string[];
58
+ sourcePath?: string;
59
+ content?: string;
60
+ }
61
+
62
+ interface GenerateBrief {
63
+ area: SpecArea;
64
+ name: string;
65
+ specId: string;
66
+ targetPath: string;
67
+ existingBasePath?: string;
68
+ existingBaseContent?: string;
69
+ benchmarkPath?: string;
70
+ summary: string;
71
+ useCases: string[];
72
+ invariants: string[];
73
+ failureModes: string[];
74
+ acceptanceCriteria: string[];
75
+ outOfScope: string[];
76
+ codePaths: string[];
77
+ testPaths: string[];
78
+ testCommands: string[];
79
+ owners: string[];
80
+ benchmark?: {
81
+ environment: string;
82
+ command: string;
83
+ required: boolean;
84
+ metrics: Record<string, string>;
85
+ notes: string[];
86
+ existingPath?: string;
87
+ existingContent?: string;
88
+ };
89
+ }
90
+
91
+ interface GeneratedFileDraft {
92
+ path: string;
93
+ content: string;
94
+ }
95
+
96
+ interface GeneratedDraftBundle {
97
+ files: GeneratedFileDraft[];
98
+ notes: string[];
99
+ }
100
+
101
+ export interface GenerateCommandIO {
102
+ ask(question: string): Promise<string>;
103
+ print(message: string): void;
104
+ error(message: string): void;
105
+ isInputClosed?(): boolean;
106
+ close?(): void;
107
+ }
108
+
109
+ export interface GenerateCommandDeps {
110
+ io?: GenerateCommandIO;
111
+ runHarness?: (harnessId: string, request: HarnessRequest, cwd: string) => Promise<HarnessResult>;
112
+ }
113
+
114
+ function createConsoleIO(): GenerateCommandIO {
115
+ if (!input.isTTY) {
116
+ const rl = createInterface({ input, terminal: false });
117
+ const answers: string[] = [];
118
+ const waiters: Array<(answer: string) => void> = [];
119
+ let closed = false;
120
+
121
+ rl.on("line", (line) => {
122
+ const waiter = waiters.shift();
123
+ if (waiter) {
124
+ waiter(line);
125
+ return;
126
+ }
127
+ answers.push(line);
128
+ });
129
+
130
+ rl.on("close", () => {
131
+ closed = true;
132
+ while (waiters.length > 0) {
133
+ const waiter = waiters.shift();
134
+ waiter?.("");
135
+ }
136
+ });
137
+
138
+ return {
139
+ async ask(question: string): Promise<string> {
140
+ output.write(question);
141
+ if (answers.length > 0) {
142
+ return answers.shift() ?? "";
143
+ }
144
+
145
+ if (closed) {
146
+ return "";
147
+ }
148
+
149
+ return new Promise((resolve) => {
150
+ waiters.push(resolve);
151
+ });
152
+ },
153
+ print(message: string): void {
154
+ console.log(message);
155
+ },
156
+ error(message: string): void {
157
+ console.error(message);
158
+ },
159
+ isInputClosed(): boolean {
160
+ return closed;
161
+ },
162
+ close(): void {
163
+ rl.close();
164
+ },
165
+ };
166
+ }
167
+
168
+ const rl = createInterface({ input, output });
169
+ let closed = false;
170
+ rl.on("close", () => {
171
+ closed = true;
172
+ });
173
+
174
+ return {
175
+ async ask(question: string): Promise<string> {
176
+ if (closed) {
177
+ return "";
178
+ }
179
+
180
+ return rl.question(question);
181
+ },
182
+ print(message: string): void {
183
+ console.log(message);
184
+ },
185
+ error(message: string): void {
186
+ console.error(message);
187
+ },
188
+ isInputClosed(): boolean {
189
+ return closed;
190
+ },
191
+ close(): void {
192
+ rl.close();
193
+ },
194
+ };
195
+ }
196
+
197
+ function printGenerateHelp(io: GenerateCommandIO): void {
198
+ io.print(`Lisa spec generate
199
+
200
+ Usage:
201
+ lisa spec generate [backend|frontend] [name] [options]
202
+
203
+ Options:
204
+ --prompt <text> Generate from a single natural-language description (skips interactive Q&A)
205
+ --profile <name> Override the generate stage profile
206
+ --harness <id> Override the configured harness adapter
207
+ --model <name> Override the configured model
208
+ --scope <name> Write to the shared repo layer or worktree override layer
209
+ --force Skip approval prompts and write immediately
210
+ --dry-run Show the proposed patch without writing files
211
+ --help, -h Show this help
212
+ `);
213
+ }
214
+
215
+ function parseGenerateArgs(args: string[]): GenerateCommandOptions {
216
+ const positionals: string[] = [];
217
+ const options: GenerateCommandOptions = {
218
+ force: false,
219
+ dryRun: false,
220
+ help: false,
221
+ };
222
+
223
+ for (let index = 0; index < args.length; index += 1) {
224
+ const arg = args[index];
225
+ if (!arg) {
226
+ continue;
227
+ }
228
+
229
+ if (arg === "--help" || arg === "-h") {
230
+ options.help = true;
231
+ continue;
232
+ }
233
+
234
+ if (arg === "--force") {
235
+ options.force = true;
236
+ continue;
237
+ }
238
+
239
+ if (arg === "--dry-run") {
240
+ options.dryRun = true;
241
+ continue;
242
+ }
243
+
244
+ if (arg === "--prompt" || arg === "--profile" || arg === "--harness" || arg === "--model" || arg === "--scope") {
245
+ const value = args[index + 1];
246
+ if (!value && arg !== "--prompt") {
247
+ throw new Error(`${arg} requires a value.`);
248
+ }
249
+
250
+ index += 1;
251
+ if (arg === "--prompt") {
252
+ options.prompt = value ?? "";
253
+ } else if (arg === "--profile") {
254
+ options.profile = value;
255
+ } else if (arg === "--harness") {
256
+ options.harness = value;
257
+ } else if (arg === "--scope") {
258
+ if (value !== "repo" && value !== "worktree") {
259
+ throw new Error("--scope requires `repo` or `worktree`.");
260
+ }
261
+ options.scope = value;
262
+ } else {
263
+ options.model = value;
264
+ }
265
+ continue;
266
+ }
267
+
268
+ if (arg.startsWith("--")) {
269
+ throw new Error(`Unknown lisa spec generate option: ${arg}`);
270
+ }
271
+
272
+ positionals.push(arg);
273
+ }
274
+
275
+ if (positionals[0] && SPEC_AREAS.includes(positionals[0] as SpecArea)) {
276
+ options.area = positionals[0] as SpecArea;
277
+ } else if (positionals[0]) {
278
+ throw new Error(`Expected first positional argument to be one of: ${SPEC_AREAS.join(", ")}.`);
279
+ }
280
+
281
+ if (positionals[1]) {
282
+ options.name = positionals[1];
283
+ }
284
+
285
+ if (positionals.length > 2) {
286
+ throw new Error("lisa spec generate accepts at most two positional arguments: [backend|frontend] [name].");
287
+ }
288
+
289
+ return options;
290
+ }
291
+
292
+ function normalizeSpecName(inputName: string): string {
293
+ return inputName
294
+ .trim()
295
+ .replace(/\.md$/i, "")
296
+ .toLowerCase()
297
+ .replace(/[^a-z0-9]+/g, "-")
298
+ .replace(/^-+|-+$/g, "");
299
+ }
300
+
301
+ function splitList(value: string): string[] {
302
+ return value
303
+ .split(/[;\n]/)
304
+ .map((entry) => entry.trim())
305
+ .filter((entry) => entry.length > 0);
306
+ }
307
+
308
+ function extractListFromSection(document: ParsedSpecDocument, title: string): string[] {
309
+ const section = document.sections.find((entry) => entry.title.toLowerCase() === title.toLowerCase());
310
+ if (!section || section.content.trim().length === 0) {
311
+ return [];
312
+ }
313
+
314
+ const bullets = section.content
315
+ .split("\n")
316
+ .map((line) => line.trim())
317
+ .filter((line) => line.startsWith("- "))
318
+ .map((line) => line.slice(2).trim())
319
+ .filter((line) => line.length > 0);
320
+
321
+ return bullets.length > 0 ? bullets : [section.content.trim()];
322
+ }
323
+
324
+ function extractSummary(document: ParsedSpecDocument): string | undefined {
325
+ return document.sections.find((section) => section.title.toLowerCase() === "summary")?.content.trim() || undefined;
326
+ }
327
+
328
+ function formatDefaultList(values: string[]): string | undefined {
329
+ return values.length > 0 ? values.join("; ") : undefined;
330
+ }
331
+
332
+ function formatMetricThresholds(metrics: Record<string, string>): string | undefined {
333
+ const entries = Object.entries(metrics).map(([metric, threshold]) => `${metric}${threshold.replace(/^\s*/, "")}`);
334
+ return entries.length > 0 ? entries.join("; ") : undefined;
335
+ }
336
+
337
+ function parseMetricThresholds(inputValue: string): Record<string, string> {
338
+ const metrics: Record<string, string> = {};
339
+
340
+ for (const entry of splitList(inputValue)) {
341
+ const match = entry.match(/^([A-Za-z0-9_.-]+)\s*(<=|>=|==|=|<|>)\s*(.+)$/);
342
+ if (!match) {
343
+ throw new Error(`Could not parse benchmark metric threshold: ${entry}`);
344
+ }
345
+
346
+ const [, name, operator, rawValue] = match;
347
+ const normalizedOperator = operator === "=" ? "==" : operator;
348
+ metrics[name] = `${normalizedOperator} ${rawValue.trim()}`;
349
+ }
350
+
351
+ return metrics;
352
+ }
353
+
354
+ function normalizeBooleanAnswer(answer: string, fallback = false): boolean {
355
+ const normalized = answer.trim().toLowerCase();
356
+ if (!normalized) {
357
+ return fallback;
358
+ }
359
+
360
+ if (["y", "yes"].includes(normalized)) {
361
+ return true;
362
+ }
363
+
364
+ if (["n", "no"].includes(normalized)) {
365
+ return false;
366
+ }
367
+
368
+ return fallback;
369
+ }
370
+
371
+ async function promptValue(
372
+ io: GenerateCommandIO,
373
+ question: string,
374
+ options: { defaultValue?: string; required?: boolean } = {},
375
+ ): Promise<string> {
376
+ const suffix = options.defaultValue ? ` [${options.defaultValue}]` : "";
377
+
378
+ while (true) {
379
+ const answer = (await io.ask(`${question}${suffix}: `)).trim();
380
+ const resolved = answer || options.defaultValue || "";
381
+
382
+ if (!options.required || resolved.trim().length > 0) {
383
+ return resolved;
384
+ }
385
+
386
+ if (io.isInputClosed?.()) {
387
+ throw new Error(`${question} is required.`);
388
+ }
389
+
390
+ io.print(`${question} is required.`);
391
+ }
392
+ }
393
+
394
+ async function promptYesNo(io: GenerateCommandIO, question: string, fallback = false): Promise<boolean> {
395
+ const label = fallback ? "Y/n" : "y/N";
396
+ const answer = await io.ask(`${question} [${label}]: `);
397
+ return normalizeBooleanAnswer(answer, fallback);
398
+ }
399
+
400
+ function resolveProfile(
401
+ workspace: LoadedSpecWorkspace,
402
+ options: GenerateCommandOptions,
403
+ ): ResolvedStageProfile {
404
+ const local = resolveLocalOverrides(workspace.localConfigRoot);
405
+ if (workspace.config) {
406
+ const resolved = resolveStageProfile(workspace.config, "generate", {
407
+ profile: options.profile,
408
+ harness: options.harness,
409
+ model: options.model,
410
+ }, local);
411
+ if (!resolved) {
412
+ throw new Error("Unable to resolve a generate stage profile from `.specs/config.yaml`.");
413
+ }
414
+ return resolved;
415
+ }
416
+
417
+ return {
418
+ stage: "generate",
419
+ profileName: options.profile ?? "default-generate",
420
+ harness: options.harness ?? local.harness ?? "opencode",
421
+ model: options.model,
422
+ allowEdits: false,
423
+ args: [],
424
+ };
425
+ }
426
+
427
+ function emptyGenerateDefaults(): GenerateDefaults {
428
+ return {
429
+ useCases: [],
430
+ invariants: [],
431
+ failureModes: [],
432
+ acceptanceCriteria: [],
433
+ outOfScope: [],
434
+ codePaths: [],
435
+ testPaths: [],
436
+ testCommands: [],
437
+ owners: [],
438
+ };
439
+ }
440
+
441
+ function buildGenerateDefaults(document: ParsedSpecDocument): GenerateDefaults {
442
+ const codePaths = Array.isArray(document.frontmatter.code_paths)
443
+ ? document.frontmatter.code_paths.filter((entry): entry is string => typeof entry === "string")
444
+ : [];
445
+ const testPaths = Array.isArray(document.frontmatter.test_paths)
446
+ ? document.frontmatter.test_paths.filter((entry): entry is string => typeof entry === "string")
447
+ : [];
448
+ const testCommands = Array.isArray(document.frontmatter.test_commands)
449
+ ? document.frontmatter.test_commands.filter((entry): entry is string => typeof entry === "string")
450
+ : [];
451
+ const owners = Array.isArray(document.frontmatter.owners)
452
+ ? document.frontmatter.owners.filter((entry): entry is string => typeof entry === "string")
453
+ : [];
454
+
455
+ return {
456
+ summary: extractSummary(document),
457
+ useCases: extractListFromSection(document, "Use Cases"),
458
+ invariants: extractListFromSection(document, "Invariants"),
459
+ failureModes: extractListFromSection(document, "Failure Modes"),
460
+ acceptanceCriteria: extractListFromSection(document, "Acceptance Criteria"),
461
+ outOfScope: extractListFromSection(document, "Out of Scope"),
462
+ codePaths,
463
+ testPaths,
464
+ testCommands,
465
+ owners,
466
+ };
467
+ }
468
+
469
+ function readExistingBaseDefaults(targetPath: string): GenerateDefaults {
470
+ if (!existsSync(targetPath)) {
471
+ return emptyGenerateDefaults();
472
+ }
473
+
474
+ return buildGenerateDefaults(parseSpecDocument(targetPath, readFileSync(targetPath, "utf8")));
475
+ }
476
+
477
+ function findBenchmarkCandidates(areaPath: string, name: string): string[] {
478
+ if (!existsSync(areaPath)) {
479
+ return [];
480
+ }
481
+
482
+ return readdirSync(areaPath)
483
+ .filter((entry) => entry.startsWith(`${name}.bench.`) && entry.endsWith(".md"))
484
+ .sort()
485
+ .map((entry) => join(areaPath, entry));
486
+ }
487
+
488
+ function readBenchmarkDefaults(areaPath: string, name: string): BenchmarkDefaults | undefined {
489
+ const benchmarkPaths = findBenchmarkCandidates(areaPath, name);
490
+ const sourcePath = benchmarkPaths[0];
491
+ if (!sourcePath) {
492
+ return undefined;
493
+ }
494
+
495
+ const content = readFileSync(sourcePath, "utf8");
496
+ const document = parseSpecDocument(sourcePath, content);
497
+ return buildBenchmarkDefaults(document, sourcePath, content);
498
+ }
499
+
500
+ function buildBenchmarkDefaults(document: ParsedSpecDocument, sourcePath?: string, content?: string): BenchmarkDefaults {
501
+ const metrics = document.frontmatter.metrics && typeof document.frontmatter.metrics === "object" && !Array.isArray(document.frontmatter.metrics)
502
+ ? Object.fromEntries(
503
+ Object.entries(document.frontmatter.metrics).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
504
+ )
505
+ : {};
506
+
507
+ return {
508
+ environment: typeof document.frontmatter.environment === "string" ? document.frontmatter.environment : undefined,
509
+ command: typeof document.frontmatter.command === "string" ? document.frontmatter.command : undefined,
510
+ required: typeof document.frontmatter.required === "boolean" ? document.frontmatter.required : undefined,
511
+ metrics,
512
+ notes: extractListFromSection(document, "Notes"),
513
+ sourcePath,
514
+ content,
515
+ };
516
+ }
517
+
518
+ async function collectGenerateBrief(
519
+ io: GenerateCommandIO,
520
+ workspace: LoadedSpecWorkspace,
521
+ options: GenerateCommandOptions,
522
+ ): Promise<GenerateBrief> {
523
+ const workspaceRoot = workspace.workspacePath;
524
+ const area = options.area ?? (await promptValue(io, "Spec area (backend or frontend)", { required: true })) as SpecArea;
525
+ if (!SPEC_AREAS.includes(area)) {
526
+ throw new Error(`Spec area must be one of: ${SPEC_AREAS.join(", ")}.`);
527
+ }
528
+
529
+ const rawName = options.name ?? (await promptValue(io, "Spec name", { required: true }));
530
+ const name = normalizeSpecName(rawName);
531
+ if (!name) {
532
+ throw new Error("Spec name must contain at least one alphanumeric character.");
533
+ }
534
+
535
+ const layout = resolveWorkspaceLayout(workspaceRoot);
536
+ const areaPath = join(resolveSpecWriteRoot(workspaceRoot, options.scope ?? "repo"), area);
537
+ const targetPath = join(areaPath, `${name}.md`);
538
+ const logicalTargetPath = join(".specs", area, `${name}.md`).split("\\").join("/");
539
+ const specId = `${area}.${name}`;
540
+ const existingBaseDocument = workspace.documents.find(
541
+ (document) => document.kind === "base" && toLogicalPath(layout, document.path) === logicalTargetPath,
542
+ );
543
+ const existingBasePath = existsSync(targetPath) ? targetPath : existingBaseDocument?.path;
544
+ const baseDefaults = existsSync(targetPath)
545
+ ? readExistingBaseDefaults(targetPath)
546
+ : existingBaseDocument
547
+ ? buildGenerateDefaults(existingBaseDocument)
548
+ : emptyGenerateDefaults();
549
+ const benchmarkPrefix = join(".specs", area, `${name}.bench.`).split("\\").join("/");
550
+ const existingBenchmarkDocument = workspace.documents.find(
551
+ (document) => document.extensionKind === "benchmark" && toLogicalPath(layout, document.path).startsWith(benchmarkPrefix),
552
+ );
553
+ const benchmarkDefaults = findBenchmarkCandidates(areaPath, name).length > 0
554
+ ? readBenchmarkDefaults(areaPath, name)
555
+ : existingBenchmarkDocument
556
+ ? buildBenchmarkDefaults(existingBenchmarkDocument, existingBenchmarkDocument.path, readFileSync(existingBenchmarkDocument.path, "utf8"))
557
+ : undefined;
558
+
559
+ io.print("");
560
+ io.print(`Collecting input for ${toLogicalPath(layout, targetPath) || basename(targetPath)}.`);
561
+ io.print("Use semicolons for multi-item answers. Leave optional prompts blank to keep existing values.");
562
+
563
+ const summary = await promptValue(io, "Summary", { defaultValue: baseDefaults.summary, required: true });
564
+ const useCases = splitList(await promptValue(io, "Use Cases", { defaultValue: formatDefaultList(baseDefaults.useCases), required: true }));
565
+ const invariants = splitList(await promptValue(io, "Invariants", { defaultValue: formatDefaultList(baseDefaults.invariants), required: true }));
566
+ const failureModes = splitList(
567
+ await promptValue(io, "Failure modes / edge cases", { defaultValue: formatDefaultList(baseDefaults.failureModes), required: true }),
568
+ );
569
+ const acceptanceCriteria = splitList(
570
+ await promptValue(io, "Acceptance Criteria", { defaultValue: formatDefaultList(baseDefaults.acceptanceCriteria), required: true }),
571
+ );
572
+ const outOfScope = splitList(
573
+ await promptValue(io, "Out of Scope", { defaultValue: formatDefaultList(baseDefaults.outOfScope), required: true }),
574
+ );
575
+ const codePaths = splitList(await promptValue(io, "Code paths", { defaultValue: formatDefaultList(baseDefaults.codePaths), required: true }));
576
+ const testPaths = splitList(await promptValue(io, "Test paths", { defaultValue: formatDefaultList(baseDefaults.testPaths) }));
577
+ const testCommands = splitList(await promptValue(io, "Test commands", { defaultValue: formatDefaultList(baseDefaults.testCommands) }));
578
+ const owners = splitList(await promptValue(io, "Owners", { defaultValue: formatDefaultList(baseDefaults.owners) }));
579
+
580
+ if (testPaths.length === 0 && testCommands.length === 0) {
581
+ throw new Error("At least one test path or test command is required.");
582
+ }
583
+
584
+ const wantsBenchmark = await promptYesNo(io, "Add or update a benchmark sidecar?", Boolean(benchmarkDefaults));
585
+ if (!wantsBenchmark) {
586
+ return {
587
+ area,
588
+ name,
589
+ specId,
590
+ targetPath,
591
+ existingBasePath,
592
+ existingBaseContent: existingBasePath ? readFileSync(existingBasePath, "utf8") : undefined,
593
+ summary,
594
+ useCases,
595
+ invariants,
596
+ failureModes,
597
+ acceptanceCriteria,
598
+ outOfScope,
599
+ codePaths,
600
+ testPaths,
601
+ testCommands,
602
+ owners,
603
+ };
604
+ }
605
+
606
+ const environment = await promptValue(io, "Benchmark environment", {
607
+ defaultValue: benchmarkDefaults?.environment,
608
+ required: true,
609
+ });
610
+ const benchmarkPath = join(areaPath, `${name}.bench.${environment}.md`);
611
+
612
+ const benchmarkCommand = await promptValue(io, "Benchmark command", {
613
+ defaultValue: benchmarkDefaults?.command,
614
+ required: true,
615
+ });
616
+ const benchmarkRequired = await promptYesNo(io, "Require this benchmark for the environment?", benchmarkDefaults?.required ?? true);
617
+ const benchmarkMetricsInput = await promptValue(io, "Benchmark metric thresholds", {
618
+ defaultValue: formatMetricThresholds(benchmarkDefaults?.metrics ?? {}),
619
+ required: true,
620
+ });
621
+ const benchmarkNotes = splitList(await promptValue(io, "Benchmark notes", { defaultValue: formatDefaultList(benchmarkDefaults?.notes ?? []) }));
622
+
623
+ return {
624
+ area,
625
+ name,
626
+ specId,
627
+ targetPath,
628
+ existingBasePath,
629
+ existingBaseContent: existingBasePath ? readFileSync(existingBasePath, "utf8") : undefined,
630
+ benchmarkPath,
631
+ summary,
632
+ useCases,
633
+ invariants,
634
+ failureModes,
635
+ acceptanceCriteria,
636
+ outOfScope,
637
+ codePaths,
638
+ testPaths,
639
+ testCommands,
640
+ owners,
641
+ benchmark: {
642
+ environment,
643
+ command: benchmarkCommand,
644
+ required: benchmarkRequired,
645
+ metrics: parseMetricThresholds(benchmarkMetricsInput),
646
+ notes: benchmarkNotes,
647
+ existingPath: benchmarkDefaults?.sourcePath,
648
+ existingContent: benchmarkDefaults?.content,
649
+ },
650
+ };
651
+ }
652
+
653
+ function readProjectContext(workspaceRoot: string): string | undefined {
654
+ const projectMdPath = resolveEffectiveProjectSpecPath(resolveWorkspaceLayout(workspaceRoot));
655
+ if (!projectMdPath || !existsSync(projectMdPath)) {
656
+ return undefined;
657
+ }
658
+ const content = readFileSync(projectMdPath, "utf8").trim();
659
+ return content || undefined;
660
+ }
661
+
662
+ function indentBlock(content: string): string {
663
+ return content
664
+ .split("\n")
665
+ .map((line) => ` ${line}`)
666
+ .join("\n");
667
+ }
668
+
669
+ function buildGeneratePrompt(workspace: LoadedSpecWorkspace, brief: GenerateBrief): string {
670
+ const layout = resolveWorkspaceLayout(workspace.workspacePath);
671
+ const baseExisting = brief.existingBaseContent ?? (existsSync(brief.targetPath) ? readFileSync(brief.targetPath, "utf8") : undefined);
672
+ const benchmarkExisting = brief.benchmark?.existingContent;
673
+ const relativeBasePath = toLogicalPath(layout, brief.targetPath);
674
+ const relativeBenchmarkPath = brief.benchmarkPath ? toLogicalPath(layout, brief.benchmarkPath) : undefined;
675
+
676
+ return `You are generating Lisa spec files for a spec-driven engineering workflow.
677
+
678
+ Return JSON only between these exact markers:
679
+ LISA_GENERATE_JSON_START
680
+ <json>
681
+ LISA_GENERATE_JSON_END
682
+
683
+ JSON shape:
684
+ {
685
+ "files": [
686
+ { "path": "${relativeBasePath}", "content": "full markdown document" }
687
+ ${relativeBenchmarkPath ? ` ,{ "path": "${relativeBenchmarkPath}", "content": "full markdown document" }` : ""}
688
+ ],
689
+ "notes": ["optional short notes"]
690
+ }
691
+
692
+ Rules:
693
+ - If you cannot complete this request safely because required capabilities or repository access are unavailable, respond with only:
694
+ LISA_ABORT_START
695
+ <short reason>
696
+ LISA_ABORT_END
697
+ - Generate exactly one base spec file at ${relativeBasePath}.
698
+ - ${relativeBenchmarkPath ? `If benchmark coverage was requested, also generate exactly one benchmark sidecar at ${relativeBenchmarkPath}.` : "Do not generate any benchmark sidecar."}
699
+ - Keep the output scoped to the requested files only.
700
+ - Base specs must include YAML frontmatter with id, status, code_paths, and at least one of test_paths or test_commands.
701
+ - Base specs generated by this workflow must set status: active.
702
+ - Active base specs must include Summary, Use Cases, Invariants, Acceptance Criteria, and Out of Scope sections.
703
+ - Base specs must also include a non-empty Failure Modes section that preserves every prompted failure mode or edge case.
704
+ - Benchmark sidecars must declare kind: benchmark, extends, status, required, environment, command, and metrics.
705
+ - Use concise, concrete language grounded in the provided answers.
706
+
707
+ Requested base spec:
708
+ - area: ${brief.area}
709
+ - name: ${brief.name}
710
+ - id: ${brief.specId}
711
+ - status: active
712
+ - path: ${relativeBasePath}
713
+ - summary: ${brief.summary}
714
+ - use_cases: ${brief.useCases.join(" | ")}
715
+ - invariants: ${brief.invariants.join(" | ")}
716
+ - failure_modes: ${brief.failureModes.join(" | ")}
717
+ - acceptance_criteria: ${brief.acceptanceCriteria.join(" | ")}
718
+ - out_of_scope: ${brief.outOfScope.join(" | ")}
719
+ - code_paths: ${brief.codePaths.join(" | ")}
720
+ - test_paths: ${brief.testPaths.join(" | ") || "(none)"}
721
+ - test_commands: ${brief.testCommands.join(" | ") || "(none)"}
722
+ - owners: ${brief.owners.join(" | ") || "(none)"}
723
+
724
+ ${brief.benchmark ? `Requested benchmark sidecar:
725
+ - path: ${relativeBenchmarkPath}
726
+ - extends: ${brief.specId}
727
+ - environment: ${brief.benchmark.environment}
728
+ - required: ${brief.benchmark.required}
729
+ - command: ${brief.benchmark.command}
730
+ - metrics: ${Object.entries(brief.benchmark.metrics).map(([name, threshold]) => `${name}${threshold.replace(/^\s*/, "")}`).join(" | ")}
731
+ - notes: ${brief.benchmark.notes.join(" | ") || "(none)"}
732
+
733
+ ` : ""}Existing config status:
734
+ - workspace config present: ${workspace.config ? "yes" : "no"}
735
+ - environment count: ${workspace.environments.length}
736
+
737
+ ${(() => { const projectContext = readProjectContext(workspace.workspacePath); return projectContext ? `Project context (.specs/project.md):\n${indentBlock(projectContext)}\n\n` : ""; })()}${baseExisting ? `Existing base spec content:
738
+ ${indentBlock(baseExisting)}
739
+
740
+ ` : `No existing base spec content.
741
+
742
+ `}${benchmarkExisting ? `Existing benchmark sidecar content:
743
+ ${indentBlock(benchmarkExisting)}
744
+
745
+ ` : ""}Respond with the JSON payload only inside the required markers.`;
746
+ }
747
+
748
+ function buildPromptBasedGeneratePrompt(
749
+ workspace: LoadedSpecWorkspace,
750
+ area: SpecArea,
751
+ name: string,
752
+ specId: string,
753
+ targetPath: string,
754
+ existingBasePath: string | undefined,
755
+ userPrompt: string,
756
+ ): string {
757
+ const layout = resolveWorkspaceLayout(workspace.workspacePath);
758
+ const baseExisting = existingBasePath && existsSync(existingBasePath) ? readFileSync(existingBasePath, "utf8") : undefined;
759
+ const relativeBasePath = toLogicalPath(layout, targetPath) || targetPath;
760
+
761
+ return `You are generating Lisa spec files for a spec-driven engineering workflow.
762
+
763
+ Return JSON only between these exact markers:
764
+ LISA_GENERATE_JSON_START
765
+ <json>
766
+ LISA_GENERATE_JSON_END
767
+
768
+ JSON shape:
769
+ {
770
+ "files": [
771
+ { "path": "${relativeBasePath}", "content": "full markdown document" }
772
+ ],
773
+ "notes": ["optional short notes"]
774
+ }
775
+
776
+ Rules:
777
+ - If you cannot complete this request safely because required capabilities or repository access are unavailable, respond with only:
778
+ LISA_ABORT_START
779
+ <short reason>
780
+ LISA_ABORT_END
781
+ - Generate exactly one base spec file at ${relativeBasePath}.
782
+ - Do not generate any benchmark sidecar.
783
+ - Keep the output scoped to the requested files only.
784
+ - Base specs must include YAML frontmatter with id, status, code_paths, and at least one of test_paths or test_commands.
785
+ - Base specs generated by this workflow must set status: active.
786
+ - Active base specs must include Summary, Use Cases, Invariants, Acceptance Criteria, and Out of Scope sections.
787
+ - Base specs must also include a non-empty Failure Modes section.
788
+ - Use concise, concrete language grounded in the provided description.
789
+ - Infer code_paths, test_paths, and test_commands from the description when not explicitly specified; use reasonable placeholders the user can refine.
790
+
791
+ Requested base spec:
792
+ - area: ${area}
793
+ - name: ${name}
794
+ - id: ${specId}
795
+ - status: active
796
+ - path: ${relativeBasePath}
797
+
798
+ User description:
799
+ ${userPrompt}
800
+
801
+ Existing config status:
802
+ - workspace config present: ${workspace.config ? "yes" : "no"}
803
+ - environment count: ${workspace.environments.length}
804
+
805
+ ${(() => { const projectContext = readProjectContext(workspace.workspacePath); return projectContext ? `Project context (.specs/project.md):\n${indentBlock(projectContext)}\n\n` : ""; })()}${baseExisting ? `Existing base spec content:
806
+ ${indentBlock(baseExisting)}
807
+
808
+ ` : `No existing base spec content.
809
+
810
+ `}Respond with the JSON payload only inside the required markers.`;
811
+ }
812
+
813
+ function extractJsonPayload(text: string): string | undefined {
814
+ const markerMatch = text.match(/LISA_GENERATE_JSON_START\s*([\s\S]*?)\s*LISA_GENERATE_JSON_END/);
815
+ if (markerMatch?.[1]) {
816
+ return markerMatch[1].trim();
817
+ }
818
+
819
+ const fencedMatch = text.match(/```json\s*([\s\S]*?)```/i);
820
+ if (fencedMatch?.[1]) {
821
+ return fencedMatch[1].trim();
822
+ }
823
+
824
+ const firstBrace = text.indexOf("{");
825
+ const lastBrace = text.lastIndexOf("}");
826
+ if (firstBrace >= 0 && lastBrace > firstBrace) {
827
+ return text.slice(firstBrace, lastBrace + 1).trim();
828
+ }
829
+
830
+ return undefined;
831
+ }
832
+
833
+ function parseGeneratedDraft(result: HarnessResult): GeneratedDraftBundle {
834
+ const raw = typeof result.structuredOutput === "object" && result.structuredOutput !== null
835
+ ? result.structuredOutput
836
+ : result.finalText
837
+ ? JSON.parse(extractJsonPayload(result.finalText) ?? "null")
838
+ : null;
839
+
840
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
841
+ throw new Error("Harness did not return a valid JSON draft payload.");
842
+ }
843
+
844
+ const rawFiles = Array.isArray((raw as Record<string, unknown>).files) ? (raw as Record<string, unknown>).files : [];
845
+ const files = rawFiles
846
+ .filter(
847
+ (entry): entry is { path: string; content: string } =>
848
+ typeof entry === "object" &&
849
+ entry !== null &&
850
+ typeof (entry as { path?: unknown }).path === "string" &&
851
+ typeof (entry as { content?: unknown }).content === "string",
852
+ )
853
+ .map((entry) => ({ path: entry.path.trim(), content: entry.content.trim() }));
854
+
855
+ if (files.length === 0) {
856
+ throw new Error("Harness draft payload did not include any files.");
857
+ }
858
+
859
+ const notes = Array.isArray((raw as Record<string, unknown>).notes)
860
+ ? (raw as Record<string, unknown>).notes.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
861
+ : [];
862
+
863
+ return { files, notes };
864
+ }
865
+
866
+ function validateGeneratedFiles(
867
+ workspace: LoadedSpecWorkspace,
868
+ drafts: GeneratedFileDraft[],
869
+ brief: GenerateBrief,
870
+ ): ValidationIssue[] {
871
+ const layout = resolveWorkspaceLayout(workspace.workspacePath);
872
+ const parsedDocs = drafts.map((draft) => parseSpecDocument(draft.path, draft.content));
873
+ const issues = parsedDocs.flatMap((document) => validateSpecDocument(document));
874
+ const baseDocument = parsedDocs.find((document) => document.path === brief.targetPath);
875
+ if (!baseDocument || baseDocument.status !== "active") {
876
+ issues.push({
877
+ severity: "error",
878
+ path: brief.targetPath,
879
+ message: "Generated base specs must declare `status: active`.",
880
+ });
881
+ }
882
+ if (!baseDocument || extractListFromSection(baseDocument, "Failure Modes").length === 0) {
883
+ issues.push({
884
+ severity: "error",
885
+ path: brief.targetPath,
886
+ message: "Generated base specs must include a non-empty `Failure Modes` section.",
887
+ });
888
+ }
889
+ const replacedPaths = new Set(drafts.map((draft) => toLogicalPath(layout, draft.path)));
890
+ const mergedDocuments = [
891
+ ...workspace.documents.filter((document) => !replacedPaths.has(toLogicalPath(layout, document.path))),
892
+ ...parsedDocs,
893
+ ];
894
+
895
+ return [...issues, ...validateLoadedSpecWorkspace({
896
+ workspacePath: workspace.workspacePath,
897
+ config: workspace.config,
898
+ documents: mergedDocuments,
899
+ environments: workspace.environments,
900
+ })];
901
+ }
902
+
903
+ function normalizeDraftPaths(workspaceRoot: string, drafts: GeneratedFileDraft[], scope: SpecWriteScope = "repo"): GeneratedFileDraft[] {
904
+ const writeRoot = resolveSpecWriteRoot(workspaceRoot, scope);
905
+ return drafts.map((draft) => ({
906
+ path: draft.path.startsWith(".specs/")
907
+ ? resolve(writeRoot, draft.path.replace(/^\.specs\//, ""))
908
+ : resolve(workspaceRoot, draft.path),
909
+ content: draft.content,
910
+ }));
911
+ }
912
+
913
+ function enforceAllowedPaths(brief: GenerateBrief, drafts: GeneratedFileDraft[]): void {
914
+ const allowedPaths = new Set([brief.targetPath, ...(brief.benchmarkPath ? [brief.benchmarkPath] : [])]);
915
+
916
+ for (const draft of drafts) {
917
+ if (!allowedPaths.has(draft.path)) {
918
+ throw new Error(`Harness returned an unexpected file path: ${draft.path}`);
919
+ }
920
+ }
921
+
922
+ if (!drafts.some((draft) => draft.path === brief.targetPath)) {
923
+ throw new Error(`Harness did not return the required base spec file: ${brief.targetPath}`);
924
+ }
925
+
926
+ if (brief.benchmarkPath && !drafts.some((draft) => draft.path === brief.benchmarkPath)) {
927
+ throw new Error(`Harness did not return the requested benchmark sidecar: ${brief.benchmarkPath}`);
928
+ }
929
+ }
930
+
931
+ function buildPatchPreview(workspaceRoot: string, targetPath: string, nextContent: string): string {
932
+ const layout = resolveWorkspaceLayout(workspaceRoot);
933
+ const currentContent = existsSync(targetPath) ? readFileSync(targetPath, "utf8") : "";
934
+ const tempDir = mkdtempSync(join(tmpdir(), "lisa-spec-preview-"));
935
+ const beforePath = join(tempDir, "before.md");
936
+ const afterPath = join(tempDir, "after.md");
937
+ writeFileSync(beforePath, currentContent);
938
+ writeFileSync(afterPath, nextContent);
939
+
940
+ try {
941
+ const proc = Bun.spawnSync({
942
+ cmd: ["git", "diff", "--no-index", "--no-color", "--", beforePath, afterPath],
943
+ stdout: "pipe",
944
+ stderr: "pipe",
945
+ });
946
+ const relativePath = toLogicalPath(layout, targetPath);
947
+ const beforeLabel = `a/${relativePath}`;
948
+ const afterLabel = `b/${relativePath}`;
949
+ const stdout = new TextDecoder().decode(proc.stdout);
950
+
951
+ if (!stdout.trim()) {
952
+ return `diff --git ${beforeLabel} ${afterLabel}\n(no content changes)`;
953
+ }
954
+
955
+ return stdout
956
+ .split(beforePath).join(relativePath)
957
+ .split(afterPath).join(relativePath)
958
+ .split(`a/${beforePath}`).join(beforeLabel)
959
+ .split(`b/${afterPath}`).join(afterLabel);
960
+ } finally {
961
+ rmSync(tempDir, { recursive: true, force: true });
962
+ }
963
+ }
964
+
965
+ function writeGeneratedFiles(workspaceRoot: string, scope: SpecWriteScope, drafts: GeneratedFileDraft[]): void {
966
+ const writeRoot = resolveSpecWriteRoot(workspaceRoot, scope);
967
+ for (const draft of drafts) {
968
+ assertSafeLisaStorageWritePath(writeRoot, draft.path, "Lisa spec generate");
969
+ mkdirSync(dirname(draft.path), { recursive: true });
970
+ writeFileSync(draft.path, `${draft.content.trim()}\n`);
971
+ }
972
+ }
973
+
974
+ function ensureParentDirectory(path: string): void {
975
+ mkdirSync(dirname(path), { recursive: true });
976
+ }
977
+
978
+ function buildGenerateSnapshot(workspaceRoot: string, paths: string[]): { snapshotRoot: string; contextFiles: string[] } {
979
+ const layout = resolveWorkspaceLayout(workspaceRoot);
980
+ const snapshotRoot = mkdtempSync(join(tmpdir(), "lisa-generate-"));
981
+ const contextFiles = new Set<string>();
982
+
983
+ for (const path of paths) {
984
+ if (!existsSync(path)) {
985
+ continue;
986
+ }
987
+
988
+ const logicalPath = toLogicalPath(layout, path).replace(/^\.\//, "");
989
+ if (!logicalPath || logicalPath === "." || logicalPath === ".." || logicalPath.startsWith("../") || logicalPath.startsWith("..\\")) {
990
+ continue;
991
+ }
992
+
993
+ const snapshotRelativePath = logicalPath;
994
+ ensureParentDirectory(join(snapshotRoot, snapshotRelativePath));
995
+ writeFileSync(join(snapshotRoot, snapshotRelativePath), readFileSync(path));
996
+ const snapshotPath = join(snapshotRoot, snapshotRelativePath);
997
+ if (existsSync(snapshotPath)) {
998
+ contextFiles.add(snapshotPath);
999
+ }
1000
+ }
1001
+
1002
+ return {
1003
+ snapshotRoot,
1004
+ contextFiles: Array.from(contextFiles).sort(),
1005
+ };
1006
+ }
1007
+
1008
+ export async function runSpecGenerateCommand(
1009
+ args: string[],
1010
+ cwd = process.cwd(),
1011
+ deps: GenerateCommandDeps = {},
1012
+ ): Promise<number> {
1013
+ const io = deps.io ?? createConsoleIO();
1014
+
1015
+ try {
1016
+ const options = parseGenerateArgs(args);
1017
+ if (options.help) {
1018
+ printGenerateHelp(io);
1019
+ return 0;
1020
+ }
1021
+
1022
+ const layout = resolveWorkspaceLayout(cwd);
1023
+ const workspaceRoot = layout.workspacePath;
1024
+ const workspace = loadSpecWorkspace(cwd);
1025
+ const profile = resolveProfile(workspace, options);
1026
+ if (options.prompt !== undefined) {
1027
+ if (!options.prompt.trim()) {
1028
+ io.error("The --prompt value must not be empty.");
1029
+ return 1;
1030
+ }
1031
+
1032
+ const area = options.area;
1033
+ if (!area || !SPEC_AREAS.includes(area)) {
1034
+ io.error(`Spec area is required and must be one of: ${SPEC_AREAS.join(", ")}.`);
1035
+ return 1;
1036
+ }
1037
+
1038
+ const rawName = options.name;
1039
+ if (!rawName) {
1040
+ io.error("Spec name is required.");
1041
+ return 1;
1042
+ }
1043
+
1044
+ const name = normalizeSpecName(rawName);
1045
+ if (!name) {
1046
+ io.error("Spec name must contain at least one alphanumeric character.");
1047
+ return 1;
1048
+ }
1049
+
1050
+ const specId = `${area}.${name}`;
1051
+ const targetPath = join(resolveSpecWriteRoot(workspaceRoot, options.scope ?? "repo"), area, `${name}.md`);
1052
+ const existingDoc = workspace.documents.find((doc) => doc.frontmatter.id === specId);
1053
+ const existingBasePath = existsSync(targetPath) ? targetPath : existingDoc?.path;
1054
+ const prompt = buildPromptBasedGeneratePrompt(workspace, area, name, specId, targetPath, existingBasePath, options.prompt);
1055
+ const runHarness = deps.runHarness ?? runHarnessStage;
1056
+ const specContextPaths = Array.isArray(existingDoc?.frontmatter.context_paths)
1057
+ ? existingDoc.frontmatter.context_paths
1058
+ .filter((entry): entry is string => typeof entry === "string")
1059
+ .map((p) => join(workspaceRoot, p))
1060
+ : [];
1061
+ const projectMdPath = resolveEffectiveProjectSpecPath(layout);
1062
+ const snapshotPaths = [workspace.configPath, targetPath, ...specContextPaths];
1063
+ if (projectMdPath && existsSync(projectMdPath)) {
1064
+ snapshotPaths.push(projectMdPath);
1065
+ }
1066
+ const snapshot = buildGenerateSnapshot(workspaceRoot, snapshotPaths);
1067
+ let result: HarnessResult;
1068
+ try {
1069
+ result = await runHarness(
1070
+ profile.harness,
1071
+ {
1072
+ stage: "generate",
1073
+ prompt,
1074
+ cwd: snapshot.snapshotRoot,
1075
+ allowEdits: false,
1076
+ contextFiles: snapshot.contextFiles,
1077
+ env: profile.command ? { LISA_HARNESS_COMMAND: resolveHarnessCommandOverride(profile.command, dirname(workspace.configPath)) } : undefined,
1078
+ model: profile.model,
1079
+ profile: profile.profileName,
1080
+ extraArgs: profile.args,
1081
+ limits: { maxTurns: 1 },
1082
+ },
1083
+ workspaceRoot,
1084
+ );
1085
+ } finally {
1086
+ rmSync(snapshot.snapshotRoot, { recursive: true, force: true });
1087
+ }
1088
+
1089
+ if (result.status === "failed" || result.status === "blocked") {
1090
+ io.error(result.abortReason || result.finalText || "Harness failed to draft a spec.");
1091
+ return 1;
1092
+ }
1093
+
1094
+ const rawDrafts = parseGeneratedDraft(result);
1095
+ const normalizedDrafts = normalizeDraftPaths(workspaceRoot, rawDrafts.files, options.scope ?? "repo");
1096
+ const promptBrief: GenerateBrief = {
1097
+ area,
1098
+ name,
1099
+ specId,
1100
+ targetPath,
1101
+ summary: "",
1102
+ useCases: [],
1103
+ invariants: [],
1104
+ failureModes: [],
1105
+ acceptanceCriteria: [],
1106
+ outOfScope: [],
1107
+ codePaths: [],
1108
+ testPaths: [],
1109
+ testCommands: [],
1110
+ owners: [],
1111
+ };
1112
+ enforceAllowedPaths(promptBrief, normalizedDrafts);
1113
+
1114
+ const issues = validateGeneratedFiles(workspace, normalizedDrafts, promptBrief);
1115
+
1116
+ io.print("");
1117
+ io.print("Proposed patch");
1118
+ io.print("");
1119
+ for (const draft of normalizedDrafts) {
1120
+ io.print(buildPatchPreview(workspaceRoot, draft.path, draft.content));
1121
+ io.print("");
1122
+ }
1123
+
1124
+ if (rawDrafts.notes.length > 0) {
1125
+ io.print("Harness notes");
1126
+ for (const note of rawDrafts.notes) {
1127
+ io.print(`- ${note}`);
1128
+ }
1129
+ io.print("");
1130
+ }
1131
+
1132
+ if (issues.length > 0) {
1133
+ io.print("Validation issues");
1134
+ for (const issue of issues) {
1135
+ io.print(`- ${issue.severity}: ${relative(workspaceRoot, issue.path) || issue.path} - ${issue.message}`);
1136
+ }
1137
+ io.print("");
1138
+ }
1139
+
1140
+ if (issues.some((issue) => issue.severity === "error")) {
1141
+ io.error("Refusing to write generated specs with validation errors.");
1142
+ return 1;
1143
+ }
1144
+
1145
+ if (options.dryRun) {
1146
+ io.print("Dry run complete. No files were written.");
1147
+ return 0;
1148
+ }
1149
+
1150
+ if (!options.force) {
1151
+ const approved = await promptYesNo(io, "Write the proposed spec patch now?", false);
1152
+ if (!approved) {
1153
+ io.print("Cancelled. No files were written.");
1154
+ return 0;
1155
+ }
1156
+ }
1157
+
1158
+ writeGeneratedFiles(workspaceRoot, options.scope ?? "repo", normalizedDrafts);
1159
+ const guidanceUpdate = updateAgentGuidanceSafely(workspaceRoot);
1160
+ io.print(`Wrote ${normalizedDrafts.length} spec file${normalizedDrafts.length === 1 ? "" : "s"}.`);
1161
+ if (guidanceUpdate.path) {
1162
+ io.print(`Updated agent guidance: ${guidanceUpdate.path}`);
1163
+ }
1164
+ if (guidanceUpdate.warning) {
1165
+ io.print(`Agent guidance warning: ${guidanceUpdate.warning}`);
1166
+ }
1167
+ return 0;
1168
+ }
1169
+
1170
+ const brief = await collectGenerateBrief(io, workspace, options);
1171
+ const prompt = buildGeneratePrompt(workspace, brief);
1172
+ const runHarness = deps.runHarness ?? runHarnessStage;
1173
+ const interactiveProjectMdPath = resolveEffectiveProjectSpecPath(layout);
1174
+ const interactiveSnapshotPaths = [workspace.configPath, brief.targetPath, ...(brief.benchmarkPath ? [brief.benchmarkPath] : [])];
1175
+ if (interactiveProjectMdPath && existsSync(interactiveProjectMdPath)) {
1176
+ interactiveSnapshotPaths.push(interactiveProjectMdPath);
1177
+ }
1178
+ const snapshot = buildGenerateSnapshot(workspaceRoot, interactiveSnapshotPaths);
1179
+ let result: HarnessResult;
1180
+ try {
1181
+ result = await runHarness(
1182
+ profile.harness,
1183
+ {
1184
+ stage: "generate",
1185
+ prompt,
1186
+ cwd: snapshot.snapshotRoot,
1187
+ allowEdits: false,
1188
+ contextFiles: snapshot.contextFiles,
1189
+ env: profile.command ? { LISA_HARNESS_COMMAND: resolveHarnessCommandOverride(profile.command, dirname(workspace.configPath)) } : undefined,
1190
+ model: profile.model,
1191
+ profile: profile.profileName,
1192
+ extraArgs: profile.args,
1193
+ limits: { maxTurns: 1 },
1194
+ },
1195
+ workspaceRoot,
1196
+ );
1197
+ } finally {
1198
+ rmSync(snapshot.snapshotRoot, { recursive: true, force: true });
1199
+ }
1200
+
1201
+ if (result.status === "failed" || result.status === "blocked") {
1202
+ io.error(result.abortReason || result.finalText || "Harness failed to draft a spec.");
1203
+ return 1;
1204
+ }
1205
+
1206
+ const rawDrafts = parseGeneratedDraft(result);
1207
+ const normalizedDrafts = normalizeDraftPaths(workspaceRoot, rawDrafts.files, options.scope ?? "repo");
1208
+ enforceAllowedPaths(brief, normalizedDrafts);
1209
+
1210
+ const issues = validateGeneratedFiles(workspace, normalizedDrafts, brief);
1211
+
1212
+ io.print("");
1213
+ io.print("Proposed patch");
1214
+ io.print("");
1215
+ for (const draft of normalizedDrafts) {
1216
+ io.print(buildPatchPreview(workspaceRoot, draft.path, draft.content));
1217
+ io.print("");
1218
+ }
1219
+
1220
+ if (rawDrafts.notes.length > 0) {
1221
+ io.print("Harness notes");
1222
+ for (const note of rawDrafts.notes) {
1223
+ io.print(`- ${note}`);
1224
+ }
1225
+ io.print("");
1226
+ }
1227
+
1228
+ if (issues.length > 0) {
1229
+ io.print("Validation issues");
1230
+ for (const issue of issues) {
1231
+ io.print(`- ${issue.severity}: ${toLogicalPath(layout, issue.path) || issue.path} - ${issue.message}`);
1232
+ }
1233
+ io.print("");
1234
+ }
1235
+
1236
+ if (issues.some((issue) => issue.severity === "error")) {
1237
+ io.error("Refusing to write generated specs with validation errors.");
1238
+ return 1;
1239
+ }
1240
+
1241
+ if (options.dryRun) {
1242
+ io.print("Dry run complete. No files were written.");
1243
+ return 0;
1244
+ }
1245
+
1246
+ if (!options.force) {
1247
+ const approved = await promptYesNo(io, "Write the proposed spec patch now?", false);
1248
+ if (!approved) {
1249
+ io.print("Cancelled. No files were written.");
1250
+ return 0;
1251
+ }
1252
+ }
1253
+
1254
+ writeGeneratedFiles(workspaceRoot, options.scope ?? "repo", normalizedDrafts);
1255
+ const guidanceUpdate = updateAgentGuidanceSafely(workspaceRoot);
1256
+ io.print(`Wrote ${normalizedDrafts.length} spec file${normalizedDrafts.length === 1 ? "" : "s"}.`);
1257
+ if (guidanceUpdate.path) {
1258
+ io.print(`Updated agent guidance: ${guidanceUpdate.path}`);
1259
+ }
1260
+ if (guidanceUpdate.warning) {
1261
+ io.print(`Agent guidance warning: ${guidanceUpdate.warning}`);
1262
+ }
1263
+ return 0;
1264
+ } catch (error) {
1265
+ io.error(error instanceof Error ? error.message : String(error));
1266
+ return 1;
1267
+ } finally {
1268
+ io.close?.();
1269
+ }
1270
+ }