From dc5e516151e69107b39e38b01d96de5cf542499c Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Wed, 8 Apr 2026 17:36:00 +0100 Subject: [PATCH] feat: spec driven development features, docs fix, and articles - Fix broken /docs/cli/figma link in HeroSection (unblocks all docs CI) - Add spec-validator with completeness scoring (0-100) - Add --spec-validate flag to run command - Add ralph-starter spec command (validate, list, summary) - Create docs/docs/cli/figma.md - Add SDD blog post (EN), LinkedIn article (PT-BR), Twitter thread (EN) Co-Authored-By: Claude Opus 4.6 (1M context) --- content/linkedin-sdd-ralph-starter-pt-br.md | 81 +++++ content/twitter-thread-sdd-ralph-starter.md | 120 +++++++ ...8-spec-driven-development-ralph-starter.md | 125 +++++++ docs/docs/cli/figma.md | 52 +++ docs/src/components/HeroSection/index.tsx | 2 +- src/cli.ts | 15 + src/commands/run.ts | 19 + src/commands/spec.ts | 325 ++++++++++++++++++ src/loop/__tests__/spec-validator.test.ts | 125 +++++++ src/loop/spec-validator.ts | 151 ++++++++ 10 files changed, 1014 insertions(+), 1 deletion(-) create mode 100644 content/linkedin-sdd-ralph-starter-pt-br.md create mode 100644 content/twitter-thread-sdd-ralph-starter.md create mode 100644 docs/blog/2026-04-08-spec-driven-development-ralph-starter.md create mode 100644 docs/docs/cli/figma.md create mode 100644 src/commands/spec.ts create mode 100644 src/loop/__tests__/spec-validator.test.ts create mode 100644 src/loop/spec-validator.ts diff --git a/content/linkedin-sdd-ralph-starter-pt-br.md b/content/linkedin-sdd-ralph-starter-pt-br.md new file mode 100644 index 00000000..927c6f8d --- /dev/null +++ b/content/linkedin-sdd-ralph-starter-pt-br.md @@ -0,0 +1,81 @@ +# LinkedIn: Spec Driven Development com ralph-starter + +## Formato +- Artigo longo no LinkedIn +- Idioma: Portugues brasileiro +- Tom: Profissional, direto, com exemplos praticos +- Publico: Devs brasileiros, tech leads, CTOs + +--- + +## Titulo + +Spec Driven Development: por que voce deveria parar de mandar "faz um CRUD" pro agente de IA + +--- + +## Corpo + +Nos ultimos meses eu vi uma mudanca silenciosa na forma como devs usam agentes de IA pra codar. + +No comeco, todo mundo fazia a mesma coisa: abria o chat, escrevia "cria uma API de autenticacao", rezava, e torcia pro resultado fazer sentido. As vezes dava certo. Na maioria das vezes, nao. + +O problema nunca foi o agente. O problema era a especificacao -- ou melhor, a falta dela. + +Isso tem nome agora: Spec Driven Development (SDD). + +A ideia e simples: antes de codar, voce escreve uma spec clara. Nao um documento de 50 paginas. Uma spec de 10-20 linhas que diz exatamente o que precisa ser feito, como validar, e quais sao os criterios de aceite. + +Tem tres ferramentas ganhando tracao nesse espaco: + +- **OpenSpec** (Fission AI) -- framework leve e tool-agnostic. Voce cria uma pasta openspec/ com proposal.md, design.md, tasks.md, e specs com keywords RFC 2119 (SHALL, MUST, SHOULD). + +- **Spec-Kit** (GitHub) -- mais pesado, com 5 fases (constituicao, especificacao, planejamento, tarefas, implementacao). Bom pra projetos grandes. + +- **Kiro** (AWS) -- IDE completa com agentes integrados. Poderoso, mas locked no ecossistema AWS. + +Eu construi o ralph-starter justamente pra resolver esse gap. Ele puxa specs de qualquer lugar -- GitHub Issues, Linear, Notion, Figma, OpenSpec -- e roda loops autonomos de codificacao ate a tarefa estar completa. + +O fluxo e assim: + +``` +Spec -> Plano de implementacao -> Agente codifica -> Lint/Build/Testes -> Se falhou, alimenta o erro de volta -> Repete -> Commit + PR +``` + +Na v0.5.0 a gente adicionou suporte nativo a OpenSpec: + +```bash +# Ler specs de um diretorio OpenSpec +ralph-starter run --from openspec:minha-feature + +# Validar completude da spec antes de rodar +ralph-starter run --from openspec:minha-feature --spec-validate + +# Listar specs disponiveis +ralph-starter spec list + +# Validar todas as specs do projeto +ralph-starter spec validate +``` + +O `--spec-validate` checa se sua spec tem: +- Uma secao de proposta/racional (por que?) +- Keywords RFC 2119 (SHALL, MUST) +- Criterios de aceite (Given/When/Then) +- Design e tasks + +E retorna um score de 0 a 100. Se a spec estiver incompleta, o ralph-starter avisa antes de gastar tokens. + +O resultado pratico: specs claras = menos iteracoes = menos custo = PRs melhores. + +Eu costumava gastar 5 loops e $3+ pra resolver uma tarefa mal especificada. Agora gasto 3 minutos escrevendo uma spec boa e 2 loops resolvem. O custo cai pra ~$0.50. + +Se voce esta usando qualquer agente de IA pra codar -- Claude Code, Cursor, Copilot, o que for -- comeca a escrever specs. Serio. E a maior alavanca de produtividade que voce vai encontrar esse ano. + +ralph-starter e open source, MIT licensed: +https://github.com/multivmlabs/ralph-starter + +--- + +## Hashtags +#SpecDrivenDevelopment #AICoding #OpenSource #DevTools #ralph-starter #OpenSpec #SDD diff --git a/content/twitter-thread-sdd-ralph-starter.md b/content/twitter-thread-sdd-ralph-starter.md new file mode 100644 index 00000000..4edf780b --- /dev/null +++ b/content/twitter-thread-sdd-ralph-starter.md @@ -0,0 +1,120 @@ +# Twitter/X Thread: Spec Driven Development with ralph-starter + +## Instructions +- Post as a thread (not a single tweet) +- Each tweet under 280 characters +- Include code screenshots where noted + +--- + +## Tweet 1 (Hook) + +Spec Driven Development is eating AI coding. + +OpenSpec, Spec-Kit, Kiro -- everyone's building spec frameworks now. + +Here's why specs matter more than prompts, and how ralph-starter fits in: + +--- + +## Tweet 2 (The problem) + +The #1 mistake with AI coding agents: + +"Add authentication to the app" + +3 words. Zero context. The agent guesses everything. You spend 5 iterations fixing what a 10-line spec would've nailed in 2. + +--- + +## Tweet 3 (What is SDD) + +Spec Driven Development = write a clear spec BEFORE the agent touches code. + +Not a 50-page doc. A focused spec: +- What to build (proposal) +- How to build it (design) +- How to verify it (acceptance criteria) + +10-20 lines. 3 minutes to write. + +--- + +## Tweet 4 (The landscape) + +Three SDD tools gaining traction: + +OpenSpec -- lightweight, tool-agnostic, fluid phases +Spec-Kit -- GitHub's heavyweight 5-phase framework +Kiro -- AWS's full IDE with built-in agents + +Each has tradeoffs. None connects to your existing workflow. + +--- + +## Tweet 5 (ralph-starter's angle) + +ralph-starter takes a different approach: + +Your specs already live in GitHub Issues, Linear tickets, Notion docs, Figma files. + +Why rewrite them? Pull from where they are, run autonomous loops until done. + +``` +ralph-starter run --from github --project myorg/repo +ralph-starter run --from openspec:my-feature +``` + +--- + +## Tweet 6 (New: OpenSpec + spec-validate) + +Just shipped in v0.5.0: + +Native OpenSpec support + spec validation. + +``` +ralph-starter spec validate +ralph-starter run --from openspec:auth --spec-validate +``` + +Checks for RFC 2119 keywords (SHALL/MUST), acceptance criteria, design sections. Scores 0-100. + +Low score = bad spec = wasted tokens. + +--- + +## Tweet 7 (The numbers) + +Before specs: 5 loops, $3+, wrong output +After specs: 2 loops, ~$0.50, correct output + +The spec IS the leverage. Not the model. Not the prompt engineering. The spec. + +--- + +## Tweet 8 (Multi-agent) + +ralph-starter works with any agent: + +- Claude Code +- Cursor +- Codex CLI +- OpenCode +- Amp (Sourcegraph) + +No lock-in. No IDE requirement. CLI that runs anywhere. + +--- + +## Tweet 9 (CTA) + +ralph-starter is open source, MIT licensed. + +Pull specs from GitHub/Linear/Notion/Figma/OpenSpec. +Run autonomous coding loops. +Ship faster. + +https://github.com/multivmlabs/ralph-starter + +Star it if SDD resonates. diff --git a/docs/blog/2026-04-08-spec-driven-development-ralph-starter.md b/docs/blog/2026-04-08-spec-driven-development-ralph-starter.md new file mode 100644 index 00000000..a8e6c614 --- /dev/null +++ b/docs/blog/2026-04-08-spec-driven-development-ralph-starter.md @@ -0,0 +1,125 @@ +--- +slug: spec-driven-development-ralph-starter +title: Spec Driven Development with ralph-starter +authors: [ruben] +tags: [ralph-starter, sdd, openspec, specs, workflow] +description: How ralph-starter brings Spec Driven Development to any AI coding agent, with native OpenSpec support, spec validation, and multi-source spec fetching. +image: /img/blog/sdd-ralph-starter.png +--- + +Spec Driven Development is the biggest shift in AI coding since agents learned to run tests. Here is how ralph-starter fits in. + + + +## The problem with "just prompt it" + +Most people use AI coding agents the same way: type a sentence, hit enter, hope for the best. "Add user auth." "Fix the sidebar." Three words and vibes. + +I did this for weeks. The agent would generate something that looked plausible but missed what I actually wanted. I blamed the tool, but the problem was me. I was not giving it enough context. + +Then I started writing specs -- not essays, just 10-20 lines describing what I actually wanted, how to verify it, and where things should go. The difference was night and day. 2 loops instead of 5. $0.50 instead of $3. Correct output instead of close-but-wrong. + +This pattern has a name now: **Spec Driven Development (SDD)**. + +## The SDD landscape + +Three frameworks are leading the SDD conversation: + +| Tool | Philosophy | Lock-in | +|------|-----------|---------| +| **OpenSpec** (Fission AI) | Lightweight, fluid, tool-agnostic | None | +| **Spec-Kit** (GitHub) | Heavyweight, rigid 5-phase gates | GitHub ecosystem | +| **Kiro** (AWS) | Full IDE with built-in agents | AWS account required | + +OpenSpec organizes specs into changes with `proposal.md`, `design.md`, `tasks.md`, and requirement specs using RFC 2119 keywords (SHALL, MUST, SHOULD). It is the lightest of the three. + +Spec-Kit enforces five phases: constitution, specification, plan, tasks, implement. Thorough but heavy. + +Kiro bundles everything into a VS Code fork with agent hooks and EARS notation. Powerful but locked to AWS. + +## Where ralph-starter fits + +ralph-starter takes a different angle: **your specs already exist somewhere**. + +They are in GitHub Issues. Linear tickets. Notion docs. Figma designs. OpenSpec directories. Why rewrite them in a new format? + +ralph-starter pulls specs from where they already live: + +```bash +# From GitHub issues +ralph-starter run --from github --project myorg/myrepo --label "ready" + +# From OpenSpec directories +ralph-starter run --from openspec:add-auth + +# From Linear tickets +ralph-starter run --from linear --project "Mobile App" + +# From a Notion doc +ralph-starter run --from notion --project "https://notion.so/spec-abc123" +``` + +Then it runs autonomous loops: build context, spawn agent, collect output, run validation (lint/build/test), commit, repeat until done. + +## New in v0.5.0: OpenSpec + spec validation + +We just shipped native OpenSpec support and a spec validator: + +```bash +# List all OpenSpec changes in the project +ralph-starter spec list + +# Validate spec completeness (0-100 score) +ralph-starter spec validate + +# Validate before running -- stops if spec is too thin +ralph-starter run --from openspec:my-feature --spec-validate +``` + +The validator checks for: +- Proposal or rationale section (why are we building this?) +- RFC 2119 keywords (SHALL, MUST -- formal requirements) +- Given/When/Then acceptance criteria (testable conditions) +- Design section (how to build it) +- Task breakdown (implementation steps) + +A spec scoring below 40/100 gets flagged before the agent starts. This saves tokens on underspecified work. + +## The new spec command + +`ralph-starter spec` gives you a CLI for spec operations: + +```bash +# Validate all specs in the project +ralph-starter spec validate + +# List available specs (auto-detects OpenSpec, Spec-Kit, or raw) +ralph-starter spec list + +# Show completeness summary +ralph-starter spec summary +``` + +It auto-detects whether you are using OpenSpec format, GitHub Spec-Kit format, or plain markdown specs. + +## The numbers + +| Metric | Without specs | With specs | +|--------|--------------|------------| +| Loops per task | 5 | 2 | +| Cost per task | ~$3.00 | ~$0.50 | +| Output accuracy | Hit or miss | Consistent | +| Time writing spec | 0 min | 3 min | + +The 3 minutes spent writing a spec save 15 minutes of iteration and debugging. The spec is the leverage. + +## What is next + +We are working on: +- **Spec coverage tracking** -- which requirements have been implemented? +- **Spec-to-test generation** -- Given/When/Then to test stubs +- **Living specs** -- specs that update as implementation diverges + +SDD is not a fad. It is the natural evolution of AI-assisted coding. The spec is the interface between human intent and machine execution. The clearer the spec, the better the output. + +ralph-starter is open source, MIT licensed: [github.com/multivmlabs/ralph-starter](https://github.com/multivmlabs/ralph-starter) diff --git a/docs/docs/cli/figma.md b/docs/docs/cli/figma.md new file mode 100644 index 00000000..680ed4f2 --- /dev/null +++ b/docs/docs/cli/figma.md @@ -0,0 +1,52 @@ +--- +sidebar_position: 12 +title: figma +description: Interactive Figma-to-code wizard +keywords: [cli, figma, wizard, design, integration] +--- + +# ralph-starter figma + +Interactive wizard for building code from Figma designs. + +## Synopsis + +```bash +ralph-starter figma [options] +``` + +## Description + +The `figma` command launches an interactive wizard that guides you through selecting a Figma file, choosing a mode (spec, tokens, components, assets, content), and running an autonomous coding loop to implement the design. + +For non-interactive usage and detailed mode documentation, see [Figma Source](/docs/sources/figma). + +## Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--commit` | Auto-commit after tasks | false | +| `--push` | Push commits to remote | false | +| `--pr` | Create pull request when done | false | +| `--validate` | Run validation after iterations | true | +| `--no-validate` | Skip validation | - | +| `--max-iterations ` | Maximum loop iterations | 50 | +| `--agent ` | Agent to use | auto-detect | + +## Examples + +```bash +# Launch the interactive wizard +ralph-starter figma + +# With auto-commit and PR creation +ralph-starter figma --commit --pr + +# Using a specific agent +ralph-starter figma --agent claude-code --max-iterations 10 +``` + +## See Also + +- [Figma Source](/docs/sources/figma) - Detailed mode documentation, authentication, and troubleshooting +- [run](/docs/cli/run) - Non-interactive Figma usage via `--from figma` diff --git a/docs/src/components/HeroSection/index.tsx b/docs/src/components/HeroSection/index.tsx index fcc41ff8..c25643b1 100644 --- a/docs/src/components/HeroSection/index.tsx +++ b/docs/src/components/HeroSection/index.tsx @@ -205,7 +205,7 @@ export default function HeroSection(): React.ReactElement { Integrations
{[ - { id: 'figma' as const, to: '/docs/cli/figma', src: '/img/figma-logo.svg', alt: 'Figma' }, + { id: 'figma' as const, to: '/docs/sources/figma', src: '/img/figma-logo.svg', alt: 'Figma' }, { id: 'github' as const, to: '/docs/sources/github', src: '/img/github logo.webp', alt: 'GitHub' }, { id: 'linear' as const, to: '/docs/sources/linear', src: '/img/linear.jpeg', alt: 'Linear' }, { id: 'notion' as const, to: '/docs/sources/notion', src: '/img/notion logo.png', alt: 'Notion' }, diff --git a/src/cli.ts b/src/cli.ts index f5dc9140..e6125f9c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ import { runCommand } from './commands/run.js'; import { setupCommand } from './commands/setup.js'; import { skillCommand } from './commands/skill.js'; import { sourceCommand } from './commands/source.js'; +import { specCommand } from './commands/spec.js'; import { taskCommand } from './commands/task.js'; import { templateCommand } from './commands/template.js'; import { startMcpServer } from './mcp/server.js'; @@ -136,6 +137,10 @@ program '--acceptance-criteria', 'Extract or generate Given/When/Then acceptance criteria and inject into agent prompt' ) + .option( + '--spec-validate', + 'Validate spec completeness before starting the loop (checks structure, keywords, criteria)' + ) .option( '--design-image ', 'Design reference image (screenshot of the target design for pixel-perfect matching)' @@ -261,6 +266,16 @@ program }); }); +// ralph-starter spec - Spec-driven development operations +program + .command('spec') + .description('Validate, list, and summarize specs (OpenSpec, Spec-Kit, or raw)') + .argument('', 'Action: validate, list, summary') + .option('--path ', 'Path to spec file or directory') + .action(async (action, options) => { + await specCommand(action, { path: options.path }); + }); + // ralph-starter init - Initialize Ralph in a project program .command('init') diff --git a/src/commands/run.ts b/src/commands/run.ts index 43dd69b4..85b73d12 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -306,6 +306,8 @@ export interface RunCommandOptions { shiftLeft?: boolean; // Acceptance criteria acceptanceCriteria?: boolean; + // Spec validation + specValidate?: boolean; // Design reference designImage?: string; // Visual comparison @@ -1428,6 +1430,23 @@ Focus on one task at a time. After completing a task, update IMPLEMENTATION_PLAN } } + // Validate spec completeness if requested + if (options.specValidate && finalTask) { + const { formatValidationResult, validateSpec } = await import('../loop/spec-validator.js'); + const result = validateSpec(finalTask); + console.log(chalk.dim(` Spec validation: ${result.score}/100`)); + if (!result.valid) { + console.log(); + console.log(formatValidationResult(result)); + console.log(); + console.log(chalk.yellow('Spec validation failed. The spec may not have enough detail.')); + console.log( + chalk.dim('Run without --spec-validate to skip this check, or improve the spec.') + ); + return; + } + } + // Extract acceptance criteria if requested if (options.acceptanceCriteria && finalTask) { const { extractAcceptanceCriteria } = await import('../loop/acceptance-criteria.js'); diff --git a/src/commands/spec.ts b/src/commands/spec.ts new file mode 100644 index 00000000..b9181a41 --- /dev/null +++ b/src/commands/spec.ts @@ -0,0 +1,325 @@ +/** + * Spec Command + * + * CLI interface for spec-driven development operations: + * validate, list, and summarize specs from any detected source. + */ + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import chalk from 'chalk'; +import { formatValidationResult, validateSpec } from '../loop/spec-validator.js'; + +type SpecCommandAction = 'validate' | 'list' | 'summary'; + +interface SpecCommandOptions { + path?: string; +} + +export async function specCommand( + action: SpecCommandAction, + options: SpecCommandOptions +): Promise { + const cwd = process.cwd(); + + switch (action) { + case 'validate': + return specValidate(cwd, options); + case 'list': + return specList(cwd); + case 'summary': + return specSummary(cwd); + default: + console.log(chalk.red(`Unknown action: ${action}`)); + console.log(chalk.dim('Available actions: validate, list, summary')); + } +} + +/** + * Detect which spec format is present in the project. + */ +function detectSpecFormat(cwd: string): 'openspec' | 'speckit' | 'raw' | null { + if (existsSync(join(cwd, 'openspec', 'changes'))) return 'openspec'; + if (existsSync(join(cwd, 'openspec', 'specs'))) return 'openspec'; + if (existsSync(join(cwd, 'specs', 'constitution.md'))) return 'speckit'; + if (existsSync(join(cwd, 'specs'))) return 'raw'; + return null; +} + +/** + * Validate specs in the project. + */ +async function specValidate(cwd: string, options: SpecCommandOptions): Promise { + console.log(); + console.log(chalk.cyan.bold('Spec Validation')); + console.log(); + + const specDir = options.path ? join(cwd, options.path) : null; + const format = detectSpecFormat(cwd); + + if (specDir && existsSync(specDir)) { + // Validate a specific file or directory + const stat = statSync(specDir); + if (stat.isFile()) { + const content = readFileSync(specDir, 'utf-8'); + const result = validateSpec(content); + console.log(chalk.dim(`File: ${options.path}`)); + console.log(formatValidationResult(result)); + return; + } + } + + if (format === 'openspec') { + validateOpenSpec(cwd); + } else if (format === 'speckit') { + validateSpecKit(cwd); + } else if (format === 'raw') { + validateRawSpecs(cwd); + } else { + console.log(chalk.yellow('No specs found.')); + console.log(chalk.dim('Create specs in openspec/ or specs/ directory.')); + console.log(); + console.log(chalk.dim('Supported formats:')); + console.log(chalk.dim(' - OpenSpec: openspec/changes//proposal.md')); + console.log(chalk.dim(' - Spec-Kit: specs/constitution.md')); + console.log(chalk.dim(' - Raw: specs/*.md')); + } +} + +function validateOpenSpec(cwd: string): void { + const changesDir = join(cwd, 'openspec', 'changes'); + if (!existsSync(changesDir)) { + console.log(chalk.yellow('No openspec/changes/ directory found.')); + return; + } + + const changes = readdirSync(changesDir).filter( + (name) => + name !== 'archive' && !name.startsWith('.') && statSync(join(changesDir, name)).isDirectory() + ); + + if (changes.length === 0) { + console.log(chalk.yellow('No changes found in openspec/changes/')); + return; + } + + console.log( + chalk.dim(`Format: OpenSpec (${changes.length} change${changes.length > 1 ? 's' : ''})`) + ); + console.log(); + + let allValid = true; + for (const name of changes) { + const changePath = join(changesDir, name); + const parts: string[] = []; + + for (const file of ['proposal.md', 'design.md', 'tasks.md']) { + const filePath = join(changePath, file); + if (existsSync(filePath)) { + parts.push(readFileSync(filePath, 'utf-8')); + } + } + + // Read specs + const specsDir = join(changePath, 'specs'); + if (existsSync(specsDir)) { + for (const area of readdirSync(specsDir)) { + const specPath = join(specsDir, area, 'spec.md'); + if (existsSync(specPath)) { + parts.push(readFileSync(specPath, 'utf-8')); + } + } + } + + const combined = parts.join('\n\n'); + const result = validateSpec(combined); + + console.log( + ` ${result.valid ? chalk.green('PASS') : chalk.red('FAIL')} ${chalk.bold(name)} (${result.score}/100)` + ); + for (const w of result.warnings.filter((w) => w.level !== 'info')) { + console.log(chalk.dim(` ${w.message}`)); + } + + if (!result.valid) allValid = false; + } + + console.log(); + if (allValid) { + console.log(chalk.green('All specs pass validation.')); + } else { + console.log(chalk.yellow('Some specs need improvement. See warnings above.')); + } +} + +function validateSpecKit(cwd: string): void { + const specsDir = join(cwd, 'specs'); + console.log(chalk.dim('Format: Spec-Kit')); + console.log(); + + const files = ['constitution.md', 'specification.md', 'plan.md', 'tasks.md']; + const parts: string[] = []; + + for (const file of files) { + const filePath = join(specsDir, file); + if (existsSync(filePath)) { + parts.push(readFileSync(filePath, 'utf-8')); + console.log(` ${chalk.green('found')} ${file}`); + } else { + console.log(` ${chalk.yellow('missing')} ${file}`); + } + } + + console.log(); + if (parts.length > 0) { + const result = validateSpec(parts.join('\n\n')); + console.log(formatValidationResult(result)); + } +} + +function validateRawSpecs(cwd: string): void { + const specsDir = join(cwd, 'specs'); + const files = readdirSync(specsDir).filter((f) => f.endsWith('.md')); + + console.log( + chalk.dim(`Format: Raw markdown (${files.length} file${files.length > 1 ? 's' : ''})`) + ); + console.log(); + + for (const file of files) { + const content = readFileSync(join(specsDir, file), 'utf-8'); + const result = validateSpec(content); + console.log( + ` ${result.valid ? chalk.green('PASS') : chalk.red('FAIL')} ${chalk.bold(file)} (${result.score}/100)` + ); + } +} + +/** + * List available specs from any detected source. + */ +async function specList(cwd: string): Promise { + console.log(); + console.log(chalk.cyan.bold('Available Specs')); + console.log(); + + const format = detectSpecFormat(cwd); + + if (format === 'openspec') { + const changesDir = join(cwd, 'openspec', 'changes'); + if (existsSync(changesDir)) { + const changes = readdirSync(changesDir).filter( + (name) => + name !== 'archive' && + !name.startsWith('.') && + statSync(join(changesDir, name)).isDirectory() + ); + console.log(chalk.dim('Format: OpenSpec')); + console.log(); + for (const name of changes) { + const changePath = join(changesDir, name); + const files = ['proposal.md', 'design.md', 'tasks.md'].filter((f) => + existsSync(join(changePath, f)) + ); + console.log(` ${chalk.bold(name)} (${files.join(', ')})`); + } + if (changes.length === 0) { + console.log(chalk.dim(' No active changes.')); + } + } + + const globalSpecs = join(cwd, 'openspec', 'specs'); + if (existsSync(globalSpecs)) { + const specs = readdirSync(globalSpecs).filter((d) => + statSync(join(globalSpecs, d)).isDirectory() + ); + if (specs.length > 0) { + console.log(); + console.log(chalk.dim('Global specs:')); + for (const spec of specs) { + console.log(` ${chalk.bold(spec)}`); + } + } + } + } else if (format === 'speckit' || format === 'raw') { + const specsDir = join(cwd, 'specs'); + const files = readdirSync(specsDir).filter((f) => f.endsWith('.md')); + console.log(chalk.dim(`Format: ${format === 'speckit' ? 'Spec-Kit' : 'Raw markdown'}`)); + console.log(); + for (const file of files) { + console.log(` ${chalk.bold(file)}`); + } + } else { + console.log(chalk.yellow('No specs found.')); + console.log(chalk.dim('Create specs in openspec/ or specs/ directory.')); + } + + console.log(); +} + +/** + * Show spec completeness summary. + */ +async function specSummary(cwd: string): Promise { + console.log(); + console.log(chalk.cyan.bold('Spec Summary')); + console.log(); + + const format = detectSpecFormat(cwd); + + if (!format) { + console.log(chalk.yellow('No specs found.')); + return; + } + + console.log( + ` Format: ${format === 'openspec' ? 'OpenSpec' : format === 'speckit' ? 'Spec-Kit' : 'Raw markdown'}` + ); + + if (format === 'openspec') { + const changesDir = join(cwd, 'openspec', 'changes'); + const changes = existsSync(changesDir) + ? readdirSync(changesDir).filter( + (name) => + name !== 'archive' && + !name.startsWith('.') && + statSync(join(changesDir, name)).isDirectory() + ) + : []; + + const archiveDir = join(cwd, 'openspec', 'changes', 'archive'); + const archived = existsSync(archiveDir) + ? readdirSync(archiveDir).filter((d) => statSync(join(archiveDir, d)).isDirectory()).length + : 0; + + const globalSpecs = join(cwd, 'openspec', 'specs'); + const globalCount = existsSync(globalSpecs) + ? readdirSync(globalSpecs).filter((d) => statSync(join(globalSpecs, d)).isDirectory()).length + : 0; + + console.log(` Changes: ${changes.length} active, ${archived} archived`); + console.log(` Specs: ${globalCount} global`); + + if (changes.length > 0) { + let totalScore = 0; + for (const name of changes) { + const changePath = join(changesDir, name); + const parts: string[] = []; + for (const file of ['proposal.md', 'design.md', 'tasks.md']) { + const fp = join(changePath, file); + if (existsSync(fp)) parts.push(readFileSync(fp, 'utf-8')); + } + const result = validateSpec(parts.join('\n\n')); + totalScore += result.score; + } + const avg = Math.round(totalScore / changes.length); + console.log(` Average: ${avg}/100 completeness`); + } + } else { + const specsDir = join(cwd, 'specs'); + const files = readdirSync(specsDir).filter((f) => f.endsWith('.md')); + console.log(` Files: ${files.length}`); + } + + console.log(); +} diff --git a/src/loop/__tests__/spec-validator.test.ts b/src/loop/__tests__/spec-validator.test.ts new file mode 100644 index 00000000..9e92ae23 --- /dev/null +++ b/src/loop/__tests__/spec-validator.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { formatValidationResult, validateSpec } from '../spec-validator.js'; + +describe('validateSpec', () => { + it('should score a complete spec highly', () => { + const spec = ` +# My Feature + +## Proposal +We need this feature because users have requested it. + +## Design +The implementation will use a new service layer with dependency injection. + +## Tasks +- [ ] Create the service class +- [ ] Add routes +- [ ] Write tests + +## Acceptance Criteria +Given: A user is logged in +When: They click the dashboard button +Then: The dashboard loads within 2 seconds + +The service SHALL handle concurrent requests. +The API MUST return 200 on success. + `.trim(); + + const result = validateSpec(spec); + expect(result.valid).toBe(true); + expect(result.score).toBeGreaterThanOrEqual(80); + expect(result.warnings.filter((w) => w.level === 'error')).toHaveLength(0); + }); + + it('should score a minimal spec low', () => { + const result = validateSpec('Fix the bug.'); + expect(result.valid).toBe(false); + expect(result.score).toBeLessThan(40); + expect(result.warnings.some((w) => w.level === 'error')).toBe(true); + }); + + it('should detect RFC 2119 keywords', () => { + const spec = ` +# Feature + +## Proposal +This is needed. + +The system SHALL process requests within 100ms. +The API MUST validate input. +The UI SHOULD show loading state. + `.trim(); + + const result = validateSpec(spec); + expect(result.score).toBeGreaterThanOrEqual(45); + }); + + it('should detect Given/When/Then criteria', () => { + const spec = ` +# Feature + +## Proposal +We need this feature. + +Given: The user has an account +When: They submit the form +Then: A confirmation email is sent + `.trim(); + + const result = validateSpec(spec); + expect(result.warnings.find((w) => w.message.includes('acceptance criteria'))).toBeUndefined(); + }); + + it('should detect task checklists', () => { + const spec = ` +# Feature + +## Proposal +Adding authentication. + +- [ ] Create auth middleware +- [ ] Add login endpoint +- [x] Set up database schema + `.trim(); + + const result = validateSpec(spec); + expect(result.warnings.find((w) => w.message.includes('tasks'))).toBeUndefined(); + }); + + it('should warn about missing design section', () => { + const result = validateSpec( + '# Feature\n\n## Proposal\nJust a proposal with enough text to not be too short.' + ); + expect(result.warnings.some((w) => w.message.includes('design'))).toBe(true); + }); + + it('should handle empty content', () => { + const result = validateSpec(''); + expect(result.valid).toBe(false); + expect(result.score).toBe(0); + }); +}); + +describe('formatValidationResult', () => { + it('should format passing result', () => { + const output = formatValidationResult({ valid: true, score: 85, warnings: [] }); + expect(output).toContain('PASS'); + expect(output).toContain('85/100'); + expect(output).toContain('No issues found'); + }); + + it('should format failing result with warnings', () => { + const output = formatValidationResult({ + valid: false, + score: 20, + warnings: [ + { level: 'error', message: 'Low completeness' }, + { level: 'warning', message: 'Missing proposal' }, + ], + }); + expect(output).toContain('FAIL'); + expect(output).toContain('[ERROR]'); + expect(output).toContain('[WARN]'); + }); +}); diff --git a/src/loop/spec-validator.ts b/src/loop/spec-validator.ts new file mode 100644 index 00000000..726cffb3 --- /dev/null +++ b/src/loop/spec-validator.ts @@ -0,0 +1,151 @@ +/** + * Spec Validator + * + * Validates spec completeness before starting a coding loop. + * Checks for required sections, RFC 2119 keywords, and acceptance criteria. + */ + +export type SpecWarning = { + level: 'error' | 'warning' | 'info'; + message: string; +}; + +export type SpecValidationResult = { + valid: boolean; + score: number; // 0-100 completeness score + warnings: SpecWarning[]; +}; + +/** + * Validate a spec's completeness and quality. + * Returns a result with a completeness score and any warnings. + */ +export function validateSpec(content: string): SpecValidationResult { + const warnings: SpecWarning[] = []; + let score = 0; + const maxScore = 100; + + // Check for proposal/rationale section (20 points) + const hasProposal = /^#+\s*(proposal|rationale|why|overview|purpose|summary)/im.test(content); + if (hasProposal) { + score += 20; + } else { + warnings.push({ + level: 'warning', + message: + 'No proposal or rationale section found. Add a ## Proposal section explaining why this change is needed.', + }); + } + + // Check for requirements with RFC 2119 keywords (25 points) + const rfc2119 = /\b(SHALL|MUST|SHOULD|MAY|REQUIRED|RECOMMENDED)\b/g; + const rfc2119Matches = content.match(rfc2119); + if (rfc2119Matches && rfc2119Matches.length > 0) { + score += 25; + } else { + warnings.push({ + level: 'info', + message: + 'No RFC 2119 keywords found (SHALL, MUST, SHOULD). Consider using formal requirement language for clarity.', + }); + // Still give partial credit for having content + if (content.length > 200) { + score += 10; + } + } + + // Check for design/technical approach section (15 points) + const hasDesign = /^#+\s*(design|architecture|technical|approach|implementation|how)/im.test( + content + ); + if (hasDesign) { + score += 15; + } else { + warnings.push({ + level: 'info', + message: + 'No design section found. Consider adding a ## Design section with the technical approach.', + }); + } + + // Check for tasks/checklist (15 points) + const hasTasks = + /^#+\s*(tasks?|checklist|steps|todo|plan)/im.test(content) || /- \[[ x]\]/m.test(content); + if (hasTasks) { + score += 15; + } else { + warnings.push({ + level: 'info', + message: + 'No tasks or checklist found. Consider adding a ## Tasks section with implementation steps.', + }); + } + + // Check for acceptance criteria or Given/When/Then (15 points) + const hasGWT = + /given\s*:/i.test(content) && /when\s*:/i.test(content) && /then\s*:/i.test(content); + const hasAcceptanceCriteria = /^#+\s*(acceptance\s+criteria|criteria|verification)/im.test( + content + ); + if (hasGWT || hasAcceptanceCriteria) { + score += 15; + } else { + warnings.push({ + level: 'warning', + message: + 'No acceptance criteria found. Add Given/When/Then blocks to define testable conditions.', + }); + } + + // Check minimum content length (10 points) + const trimmed = content.trim(); + if (trimmed.length >= 500) { + score += 10; + } else if (trimmed.length >= 200) { + score += 5; + warnings.push({ + level: 'info', + message: `Spec is short (${trimmed.length} chars). More detail helps the agent produce better results.`, + }); + } else { + warnings.push({ + level: 'warning', + message: `Spec is very short (${trimmed.length} chars). Consider adding more context and requirements.`, + }); + } + + // Determine validity: score >= 40 means usable + const valid = score >= 40; + + if (!valid) { + warnings.unshift({ + level: 'error', + message: `Spec completeness is low (${score}/${maxScore}). The spec may not have enough detail for the agent to work effectively.`, + }); + } + + return { valid, score, warnings }; +} + +/** + * Format validation result for CLI output. + */ +export function formatValidationResult(result: SpecValidationResult): string { + const lines: string[] = []; + + const emoji = result.valid ? 'PASS' : 'FAIL'; + lines.push(`Spec Validation: ${emoji} (${result.score}/100)`); + lines.push(''); + + for (const warning of result.warnings) { + const prefix = + warning.level === 'error' ? '[ERROR]' : warning.level === 'warning' ? '[WARN]' : '[INFO]'; + lines.push(` ${prefix} ${warning.message}`); + } + + if (result.warnings.length === 0) { + lines.push(' No issues found.'); + } + + return lines.join('\n'); +}