UNPKG

@fission-ai/openspec

Version:

AI-native system for spec-driven development

381 lines 16 kB
/** * Instructions Command * * Generates enriched instructions for creating artifacts or applying tasks. * Includes both artifact instructions and apply instructions. */ import ora from 'ora'; import path from 'path'; import * as fs from 'fs'; import { loadChangeContext, generateInstructions, resolveSchema, } from '../../core/artifact-graph/index.js'; import { validateChangeExists, validateSchemaExists, } from './shared.js'; // ----------------------------------------------------------------------------- // Artifact Instructions Command // ----------------------------------------------------------------------------- export async function instructionsCommand(artifactId, options) { const spinner = ora('Generating instructions...').start(); try { const projectRoot = process.cwd(); const changeName = await validateChangeExists(options.change, projectRoot); // Validate schema if explicitly provided if (options.schema) { validateSchemaExists(options.schema, projectRoot); } // loadChangeContext will auto-detect schema from metadata if not provided const context = loadChangeContext(projectRoot, changeName, options.schema); if (!artifactId) { spinner.stop(); const validIds = context.graph.getAllArtifacts().map((a) => a.id); throw new Error(`Missing required argument <artifact>. Valid artifacts:\n ${validIds.join('\n ')}`); } const artifact = context.graph.getArtifact(artifactId); if (!artifact) { spinner.stop(); const validIds = context.graph.getAllArtifacts().map((a) => a.id); throw new Error(`Artifact '${artifactId}' not found in schema '${context.schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`); } const instructions = generateInstructions(context, artifactId, projectRoot); const isBlocked = instructions.dependencies.some((d) => !d.done); spinner.stop(); if (options.json) { console.log(JSON.stringify(instructions, null, 2)); return; } printInstructionsText(instructions, isBlocked); } catch (error) { spinner.stop(); throw error; } } export function printInstructionsText(instructions, isBlocked) { const { artifactId, changeName, schemaName, changeDir, outputPath, description, instruction, context, rules, template, dependencies, unlocks, } = instructions; // Opening tag console.log(`<artifact id="${artifactId}" change="${changeName}" schema="${schemaName}">`); console.log(); // Warning for blocked artifacts if (isBlocked) { const missing = dependencies.filter((d) => !d.done).map((d) => d.id); console.log('<warning>'); console.log('This artifact has unmet dependencies. Complete them first or proceed with caution.'); console.log(`Missing: ${missing.join(', ')}`); console.log('</warning>'); console.log(); } // Task directive console.log('<task>'); console.log(`Create the ${artifactId} artifact for change "${changeName}".`); console.log(description); console.log('</task>'); console.log(); // Project context (AI constraint - do not include in output) if (context) { console.log('<project_context>'); console.log('<!-- This is background information for you. Do NOT include this in your output. -->'); console.log(context); console.log('</project_context>'); console.log(); } // Rules (AI constraint - do not include in output) if (rules && rules.length > 0) { console.log('<rules>'); console.log('<!-- These are constraints for you to follow. Do NOT include this in your output. -->'); for (const rule of rules) { console.log(`- ${rule}`); } console.log('</rules>'); console.log(); } // Dependencies (files to read for context) if (dependencies.length > 0) { console.log('<dependencies>'); console.log('Read these files for context before creating this artifact:'); console.log(); for (const dep of dependencies) { const status = dep.done ? 'done' : 'missing'; const fullPath = path.join(changeDir, dep.path); console.log(`<dependency id="${dep.id}" status="${status}">`); console.log(` <path>${fullPath}</path>`); console.log(` <description>${dep.description}</description>`); console.log('</dependency>'); } console.log('</dependencies>'); console.log(); } // Output location console.log('<output>'); console.log(`Write to: ${path.join(changeDir, outputPath)}`); console.log('</output>'); console.log(); // Instruction (guidance) if (instruction) { console.log('<instruction>'); console.log(instruction.trim()); console.log('</instruction>'); console.log(); } // Template console.log('<template>'); console.log('<!-- Use this as the structure for your output file. Fill in the sections. -->'); console.log(template.trim()); console.log('</template>'); console.log(); // Success criteria placeholder console.log('<success_criteria>'); console.log('<!-- To be defined in schema validation rules -->'); console.log('</success_criteria>'); console.log(); // Unlocks if (unlocks.length > 0) { console.log('<unlocks>'); console.log(`Completing this artifact enables: ${unlocks.join(', ')}`); console.log('</unlocks>'); console.log(); } // Closing tag console.log('</artifact>'); } // ----------------------------------------------------------------------------- // Apply Instructions Command // ----------------------------------------------------------------------------- /** * Parses tasks.md content and extracts task items with their completion status. */ function parseTasksFile(content) { const tasks = []; const lines = content.split('\n'); let taskIndex = 0; for (const line of lines) { // Match checkbox patterns: - [ ] or - [x] or - [X] const checkboxMatch = line.match(/^[-*]\s*\[([ xX])\]\s*(.+)\s*$/); if (checkboxMatch) { taskIndex++; const done = checkboxMatch[1].toLowerCase() === 'x'; const description = checkboxMatch[2].trim(); tasks.push({ id: `${taskIndex}`, description, done, }); } } return tasks; } /** * Checks if an artifact output exists in the change directory. * Supports glob patterns (e.g., "specs/*.md") by verifying at least one matching file exists. */ function artifactOutputExists(changeDir, generates) { // Normalize the generates path to use platform-specific separators const normalizedGenerates = generates.split('/').join(path.sep); const fullPath = path.join(changeDir, normalizedGenerates); // If it's a glob pattern (contains ** or *), check for matching files if (generates.includes('*')) { // Extract the directory part before the glob pattern const parts = normalizedGenerates.split(path.sep); const dirParts = []; let patternPart = ''; for (const part of parts) { if (part.includes('*')) { patternPart = part; break; } dirParts.push(part); } const dirPath = path.join(changeDir, ...dirParts); // Check if directory exists if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) { return false; } // Extract expected extension from pattern (e.g., "*.md" -> ".md") const extMatch = patternPart.match(/\*(\.[a-zA-Z0-9]+)$/); const expectedExt = extMatch ? extMatch[1] : null; // Recursively check for matching files const hasMatchingFiles = (dir) => { try { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { // For ** patterns, recurse into subdirectories if (generates.includes('**') && hasMatchingFiles(path.join(dir, entry.name))) { return true; } } else if (entry.isFile()) { // Check if file matches expected extension (or any file if no extension specified) if (!expectedExt || entry.name.endsWith(expectedExt)) { return true; } } } } catch { return false; } return false; }; return hasMatchingFiles(dirPath); } return fs.existsSync(fullPath); } /** * Generates apply instructions for implementing tasks from a change. * Schema-aware: reads apply phase configuration from schema to determine * required artifacts, tracking file, and instruction. */ export async function generateApplyInstructions(projectRoot, changeName, schemaName) { // loadChangeContext will auto-detect schema from metadata if not provided const context = loadChangeContext(projectRoot, changeName, schemaName); const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName); // Get the full schema to access the apply phase configuration const schema = resolveSchema(context.schemaName, projectRoot); const applyConfig = schema.apply; // Determine required artifacts and tracking file from schema // Fallback: if no apply block, require all artifacts const requiredArtifactIds = applyConfig?.requires ?? schema.artifacts.map((a) => a.id); const tracksFile = applyConfig?.tracks ?? null; const schemaInstruction = applyConfig?.instruction ?? null; // Check which required artifacts are missing const missingArtifacts = []; for (const artifactId of requiredArtifactIds) { const artifact = schema.artifacts.find((a) => a.id === artifactId); if (artifact && !artifactOutputExists(changeDir, artifact.generates)) { missingArtifacts.push(artifactId); } } // Build context files from all existing artifacts in schema const contextFiles = {}; for (const artifact of schema.artifacts) { if (artifactOutputExists(changeDir, artifact.generates)) { contextFiles[artifact.id] = path.join(changeDir, artifact.generates); } } // Parse tasks if tracking file exists let tasks = []; let tracksFileExists = false; if (tracksFile) { const tracksPath = path.join(changeDir, tracksFile); tracksFileExists = fs.existsSync(tracksPath); if (tracksFileExists) { const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8'); tasks = parseTasksFile(tasksContent); } } // Calculate progress const total = tasks.length; const complete = tasks.filter((t) => t.done).length; const remaining = total - complete; // Determine state and instruction let state; let instruction; if (missingArtifacts.length > 0) { state = 'blocked'; instruction = `Cannot apply this change yet. Missing artifacts: ${missingArtifacts.join(', ')}.\nUse the openspec-continue-change skill to create the missing artifacts first.`; } else if (tracksFile && !tracksFileExists) { // Tracking file configured but doesn't exist yet const tracksFilename = path.basename(tracksFile); state = 'blocked'; instruction = `The ${tracksFilename} file is missing and must be created.\nUse openspec-continue-change to generate the tracking file.`; } else if (tracksFile && tracksFileExists && total === 0) { // Tracking file exists but contains no tasks const tracksFilename = path.basename(tracksFile); state = 'blocked'; instruction = `The ${tracksFilename} file exists but contains no tasks.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`; } else if (tracksFile && remaining === 0 && total > 0) { state = 'all_done'; instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.'; } else if (!tracksFile) { // No tracking file configured in schema - ready to apply state = 'ready'; instruction = schemaInstruction?.trim() ?? 'All required artifacts complete. Proceed with implementation.'; } else { state = 'ready'; instruction = schemaInstruction?.trim() ?? 'Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.'; } return { changeName, changeDir, schemaName: context.schemaName, contextFiles, progress: { total, complete, remaining }, tasks, state, missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : undefined, instruction, }; } export async function applyInstructionsCommand(options) { const spinner = ora('Generating apply instructions...').start(); try { const projectRoot = process.cwd(); const changeName = await validateChangeExists(options.change, projectRoot); // Validate schema if explicitly provided if (options.schema) { validateSchemaExists(options.schema, projectRoot); } // generateApplyInstructions uses loadChangeContext which auto-detects schema const instructions = await generateApplyInstructions(projectRoot, changeName, options.schema); spinner.stop(); if (options.json) { console.log(JSON.stringify(instructions, null, 2)); return; } printApplyInstructionsText(instructions); } catch (error) { spinner.stop(); throw error; } } export function printApplyInstructionsText(instructions) { const { changeName, schemaName, contextFiles, progress, tasks, state, missingArtifacts, instruction } = instructions; console.log(`## Apply: ${changeName}`); console.log(`Schema: ${schemaName}`); console.log(); // Warning for blocked state if (state === 'blocked' && missingArtifacts) { console.log('### ⚠️ Blocked'); console.log(); console.log(`Missing artifacts: ${missingArtifacts.join(', ')}`); console.log('Use the openspec-continue-change skill to create these first.'); console.log(); } // Context files (dynamically from schema) const contextFileEntries = Object.entries(contextFiles); if (contextFileEntries.length > 0) { console.log('### Context Files'); for (const [artifactId, filePath] of contextFileEntries) { console.log(`- ${artifactId}: ${filePath}`); } console.log(); } // Progress (only show if we have tracking) if (progress.total > 0 || tasks.length > 0) { console.log('### Progress'); if (state === 'all_done') { console.log(`${progress.complete}/${progress.total} complete ✓`); } else { console.log(`${progress.complete}/${progress.total} complete`); } console.log(); } // Tasks if (tasks.length > 0) { console.log('### Tasks'); for (const task of tasks) { const checkbox = task.done ? '[x]' : '[ ]'; console.log(`- ${checkbox} ${task.description}`); } console.log(); } // Instruction console.log('### Instruction'); console.log(instruction); } //# sourceMappingURL=instructions.js.map