@specmarket/cli 0.0.5 → 0.0.6
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.
- package/dist/{chunk-DLEMNRTH.js → chunk-OTXWWFAO.js} +24 -2
- package/dist/chunk-OTXWWFAO.js.map +1 -0
- package/dist/{config-OAU6SJLC.js → config-5JMI3YAR.js} +2 -2
- package/dist/index.js +1283 -389
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/init.test.ts +162 -23
- package/src/commands/init.ts +349 -17
- package/src/commands/issues.test.ts +8 -3
- package/src/commands/issues.ts +2 -9
- package/src/commands/login.ts +2 -6
- package/src/commands/publish.test.ts +14 -1
- package/src/commands/publish.ts +1 -0
- package/src/commands/run.test.ts +206 -0
- package/src/commands/run.ts +63 -3
- package/src/commands/validate.test.ts +83 -6
- package/src/commands/validate.ts +96 -114
- package/src/lib/format-detection.test.ts +4 -4
- package/src/lib/format-detection.ts +3 -3
- package/src/lib/meta-instructions.test.ts +340 -0
- package/src/lib/meta-instructions.ts +562 -0
- package/src/lib/ralph-loop.test.ts +404 -0
- package/src/lib/ralph-loop.ts +475 -98
- package/src/lib/telemetry.ts +5 -0
- package/dist/chunk-DLEMNRTH.js.map +0 -1
- /package/dist/{config-OAU6SJLC.js.map → config-5JMI3YAR.js.map} +0 -0
package/src/commands/validate.ts
CHANGED
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
} from '@specmarket/shared';
|
|
14
14
|
import type { ValidationResult } from '@specmarket/shared';
|
|
15
15
|
import {
|
|
16
|
-
detectSpecFormat,
|
|
17
16
|
fileExists,
|
|
18
17
|
directoryExists,
|
|
19
18
|
hasStoryFiles,
|
|
@@ -134,10 +133,10 @@ export async function detectCircularReferences(dir: string): Promise<string[]> {
|
|
|
134
133
|
}
|
|
135
134
|
|
|
136
135
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
136
|
+
* Content validation when spec_format is "specmarket" (spec.yaml + PROMPT.md + SPEC.md + SUCCESS_CRITERIA.md + stdlib).
|
|
137
|
+
* Pushes to errors/warnings.
|
|
139
138
|
*/
|
|
140
|
-
async function
|
|
139
|
+
async function validateSpecmarketContent(
|
|
141
140
|
dir: string,
|
|
142
141
|
errors: string[],
|
|
143
142
|
warnings: string[]
|
|
@@ -259,18 +258,17 @@ async function validateLegacySpec(
|
|
|
259
258
|
}
|
|
260
259
|
|
|
261
260
|
/**
|
|
262
|
-
* Validates a spec directory.
|
|
263
|
-
* and format-specific
|
|
264
|
-
* formatDetectedBy when detection ran.
|
|
261
|
+
* Validates a spec directory. Single standard: specmarket.yaml is required for every spec.
|
|
262
|
+
* Format and metadata come from the sidecar; format-specific content checks run based on spec_format.
|
|
265
263
|
*/
|
|
266
264
|
export async function validateSpec(specPath: string): Promise<ValidationResult> {
|
|
267
265
|
const dir = resolve(specPath);
|
|
268
266
|
const errors: string[] = [];
|
|
269
267
|
const warnings: string[] = [];
|
|
268
|
+
let format: string | undefined;
|
|
269
|
+
let formatDetectedBy: 'sidecar' | 'heuristic' | undefined = 'sidecar';
|
|
270
270
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
// Universal: directory non-empty (at least one readable file)
|
|
271
|
+
// Universal: directory non-empty
|
|
274
272
|
try {
|
|
275
273
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
276
274
|
const hasAnyFile = entries.some((e) => e.isFile());
|
|
@@ -281,9 +279,12 @@ export async function validateSpec(specPath: string): Promise<ValidationResult>
|
|
|
281
279
|
errors.push('Directory is empty or unreadable');
|
|
282
280
|
}
|
|
283
281
|
|
|
284
|
-
// If sidecar exists, validate with specmarketSidecarSchema and sanity-check estimates
|
|
285
282
|
const sidecarPath = join(dir, SIDECAR_FILENAME);
|
|
286
|
-
|
|
283
|
+
const sidecarExists = await fileExists(sidecarPath);
|
|
284
|
+
|
|
285
|
+
if (!sidecarExists) {
|
|
286
|
+
errors.push(`${SIDECAR_FILENAME} is required for all specs (single source of truth for format and metadata)`);
|
|
287
|
+
} else {
|
|
287
288
|
try {
|
|
288
289
|
const raw = await readFile(sidecarPath, 'utf-8');
|
|
289
290
|
const parsed = parseYaml(raw) as unknown;
|
|
@@ -296,24 +297,21 @@ export async function validateSpec(specPath: string): Promise<ValidationResult>
|
|
|
296
297
|
}
|
|
297
298
|
} else {
|
|
298
299
|
const sidecar = sidecarResult.data;
|
|
300
|
+
format = sidecar.spec_format;
|
|
301
|
+
|
|
299
302
|
if (sidecar.estimated_tokens !== undefined) {
|
|
300
303
|
if (sidecar.estimated_tokens < 1000) {
|
|
301
304
|
warnings.push(
|
|
302
|
-
`
|
|
305
|
+
`estimated_tokens (${sidecar.estimated_tokens}) seems very low.`
|
|
303
306
|
);
|
|
304
307
|
}
|
|
305
308
|
if (sidecar.estimated_tokens > 10_000_000) {
|
|
306
|
-
warnings.push(
|
|
307
|
-
`sidecar estimated_tokens (${sidecar.estimated_tokens}) seems very high.`
|
|
308
|
-
);
|
|
309
|
+
warnings.push(`estimated_tokens (${sidecar.estimated_tokens}) seems very high.`);
|
|
309
310
|
}
|
|
310
311
|
}
|
|
311
|
-
if (
|
|
312
|
-
sidecar.estimated_cost_usd !== undefined &&
|
|
313
|
-
sidecar.estimated_cost_usd < 0.01
|
|
314
|
-
) {
|
|
312
|
+
if (sidecar.estimated_cost_usd !== undefined && sidecar.estimated_cost_usd < 0.01) {
|
|
315
313
|
warnings.push(
|
|
316
|
-
`
|
|
314
|
+
`estimated_cost_usd ($${sidecar.estimated_cost_usd}) seems very low.`
|
|
317
315
|
);
|
|
318
316
|
}
|
|
319
317
|
if (
|
|
@@ -321,103 +319,87 @@ export async function validateSpec(specPath: string): Promise<ValidationResult>
|
|
|
321
319
|
sidecar.estimated_time_minutes < 1
|
|
322
320
|
) {
|
|
323
321
|
warnings.push(
|
|
324
|
-
`
|
|
322
|
+
`estimated_time_minutes (${sidecar.estimated_time_minutes}) seems unrealistically low.`
|
|
325
323
|
);
|
|
326
324
|
}
|
|
327
|
-
}
|
|
328
|
-
} catch (err) {
|
|
329
|
-
errors.push(
|
|
330
|
-
`${SIDECAR_FILENAME}: Failed to read or parse: ${(err as Error).message}`
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Format-specific validation. Run legacy validation when spec.yaml exists or
|
|
336
|
-
// when format is legacy (e.g. PROMPT+SUCCESS_CRITERIA but spec.yaml missing).
|
|
337
|
-
const hasSpecYaml = await fileExists(join(dir, 'spec.yaml'));
|
|
338
|
-
if (hasSpecYaml || detection.format === 'specmarket-legacy') {
|
|
339
|
-
await validateLegacySpec(dir, errors, warnings);
|
|
340
|
-
}
|
|
341
325
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
326
|
+
// Format-specific content validation (based on sidecar.spec_format)
|
|
327
|
+
switch (format) {
|
|
328
|
+
case 'specmarket':
|
|
329
|
+
await validateSpecmarketContent(dir, errors, warnings);
|
|
330
|
+
break;
|
|
331
|
+
case 'speckit': {
|
|
332
|
+
const hasSpecMd = await fileExists(join(dir, 'spec.md'));
|
|
333
|
+
const hasTasksMd = await fileExists(join(dir, 'tasks.md'));
|
|
334
|
+
const hasPlanMd = await fileExists(join(dir, 'plan.md'));
|
|
335
|
+
const hasSpecifyDir = await directoryExists(join(dir, '.specify'));
|
|
336
|
+
if (!hasSpecMd) errors.push('speckit format requires spec.md');
|
|
337
|
+
if (!hasTasksMd && !hasPlanMd) errors.push('speckit format requires tasks.md or plan.md');
|
|
338
|
+
if (!hasSpecifyDir) warnings.push('speckit format: .specify/ directory is recommended');
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
case 'bmad': {
|
|
342
|
+
const hasPrdMd = await fileExists(join(dir, 'prd.md'));
|
|
343
|
+
const hasStory = await hasStoryFiles(dir);
|
|
344
|
+
if (!hasPrdMd && !hasStory) errors.push('bmad format requires prd.md or story-*.md files');
|
|
345
|
+
const hasArch = await fileExists(join(dir, 'architecture.md'));
|
|
346
|
+
if (!hasArch) warnings.push('bmad format: architecture.md is recommended');
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
case 'ralph': {
|
|
350
|
+
const prdPath = join(dir, 'prd.json');
|
|
351
|
+
if (!(await fileExists(prdPath))) {
|
|
352
|
+
errors.push('ralph format requires prd.json');
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const raw = await readFile(prdPath, 'utf-8');
|
|
357
|
+
const data = JSON.parse(raw) as unknown;
|
|
358
|
+
if (
|
|
359
|
+
!data ||
|
|
360
|
+
typeof data !== 'object' ||
|
|
361
|
+
!('userStories' in data) ||
|
|
362
|
+
!Array.isArray((data as { userStories: unknown }).userStories)
|
|
363
|
+
) {
|
|
364
|
+
errors.push('ralph format: prd.json must have userStories array');
|
|
365
|
+
}
|
|
366
|
+
} catch {
|
|
367
|
+
errors.push('ralph format: prd.json must be valid JSON with userStories array');
|
|
368
|
+
}
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
case 'custom':
|
|
372
|
+
default: {
|
|
373
|
+
const hasMd = await hasMarkdownFiles(dir);
|
|
374
|
+
if (!hasMd) {
|
|
375
|
+
errors.push('custom format requires at least one .md file');
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
const textExtensions = new Set(['.md']);
|
|
379
|
+
const mdFiles = await collectFiles(dir, dir, textExtensions);
|
|
380
|
+
let hasSubstantialMd = false;
|
|
381
|
+
for (const f of mdFiles) {
|
|
382
|
+
try {
|
|
383
|
+
const content = await readFile(join(dir, f), 'utf-8');
|
|
384
|
+
if (content.length > 100) {
|
|
385
|
+
hasSubstantialMd = true;
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
} catch {
|
|
389
|
+
// skip
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (!hasSubstantialMd) {
|
|
393
|
+
errors.push('custom format requires at least one .md file larger than 100 bytes');
|
|
394
|
+
}
|
|
411
395
|
break;
|
|
412
396
|
}
|
|
413
|
-
} catch {
|
|
414
|
-
// skip
|
|
415
397
|
}
|
|
416
398
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
399
|
+
} catch (err) {
|
|
400
|
+
errors.push(
|
|
401
|
+
`${SIDECAR_FILENAME}: Failed to read or parse: ${(err as Error).message}`
|
|
402
|
+
);
|
|
421
403
|
}
|
|
422
404
|
}
|
|
423
405
|
|
|
@@ -425,8 +407,8 @@ export async function validateSpec(specPath: string): Promise<ValidationResult>
|
|
|
425
407
|
valid: errors.length === 0,
|
|
426
408
|
errors,
|
|
427
409
|
warnings,
|
|
428
|
-
format
|
|
429
|
-
formatDetectedBy
|
|
410
|
+
format,
|
|
411
|
+
formatDetectedBy,
|
|
430
412
|
};
|
|
431
413
|
}
|
|
432
414
|
|
|
@@ -439,7 +421,7 @@ export function createValidateCommand(): Command {
|
|
|
439
421
|
const result = await validateSpec(specPath);
|
|
440
422
|
|
|
441
423
|
if (result.format !== undefined) {
|
|
442
|
-
console.log(chalk.gray(`
|
|
424
|
+
console.log(chalk.gray(`Format: ${result.format}`));
|
|
443
425
|
}
|
|
444
426
|
|
|
445
427
|
if (result.warnings.length > 0) {
|
|
@@ -96,12 +96,12 @@ describe('format-detection', () => {
|
|
|
96
96
|
expect(result.detectedBy).toBe('heuristic');
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
it('
|
|
99
|
+
it('specmarket dir (spec.yaml + PROMPT.md + SUCCESS_CRITERIA.md) → specmarket, high', async () => {
|
|
100
100
|
await writeFile(join(tmpDir, 'spec.yaml'), 'name: x');
|
|
101
101
|
await writeFile(join(tmpDir, 'PROMPT.md'), '# P');
|
|
102
102
|
await writeFile(join(tmpDir, 'SUCCESS_CRITERIA.md'), '- [ ] C');
|
|
103
103
|
const result = await detectSpecFormat(tmpDir);
|
|
104
|
-
expect(result.format).toBe('specmarket
|
|
104
|
+
expect(result.format).toBe('specmarket');
|
|
105
105
|
expect(result.confidence).toBe('high');
|
|
106
106
|
expect(result.detectedBy).toBe('heuristic');
|
|
107
107
|
});
|
|
@@ -213,11 +213,11 @@ describe('format-detection', () => {
|
|
|
213
213
|
expect(result.format).toBe('custom');
|
|
214
214
|
});
|
|
215
215
|
|
|
216
|
-
it('
|
|
216
|
+
it('specmarket requires spec.yaml + PROMPT + SUCCESS_CRITERIA (no spec with only two)', async () => {
|
|
217
217
|
await writeFile(join(tmpDir, 'spec.yaml'), 'x');
|
|
218
218
|
await writeFile(join(tmpDir, 'PROMPT.md'), 'x');
|
|
219
219
|
const result = await detectSpecFormat(tmpDir);
|
|
220
|
-
expect(result.format).not.toBe('specmarket
|
|
220
|
+
expect(result.format).not.toBe('specmarket');
|
|
221
221
|
});
|
|
222
222
|
});
|
|
223
223
|
});
|
|
@@ -75,7 +75,7 @@ export async function tryReadSidecar(
|
|
|
75
75
|
/**
|
|
76
76
|
* Detect spec format for a directory. Priority:
|
|
77
77
|
* 1. Sidecar specmarket.yaml with spec_format → use that value, high confidence
|
|
78
|
-
* 2. specmarket
|
|
78
|
+
* 2. specmarket: spec.yaml + PROMPT.md + SUCCESS_CRITERIA.md
|
|
79
79
|
* 3. speckit: spec.md + (plan.md | tasks.md) + .specify/ directory
|
|
80
80
|
* 4. bmad: prd.md + (architecture.md | story-*.md)
|
|
81
81
|
* 5. ralph: prd.json with userStories[] array
|
|
@@ -97,14 +97,14 @@ export async function detectSpecFormat(dir: string): Promise<FormatDetectionResu
|
|
|
97
97
|
const hasSuccessCriteria = await fileExists(join(dir, 'SUCCESS_CRITERIA.md'));
|
|
98
98
|
if (hasSpecYaml && hasPromptMd && hasSuccessCriteria) {
|
|
99
99
|
return {
|
|
100
|
-
format: 'specmarket
|
|
100
|
+
format: 'specmarket',
|
|
101
101
|
detectedBy: 'heuristic',
|
|
102
102
|
confidence: 'high',
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
105
|
if (hasPromptMd && hasSuccessCriteria) {
|
|
106
106
|
return {
|
|
107
|
-
format: 'specmarket
|
|
107
|
+
format: 'specmarket',
|
|
108
108
|
detectedBy: 'heuristic',
|
|
109
109
|
confidence: 'high',
|
|
110
110
|
};
|