├── logo.png ├── .npmignore ├── tsconfig.json ├── eslint.config.js ├── LICENSE ├── src ├── features │ ├── shared │ │ ├── documentAnalyzer.ts │ │ ├── documentUtils.ts │ │ ├── progressCalculator.ts │ │ ├── typeGuards.ts │ │ ├── documentStatus.ts │ │ ├── confirmationStatus.ts │ │ ├── mcpTypes.ts │ │ ├── documentTemplates.ts │ │ ├── openApiTypes.ts │ │ ├── taskGuidanceTemplate.ts │ │ ├── openApiLoader.ts │ │ ├── taskParser.ts │ │ └── responseBuilder.ts │ ├── init │ │ ├── createRequirementsDoc.ts │ │ └── initWorkflow.ts │ ├── check │ │ ├── generateNextDocument.ts │ │ ├── checkWorkflow.ts │ │ └── analyzeStage.ts │ ├── executeWorkflow.ts │ ├── confirm │ │ └── confirmStage.ts │ ├── skip │ │ └── skipStage.ts │ └── task │ │ └── completeTask.ts ├── index.ts └── tools │ └── specWorkflowTool.ts ├── .gitignore ├── package.json ├── scripts ├── publish-npm.sh ├── sync-package.sh ├── generateTypes.ts ├── validateOpenApi.ts └── generateOpenApiWebUI.ts ├── README-zh.md └── README.md /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingkongshot/specs-workflow-mcp/HEAD/logo.png -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # 源码相关 2 | src/ 3 | *.ts 4 | !dist/**/*.d.ts 5 | tsconfig.json 6 | 7 | # 开发工具 8 | .claude/ 9 | scripts/ 10 | debug.sh 11 | .gitignore 12 | .npmignore 13 | 14 | # 测试相关 15 | tests/ 16 | test-specs/ 17 | *.test.js 18 | *.spec.js 19 | 20 | # 文档(保留 prompts 因为运行时需要) 21 | docs/ 22 | README.md 23 | 24 | # 本地配置 25 | CLAUDE.local.md 26 | memory/ 27 | specs/ 28 | 29 | # WebUI(开发时使用) 30 | webui/ 31 | 32 | # 构建工具 33 | node_modules/ 34 | package-lock.json -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ES2022", 5 | "lib": ["ES2022"], 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true, 13 | "moduleResolution": "node", 14 | "declaration": true, 15 | "declarationMap": true, 16 | "sourceMap": true, 17 | "types": ["node"] 18 | }, 19 | "include": ["src/**/*"], 20 | "exclude": ["node_modules", "dist", "tests"] 21 | } -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import tseslint from '@typescript-eslint/eslint-plugin'; 3 | import tsParser from '@typescript-eslint/parser'; 4 | 5 | export default [ 6 | { 7 | files: ['src/**/*.ts'], 8 | languageOptions: { 9 | parser: tsParser, 10 | parserOptions: { 11 | ecmaVersion: 2022, 12 | sourceType: 'module', 13 | project: './tsconfig.json' 14 | } 15 | }, 16 | plugins: { 17 | '@typescript-eslint': tseslint 18 | }, 19 | rules: { 20 | ...js.configs.recommended.rules, 21 | ...tseslint.configs.recommended.rules, 22 | '@typescript-eslint/explicit-function-return-type': 'error', 23 | '@typescript-eslint/no-unused-vars': 'error', 24 | '@typescript-eslint/no-explicit-any': 'error' 25 | } 26 | }, 27 | { 28 | ignores: ['dist/', 'node_modules/', '*.js', 'scripts/'] 29 | } 30 | ]; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 kingkongshot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/features/shared/documentAnalyzer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Document content analyzer 3 | * Uses XML markers to detect if document has been edited 4 | */ 5 | 6 | import { readFileSync, existsSync } from 'fs'; 7 | 8 | // XML marker definitions - matching actual template placeholders 9 | export const TEMPLATE_MARKERS = { 10 | REQUIREMENTS_START: '', 11 | REQUIREMENTS_END: '', 12 | DESIGN_START: '', 13 | DESIGN_END: '', 14 | TASKS_START: '', 15 | TASKS_END: '' 16 | }; 17 | 18 | /** 19 | * Analyze if document has been edited 20 | * Determined by checking if XML template markers exist 21 | */ 22 | export function isDocumentEdited(filePath: string): boolean { 23 | if (!existsSync(filePath)) { 24 | return false; 25 | } 26 | 27 | try { 28 | const content = readFileSync(filePath, 'utf-8'); 29 | 30 | // Check if contains any template markers 31 | const hasTemplateMarkers = Object.values(TEMPLATE_MARKERS).some(marker => 32 | content.includes(marker) 33 | ); 34 | 35 | // If no template markers, it means it has been edited 36 | return !hasTemplateMarkers; 37 | } catch { 38 | return false; 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /src/features/init/createRequirementsDoc.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Create requirements document 3 | */ 4 | 5 | import { writeFileSync, existsSync } from 'fs'; 6 | import { join } from 'path'; 7 | import { getRequirementsTemplate } from '../shared/documentTemplates.js'; 8 | 9 | export interface CreateResult { 10 | generated: boolean; 11 | message: string; 12 | filePath?: string; 13 | fileName?: string; 14 | } 15 | 16 | export function createRequirementsDocument( 17 | path: string, 18 | featureName: string, 19 | introduction: string 20 | ): CreateResult { 21 | const fileName = 'requirements.md'; 22 | const filePath = join(path, fileName); 23 | 24 | if (existsSync(filePath)) { 25 | return { 26 | generated: false, 27 | message: 'Requirements document already exists', 28 | fileName, 29 | filePath 30 | }; 31 | } 32 | 33 | try { 34 | const content = getRequirementsTemplate(featureName, introduction); 35 | writeFileSync(filePath, content, 'utf-8'); 36 | 37 | return { 38 | generated: true, 39 | message: 'Requirements document', 40 | fileName, 41 | filePath 42 | }; 43 | } catch (error) { 44 | return { 45 | generated: false, 46 | message: `Failed to create document: ${error}`, 47 | fileName 48 | }; 49 | } 50 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Claude configuration 2 | .claude 3 | /memory 4 | 5 | # Dependencies 6 | node_modules/ 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Testing related 12 | # 排除所有测试相关文件 13 | /src/**/*.test.js 14 | /src/**/*.spec.js 15 | /src/**/*.test.ts 16 | /src/**/*.spec.ts 17 | coverage/ 18 | # test-specs/ - 在 dev 分支中保留测试套件 19 | test-specs/reports/ 20 | test-specs/node_modules/ 21 | test-specs/temp/ 22 | 23 | # Documentation 24 | docs/ 25 | guidance/zh/ 26 | 27 | # Build artifacts 28 | dist/ 29 | build/ 30 | *.log 31 | 32 | # Generated package directory (auto-generated during build) 33 | package/ 34 | 35 | # Environment configuration 36 | .env 37 | .env.local 38 | .env.*.local 39 | test-specs/.env.test 40 | 41 | # Editor configuration 42 | .vscode/ 43 | .idea/ 44 | *.swp 45 | *.swo 46 | *~ 47 | 48 | # System files 49 | .DS_Store 50 | Thumbs.db 51 | 52 | # Temporary files 53 | *.tmp 54 | *.temp 55 | .cache/ 56 | 57 | # Local configuration 58 | *.local.* 59 | mcp-config.json 60 | CLAUDE.local.md 61 | 62 | # Preview files 63 | preview/ 64 | 65 | # Prompt files (migrated to OpenAPI) 66 | prompts/ 67 | 68 | # WebUI generated files 69 | webui/ 70 | /specs 71 | 72 | # Debug files 73 | debug.sh 74 | 75 | # Security - Never commit these files 76 | push-to-github.sh 77 | *-token.sh 78 | *.token 79 | .clinerules 80 | -------------------------------------------------------------------------------- /src/features/shared/documentUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Shared utilities for document processing 3 | */ 4 | 5 | import { readFileSync } from 'fs'; 6 | 7 | export interface DocumentInfo { 8 | featureName: string; 9 | introduction: string; 10 | } 11 | 12 | export function extractDocumentInfo(requirementsPath: string): DocumentInfo { 13 | try { 14 | const content = readFileSync(requirementsPath, 'utf-8'); 15 | const lines = content.split('\n'); 16 | 17 | // Extract feature name 18 | const titleLine = lines.find(line => line.startsWith('# ')); 19 | const featureName = titleLine 20 | ? titleLine.replace('# ', '').replace(' - Requirements Document', '').trim() 21 | : 'Unnamed Feature'; 22 | 23 | // Extract project background 24 | const backgroundIndex = lines.findIndex(line => line.includes('## Project Background')); 25 | let introduction = ''; 26 | 27 | if (backgroundIndex !== -1) { 28 | for (let i = backgroundIndex + 1; i < lines.length; i++) { 29 | if (lines[i].startsWith('##')) break; 30 | if (lines[i].trim()) { 31 | introduction += lines[i] + '\n'; 32 | } 33 | } 34 | } 35 | 36 | return { 37 | featureName, 38 | introduction: introduction.trim() || 'No description' 39 | }; 40 | } catch { 41 | return { 42 | featureName: 'Unnamed Feature', 43 | introduction: 'No description' 44 | }; 45 | } 46 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spec-workflow-mcp", 3 | "version": "1.0.8", 4 | "description": "MCP server for managing spec workflow (requirements, design, implementation)", 5 | "type": "module", 6 | "main": "dist/index.js", 7 | "bin": { 8 | "spec-workflow-mcp": "dist/index.js" 9 | }, 10 | "files": [ 11 | "dist/**/*", 12 | "api/**/*" 13 | ], 14 | "scripts": { 15 | "build": "tsc && chmod 755 dist/index.js && ./scripts/sync-package.sh", 16 | "dev": "tsx src/index.ts", 17 | "start": "node dist/index.js", 18 | "lint": "eslint src", 19 | "typecheck": "tsc --noEmit", 20 | "debug": "./debug.sh", 21 | "watch": "tsc --watch", 22 | "inspector": "npx @modelcontextprotocol/inspector node dist/index.js", 23 | "generate:types": "tsx scripts/generateTypes.ts", 24 | "generate:webui": "tsx scripts/generateOpenApiWebUI.ts", 25 | "sync:package": "./scripts/sync-package.sh", 26 | "publish": "./scripts/publish-npm.sh" 27 | }, 28 | "keywords": [ 29 | "mcp", 30 | "workflow", 31 | "spec" 32 | ], 33 | "author": "kingkongshot", 34 | "license": "MIT", 35 | "repository": { 36 | "type": "git", 37 | "url": "git+https://github.com/kingkongshot/specs-workflow-mcp.git" 38 | }, 39 | "bugs": { 40 | "url": "https://github.com/kingkongshot/specs-workflow-mcp/issues" 41 | }, 42 | "homepage": "https://github.com/kingkongshot/specs-workflow-mcp#readme", 43 | "dependencies": { 44 | "@modelcontextprotocol/sdk": "^1.16.0", 45 | "@types/js-yaml": "^4.0.9", 46 | "js-yaml": "^4.1.0", 47 | "zod": "^3.25.76" 48 | }, 49 | "devDependencies": { 50 | "@eslint/js": "^9.32.0", 51 | "@types/node": "^22.10.5", 52 | "@typescript-eslint/eslint-plugin": "^8.20.0", 53 | "@typescript-eslint/parser": "^8.20.0", 54 | "eslint": "^9.17.0", 55 | "tsx": "^4.19.2", 56 | "typescript": "^5.7.3" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /scripts/publish-npm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Publish package to npm 4 | # This script builds the project, generates the package, and publishes to npm 5 | 6 | set -e 7 | 8 | echo "🚀 Publishing to npm..." 9 | 10 | # Check if user is logged in to npm 11 | if ! npm whoami > /dev/null 2>&1; then 12 | echo "❌ Error: Not logged in to npm" 13 | echo "Please run: npm login" 14 | exit 1 15 | fi 16 | 17 | # Build the project (this will also generate the package directory) 18 | echo "🏗️ Building project..." 19 | npm run build 20 | 21 | # Verify package directory exists 22 | if [ ! -d "package" ]; then 23 | echo "❌ Error: Package directory not found" 24 | echo "Build process may have failed" 25 | exit 1 26 | fi 27 | 28 | # Check if package already exists on npm 29 | PACKAGE_NAME=$(node -p "require('./package/package.json').name") 30 | PACKAGE_VERSION=$(node -p "require('./package/package.json').version") 31 | 32 | echo "📦 Package: $PACKAGE_NAME@$PACKAGE_VERSION" 33 | 34 | # Check if this version already exists 35 | if npm view "$PACKAGE_NAME@$PACKAGE_VERSION" version > /dev/null 2>&1; then 36 | echo "⚠️ Warning: Version $PACKAGE_VERSION already exists on npm" 37 | echo "Please update the version in package.json and try again" 38 | exit 1 39 | fi 40 | 41 | # Publish to npm 42 | echo "📤 Publishing to npm..." 43 | cd package 44 | 45 | # Dry run first to check for issues 46 | echo "🔍 Running dry-run..." 47 | npm publish --dry-run 48 | 49 | # Ask for confirmation 50 | echo "" 51 | read -p "🤔 Proceed with publishing $PACKAGE_NAME@$PACKAGE_VERSION? (y/N): " -n 1 -r 52 | echo "" 53 | 54 | if [[ $REPLY =~ ^[Yy]$ ]]; then 55 | # Actual publish 56 | npm publish 57 | 58 | echo "" 59 | echo "✅ Successfully published $PACKAGE_NAME@$PACKAGE_VERSION!" 60 | echo "🔗 View on npm: https://www.npmjs.com/package/$PACKAGE_NAME" 61 | echo "" 62 | echo "📥 Install with:" 63 | echo " npm install -g $PACKAGE_NAME" 64 | else 65 | echo "❌ Publishing cancelled" 66 | exit 1 67 | fi 68 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * MCP specification workflow server 5 | * Standard implementation based on MCP SDK 6 | */ 7 | 8 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 9 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 10 | import { specWorkflowTool } from './tools/specWorkflowTool.js'; 11 | import { openApiLoader } from './features/shared/openApiLoader.js'; 12 | import { readFileSync } from 'fs'; 13 | import { fileURLToPath } from 'url'; 14 | import { dirname, join } from 'path'; 15 | 16 | const __filename = fileURLToPath(import.meta.url); 17 | const __dirname = dirname(__filename); 18 | const packageJson = JSON.parse( 19 | readFileSync(join(__dirname, '..', 'package.json'), 'utf-8') 20 | ); 21 | 22 | // Create server instance 23 | const server = new McpServer({ 24 | name: 'specs-workflow-mcp', 25 | version: packageJson.version 26 | }); 27 | 28 | // Register tools 29 | specWorkflowTool.register(server); 30 | 31 | // Start server 32 | async function main(): Promise { 33 | try { 34 | // Initialize OpenAPI loader to ensure examples are cached 35 | openApiLoader.loadSpec(); 36 | 37 | const transport = new StdioServerTransport(); 38 | await server.connect(transport); 39 | 40 | // eslint-disable-next-line no-console 41 | console.error('✨ MCP specification workflow server started'); 42 | // eslint-disable-next-line no-console 43 | console.error(`📍 Version: ${packageJson.version} (Fully compliant with MCP best practices)`); 44 | 45 | } catch (error) { 46 | // eslint-disable-next-line no-console 47 | console.error('❌ Startup failed:', error); 48 | // eslint-disable-next-line no-undef 49 | process.exit(1); 50 | } 51 | } 52 | 53 | // Graceful shutdown 54 | // eslint-disable-next-line no-undef 55 | process.on('SIGINT', () => { 56 | // eslint-disable-next-line no-console 57 | console.error('\n👋 Server shutdown'); 58 | // eslint-disable-next-line no-undef 59 | process.exit(0); 60 | }); 61 | 62 | main(); -------------------------------------------------------------------------------- /src/features/shared/progressCalculator.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Progress calculation 3 | */ 4 | 5 | import { WorkflowStatus } from './documentStatus.js'; 6 | import { getWorkflowConfirmations } from './confirmationStatus.js'; 7 | 8 | export interface WorkflowProgress { 9 | percentage: number; 10 | completedStages: number; 11 | totalStages: number; 12 | details: { 13 | requirements: StageProgress; 14 | design: StageProgress; 15 | tasks: StageProgress; 16 | }; 17 | } 18 | 19 | interface StageProgress { 20 | exists: boolean; 21 | confirmed: boolean; 22 | skipped: boolean; 23 | } 24 | 25 | export function calculateWorkflowProgress( 26 | path: string, 27 | status: WorkflowStatus 28 | ): WorkflowProgress { 29 | const confirmations = getWorkflowConfirmations(path); 30 | 31 | const details = { 32 | requirements: getStageProgress(status.requirements, confirmations.confirmed.requirements, confirmations.skipped.requirements), 33 | design: getStageProgress(status.design, confirmations.confirmed.design, confirmations.skipped.design), 34 | tasks: getStageProgress(status.tasks, confirmations.confirmed.tasks, confirmations.skipped.tasks) 35 | }; 36 | 37 | const stages = [details.requirements, details.design, details.tasks]; 38 | const completedStages = stages.filter(s => s.confirmed || s.skipped).length; 39 | const totalStages = stages.length; 40 | 41 | // Simplified progress calculation: each stage takes 1/3 42 | // const stageProgress = 100 / totalStages; // \u672a\u4f7f\u7528 43 | let totalProgress = 0; 44 | 45 | // Requirements stage: 30% 46 | if (details.requirements.confirmed || details.requirements.skipped) { 47 | totalProgress += 30; 48 | } 49 | 50 | // Design stage: 30% 51 | if (details.design.confirmed || details.design.skipped) { 52 | totalProgress += 30; 53 | } 54 | 55 | // Tasks stage: 40% (only if confirmed, not skipped) 56 | // Skipping tasks doesn't count as progress since it's essential for development 57 | if (details.tasks.confirmed) { 58 | totalProgress += 40; 59 | } 60 | 61 | return { 62 | percentage: Math.round(totalProgress), 63 | completedStages, 64 | totalStages, 65 | details 66 | }; 67 | } 68 | 69 | function getStageProgress( 70 | status: { exists: boolean }, 71 | confirmed: boolean, 72 | skipped: boolean 73 | ): StageProgress { 74 | return { 75 | exists: status.exists, 76 | confirmed, 77 | skipped 78 | }; 79 | } -------------------------------------------------------------------------------- /src/features/check/generateNextDocument.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generate next stage document 3 | */ 4 | 5 | import { writeFileSync, existsSync } from 'fs'; 6 | import { join } from 'path'; 7 | import { WorkflowStage, getNextStage, getStageFileName } from '../shared/documentStatus.js'; 8 | import { getDesignTemplate, getTasksTemplate } from '../shared/documentTemplates.js'; 9 | import { extractDocumentInfo } from '../shared/documentUtils.js'; 10 | 11 | export interface NextDocumentResult { 12 | generated: boolean; 13 | alreadyExists?: boolean; 14 | message: string; 15 | fileName?: string; 16 | filePath?: string; 17 | guide?: unknown; 18 | } 19 | 20 | export async function generateNextDocument( 21 | path: string, 22 | currentStage: WorkflowStage 23 | ): Promise { 24 | const nextStage = getNextStage(currentStage); 25 | 26 | if (nextStage === 'completed') { 27 | return { 28 | generated: false, 29 | message: 'All documents completed' 30 | }; 31 | } 32 | 33 | const fileName = getStageFileName(nextStage); 34 | const filePath = join(path, fileName); 35 | 36 | if (existsSync(filePath)) { 37 | return { 38 | generated: false, 39 | alreadyExists: true, 40 | message: `${fileName} already exists`, 41 | fileName, 42 | filePath 43 | }; 44 | } 45 | 46 | // Extract feature information 47 | const documentInfo = extractDocumentInfo(join(path, 'requirements.md')); 48 | 49 | // Generate document content 50 | let content: string; 51 | 52 | switch (nextStage) { 53 | case 'design': 54 | content = getDesignTemplate(documentInfo.featureName); 55 | // guideType = 'design'; // \u672a\u4f7f\u7528 56 | break; 57 | case 'tasks': 58 | content = getTasksTemplate(documentInfo.featureName); 59 | // guideType = 'implementation'; // \u672a\u4f7f\u7528 60 | break; 61 | default: 62 | return { 63 | generated: false, 64 | message: `Unknown document type: ${nextStage}` 65 | }; 66 | } 67 | 68 | try { 69 | writeFileSync(filePath, content, 'utf-8'); 70 | 71 | return { 72 | generated: true, 73 | message: `Generated ${fileName}`, 74 | fileName, 75 | filePath, 76 | guide: undefined // Guide resources are now handled via OpenAPI shared resources 77 | }; 78 | } catch (error) { 79 | return { 80 | generated: false, 81 | message: `Failed to generate document: ${error}` 82 | }; 83 | } 84 | } 85 | 86 | -------------------------------------------------------------------------------- /src/features/shared/typeGuards.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 类型守卫和类型验证工具 3 | */ 4 | 5 | /** 6 | * 检查值是否为对象(非 null) 7 | */ 8 | export function isObject(value: unknown): value is Record { 9 | return typeof value === 'object' && value !== null && !Array.isArray(value); 10 | } 11 | 12 | /** 13 | * 检查值是否为字符串 14 | */ 15 | export function isString(value: unknown): value is string { 16 | return typeof value === 'string'; 17 | } 18 | 19 | /** 20 | * 检查值是否为数字 21 | */ 22 | export function isNumber(value: unknown): value is number { 23 | return typeof value === 'number'; 24 | } 25 | 26 | /** 27 | * 检查值是否为布尔值 28 | */ 29 | export function isBoolean(value: unknown): value is boolean { 30 | return typeof value === 'boolean'; 31 | } 32 | 33 | /** 34 | * 检查值是否为数组 35 | */ 36 | export function isArray(value: unknown): value is unknown[] { 37 | return Array.isArray(value); 38 | } 39 | 40 | /** 41 | * 安全地获取对象属性 42 | */ 43 | export function getProperty( 44 | obj: unknown, 45 | key: string, 46 | validator?: (value: unknown) => value is T 47 | ): T | undefined { 48 | if (!isObject(obj)) { 49 | return undefined; 50 | } 51 | 52 | const value = obj[key]; 53 | 54 | if (validator) { 55 | return validator(value) ? value : undefined; 56 | } 57 | 58 | return value as T; 59 | } 60 | 61 | /** 62 | * 检查对象是否具有特定属性 63 | */ 64 | export function hasProperty( 65 | obj: unknown, 66 | key: string 67 | ): obj is Record { 68 | return isObject(obj) && key in obj; 69 | } 70 | 71 | /** 72 | * 验证对象具有必需的属性 73 | */ 74 | export function hasRequiredProperties( 75 | obj: unknown, 76 | properties: string[] 77 | ): obj is Record { 78 | if (!isObject(obj)) { 79 | return false; 80 | } 81 | 82 | return properties.every(prop => prop in obj); 83 | } 84 | 85 | /** 86 | * 类型断言辅助函数 87 | */ 88 | export function assertType( 89 | value: unknown, 90 | validator: (value: unknown) => value is T, 91 | errorMessage: string 92 | ): T { 93 | if (!validator(value)) { 94 | throw new TypeError(errorMessage); 95 | } 96 | return value; 97 | } 98 | 99 | /** 100 | * 安全的 JSON 解析 101 | */ 102 | export function safeJsonParse(json: string): unknown { 103 | try { 104 | return JSON.parse(json); 105 | } catch { 106 | return undefined; 107 | } 108 | } 109 | 110 | /** 111 | * 将 unknown 类型转换为 Record 112 | * 如果不是对象,返回空对象 113 | */ 114 | export function toRecord(value: unknown): Record { 115 | return isObject(value) ? value : {}; 116 | } 117 | 118 | /** 119 | * 将 unknown 类型转换为数组 120 | * 如果不是数组,返回空数组 121 | */ 122 | export function toArray(value: unknown): unknown[] { 123 | return isArray(value) ? value : []; 124 | } -------------------------------------------------------------------------------- /src/features/shared/documentStatus.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Document status management related functions 3 | */ 4 | 5 | import { existsSync } from 'fs'; 6 | import { join } from 'path'; 7 | import { isStageSkipped, isStageConfirmed } from './confirmationStatus.js'; 8 | 9 | export interface DocumentStatus { 10 | exists: boolean; 11 | } 12 | 13 | export interface WorkflowStatus { 14 | requirements: DocumentStatus; 15 | design: DocumentStatus; 16 | tasks: DocumentStatus; 17 | } 18 | 19 | export type WorkflowStage = 'requirements' | 'design' | 'tasks' | 'completed'; 20 | 21 | export function getWorkflowStatus(path: string): WorkflowStatus { 22 | return { 23 | requirements: getDocumentStatus(path, 'requirements.md'), 24 | design: getDocumentStatus(path, 'design.md'), 25 | tasks: getDocumentStatus(path, 'tasks.md') 26 | }; 27 | } 28 | 29 | function getDocumentStatus(path: string, fileName: string): DocumentStatus { 30 | const filePath = join(path, fileName); 31 | return { exists: existsSync(filePath) }; 32 | } 33 | 34 | 35 | export function getCurrentStage(status: WorkflowStatus, path?: string): WorkflowStage { 36 | if (!path) { 37 | // Backward compatibility: if no path, return the first existing document stage 38 | if (status.requirements.exists) return 'requirements'; 39 | if (status.design.exists) return 'design'; 40 | if (status.tasks.exists) return 'tasks'; 41 | return 'completed'; 42 | } 43 | 44 | // Determine current stage based on confirmations 45 | // If requirements stage is not confirmed and not skipped, current stage is requirements 46 | if (!isStageConfirmed(path, 'requirements') && !isStageSkipped(path, 'requirements')) { 47 | return 'requirements'; 48 | } 49 | 50 | // If design stage is not confirmed and not skipped, current stage is design 51 | if (!isStageConfirmed(path, 'design') && !isStageSkipped(path, 'design')) { 52 | return 'design'; 53 | } 54 | 55 | // If tasks stage is not confirmed and not skipped, current stage is tasks 56 | if (!isStageConfirmed(path, 'tasks') && !isStageSkipped(path, 'tasks')) { 57 | return 'tasks'; 58 | } 59 | 60 | return 'completed'; 61 | } 62 | 63 | export function getNextStage(stage: WorkflowStage): WorkflowStage { 64 | const stages: WorkflowStage[] = ['requirements', 'design', 'tasks', 'completed']; 65 | const index = stages.indexOf(stage); 66 | return stages[Math.min(index + 1, stages.length - 1)]; 67 | } 68 | 69 | export function getStageName(stage: string): string { 70 | const names: Record = { 71 | requirements: 'Requirements Document', 72 | design: 'Design Document', 73 | tasks: 'Task List', 74 | completed: 'Completed' 75 | }; 76 | return names[stage] || stage; 77 | } 78 | 79 | export function getStageFileName(stage: string): string { 80 | const fileNames: Record = { 81 | requirements: 'requirements.md', 82 | design: 'design.md', 83 | tasks: 'tasks.md' 84 | }; 85 | return fileNames[stage] || ''; 86 | } -------------------------------------------------------------------------------- /src/features/shared/confirmationStatus.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Confirmation status management 3 | */ 4 | 5 | import { readFileSync, writeFileSync, existsSync } from 'fs'; 6 | import { join } from 'path'; 7 | 8 | export interface ConfirmationStatus { 9 | requirements: boolean; 10 | design: boolean; 11 | tasks: boolean; 12 | } 13 | 14 | export interface SkipStatus { 15 | requirements: boolean; 16 | design: boolean; 17 | tasks: boolean; 18 | } 19 | 20 | export interface WorkflowConfirmations { 21 | confirmed: ConfirmationStatus; 22 | skipped: SkipStatus; 23 | } 24 | 25 | const CONFIRMATION_FILE = '.workflow-confirmations.json'; 26 | 27 | export function getWorkflowConfirmations(path: string): WorkflowConfirmations { 28 | const filePath = join(path, CONFIRMATION_FILE); 29 | 30 | const defaultStatus: WorkflowConfirmations = { 31 | confirmed: { 32 | requirements: false, 33 | design: false, 34 | tasks: false 35 | }, 36 | skipped: { 37 | requirements: false, 38 | design: false, 39 | tasks: false 40 | } 41 | }; 42 | 43 | if (!existsSync(filePath)) { 44 | return defaultStatus; 45 | } 46 | 47 | try { 48 | const content = readFileSync(filePath, 'utf-8'); 49 | const parsed = JSON.parse(content); 50 | 51 | // Compatible with old format 52 | if (!parsed.confirmed && !parsed.skipped) { 53 | return { 54 | confirmed: parsed, 55 | skipped: { 56 | requirements: false, 57 | design: false, 58 | tasks: false 59 | } 60 | }; 61 | } 62 | 63 | return parsed; 64 | } catch { 65 | return defaultStatus; 66 | } 67 | } 68 | 69 | // Keep old function for compatibility with existing code 70 | export function getConfirmationStatus(path: string): ConfirmationStatus { 71 | const confirmations = getWorkflowConfirmations(path); 72 | return confirmations.confirmed; 73 | } 74 | 75 | export function updateStageConfirmation( 76 | path: string, 77 | stage: keyof ConfirmationStatus, 78 | confirmed: boolean 79 | ): void { 80 | const confirmations = getWorkflowConfirmations(path); 81 | confirmations.confirmed[stage] = confirmed; 82 | 83 | const filePath = join(path, CONFIRMATION_FILE); 84 | writeFileSync(filePath, JSON.stringify(confirmations, null, 2)); 85 | } 86 | 87 | export function updateStageSkipped( 88 | path: string, 89 | stage: keyof SkipStatus, 90 | skipped: boolean 91 | ): void { 92 | const confirmations = getWorkflowConfirmations(path); 93 | confirmations.skipped[stage] = skipped; 94 | 95 | const filePath = join(path, CONFIRMATION_FILE); 96 | writeFileSync(filePath, JSON.stringify(confirmations, null, 2)); 97 | } 98 | 99 | export function isStageConfirmed( 100 | path: string, 101 | stage: keyof ConfirmationStatus 102 | ): boolean { 103 | const confirmations = getWorkflowConfirmations(path); 104 | return confirmations.confirmed[stage]; 105 | } 106 | 107 | export function isStageSkipped( 108 | path: string, 109 | stage: keyof SkipStatus 110 | ): boolean { 111 | const confirmations = getWorkflowConfirmations(path); 112 | return confirmations.skipped[stage]; 113 | } -------------------------------------------------------------------------------- /src/features/shared/mcpTypes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * MCP (Model Context Protocol) compatible type definitions 3 | * Standard interfaces conforming to MCP specification 4 | */ 5 | 6 | /** 7 | * MCP text content 8 | */ 9 | export interface McpTextContent { 10 | type: "text"; 11 | text: string; 12 | } 13 | 14 | /** 15 | * MCP image content 16 | */ 17 | export interface McpImageContent { 18 | type: "image"; 19 | data: string; // base64 encoded 20 | mimeType: string; // e.g. "image/png" 21 | } 22 | 23 | /** 24 | * MCP audio content 25 | */ 26 | export interface McpAudioContent { 27 | type: "audio"; 28 | data: string; // base64 encoded 29 | mimeType: string; // e.g. "audio/mp3" 30 | } 31 | 32 | /** 33 | * MCP resource content 34 | */ 35 | export interface McpResourceContent { 36 | type: "resource"; 37 | resource: { 38 | uri: string; // Resource URI 39 | title?: string; // Optional title 40 | mimeType: string; // MIME type 41 | text?: string; // Optional text content 42 | }; 43 | } 44 | 45 | /** 46 | * MCP content type union 47 | */ 48 | export type McpContent = 49 | | McpTextContent 50 | | McpImageContent 51 | | McpAudioContent 52 | | McpResourceContent; 53 | 54 | /** 55 | * MCP tool call result 56 | * This is the standard format that must be returned after tool execution 57 | */ 58 | export interface McpCallToolResult { 59 | content: McpContent[]; 60 | isError?: boolean; 61 | structuredContent?: unknown; // Used when outputSchema is defined 62 | } 63 | 64 | /** 65 | * Internally used workflow result 66 | * Used to pass data between functional modules 67 | */ 68 | export interface WorkflowResult { 69 | displayText: string; 70 | data: { 71 | success?: boolean; 72 | error?: string; 73 | [key: string]: unknown; 74 | }; 75 | resources?: Array<{ 76 | uri: string; 77 | title?: string; 78 | mimeType: string; 79 | text?: string; 80 | }>; 81 | } 82 | 83 | /** 84 | * Create text content 85 | */ 86 | export function createTextContent(text: string): McpTextContent { 87 | return { 88 | type: "text", 89 | text 90 | }; 91 | } 92 | 93 | /** 94 | * Create resource content 95 | */ 96 | export function createResourceContent(resource: McpResourceContent['resource']): McpResourceContent { 97 | return { 98 | type: "resource", 99 | resource 100 | }; 101 | } 102 | 103 | /** 104 | * Convert internal workflow result to MCP format 105 | */ 106 | export function toMcpResult(result: WorkflowResult): McpCallToolResult { 107 | const content: McpContent[] = [ 108 | createTextContent(result.displayText) 109 | ]; 110 | 111 | // Resources are now embedded in displayText, no need to add them separately 112 | // This avoids duplicate display in clients that support resource content type 113 | 114 | return { 115 | content, 116 | isError: result.data.success === false, 117 | // Add structured content, return response object conforming to OpenAPI specification 118 | structuredContent: result.data && typeof result.data === 'object' && 'displayText' in result.data 119 | ? result.data // If data is already a complete response object 120 | : undefined // Otherwise don't return structuredContent 121 | }; 122 | } -------------------------------------------------------------------------------- /src/features/shared/documentTemplates.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Document templates - using OpenAPI as single source of truth 3 | */ 4 | 5 | import { openApiLoader } from './openApiLoader.js'; 6 | import { isObject, isArray } from './typeGuards.js'; 7 | 8 | // Format template, replace variables 9 | function formatTemplate(template: unknown, values: { [key: string]: unknown }): string { 10 | const lines: string[] = []; 11 | 12 | if (!isObject(template)) { 13 | throw new Error('Invalid template format'); 14 | } 15 | 16 | const title = template.title; 17 | if (typeof title === 'string') { 18 | lines.push(`# ${interpolate(title, values)}`); 19 | lines.push(''); 20 | } 21 | 22 | const sections = template.sections; 23 | if (isArray(sections)) { 24 | for (const section of sections) { 25 | if (isObject(section)) { 26 | if (typeof section.content === 'string') { 27 | lines.push(interpolate(section.content, values)); 28 | } else if (typeof section.placeholder === 'string') { 29 | lines.push(section.placeholder); 30 | } 31 | lines.push(''); 32 | } 33 | } 34 | } 35 | 36 | return lines.join('\n'); 37 | } 38 | 39 | // Variable interpolation 40 | function interpolate(template: string, values: { [key: string]: unknown }): string { 41 | return template.replace(/\${([^}]+)}/g, (match, key) => { 42 | const keys = key.split('.'); 43 | let value: unknown = values; 44 | 45 | for (const k of keys) { 46 | if (isObject(value) && k in value) { 47 | value = value[k]; 48 | } else { 49 | return match; 50 | } 51 | } 52 | 53 | return String(value); 54 | }); 55 | } 56 | 57 | // Get requirements document template 58 | export function getRequirementsTemplate(featureName: string, introduction: string): string { 59 | // Ensure spec is loaded 60 | openApiLoader.loadSpec(); 61 | const template = openApiLoader.getDocumentTemplate('requirements'); 62 | if (!template) { 63 | throw new Error('Requirements template not found in OpenAPI specification'); 64 | } 65 | 66 | return formatTemplate(template, { featureName, introduction }); 67 | } 68 | 69 | // Get design document template 70 | export function getDesignTemplate(featureName: string): string { 71 | // Ensure spec is loaded 72 | openApiLoader.loadSpec(); 73 | const template = openApiLoader.getDocumentTemplate('design'); 74 | if (!template) { 75 | throw new Error('Design template not found in OpenAPI specification'); 76 | } 77 | 78 | return formatTemplate(template, { featureName }); 79 | } 80 | 81 | // Get tasks list template 82 | export function getTasksTemplate(featureName: string): string { 83 | // Ensure spec is loaded 84 | openApiLoader.loadSpec(); 85 | const template = openApiLoader.getDocumentTemplate('tasks'); 86 | if (!template) { 87 | throw new Error('Tasks template not found in OpenAPI specification'); 88 | } 89 | 90 | return formatTemplate(template, { featureName }); 91 | } 92 | 93 | // Get skipped marker template 94 | export function getSkippedTemplate(featureName: string, stageName: string): string { 95 | // Ensure spec is loaded 96 | openApiLoader.loadSpec(); 97 | const template = openApiLoader.getDocumentTemplate('skipped'); 98 | if (!template) { 99 | throw new Error('Skipped template not found in OpenAPI specification'); 100 | } 101 | 102 | return formatTemplate(template, { featureName, stageName }); 103 | } -------------------------------------------------------------------------------- /src/features/executeWorkflow.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Workflow execution entry point 3 | */ 4 | 5 | import { existsSync } from 'fs'; 6 | import { getWorkflowStatus, getCurrentStage } from './shared/documentStatus.js'; 7 | import { initWorkflow } from './init/initWorkflow.js'; 8 | import { checkWorkflow } from './check/checkWorkflow.js'; 9 | import { skipStage } from './skip/skipStage.js'; 10 | import { confirmStage } from './confirm/confirmStage.js'; 11 | import { completeTask } from './task/completeTask.js'; 12 | import { WorkflowResult } from './shared/mcpTypes.js'; 13 | 14 | export interface WorkflowArgs { 15 | path: string; 16 | action?: { 17 | type: string; 18 | featureName?: string; 19 | introduction?: string; 20 | taskNumber?: string | string[]; 21 | }; 22 | } 23 | 24 | export async function executeWorkflow( 25 | args: WorkflowArgs, 26 | onProgress?: (progress: number, total: number, message: string) => Promise 27 | ): Promise { 28 | const { path, action } = args; 29 | 30 | if (!action) { 31 | return getStatus(path); 32 | } 33 | 34 | switch (action.type) { 35 | case 'init': 36 | if (!action.featureName || !action.introduction) { 37 | return { 38 | displayText: '❌ Initialization requires featureName and introduction parameters', 39 | data: { 40 | success: false, 41 | error: 'Missing required parameters' 42 | } 43 | }; 44 | } 45 | return initWorkflow({ 46 | path, 47 | featureName: action.featureName, 48 | introduction: action.introduction, 49 | onProgress 50 | }); 51 | 52 | case 'check': 53 | return checkWorkflow({ path, onProgress }); 54 | 55 | case 'skip': 56 | return skipStage({ path }); 57 | 58 | case 'confirm': 59 | return confirmStage({ path }); 60 | 61 | case 'complete_task': 62 | if (!action.taskNumber) { 63 | return { 64 | displayText: '❌ Completing task requires taskNumber parameter', 65 | data: { 66 | success: false, 67 | error: 'Missing required parameters' 68 | } 69 | }; 70 | } 71 | return completeTask({ 72 | path, 73 | taskNumber: action.taskNumber 74 | }); 75 | 76 | default: 77 | return { 78 | displayText: `❌ Unknown operation type: ${action.type}`, 79 | data: { 80 | success: false, 81 | error: `Unknown operation type: ${action.type}` 82 | } 83 | }; 84 | } 85 | } 86 | 87 | function getStatus(path: string): WorkflowResult { 88 | if (!existsSync(path)) { 89 | return { 90 | displayText: `📁 Directory does not exist 91 | 92 | Please use init operation to initialize: 93 | \`\`\`json 94 | { 95 | "action": { 96 | "type": "init", 97 | "featureName": "Feature name", 98 | "introduction": "Feature description" 99 | } 100 | } 101 | \`\`\``, 102 | data: { 103 | message: 'Directory does not exist, initialization required' 104 | } 105 | }; 106 | } 107 | 108 | const status = getWorkflowStatus(path); 109 | const stage = getCurrentStage(status, path); 110 | 111 | return { 112 | displayText: `📊 Current status 113 | 114 | Available operations: 115 | - check: Check current stage 116 | - skip: Skip current stage`, 117 | data: { 118 | message: 'Please select an operation', 119 | stage 120 | } 121 | }; 122 | } -------------------------------------------------------------------------------- /src/tools/specWorkflowTool.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Intelligent specification workflow tool 3 | * Implementation fully compliant with MCP best practices 4 | */ 5 | 6 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 7 | import { z } from 'zod'; 8 | import { executeWorkflow } from '../features/executeWorkflow.js'; 9 | import { toMcpResult } from '../features/shared/mcpTypes.js'; 10 | 11 | // Input parameter Schema 12 | const inputSchema = { 13 | path: z.string().describe('Specification directory path (e.g., /Users/link/specs-mcp/batch-log-test)'), 14 | action: z.object({ 15 | type: z.enum(['init', 'check', 'skip', 'confirm', 'complete_task']).describe('Operation type'), 16 | featureName: z.string().optional().describe('Feature name (required for init)'), 17 | introduction: z.string().optional().describe('Feature introduction (required for init)'), 18 | taskNumber: z.union([ 19 | z.string(), 20 | z.array(z.string()) 21 | ]).optional().describe('Task number(s) to mark as completed (required for complete_task). Can be a single string or an array of strings') 22 | }).optional().describe('Operation parameters') 23 | }; 24 | 25 | export const specWorkflowTool = { 26 | /** 27 | * Register tool to MCP server 28 | */ 29 | register(server: McpServer): void { 30 | server.registerTool( 31 | 'specs-workflow', 32 | { 33 | title: 'Intelligent Specification Workflow Tool', // Added title property 34 | description: 'Manage intelligent writing workflow for software project requirements, design, and task documents. Supports initialization, checking, skipping, confirmation, and task completion operations (single or batch).', 35 | inputSchema, 36 | annotations: { 37 | progressReportingHint: true, 38 | longRunningHint: true, 39 | readOnlyHint: false, // This tool modifies files 40 | idempotentHint: false // Operation is not idempotent 41 | } 42 | }, 43 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 44 | async (args, _extra) => { 45 | try { 46 | // Temporarily not using progress callback, as MCP SDK type definitions may differ 47 | const onProgress = undefined; 48 | 49 | // Execute workflow 50 | const workflowResult = await executeWorkflow({ 51 | path: args.path, 52 | action: args.action 53 | }, onProgress); 54 | 55 | // Use standard MCP format converter 56 | const mcpResult = toMcpResult(workflowResult); 57 | 58 | // Return format that meets SDK requirements, including structuredContent 59 | const callToolResult: Record = { 60 | content: mcpResult.content, 61 | isError: mcpResult.isError 62 | }; 63 | 64 | if (mcpResult.structuredContent !== undefined) { 65 | callToolResult.structuredContent = mcpResult.structuredContent; 66 | } 67 | 68 | // Type assertion to satisfy MCP SDK requirements 69 | return callToolResult as { 70 | content: Array<{ 71 | type: 'text'; 72 | text: string; 73 | [x: string]: unknown; 74 | }>; 75 | isError?: boolean; 76 | [x: string]: unknown; 77 | }; 78 | 79 | } catch (error) { 80 | // Error handling must also comply with MCP format 81 | return { 82 | content: [{ 83 | type: 'text' as const, 84 | text: `Execution failed: ${error instanceof Error ? error.message : String(error)}` 85 | }], 86 | isError: true 87 | }; 88 | } 89 | } 90 | ); 91 | } 92 | }; -------------------------------------------------------------------------------- /src/features/shared/openApiTypes.ts: -------------------------------------------------------------------------------- 1 | // Auto-generated type definitions - do not modify manually 2 | // Generated from api/spec-workflow.openapi.yaml 3 | 4 | export interface WorkflowRequest { 5 | path: string; // Specification directory path (e.g., /Users/link/specs-mcp/batch-log-test) 6 | action: Action; 7 | } 8 | 9 | export interface Action { 10 | type: 'init' | 'check' | 'skip' | 'confirm' | 'complete_task'; 11 | featureName?: string; // Feature name (required for init) 12 | introduction?: string; // Feature introduction (required for init) 13 | taskNumber?: string | string[]; // Task number(s) to mark as completed (required for complete_task) 14 | } 15 | 16 | export interface WorkflowResponse { 17 | result: InitResponse | CheckResponse | SkipResponse | ConfirmResponse | BatchCompleteTaskResponse; 18 | } 19 | 20 | export interface InitResponse { 21 | success: boolean; 22 | data: { path: string; featureName: string; nextAction: 'edit_requirements' }; 23 | displayText: string; 24 | resources?: ResourceRef[]; 25 | } 26 | 27 | export interface CheckResponse { 28 | stage: Stage; 29 | progress: Progress; 30 | status: Status; 31 | displayText: string; 32 | resources?: ResourceRef[]; 33 | } 34 | 35 | export interface SkipResponse { 36 | stage: string; 37 | skipped: boolean; 38 | progress: Progress; 39 | displayText: string; 40 | resources?: ResourceRef[]; 41 | } 42 | 43 | export interface ConfirmResponse { 44 | stage: string; 45 | confirmed: boolean; 46 | nextStage: string; 47 | progress: Progress; 48 | displayText: string; 49 | resources?: ResourceRef[]; 50 | } 51 | 52 | 53 | 54 | export interface BatchCompleteTaskResponse { 55 | success: boolean; // Whether the batch operation succeeded 56 | completedTasks?: string[]; // Task numbers that were actually completed in this operation 57 | alreadyCompleted?: string[]; // Task numbers that were already completed before this operation 58 | failedTasks?: { taskNumber: string; reason: string }[]; // Tasks that could not be completed with reasons 59 | results?: { taskNumber: string; success: boolean; status: 'completed' | 'already_completed' | 'failed' }[]; // Detailed results for each task in the batch 60 | nextTask?: { number: string; description: string }; // Information about the next uncompleted task 61 | hasNextTask?: boolean; // Whether there are more tasks to complete 62 | displayText: string; // Human-readable message about the batch operation 63 | } 64 | 65 | export interface Stage { 66 | } 67 | 68 | export interface Progress { 69 | overall: number; // Overall progress percentage 70 | requirements: number; // Requirements phase progress 71 | design: number; // Design phase progress 72 | tasks: number; // Tasks phase progress 73 | } 74 | 75 | export interface Status { 76 | type: 'not_started' | 'not_edited' | 'in_progress' | 'ready_to_confirm' | 'confirmed' | 'completed'; 77 | reason?: string; 78 | readyToConfirm?: boolean; 79 | } 80 | 81 | export interface Resource { 82 | id: string; // Resource identifier 83 | content: string; // Resource content (Markdown format) 84 | } 85 | 86 | export interface ResourceRef { 87 | uri: string; // Resource URI 88 | title?: string; // Optional resource title 89 | mimeType: string; // Resource MIME type 90 | text?: string; // Optional resource text content 91 | } 92 | 93 | // Extended type definitions 94 | export interface ErrorResponse { 95 | displayText: string; 96 | variables?: Record; 97 | } 98 | 99 | export interface ContentCheckRules { 100 | minLength?: number; 101 | requiredSections?: string[]; 102 | optionalSections?: string[]; 103 | minTasks?: number; 104 | taskFormat?: string; 105 | requiresEstimate?: boolean; 106 | } -------------------------------------------------------------------------------- /scripts/sync-package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Generate package directory with all necessary files for npm publishing 4 | # This script creates a complete package directory from scratch 5 | 6 | set -e 7 | 8 | echo "📦 Generating package directory..." 9 | 10 | # Create package directory structure 11 | echo "📁 Creating directory structure..." 12 | rm -rf package 13 | mkdir -p package/api 14 | mkdir -p package/dist 15 | 16 | # Generate package.json 17 | echo "📄 Generating package.json..." 18 | MAIN_VERSION=$(node -p "require('./package.json').version") 19 | cat > package/package.json << EOF 20 | { 21 | "name": "spec-workflow-mcp", 22 | "version": "$MAIN_VERSION", 23 | "description": "MCP server for managing spec workflow (requirements, design, implementation)", 24 | "type": "module", 25 | "main": "dist/index.js", 26 | "bin": { 27 | "spec-workflow-mcp": "dist/index.js" 28 | }, 29 | "files": [ 30 | "dist/**/*", 31 | "api/**/*" 32 | ], 33 | "keywords": [ 34 | "mcp", 35 | "workflow", 36 | "spec", 37 | "requirements", 38 | "design", 39 | "implementation", 40 | "openapi" 41 | ], 42 | "author": "kingkongshot", 43 | "license": "MIT", 44 | "repository": { 45 | "type": "git", 46 | "url": "git+https://github.com/kingkongshot/specs-workflow-mcp.git" 47 | }, 48 | "bugs": { 49 | "url": "https://github.com/kingkongshot/specs-workflow-mcp/issues" 50 | }, 51 | "homepage": "https://github.com/kingkongshot/specs-workflow-mcp#readme", 52 | "dependencies": { 53 | "@modelcontextprotocol/sdk": "^1.0.6", 54 | "@types/js-yaml": "^4.0.9", 55 | "js-yaml": "^4.1.0", 56 | "zod": "^3.25.76" 57 | }, 58 | "engines": { 59 | "node": ">=18.0.0" 60 | } 61 | } 62 | EOF 63 | 64 | # Generate README.md 65 | echo "📖 Generating README.md..." 66 | cat > package/README.md << 'EOF' 67 | # Spec Workflow MCP 68 | 69 | A Model Context Protocol (MCP) server for managing specification workflows including requirements, design, and implementation phases. 70 | 71 | ## Features 72 | 73 | - **Requirements Management**: Create and validate requirement documents 74 | - **Design Documentation**: Generate and review design specifications 75 | - **Task Management**: Break down implementation into manageable tasks 76 | - **Progress Tracking**: Monitor workflow progress across all phases 77 | - **OpenAPI Integration**: Full OpenAPI 3.1.0 specification support 78 | 79 | ## Installation 80 | 81 | ```bash 82 | npm install -g spec-workflow-mcp 83 | ``` 84 | 85 | ## Usage 86 | 87 | ### As MCP Server 88 | 89 | Add to your MCP client configuration: 90 | 91 | ```json 92 | { 93 | "mcpServers": { 94 | "specs-workflow": { 95 | "command": "spec-workflow-mcp" 96 | } 97 | } 98 | } 99 | ``` 100 | 101 | ### Available Operations 102 | 103 | - `init` - Initialize a new feature specification 104 | - `check` - Check current workflow status 105 | - `confirm` - Confirm stage completion 106 | - `skip` - Skip current stage 107 | - `complete_task` - Mark tasks as completed 108 | 109 | ## Documentation 110 | 111 | For detailed usage instructions and examples, visit the [GitHub repository](https://github.com/kingkongshot/specs-workflow-mcp). 112 | 113 | ## License 114 | 115 | MIT 116 | EOF 117 | 118 | # Copy OpenAPI specification 119 | echo "📋 Copying OpenAPI specification..." 120 | cp api/spec-workflow.openapi.yaml package/api/spec-workflow.openapi.yaml 121 | 122 | # Copy built files 123 | echo "🏗️ Copying built files..." 124 | if [ -d "dist" ]; then 125 | cp -r dist/* package/dist/ 126 | else 127 | echo "❌ Error: dist directory not found. Run 'npm run build' first." 128 | exit 1 129 | fi 130 | 131 | echo "✅ Package directory generated successfully!" 132 | echo "📦 Version: $MAIN_VERSION" 133 | echo "📁 Location: ./package/" 134 | echo "" 135 | echo "Contents:" 136 | echo " 📄 package.json" 137 | echo " 📖 README.md" 138 | echo " 📋 api/spec-workflow.openapi.yaml" 139 | echo " 🏗️ dist/ (compiled JavaScript)" 140 | -------------------------------------------------------------------------------- /src/features/check/checkWorkflow.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check workflow status 3 | */ 4 | 5 | import { existsSync, readFileSync } from 'fs'; 6 | import { join } from 'path'; 7 | import { getWorkflowStatus, getCurrentStage } from '../shared/documentStatus.js'; 8 | import { calculateWorkflowProgress } from '../shared/progressCalculator.js'; 9 | import { analyzeStage } from './analyzeStage.js'; 10 | import { generateNextDocument } from './generateNextDocument.js'; 11 | import { responseBuilder } from '../shared/responseBuilder.js'; 12 | import { WorkflowResult } from '../shared/mcpTypes.js'; 13 | import { parseTasksFile, getFirstUncompletedTask, formatTaskForFullDisplay } from '../shared/taskParser.js'; 14 | 15 | export interface CheckOptions { 16 | path: string; 17 | onProgress?: (progress: number, total: number, message: string) => Promise; 18 | } 19 | 20 | export async function checkWorkflow(options: CheckOptions): Promise { 21 | const { path, onProgress } = options; 22 | 23 | if (!existsSync(path)) { 24 | return { 25 | displayText: responseBuilder.buildErrorResponse('invalidPath', { path }), 26 | data: { 27 | success: false, 28 | error: 'Directory does not exist' 29 | } 30 | }; 31 | } 32 | 33 | await reportProgress(onProgress, 33, 100, 'Checking document status...'); 34 | 35 | const status = getWorkflowStatus(path); 36 | 37 | // Check if all files do not exist 38 | if (!status.requirements.exists && !status.design.exists && !status.tasks.exists) { 39 | await reportProgress(onProgress, 100, 100, 'Check completed'); 40 | return { 41 | displayText: responseBuilder.buildErrorResponse('invalidPath', { 42 | path, 43 | error: 'Project not initialized' 44 | }), 45 | data: { 46 | success: false, 47 | error: 'Project not initialized' 48 | } 49 | }; 50 | } 51 | 52 | const currentStage = getCurrentStage(status, path); 53 | // const stageStatus = getStageStatus(currentStage, status, path); // 未使用 54 | 55 | await reportProgress(onProgress, 66, 100, 'Analyzing document content...'); 56 | 57 | // Analyze current stage 58 | const analysis = analyzeStage(path, currentStage, status); 59 | 60 | // Check if need to generate next document 61 | if (analysis.canProceed) { 62 | await generateNextDocument(path, currentStage); 63 | } 64 | 65 | await reportProgress(onProgress, 100, 100, 'Check completed'); 66 | 67 | const progress = calculateWorkflowProgress(path, status); 68 | 69 | // Determine status type 70 | let statusType: string; 71 | let reason: string | undefined; 72 | 73 | if (currentStage === 'completed') { 74 | statusType = 'completed'; 75 | } else if (!status[currentStage].exists) { 76 | statusType = 'not_edited'; 77 | reason = `${currentStage === 'requirements' ? 'Requirements' : currentStage === 'design' ? 'Design' : 'Tasks'} document does not exist`; 78 | } else if (analysis.canProceed) { 79 | statusType = 'ready_to_confirm'; 80 | } else if (analysis.needsConfirmation) { 81 | statusType = 'ready_to_confirm'; 82 | } else { 83 | statusType = 'not_edited'; 84 | reason = analysis.reason; 85 | } 86 | 87 | // If workflow is completed, get the first uncompleted task 88 | let firstTask = null; 89 | if (currentStage === 'completed') { 90 | const tasks = parseTasksFile(path); 91 | const task = getFirstUncompletedTask(tasks); 92 | if (task) { 93 | const tasksPath = join(path, 'tasks.md'); 94 | const content = readFileSync(tasksPath, 'utf-8'); 95 | firstTask = formatTaskForFullDisplay(task, content); 96 | } 97 | } 98 | 99 | return responseBuilder.buildCheckResponse( 100 | currentStage, 101 | progress, 102 | { 103 | type: statusType, 104 | reason, 105 | readyToConfirm: analysis.canProceed 106 | }, 107 | analysis, 108 | path, 109 | firstTask 110 | ); 111 | } 112 | 113 | async function reportProgress( 114 | onProgress: ((progress: number, total: number, message: string) => Promise) | undefined, 115 | progress: number, 116 | total: number, 117 | message: string 118 | ): Promise { 119 | if (onProgress) { 120 | await onProgress(progress, total, message); 121 | } 122 | } -------------------------------------------------------------------------------- /src/features/confirm/confirmStage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Confirm stage completion 3 | */ 4 | 5 | import { existsSync, readFileSync } from 'fs'; 6 | import { join } from 'path'; 7 | import { getWorkflowStatus, getStageName, getNextStage, getCurrentStage, getStageFileName } from '../shared/documentStatus.js'; 8 | import { updateStageConfirmation, isStageSkipped } from '../shared/confirmationStatus.js'; 9 | import { generateNextDocument } from '../check/generateNextDocument.js'; 10 | import { responseBuilder } from '../shared/responseBuilder.js'; 11 | import { WorkflowResult } from '../shared/mcpTypes.js'; 12 | import { parseTasksFile, getFirstUncompletedTask, formatTaskForFullDisplay } from '../shared/taskParser.js'; 13 | import { isDocumentEdited } from '../shared/documentAnalyzer.js'; 14 | import { calculateWorkflowProgress } from '../shared/progressCalculator.js'; 15 | 16 | export interface ConfirmOptions { 17 | path: string; 18 | } 19 | 20 | export async function confirmStage(options: ConfirmOptions): Promise { 21 | const { path } = options; 22 | 23 | if (!existsSync(path)) { 24 | return { 25 | displayText: responseBuilder.buildErrorResponse('invalidPath', { path }), 26 | data: { 27 | success: false, 28 | error: 'Directory does not exist' 29 | } 30 | }; 31 | } 32 | 33 | const status = getWorkflowStatus(path); 34 | const currentStage = getCurrentStage(status, path); 35 | 36 | // Check if all stages are completed 37 | if (currentStage === 'completed') { 38 | return { 39 | displayText: `✅ All stages completed! 40 | 41 | Workflow completed, no need to confirm again.`, 42 | data: { 43 | success: false, 44 | reason: 'All stages completed' 45 | } 46 | }; 47 | } 48 | 49 | const stageData = status[currentStage as keyof typeof status]; 50 | 51 | // Check if document exists 52 | if (!stageData || !stageData.exists) { 53 | return { 54 | displayText: `⚠️ ${getStageName(currentStage)} does not exist 55 | 56 | Please create ${getStageName(currentStage)} document before confirming.`, 57 | data: { 58 | success: false, 59 | reason: `${getStageName(currentStage)} does not exist` 60 | } 61 | }; 62 | } 63 | 64 | // Check if already skipped 65 | if (isStageSkipped(path, currentStage)) { 66 | return { 67 | displayText: `⚠️ ${getStageName(currentStage)} already skipped 68 | 69 | This stage has been skipped, no need to confirm.`, 70 | data: { 71 | success: false, 72 | reason: `${getStageName(currentStage)} already skipped` 73 | } 74 | }; 75 | } 76 | 77 | // Check if document has been edited 78 | const fileName = getStageFileName(currentStage); 79 | const filePath = join(path, fileName); 80 | if (!isDocumentEdited(filePath)) { 81 | return { 82 | displayText: responseBuilder.buildErrorResponse('documentNotEdited', { 83 | documentName: getStageName(currentStage) 84 | }), 85 | data: { 86 | success: false, 87 | error: 'Document not edited' 88 | } 89 | }; 90 | } 91 | 92 | // Update confirmation status 93 | updateStageConfirmation(path, currentStage, true); 94 | 95 | // Get next stage 96 | const nextStage = getNextStage(currentStage); 97 | 98 | // Generate document for next stage 99 | if (nextStage !== 'completed') { 100 | await generateNextDocument(path, currentStage); 101 | } 102 | 103 | // If tasks stage, get first task details 104 | let firstTaskContent = null; 105 | if (currentStage === 'tasks' && nextStage === 'completed') { 106 | const tasks = parseTasksFile(path); 107 | const firstTask = getFirstUncompletedTask(tasks); 108 | if (firstTask) { 109 | const tasksPath = join(path, 'tasks.md'); 110 | const content = readFileSync(tasksPath, 'utf-8'); 111 | firstTaskContent = formatTaskForFullDisplay(firstTask, content); 112 | } 113 | } 114 | 115 | // Calculate progress after confirmation 116 | const updatedStatus = getWorkflowStatus(path); 117 | const progress = calculateWorkflowProgress(path, updatedStatus); 118 | 119 | return responseBuilder.buildConfirmResponse(currentStage, nextStage === 'completed' ? null : nextStage, path, firstTaskContent, progress); 120 | } -------------------------------------------------------------------------------- /scripts/generateTypes.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env tsx 2 | /** 3 | * Generate TypeScript type definitions from OpenAPI specification 4 | */ 5 | 6 | import * as fs from 'fs'; 7 | import * as path from 'path'; 8 | import * as yaml from 'js-yaml'; 9 | import { fileURLToPath } from 'url'; 10 | import { dirname } from 'path'; 11 | 12 | const __filename = fileURLToPath(import.meta.url); 13 | const __dirname = dirname(__filename); 14 | 15 | // Read OpenAPI specification 16 | const specPath = path.join(__dirname, '../api/spec-workflow.openapi.yaml'); 17 | const spec = yaml.load(fs.readFileSync(specPath, 'utf8')) as any; 18 | 19 | // Generate TypeScript types 20 | function generateTypes(): string { 21 | const types: string[] = []; 22 | 23 | types.push('// Auto-generated type definitions - do not modify manually'); 24 | types.push('// Generated from api/spec-workflow.openapi.yaml'); 25 | types.push(''); 26 | 27 | // Generate types for schemas 28 | for (const [schemaName, schema] of Object.entries(spec.components.schemas)) { 29 | types.push(generateSchemaType(schemaName, schema)); 30 | types.push(''); 31 | } 32 | 33 | // Generate extended types 34 | types.push('// Extended type definitions'); 35 | types.push('export interface ErrorResponse {'); 36 | types.push(' displayText: string;'); 37 | types.push(' variables?: Record;'); 38 | types.push('}'); 39 | types.push(''); 40 | 41 | types.push('export interface ContentCheckRules {'); 42 | types.push(' minLength?: number;'); 43 | types.push(' requiredSections?: string[];'); 44 | types.push(' optionalSections?: string[];'); 45 | types.push(' minTasks?: number;'); 46 | types.push(' taskFormat?: string;'); 47 | types.push(' requiresEstimate?: boolean;'); 48 | types.push('}'); 49 | 50 | return types.join('\n'); 51 | } 52 | 53 | function generateSchemaType(name: string, schema: any): string { 54 | const lines: string[] = []; 55 | 56 | lines.push(`export interface ${name} {`); 57 | 58 | if (schema.properties) { 59 | for (const [propName, prop] of Object.entries(schema.properties) as [string, any][]) { 60 | const required = schema.required?.includes(propName) || false; 61 | const optional = required ? '' : '?'; 62 | const type = getTypeScriptType(prop); 63 | const comment = prop.description ? ` // ${prop.description}` : ''; 64 | 65 | lines.push(` ${propName}${optional}: ${type};${comment}`); 66 | } 67 | } 68 | 69 | // Handle oneOf 70 | if (schema.oneOf) { 71 | lines.push(' // oneOf:'); 72 | schema.oneOf.forEach((item: any) => { 73 | if (item.$ref) { 74 | const refType = item.$ref.split('/').pop(); 75 | lines.push(` // - ${refType}`); 76 | } 77 | }); 78 | } 79 | 80 | lines.push('}'); 81 | 82 | return lines.join('\n'); 83 | } 84 | 85 | function getTypeScriptType(prop: any): string { 86 | if (prop.$ref) { 87 | return prop.$ref.split('/').pop(); 88 | } 89 | 90 | if (prop.oneOf) { 91 | const types = prop.oneOf.map((item: any) => { 92 | if (item.$ref) { 93 | return item.$ref.split('/').pop(); 94 | } 95 | return getTypeScriptType(item); 96 | }); 97 | return types.join(' | '); 98 | } 99 | 100 | if (prop.enum) { 101 | return prop.enum.map((v: any) => `'${v}'`).join(' | '); 102 | } 103 | 104 | switch (prop.type) { 105 | case 'string': 106 | if (prop.const) { 107 | return `'${prop.const}'`; 108 | } 109 | return 'string'; 110 | case 'number': 111 | case 'integer': 112 | return 'number'; 113 | case 'boolean': 114 | return 'boolean'; 115 | case 'array': 116 | if (prop.items) { 117 | return `${getTypeScriptType(prop.items)}[]`; 118 | } 119 | return 'any[]'; 120 | case 'object': 121 | if (prop.properties) { 122 | const props = Object.entries(prop.properties) 123 | .map(([k, v]: [string, any]) => `${k}: ${getTypeScriptType(v)}`) 124 | .join('; '); 125 | return `{ ${props} }`; 126 | } 127 | return 'Record'; 128 | default: 129 | return 'any'; 130 | } 131 | } 132 | 133 | // Generate type file 134 | const types = generateTypes(); 135 | const outputPath = path.join(__dirname, '../src/features/shared/openApiTypes.ts'); 136 | fs.writeFileSync(outputPath, types, 'utf8'); 137 | 138 | console.log('✅ TypeScript types generated to:', outputPath); -------------------------------------------------------------------------------- /src/features/skip/skipStage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Skip current stage 3 | */ 4 | 5 | import { existsSync, writeFileSync } from 'fs'; 6 | import { join } from 'path'; 7 | import { getWorkflowStatus, getCurrentStage, getNextStage, getStageName, getStageFileName } from '../shared/documentStatus.js'; 8 | import { getSkippedTemplate, getDesignTemplate, getTasksTemplate } from '../shared/documentTemplates.js'; 9 | import { updateStageConfirmation, updateStageSkipped } from '../shared/confirmationStatus.js'; 10 | import { responseBuilder } from '../shared/responseBuilder.js'; 11 | import { WorkflowResult } from '../shared/mcpTypes.js'; 12 | import { extractDocumentInfo } from '../shared/documentUtils.js'; 13 | import { calculateWorkflowProgress } from '../shared/progressCalculator.js'; 14 | 15 | export interface SkipOptions { 16 | path: string; 17 | } 18 | 19 | export async function skipStage(options: SkipOptions): Promise { 20 | const { path } = options; 21 | 22 | if (!existsSync(path)) { 23 | return { 24 | displayText: responseBuilder.buildErrorResponse('invalidPath', { path }), 25 | data: { 26 | success: false, 27 | error: 'Directory does not exist' 28 | } 29 | }; 30 | } 31 | 32 | const status = getWorkflowStatus(path); 33 | const currentStage = getCurrentStage(status, path); 34 | 35 | if (currentStage === 'completed') { 36 | return { 37 | displayText: '✅ All stages completed, no need to skip', 38 | data: { 39 | success: false, 40 | reason: 'All stages completed' 41 | } 42 | }; 43 | } 44 | 45 | // Get document information 46 | const documentInfo = extractDocumentInfo(join(path, 'requirements.md')); 47 | 48 | // Create document for skipped stage 49 | createSkippedDocument(path, currentStage, documentInfo.featureName); 50 | 51 | // Mark current stage as skipped 52 | updateStageSkipped(path, currentStage, true); 53 | // For tasks stage, don't mark as confirmed when skipping 54 | // since it's essential for development 55 | if (currentStage !== 'tasks') { 56 | updateStageConfirmation(path, currentStage, true); 57 | } 58 | 59 | // Generate next document (if needed) 60 | const nextStage = getNextStage(currentStage); 61 | 62 | if (nextStage !== 'completed') { 63 | createNextStageDocument(path, nextStage, documentInfo.featureName); 64 | // Initialize next stage confirmation status as unconfirmed 65 | updateStageConfirmation(path, nextStage, false); 66 | } 67 | 68 | // Calculate progress after skip 69 | const updatedStatus = getWorkflowStatus(path); 70 | const progress = calculateWorkflowProgress(path, updatedStatus); 71 | 72 | return responseBuilder.buildSkipResponse(currentStage, path, progress); 73 | } 74 | 75 | 76 | interface DocumentResult { 77 | created: boolean; 78 | fileName: string; 79 | message: string; 80 | } 81 | 82 | function createSkippedDocument( 83 | path: string, 84 | stage: string, 85 | featureName: string 86 | ): DocumentResult { 87 | const fileName = getStageFileName(stage); 88 | const filePath = join(path, fileName); 89 | 90 | // If document already exists, don't overwrite 91 | if (existsSync(filePath)) { 92 | return { 93 | created: false, 94 | fileName, 95 | message: `${fileName} already exists, keeping original content` 96 | }; 97 | } 98 | 99 | const content = getSkippedTemplate(getStageName(stage), featureName); 100 | 101 | try { 102 | writeFileSync(filePath, content, 'utf-8'); 103 | return { 104 | created: true, 105 | fileName, 106 | message: `Created skip marker document: ${fileName}` 107 | }; 108 | } catch (error) { 109 | return { 110 | created: false, 111 | fileName, 112 | message: `Failed to create skip document: ${error}` 113 | }; 114 | } 115 | } 116 | 117 | function createNextStageDocument( 118 | path: string, 119 | stage: string, 120 | featureName: string 121 | ): DocumentResult | null { 122 | const fileName = getStageFileName(stage); 123 | const filePath = join(path, fileName); 124 | 125 | if (existsSync(filePath)) { 126 | return { 127 | created: false, 128 | fileName, 129 | message: `${fileName} already exists` 130 | }; 131 | } 132 | 133 | let content: string; 134 | switch (stage) { 135 | case 'design': 136 | content = getDesignTemplate(featureName); 137 | break; 138 | case 'tasks': 139 | content = getTasksTemplate(featureName); 140 | break; 141 | default: 142 | return null; 143 | } 144 | 145 | try { 146 | writeFileSync(filePath, content, 'utf-8'); 147 | return { 148 | created: true, 149 | fileName, 150 | message: `Created next stage document: ${fileName}` 151 | }; 152 | } catch (error) { 153 | return { 154 | created: false, 155 | fileName, 156 | message: `Failed to create document: ${error}` 157 | }; 158 | } 159 | } -------------------------------------------------------------------------------- /src/features/init/initWorkflow.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Initialize workflow functionality 3 | */ 4 | 5 | import { existsSync, mkdirSync } from 'fs'; 6 | import { join } from 'path'; 7 | import { getWorkflowStatus, getCurrentStage } from '../shared/documentStatus.js'; 8 | import { calculateWorkflowProgress } from '../shared/progressCalculator.js'; 9 | import { createRequirementsDocument } from './createRequirementsDoc.js'; 10 | import { updateStageConfirmation } from '../shared/confirmationStatus.js'; 11 | import { responseBuilder } from '../shared/responseBuilder.js'; 12 | import { WorkflowResult } from '../shared/mcpTypes.js'; 13 | 14 | export interface InitOptions { 15 | path: string; 16 | featureName: string; 17 | introduction: string; 18 | onProgress?: (progress: number, total: number, message: string) => Promise; 19 | } 20 | 21 | export async function initWorkflow(options: InitOptions): Promise { 22 | const { path, featureName, introduction, onProgress } = options; 23 | 24 | try { 25 | await reportProgress(onProgress, 0, 100, 'Starting initialization...'); 26 | 27 | // Create directory 28 | if (!existsSync(path)) { 29 | mkdirSync(path, { recursive: true }); 30 | } 31 | 32 | await reportProgress(onProgress, 50, 100, 'Checking project status...'); 33 | 34 | // Comprehensively check if project already exists 35 | const requirementsPath = join(path, 'requirements.md'); 36 | const designPath = join(path, 'design.md'); 37 | const tasksPath = join(path, 'tasks.md'); 38 | const confirmationsPath = join(path, '.workflow-confirmations.json'); 39 | 40 | // If any workflow-related files exist, consider the project already exists 41 | const projectExists = existsSync(requirementsPath) || 42 | existsSync(designPath) || 43 | existsSync(tasksPath) || 44 | existsSync(confirmationsPath); 45 | 46 | if (projectExists) { 47 | await reportProgress(onProgress, 100, 100, 'Found existing project'); 48 | 49 | const status = getWorkflowStatus(path); 50 | const currentStage = getCurrentStage(status, path); 51 | const progress = calculateWorkflowProgress(path, status); 52 | 53 | const enhancedStatus = { 54 | ...status, 55 | design: { ...status.design, exists: existsSync(designPath) }, 56 | tasks: { ...status.tasks, exists: existsSync(tasksPath) } 57 | }; 58 | 59 | // Build detailed existing reason 60 | const existingFiles = []; 61 | if (existsSync(requirementsPath)) existingFiles.push('Requirements document'); 62 | if (existsSync(designPath)) existingFiles.push('Design document'); 63 | if (existsSync(tasksPath)) existingFiles.push('Task list'); 64 | if (existsSync(confirmationsPath)) existingFiles.push('Workflow status'); 65 | 66 | // Use responseBuilder to build error response 67 | return { 68 | displayText: responseBuilder.buildErrorResponse('alreadyInitialized', { 69 | path, 70 | existingFiles: existingFiles.join(', ') 71 | }), 72 | data: { 73 | success: false, 74 | error: 'PROJECT_ALREADY_EXISTS', 75 | existingFiles: existingFiles, 76 | currentStage: currentStage, 77 | progress: progress 78 | } 79 | }; 80 | } 81 | 82 | // Generate requirements document 83 | const result = createRequirementsDocument(path, featureName, introduction); 84 | 85 | if (!result.generated) { 86 | return { 87 | displayText: responseBuilder.buildErrorResponse('invalidPath', { path }), 88 | data: { 89 | success: false, 90 | error: 'Failed to create requirements document', 91 | details: result 92 | } 93 | }; 94 | } 95 | 96 | // Initialize status file, mark requirements stage as unconfirmed 97 | updateStageConfirmation(path, 'requirements', false); 98 | updateStageConfirmation(path, 'design', false); 99 | updateStageConfirmation(path, 'tasks', false); 100 | 101 | await reportProgress(onProgress, 100, 100, 'Initialization completed!'); 102 | 103 | // Use responseBuilder to build success response 104 | return responseBuilder.buildInitResponse(path, featureName); 105 | 106 | } catch (error) { 107 | return { 108 | displayText: responseBuilder.buildErrorResponse('invalidPath', { 109 | path, 110 | error: String(error) 111 | }), 112 | data: { 113 | success: false, 114 | error: `Initialization failed: ${error}` 115 | } 116 | }; 117 | } 118 | } 119 | 120 | async function reportProgress( 121 | onProgress: ((progress: number, total: number, message: string) => Promise) | undefined, 122 | progress: number, 123 | total: number, 124 | message: string 125 | ): Promise { 126 | if (onProgress) { 127 | await onProgress(progress, total, message); 128 | } 129 | } -------------------------------------------------------------------------------- /scripts/validateOpenApi.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env tsx 2 | /** 3 | * Validate if MCP responses conform to OpenAPI specification 4 | */ 5 | 6 | import { fileURLToPath } from 'url'; 7 | import { dirname, join } from 'path'; 8 | import * as fs from 'fs/promises'; 9 | import * as yaml from 'js-yaml'; 10 | 11 | const __filename = fileURLToPath(import.meta.url); 12 | const __dirname = dirname(__filename); 13 | 14 | // Load OpenAPI specification 15 | async function loadOpenApiSpec() { 16 | const specPath = join(__dirname, '../api/spec-workflow.openapi.yaml'); 17 | const specContent = await fs.readFile(specPath, 'utf-8'); 18 | return yaml.load(specContent) as any; 19 | } 20 | 21 | // Manually validate response 22 | function validateResponse(response: any, schemaName: string, spec: any): { valid: boolean; errors: string[] } { 23 | const errors: string[] = []; 24 | const schema = spec.components.schemas[schemaName]; 25 | 26 | if (!schema) { 27 | return { valid: false, errors: [`Schema ${schemaName} not found`] }; 28 | } 29 | 30 | // Check required fields 31 | if (schema.required) { 32 | for (const field of schema.required) { 33 | if (!(field in response)) { 34 | errors.push(`Missing required field: ${field}`); 35 | } 36 | } 37 | } 38 | 39 | // Check field types 40 | if (schema.properties) { 41 | for (const [field, fieldSchema] of Object.entries(schema.properties)) { 42 | if (field in response) { 43 | const value = response[field]; 44 | const expectedType = (fieldSchema as any).type; 45 | 46 | if (expectedType) { 47 | const actualType = Array.isArray(value) ? 'array' : typeof value; 48 | 49 | if (expectedType === 'integer' && typeof value === 'number') { 50 | // integer and number are compatible 51 | } else if (expectedType !== actualType) { 52 | errors.push(`Field ${field}: expected ${expectedType}, got ${actualType}`); 53 | } 54 | } 55 | 56 | // Recursively check nested objects 57 | if ((fieldSchema as any).$ref) { 58 | const refSchemaName = (fieldSchema as any).$ref.split('/').pop(); 59 | const nestedResult = validateResponse(value, refSchemaName, spec); 60 | errors.push(...nestedResult.errors.map(e => `${field}.${e}`)); 61 | } 62 | } 63 | } 64 | } 65 | 66 | return { valid: errors.length === 0, errors }; 67 | } 68 | 69 | // Test example responses 70 | async function testResponses() { 71 | const spec = await loadOpenApiSpec(); 72 | 73 | // Test response examples 74 | const testCases = [ 75 | { 76 | name: 'InitResponse', 77 | response: { 78 | success: true, 79 | data: { 80 | path: '/test/path', 81 | featureName: 'Test Feature', 82 | nextAction: 'edit_requirements' 83 | }, 84 | displayText: 'Initialization successful', 85 | resources: [] 86 | } 87 | }, 88 | { 89 | name: 'CheckResponse', 90 | response: { 91 | stage: 'requirements', 92 | progress: { 93 | overall: 30, 94 | requirements: 100, 95 | design: 0, 96 | tasks: 0 97 | }, 98 | status: { 99 | type: 'ready_to_confirm', 100 | readyToConfirm: true 101 | }, 102 | displayText: 'Check passed' 103 | } 104 | }, 105 | { 106 | name: 'SkipResponse', 107 | response: { 108 | stage: 'requirements', 109 | skipped: true, 110 | displayText: 'Skipped' 111 | } 112 | } 113 | ]; 114 | 115 | console.log('🧪 Validating OpenAPI response format\n'); 116 | 117 | for (const testCase of testCases) { 118 | console.log(`📝 Testing ${testCase.name}:`); 119 | const result = validateResponse(testCase.response, testCase.name, spec); 120 | 121 | if (result.valid) { 122 | console.log(' ✅ Validation passed'); 123 | } else { 124 | console.log(' ❌ Validation failed:'); 125 | result.errors.forEach(error => { 126 | console.log(` - ${error}`); 127 | }); 128 | } 129 | console.log(); 130 | } 131 | 132 | // Check Progress definition 133 | console.log('📊 Progress Schema definition:'); 134 | const progressSchema = spec.components.schemas.Progress; 135 | console.log('Required fields:', progressSchema.required); 136 | console.log('Properties:', Object.keys(progressSchema.properties)); 137 | console.log(); 138 | 139 | // Test Progress 140 | const progressTest = { 141 | overall: 30, 142 | requirements: 100, 143 | design: 0, 144 | tasks: 0 145 | }; 146 | 147 | const progressResult = validateResponse(progressTest, 'Progress', spec); 148 | console.log('Progress validation:', progressResult.valid ? '✅ Passed' : '❌ Failed'); 149 | if (!progressResult.valid) { 150 | progressResult.errors.forEach(error => { 151 | console.log(` - ${error}`); 152 | }); 153 | } 154 | } 155 | 156 | if (import.meta.url === `file://${process.argv[1]}`) { 157 | testResponses().catch(console.error); 158 | } -------------------------------------------------------------------------------- /src/features/check/analyzeStage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Analyze workflow stage 3 | */ 4 | 5 | import { join } from 'path'; 6 | import { WorkflowStatus, WorkflowStage, getStageFileName, getStageName } from '../shared/documentStatus.js'; 7 | import { isStageConfirmed, isStageSkipped, ConfirmationStatus, SkipStatus } from '../shared/confirmationStatus.js'; 8 | import { openApiLoader } from '../shared/openApiLoader.js'; 9 | import { isDocumentEdited } from '../shared/documentAnalyzer.js'; 10 | import { isObject, hasProperty } from '../shared/typeGuards.js'; 11 | 12 | export interface StageAnalysis { 13 | canProceed: boolean; 14 | needsConfirmation: boolean; 15 | reason?: string; 16 | suggestions: string[]; 17 | guide?: unknown; // Writing guide provided when document is not edited 18 | } 19 | 20 | export interface StageStatus { 21 | exists: boolean; 22 | confirmed: boolean; 23 | skipped: boolean; 24 | displayStatus: string; 25 | } 26 | 27 | export function analyzeStage( 28 | path: string, 29 | stage: WorkflowStage, 30 | status: WorkflowStatus 31 | ): StageAnalysis { 32 | if (stage === 'completed') { 33 | return { 34 | canProceed: false, 35 | needsConfirmation: false, 36 | reason: 'All stages completed', 37 | suggestions: [] 38 | }; 39 | } 40 | 41 | const stageData = status[stage as keyof WorkflowStatus]; 42 | // Type guard to handle 'completed' stage 43 | const isCompletedStage = (s: WorkflowStage): s is 'completed' => s === 'completed'; 44 | const confirmed = isCompletedStage(stage) ? false : isStageConfirmed(path, stage as keyof ConfirmationStatus); 45 | const skipped = isCompletedStage(stage) ? false : isStageSkipped(path, stage as keyof SkipStatus); 46 | 47 | // If stage is skipped or confirmed, can proceed 48 | if (confirmed || skipped) { 49 | return { 50 | canProceed: true, 51 | needsConfirmation: false, 52 | suggestions: [`${getStageName(stage)} completed, can proceed to next stage`] 53 | }; 54 | } 55 | 56 | // Check if document exists 57 | if (!stageData || !stageData.exists) { 58 | return { 59 | canProceed: false, 60 | needsConfirmation: false, 61 | reason: `${getStageName(stage)} does not exist`, 62 | suggestions: [`Create ${getStageName(stage)}`], 63 | guide: getStageGuide(stage) 64 | }; 65 | } 66 | 67 | // Check if document has been edited 68 | const fileName = getStageFileName(stage); 69 | const filePath = join(path, fileName); 70 | const edited = isDocumentEdited(filePath); 71 | 72 | if (!edited) { 73 | // Document exists but not edited 74 | return { 75 | canProceed: false, 76 | needsConfirmation: false, 77 | reason: `${getStageName(stage)} not edited yet (still contains template markers)`, 78 | suggestions: [ 79 | `Please edit ${fileName} and remove all markers`, 80 | 'Fill in actual content before using check operation' 81 | ], 82 | guide: getStageGuide(stage) 83 | }; 84 | } 85 | 86 | // Document edited but not confirmed 87 | return { 88 | canProceed: false, 89 | needsConfirmation: true, 90 | reason: `${getStageName(stage)} edited but not confirmed yet`, 91 | suggestions: ['Please use confirm operation to confirm this stage is complete'], 92 | guide: getStageGuide(stage) 93 | }; 94 | } 95 | 96 | export function getStageStatus( 97 | stage: string, 98 | status: WorkflowStatus, 99 | path: string 100 | ): StageStatus { 101 | const stageData = status[stage as keyof WorkflowStatus]; 102 | const exists = stageData?.exists || false; 103 | const confirmed = exists && stage !== 'completed' ? isStageConfirmed(path, stage as keyof ConfirmationStatus) : false; 104 | const skipped = exists && stage !== 'completed' ? isStageSkipped(path, stage as keyof SkipStatus) : false; 105 | 106 | const globalConfig = openApiLoader.getGlobalConfig(); 107 | const statusTextConfig = isObject(globalConfig) && hasProperty(globalConfig, 'status_text') && isObject(globalConfig.status_text) ? globalConfig.status_text : {}; 108 | const statusText = { 109 | not_created: typeof statusTextConfig.not_created === 'string' ? statusTextConfig.not_created : 'Not created', 110 | not_confirmed: typeof statusTextConfig.not_confirmed === 'string' ? statusTextConfig.not_confirmed : 'Pending confirmation', 111 | completed: typeof statusTextConfig.completed === 'string' ? statusTextConfig.completed : 'Completed', 112 | skipped: typeof statusTextConfig.skipped === 'string' ? statusTextConfig.skipped : 'Skipped' 113 | }; 114 | 115 | let displayStatus = statusText.not_created; 116 | if (exists) { 117 | if (skipped) { 118 | displayStatus = statusText.skipped; 119 | } else if (confirmed) { 120 | displayStatus = statusText.completed; 121 | } else { 122 | displayStatus = statusText.not_confirmed; 123 | } 124 | } 125 | 126 | return { 127 | exists, 128 | confirmed, 129 | skipped, 130 | displayStatus 131 | }; 132 | } 133 | 134 | 135 | function getStageGuide(stage: WorkflowStage): unknown { 136 | const guideMap: Record = { 137 | requirements: 'requirements-guide', 138 | design: 'design-guide', 139 | tasks: 'tasks-guide', 140 | completed: '' 141 | }; 142 | 143 | const guideId = guideMap[stage]; 144 | if (!guideId) return null; 145 | 146 | // Get resource from OpenAPI - already in MCP format 147 | const resource = openApiLoader.getSharedResource(guideId); 148 | return resource || null; 149 | } -------------------------------------------------------------------------------- /src/features/shared/taskGuidanceTemplate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Task guidance template extractor 3 | * Reads task completion guidance text templates from OpenAPI specification 4 | */ 5 | 6 | import { openApiLoader, OpenApiLoader } from './openApiLoader.js'; 7 | 8 | export class TaskGuidanceExtractor { 9 | private static _template: ReturnType | undefined; 10 | 11 | private static get template() { 12 | if (!this._template) { 13 | // Lazy loading to ensure OpenAPI spec is loaded 14 | openApiLoader.loadSpec(); 15 | this._template = openApiLoader.getTaskGuidanceTemplate(); 16 | } 17 | return this._template; 18 | } 19 | 20 | /** 21 | * Build task guidance text 22 | * Read templates from OpenAPI spec and assemble them 23 | */ 24 | static buildGuidanceText( 25 | nextTaskContent: string, 26 | firstSubtask: string, 27 | taskNumber?: string, 28 | isFirstTask: boolean = false 29 | ): string { 30 | const template = this.template; 31 | if (!template) { 32 | throw new Error('Failed to load task guidance template from OpenAPI specification'); 33 | } 34 | 35 | const parts: string[] = []; 36 | 37 | // Add separator line 38 | parts.push(template.separator); 39 | parts.push(''); 40 | 41 | // Add task header 42 | parts.push(template.header); 43 | parts.push(nextTaskContent); 44 | parts.push(''); 45 | 46 | // Add model instructions 47 | parts.push(template.instructions.prefix); 48 | const taskFocusText = OpenApiLoader.replaceVariables(template.instructions.taskFocus, { firstSubtask }); 49 | parts.push(taskFocusText); 50 | 51 | parts.push(''); 52 | parts.push(template.instructions.progressTracking); 53 | parts.push(template.instructions.workflow); 54 | parts.push(''); 55 | 56 | // Add model prompt based on scenario 57 | let prompt: string; 58 | if (isFirstTask) { 59 | // Replace firstSubtask placeholder in firstTask prompt 60 | prompt = OpenApiLoader.replaceVariables(template.prompts.firstTask, { firstSubtask }); 61 | } else if (taskNumber) { 62 | // Determine if it's a new task or continuation 63 | if (taskNumber.includes('.')) { 64 | // Subtask, use continuation prompt 65 | prompt = OpenApiLoader.replaceVariables(template.prompts.continueTask, { taskNumber, firstSubtask }); 66 | } else { 67 | // Main task, use new task prompt 68 | prompt = OpenApiLoader.replaceVariables(template.prompts.nextTask, { taskNumber, firstSubtask }); 69 | } 70 | } else { 71 | // Batch completion scenario, no specific task number 72 | prompt = OpenApiLoader.replaceVariables(template.prompts.batchContinue, { firstSubtask }); 73 | } 74 | 75 | parts.push(prompt); 76 | 77 | return parts.join('\n'); 78 | } 79 | 80 | /** 81 | * Extract the first uncompleted task with its context 82 | */ 83 | static extractFirstSubtask(taskContent: string): string { 84 | const taskLines = taskContent.split('\n'); 85 | let firstSubtaskFound = false; 86 | let firstSubtaskLines: string[] = []; 87 | let currentIndent = ''; 88 | 89 | for (let i = 0; i < taskLines.length; i++) { 90 | const line = taskLines[i]; 91 | 92 | // 忽略空行(但在收集过程中保留) 93 | if (!line.trim()) { 94 | if (firstSubtaskFound) { 95 | firstSubtaskLines.push(line); 96 | } 97 | continue; 98 | } 99 | 100 | // 寻找第一个包含 [ ] 的行(未完成任务) 101 | if (line.includes('[ ]') && !firstSubtaskFound) { 102 | // 提取任务号验证这是一个子任务(包含点号) 103 | const taskMatch = line.match(/(\d+(?:\.\d+)+)\./); 104 | if (taskMatch) { 105 | firstSubtaskFound = true; 106 | firstSubtaskLines.push(line); 107 | currentIndent = line.match(/^(\s*)/)?.[1] || ''; 108 | continue; 109 | } 110 | } 111 | 112 | // 如果已经找到第一个子任务,继续收集其详细内容 113 | if (firstSubtaskFound) { 114 | const lineIndent = line.match(/^(\s*)/)?.[1] || ''; 115 | 116 | // 如果遇到同级或更高级的任务,停止收集 117 | if (line.includes('[ ]') && lineIndent.length <= currentIndent.length) { 118 | break; 119 | } 120 | 121 | // 如果是更深层次的缩进内容,继续收集 122 | if (lineIndent.length > currentIndent.length || line.trim().startsWith('-') || line.trim().startsWith('*')) { 123 | firstSubtaskLines.push(line); 124 | } else { 125 | // 遇到非缩进内容,停止收集 126 | break; 127 | } 128 | } 129 | } 130 | 131 | // 如果找到了第一个子任务,返回其完整内容 132 | if (firstSubtaskLines.length > 0) { 133 | return firstSubtaskLines.join('\n').trim(); 134 | } 135 | 136 | // 如果没有找到子任务,尝试找第一个未完成的任务 137 | for (const line of taskLines) { 138 | if (!line.trim()) continue; 139 | 140 | if (line.includes('[ ]')) { 141 | const taskMatch = line.match(/(\d+(?:\.\d+)*)\.\s*(.+)/); 142 | if (taskMatch) { 143 | const taskNumber = taskMatch[1]; 144 | const taskDesc = taskMatch[2].replace(/\*\*|\*/g, '').trim(); 145 | return `${taskNumber}. ${taskDesc}`; 146 | } 147 | return line.replace(/[-[\]\s]/g, '').replace(/\*\*|\*/g, '').trim(); 148 | } 149 | } 150 | 151 | return 'Next task'; 152 | } 153 | 154 | /** 155 | * Get completion message 156 | */ 157 | static getCompletionMessage(type: 'taskCompleted' | 'allCompleted' | 'alreadyCompleted' | 'batchSucceeded' | 'batchCompleted', taskNumber?: string): string { 158 | const template = this.template; 159 | if (!template) { 160 | throw new Error('Failed to load task guidance template from OpenAPI specification'); 161 | } 162 | 163 | const message = template.completionMessages[type]; 164 | if (taskNumber && message.includes('${taskNumber}')) { 165 | return OpenApiLoader.replaceVariables(message, { taskNumber }); 166 | } 167 | return message; 168 | } 169 | } -------------------------------------------------------------------------------- /README-zh.md: -------------------------------------------------------------------------------- 1 | # Spec Workflow MCP 2 | 3 | [![npm version](https://img.shields.io/npm/v/spec-workflow-mcp.svg)](https://www.npmjs.com/package/spec-workflow-mcp) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | [![MCP](https://img.shields.io/badge/MCP-Compatible-blue)](https://modelcontextprotocol.com) 6 | 7 | [English](README.md) | [简体中文](README-zh.md) 8 | 9 | 通过结构化的 **需求 → 设计 → 任务** 工作流,引导 AI 系统地完成软件开发,确保代码实现与业务需求始终保持一致。 10 | 11 | ## 为什么需要它? 12 | 13 | ### ❌ 没有 Spec Workflow 时 14 | - AI 在任务间随机跳跃,缺乏系统性 15 | - 需求与实际代码实现脱节 16 | - 文档散乱,难以追踪项目进度 17 | - 缺少设计决策的记录 18 | 19 | ### ✅ 使用 Spec Workflow 后 20 | - AI 按顺序完成任务,保持专注和上下文 21 | - 从用户故事到代码实现的完整追踪 22 | - 标准化文档模板,自动进度管理 23 | - 每个阶段都需要确认,确保方向正确 24 | - **进度持久化保存**:即使新建对话,也能通过 `check` 继续之前的工作 25 | 26 | ## 最近更新 27 | 28 | > **v1.0.7** 29 | > - 🎯 提高了绝大部分模型用 spec workflow 管理任务进度的可靠性 30 | > 31 | > **v1.0.6** 32 | > - ✨ 批量任务完成:一次完成多个任务,大型项目进展更快 33 | > 34 | > **v1.0.5** 35 | > - 🐛 边缘情况修复:区分"任务不存在"和"任务已完成",避免工作流中断 36 | > 37 | > **v1.0.4** 38 | > - ✅ 任务管理功能:添加任务完成追踪,让 AI 能够系统地推进项目 39 | > 40 | > **v1.0.3** 41 | > - 🎉 首次发布:提供需求→设计→任务的核心工作流框架 42 | 43 | ## 快速开始 44 | 45 | ### 1. 安装(以 Claude Code 为例) 46 | ```bash 47 | claude mcp add spec-workflow-mcp -s user -- npx -y spec-workflow-mcp@latest 48 | ``` 49 | 50 | 其他客户端请参考[完整安装指南](#安装指南)。 51 | 52 | ### 2. 开始新项目 53 | ``` 54 | "帮我用 spec workflow 创建一个用户认证系统" 55 | ``` 56 | 57 | ### 3. 继续现有项目 58 | ``` 59 | "用 spec workflow check ./my-project" 60 | ``` 61 | 62 | AI 会自动检测项目状态并从上次中断的地方继续。 63 | 64 | ## 工作流程示例 65 | 66 | ### 1. 你描述需求 67 | ``` 68 | 你:"我需要构建一个用户认证系统" 69 | ``` 70 | 71 | ### 2. AI 创建结构化文档 72 | ``` 73 | AI:"我来帮你创建用户认证的 spec workflow..." 74 | 75 | 📝 requirements.md - 用户故事和功能需求 76 | 🎨 design.md - 技术架构和设计决策 77 | ✅ tasks.md - 具体实现任务列表 78 | ``` 79 | 80 | ### 3. 逐步审批和实施 81 | 每个阶段完成后,AI 会请求你的确认才继续下一步,确保项目始终在正确的轨道上。 82 | 83 | ## 文档组织 84 | 85 | ### 基础结构 86 | ``` 87 | my-project/specs/ 88 | ├── requirements.md # 需求:用户故事、功能规格 89 | ├── design.md # 设计:架构、API、数据模型 90 | ├── tasks.md # 任务:编号的实施步骤 91 | └── .workflow-confirmations.json # 状态:自动进度追踪 92 | ``` 93 | 94 | ### 多模块项目 95 | ``` 96 | my-project/specs/ 97 | ├── user-authentication/ # 认证模块 98 | ├── payment-system/ # 支付模块 99 | └── notification-service/ # 通知模块 100 | ``` 101 | 102 | 你可以指定任意目录:`"用 spec workflow 在 ./src/features/auth 创建认证文档"` 103 | 104 | ## AI 使用指南 105 | 106 | ### 🤖 让 AI 更好地使用此工具 107 | 108 | **强烈建议**在你的 AI 助手配置中添加以下引导词。如果不配置,AI 可能会: 109 | - ❌ 不知道何时调用 Spec Workflow 110 | - ❌ 忘记管理任务进度,导致工作混乱 111 | - ❌ 不会利用 Spec Workflow 来系统地编写文档 112 | - ❌ 无法持续跟踪项目状态 113 | 114 | 配置后,AI 将能够智能地使用 Spec Workflow 来管理整个开发流程。 115 | 116 | > **配置提醒**:请根据您的实际情况修改以下内容: 117 | > 1. 将 `./specs` 改为您偏好的文档目录路径 118 | > 2. 将"中文"改为您偏好的文档语言(如"英文") 119 | 120 | ``` 121 | # Spec Workflow 使用规范 122 | 123 | ## 1. 检查项目进度 124 | 当用户提到继续之前的项目或不确定当前进度时,主动使用: 125 | specs-workflow 工具,action.type="check",path="./specs" 126 | 127 | ## 2. 文档语言 128 | 所有 spec workflow 文档统一使用中文编写,包括需求、设计、任务文档中的所有内容。 129 | 130 | ## 3. 文档目录 131 | 所有 spec workflow 文档统一放置在 ./specs 目录下,保持项目文档的组织一致性。 132 | 133 | ## 4. 任务管理 134 | 始终使用以下方式管理任务进度: 135 | specs-workflow 工具,action.type="complete_task",taskNumber="当前任务编号" 136 | 按照 workflow 返回的指引继续工作,直到所有任务完成。 137 | 138 | ## 5. 最佳实践 139 | - 主动检查进度:当用户说"继续上次的工作"时,先用 check 查看当前状态 140 | - 保持语言一致:整个项目文档使用同一种语言 141 | - 灵活的目录结构:根据项目规模选择单模块或多模块组织方式 142 | - 任务粒度控制:每个任务应该可以在 1-2 小时内完成 143 | ``` 144 | 145 | ## 安装指南 146 | 147 |
148 | 📦 查看完整安装说明 149 | 150 | ### 系统要求 151 | 152 | - Node.js ≥ v18.0.0 153 | - npm 或 yarn 154 | - Claude Desktop 或任何兼容 MCP 的客户端 155 | 156 | ### 在不同 MCP 客户端中安装 157 | 158 | #### Claude Code(推荐) 159 | 160 | 使用 Claude CLI 添加 MCP 服务器: 161 | 162 | ```bash 163 | claude mcp add spec-workflow-mcp -s user -- npx -y spec-workflow-mcp@latest 164 | ``` 165 | 166 | #### Claude Desktop 167 | 168 | 添加到您的 Claude Desktop 配置文件: 169 | - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` 170 | - Windows: `%APPDATA%/Claude/claude_desktop_config.json` 171 | - Linux: `~/.config/Claude/claude_desktop_config.json` 172 | 173 | ```json 174 | { 175 | "mcpServers": { 176 | "spec-workflow": { 177 | "command": "npx", 178 | "args": ["-y", "spec-workflow-mcp@latest"] 179 | } 180 | } 181 | } 182 | ``` 183 | 184 | #### Cursor 185 | 186 | 添加到您的 Cursor 配置文件(`~/.cursor/config.json`): 187 | 188 | ```json 189 | { 190 | "mcpServers": { 191 | "spec-workflow": { 192 | "command": "npx", 193 | "args": ["-y", "spec-workflow-mcp@latest"] 194 | } 195 | } 196 | } 197 | ``` 198 | 199 | #### Cline 200 | 201 | 使用 Cline 的 MCP 服务器管理界面添加服务器: 202 | 203 | 1. 打开安装了 Cline 扩展的 VS Code 204 | 2. 打开 Cline 设置(齿轮图标) 205 | 3. 导航到 MCP Servers 部分 206 | 4. 添加新服务器: 207 | - Command: `npx` 208 | - Arguments: `-y spec-workflow-mcp@latest` 209 | 210 | #### Windsurf (Codeium) 211 | 212 | 添加到您的 Windsurf 配置文件(`~/.codeium/windsurf/mcp_config.json`): 213 | 214 | ```json 215 | { 216 | "mcpServers": { 217 | "spec-workflow": { 218 | "command": "npx", 219 | "args": ["-y", "spec-workflow-mcp@latest"], 220 | "env": {}, 221 | "autoApprove": [], 222 | "disabled": false, 223 | "timeout": 60, 224 | "transportType": "stdio" 225 | } 226 | } 227 | } 228 | ``` 229 | 230 | #### VS Code(需要 MCP 扩展) 231 | 232 | 添加到您的 VS Code 设置(`settings.json`): 233 | 234 | ```json 235 | { 236 | "mcp.servers": { 237 | "spec-workflow": { 238 | "command": "npx", 239 | "args": ["-y", "spec-workflow-mcp@latest"] 240 | } 241 | } 242 | } 243 | ``` 244 | 245 | #### Zed 246 | 247 | 添加到您的 Zed 配置文件(`~/.config/zed/settings.json`): 248 | 249 | ```json 250 | { 251 | "assistant": { 252 | "version": "2", 253 | "mcp": { 254 | "servers": { 255 | "spec-workflow": { 256 | "command": "npx", 257 | "args": ["-y", "spec-workflow-mcp@latest"] 258 | } 259 | } 260 | } 261 | } 262 | } 263 | ``` 264 | 265 | ### 从源码安装 266 | 267 | ```bash 268 | git clone https://github.com/kingkongshot/specs-mcp.git 269 | cd specs-mcp 270 | npm install 271 | npm run build 272 | ``` 273 | 274 | 然后添加到 Claude Desktop 配置: 275 | 276 | ```json 277 | { 278 | "mcpServers": { 279 | "spec-workflow": { 280 | "command": "node", 281 | "args": ["/absolute/path/to/specs-mcp/dist/index.js"] 282 | } 283 | } 284 | } 285 | ``` 286 | 287 |
288 | 289 | 290 | ## 链接 291 | 292 | - [GitHub 仓库](https://github.com/kingkongshot/specs-mcp) 293 | - [NPM 包](https://www.npmjs.com/package/spec-workflow-mcp) 294 | - [问题反馈](https://github.com/kingkongshot/specs-mcp/issues) 295 | 296 | ## 许可证 297 | 298 | MIT License 299 | 300 | --- 301 | 302 | 303 | Spec Workflow MCP server 304 | -------------------------------------------------------------------------------- /src/features/shared/openApiLoader.ts: -------------------------------------------------------------------------------- 1 | import * as yaml from 'js-yaml'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import { fileURLToPath } from 'url'; 5 | import { dirname } from 'path'; 6 | import { isObject } from './typeGuards.js'; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = dirname(__filename); 10 | 11 | // OpenAPI specification type definitions 12 | export interface OpenApiSpec { 13 | paths: { 14 | '/spec': { 15 | post: { 16 | responses: { 17 | '200': { 18 | content: { 19 | 'application/json': { 20 | schema: { 21 | $ref: string; 22 | }; 23 | }; 24 | }; 25 | }; 26 | }; 27 | }; 28 | }; 29 | }; 30 | components: { 31 | schemas: Record; 32 | }; 33 | 'x-error-responses': Record; 36 | 'x-shared-resources': Record; 42 | 'x-global-config': unknown; 43 | 'x-document-templates': Record; 44 | 'x-task-guidance-template'?: { 45 | separator: string; 46 | header: string; 47 | instructions: { 48 | prefix: string; 49 | taskFocus: string; 50 | progressTracking: string; 51 | workflow: string; 52 | }; 53 | prompts: { 54 | firstTask: string; 55 | nextTask: string; 56 | continueTask: string; 57 | batchContinue: string; 58 | }; 59 | completionMessages: { 60 | taskCompleted: string; 61 | allCompleted: string; 62 | alreadyCompleted: string; 63 | batchSucceeded: string; 64 | batchCompleted: string; 65 | }; 66 | }; 67 | } 68 | 69 | // Singleton pattern for loading OpenAPI specification 70 | export class OpenApiLoader { 71 | private static instance: OpenApiLoader; 72 | private spec: OpenApiSpec | null = null; 73 | private examples: Map = new Map(); 74 | 75 | private constructor() {} 76 | 77 | static getInstance(): OpenApiLoader { 78 | if (!OpenApiLoader.instance) { 79 | OpenApiLoader.instance = new OpenApiLoader(); 80 | } 81 | return OpenApiLoader.instance; 82 | } 83 | 84 | // Load OpenAPI specification 85 | loadSpec(): OpenApiSpec { 86 | if (this.spec) { 87 | return this.spec; 88 | } 89 | 90 | const specPath = path.join(__dirname, '../../../api/spec-workflow.openapi.yaml'); 91 | const specContent = fs.readFileSync(specPath, 'utf8'); 92 | this.spec = yaml.load(specContent) as OpenApiSpec; 93 | 94 | // Parse and cache all examples 95 | this.cacheExamples(); 96 | 97 | return this.spec; 98 | } 99 | 100 | // Cache all response examples 101 | private cacheExamples(): void { 102 | if (!this.spec) return; 103 | 104 | const schemas = this.spec.components.schemas; 105 | for (const [schemaName, schema] of Object.entries(schemas)) { 106 | if (!isObject(schema)) continue; 107 | // Support standard OpenAPI 3.1.0 examples field 108 | if ('examples' in schema && Array.isArray(schema.examples)) { 109 | this.examples.set(schemaName, schema.examples); 110 | } 111 | // Maintain backward compatibility with custom x-examples field 112 | else if ('x-examples' in schema && Array.isArray(schema['x-examples'])) { 113 | this.examples.set(schemaName, schema['x-examples']); 114 | } 115 | } 116 | } 117 | 118 | // Get response example 119 | getResponseExample(responseType: string, criteria?: Record): unknown { 120 | const examples = this.examples.get(responseType); 121 | if (!examples || examples.length === 0) { 122 | return null; 123 | } 124 | 125 | // If no filter criteria, return the first example 126 | if (!criteria) { 127 | return examples[0]; 128 | } 129 | 130 | // Filter examples by criteria 131 | for (const example of examples) { 132 | let matches = true; 133 | for (const [key, value] of Object.entries(criteria)) { 134 | if (this.getNestedValue(example, key) !== value) { 135 | matches = false; 136 | break; 137 | } 138 | } 139 | if (matches) { 140 | return example; 141 | } 142 | } 143 | 144 | // No match found, return the first one 145 | return examples[0]; 146 | } 147 | 148 | // Get error response template 149 | getErrorResponse(errorType: string): string | null { 150 | if (!this.spec || !this.spec['x-error-responses']) { 151 | return null; 152 | } 153 | 154 | const errorResponse = this.spec['x-error-responses'][errorType]; 155 | return errorResponse?.displayText || null; 156 | } 157 | 158 | 159 | // Get progress calculation rules 160 | getProgressRules(): unknown { 161 | if (!this.spec) return null; 162 | 163 | const progressSchema = this.spec.components.schemas.Progress; 164 | if (isObject(progressSchema) && 'x-progress-rules' in progressSchema) { 165 | return progressSchema['x-progress-rules']; 166 | } 167 | return null; 168 | } 169 | 170 | // Utility function: get nested object value 171 | private getNestedValue(obj: unknown, path: string): unknown { 172 | const keys = path.split('.'); 173 | let current = obj; 174 | 175 | for (const key of keys) { 176 | if (isObject(current) && key in current) { 177 | current = current[key]; 178 | } else { 179 | return undefined; 180 | } 181 | } 182 | 183 | return current; 184 | } 185 | 186 | // Replace template variables 187 | static replaceVariables(template: string, variables: Record): string { 188 | let result = template; 189 | 190 | for (const [key, value] of Object.entries(variables)) { 191 | const regex = new RegExp(`\\$\\{${key}\\}`, 'g'); 192 | result = result.replace(regex, String(value)); 193 | } 194 | 195 | return result; 196 | } 197 | 198 | // Get shared resource - directly return MCP format 199 | getSharedResource(resourceId: string): { uri: string; title?: string; mimeType: string; text?: string } | null { 200 | if (!this.spec || !this.spec['x-shared-resources']) { 201 | return null; 202 | } 203 | 204 | return this.spec['x-shared-resources'][resourceId] || null; 205 | } 206 | 207 | // Get global configuration 208 | getGlobalConfig(): unknown { 209 | if (!this.spec) return {}; 210 | return this.spec['x-global-config'] || {}; 211 | } 212 | 213 | // Get document template 214 | getDocumentTemplate(templateType: string): unknown { 215 | if (!this.spec) return null; 216 | return this.spec['x-document-templates']?.[templateType] || null; 217 | } 218 | 219 | // Resolve resource list - no conversion needed, use MCP format directly 220 | resolveResources(resources?: Array): Array<{ uri: string; title?: string; mimeType: string; text?: string }> | undefined { 221 | if (!resources || resources.length === 0) { 222 | return undefined; 223 | } 224 | 225 | const resolved: Array<{ uri: string; title?: string; mimeType: string; text?: string }> = []; 226 | 227 | for (const resource of resources) { 228 | if (isObject(resource) && 'ref' in resource && typeof resource.ref === 'string') { 229 | // Get from shared resources - already in MCP format 230 | const sharedResource = this.getSharedResource(resource.ref); 231 | if (sharedResource) { 232 | resolved.push(sharedResource); 233 | } 234 | } 235 | } 236 | 237 | return resolved.length > 0 ? resolved : undefined; 238 | } 239 | 240 | // Get task guidance template 241 | getTaskGuidanceTemplate(): OpenApiSpec['x-task-guidance-template'] | null { 242 | if (!this.spec) return null; 243 | return this.spec['x-task-guidance-template'] || null; 244 | } 245 | 246 | // Debug method: get cached examples count 247 | getExamplesCount(responseType: string): number { 248 | return this.examples.get(responseType)?.length || 0; 249 | } 250 | } 251 | 252 | export const openApiLoader = OpenApiLoader.getInstance(); 253 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spec Workflow MCP 2 | 3 | [![npm version](https://img.shields.io/npm/v/spec-workflow-mcp.svg)](https://www.npmjs.com/package/spec-workflow-mcp) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | [![MCP](https://img.shields.io/badge/MCP-Compatible-blue)](https://modelcontextprotocol.com) 6 | 7 | [English](README.md) | [简体中文](README-zh.md) 8 | 9 | Guide AI to systematically complete software development through a structured **Requirements → Design → Tasks** workflow, ensuring code implementation stays aligned with business needs. 10 | 11 | ## Why Use It? 12 | 13 | ### ❌ Without Spec Workflow 14 | - AI jumps randomly between tasks, lacking systematic approach 15 | - Requirements disconnect from actual code implementation 16 | - Scattered documentation, difficult to track project progress 17 | - Missing design decision records 18 | 19 | ### ✅ With Spec Workflow 20 | - AI completes tasks sequentially, maintaining focus and context 21 | - Complete traceability from user stories to code implementation 22 | - Standardized document templates with automatic progress management 23 | - Each stage requires confirmation, ensuring correct direction 24 | - **Persistent progress**: Continue from where you left off with `check`, even in new conversations 25 | 26 | ## Recent Updates 27 | 28 | > **v1.0.7** 29 | > - 🎯 Improved reliability for most models to manage tasks with spec workflow 30 | > 31 | > **v1.0.6** 32 | > - ✨ Batch task completion: Complete multiple tasks at once for faster progress on large projects 33 | > 34 | > **v1.0.5** 35 | > - 🐛 Edge case fixes: Distinguish between "task not found" and "task already completed" to prevent workflow interruption 36 | > 37 | > **v1.0.4** 38 | > - ✅ Task management: Added task completion tracking for systematic project progression 39 | > 40 | > **v1.0.3** 41 | > - 🎉 Initial release: Core workflow framework for Requirements → Design → Tasks 42 | 43 | ## Quick Start 44 | 45 | ### 1. Install (Claude Code Example) 46 | ```bash 47 | claude mcp add spec-workflow-mcp -s user -- npx -y spec-workflow-mcp@latest 48 | ``` 49 | 50 | See [full installation guide](#installation) for other clients. 51 | 52 | ### 2. Start a New Project 53 | ``` 54 | "Help me use spec workflow to create a user authentication system" 55 | ``` 56 | 57 | ### 3. Continue Existing Project 58 | ``` 59 | "Use spec workflow to check ./my-project" 60 | ``` 61 | 62 | The AI will automatically detect project status and continue from where it left off. 63 | 64 | ## Workflow Example 65 | 66 | ### 1. You describe requirements 67 | ``` 68 | You: "I need to build a user authentication system" 69 | ``` 70 | 71 | ### 2. AI creates structured documents 72 | ``` 73 | AI: "I'll help you create spec workflow for user authentication..." 74 | 75 | 📝 requirements.md - User stories and functional requirements 76 | 🎨 design.md - Technical architecture and design decisions 77 | ✅ tasks.md - Concrete implementation task list 78 | ``` 79 | 80 | ### 3. Review and implement step by step 81 | After each stage, the AI requests your confirmation before proceeding, ensuring the project stays on the right track. 82 | 83 | ## Document Organization 84 | 85 | ### Basic Structure 86 | ``` 87 | my-project/specs/ 88 | ├── requirements.md # Requirements: user stories, functional specs 89 | ├── design.md # Design: architecture, APIs, data models 90 | ├── tasks.md # Tasks: numbered implementation steps 91 | └── .workflow-confirmations.json # Status: automatic progress tracking 92 | ``` 93 | 94 | ### Multi-module Projects 95 | ``` 96 | my-project/specs/ 97 | ├── user-authentication/ # Auth module 98 | ├── payment-system/ # Payment module 99 | └── notification-service/ # Notification module 100 | ``` 101 | 102 | You can specify any directory: `"Use spec workflow to create auth docs in ./src/features/auth"` 103 | 104 | ## AI Usage Guide 105 | 106 | ### 🤖 Make AI Use This Tool Better 107 | 108 | **Strongly recommended** to add the following prompt to your AI assistant configuration. Without it, AI may: 109 | - ❌ Not know when to invoke Spec Workflow 110 | - ❌ Forget to manage task progress, causing disorganized work 111 | - ❌ Not utilize Spec Workflow for systematic documentation 112 | - ❌ Unable to continuously track project status 113 | 114 | With this configuration, AI will intelligently use Spec Workflow to manage the entire development process. 115 | 116 | > **Configuration Note**: Please modify the following based on your needs: 117 | > 1. Change `./specs` to your preferred documentation directory path 118 | > 2. Change "English" to your preferred documentation language (e.g., "Chinese") 119 | 120 | ``` 121 | # Spec Workflow Usage Guidelines 122 | 123 | ## 1. Check Project Progress 124 | When user mentions continuing previous project or is unsure about current progress, proactively use: 125 | specs-workflow tool with action.type="check" and path="./specs" 126 | 127 | ## 2. Documentation Language 128 | All spec workflow documents should be written in English consistently, including all content in requirements, design, and task documents. 129 | 130 | ## 3. Documentation Directory 131 | All spec workflow documents should be placed in ./specs directory to maintain consistent project documentation organization. 132 | 133 | ## 4. Task Management 134 | Always use the following to manage task progress: 135 | specs-workflow tool with action.type="complete_task" and taskNumber="current task number" 136 | Follow the workflow guidance to continue working until all tasks are completed. 137 | 138 | ## 5. Best Practices 139 | - Proactive progress check: When user says "continue from last time", first use check to see current status 140 | - Language consistency: Use the same language throughout all project documents 141 | - Flexible structure: Choose single-module or multi-module organization based on project scale 142 | - Task granularity: Each task should be completable within 1-2 hours 143 | ``` 144 | 145 | ## Installation 146 | 147 |
148 | 📦 Installation Instructions 149 | 150 | ### Requirements 151 | 152 | - Node.js ≥ v18.0.0 153 | - npm or yarn 154 | - Claude Desktop or any MCP-compatible client 155 | 156 | ### Install in Different MCP Clients 157 | 158 | #### Claude Code (Recommended) 159 | 160 | Use the Claude CLI to add the MCP server: 161 | 162 | ```bash 163 | claude mcp add spec-workflow-mcp -s user -- npx -y spec-workflow-mcp@latest 164 | ``` 165 | 166 | #### Claude Desktop 167 | 168 | Add to your Claude Desktop configuration: 169 | - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` 170 | - Windows: `%APPDATA%/Claude/claude_desktop_config.json` 171 | - Linux: `~/.config/Claude/claude_desktop_config.json` 172 | 173 | ```json 174 | { 175 | "mcpServers": { 176 | "spec-workflow": { 177 | "command": "npx", 178 | "args": ["-y", "spec-workflow-mcp@latest"] 179 | } 180 | } 181 | } 182 | ``` 183 | 184 | #### Cursor 185 | 186 | Add to your Cursor configuration (`~/.cursor/config.json`): 187 | 188 | ```json 189 | { 190 | "mcpServers": { 191 | "spec-workflow": { 192 | "command": "npx", 193 | "args": ["-y", "spec-workflow-mcp@latest"] 194 | } 195 | } 196 | } 197 | ``` 198 | 199 | #### Cline 200 | 201 | Use Cline's MCP server management UI to add the server: 202 | 203 | 1. Open VS Code with Cline extension 204 | 2. Open Cline settings (gear icon) 205 | 3. Navigate to MCP Servers section 206 | 4. Add new server with: 207 | - Command: `npx` 208 | - Arguments: `-y spec-workflow-mcp@latest` 209 | 210 | #### Windsurf (Codeium) 211 | 212 | Add to your Windsurf configuration (`~/.codeium/windsurf/mcp_config.json`): 213 | 214 | ```json 215 | { 216 | "mcpServers": { 217 | "spec-workflow": { 218 | "command": "npx", 219 | "args": ["-y", "spec-workflow-mcp@latest"], 220 | "env": {}, 221 | "autoApprove": [], 222 | "disabled": false, 223 | "timeout": 60, 224 | "transportType": "stdio" 225 | } 226 | } 227 | } 228 | ``` 229 | 230 | #### VS Code (with MCP extension) 231 | 232 | Add to your VS Code settings (`settings.json`): 233 | 234 | ```json 235 | { 236 | "mcp.servers": { 237 | "spec-workflow": { 238 | "command": "npx", 239 | "args": ["-y", "spec-workflow-mcp@latest"] 240 | } 241 | } 242 | } 243 | ``` 244 | 245 | #### Zed 246 | 247 | Add to your Zed configuration (`~/.config/zed/settings.json`): 248 | 249 | ```json 250 | { 251 | "assistant": { 252 | "version": "2", 253 | "mcp": { 254 | "servers": { 255 | "spec-workflow": { 256 | "command": "npx", 257 | "args": ["-y", "spec-workflow-mcp@latest"] 258 | } 259 | } 260 | } 261 | } 262 | } 263 | ``` 264 | 265 | ### Install from Source 266 | 267 | ```bash 268 | git clone https://github.com/kingkongshot/specs-mcp.git 269 | cd specs-mcp 270 | npm install 271 | npm run build 272 | ``` 273 | 274 | Then add to Claude Desktop configuration: 275 | 276 | ```json 277 | { 278 | "mcpServers": { 279 | "spec-workflow": { 280 | "command": "node", 281 | "args": ["/absolute/path/to/specs-mcp/dist/index.js"] 282 | } 283 | } 284 | } 285 | ``` 286 | 287 |
288 | 289 | 290 | ## Links 291 | 292 | - [GitHub Repository](https://github.com/kingkongshot/specs-mcp) 293 | - [NPM Package](https://www.npmjs.com/package/spec-workflow-mcp) 294 | - [Report Issues](https://github.com/kingkongshot/specs-mcp/issues) 295 | 296 | ## License 297 | 298 | MIT License 299 | 300 | --- 301 | 302 | 303 | Spec Workflow MCP server 304 | -------------------------------------------------------------------------------- /src/features/shared/taskParser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse tasks from tasks.md file 3 | */ 4 | 5 | import { readFileSync } from 'fs'; 6 | import { join } from 'path'; 7 | 8 | export interface Task { 9 | number: string; 10 | description: string; 11 | checked: boolean; 12 | subtasks?: Task[]; 13 | isVirtual?: boolean; // 标识是否为虚拟创建的任务 14 | } 15 | 16 | export function parseTasksFile(path: string): Task[] { 17 | try { 18 | const tasksPath = join(path, 'tasks.md'); 19 | const content = readFileSync(tasksPath, 'utf-8'); 20 | 21 | // Remove template marker blocks 22 | const cleanContent = content 23 | .replace(//g, '') // Compatible with old format 24 | .replace(/[\s\S]*?<\/template-tasks>/g, '') // Match actual task template markers 25 | .trim(); 26 | 27 | if (!cleanContent) { 28 | return []; 29 | } 30 | 31 | return parseTasksFromContent(cleanContent); 32 | } catch { 33 | return []; 34 | } 35 | } 36 | 37 | export function parseTasksFromContent(content: string): Task[] { 38 | const lines = content.split('\n'); 39 | const allTasks: Task[] = []; 40 | 41 | // Phase 1: Collect all tasks with checkboxes 42 | for (let i = 0; i < lines.length; i++) { 43 | const line = lines[i]; 44 | 45 | // Find checkbox pattern 46 | const checkboxMatch = line.match(/\[([xX ])\]/); 47 | if (!checkboxMatch) continue; 48 | 49 | // Extract task number (flexible matching) 50 | const numberMatch = line.match(/(\d+(?:\.\d+)*)/); 51 | if (!numberMatch) continue; 52 | 53 | const taskNumber = numberMatch[1]; 54 | const isChecked = checkboxMatch[1].toLowerCase() === 'x'; 55 | 56 | // Extract description (remove task number and checkbox) 57 | let description = line 58 | .replace(/\[([xX ])\]/, '') // Remove checkbox 59 | .replace(/(\d+(?:\.\d+)*)\s*[.:\-)]?/, '') // Remove task number 60 | .replace(/^[\s\-*]+/, '') // Remove leading symbols 61 | .trim(); 62 | 63 | // If description is empty, try to get from next line 64 | if (!description && i + 1 < lines.length) { 65 | const nextLine = lines[i + 1].trim(); 66 | if (nextLine && !nextLine.match(/\[([xX ])\]/) && !nextLine.match(/^#/)) { 67 | description = nextLine; 68 | i++; // Skip next line 69 | } 70 | } 71 | 72 | if (!description) continue; 73 | 74 | allTasks.push({ 75 | number: taskNumber, 76 | description: description, 77 | checked: isChecked 78 | }); 79 | } 80 | 81 | // Phase 2: Build hierarchy structure 82 | const taskMap = new Map(); 83 | const rootTasks: Task[] = []; 84 | 85 | // Infer main tasks from task numbers 86 | for (const task of allTasks) { 87 | if (!task.number.includes('.')) { 88 | // Top-level task 89 | taskMap.set(task.number, task); 90 | rootTasks.push(task); 91 | } 92 | } 93 | 94 | // Process subtasks 95 | for (const task of allTasks) { 96 | if (task.number.includes('.')) { 97 | const parts = task.number.split('.'); 98 | const parentNumber = parts[0]; 99 | 100 | // If main task doesn't exist, create virtual parent task 101 | if (!taskMap.has(parentNumber)) { 102 | // Try to find better title from document 103 | const betterTitle = findMainTaskTitle(lines, parentNumber); 104 | const virtualParent: Task = { 105 | number: parentNumber, 106 | description: betterTitle || `Task Group ${parentNumber}`, 107 | checked: false, 108 | subtasks: [], 109 | isVirtual: true // 标记为虚拟任务 110 | }; 111 | taskMap.set(parentNumber, virtualParent); 112 | rootTasks.push(virtualParent); 113 | } 114 | 115 | // Add subtask to main task 116 | const parent = taskMap.get(parentNumber)!; 117 | if (!parent.subtasks) { 118 | parent.subtasks = []; 119 | } 120 | parent.subtasks.push(task); 121 | } 122 | } 123 | 124 | // Update main task completion status (only when all subtasks are completed) 125 | for (const task of rootTasks) { 126 | if (task.subtasks && task.subtasks.length > 0) { 127 | task.checked = task.subtasks.every(st => st.checked); 128 | } 129 | } 130 | 131 | // Sort by task number 132 | rootTasks.sort((a, b) => { 133 | const numA = parseInt(a.number); 134 | const numB = parseInt(b.number); 135 | return numA - numB; 136 | }); 137 | 138 | // Sort subtasks 139 | for (const task of rootTasks) { 140 | if (task.subtasks) { 141 | task.subtasks.sort((a, b) => { 142 | const partsA = a.number.split('.').map(n => parseInt(n)); 143 | const partsB = b.number.split('.').map(n => parseInt(n)); 144 | for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { 145 | const diff = (partsA[i] || 0) - (partsB[i] || 0); 146 | if (diff !== 0) return diff; 147 | } 148 | return 0; 149 | }); 150 | } 151 | } 152 | 153 | return rootTasks; 154 | } 155 | 156 | // Find main task title (from headers or other places) 157 | function findMainTaskTitle(lines: string[], taskNumber: string): string | null { 158 | // Look for lines like "### 1. Title" or "## 1. Title" 159 | for (const line of lines) { 160 | const headerMatch = line.match(/^#+\s*(\d+)\.\s*(.+)$/); 161 | if (headerMatch && headerMatch[1] === taskNumber) { 162 | return headerMatch[2].trim(); 163 | } 164 | } 165 | 166 | // Also support other formats like "1. **Title**" 167 | for (const line of lines) { 168 | const boldMatch = line.match(/^(\d+)\.\s*\*\*(.+?)\*\*$/); 169 | if (boldMatch && boldMatch[1] === taskNumber) { 170 | return boldMatch[2].trim(); 171 | } 172 | } 173 | 174 | return null; 175 | } 176 | 177 | export function getFirstUncompletedTask(tasks: Task[]): Task | null { 178 | for (const task of tasks) { 179 | // 如果任务有子任务,优先检查子任务 180 | if (task.subtasks && task.subtasks.length > 0) { 181 | // 检查是否有未完成的子任务 182 | const firstUncompletedSubtask = task.subtasks.find(subtask => !subtask.checked); 183 | 184 | if (firstUncompletedSubtask) { 185 | // 无论是虚拟主任务还是真实主任务,都返回第一个未完成的子任务 186 | return firstUncompletedSubtask; 187 | } 188 | 189 | // 如果所有子任务都完成了,但主任务未完成,返回主任务 190 | if (!task.checked) { 191 | return task; 192 | } 193 | } else { 194 | // 没有子任务的情况,直接检查主任务 195 | if (!task.checked) { 196 | return task; 197 | } 198 | } 199 | } 200 | 201 | return null; 202 | } 203 | 204 | export function formatTaskForDisplay(task: Task): string { 205 | let display = `📋 Task ${task.number}: ${task.description}`; 206 | 207 | if (task.subtasks && task.subtasks.length > 0) { 208 | display += '\n\nSubtasks:'; 209 | for (const subtask of task.subtasks) { 210 | const status = subtask.checked ? '✓' : '☐'; 211 | display += `\n ${status} ${subtask.number}. ${subtask.description}`; 212 | } 213 | } 214 | 215 | return display; 216 | } 217 | 218 | export function formatTaskForFullDisplay(task: Task, content: string): string { 219 | const lines = content.split('\n'); 220 | const taskLines: string[] = []; 221 | let capturing = false; 222 | let indent = ''; 223 | 224 | for (const line of lines) { 225 | // Find task start (supports two formats: `1. - [ ] task` or `- [ ] 1. task`) 226 | const taskPattern1 = new RegExp(`^(\\s*)${task.number}\\.\\s*-\\s*\\[[ x]\\]\\s*`); 227 | const taskPattern2 = new RegExp(`^(\\s*)-\\s*\\[[ x]\\]\\s*${task.number}\\.\\s*`); 228 | if (line.match(taskPattern1) || line.match(taskPattern2)) { 229 | capturing = true; 230 | taskLines.push(line); 231 | indent = line.match(/^(\s*)/)?.[1] || ''; 232 | continue; 233 | } 234 | 235 | // If capturing task content 236 | if (capturing) { 237 | // Check if reached next task at same or higher level 238 | const nextTaskPattern = /^(\s*)-\s*\[[ x]\]\s*\d+(\.\d+)*\.\s*/; 239 | const nextMatch = line.match(nextTaskPattern); 240 | if (nextMatch) { 241 | const nextIndent = nextMatch[1] || ''; 242 | if (nextIndent.length <= indent.length) { 243 | break; // Found same or higher level task, stop capturing 244 | } 245 | } 246 | 247 | // Continue capturing content belonging to current task 248 | if (line.trim() === '') { 249 | taskLines.push(line); 250 | } else if (line.startsWith(indent + ' ') || line.startsWith(indent + '\t')) { 251 | // Deeper indented content belongs to current task 252 | taskLines.push(line); 253 | } else if (line.match(/^#+\s/)) { 254 | // Found header, stop capturing 255 | break; 256 | } else if (line.match(/^\d+\.\s*-\s*\[[ x]\]/)) { 257 | // Found other top-level task, stop 258 | break; 259 | } else { 260 | // Other cases continue capturing (might be continuation of task description) 261 | const isTaskLine = line.match(/^(\s*)-\s*\[[ x]\]/) || line.match(/^(\s*)\d+(\.\d+)*\.\s*-\s*\[[ x]\]/); 262 | if (isTaskLine) { 263 | break; // Found other task, stop 264 | } else if (line.match(/^\s/) && !line.match(/^\s{8,}/)) { 265 | // If indented but not too deep, might still be current task content 266 | taskLines.push(line); 267 | } else { 268 | break; // Otherwise stop 269 | } 270 | } 271 | } 272 | } 273 | 274 | return taskLines.join('\n').trimEnd(); 275 | } 276 | 277 | // Format task list overview for display 278 | export function formatTaskListOverview(path: string): string { 279 | try { 280 | const tasks = parseTasksFile(path); 281 | if (tasks.length === 0) { 282 | return 'No tasks found.'; 283 | } 284 | 285 | const taskItems = tasks.map(task => { 286 | const status = task.checked ? '[x]' : '[ ]'; 287 | return `- ${status} ${task.number}. ${task.description}`; 288 | }); 289 | 290 | return taskItems.join('\n'); 291 | } catch { 292 | return 'Error loading tasks list.'; 293 | } 294 | } -------------------------------------------------------------------------------- /src/features/shared/responseBuilder.ts: -------------------------------------------------------------------------------- 1 | import { openApiLoader } from './openApiLoader.js'; 2 | import { OpenApiLoader } from './openApiLoader.js'; 3 | import { WorkflowResult } from './mcpTypes.js'; 4 | import { isObject, hasProperty, isArray } from './typeGuards.js'; 5 | import { TaskGuidanceExtractor } from './taskGuidanceTemplate.js'; 6 | 7 | // Response builder - builds responses based on OpenAPI specification 8 | export class ResponseBuilder { 9 | 10 | // Build initialization response 11 | buildInitResponse(path: string, featureName: string): WorkflowResult { 12 | const example = openApiLoader.getResponseExample('InitResponse', { 13 | success: true 14 | }); 15 | 16 | if (!example) { 17 | throw new Error('Initialization response template not found'); 18 | } 19 | 20 | // Deep copy example 21 | const response = JSON.parse(JSON.stringify(example)); 22 | 23 | // Replace variables 24 | response.displayText = OpenApiLoader.replaceVariables(response.displayText, { 25 | featureName, 26 | path, 27 | progress: response.progress?.overall || 0 28 | }); 29 | 30 | // Update data 31 | response.data.path = path; 32 | response.data.featureName = featureName; 33 | 34 | // Resolve resource references 35 | if (response.resources) { 36 | response.resources = openApiLoader.resolveResources(response.resources); 37 | } 38 | 39 | // Embed resources into display text for better client compatibility 40 | const enhancedDisplayText = this.embedResourcesIntoText(response.displayText, response.resources); 41 | 42 | // Return WorkflowResult format, but include complete OpenAPI response in data 43 | return { 44 | displayText: enhancedDisplayText, 45 | data: response, 46 | resources: response.resources 47 | }; 48 | } 49 | 50 | // Build check response 51 | buildCheckResponse( 52 | stage: string, 53 | progress: unknown, 54 | status: unknown, 55 | checkResults?: unknown, 56 | path?: string, 57 | firstTask?: string | null 58 | ): WorkflowResult { 59 | // Select appropriate example based on status type 60 | const statusType = isObject(status) && 'type' in status ? status.type : 'not_started'; 61 | 62 | // Debug info: check examples cache 63 | const examplesCount = openApiLoader.getExamplesCount('CheckResponse'); 64 | 65 | const example = openApiLoader.getResponseExample('CheckResponse', { 66 | stage, 67 | 'status.type': statusType 68 | }); 69 | 70 | if (!example) { 71 | throw new Error(`Check response template not found: stage=${stage}, status=${statusType} (cached examples: ${examplesCount})`); 72 | } 73 | 74 | // Deep copy example 75 | const response = JSON.parse(JSON.stringify(example)); 76 | 77 | // Update actual values 78 | response.stage = stage; 79 | 80 | // Convert progress format to comply with OpenAPI specification 81 | // If input is WorkflowProgress format, need to convert 82 | if (isObject(progress) && hasProperty(progress, 'percentage')) { 83 | // Calculate phase progress based on stage status 84 | const details = isObject(progress.details) ? progress.details : {}; 85 | const requirements = isObject(details.requirements) ? details.requirements : {}; 86 | const design = isObject(details.design) ? details.design : {}; 87 | const tasks = isObject(details.tasks) ? details.tasks : {}; 88 | 89 | const requirementsProgress = requirements.confirmed || requirements.skipped ? 100 : 0; 90 | const designProgress = design.confirmed || design.skipped ? 100 : 0; 91 | // Tasks stage: only count as progress if confirmed, not skipped 92 | const tasksProgress = tasks.confirmed ? 100 : 0; 93 | 94 | response.progress = this.calculateProgress(requirementsProgress, designProgress, tasksProgress); 95 | } else { 96 | // If already in correct format, use directly 97 | response.progress = progress; 98 | } 99 | 100 | response.status = status; 101 | 102 | // If there are check results, update display text 103 | if (checkResults && response.displayText.includes('The tasks document includes')) { 104 | // Dynamically build check items list 105 | const checkItems = this.buildCheckItemsList(checkResults); 106 | // More precise regex that only matches until next empty line or "Model please" line 107 | response.displayText = response.displayText.replace( 108 | /The tasks document includes:[\s\S]*?(?=\n\s*Model please|\n\s*\n\s*Model please|$)/, 109 | `The tasks document includes:\n${checkItems}\n\n` 110 | ); 111 | } 112 | 113 | // Replace variables including progress 114 | const variables: Record = {}; 115 | if (path) { 116 | variables.path = path; 117 | } 118 | if (response.progress && typeof response.progress.overall === 'number') { 119 | variables.progress = response.progress.overall; 120 | } 121 | response.displayText = OpenApiLoader.replaceVariables(response.displayText, variables); 122 | 123 | // If completed stage and has uncompleted tasks, add task information 124 | if (stage === 'completed' && firstTask) { 125 | response.displayText += `\n\n📄 Next uncompleted task:\n${firstTask}\n\nModel please ask the user: "Ready to start the next task?"`; 126 | } 127 | 128 | // Resolve resource references 129 | if (response.resources) { 130 | response.resources = openApiLoader.resolveResources(response.resources); 131 | } 132 | 133 | // Embed resources into display text for better client compatibility 134 | const enhancedDisplayText = this.embedResourcesIntoText(response.displayText, response.resources); 135 | 136 | // Return WorkflowResult format 137 | return { 138 | displayText: enhancedDisplayText, 139 | data: response, 140 | resources: response.resources 141 | }; 142 | } 143 | 144 | // Build skip response 145 | buildSkipResponse(stage: string, path?: string, progress?: unknown): WorkflowResult { 146 | const example = openApiLoader.getResponseExample('SkipResponse', { 147 | stage 148 | }); 149 | 150 | if (!example) { 151 | throw new Error(`Skip response template not found: stage=${stage}`); 152 | } 153 | 154 | // Deep copy example 155 | const response = JSON.parse(JSON.stringify(example)); 156 | response.stage = stage; 157 | 158 | // Update progress if provided 159 | if (progress) { 160 | // Convert progress format to comply with OpenAPI specification 161 | if (isObject(progress) && hasProperty(progress, 'percentage')) { 162 | // Calculate phase progress based on stage status 163 | const details = isObject(progress.details) ? progress.details : {}; 164 | const requirements = isObject(details.requirements) ? details.requirements : {}; 165 | const design = isObject(details.design) ? details.design : {}; 166 | const tasks = isObject(details.tasks) ? details.tasks : {}; 167 | 168 | const requirementsProgress = requirements.confirmed || requirements.skipped ? 100 : 0; 169 | const designProgress = design.confirmed || design.skipped ? 100 : 0; 170 | // Tasks stage: only count as progress if confirmed, not skipped 171 | const tasksProgress = tasks.confirmed ? 100 : 0; 172 | 173 | response.progress = this.calculateProgress(requirementsProgress, designProgress, tasksProgress); 174 | } else { 175 | // If already in correct format, use directly 176 | response.progress = progress; 177 | } 178 | } 179 | 180 | // Replace variables including progress 181 | const variables: Record = {}; 182 | if (path) { 183 | variables.path = path; 184 | } 185 | if (response.progress && typeof response.progress.overall === 'number') { 186 | variables.progress = response.progress.overall; 187 | } 188 | response.displayText = OpenApiLoader.replaceVariables(response.displayText, variables); 189 | 190 | // Resolve resource references 191 | if (response.resources) { 192 | response.resources = openApiLoader.resolveResources(response.resources); 193 | } 194 | 195 | // Embed resources into display text for better client compatibility 196 | const enhancedDisplayText = this.embedResourcesIntoText(response.displayText, response.resources); 197 | 198 | // Return WorkflowResult format 199 | return { 200 | displayText: enhancedDisplayText, 201 | data: response, 202 | resources: response.resources 203 | }; 204 | } 205 | 206 | // Build confirm response 207 | buildConfirmResponse(stage: string, nextStage: string | null, path?: string, firstTaskContent?: string | null, progress?: unknown): WorkflowResult { 208 | const example = openApiLoader.getResponseExample('ConfirmResponse', { 209 | stage, 210 | nextStage: nextStage || null 211 | }); 212 | 213 | if (!example) { 214 | throw new Error(`Confirm response template not found: stage=${stage}`); 215 | } 216 | 217 | // Deep copy example 218 | const response = JSON.parse(JSON.stringify(example)); 219 | response.stage = stage; 220 | response.nextStage = nextStage; 221 | 222 | // Update progress if provided 223 | if (progress) { 224 | // Convert progress format to comply with OpenAPI specification 225 | if (isObject(progress) && hasProperty(progress, 'percentage')) { 226 | // Calculate phase progress based on stage status 227 | const details = isObject(progress.details) ? progress.details : {}; 228 | const requirements = isObject(details.requirements) ? details.requirements : {}; 229 | const design = isObject(details.design) ? details.design : {}; 230 | const tasks = isObject(details.tasks) ? details.tasks : {}; 231 | 232 | const requirementsProgress = requirements.confirmed || requirements.skipped ? 100 : 0; 233 | const designProgress = design.confirmed || design.skipped ? 100 : 0; 234 | // Tasks stage: only count as progress if confirmed, not skipped 235 | const tasksProgress = tasks.confirmed ? 100 : 0; 236 | 237 | response.progress = this.calculateProgress(requirementsProgress, designProgress, tasksProgress); 238 | } else { 239 | // If already in correct format, use directly 240 | response.progress = progress; 241 | } 242 | } 243 | 244 | // Replace variables including progress 245 | const variables: Record = {}; 246 | if (path) { 247 | variables.path = path; 248 | } 249 | if (response.progress && typeof response.progress.overall === 'number') { 250 | variables.progress = response.progress.overall; 251 | } 252 | response.displayText = OpenApiLoader.replaceVariables(response.displayText, variables); 253 | 254 | // If tasks stage confirmation and has first task content, append to display text 255 | if (stage === 'tasks' && nextStage === null && firstTaskContent) { 256 | // Extract first uncompleted subtask for focused planning 257 | const firstSubtask = TaskGuidanceExtractor.extractFirstSubtask(firstTaskContent); 258 | 259 | // 如果没有找到子任务,从任务内容中提取任务描述 260 | let effectiveFirstSubtask = firstSubtask; 261 | if (!effectiveFirstSubtask) { 262 | // 从 firstTaskContent 中提取任务号和描述 263 | const taskMatch = firstTaskContent.match(/(\d+(?:\.\d+)*)\.\s*\*?\*?([^*\n]+)/); 264 | if (taskMatch) { 265 | effectiveFirstSubtask = `${taskMatch[1]}. ${taskMatch[2].trim()}`; 266 | } else { 267 | effectiveFirstSubtask = 'Next task'; 268 | } 269 | } 270 | 271 | // Build guidance text using the template 272 | const guidanceText = TaskGuidanceExtractor.buildGuidanceText( 273 | firstTaskContent, 274 | effectiveFirstSubtask, 275 | undefined, // no specific task number 276 | true // is first task 277 | ); 278 | 279 | response.displayText += '\n\n' + guidanceText; 280 | } 281 | 282 | // Resolve resource references 283 | if (response.resources) { 284 | response.resources = openApiLoader.resolveResources(response.resources); 285 | } 286 | 287 | // Embed resources into display text for better client compatibility 288 | const enhancedDisplayText = this.embedResourcesIntoText(response.displayText, response.resources); 289 | 290 | // Return WorkflowResult format 291 | return { 292 | displayText: enhancedDisplayText, 293 | data: response, 294 | resources: response.resources 295 | }; 296 | } 297 | 298 | // Build error response 299 | buildErrorResponse(errorType: string, variables?: Record): string { 300 | const template = openApiLoader.getErrorResponse(errorType); 301 | 302 | if (!template) { 303 | return `❌ Error: ${errorType}`; 304 | } 305 | 306 | if (variables) { 307 | return OpenApiLoader.replaceVariables(template, variables); 308 | } 309 | 310 | return template; 311 | } 312 | 313 | // Calculate progress 314 | calculateProgress( 315 | requirementsProgress: number, 316 | designProgress: number, 317 | tasksProgress: number 318 | ): Record { 319 | // const rules = openApiLoader.getProgressRules(); // \u672a\u4f7f\u7528 320 | 321 | // Use rules defined in OpenAPI to calculate overall progress 322 | const overall = Math.round( 323 | requirementsProgress * 0.3 + 324 | designProgress * 0.3 + 325 | tasksProgress * 0.4 326 | ); 327 | 328 | return { 329 | overall, 330 | requirements: requirementsProgress, 331 | design: designProgress, 332 | tasks: tasksProgress 333 | }; 334 | } 335 | 336 | 337 | 338 | 339 | // Private method: embed resources into display text 340 | private embedResourcesIntoText(displayText: string, resources?: unknown[]): string { 341 | if (!resources || resources.length === 0) { 342 | return displayText; 343 | } 344 | 345 | // 为每个 resource 构建嵌入文本 346 | const resourceTexts = resources.map(resource => { 347 | if (!isObject(resource)) return ''; 348 | const header = `\n\n---\n[Resource: ${resource.title || resource.uri}]\n`; 349 | const content = resource.text || ''; 350 | return header + content; 351 | }); 352 | 353 | // 将资源内容附加到显示文本末尾 354 | return displayText + resourceTexts.join(''); 355 | } 356 | 357 | // Private method: build check items list 358 | private buildCheckItemsList(checkResults: unknown): string { 359 | const items: string[] = []; 360 | 361 | if (!isObject(checkResults)) return ''; 362 | 363 | if (isArray(checkResults.requiredSections)) { 364 | checkResults.requiredSections.forEach((section: unknown) => { 365 | if (typeof section === 'string') { 366 | items.push(`- ✓ ${section}`); 367 | } 368 | }); 369 | } 370 | 371 | if (isArray(checkResults.optionalSections) && checkResults.optionalSections.length > 0) { 372 | checkResults.optionalSections.forEach((section: unknown) => { 373 | if (typeof section === 'string') { 374 | items.push(`- ✓ ${section}`); 375 | } 376 | }); 377 | } 378 | 379 | return items.join('\n'); 380 | } 381 | } 382 | 383 | // Export singleton 384 | export const responseBuilder = new ResponseBuilder(); 385 | -------------------------------------------------------------------------------- /src/features/task/completeTask.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Complete task - 统一使用批量完成逻辑 3 | */ 4 | 5 | import { existsSync, readFileSync, writeFileSync } from 'fs'; 6 | import { join } from 'path'; 7 | import { parseTasksFromContent, getFirstUncompletedTask, formatTaskForFullDisplay, Task } from '../shared/taskParser.js'; 8 | import { responseBuilder } from '../shared/responseBuilder.js'; 9 | import { WorkflowResult } from '../shared/mcpTypes.js'; 10 | import { BatchCompleteTaskResponse } from '../shared/openApiTypes.js'; 11 | import { TaskGuidanceExtractor } from '../shared/taskGuidanceTemplate.js'; 12 | 13 | export interface CompleteTaskOptions { 14 | path: string; 15 | taskNumber: string | string[]; 16 | } 17 | 18 | export async function completeTask(options: CompleteTaskOptions): Promise { 19 | const { path, taskNumber } = options; 20 | 21 | // 统一转换为数组格式进行批量处理 22 | const taskNumbers = Array.isArray(taskNumber) ? taskNumber : [taskNumber]; 23 | 24 | if (!existsSync(path)) { 25 | return { 26 | displayText: responseBuilder.buildErrorResponse('invalidPath', { path }), 27 | data: { 28 | success: false, 29 | error: 'Directory does not exist' 30 | } 31 | }; 32 | } 33 | 34 | const tasksPath = join(path, 'tasks.md'); 35 | if (!existsSync(tasksPath)) { 36 | return { 37 | displayText: '❌ Error: tasks.md file does not exist\n\nPlease complete writing the tasks document first.', 38 | data: { 39 | success: false, 40 | error: 'tasks.md does not exist' 41 | } 42 | }; 43 | } 44 | 45 | // 统一使用批量处理逻辑 46 | const batchResult = await completeBatchTasks(tasksPath, taskNumbers); 47 | return { 48 | displayText: batchResult.displayText, 49 | data: { ...batchResult } 50 | }; 51 | } 52 | 53 | 54 | /** 55 | * Complete multiple tasks in batch 56 | */ 57 | async function completeBatchTasks(tasksPath: string, taskNumbers: string[]): Promise { 58 | // Read tasks file 59 | const originalContent = readFileSync(tasksPath, 'utf-8'); 60 | const tasks = parseTasksFromContent(originalContent); 61 | 62 | // Categorize tasks: already completed, can be completed, cannot be completed 63 | const alreadyCompleted: string[] = []; 64 | const canBeCompleted: string[] = []; 65 | const cannotBeCompleted: Array<{ 66 | taskNumber: string; 67 | reason: string; 68 | }> = []; 69 | 70 | for (const taskNum of taskNumbers) { 71 | const targetTask = findTaskByNumber(tasks, taskNum); 72 | 73 | if (!targetTask) { 74 | cannotBeCompleted.push({ 75 | taskNumber: taskNum, 76 | reason: 'Task does not exist' 77 | }); 78 | } else if (targetTask.checked) { 79 | alreadyCompleted.push(taskNum); 80 | } else if (targetTask.subtasks && targetTask.subtasks.some(s => !s.checked)) { 81 | cannotBeCompleted.push({ 82 | taskNumber: taskNum, 83 | reason: 'Has uncompleted subtasks' 84 | }); 85 | } else { 86 | canBeCompleted.push(taskNum); 87 | } 88 | } 89 | 90 | // If there are tasks that cannot be completed (excluding already completed), return error 91 | if (cannotBeCompleted.length > 0) { 92 | const errorMessages = cannotBeCompleted 93 | .map(v => `- ${v.taskNumber}: ${v.reason}`) 94 | .join('\n'); 95 | 96 | return { 97 | success: false, 98 | completedTasks: [], 99 | alreadyCompleted: [], 100 | failedTasks: cannotBeCompleted, 101 | displayText: `❌ Batch task completion failed\n\nThe following tasks cannot be completed:\n${errorMessages}\n\nPlease resolve these issues and try again.` 102 | }; 103 | } 104 | 105 | // If no tasks can be completed but there are already completed tasks, still return success 106 | if (canBeCompleted.length === 0 && alreadyCompleted.length > 0) { 107 | const allTasks = parseTasksFromContent(originalContent); 108 | const nextTask = getFirstUncompletedTask(allTasks); 109 | 110 | const alreadyCompletedText = alreadyCompleted 111 | .map(t => `- ${t} (already completed)`) 112 | .join('\n'); 113 | 114 | const displayText = `${TaskGuidanceExtractor.getCompletionMessage('batchCompleted')}\n\nThe following tasks were already completed:\n${alreadyCompletedText}\n\n${nextTask ? `Next task: ${nextTask.number}. ${nextTask.description}` : TaskGuidanceExtractor.getCompletionMessage('allCompleted')}`; 115 | 116 | return { 117 | success: true, 118 | completedTasks: [], 119 | alreadyCompleted, 120 | nextTask: nextTask ? { 121 | number: nextTask.number, 122 | description: nextTask.description 123 | } : undefined, 124 | hasNextTask: nextTask !== null, 125 | displayText 126 | }; 127 | } 128 | 129 | // Execution phase: complete tasks in dependency order 130 | let currentContent = originalContent; 131 | const actuallyCompleted: string[] = []; 132 | const results: Array<{ 133 | taskNumber: string; 134 | success: boolean; 135 | status: 'completed' | 'already_completed' | 'failed'; 136 | }> = []; 137 | 138 | try { 139 | // Sort by task number, ensure parent tasks are processed after subtasks (avoid dependency conflicts) 140 | const sortedTaskNumbers = [...canBeCompleted].sort((a, b) => { 141 | // Subtasks first (numbers with more dots have priority) 142 | const aDepth = a.split('.').length; 143 | const bDepth = b.split('.').length; 144 | if (aDepth !== bDepth) { 145 | return bDepth - aDepth; // Process deeper levels first 146 | } 147 | return a.localeCompare(b); // Same depth, sort by string 148 | }); 149 | 150 | for (const taskNum of sortedTaskNumbers) { 151 | const updatedContent = markTaskAsCompleted(currentContent, taskNum); 152 | 153 | if (!updatedContent) { 154 | // This should not happen as we have already validated 155 | throw new Error(`Unexpected error: Task ${taskNum} could not be marked`); 156 | } 157 | 158 | currentContent = updatedContent; 159 | actuallyCompleted.push(taskNum); 160 | results.push({ 161 | taskNumber: taskNum, 162 | success: true, 163 | status: 'completed' as const 164 | }); 165 | } 166 | 167 | // Add results for already completed tasks 168 | for (const taskNum of alreadyCompleted) { 169 | results.push({ 170 | taskNumber: taskNum, 171 | success: true, 172 | status: 'already_completed' as const 173 | }); 174 | } 175 | 176 | // All tasks completed successfully, save file 177 | if (actuallyCompleted.length > 0) { 178 | writeFileSync(tasksPath, currentContent, 'utf-8'); 179 | } 180 | 181 | // Build success response 182 | const allTasks = parseTasksFromContent(currentContent); 183 | const nextTask = getFirstUncompletedTask(allTasks); 184 | 185 | // Build detailed completion information 186 | let completedInfo = ''; 187 | if (actuallyCompleted.length > 0) { 188 | completedInfo += 'Newly completed tasks:\n' + actuallyCompleted.map(t => `- ${t}`).join('\n'); 189 | } 190 | if (alreadyCompleted.length > 0) { 191 | if (completedInfo) completedInfo += '\n\n'; 192 | completedInfo += 'Already completed tasks:\n' + alreadyCompleted.map(t => `- ${t} (already completed)`).join('\n'); 193 | } 194 | 195 | let displayText = `${TaskGuidanceExtractor.getCompletionMessage('batchSucceeded')}\n\n${completedInfo}`; 196 | 197 | // Add enhanced guidance for next task 198 | if (nextTask) { 199 | // 获取主任务的完整内容用于显示任务块 200 | let mainTask = nextTask; 201 | let mainTaskContent = ''; 202 | 203 | // 如果当前是子任务,需要找到对应的主任务 204 | if (nextTask.number.includes('.')) { 205 | const mainTaskNumber = nextTask.number.split('.')[0]; 206 | const mainTaskObj = allTasks.find(task => task.number === mainTaskNumber); 207 | if (mainTaskObj) { 208 | mainTask = mainTaskObj; 209 | mainTaskContent = formatTaskForFullDisplay(mainTask, currentContent); 210 | } else { 211 | // 如果找不到主任务,使用当前任务 212 | mainTaskContent = formatTaskForFullDisplay(nextTask, currentContent); 213 | } 214 | } else { 215 | // 如果本身就是主任务,直接使用 216 | mainTaskContent = formatTaskForFullDisplay(nextTask, currentContent); 217 | } 218 | 219 | // 构建下一个具体子任务的描述(用于指导文本) 220 | let effectiveFirstSubtask: string; 221 | let actualNextSubtask: Task | null = null; 222 | 223 | if (nextTask.number.includes('.')) { 224 | // 如果下一个任务是子任务,直接使用 225 | actualNextSubtask = nextTask; 226 | } else { 227 | // 如果下一个任务是主任务,找到第一个未完成的子任务 228 | if (mainTask.subtasks && mainTask.subtasks.length > 0) { 229 | actualNextSubtask = mainTask.subtasks.find(subtask => !subtask.checked) || null; 230 | } 231 | } 232 | 233 | if (actualNextSubtask) { 234 | // 使用具体的子任务构建指导文本,包含完整内容 235 | const nextSubtaskContent = formatTaskForFullDisplay(actualNextSubtask, currentContent); 236 | 237 | if (nextSubtaskContent.trim()) { 238 | // 如果能获取到完整内容,直接使用 239 | effectiveFirstSubtask = nextSubtaskContent.trim(); 240 | } else { 241 | // 如果获取不到完整内容,手动构建 242 | effectiveFirstSubtask = `- [ ] ${actualNextSubtask.number} ${actualNextSubtask.description}`; 243 | 244 | // 从主任务内容中提取这个子任务的详细信息 245 | const mainTaskLines = mainTaskContent.split('\n'); 246 | let capturing = false; 247 | let taskIndent = ''; 248 | 249 | for (const line of mainTaskLines) { 250 | // 找到目标子任务的开始 251 | if (line.includes(`${actualNextSubtask.number} ${actualNextSubtask.description}`) || 252 | line.includes(`${actualNextSubtask.number}. ${actualNextSubtask.description}`)) { 253 | capturing = true; 254 | taskIndent = line.match(/^(\s*)/)?.[1] || ''; 255 | continue; 256 | } 257 | 258 | // 如果正在捕获内容 259 | if (capturing) { 260 | const lineIndent = line.match(/^(\s*)/)?.[1] || ''; 261 | 262 | // 如果遇到下一个任务(同级或更高级),停止捕获 263 | if (line.includes('[ ]') && lineIndent.length <= taskIndent.length) { 264 | break; 265 | } 266 | 267 | // 如果是更深层次的内容,添加到结果中 268 | if (lineIndent.length > taskIndent.length && line.trim()) { 269 | effectiveFirstSubtask += `\n${line}`; 270 | } 271 | } 272 | } 273 | } 274 | } else { 275 | // 如果找不到具体的子任务,使用主任务 276 | effectiveFirstSubtask = `${nextTask.number}. ${nextTask.description}`; 277 | } 278 | 279 | // Build guidance text using the template 280 | const guidanceText = TaskGuidanceExtractor.buildGuidanceText( 281 | mainTaskContent, // 显示主任务块 282 | effectiveFirstSubtask, // 用于指导文本的具体子任务 283 | undefined, // no specific task number for batch 284 | false // not first task 285 | ); 286 | 287 | displayText += '\n\n' + guidanceText; 288 | } else { 289 | displayText += '\n\n' + TaskGuidanceExtractor.getCompletionMessage('allCompleted'); 290 | } 291 | 292 | return { 293 | success: true, 294 | completedTasks: actuallyCompleted, 295 | alreadyCompleted, 296 | failedTasks: [], 297 | results, 298 | nextTask: nextTask ? { 299 | number: nextTask.number, 300 | description: nextTask.description 301 | } : undefined, 302 | hasNextTask: nextTask !== null, 303 | displayText 304 | }; 305 | 306 | } catch (error) { 307 | // Execution failed, need to rollback to original state 308 | if (actuallyCompleted.length > 0) { 309 | writeFileSync(tasksPath, originalContent, 'utf-8'); 310 | } 311 | 312 | return { 313 | success: false, 314 | completedTasks: [], 315 | alreadyCompleted: [], 316 | failedTasks: [{ 317 | taskNumber: 'batch', 318 | reason: error instanceof Error ? error.message : String(error) 319 | }], 320 | results, 321 | displayText: `❌ Batch task execution failed\n\nError: ${error instanceof Error ? error.message : String(error)}\n\nRolled back to original state.` 322 | }; 323 | } 324 | } 325 | 326 | 327 | 328 | /** 329 | * Mark task as completed 330 | */ 331 | function markTaskAsCompleted(content: string, taskNumber: string): string | null { 332 | const lines = content.split('\n'); 333 | const tasks = parseTasksFromContent(content); 334 | let found = false; 335 | 336 | // Find target task (including subtasks) 337 | const targetTask = findTaskByNumber(tasks, taskNumber); 338 | if (!targetTask) { 339 | return null; 340 | } 341 | 342 | // Build set of task numbers to mark 343 | const numbersToMark = new Set(); 344 | numbersToMark.add(taskNumber); 345 | 346 | // If it's a leaf task, check if parent task should be auto-marked 347 | const parentNumber = taskNumber.substring(0, taskNumber.lastIndexOf('.')); 348 | if (parentNumber && taskNumber.includes('.')) { 349 | const parentTask = findTaskByNumber(tasks, parentNumber); 350 | if (parentTask && parentTask.subtasks) { 351 | // Check if all sibling tasks are completed 352 | const allSiblingsCompleted = parentTask.subtasks 353 | .filter(s => s.number !== taskNumber) 354 | .every(s => s.checked); 355 | 356 | if (allSiblingsCompleted) { 357 | numbersToMark.add(parentNumber); 358 | } 359 | } 360 | } 361 | 362 | // Mark all related tasks 363 | for (let i = 0; i < lines.length; i++) { 364 | const line = lines[i]; 365 | 366 | // Skip already completed tasks 367 | if (!line.includes('[ ]')) continue; 368 | 369 | // Check if line contains task number to mark 370 | for (const num of numbersToMark) { 371 | // More robust matching strategy: as long as the line contains both task number and checkbox 372 | // Don't care about their relative position and format details 373 | if (containsTaskNumber(line, num)) { 374 | lines[i] = line.replace('[ ]', '[x]'); 375 | found = true; 376 | break; 377 | } 378 | } 379 | } 380 | 381 | return found ? lines.join('\n') : null; 382 | } 383 | 384 | /** 385 | * Check if line contains specified task number 386 | * Use flexible matching strategy, ignore format details 387 | */ 388 | function containsTaskNumber(line: string, taskNumber: string): boolean { 389 | // Remove checkbox part to avoid interference with matching 390 | const lineWithoutCheckbox = line.replace(/\[[xX ]\]/g, ''); 391 | 392 | // Use word boundary to ensure matching complete task number 393 | // For example: won't mistakenly match "11.1" as "1.1" 394 | const escapedNumber = escapeRegExp(taskNumber); 395 | const regex = new RegExp(`\\b${escapedNumber}\\b`); 396 | 397 | return regex.test(lineWithoutCheckbox); 398 | } 399 | 400 | /** 401 | * Escape regex special characters 402 | */ 403 | function escapeRegExp(string: string): string { 404 | return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 405 | } 406 | 407 | /** 408 | * Recursively find task (including subtasks) 409 | */ 410 | function findTaskByNumber(tasks: Task[], targetNumber: string): Task | null { 411 | for (const task of tasks) { 412 | if (task.number === targetNumber) { 413 | return task; 414 | } 415 | 416 | // Recursively search subtasks 417 | if (task.subtasks) { 418 | const found = findTaskByNumber(task.subtasks, targetNumber); 419 | if (found) { 420 | return found; 421 | } 422 | } 423 | } 424 | 425 | return null; 426 | } -------------------------------------------------------------------------------- /scripts/generateOpenApiWebUI.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env tsx 2 | /** 3 | * Generate WebUI directly from OpenAPI specification 4 | * No intermediate JSON files needed, directly parse YAML to generate HTML 5 | */ 6 | 7 | import * as fs from 'fs'; 8 | import * as path from 'path'; 9 | import * as yaml from 'js-yaml'; 10 | import { fileURLToPath } from 'url'; 11 | import { dirname } from 'path'; 12 | 13 | const __filename = fileURLToPath(import.meta.url); 14 | const __dirname = dirname(__filename); 15 | 16 | // Read OpenAPI specification 17 | const specPath = path.join(__dirname, '../api/spec-workflow.openapi.yaml'); 18 | const spec = yaml.load(fs.readFileSync(specPath, 'utf8')) as any; 19 | 20 | // Scenario type definition 21 | interface Scenario { 22 | id: string; 23 | title: string; 24 | description: string; 25 | responseType: string; 26 | example: any; 27 | schema: any; 28 | } 29 | 30 | // Extract all scenarios from OpenAPI 31 | function extractScenarios(): Scenario[] { 32 | const allScenarios: Scenario[] = []; 33 | 34 | // First collect all scenarios, without numbering 35 | const responseSchemas = ['InitResponse', 'CheckResponse', 'SkipResponse', 'ConfirmResponse', 'CompleteTaskResponse']; 36 | 37 | for (const schemaName of responseSchemas) { 38 | const schema = spec.components.schemas[schemaName]; 39 | if (!schema || !schema.examples) continue; 40 | 41 | schema.examples.forEach((example: any, index: number) => { 42 | allScenarios.push({ 43 | id: `${schemaName.toLowerCase()}-${index + 1}`, 44 | title: getScenarioTitle(schemaName, example), 45 | description: getScenarioDescription(schemaName, example), 46 | responseType: schemaName, 47 | example: example, 48 | schema: schema 49 | }); 50 | }); 51 | } 52 | 53 | // Add error response scenarios 54 | if (spec['x-error-responses']) { 55 | for (const [errorType, errorDef] of Object.entries(spec['x-error-responses']) as [string, any][]) { 56 | allScenarios.push({ 57 | id: `error-${errorType}`, 58 | title: `Error: ${errorType}`, 59 | description: 'Error response example', 60 | responseType: 'ErrorResponse', 61 | example: { displayText: errorDef.displayText }, 62 | schema: null 63 | }); 64 | } 65 | } 66 | 67 | // Reorder scenarios by workflow sequence 68 | const orderedScenarios: Scenario[] = []; 69 | 70 | // 1. Initialization 71 | const initScenario = allScenarios.find(s => s.responseType === 'InitResponse'); 72 | if (initScenario) orderedScenarios.push(initScenario); 73 | 74 | // 2. requirements stage 75 | const reqNotEdited = allScenarios.find(s => 76 | s.responseType === 'CheckResponse' && 77 | s.example.stage === 'requirements' && 78 | s.example.status?.type === 'not_edited' && 79 | !s.example.status?.skipIntent 80 | ); 81 | if (reqNotEdited) orderedScenarios.push(reqNotEdited); 82 | 83 | const reqReadyToConfirm = allScenarios.find(s => 84 | s.responseType === 'CheckResponse' && 85 | s.example.stage === 'requirements' && 86 | s.example.status?.type === 'ready_to_confirm' && 87 | !s.example.status?.userApproved 88 | ); 89 | if (reqReadyToConfirm) orderedScenarios.push(reqReadyToConfirm); 90 | 91 | const confirmReq = allScenarios.find(s => 92 | s.responseType === 'ConfirmResponse' && 93 | s.example.stage === 'requirements' 94 | ); 95 | if (confirmReq) orderedScenarios.push(confirmReq); 96 | 97 | // 3. design stage 98 | const designNotEdited = allScenarios.find(s => 99 | s.responseType === 'CheckResponse' && 100 | s.example.stage === 'design' && 101 | s.example.status?.type === 'not_edited' 102 | ); 103 | if (designNotEdited) orderedScenarios.push(designNotEdited); 104 | 105 | const designReadyToConfirm = allScenarios.find(s => 106 | s.responseType === 'CheckResponse' && 107 | s.example.stage === 'design' && 108 | s.example.status?.type === 'ready_to_confirm' 109 | ); 110 | if (designReadyToConfirm) orderedScenarios.push(designReadyToConfirm); 111 | 112 | const confirmDesign = allScenarios.find(s => 113 | s.responseType === 'ConfirmResponse' && 114 | s.example.stage === 'design' 115 | ); 116 | if (confirmDesign) orderedScenarios.push(confirmDesign); 117 | 118 | // 4. tasks stage 119 | const tasksNotEdited = allScenarios.find(s => 120 | s.responseType === 'CheckResponse' && 121 | s.example.stage === 'tasks' && 122 | s.example.status?.type === 'not_edited' 123 | ); 124 | if (tasksNotEdited) orderedScenarios.push(tasksNotEdited); 125 | 126 | const tasksReadyToConfirm = allScenarios.find(s => 127 | s.responseType === 'CheckResponse' && 128 | s.example.stage === 'tasks' && 129 | s.example.status?.type === 'ready_to_confirm' 130 | ); 131 | if (tasksReadyToConfirm) orderedScenarios.push(tasksReadyToConfirm); 132 | 133 | const confirmTasks = allScenarios.find(s => 134 | s.responseType === 'ConfirmResponse' && 135 | s.example.stage === 'tasks' 136 | ); 137 | if (confirmTasks) orderedScenarios.push(confirmTasks); 138 | 139 | // 5. Complete task scenarios 140 | const completeTaskScenarios = allScenarios.filter(s => s.responseType === 'CompleteTaskResponse'); 141 | orderedScenarios.push(...completeTaskScenarios); 142 | 143 | // 6. Skip related scenarios 144 | // First add skip intent detection 145 | const reqSkipIntent = allScenarios.find(s => 146 | s.responseType === 'CheckResponse' && 147 | s.example.stage === 'requirements' && 148 | s.example.status?.skipIntent 149 | ); 150 | if (reqSkipIntent) orderedScenarios.push(reqSkipIntent); 151 | 152 | // Then add actual skip responses 153 | const skipScenarios = allScenarios.filter(s => s.responseType === 'SkipResponse'); 154 | orderedScenarios.push(...skipScenarios); 155 | 156 | // 7. Error scenarios 157 | const errorScenarios = allScenarios.filter(s => s.responseType === 'ErrorResponse'); 158 | orderedScenarios.push(...errorScenarios); 159 | 160 | // Renumber 161 | orderedScenarios.forEach((scenario, index) => { 162 | scenario.title = `${index + 1}. ${scenario.title}`; 163 | }); 164 | 165 | return orderedScenarios; 166 | } 167 | 168 | // Get scenario title 169 | function getScenarioTitle(schemaName: string, example: any): string { 170 | const titles: Record string> = { 171 | InitResponse: (ex) => ex.success ? 'Initialization Successful' : 'Initialization Scenario', 172 | CheckResponse: (ex) => { 173 | if (ex.status?.type === 'not_edited' && ex.status?.skipIntent) return `${ex.stage || 'Stage'} Skip Confirmation`; 174 | if (ex.status?.type === 'not_edited') return `${ex.stage || 'Stage'} Not Edited`; 175 | if (ex.status?.type === 'ready_to_confirm' && ex.status?.userApproved) return `${ex.stage || 'Stage'} User Approved`; 176 | if (ex.status?.type === 'ready_to_confirm') return `${ex.stage || 'Stage'} Ready to Confirm`; 177 | return 'Check Status'; 178 | }, 179 | SkipResponse: (ex) => `Skip ${ex.stage || 'Stage'}`, 180 | ConfirmResponse: (ex) => `Confirm ${ex.stage || 'Stage'}`, 181 | CompleteTaskResponse: (ex) => ex.hasNextTask ? 'Complete Task (Has Next)' : 'Complete Task (All Done)' 182 | }; 183 | 184 | const titleFn = titles[schemaName]; 185 | return titleFn ? titleFn(example) : schemaName; 186 | } 187 | 188 | // Get scenario description 189 | function getScenarioDescription(schemaName: string, example: any): string { 190 | const descriptions: Record = { 191 | InitResponse: 'Response for initializing workflow', 192 | CheckResponse: 'Response for checking current status', 193 | SkipResponse: 'Response for skipping stage', 194 | ConfirmResponse: 'Response for confirming stage completion', 195 | CompleteTaskResponse: 'Response for marking task as complete' 196 | }; 197 | return descriptions[schemaName] || schemaName; 198 | } 199 | 200 | // Generate HTML 201 | function generateHTML(scenarios: Scenario[]): string { 202 | const scenarioCards = scenarios.map(scenario => ` 203 |
204 |

