@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,443 @@
1
+ import { basename } from "path";
2
+
3
+ import { isRegisteredExtensionKind } from "./extensions/registry";
4
+ import { validateGrammarCrossReferences } from "./grammar";
5
+ import { resolveConfiguredHarnessId } from "./config";
6
+ import type {
7
+ LoadedSpecWorkspace,
8
+ ParsedEnvironmentConfig,
9
+ ParsedSpecConfig,
10
+ ParsedSpecDocument,
11
+ ValidationIssue,
12
+ } from "./types";
13
+ import { SPEC_STATUSES, SPEC_STAGES } from "./types";
14
+
15
+ function isRecord(value: unknown): value is Record<string, unknown> {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ }
18
+
19
+ function asStringArray(value: unknown): string[] {
20
+ if (!Array.isArray(value)) {
21
+ return [];
22
+ }
23
+
24
+ return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
25
+ }
26
+
27
+ function isStringArray(value: unknown): value is string[] {
28
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
29
+ }
30
+
31
+ function hasNonEmptyStringArray(value: unknown): boolean {
32
+ return isStringArray(value) && value.some((entry) => entry.trim().length > 0);
33
+ }
34
+
35
+ function hasPlaceholderValue(values: string[]): boolean {
36
+ return values.some((entry) => /^TODO:\s*replace\b/i.test(entry.trim()));
37
+ }
38
+
39
+ function asStringRecord(value: unknown): Record<string, string> {
40
+ if (!isRecord(value)) {
41
+ return {};
42
+ }
43
+
44
+ return Object.fromEntries(
45
+ Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0),
46
+ );
47
+ }
48
+
49
+ function isStringRecord(value: unknown): value is Record<string, string> {
50
+ return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
51
+ }
52
+
53
+ function issue(path: string, message: string, severity: ValidationIssue["severity"] = "error"): ValidationIssue {
54
+ return { path, message, severity };
55
+ }
56
+
57
+ function isSafeRepoRelativePath(value: string): boolean {
58
+ const normalized = value.replace(/\\/g, "/").trim();
59
+ if (!normalized || normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized)) {
60
+ return false;
61
+ }
62
+
63
+ return !normalized.split("/").some((segment) => segment === ".." || segment.length === 0);
64
+ }
65
+
66
+ function isReservedLisaPath(value: string): boolean {
67
+ const normalized = value.replace(/\\/g, "/").trim().replace(/^\.\//, "");
68
+ return normalized === ".specs"
69
+ || normalized.startsWith(".specs/")
70
+ || normalized === ".lisa"
71
+ || normalized.startsWith(".lisa/");
72
+ }
73
+
74
+ function normalizeHeading(title: string): string {
75
+ return title.toLowerCase().replace(/\s+/g, " ").trim();
76
+ }
77
+
78
+ function hasSection(document: ParsedSpecDocument, expectedTitle: string): boolean {
79
+ const expected = normalizeHeading(expectedTitle);
80
+
81
+ for (const [index, section] of document.sections.entries()) {
82
+ if (normalizeHeading(section.title) !== expected) {
83
+ continue;
84
+ }
85
+
86
+ if (section.content.length > 0) {
87
+ return true;
88
+ }
89
+
90
+ for (let childIndex = index + 1; childIndex < document.sections.length; childIndex += 1) {
91
+ const childSection = document.sections[childIndex];
92
+ if (childSection.level <= section.level) {
93
+ break;
94
+ }
95
+
96
+ if (childSection.content.length > 0) {
97
+ return true;
98
+ }
99
+ }
100
+ }
101
+
102
+ return false;
103
+ }
104
+
105
+ function getSectionContent(document: ParsedSpecDocument, expectedTitle: string): string | undefined {
106
+ const expected = normalizeHeading(expectedTitle);
107
+ return document.sections.find((section) => normalizeHeading(section.title) === expected)?.content.trim();
108
+ }
109
+
110
+ function isPlaceholderSectionContent(content: string | undefined): boolean {
111
+ if (!content) {
112
+ return false;
113
+ }
114
+
115
+ const meaningfulLines = content
116
+ .split("\n")
117
+ .map((line) => line.replace(/^[-*]\s*/, "").trim())
118
+ .filter((line) => line.length > 0);
119
+
120
+ return meaningfulLines.length > 0 && meaningfulLines.every((line) => line === "TODO" || line.startsWith("TODO:"));
121
+ }
122
+
123
+ export function validateSpecDocument(document: ParsedSpecDocument): ValidationIssue[] {
124
+ const issues: ValidationIssue[] = [];
125
+ const { frontmatter } = document;
126
+
127
+ if (!document.id) {
128
+ issues.push(issue(document.path, "Spec frontmatter must declare a non-empty `id`."));
129
+ }
130
+
131
+ if (!document.status || !SPEC_STATUSES.includes(document.status as (typeof SPEC_STATUSES)[number])) {
132
+ issues.push(issue(document.path, "Spec frontmatter must declare `status` as draft, active, or deprecated."));
133
+ }
134
+
135
+ if (typeof frontmatter.kind === "string" && frontmatter.kind !== "benchmark" && !isRegisteredExtensionKind(frontmatter.kind)) {
136
+ issues.push(issue(document.path, `Spec frontmatter declares unknown extension kind \`${frontmatter.kind}\`.`));
137
+ }
138
+
139
+ if (frontmatter.kind === "benchmark" && document.kind !== "benchmark") {
140
+ issues.push(issue(document.path, "Benchmark sidecars must use the `.bench.` sidecar naming convention."));
141
+ }
142
+
143
+ if (document.area && document.id && !document.id.startsWith(`${document.area}.`)) {
144
+ issues.push(issue(document.path, `Spec id \`${document.id}\` must be prefixed with \`${document.area}.\`.`));
145
+ }
146
+
147
+ if (document.kind === "base") {
148
+ const codePaths = asStringArray(frontmatter.code_paths);
149
+
150
+ if (!isStringArray(frontmatter.code_paths)) {
151
+ issues.push(issue(document.path, "Base specs must declare `code_paths` as an array of strings."));
152
+ }
153
+
154
+ if (codePaths.length === 0) {
155
+ issues.push(issue(document.path, "Base specs must declare at least one `code_paths` entry."));
156
+ }
157
+
158
+ if (hasPlaceholderValue(codePaths)) {
159
+ issues.push(issue(document.path, "Replace placeholder values in `code_paths` before using this spec."));
160
+ }
161
+
162
+ const testPaths = asStringArray(frontmatter.test_paths);
163
+ const testCommands = asStringArray(frontmatter.test_commands);
164
+
165
+ if (frontmatter.test_paths !== undefined && !isStringArray(frontmatter.test_paths)) {
166
+ issues.push(issue(document.path, "`test_paths` must be an array of strings when provided."));
167
+ }
168
+
169
+ if (frontmatter.test_commands !== undefined && !isStringArray(frontmatter.test_commands)) {
170
+ issues.push(issue(document.path, "`test_commands` must be an array of strings when provided."));
171
+ }
172
+
173
+ if (!hasNonEmptyStringArray(frontmatter.test_paths) && !hasNonEmptyStringArray(frontmatter.test_commands)) {
174
+ issues.push(issue(document.path, "Base specs must declare `test_paths` or `test_commands`."));
175
+ }
176
+
177
+ if (hasPlaceholderValue(testPaths)) {
178
+ issues.push(issue(document.path, "Replace placeholder values in `test_paths` before using this spec."));
179
+ }
180
+
181
+ if (hasPlaceholderValue(testCommands)) {
182
+ issues.push(issue(document.path, "Replace placeholder values in `test_commands` before using this spec."));
183
+ }
184
+
185
+ if (frontmatter.owners !== undefined && !isStringArray(frontmatter.owners)) {
186
+ issues.push(issue(document.path, "`owners` must be an array of strings when provided."));
187
+ }
188
+
189
+ if (frontmatter.depends_on !== undefined && !isStringArray(frontmatter.depends_on)) {
190
+ issues.push(issue(document.path, "`depends_on` entries must be strings when provided."));
191
+ }
192
+
193
+ for (const sectionTitle of ["Summary", "Use Cases", "Invariants", "Failure Modes", "Acceptance Criteria", "Out of Scope"]) {
194
+ if (isPlaceholderSectionContent(getSectionContent(document, sectionTitle))) {
195
+ issues.push(issue(document.path, `Replace placeholder content in \`${sectionTitle}\` before using this spec.`));
196
+ }
197
+ }
198
+
199
+ if (document.status === "active") {
200
+ for (const requiredSection of ["Summary", "Use Cases", "Invariants", "Failure Modes", "Acceptance Criteria", "Out of Scope"]) {
201
+ if (!hasSection(document, requiredSection)) {
202
+ issues.push(issue(document.path, `Active base specs must include a non-empty \`${requiredSection}\` section.`));
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ if (document.extensionKind === "benchmark") {
209
+ if (frontmatter.kind !== "benchmark") {
210
+ issues.push(issue(document.path, "Benchmark sidecars must declare `kind: benchmark` in frontmatter."));
211
+ }
212
+
213
+ if (typeof frontmatter.extends !== "string" || frontmatter.extends.trim().length === 0) {
214
+ issues.push(issue(document.path, "Benchmark sidecars must declare a non-empty `extends` spec id."));
215
+ }
216
+
217
+ if (typeof frontmatter.environment !== "string" || frontmatter.environment.trim().length === 0) {
218
+ issues.push(issue(document.path, "Benchmark sidecars must declare a non-empty `environment`."));
219
+ }
220
+
221
+ if (typeof frontmatter.command !== "string" || frontmatter.command.trim().length === 0) {
222
+ issues.push(issue(document.path, "Benchmark sidecars must declare a non-empty `command`."));
223
+ }
224
+
225
+ if (frontmatter.required !== undefined && typeof frontmatter.required !== "boolean") {
226
+ issues.push(issue(document.path, "Benchmark sidecars must declare `required` as a boolean when provided."));
227
+ }
228
+
229
+ if (frontmatter.noise_tolerance_pct !== undefined && typeof frontmatter.noise_tolerance_pct !== "number") {
230
+ issues.push(issue(document.path, "Benchmark sidecars must declare `noise_tolerance_pct` as a number when provided."));
231
+ }
232
+
233
+ if (frontmatter.metrics !== undefined && !isStringRecord(frontmatter.metrics)) {
234
+ issues.push(issue(document.path, "Benchmark sidecars must declare `metrics` as an object with string threshold values."));
235
+ }
236
+
237
+ if (Object.keys(asStringRecord(frontmatter.metrics)).length === 0) {
238
+ issues.push(issue(document.path, "Benchmark sidecars must declare at least one string-valued metric threshold under `metrics`."));
239
+ }
240
+ }
241
+
242
+ return issues;
243
+ }
244
+
245
+ export function validateSpecConfig(config: ParsedSpecConfig): ValidationIssue[] {
246
+ const issues: ValidationIssue[] = [];
247
+
248
+ if (!isRecord(config.raw.default_stage_profiles)) {
249
+ issues.push(issue(config.path, "Spec config must declare `default_stage_profiles` as an object."));
250
+ }
251
+
252
+ if (!isRecord(config.raw.profiles) || Object.keys(config.profiles).length === 0) {
253
+ issues.push(issue(config.path, "Spec config must declare at least one stage profile under `profiles`."));
254
+ }
255
+
256
+ if (!isRecord(config.raw.harnesses) || Object.keys(config.harnesses).length === 0) {
257
+ issues.push(issue(config.path, "Spec config must declare at least one harness under `harnesses`."));
258
+ }
259
+
260
+ for (const stage of SPEC_STAGES) {
261
+ if (!config.defaultStageProfiles[stage]) {
262
+ issues.push(issue(config.path, `Spec config must map the \`${stage}\` stage under \`default_stage_profiles\`.`));
263
+ }
264
+ }
265
+
266
+ for (const [profileName, profile] of Object.entries(config.profiles)) {
267
+ if (!profile.harness) {
268
+ issues.push(issue(config.path, `Profile \`${profileName}\` must declare a non-empty \`harness\`.`));
269
+ }
270
+
271
+ if (profile.allowEdits === undefined) {
272
+ issues.push(issue(config.path, `Profile \`${profileName}\` must declare boolean \`allow_edits\`.`));
273
+ }
274
+
275
+ if (profile.model === undefined && profile.raw.model !== undefined && typeof profile.raw.model !== "string") {
276
+ issues.push(issue(config.path, `Profile \`${profileName}\` must declare \`model\` as a string when provided.`));
277
+ }
278
+
279
+ if (profile.harness && !config.harnesses[resolveConfiguredHarnessId(config, profile.harness)]) {
280
+ issues.push(issue(config.path, `Profile \`${profileName}\` references unknown harness \`${profile.harness}\`.`));
281
+ }
282
+ }
283
+
284
+ for (const [stage, profileName] of Object.entries(config.defaultStageProfiles)) {
285
+ if (!config.profiles[profileName]) {
286
+ issues.push(issue(config.path, `Stage \`${stage}\` references unknown profile \`${profileName}\`.`));
287
+ }
288
+ }
289
+
290
+ for (const [harnessName, harness] of Object.entries(config.harnesses)) {
291
+ if (!harness.command) {
292
+ issues.push(issue(config.path, `Harness \`${harnessName}\` must declare a non-empty \`command\`.`));
293
+ }
294
+
295
+ if (harness.raw.args !== undefined && !Array.isArray(harness.raw.args)) {
296
+ issues.push(issue(config.path, `Harness \`${harnessName}\` must declare \`args\` as an array when provided.`));
297
+ }
298
+
299
+ if (Array.isArray(harness.raw.args) && !isStringArray(harness.raw.args)) {
300
+ issues.push(issue(config.path, `Harness \`${harnessName}\` must declare \`args\` as an array of strings.`));
301
+ }
302
+ }
303
+
304
+ if (config.raw.agent_guidance !== undefined && !isRecord(config.raw.agent_guidance)) {
305
+ issues.push(issue(config.path, "Spec config `agent_guidance` must be an object when provided."));
306
+ }
307
+
308
+ if (config.agentGuidance) {
309
+ if (config.agentGuidance.raw.enabled !== undefined && typeof config.agentGuidance.raw.enabled !== "boolean") {
310
+ issues.push(issue(config.path, "Spec config `agent_guidance.enabled` must be a boolean when provided."));
311
+ }
312
+
313
+ if (config.agentGuidance.raw.target !== undefined && typeof config.agentGuidance.raw.target !== "string") {
314
+ issues.push(issue(config.path, "Spec config `agent_guidance.target` must be a string when provided."));
315
+ }
316
+
317
+ if (config.agentGuidance.raw.targets !== undefined && (!Array.isArray(config.agentGuidance.raw.targets) || !config.agentGuidance.raw.targets.every((entry: unknown) => typeof entry === "string"))) {
318
+ issues.push(issue(config.path, "Spec config `agent_guidance.targets` must be a list of strings when provided."));
319
+ }
320
+
321
+ if (config.agentGuidance.target !== undefined && config.agentGuidance.target.trim().length === 0) {
322
+ issues.push(issue(config.path, "Spec config `agent_guidance.target` must not be empty when provided."));
323
+ }
324
+
325
+ const allTargets = [
326
+ ...(config.agentGuidance.target ? [config.agentGuidance.target] : []),
327
+ ...(config.agentGuidance.targets ?? []),
328
+ ];
329
+
330
+ for (const target of allTargets) {
331
+ if (!isSafeRepoRelativePath(target)) {
332
+ issues.push(issue(config.path, `Spec config agent_guidance target \`${target}\` must stay inside the repository using a safe relative path.`));
333
+ }
334
+
335
+ if (isReservedLisaPath(target)) {
336
+ issues.push(issue(config.path, `Spec config agent_guidance target \`${target}\` must not point inside \`.specs/\` or \`.lisa/\`.`));
337
+ }
338
+ }
339
+
340
+ const guidanceEnabled = config.agentGuidance.enabled ?? Boolean(config.agentGuidance.target) ?? (config.agentGuidance.targets && config.agentGuidance.targets.length > 0);
341
+ if (guidanceEnabled && allTargets.length === 0) {
342
+ issues.push(issue(config.path, "Spec config `agent_guidance` must declare `target` or `targets` when enabled."));
343
+ }
344
+ }
345
+
346
+ return issues;
347
+ }
348
+
349
+ export function validateEnvironmentConfig(environment: ParsedEnvironmentConfig): ValidationIssue[] {
350
+ const issues: ValidationIssue[] = [];
351
+
352
+ if (!environment.name) {
353
+ issues.push(issue(environment.path, "Environment config must declare a non-empty `name`."));
354
+ }
355
+
356
+ if (environment.raw.runtime !== undefined && !isRecord(environment.raw.runtime)) {
357
+ issues.push(issue(environment.path, "Environment config `runtime` must be an object when provided."));
358
+ }
359
+
360
+ if (environment.raw.resources !== undefined && !isRecord(environment.raw.resources)) {
361
+ issues.push(issue(environment.path, "Environment config `resources` must be an object when provided."));
362
+ }
363
+
364
+ if (environment.raw.notes !== undefined && typeof environment.raw.notes !== "string") {
365
+ issues.push(issue(environment.path, "Environment config `notes` must be a string when provided."));
366
+ }
367
+
368
+ const expectedName = basename(environment.path).replace(/\.(yaml|yml)$/i, "");
369
+ if (environment.name && environment.name !== expectedName) {
370
+ issues.push(issue(environment.path, `Environment file name should match declared name \`${environment.name}\`.`, "warning"));
371
+ }
372
+
373
+ return issues;
374
+ }
375
+
376
+ export function validateLoadedSpecWorkspace(
377
+ workspace: Pick<LoadedSpecWorkspace, "documents" | "environments" | "config"> & { workspacePath?: string },
378
+ ): ValidationIssue[] {
379
+ const issues: ValidationIssue[] = [];
380
+ const seenSpecIds = new Map<string, string>();
381
+ const baseSpecIds = new Set<string>();
382
+ const seenEnvironmentNames = new Map<string, string>();
383
+
384
+ for (const document of workspace.documents) {
385
+ if (document.id) {
386
+ const previousPath = seenSpecIds.get(document.id);
387
+ if (previousPath) {
388
+ issues.push(issue(document.path, `Spec id \`${document.id}\` is duplicated in ${previousPath}.`));
389
+ } else {
390
+ seenSpecIds.set(document.id, document.path);
391
+ }
392
+ }
393
+
394
+ if (document.kind === "base" && document.id) {
395
+ baseSpecIds.add(document.id);
396
+ }
397
+ }
398
+
399
+ for (const environment of workspace.environments) {
400
+ if (!environment.name) {
401
+ continue;
402
+ }
403
+
404
+ const previousPath = seenEnvironmentNames.get(environment.name);
405
+ if (previousPath) {
406
+ issues.push(issue(environment.path, `Environment \`${environment.name}\` is duplicated in ${previousPath}.`));
407
+ } else {
408
+ seenEnvironmentNames.set(environment.name, environment.path);
409
+ }
410
+ }
411
+
412
+ for (const document of workspace.documents) {
413
+ if (document.extensionKind !== "benchmark") {
414
+ continue;
415
+ }
416
+
417
+ const extendsId = typeof document.frontmatter.extends === "string" ? document.frontmatter.extends : undefined;
418
+ const environmentName = typeof document.frontmatter.environment === "string" ? document.frontmatter.environment : undefined;
419
+
420
+ if (extendsId && !baseSpecIds.has(extendsId)) {
421
+ issues.push(issue(document.path, `Benchmark sidecar extends unknown base spec \`${extendsId}\`.`));
422
+ }
423
+
424
+ if (environmentName && !seenEnvironmentNames.has(environmentName)) {
425
+ issues.push(issue(document.path, `Benchmark sidecar references unknown environment \`${environmentName}\`.`));
426
+ }
427
+ }
428
+
429
+ if (workspace.config) {
430
+ for (const stage of SPEC_STAGES) {
431
+ const profileName = workspace.config.defaultStageProfiles[stage];
432
+ if (profileName && !workspace.config.profiles[profileName]) {
433
+ issues.push(issue(workspace.config.path, `Stage \`${stage}\` references unknown profile \`${profileName}\`.`));
434
+ }
435
+ }
436
+ }
437
+
438
+ if (workspace.workspacePath) {
439
+ issues.push(...validateGrammarCrossReferences(workspace.documents, { repoRoot: workspace.workspacePath }).issues);
440
+ }
441
+
442
+ return issues;
443
+ }