@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,797 @@
1
+ import { createHash } from "crypto";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "fs";
3
+ import { dirname, join, relative } from "path";
4
+
5
+ import { resolveHarnessCommandOverride } from "../../harness/command";
6
+ import { getHarnessAdapterById } from "../../harness/registry";
7
+ import { runHarnessStage } from "../../harness/runner";
8
+ import type { HarnessInspection, HarnessRequest, HarnessResult } from "../../harness/types";
9
+ import { updateAgentGuidanceSafely } from "../agent-guidance";
10
+ import { DEFAULT_SPEC_DIFF_BASE_REF, collectSpecDiff, getSpecDiffRelativePath } from "../diff";
11
+ import { resolveStageProfile } from "../config";
12
+ import { resolveLocalOverrides } from "../local-config";
13
+ import { loadSpecWorkspace, loadSpecWorkspaceAtRef } from "../loader";
14
+ import { planSpecImplementation } from "../planner";
15
+ import { renderImplementPrompt } from "../prompt-renderer";
16
+ import type { LoadedSpecWorkspace, ResolvedStageProfile, SpecDiffReport, SpecImplementationPlan, ValidationIssue } from "../types";
17
+ import { resolveEffectiveProjectSpecPath, resolveWorkspaceLayout } from "../workspace";
18
+ import {
19
+ escapeRegExp,
20
+ globToRegExp,
21
+ hasGlobMagic,
22
+ isSafeContextPattern,
23
+ normalizeRelativePath,
24
+ } from "../path-utils";
25
+
26
+ const MAX_CONTEXT_CODE_FILES_PER_SPEC = 8;
27
+ const MAX_CONTEXT_TEST_FILES_PER_SPEC = 8;
28
+ const MAX_CONTEXT_MATCHES_PER_PATTERN = 4;
29
+
30
+ interface ImplementCommandOptions {
31
+ base?: string;
32
+ head?: string;
33
+ profile?: string;
34
+ harness?: string;
35
+ model?: string;
36
+ allowSpecDeletions: boolean;
37
+ help: boolean;
38
+ }
39
+
40
+ interface CommandExecutionResult {
41
+ exitCode: number;
42
+ stdout: string;
43
+ stderr: string;
44
+ }
45
+
46
+ interface DirtySnapshot {
47
+ files: Map<string, string>;
48
+ }
49
+
50
+ interface WriterLock {
51
+ path: string;
52
+ release(): void;
53
+ }
54
+
55
+ export interface ImplementCommandIO {
56
+ print(message: string): void;
57
+ error(message: string): void;
58
+ }
59
+
60
+ export interface ImplementCommandDeps {
61
+ io?: ImplementCommandIO;
62
+ runHarness?: (harnessId: string, request: HarnessRequest, cwd: string) => Promise<HarnessResult>;
63
+ inspectHarness?: (harnessId: string, cwd: string, commandOverride?: string) => Promise<HarnessInspection>;
64
+ runCommand?: (command: string, cwd: string) => CommandExecutionResult;
65
+ }
66
+
67
+ function createConsoleIO(): ImplementCommandIO {
68
+ return {
69
+ print(message: string): void {
70
+ console.log(message);
71
+ },
72
+ error(message: string): void {
73
+ console.error(message);
74
+ },
75
+ };
76
+ }
77
+
78
+ function printImplementHelp(io: ImplementCommandIO): void {
79
+ io.print(`Lisa spec implement
80
+
81
+ Usage:
82
+ lisa spec implement [options]
83
+
84
+ Options:
85
+ --base <rev> Diff base revision (default: HEAD)
86
+ --head <rev> Diff head revision (default: worktree)
87
+ --profile <name> Override the implement stage profile
88
+ --harness <id> Override the configured harness adapter
89
+ --model <name> Override the configured model
90
+ --allow-spec-deletions Allow plans that include deleted specs
91
+ --help, -h Show this help
92
+ `);
93
+ }
94
+
95
+ function parseImplementArgs(args: string[]): ImplementCommandOptions {
96
+ const options: ImplementCommandOptions = {
97
+ allowSpecDeletions: false,
98
+ help: false,
99
+ };
100
+
101
+ for (let index = 0; index < args.length; index += 1) {
102
+ const arg = args[index];
103
+ if (!arg) {
104
+ continue;
105
+ }
106
+
107
+ if (arg === "--help" || arg === "-h") {
108
+ options.help = true;
109
+ continue;
110
+ }
111
+
112
+ if (arg === "--allow-spec-deletions") {
113
+ options.allowSpecDeletions = true;
114
+ continue;
115
+ }
116
+
117
+ if (arg === "--base" || arg === "--head" || arg === "--profile" || arg === "--harness" || arg === "--model") {
118
+ const value = args[index + 1];
119
+ if (!value) {
120
+ throw new Error(`${arg} requires a value.`);
121
+ }
122
+
123
+ index += 1;
124
+ if (arg === "--base") {
125
+ options.base = value;
126
+ } else if (arg === "--head") {
127
+ options.head = value.toLowerCase() === "worktree" ? undefined : value;
128
+ } else if (arg === "--profile") {
129
+ options.profile = value;
130
+ } else if (arg === "--harness") {
131
+ options.harness = value;
132
+ } else {
133
+ options.model = value;
134
+ }
135
+ continue;
136
+ }
137
+
138
+ throw new Error(`Unknown lisa spec implement option: ${arg}`);
139
+ }
140
+
141
+ return options;
142
+ }
143
+
144
+ function resolveProfile(
145
+ workspace: LoadedSpecWorkspace,
146
+ options: ImplementCommandOptions,
147
+ ): ResolvedStageProfile {
148
+ const local = resolveLocalOverrides(workspace.localConfigRoot);
149
+ if (workspace.config) {
150
+ const resolved = resolveStageProfile(workspace.config, "implement", {
151
+ profile: options.profile,
152
+ harness: options.harness,
153
+ model: options.model,
154
+ }, local);
155
+ if (!resolved) {
156
+ throw new Error("Unable to resolve an implement stage profile from `.specs/config.yaml`.");
157
+ }
158
+ return resolved;
159
+ }
160
+
161
+ return {
162
+ stage: "implement",
163
+ profileName: options.profile ?? "default-implement",
164
+ harness: options.harness ?? local.harness ?? "opencode",
165
+ model: options.model,
166
+ allowEdits: true,
167
+ args: [],
168
+ };
169
+ }
170
+
171
+ function decodeOutput(bytes: Uint8Array<ArrayBufferLike>): string {
172
+ return new TextDecoder().decode(bytes);
173
+ }
174
+
175
+ function runGitCommand(workspaceRoot: string, args: string[]): CommandExecutionResult {
176
+ const proc = Bun.spawnSync({
177
+ cmd: ["git", ...args],
178
+ cwd: workspaceRoot,
179
+ stdout: "pipe",
180
+ stderr: "pipe",
181
+ });
182
+
183
+ return {
184
+ exitCode: proc.exitCode,
185
+ stdout: decodeOutput(proc.stdout),
186
+ stderr: decodeOutput(proc.stderr),
187
+ };
188
+ }
189
+
190
+ function resolveGitRevision(workspaceRoot: string, revision: string): string {
191
+ const result = runGitCommand(workspaceRoot, ["rev-parse", revision]);
192
+ if (result.exitCode !== 0) {
193
+ throw new Error(result.stderr.trim() || `Could not resolve git revision \`${revision}\`.`);
194
+ }
195
+
196
+ return result.stdout.trim();
197
+ }
198
+
199
+ function hasSpecWorktreeDrift(workspaceRoot: string, revision: string): boolean {
200
+ const result = runGitCommand(workspaceRoot, [
201
+ "diff",
202
+ "--quiet",
203
+ revision,
204
+ "--",
205
+ ".specs/backend",
206
+ ".specs/frontend",
207
+ ".specs/config.yaml",
208
+ ".specs/environments",
209
+ ]);
210
+ return result.exitCode !== 0;
211
+ }
212
+
213
+ function hasUntrackedSpecFiles(workspaceRoot: string): boolean {
214
+ const result = runGitCommand(workspaceRoot, ["ls-files", "--others", "--exclude-standard", "--", ".specs"]);
215
+ return result.stdout.trim().length > 0;
216
+ }
217
+
218
+ function listWorkspaceFiles(workspaceRoot: string): string[] {
219
+ const ignoredDirectories = new Set([".git", "node_modules", ".lisa"]);
220
+ const files: string[] = [];
221
+
222
+ const walk = (currentPath: string): void => {
223
+ for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
224
+ if (entry.isDirectory()) {
225
+ if (ignoredDirectories.has(entry.name)) {
226
+ continue;
227
+ }
228
+ walk(join(currentPath, entry.name));
229
+ continue;
230
+ }
231
+
232
+ if (!entry.isFile()) {
233
+ continue;
234
+ }
235
+
236
+ files.push(normalizeRelativePath(relative(workspaceRoot, join(currentPath, entry.name))));
237
+ }
238
+ };
239
+
240
+ walk(workspaceRoot);
241
+ return files.sort();
242
+ }
243
+
244
+ function resolvePatternMatches(workspaceRoot: string, pattern: string, workspaceFiles: string[]): string[] {
245
+ if (!isSafeContextPattern(pattern)) {
246
+ return [];
247
+ }
248
+
249
+ const normalized = normalizeRelativePath(pattern);
250
+
251
+ if (!hasGlobMagic(normalized)) {
252
+ const absolutePath = join(workspaceRoot, normalized);
253
+ if (!existsSync(absolutePath)) {
254
+ return [];
255
+ }
256
+
257
+ const stat = lstatSync(absolutePath);
258
+ if (stat.isSymbolicLink()) {
259
+ return [];
260
+ }
261
+
262
+ if (stat.isDirectory()) {
263
+ return workspaceFiles.filter((file) => file.startsWith(`${normalized}/`));
264
+ }
265
+
266
+ return [normalized];
267
+ }
268
+
269
+ const matcher = globToRegExp(normalized);
270
+ return workspaceFiles.filter((file) => matcher.test(file));
271
+ }
272
+
273
+ function rankContextMatches(matches: string[]): string[] {
274
+ return [...matches].sort((left, right) => {
275
+ const leftDepth = left.split("/").length;
276
+ const rightDepth = right.split("/").length;
277
+ if (leftDepth !== rightDepth) {
278
+ return leftDepth - rightDepth;
279
+ }
280
+
281
+ if (left.length !== right.length) {
282
+ return left.length - right.length;
283
+ }
284
+
285
+ return left.localeCompare(right);
286
+ });
287
+ }
288
+
289
+ function collectTargetContextMatches(
290
+ workspaceRoot: string,
291
+ patterns: string[],
292
+ workspaceFiles: string[],
293
+ limit: number,
294
+ ): string[] {
295
+ const matches = new Set<string>();
296
+
297
+ for (const pattern of patterns) {
298
+ const resolved = resolvePatternMatches(workspaceRoot, pattern, workspaceFiles);
299
+ for (const match of rankContextMatches(resolved).slice(0, MAX_CONTEXT_MATCHES_PER_PATTERN)) {
300
+ matches.add(match);
301
+ if (matches.size >= limit) {
302
+ return Array.from(matches).sort();
303
+ }
304
+ }
305
+ }
306
+
307
+ return Array.from(matches).sort();
308
+ }
309
+
310
+ function defaultRunCommand(command: string, cwd: string): CommandExecutionResult {
311
+ const proc = Bun.spawnSync({
312
+ cmd: ["sh", "-lc", command],
313
+ cwd,
314
+ stdout: "pipe",
315
+ stderr: "pipe",
316
+ env: process.env,
317
+ });
318
+
319
+ return {
320
+ exitCode: proc.exitCode,
321
+ stdout: decodeOutput(proc.stdout),
322
+ stderr: decodeOutput(proc.stderr),
323
+ };
324
+ }
325
+
326
+ function formatIssuesForConsole(issues: ValidationIssue[], workspaceRoot: string): string[] {
327
+ return issues.map((issue) => `${issue.severity}: ${getSpecDiffRelativePath({ workspacePath: workspaceRoot, baseRef: "", headRef: "", deltas: [] }, issue.path)} - ${issue.message}`);
328
+ }
329
+
330
+ function getBlockingValidationIssues(workspace: LoadedSpecWorkspace, diff: SpecDiffReport): ValidationIssue[] {
331
+ const changedSpecIds = new Set(diff.deltas.map((delta) => delta.specId));
332
+ const changedBaseSpecIds = new Set(
333
+ diff.deltas.flatMap((delta) => {
334
+ if (delta.kind === "base") {
335
+ return [delta.specId];
336
+ }
337
+
338
+ return delta.extendsSpecId ? [delta.extendsSpecId] : [];
339
+ }),
340
+ );
341
+ const referencedEnvironments = new Set(
342
+ diff.deltas.flatMap((delta) => {
343
+ const document = delta.nextDocument ?? delta.previousDocument;
344
+ return typeof document?.frontmatter.environment === "string" ? [document.frontmatter.environment] : [];
345
+ }),
346
+ );
347
+ const relevantPaths = new Set<string>(diff.deltas.map((delta) => delta.path));
348
+
349
+ for (const document of workspace.documents) {
350
+ if (document.id && (changedSpecIds.has(document.id) || changedBaseSpecIds.has(document.id))) {
351
+ relevantPaths.add(document.path);
352
+ continue;
353
+ }
354
+
355
+ if (
356
+ document.kind !== "base" &&
357
+ typeof document.frontmatter.extends === "string" &&
358
+ changedBaseSpecIds.has(document.frontmatter.extends)
359
+ ) {
360
+ relevantPaths.add(document.path);
361
+ }
362
+ }
363
+
364
+ for (const environment of workspace.environments) {
365
+ if (environment.name && referencedEnvironments.has(environment.name)) {
366
+ relevantPaths.add(environment.path);
367
+ }
368
+ }
369
+
370
+ return workspace.issues.filter((issue) => issue.severity === "error" && relevantPaths.has(issue.path));
371
+ }
372
+
373
+ function formatCapability(value: boolean): string {
374
+ return value ? "yes" : "no";
375
+ }
376
+
377
+ async function defaultInspectHarness(harnessId: string, cwd: string, commandOverride?: string): Promise<HarnessInspection> {
378
+ const adapter = getHarnessAdapterById(harnessId, cwd);
379
+ if (!adapter) {
380
+ throw new Error(`No harness adapter is registered for \`${harnessId}\`.`);
381
+ }
382
+
383
+ return {
384
+ adapter,
385
+ availability: commandOverride
386
+ ? {
387
+ available: true,
388
+ command: commandOverride,
389
+ reason: "Configured command override supplied by the selected stage profile.",
390
+ }
391
+ : await adapter.detect(),
392
+ capabilities: await adapter.capabilities(),
393
+ };
394
+ }
395
+
396
+ function ensureHarnessSupportsImplementation(inspection: HarnessInspection, profile: ResolvedStageProfile, options: ImplementCommandOptions): void {
397
+ if (!inspection.adapter.supportedStages.includes("implement")) {
398
+ throw new Error(`Harness \`${inspection.adapter.id}\` does not support the implement stage.`);
399
+ }
400
+
401
+ if (!profile.allowEdits) {
402
+ throw new Error(`Implement stage profile \`${profile.profileName}\` must declare \`allow_edits: true\`.`);
403
+ }
404
+
405
+ if (!inspection.capabilities.canEditFiles) {
406
+ throw new Error(`Harness \`${inspection.adapter.id}\` cannot edit files, so it cannot run \`spec implement\`.`);
407
+ }
408
+
409
+ if (options.model && !inspection.capabilities.supportsModelSelection) {
410
+ throw new Error(`Harness \`${inspection.adapter.id}\` does not support model overrides.`);
411
+ }
412
+
413
+ if (!inspection.availability.available) {
414
+ throw new Error(`Harness \`${inspection.adapter.id}\` is unavailable: ${inspection.availability.reason}`);
415
+ }
416
+ }
417
+
418
+ function printHarnessReport(io: ImplementCommandIO, inspection: HarnessInspection, profile: ResolvedStageProfile): void {
419
+ io.print("Harness");
420
+ io.print(`- adapter: ${inspection.adapter.id} (${inspection.adapter.displayName})`);
421
+ io.print(`- profile: ${profile.profileName}`);
422
+ io.print(`- command: ${inspection.availability.command ?? "(auto-detect)"}`);
423
+ io.print(`- availability: ${inspection.availability.reason}`);
424
+ io.print(`- can edit files: ${formatCapability(inspection.capabilities.canEditFiles)}`);
425
+ io.print(`- can resume sessions: ${formatCapability(inspection.capabilities.canResumeSessions)}`);
426
+ io.print(`- supports structured output: ${formatCapability(inspection.capabilities.supportsStructuredOutput)}`);
427
+ io.print(`- supports model selection: ${formatCapability(inspection.capabilities.supportsModelSelection)}`);
428
+ io.print(`- supports read-only mode: ${formatCapability(inspection.capabilities.supportsReadOnlyMode)}`);
429
+ io.print("");
430
+ }
431
+
432
+ function sha1ForPath(path: string): string {
433
+ if (!existsSync(path)) {
434
+ return "missing";
435
+ }
436
+
437
+ const hash = createHash("sha1");
438
+ hash.update(readFileSync(path));
439
+ return hash.digest("hex");
440
+ }
441
+
442
+ function listDirtyPaths(workspaceRoot: string): string[] {
443
+ const outputs = [
444
+ runGitCommand(workspaceRoot, ["diff", "--name-only"]),
445
+ runGitCommand(workspaceRoot, ["diff", "--cached", "--name-only"]),
446
+ runGitCommand(workspaceRoot, ["ls-files", "--others", "--exclude-standard"]),
447
+ ];
448
+ const paths = new Set<string>();
449
+
450
+ for (const output of outputs) {
451
+ for (const line of output.stdout.split("\n")) {
452
+ const path = line.trim();
453
+ if (path.length > 0) {
454
+ paths.add(path);
455
+ }
456
+ }
457
+ }
458
+
459
+ return Array.from(paths).sort();
460
+ }
461
+
462
+ function captureDirtySnapshot(workspaceRoot: string): DirtySnapshot {
463
+ const files = new Map<string, string>();
464
+
465
+ for (const path of listDirtyPaths(workspaceRoot)) {
466
+ files.set(path, sha1ForPath(join(workspaceRoot, path)));
467
+ }
468
+
469
+ return { files };
470
+ }
471
+
472
+ function detectChangedFiles(before: DirtySnapshot, after: DirtySnapshot, reportedChangedFiles: string[] = []): string[] {
473
+ const changed = new Set<string>(reportedChangedFiles.map((path) => path.split("\\").join("/")));
474
+ const keys = new Set<string>([...before.files.keys(), ...after.files.keys()]);
475
+
476
+ for (const key of keys) {
477
+ if (before.files.get(key) !== after.files.get(key)) {
478
+ changed.add(key);
479
+ }
480
+ }
481
+
482
+ return Array.from(changed).sort();
483
+ }
484
+
485
+ function isProcessRunning(pid: number): boolean {
486
+ try {
487
+ process.kill(pid, 0);
488
+ return true;
489
+ } catch (error) {
490
+ const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : undefined;
491
+ return code !== "ESRCH";
492
+ }
493
+ }
494
+
495
+ function acquireWriterLock(workspaceRoot: string): WriterLock {
496
+ const runtimeDir = resolveWorkspaceLayout(workspaceRoot).runtimeRoot;
497
+ const lockPath = join(runtimeDir, "spec-implement.lock");
498
+ mkdirSync(runtimeDir, { recursive: true });
499
+
500
+ const writeLock = (): WriterLock => {
501
+ writeFileSync(lockPath, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }), { flag: "wx" });
502
+ return {
503
+ path: lockPath,
504
+ release(): void {
505
+ rmSync(lockPath, { force: true });
506
+ },
507
+ };
508
+ };
509
+
510
+ try {
511
+ return writeLock();
512
+ } catch (error) {
513
+ const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : undefined;
514
+ if (code !== "EEXIST") {
515
+ throw error;
516
+ }
517
+
518
+ try {
519
+ const raw = JSON.parse(readFileSync(lockPath, "utf8")) as { pid?: unknown };
520
+ const pid = typeof raw.pid === "number" ? raw.pid : undefined;
521
+ if (pid !== undefined && !isProcessRunning(pid)) {
522
+ rmSync(lockPath, { force: true });
523
+ return writeLock();
524
+ }
525
+ } catch {
526
+ throw new Error(`Another \`spec implement\` run appears to be active (${lockPath}). Remove the lock if it is stale.`);
527
+ }
528
+
529
+ throw new Error(`Another \`spec implement\` run appears to be active (${lockPath}).`);
530
+ }
531
+ }
532
+
533
+ function buildContextFiles(
534
+ workspace: LoadedSpecWorkspace,
535
+ diff: SpecDiffReport,
536
+ plan: SpecImplementationPlan,
537
+ useWorktreeContext: boolean,
538
+ ): string[] {
539
+ if (!useWorktreeContext) {
540
+ return [];
541
+ }
542
+
543
+ const files = new Set<string>();
544
+
545
+ if (existsSync(workspace.configPath)) {
546
+ files.add(workspace.configPath);
547
+ }
548
+
549
+ const projectMdPath = resolveEffectiveProjectSpecPath(resolveWorkspaceLayout(workspace.workspacePath));
550
+ if (projectMdPath && existsSync(projectMdPath)) {
551
+ files.add(projectMdPath);
552
+ }
553
+
554
+ for (const delta of diff.deltas) {
555
+ if (existsSync(delta.path)) {
556
+ files.add(delta.path);
557
+ }
558
+ }
559
+
560
+ const workspaceFiles = listWorkspaceFiles(workspace.workspacePath);
561
+ for (const target of plan.impactedSpecs) {
562
+ const codeMatches = collectTargetContextMatches(
563
+ workspace.workspacePath,
564
+ target.codePaths,
565
+ workspaceFiles,
566
+ MAX_CONTEXT_CODE_FILES_PER_SPEC,
567
+ );
568
+ const testMatches = collectTargetContextMatches(
569
+ workspace.workspacePath,
570
+ target.testPaths,
571
+ workspaceFiles,
572
+ MAX_CONTEXT_TEST_FILES_PER_SPEC,
573
+ );
574
+
575
+ for (const match of [...codeMatches, ...testMatches]) {
576
+ files.add(join(workspace.workspacePath, match));
577
+ }
578
+
579
+ const specDoc = workspace.documents.find((doc) => doc.frontmatter.id === target.specId);
580
+ const contextPaths = Array.isArray(specDoc?.frontmatter.context_paths)
581
+ ? specDoc.frontmatter.context_paths.filter((entry): entry is string => typeof entry === "string")
582
+ : [];
583
+ for (const contextPath of contextPaths) {
584
+ const resolved = join(workspace.workspacePath, contextPath);
585
+ if (existsSync(resolved)) {
586
+ files.add(resolved);
587
+ }
588
+ }
589
+ }
590
+
591
+ return Array.from(files).sort();
592
+ }
593
+
594
+ function printDiffSummary(io: ImplementCommandIO, diff: SpecDiffReport): void {
595
+ io.print("Spec Deltas");
596
+ io.print(`- base: ${diff.baseRef}`);
597
+ io.print(`- head: ${diff.headRef}`);
598
+ io.print(`- changed specs: ${diff.deltas.length}`);
599
+ for (const delta of diff.deltas) {
600
+ io.print(`- ${delta.specId} (${delta.kind}, ${delta.fileChange}) at ${getSpecDiffRelativePath(diff, delta.path)}`);
601
+ }
602
+ io.print("");
603
+ }
604
+
605
+ function printPlanSummary(io: ImplementCommandIO, plan: ReturnType<typeof planSpecImplementation>): void {
606
+ io.print("Plan");
607
+ io.print(`- impacted specs: ${plan.impactedSpecs.length}`);
608
+ io.print(`- planned tasks: ${plan.tasks.length}`);
609
+ io.print(`- mapped test commands: ${plan.testCommands.length}`);
610
+ io.print("");
611
+ }
612
+
613
+ function printChangedFiles(io: ImplementCommandIO, changedFiles: string[]): void {
614
+ io.print("Changed Files");
615
+ if (changedFiles.length === 0) {
616
+ io.print("- none detected");
617
+ } else {
618
+ for (const file of changedFiles) {
619
+ io.print(`- ${file}`);
620
+ }
621
+ }
622
+ io.print("");
623
+ }
624
+
625
+ function printTestResults(io: ImplementCommandIO, results: Array<{ command: string; result: CommandExecutionResult }>): void {
626
+ io.print("Mapped Tests");
627
+ if (results.length === 0) {
628
+ io.print("- no mapped test commands declared by the impacted specs");
629
+ io.print("");
630
+ return;
631
+ }
632
+
633
+ for (const entry of results) {
634
+ io.print(`- ${entry.command} (${entry.result.exitCode === 0 ? "pass" : "fail"})`);
635
+ if (entry.result.exitCode !== 0) {
636
+ const stderr = entry.result.stderr.trim();
637
+ const stdout = entry.result.stdout.trim();
638
+ if (stderr) {
639
+ io.print(stderr);
640
+ } else if (stdout) {
641
+ io.print(stdout);
642
+ }
643
+ }
644
+ }
645
+ io.print("");
646
+ }
647
+
648
+ export async function runSpecImplementCommand(
649
+ args: string[],
650
+ cwd = process.cwd(),
651
+ deps: ImplementCommandDeps = {},
652
+ ): Promise<number> {
653
+ const io = deps.io ?? createConsoleIO();
654
+
655
+ try {
656
+ const options = parseImplementArgs(args);
657
+ if (options.help) {
658
+ printImplementHelp(io);
659
+ return 0;
660
+ }
661
+
662
+ const layout = resolveWorkspaceLayout(cwd);
663
+ const workspaceRoot = layout.workspacePath;
664
+ if (layout.storageMode === "external" && (args.includes("--base") || args.includes("--head"))) {
665
+ io.error("External Lisa workspaces do not support `--base` or `--head` for spec history yet.");
666
+ return 1;
667
+ }
668
+ if (options.head) {
669
+ const requestedHead = resolveGitRevision(workspaceRoot, options.head);
670
+ const currentHead = resolveGitRevision(workspaceRoot, "HEAD");
671
+ if (requestedHead !== currentHead) {
672
+ io.error("`spec implement --head <rev>` can only target the currently checked out commit. Check out that revision first or omit `--head`.");
673
+ return 1;
674
+ }
675
+
676
+ if (hasSpecWorktreeDrift(workspaceRoot, options.head)) {
677
+ io.error("`spec implement --head <rev>` requires the `.specs` workspace to match that revision. Commit or discard spec edits first.");
678
+ return 1;
679
+ }
680
+
681
+ if (hasUntrackedSpecFiles(workspaceRoot)) {
682
+ io.error("`spec implement --head <rev>` requires the `.specs` workspace to match that revision. Remove or commit untracked spec files first.");
683
+ return 1;
684
+ }
685
+ }
686
+
687
+ const workspace = options.head ? loadSpecWorkspaceAtRef(options.head, workspaceRoot) : loadSpecWorkspace(cwd);
688
+ const profile = resolveProfile(workspace, options);
689
+ const diff = collectSpecDiff(workspaceRoot, {
690
+ baseRef: options.base,
691
+ headRef: options.head,
692
+ });
693
+
694
+ if (workspace.issues.length > 0) {
695
+ io.print("Workspace Validation");
696
+ for (const issue of formatIssuesForConsole(workspace.issues, workspaceRoot)) {
697
+ io.print(`- ${issue}`);
698
+ }
699
+ io.print("");
700
+ }
701
+
702
+ if (diff.deltas.length === 0) {
703
+ io.print(`No changed specs detected between ${diff.baseRef} and ${diff.headRef}.`);
704
+ return 0;
705
+ }
706
+
707
+ const plan = planSpecImplementation(workspace, diff);
708
+ const blockingValidationIssues = getBlockingValidationIssues(workspace, diff);
709
+ if (blockingValidationIssues.length > 0) {
710
+ io.error("Refusing to run `spec implement` with validation errors in the changed spec set.");
711
+ return 1;
712
+ }
713
+
714
+ if (plan.tasks.length === 0) {
715
+ io.print("No implementation tasks were derived from the changed specs. Skipping the writer harness.");
716
+ return 0;
717
+ }
718
+
719
+ if (plan.deletedSpecIds.length > 0 && !options.allowSpecDeletions) {
720
+ io.error(`Spec deletions require explicit confirmation. Re-run with --allow-spec-deletions for: ${plan.deletedSpecIds.join(", ")}`);
721
+ return 1;
722
+ }
723
+
724
+ const inspectHarness = deps.inspectHarness ?? defaultInspectHarness;
725
+ const inspection = await inspectHarness(profile.harness, workspaceRoot, profile.command);
726
+ ensureHarnessSupportsImplementation(inspection, profile, options);
727
+
728
+ printHarnessReport(io, inspection, profile);
729
+ printDiffSummary(io, diff);
730
+ printPlanSummary(io, plan);
731
+
732
+ const prompt = renderImplementPrompt(plan);
733
+ const contextFiles = buildContextFiles(workspace, diff, plan, true);
734
+ const runHarness = deps.runHarness ?? runHarnessStage;
735
+ const lock = acquireWriterLock(workspaceRoot);
736
+
737
+ let changedFiles: string[] = [];
738
+ try {
739
+ const beforeSnapshot = captureDirtySnapshot(workspaceRoot);
740
+ const result = await runHarness(
741
+ profile.harness,
742
+ {
743
+ stage: "implement",
744
+ prompt,
745
+ cwd: workspaceRoot,
746
+ allowEdits: true,
747
+ contextFiles,
748
+ env: profile.command ? { LISA_HARNESS_COMMAND: resolveHarnessCommandOverride(profile.command, dirname(workspace.configPath)) } : undefined,
749
+ model: profile.model,
750
+ profile: profile.profileName,
751
+ extraArgs: profile.args,
752
+ limits: { maxTurns: 8 },
753
+ },
754
+ workspaceRoot,
755
+ );
756
+ const afterHarnessSnapshot = captureDirtySnapshot(workspaceRoot);
757
+ changedFiles = detectChangedFiles(beforeSnapshot, afterHarnessSnapshot, result.changedFiles ?? []);
758
+ printChangedFiles(io, changedFiles);
759
+
760
+ if (result.status !== "success") {
761
+ const diagnostic = result.abortReason ?? result.finalText;
762
+ if (diagnostic?.trim()) {
763
+ if (result.status === "blocked") {
764
+ io.error(`Operation blocked: ${diagnostic.trim()}`);
765
+ } else {
766
+ io.error(diagnostic.trim());
767
+ }
768
+ }
769
+ return 1;
770
+ }
771
+
772
+ const runCommand = deps.runCommand ?? defaultRunCommand;
773
+ const testResults = plan.testCommands.map((command) => ({ command, result: runCommand(command, workspaceRoot) }));
774
+ printTestResults(io, testResults);
775
+
776
+ if (testResults.some((entry) => entry.result.exitCode !== 0)) {
777
+ io.error("One or more mapped test commands failed.");
778
+ return 1;
779
+ }
780
+
781
+ const guidanceUpdate = updateAgentGuidanceSafely(workspaceRoot);
782
+ if (guidanceUpdate.path) {
783
+ io.print(`Updated agent guidance: ${guidanceUpdate.path}`);
784
+ }
785
+ if (guidanceUpdate.warning) {
786
+ io.print(`Agent guidance warning: ${guidanceUpdate.warning}`);
787
+ }
788
+ io.print("Implementation completed successfully.");
789
+ return 0;
790
+ } finally {
791
+ lock.release();
792
+ }
793
+ } catch (error) {
794
+ io.error(error instanceof Error ? error.message : String(error));
795
+ return 1;
796
+ }
797
+ }