${scenario.title}

205 |

${scenario.description}

206 | 207 |
Response Type: ${scenario.responseType}
208 | 209 |
210 |

Example Response:

211 |
${JSON.stringify(scenario.example, null, 2)}
212 |
213 | 214 | ${scenario.example.displayText ? ` 215 |
216 |

Display Text:

217 |
${scenario.example.displayText}
218 |
219 | ` : ''} 220 | 221 | ${scenario.example.resources ? ` 222 |
223 |

Included Resources:

224 |
    225 | ${scenario.example.resources.map((r: any) => `
  • ${r.ref || r.id || 'Unknown Resource'}
  • `).join('')} 226 |
227 |
228 | ` : ''} 229 |
230 | `).join(''); 231 | 232 | return ` 233 | 234 | 235 | 236 | 237 | Spec Workflow - OpenAPI Response Examples 238 | 404 | 405 | 406 |
407 |

Spec Workflow - OpenAPI Response Examples

408 |

All response scenarios automatically generated from OpenAPI specification

409 | 410 |
411 |

📍 Data Source: api/spec-workflow.openapi.yaml

412 |

🔄 Last Updated: ${new Date().toLocaleString('en-US')}

413 |
414 | 415 |
416 | ${scenarioCards} 417 |
418 | 419 |
420 |

Statistics

421 |
422 |
423 |
${scenarios.length}
424 |
Total Scenarios
425 |
426 |
427 |
${responseSchemas.length}
428 |
Response Types
429 |
430 |
431 |
${Object.keys(spec['x-error-responses'] || {}).length}
432 |
Error Types
433 |
434 |
435 |
436 |
437 | 438 | `; 439 | } 440 | 441 | // Main function 442 | function main() { 443 | console.log('🔍 Extracting scenarios from OpenAPI specification...'); 444 | 445 | const scenarios = extractScenarios(); 446 | console.log(`✅ Extracted ${scenarios.length} scenarios`); 447 | 448 | const html = generateHTML(scenarios); 449 | 450 | const outputPath = path.join(__dirname, '../webui/prompt-grid.html'); 451 | fs.writeFileSync(outputPath, html, 'utf8'); 452 | 453 | console.log('✅ WebUI generated to:', outputPath); 454 | console.log('🚀 Open this file in browser to view all response examples'); 455 | } 456 | 457 | // Define response type list 458 | const responseSchemas = ['InitResponse', 'CheckResponse', 'SkipResponse', 'ConfirmResponse', 'CompleteTaskResponse']; 459 | 460 | main(); --------------------------------------------------------------------------------