This is page 1 of 2. Use http://codebase.md/kingkongshot/specs-workflow-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .gitignore
├── .npmignore
├── api
│ └── spec-workflow.openapi.yaml
├── eslint.config.js
├── LICENSE
├── logo.png
├── package-lock.json
├── package.json
├── README-zh.md
├── README.md
├── scripts
│ ├── generateOpenApiWebUI.ts
│ ├── generateTypes.ts
│ ├── publish-npm.sh
│ ├── sync-package.sh
│ └── validateOpenApi.ts
├── src
│ ├── features
│ │ ├── check
│ │ │ ├── analyzeStage.ts
│ │ │ ├── checkWorkflow.ts
│ │ │ └── generateNextDocument.ts
│ │ ├── confirm
│ │ │ └── confirmStage.ts
│ │ ├── executeWorkflow.ts
│ │ ├── init
│ │ │ ├── createRequirementsDoc.ts
│ │ │ └── initWorkflow.ts
│ │ ├── shared
│ │ │ ├── confirmationStatus.ts
│ │ │ ├── documentAnalyzer.ts
│ │ │ ├── documentStatus.ts
│ │ │ ├── documentTemplates.ts
│ │ │ ├── documentUtils.ts
│ │ │ ├── mcpTypes.ts
│ │ │ ├── openApiLoader.ts
│ │ │ ├── openApiTypes.ts
│ │ │ ├── progressCalculator.ts
│ │ │ ├── responseBuilder.ts
│ │ │ ├── taskGuidanceTemplate.ts
│ │ │ ├── taskParser.ts
│ │ │ └── typeGuards.ts
│ │ ├── skip
│ │ │ └── skipStage.ts
│ │ └── task
│ │ └── completeTask.ts
│ ├── index.ts
│ └── tools
│ └── specWorkflowTool.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.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 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Spec Workflow MCP
2 |
3 | [](https://www.npmjs.com/package/spec-workflow-mcp)
4 | [](https://opensource.org/licenses/MIT)
5 | [](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 | <details>
148 | <summary>📦 Installation Instructions</summary>
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 | </details>
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 | <a href="https://glama.ai/mcp/servers/@kingkongshot/specs-workflow-mcp">
303 | <img width="380" height="200" src="https://glama.ai/mcp/servers/@kingkongshot/specs-workflow-mcp/badge" alt="Spec Workflow MCP server" />
304 | </a>
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```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:
--------------------------------------------------------------------------------
```javascript
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 | ];
```
--------------------------------------------------------------------------------
/src/features/init/createRequirementsDoc.ts:
--------------------------------------------------------------------------------
```typescript
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 | }
```
--------------------------------------------------------------------------------
/src/features/shared/documentAnalyzer.ts:
--------------------------------------------------------------------------------
```typescript
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: '<template-requirements>',
11 | REQUIREMENTS_END: '</template-requirements>',
12 | DESIGN_START: '<template-design>',
13 | DESIGN_END: '</template-design>',
14 | TASKS_START: '<template-tasks>',
15 | TASKS_END: '</template-tasks>'
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/shared/documentUtils.ts:
--------------------------------------------------------------------------------
```typescript
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:
--------------------------------------------------------------------------------
```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:
--------------------------------------------------------------------------------
```bash
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:
--------------------------------------------------------------------------------
```typescript
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<void> {
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:
--------------------------------------------------------------------------------
```typescript
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:
--------------------------------------------------------------------------------
```typescript
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<NextDocumentResult> {
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/documentStatus.ts:
--------------------------------------------------------------------------------
```typescript
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<string, string> = {
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<string, string> = {
81 | requirements: 'requirements.md',
82 | design: 'design.md',
83 | tasks: 'tasks.md'
84 | };
85 | return fileNames[stage] || '';
86 | }
```
--------------------------------------------------------------------------------
/src/features/shared/confirmationStatus.ts:
--------------------------------------------------------------------------------
```typescript
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:
--------------------------------------------------------------------------------
```typescript
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/executeWorkflow.ts:
--------------------------------------------------------------------------------
```typescript
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<void>
27 | ): Promise<WorkflowResult> {
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/features/shared/documentTemplates.ts:
--------------------------------------------------------------------------------
```typescript
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/shared/openApiTypes.ts:
--------------------------------------------------------------------------------
```typescript
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<string, any>;
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:
--------------------------------------------------------------------------------
```bash
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/tools/specWorkflowTool.ts:
--------------------------------------------------------------------------------
```typescript
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<string, unknown> = {
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/check/checkWorkflow.ts:
--------------------------------------------------------------------------------
```typescript
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<void>;
18 | }
19 |
20 | export async function checkWorkflow(options: CheckOptions): Promise<WorkflowResult> {
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<void>) | undefined,
115 | progress: number,
116 | total: number,
117 | message: string
118 | ): Promise<void> {
119 | if (onProgress) {
120 | await onProgress(progress, total, message);
121 | }
122 | }
```
--------------------------------------------------------------------------------
/src/features/confirm/confirmStage.ts:
--------------------------------------------------------------------------------
```typescript
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<WorkflowResult> {
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:
--------------------------------------------------------------------------------
```typescript
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<string, any>;');
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<string, any>';
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:
--------------------------------------------------------------------------------
```typescript
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<WorkflowResult> {
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 | }
```
--------------------------------------------------------------------------------
/scripts/validateOpenApi.ts:
--------------------------------------------------------------------------------
```typescript
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/init/initWorkflow.ts:
--------------------------------------------------------------------------------
```typescript
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<void>;
19 | }
20 |
21 | export async function initWorkflow(options: InitOptions): Promise<WorkflowResult> {
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<void>) | undefined,
122 | progress: number,
123 | total: number,
124 | message: string
125 | ): Promise<void> {
126 | if (onProgress) {
127 | await onProgress(progress, total, message);
128 | }
129 | }
```
--------------------------------------------------------------------------------
/src/features/check/analyzeStage.ts:
--------------------------------------------------------------------------------
```typescript
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 <template-*> 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<WorkflowStage, string> = {
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:
--------------------------------------------------------------------------------
```typescript
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<typeof openApiLoader.getTaskGuidanceTemplate> | 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 | }
```
--------------------------------------------------------------------------------
/src/features/shared/openApiLoader.ts:
--------------------------------------------------------------------------------
```typescript
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<string, unknown>;
32 | };
33 | 'x-error-responses': Record<string, {
34 | displayText: string;
35 | }>;
36 | 'x-shared-resources': Record<string, {
37 | uri: string;
38 | title?: string;
39 | mimeType: string;
40 | text?: string;
41 | }>;
42 | 'x-global-config': unknown;
43 | 'x-document-templates': Record<string, unknown>;
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<string, unknown[]> = 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<string, unknown>): 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, unknown>): 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<unknown>): 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 |
```
--------------------------------------------------------------------------------
/src/features/shared/taskParser.ts:
--------------------------------------------------------------------------------
```typescript
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(/<!--\s*SPEC-MARKER[\s\S]*?-->/g, '') // Compatible with old format
24 | .replace(/<template-tasks>[\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<string, Task>();
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:
--------------------------------------------------------------------------------
```typescript
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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {};
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, unknown>): 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<string, unknown> {
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:
--------------------------------------------------------------------------------
```typescript
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<WorkflowResult> {
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<BatchCompleteTaskResponse> {
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<string>();
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:
--------------------------------------------------------------------------------
```typescript
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, (ex: any) => 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<string, string> = {
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 | <div class="scenario-card">
204 | <h3>${scenario.title}</h3>
205 | <p class="description">${scenario.description}</p>
206 |
207 | <div class="response-type">Response Type: <code>${scenario.responseType}</code></div>
208 |
209 | <div class="example-section">
210 | <h4>Example Response:</h4>
211 | <pre class="example-content">${JSON.stringify(scenario.example, null, 2)}</pre>
212 | </div>
213 |
214 | ${scenario.example.displayText ? `
215 | <div class="display-text-section">
216 | <h4>Display Text:</h4>
217 | <pre class="display-text">${scenario.example.displayText}</pre>
218 | </div>
219 | ` : ''}
220 |
221 | ${scenario.example.resources ? `
222 | <div class="resources-section">
223 | <h4>Included Resources:</h4>
224 | <ul>
225 | ${scenario.example.resources.map((r: any) => `<li>${r.ref || r.id || 'Unknown Resource'}</li>`).join('')}
226 | </ul>
227 | </div>
228 | ` : ''}
229 | </div>
230 | `).join('');
231 |
232 | return `<!DOCTYPE html>
233 | <html lang="en">
234 | <head>
235 | <meta charset="UTF-8">
236 | <meta name="viewport" content="width=device-width, initial-scale=1.0">
237 | <title>Spec Workflow - OpenAPI Response Examples</title>
238 | <style>
239 | * {
240 | margin: 0;
241 | padding: 0;
242 | box-sizing: border-box;
243 | }
244 |
245 | body {
246 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
247 | background: #f6f8fa;
248 | color: #24292e;
249 | line-height: 1.6;
250 | padding: 20px;
251 | }
252 |
253 | .container {
254 | max-width: 1400px;
255 | margin: 0 auto;
256 | }
257 |
258 | h1 {
259 | text-align: center;
260 | font-size: 2.5em;
261 | margin-bottom: 10px;
262 | color: #0366d6;
263 | }
264 |
265 | .subtitle {
266 | text-align: center;
267 | color: #586069;
268 | margin-bottom: 20px;
269 | font-size: 1.1em;
270 | }
271 |
272 | .info-box {
273 | background: #f0f7ff;
274 | border: 1px solid #c8e1ff;
275 | border-radius: 6px;
276 | padding: 16px;
277 | margin-bottom: 30px;
278 | text-align: center;
279 | }
280 |
281 | .grid {
282 | display: grid;
283 | grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
284 | gap: 20px;
285 | margin-bottom: 40px;
286 | }
287 |
288 | .scenario-card {
289 | background: white;
290 | border: 1px solid #e1e4e8;
291 | border-radius: 8px;
292 | padding: 20px;
293 | box-shadow: 0 1px 3px rgba(0,0,0,0.05);
294 | transition: all 0.3s ease;
295 | }
296 |
297 | .scenario-card:hover {
298 | box-shadow: 0 4px 12px rgba(0,0,0,0.1);
299 | transform: translateY(-2px);
300 | }
301 |
302 | .scenario-card h3 {
303 | color: #0366d6;
304 | margin-bottom: 10px;
305 | font-size: 1.3em;
306 | }
307 |
308 | .description {
309 | color: #586069;
310 | margin-bottom: 15px;
311 | }
312 |
313 | .response-type {
314 | background: #f3f4f6;
315 | padding: 4px 8px;
316 | border-radius: 4px;
317 | font-size: 0.9em;
318 | margin-bottom: 15px;
319 | display: inline-block;
320 | }
321 |
322 | code {
323 | background: #f3f4f6;
324 | padding: 2px 4px;
325 | border-radius: 3px;
326 | font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
327 | }
328 |
329 | .example-section, .display-text-section, .resources-section {
330 | margin-top: 15px;
331 | }
332 |
333 | h4 {
334 | color: #24292e;
335 | font-size: 1em;
336 | margin-bottom: 8px;
337 | }
338 |
339 | pre {
340 | background: #f6f8fa;
341 | border: 1px solid #e1e4e8;
342 | border-radius: 6px;
343 | padding: 12px;
344 | overflow-x: auto;
345 | font-size: 0.85em;
346 | font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
347 | }
348 |
349 | .example-content {
350 | max-height: 300px;
351 | overflow-y: auto;
352 | }
353 |
354 | .display-text {
355 | background: #f0f9ff;
356 | border-color: #bae6fd;
357 | white-space: pre-wrap;
358 | }
359 |
360 | .resources-section ul {
361 | list-style: none;
362 | padding-left: 0;
363 | }
364 |
365 | .resources-section li {
366 | background: #e7f5ff;
367 | padding: 4px 8px;
368 | border-radius: 4px;
369 | margin-bottom: 4px;
370 | font-size: 0.9em;
371 | }
372 |
373 | .stats {
374 | text-align: center;
375 | margin-top: 40px;
376 | padding: 20px;
377 | background: white;
378 | border-radius: 8px;
379 | border: 1px solid #e1e4e8;
380 | }
381 |
382 | .stats-grid {
383 | display: grid;
384 | grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
385 | gap: 20px;
386 | margin-top: 15px;
387 | }
388 |
389 | .stat-item {
390 | text-align: center;
391 | }
392 |
393 | .stat-number {
394 | font-size: 2em;
395 | font-weight: bold;
396 | color: #0366d6;
397 | }
398 |
399 | .stat-label {
400 | color: #586069;
401 | font-size: 0.9em;
402 | }
403 | </style>
404 | </head>
405 | <body>
406 | <div class="container">
407 | <h1>Spec Workflow - OpenAPI Response Examples</h1>
408 | <p class="subtitle">All response scenarios automatically generated from OpenAPI specification</p>
409 |
410 | <div class="info-box">
411 | <p>📍 Data Source: <code>api/spec-workflow.openapi.yaml</code></p>
412 | <p>🔄 Last Updated: ${new Date().toLocaleString('en-US')}</p>
413 | </div>
414 |
415 | <div class="grid">
416 | ${scenarioCards}
417 | </div>
418 |
419 | <div class="stats">
420 | <h2>Statistics</h2>
421 | <div class="stats-grid">
422 | <div class="stat-item">
423 | <div class="stat-number">${scenarios.length}</div>
424 | <div class="stat-label">Total Scenarios</div>
425 | </div>
426 | <div class="stat-item">
427 | <div class="stat-number">${responseSchemas.length}</div>
428 | <div class="stat-label">Response Types</div>
429 | </div>
430 | <div class="stat-item">
431 | <div class="stat-number">${Object.keys(spec['x-error-responses'] || {}).length}</div>
432 | <div class="stat-label">Error Types</div>
433 | </div>
434 | </div>
435 | </div>
436 | </div>
437 | </body>
438 | </html>`;
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();
```